diff --git a/frontend/src/composables/useProducts.ts b/frontend/src/composables/useProducts.ts index ef5c3d2..2de2799 100644 --- a/frontend/src/composables/useProducts.ts +++ b/frontend/src/composables/useProducts.ts @@ -6,6 +6,7 @@ import { LoadProducts } from '@wails/go/app/App' export interface Product { commodityId: string commodityName: string + actionId?: string category: string score: number payType?: number diff --git a/internal/app/app_seckill.go b/internal/app/app_seckill.go index 53cfa55..8509692 100644 --- a/internal/app/app_seckill.go +++ b/internal/app/app_seckill.go @@ -13,6 +13,12 @@ const seckillStateEvent = "seckill:state" // StartSeckill 立即启动抢购。 func (a *App) StartSeckill(accountProducts map[string]string) types.MessageResult { + // 兼容旧前端/旧构建:和平小店只能走 livebuy 专用链路,不能进入 CSRF 兑换流程。 + if a.getClient().Activity.IsXiaodian { + return a.startXiaodianSeckill(types.FixedSeckillRequest{ + AccountProducts: accountProducts, + }, "和平小店抢购已启动") + } if len(accountProducts) == 0 { return types.MessageResult{Error: "请选择账号和商品"} } diff --git a/internal/client/client.go b/internal/client/client.go index 8f6d944..e69474f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -256,6 +256,7 @@ type xiaodianRecommendation struct { type xiaodianItem struct { GoodsID string `json:"iGoodsId"` GoodsName string `json:"sGoodsName"` + ActionID xiaodianFlexibleString `json:"iActionId"` Category xiaodianFlexibleString `json:"iCategoryId"` Price string `json:"iPrice"` JB2Price string `json:"iJb2Price"` @@ -264,7 +265,6 @@ type xiaodianItem struct { GoodsPic string `json:"sGoodsPic"` ShowBegin string `json:"dtShowBeginTime"` ShowEnd string `json:"dtShowEndTime"` - ActionID string `json:"iActionId"` } type xiaodianFlexibleString string @@ -328,6 +328,16 @@ type xiaodianBuyInfo struct { // xiaodianEmbedQuery 获取斗鱼为当前账号签发的短时道具城参数。 func (c *DouyuClient) xiaodianEmbedQuery(cookie string) (url.Values, error) { + return c.xiaodianEmbedQueryWithCache(cookie, true) +} + +// xiaodianEmbedQueryFresh 每次调用都重新获取 H5 授权,供会真实扣款的兑换请求使用。 +// code/sig/timestamp 是短时凭证,不应跨兑换轮次长期复用。 +func (c *DouyuClient) xiaodianEmbedQueryFresh(cookie string) (url.Values, error) { + return c.xiaodianEmbedQueryWithCache(cookie, false) +} + +func (c *DouyuClient) xiaodianEmbedQueryWithCache(cookie string, useCache bool) (url.Values, error) { uid := cookieValue(cookie, "acf_uid") stk := cookieValue(cookie, "acf_stk") ltkid := cookieValue(cookie, "acf_ltkid") @@ -335,8 +345,10 @@ func (c *DouyuClient) xiaodianEmbedQuery(cookie string) (url.Values, error) { return nil, errors.New("斗鱼 Cookie 缺少 acf_uid、acf_stk 或 acf_ltkid,无法获取和平小店授权") } cacheKey := uid + ":" + c.Activity.ActAlias + ":" + c.Activity.RoomID - if cached, ok := c.getXiaodianEmbedCache(cacheKey); ok { - return cached, nil + if useCache { + if cached, ok := c.getXiaodianEmbedCache(cacheKey); ok { + return cached, nil + } } endpoint, err := url.Parse(c.Activity.RoleParamURL) if err != nil { @@ -539,6 +551,7 @@ func (c *DouyuClient) xiaodianProducts(cookie string, options types.XiaodianProd products = append(products, types.Product{ CommodityID: item.GoodsID, CommodityName: item.GoodsName, + ActionID: string(item.ActionID), Category: string(item.Category), Score: price, PayType: payType, @@ -1231,7 +1244,7 @@ func (c *DouyuClient) xiaodianExchange(cookie string, product types.Product) (st if strings.TrimSpace(product.CommodityID) == "" { return "", errors.New("商品 ID 为空") } - embedQuery, err := c.xiaodianEmbedQuery(cookie) + embedQuery, err := c.xiaodianEmbedQueryFresh(cookie) if err != nil { return "", err } @@ -1254,7 +1267,11 @@ func (c *DouyuClient) xiaodianExchange(cookie string, product types.Product) (st query.Set("_jsvar", "buyInfo") query.Set("acctype", "livelink") query.Set("_app_id", "2123") - query.Set("iActionId", c.Activity.ActID) + actionID := strings.TrimSpace(product.ActionID) + if actionID == "" { + actionID = c.Activity.ActID + } + query.Set("iActionId", actionID) query.Set("propid", product.CommodityID) query.Set("paytype", strconv.Itoa(payType)) query.Set("source", c.Activity.RoomID) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 180e80c..fe3180d 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -177,7 +177,7 @@ func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) { } else { switch offset { case "0": - items = []map[string]string{{"iGoodsId": "g1", "sGoodsName": "商品一", "iCategoryId": "1", "iPrice": "10", "sPayType": "qpqb", "iGoodsLeft": "-1"}, {"iGoodsId": "g2", "sGoodsName": "特惠商品", "iCategoryId": "45", "iPrice": "0", "iJb2Price": "20", "sPayType": "jb2", "iGoodsLeft": "3"}} + items = []map[string]string{{"iGoodsId": "g1", "iActionId": "act-g1", "sGoodsName": "商品一", "iCategoryId": "1", "iPrice": "10", "sPayType": "qpqb", "iGoodsLeft": "-1"}, {"iGoodsId": "g2", "sGoodsName": "特惠商品", "iCategoryId": "45", "iPrice": "0", "iJb2Price": "20", "sPayType": "jb2", "iGoodsLeft": "3"}} case "2": items = []map[string]string{{"iGoodsId": "g2", "sGoodsName": "重复商品", "iCategoryId": "45", "iPrice": "0", "iJb2Price": "20", "sPayType": "jb2", "iGoodsLeft": "3"}, {"iGoodsId": "g4", "sGoodsName": "商品四", "iCategoryId": "1", "iPrice": "40", "sPayType": "qpqb", "iGoodsLeft": "0"}} } @@ -219,6 +219,9 @@ func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) { if products[1].Category != "45" || products[3].Score != 30 { t.Fatalf("商品字段 = %#v", products) } + if products[0].ActionID != "act-g1" { + t.Fatalf("商品动作 ID = %q", products[0].ActionID) + } if products[0].PayType != 1 || products[0].PayLabel != "点券" || products[1].PayType != 5 || products[1].PayLabel != "扭蛋碎片" || products[1].Score != 20 { t.Fatalf("支付字段 = %#v", products) } diff --git a/internal/seckill/attempt.go b/internal/seckill/attempt.go index 2a7245d..6bf6cff 100644 --- a/internal/seckill/attempt.go +++ b/internal/seckill/attempt.go @@ -12,6 +12,10 @@ import ( // runAttempt 标准模式兑换:完整执行鉴权 token→BatchLimit→PreExchange→Exchange 链路。 func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResult { cli := s.clientSnapshot() + // 最终防护:任何旧入口在和平小店活动下都必须使用 livebuy。 + if cli.Activity.IsXiaodian { + return s.runXiaodianAttempt(attempt) + } result := types.ExchangeResult{ Account: attempt.Account.Name, Product: attempt.Product.CommodityName, @@ -101,6 +105,9 @@ func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResu // runAttemptFast 激进模式的兑换逻辑,复用缓存的鉴权 token,跳过 BatchExchangeLimit 和 PreExchange。 func (s *Scheduler) runAttemptFast(attempt types.ExchangeAttempt) types.ExchangeResult { cli := s.clientSnapshot() + if cli.Activity.IsXiaodian { + return s.runXiaodianAttempt(attempt) + } result := types.ExchangeResult{ Account: attempt.Account.Name, Product: attempt.Product.CommodityName, diff --git a/internal/types/types.go b/internal/types/types.go index c01219b..b2cb020 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -96,18 +96,20 @@ type Account struct { type Product struct { CommodityID string `json:"commodityId"` CommodityName string `json:"commodityName"` - Category string `json:"category"` - Score int `json:"score"` - PayType int `json:"payType,omitempty"` - PayLabel string `json:"payLabel,omitempty"` - Count int `json:"count"` - Pic string `json:"pic"` - Status int `json:"status"` - OpenStatus int `json:"openStatus"` - StoreStatus int `json:"storeStatus"` - TimeLimited int `json:"timeLimited"` - CountLimited int `json:"countLimited"` - EliteLimited int `json:"eliteLimited"` + // ActionID 是和平小店商品所属的动作 ID;不同商品可能不属于同一个动作。 + ActionID string `json:"actionId,omitempty"` + Category string `json:"category"` + Score int `json:"score"` + PayType int `json:"payType,omitempty"` + PayLabel string `json:"payLabel,omitempty"` + Count int `json:"count"` + Pic string `json:"pic"` + Status int `json:"status"` + OpenStatus int `json:"openStatus"` + StoreStatus int `json:"storeStatus"` + TimeLimited int `json:"timeLimited"` + CountLimited int `json:"countLimited"` + EliteLimited int `json:"eliteLimited"` } // ProductPayload 前端商品响应。