package client import ( "context" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "net/url" "sort" "strconv" "strings" "sync" "time" "douyu_go/internal/activity" "douyu_go/internal/constants" "douyu_go/internal/logger" "douyu_go/internal/trace" "douyu_go/internal/types" "douyu_go/internal/utils" ) // DouyuClient 斗鱼接口客户端。 type DouyuClient struct { HTTPClient *http.Client Activity *activity.Activity TimeAPIURL string Trace *trace.Recorder xiaodianEmbedMu sync.Mutex xiaodianEmbedCache map[string]xiaodianEmbedCacheItem } type xiaodianEmbedCacheItem struct { query url.Values expiresAt time.Time } // New 创建斗鱼客户端。act 决定使用哪个活动的接口参数; // timeAPIURL 为空时回退到 constants.DefaultTimeAPI(全局共享的服务端时间接口)。 func New(act *activity.Activity, timeAPIURL string, tr *trace.Recorder) *DouyuClient { if act == nil { act = activity.Default() } if timeAPIURL == "" { timeAPIURL = constants.DefaultTimeAPI } if tr == nil { tr = &trace.Recorder{} } return &DouyuClient{ Activity: act, TimeAPIURL: timeAPIURL, Trace: tr, xiaodianEmbedCache: make(map[string]xiaodianEmbedCacheItem), HTTPClient: &http.Client{ Timeout: 8 * time.Second, Transport: &http.Transport{ MaxConnsPerHost: 120, MaxIdleConns: 120, MaxIdleConnsPerHost: 80, IdleConnTimeout: 30 * time.Second, }, }, } } // ServerTime 从商品详情接口读取 data.preExchange.currentTime。 func (c *DouyuClient) ServerTime() (time.Time, error) { sample, err := c.ServerTimeSample() return sample.EstimatedServerNow(), err } // ServerTimeSample 从斗鱼秒级 currentTime 估算毫秒级服务端时间。 func (c *DouyuClient) ServerTimeSample() (types.TimeSample, error) { var lastErr error for i := 0; i < 3; i++ { sample, err := c.fetchServerTimeOnce() if err == nil { sample.Samples = 1 sample.Source = "single" return sample, nil } lastErr = err time.Sleep(100 * time.Millisecond) } now := time.Now() return types.TimeSample{ ServerTime: now.UTC(), LocalTime: now, Offset: 0, Source: "localFallback", Fallback: true, }, fmt.Errorf("服务端时间获取失败,已降级本地时间: %w", lastErr) } // CalibratedServerTimeSample 通过等待 currentTime 跳秒估算本地与斗鱼秒边界的偏移。 func (c *DouyuClient) CalibratedServerTimeSample(maxWait time.Duration) (types.TimeSample, error) { first, err := c.ServerTimeSample() if err != nil || maxWait <= 0 { return first, err } deadline := time.Now().Add(maxWait) prev := first for time.Now().Before(deadline) { time.Sleep(40 * time.Millisecond) current, err := c.fetchServerTimeOnce() if err != nil { continue } if current.RawCurrentTime <= prev.RawCurrentTime { prev = current continue } sampleGap := current.LocalTime.Sub(prev.LocalTime) localBoundary := prev.LocalTime.Add(sampleGap / 2) serverBoundary := time.Unix(current.RawCurrentTime, 0).UTC() offset := serverBoundary.Sub(localBoundary.UTC()) current.Offset = offset current.ServerTime = current.EstimatedServerNow() current.LocalTime = time.Now() current.RTT += first.RTT current.ErrorBound = sampleGap / 2 current.Samples = 2 current.Source = "boundary" return current, nil } first.ServerTime = first.EstimatedServerNow() return first, nil } // BestServerTimeCalibration 多次校准,选择误差范围最小、RTT更低的一次。 func (c *DouyuClient) BestServerTimeCalibration(rounds int, maxWait time.Duration) (types.TimeCalibration, error) { if rounds <= 0 { rounds = 1 } var best types.TimeSample var lastErr error for i := 0; i < rounds; i++ { sample, err := c.CalibratedServerTimeSample(maxWait) if err != nil { lastErr = err } if sample.LocalTime.IsZero() { continue } sample.Samples = max(sample.Samples, 1) if best.LocalTime.IsZero() || betterTimeSample(sample, best) { best = sample } } if best.LocalTime.IsZero() { now := time.Now() return types.TimeCalibration{ CreatedAt: now, Source: "localFallback", Fallback: true, }, fmt.Errorf("斗鱼时间校准失败: %w", lastErr) } var calErr error if lastErr != nil { calErr = fmt.Errorf("斗鱼时间校准部分失败(已使用最优结果): %w", lastErr) } return types.TimeCalibration{ Offset: best.Offset, ErrorBound: best.ErrorBound, RTT: best.RTT, Samples: rounds, CreatedAt: time.Now(), Source: best.Source, Fallback: best.Fallback, }, calErr } func betterTimeSample(candidate, current types.TimeSample) bool { if candidate.ErrorBound != 0 && current.ErrorBound == 0 { return true } if candidate.ErrorBound == 0 && current.ErrorBound != 0 { return false } if candidate.ErrorBound != current.ErrorBound { return candidate.ErrorBound < current.ErrorBound } return candidate.RTT < current.RTT } func (c *DouyuClient) fetchServerTimeOnce() (types.TimeSample, error) { url := utils.AppendCacheBuster(c.TimeAPIURL) var result struct { types.APIResponse Data struct { PreExchange struct { CurrentTime string `json:"currentTime"` } `json:"preExchange"` } `json:"data"` } start := time.Now() if err := c.getJSON(url, "", "服务时间", &result); err != nil { return types.TimeSample{}, err } end := time.Now() ts, err := strconv.ParseInt(result.Data.PreExchange.CurrentTime, 10, 64) if err != nil || ts <= 0 { return types.TimeSample{}, fmt.Errorf("currentTime 无效: %q", result.Data.PreExchange.CurrentTime) } rtt := end.Sub(start) estimatedLocal := start.Add(rtt / 2) serverTime := time.Unix(ts, 0).Add(time.Duration(estimatedLocal.Nanosecond())).UTC() return types.TimeSample{ ServerTime: serverTime, LocalTime: estimatedLocal, RTT: rtt, Offset: serverTime.Sub(estimatedLocal.UTC()), RawCurrentTime: ts, }, nil } // Products 拉取当前活动的商品列表。 func (c *DouyuClient) Products(cookie string, xiaodianOptions types.XiaodianProductListOptions) ([]types.Product, error) { if c.Activity.IsXiaodian { return c.xiaodianProducts(cookie, xiaodianOptions) } var result struct { types.APIResponse Data struct { List []types.Product `json:"list"` } `json:"data"` } if err := c.getJSON(c.Activity.StoreHomeURL, cookie, "商品列表", &result); err != nil { return nil, err } if result.Error != 0 { return nil, fmt.Errorf("商品接口失败: %s", result.Msg) } return result.Data.List, nil } type xiaodianRecommendation struct { ErrCode string `json:"errcode"` ErrDesc string `json:"errdesc"` Data struct { ClientData struct { Items []xiaodianItem `json:"itemsdetail"` } `json:"client_data"` } `json:"data"` } 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"` PayType string `json:"sPayType"` GoodsLeft xiaodianFlexibleString `json:"iGoodsLeft"` GoodsPic string `json:"sGoodsPic"` ShowBegin string `json:"dtShowBeginTime"` ShowEnd string `json:"dtShowEndTime"` } type xiaodianFlexibleString string // UnmarshalJSON 兼容道具接口把同一字段返回为字符串或数字。 func (s *xiaodianFlexibleString) UnmarshalJSON(data []byte) error { value := strings.TrimSpace(string(data)) if value == "" || value == "null" { *s = "" return nil } if strings.HasPrefix(value, `"`) { var decoded string if err := json.Unmarshal(data, &decoded); err != nil { return err } *s = xiaodianFlexibleString(decoded) return nil } *s = xiaodianFlexibleString(value) return nil } type xiaodianRoleContext struct { OpenID xiaodianFlexibleString `json:"gameOpenId"` RoleID xiaodianFlexibleString `json:"roleId"` RoleName xiaodianFlexibleString `json:"roleName"` Type xiaodianFlexibleString `json:"type"` Area xiaodianFlexibleString `json:"area"` PlatID xiaodianFlexibleString `json:"platId"` } const xiaodianBindRoleParamURL = "https://apiv2.douyucdn.cn/japi/carnivalApi/tencent/roleParam" type xiaodianOrderList struct { Data struct { Count xiaodianFlexibleString `json:"cnt"` List []xiaodianOrder `json:"list"` } `json:"data"` } type xiaodianOrder struct { BuyTime string `json:"dtBuyTime"` PayTime string `json:"dtPayTime"` Status string `json:"iStatus"` Price xiaodianFlexibleString `json:"iPrice"` PayAmount xiaodianFlexibleString `json:"iPayAmount"` RoleName string `json:"sRoleName"` GoodsRaw string `json:"sGoodsInfo"` } type xiaodianOrderGoods struct { GoodsID string `json:"iGoodsId"` GoodsName string `json:"sGoodsName"` Quantity xiaodianFlexibleString `json:"iQuantity"` } type xiaodianBuyInfo struct { Ret xiaodianFlexibleString `json:"ret"` Msg string `json:"msg"` Serial string `json:"serial"` SerialNum string `json:"sSerialNum"` } // 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") if uid == "" || stk == "" || ltkid == "" { return nil, errors.New("斗鱼 Cookie 缺少 acf_uid、acf_stk 或 acf_ltkid,无法获取和平小店授权") } cacheKey := uid + ":" + c.Activity.ActAlias + ":" + c.Activity.RoomID if useCache { if cached, ok := c.getXiaodianEmbedCache(cacheKey); ok { return cached, nil } } endpoint, err := url.Parse(c.Activity.RoleParamURL) if err != nil { return nil, fmt.Errorf("和平小店授权接口地址无效: %w", err) } query := endpoint.Query() query.Set("actAlias", c.Activity.ActAlias) query.Set("rid", c.Activity.RoomID) query.Set("token", uid+"_1_"+stk+"_0_"+ltkid) endpoint.RawQuery = query.Encode() req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) if err != nil { return nil, err } c.setBrowserHeaders(req, cookie) req.Header.Set("Origin", constants.BaseURL) req.Header.Set("Referer", "https://www.douyu.com/topic/h5/hpxd01") var result struct { types.APIResponse Data struct { TXURLH5 string `json:"txurlH5"` } `json:"data"` } if _, err := c.doJSON(req, &result, "获取小店 H5 参数"); err != nil { return nil, err } if result.Error != 0 { return nil, fmt.Errorf("获取和平小店授权失败: %s", result.Msg) } h5, err := url.Parse(result.Data.TXURLH5) if err != nil || result.Data.TXURLH5 == "" { return nil, errors.New("和平小店授权响应缺少 txurlH5") } queryValues := h5.Query() c.setXiaodianEmbedCache(cacheKey, queryValues) return cloneValues(queryValues), nil } func (c *DouyuClient) getXiaodianEmbedCache(key string) (url.Values, bool) { c.xiaodianEmbedMu.Lock() defer c.xiaodianEmbedMu.Unlock() item, ok := c.xiaodianEmbedCache[key] if !ok || time.Now().After(item.expiresAt) { delete(c.xiaodianEmbedCache, key) return nil, false } return cloneValues(item.query), true } func (c *DouyuClient) setXiaodianEmbedCache(key string, values url.Values) { c.xiaodianEmbedMu.Lock() defer c.xiaodianEmbedMu.Unlock() c.xiaodianEmbedCache[key] = xiaodianEmbedCacheItem{ query: cloneValues(values), expiresAt: time.Now().Add(45 * time.Second), } } func cloneValues(values url.Values) url.Values { out := make(url.Values, len(values)) for key, items := range values { out[key] = append([]string(nil), items...) } return out } // xiaodianRole 使用短时授权参数解析当前斗鱼账号绑定的游戏角色。 func (c *DouyuClient) xiaodianRole(embedQuery url.Values) (xiaodianRoleContext, error) { endpoint, err := url.Parse(c.Activity.RoleURL) if err != nil { return xiaodianRoleContext{}, fmt.Errorf("和平小店角色接口地址无效: %w", err) } query := endpoint.Query() query.Set("acctype", "livelink") query.Set("_jsvar", "info") query.Set("_service", "other.livelink.getrole.out") query.Set("_biz_code", "cjm") query.Set("_act_id", c.Activity.ActID) query.Set("_app_id", "2123") query.Set("isCode", "1") for _, key := range []string{"gameId", "actId", "appId", "livePlatId", "code", "timestamp", "v", "sig"} { query.Set(key, embedQuery.Get(key)) } query.Set("authType", "delegate") query.Set("sAnchorId", c.Activity.RoomID) query.Set("sVideoId", "") endpoint.RawQuery = query.Encode() req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) if err != nil { return xiaodianRoleContext{}, err } c.setXiaodianHeaders(req) body, err := c.doText(req, "查询小店绑定角色", true) if err != nil { return xiaodianRoleContext{}, err } var result xiaodianRoleContext if err := parseXiaodianVariable(body, "info", &result); err != nil { return xiaodianRoleContext{}, fmt.Errorf("和平小店角色响应解析失败: %w", err) } switch strings.ToLower(string(result.Type)) { case "wx": result.Area = "1" case "qq": result.Area = "2" } areaID := parseXiaodianInt(string(result.Area)) if areaID <= 0 { areaID = 1 } result.Area = xiaodianFlexibleString(strconv.Itoa(areaID)) return result, nil } func parseXiaodianVariable(body []byte, name string, out any) error { text := strings.TrimSpace(string(body)) marker := "var " + name start := strings.Index(text, marker) if start < 0 { return fmt.Errorf("响应中找不到 var %s", name) } text = strings.TrimSpace(text[start+len(marker):]) if !strings.HasPrefix(text, "=") { return fmt.Errorf("var %s 格式无效", name) } text = strings.TrimSpace(strings.TrimPrefix(text, "=")) text = strings.TrimSuffix(text, ";") return json.Unmarshal([]byte(text), out) } // xiaodianProducts 查询和平小店的全部、特惠专区及热门抢购商品。 func (c *DouyuClient) xiaodianProducts(cookie string, options types.XiaodianProductListOptions) ([]types.Product, error) { if strings.TrimSpace(cookie) == "" { return nil, errors.New("和平小店需要至少一个有效斗鱼 Cookie") } if options.PageSize <= 0 || options.PageSize > 200 { options.PageSize = 200 } embedQuery, err := c.xiaodianEmbedQuery(cookie) if err != nil { return nil, err } role, err := c.xiaodianRole(embedQuery) if err != nil { return nil, err } if role.OpenID == "" || role.RoleID == "" { return nil, errors.New("未获取到和平小店绑定角色,请确认该斗鱼账号已绑定游戏角色") } areaID := parseXiaodianInt(string(role.Area)) if areaID <= 0 { areaID = 1 } endpoint, err := url.Parse(c.Activity.StoreHomeURL) if err != nil { return nil, fmt.Errorf("和平小店商品接口地址无效: %w", err) } query := endpoint.Query() query.Set("_service", "act.recommend.query") query.Set("_biz_code", "cjm") query.Set("_app_id", "2123") query.Set("app_id", "2123") query.Set("page_size", strconv.Itoa(options.PageSize)) query.Set("fields", "iRelationGoodsid,dtModifyTime,sBuyLimitInfo,sGoodsWaterMark,dtShowBeginTime,dtShowEndTime,iSort,iGoodsId,sGoodsName,iJbPrice,iJbOrgPrice,iJb2Price,iJb2OrgPrice,iPrice,iOrgPrice,sGoodsPic,sGoodsDesc,sPayType,dtBeginTime,dtEndTime,iActionId,sExtShowInfo,sExtInfo,iCategoryId,dtRushBegin,dtRushEnd") query.Set("actionFields", "iActionId,sActionName,dtBeginTime,dtEndTime") query.Set("order_by", "dtShowBeginTime") query.Set("orderColumnBatch", `[{"key":"iSort","sort":"desc"},{"key":"dtShowBeginTime","sort":"desc"},{"key":"iGoodsId","sort":"asc"}]`) query.Set("desc", "1") query.Set("_act_id", c.Activity.ActID) query.Set("actid", c.Activity.ActID) query.Set("userid", string(role.OpenID)) query.Set("roleid", string(role.RoleID)) query.Set("area", "1") query.Set("areaid", strconv.Itoa(areaID)) query.Set("need_limit", "1") // 抓包中的“全部商品”会排除热门抢购(分类 76),因此需要再查询一次热门分类后合并。 normalItems, err := c.xiaodianProductPages(endpoint, query, options.PageSize, "", "76", "和平小店全部商品") if err != nil { return nil, err } hotItems, err := c.xiaodianProductPages(endpoint, query, options.PageSize, "76", "", "和平小店热门抢购") if err != nil { return nil, err } products := make([]types.Product, 0, len(normalItems)+len(hotItems)) seen := make(map[string]struct{}) for _, item := range append(normalItems, hotItems...) { if item.GoodsID == "" { continue } if _, ok := seen[item.GoodsID]; ok { continue } seen[item.GoodsID] = struct{}{} payType, payLabel, price := xiaodianPayment(item) stock, stockLabel := xiaodianStock(item.GoodsLeft) products = append(products, types.Product{ CommodityID: item.GoodsID, CommodityName: item.GoodsName, ActionID: string(item.ActionID), Category: string(item.Category), Score: price, PayType: payType, PayLabel: payLabel, Count: stock, StockLabel: stockLabel, Pic: xiaodianImageURL(item.GoodsPic), }) } return products, nil } // xiaodianStock 保留道具接口的展示语义。iGoodsLeft 除数字外还会返回“999+”和“-1”, // 前者不是售罄,后者表示库存未公开,二者都不能按 strconv.Atoi 失败后归零处理。 func xiaodianStock(value xiaodianFlexibleString) (int, string) { raw := strings.TrimSpace(string(value)) switch raw { case "", "-1": return -1, "库存未公开" } if strings.HasSuffix(raw, "+") { minimum, err := strconv.Atoi(strings.TrimSuffix(raw, "+")) if err == nil && minimum >= 0 { return minimum, "库存 " + raw } return -1, "库存未公开" } stock, err := strconv.Atoi(raw) if err != nil || stock < 0 { return -1, "库存未公开" } return stock, "" } // xiaodianImageURL 与官方页面一致,使用 OSS 图片的 HTTPS 地址,避免桌面 WebView 拦截 HTTP 资源。 func xiaodianImageURL(raw string) string { endpoint, err := url.Parse(strings.TrimSpace(raw)) if err != nil || endpoint.Scheme != "http" || endpoint.Hostname() != "ossweb-img.qq.com" { return raw } endpoint.Scheme = "https" return endpoint.String() } func xiaodianPayment(item xiaodianItem) (int, string, int) { payType := 1 label := "点券" price := parseXiaodianInt(item.Price) if strings.Contains(strings.ToLower(item.PayType), "jb2") || parseXiaodianInt(item.JB2Price) > 0 { payType = 5 label = "扭蛋碎片" price = parseXiaodianInt(item.JB2Price) } return payType, label, price } // xiaodianProductPages 按分类拉取完整分页;分类为空时查询全部商品。 func (c *DouyuClient) xiaodianProductPages(endpoint *url.URL, query url.Values, pageSize int, category, excludeCategory, label string) ([]xiaodianItem, error) { itemsAll := make([]xiaodianItem, 0) for offset, pageNum := 0, 1; ; pageNum++ { pageQuery := make(url.Values, len(query)) for key, values := range query { pageQuery[key] = append([]string(nil), values...) } if category != "" { pageQuery.Set("category", category) } if excludeCategory != "" { pageQuery.Set("excludeFields", "iCategoryId_"+excludeCategory) } pageQuery.Set("page_begin", strconv.Itoa(offset)) pageQuery.Set("page_num", strconv.Itoa(pageNum)) items, err := c.xiaodianProductPage(endpoint, pageQuery, label) if err != nil { return nil, err } itemsAll = append(itemsAll, items...) if len(items) < pageSize { break } if pageNum >= 100 { return nil, errors.New("和平小店商品分页超出安全上限") } offset += pageSize } return itemsAll, nil } func (c *DouyuClient) xiaodianProductPage(endpoint *url.URL, query url.Values, label string) ([]xiaodianItem, error) { pageURL := *endpoint pageURL.RawQuery = query.Encode() req, err := http.NewRequest(http.MethodGet, pageURL.String(), nil) if err != nil { return nil, err } c.setXiaodianHeaders(req) body, err := c.doText(req, label, false) if err != nil { return nil, err } var result xiaodianRecommendation if err := parseXiaodianVariable(body, "recommend", &result); err != nil { return nil, fmt.Errorf("和平小店商品响应解析失败: %w", err) } if result.ErrCode != "" && result.ErrCode != "0" { return nil, fmt.Errorf("和平小店商品查询失败: %s", result.ErrDesc) } items := result.Data.ClientData.Items if items == nil { return nil, errors.New("和平小店商品响应格式异常") } return items, nil } // XiaodianAssets 查询一个账号的点券与扭蛋碎片,角色信息只解析一次。 func (c *DouyuClient) XiaodianAssets(cookie string) (types.XiaodianAssets, error) { if !c.Activity.IsXiaodian { return types.XiaodianAssets{}, errors.New("当前活动不是和平小店") } if strings.TrimSpace(cookie) == "" { return types.XiaodianAssets{}, errors.New("Cookie 为空") } embedQuery, err := c.xiaodianEmbedQuery(cookie) if err != nil { return types.XiaodianAssets{}, err } role, err := c.xiaodianRole(embedQuery) if err != nil { return types.XiaodianAssets{}, err } if role.OpenID == "" || role.RoleID == "" { return types.XiaodianAssets{}, errors.New("未获取到和平小店绑定角色") } areaID := parseXiaodianInt(string(role.Area)) if areaID <= 0 { areaID = 1 } platID := parseXiaodianOptionalInt(string(role.PlatID), 1) coupons, err := c.xiaodianAsset(cookie, role, areaID, platID, "banlanceInfo", "pay.midas.dq.get.ttpp", "balance", true) if err != nil { return types.XiaodianAssets{}, err } fragments, err := c.xiaodianAsset(cookie, role, areaID, platID, "jbInfos", "pay.idip.gamecoin.get", "jb2", false) if err != nil { return types.XiaodianAssets{}, err } return types.XiaodianAssets{Success: true, Coupons: coupons, Fragments: fragments}, nil } // XiaodianRole 查询一个账号当前绑定的和平小店角色。 func (c *DouyuClient) XiaodianRole(cookie string) (types.XiaodianRole, error) { if !c.Activity.IsXiaodian { return types.XiaodianRole{}, errors.New("当前活动不是和平小店") } if strings.TrimSpace(cookie) == "" { return types.XiaodianRole{}, errors.New("Cookie 为空") } embedQuery, err := c.xiaodianEmbedQuery(cookie) if err != nil { return types.XiaodianRole{}, err } role, err := c.xiaodianRole(embedQuery) if err != nil { return types.XiaodianRole{}, err } if strings.TrimSpace(string(role.RoleID)) == "" { return types.XiaodianRole{}, errors.New("未获取到和平小店绑定角色") } areaID := parseXiaodianInt(string(role.Area)) if areaID <= 0 { areaID = 1 } platID := parseXiaodianOptionalInt(string(role.PlatID), 1) result := types.XiaodianRole{ Success: true, RoleID: string(role.RoleID), RoleName: string(role.RoleName), OpenID: string(role.OpenID), Type: string(role.Type), PlatID: platID, AreaID: areaID, } return result, nil } // XiaodianBindRole 确认扫码后的和平小店角色,并返回最新角色信息。 func (c *DouyuClient) XiaodianBindRole(cookie string) (types.XiaodianRole, error) { if !c.Activity.IsXiaodian { return types.XiaodianRole{}, errors.New("当前活动不是和平小店") } uid := cookieValue(cookie, "acf_uid") stk := cookieValue(cookie, "acf_stk") ltkid := cookieValue(cookie, "acf_ltkid") if uid == "" || stk == "" || ltkid == "" { return types.XiaodianRole{}, errors.New("斗鱼 Cookie 缺少 acf_uid、acf_stk 或 acf_ltkid") } endpoint, err := url.Parse(c.Activity.BindInfoURL) if err != nil { return types.XiaodianRole{}, err } query := endpoint.Query() query.Set("actAlias", c.Activity.ActAlias) query.Set("token", uid+"_1_"+stk+"_0_"+ltkid) endpoint.RawQuery = query.Encode() req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) if err != nil { return types.XiaodianRole{}, err } c.setBrowserHeaders(req, cookie) var bind struct { types.APIResponse Data struct { BindRole xiaodianFlexibleString `json:"bindRole"` } `json:"data"` } if _, err := c.doJSON(req, &bind, "确认小店绑定"); err != nil { return types.XiaodianRole{}, err } if bind.Error != 0 || parseXiaodianInt(string(bind.Data.BindRole)) == 0 { if bind.Msg != "" { return types.XiaodianRole{}, errors.New(bind.Msg) } return types.XiaodianRole{}, errors.New("尚未检测到小店绑定角色,请先扫码绑定") } return c.XiaodianRole(cookie) } // XiaodianBindQRCode 生成一个账号专用的小店角色绑定二维码链接。 func (c *DouyuClient) XiaodianBindQRCode(cookie string) (string, string, error) { if !c.Activity.IsXiaodian { return "", "", errors.New("当前活动不是和平小店") } uid := cookieValue(cookie, "acf_uid") stk := cookieValue(cookie, "acf_stk") ltkid := cookieValue(cookie, "acf_ltkid") if uid == "" || stk == "" || ltkid == "" { return "", "", errors.New("斗鱼 Cookie 缺少 acf_uid、acf_stk 或 acf_ltkid") } form := url.Values{ "token": {uid + "_1_" + stk + "_0_" + ltkid}, "uid": {uid}, "client": {"mobile"}, "actAlias": {c.Activity.ActAlias}, } endpoint, err := url.Parse(xiaodianBindRoleParamURL) if err != nil { return "", "", err } query := endpoint.Query() query.Set("client_sys", "android") endpoint.RawQuery = query.Encode() req, err := http.NewRequest(http.MethodPost, endpoint.String(), strings.NewReader(form.Encode())) if err != nil { return "", "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") req.Header.Set("User-Agent", "android/7.9.2 (android 9; ; M973Q)") req.Header.Set("User-Device", "MDExZTUxMzEyYmYxMmY5MmQ3YmY1M2M4MDA3MDcxMTF8djcuOS4y") req.Header.Set("aid", "android1") req.Header.Set("Cookie", cookie) var result struct { types.APIResponse Data struct { URL string `json:"url"` GameRoleBindURL string `json:"gameRoleBindUrl"` } `json:"data"` } if _, err := c.doJSON(req, &result, "生成小店绑定二维码"); err != nil { return "", "", err } if result.Error != 0 { if result.Msg != "" { return "", "", errors.New(result.Msg) } return "", "", errors.New("生成小店绑定二维码失败") } if result.Data.URL == "" { return "", "", errors.New("生成小店绑定二维码失败:响应缺少二维码链接") } return result.Data.URL, result.Data.GameRoleBindURL, nil } // XiaodianExchangeList 查询和平小店真实订单记录。 func (c *DouyuClient) XiaodianExchangeList(cookie string) ([]types.XiaodianExchangeRecord, error) { if !c.Activity.IsXiaodian { return nil, errors.New("当前活动不是和平小店") } if strings.TrimSpace(cookie) == "" { return nil, errors.New("Cookie 为空") } embedQuery, err := c.xiaodianEmbedQuery(cookie) if err != nil { return nil, err } endpoint, err := url.Parse(strings.TrimRight(c.Activity.RoleURL, "/") + "/") if err != nil { return nil, err } all := make([]types.XiaodianExchangeRecord, 0) const pageSize = 100 for page := 1; page <= 100; page++ { query := endpoint.Query() query.Set("_service", "order.list") query.Set("_jsvar", "info") query.Set("acctype", "livelink") query.Set("_app_id", "2123") query.Set("hist", "0") query.Set("ps", strconv.Itoa(pageSize)) query.Set("pn", strconv.Itoa(page)) query.Set("_biz_code", "cjm") query.Set("_act_id", c.Activity.ActID) query.Set("authType", "delegate") query.Set("_sid", "8") query.Set("set", "8") for _, key := range []string{"gameId", "actId", "appId", "livePlatId", "code", "timestamp", "v", "sig"} { query.Set(key, embedQuery.Get(key)) } endpoint.RawQuery = query.Encode() req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) if err != nil { return nil, err } c.setXiaodianHeaders(req) body, err := c.doText(req, "和平小店兑换记录", true) if err != nil { return nil, err } var payload xiaodianOrderList if err := parseXiaodianVariable(body, "info", &payload); err != nil { return nil, fmt.Errorf("和平小店兑换记录响应解析失败: %w", err) } for _, order := range payload.Data.List { var goods struct { List []xiaodianOrderGoods `json:"list"` } if strings.TrimSpace(order.GoodsRaw) != "" { if err := json.Unmarshal([]byte(order.GoodsRaw), &goods); err != nil { return nil, fmt.Errorf("和平小店订单商品解析失败: %w", err) } } for _, item := range goods.List { price := parseXiaodianInt(string(order.PayAmount)) if strings.TrimSpace(string(order.PayAmount)) == "" { price = parseXiaodianInt(string(order.Price)) } all = append(all, types.XiaodianExchangeRecord{ CommodityID: item.GoodsID, CommodityName: item.GoodsName, Quantity: parseXiaodianInt(string(item.Quantity)), BuyTime: order.BuyTime, PayTime: order.PayTime, Price: price, RoleName: order.RoleName, Status: order.Status, }) } } totalCount := parseXiaodianInt(string(payload.Data.Count)) if len(payload.Data.List) == 0 || (totalCount > 0 && len(all) >= totalCount) || (totalCount <= 0 && len(payload.Data.List) < pageSize) { break } } return all, nil } func (c *DouyuClient) xiaodianAsset(cookie string, role xiaodianRoleContext, areaID, platID int, variable, service, field string, withTime bool) (int, error) { endpoint, err := url.Parse(strings.TrimRight(c.Activity.RoleURL, "/") + "/") if err != nil { return 0, err } query := endpoint.Query() query.Set("_jsvar", variable) query.Set("_service", service) query.Set("_app_id", "2123") query.Set("acctype", "ttpp") query.Set("areaid", strconv.Itoa(areaID)) query.Set("eventid", "") query.Set("interwork", "0") query.Set("openid", string(role.OpenID)) query.Set("openkey", "openkey") query.Set("partition", "0") query.Set("pay_token", "") query.Set("plat", strconv.Itoa(platID)) query.Set("plat_pc", "0") query.Set("roleid", string(role.RoleID)) query.Set("_biz_code", "cjm") query.Set("_act_id", c.Activity.ActID) query.Set("_sid", "6") if withTime { query.Set("_time", strconv.FormatInt(time.Now().UnixMilli(), 10)) } else { query.Set("coin_type", "1") } endpoint.RawQuery = query.Encode() req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) if err != nil { return 0, err } c.setXiaodianHeaders(req) body, err := c.doText(req, "查询小店资产", true) if err != nil { return 0, err } var payload map[string]xiaodianFlexibleString if err := parseXiaodianVariable(body, variable, &payload); err != nil { return 0, fmt.Errorf("和平小店%s响应解析失败: %w", field, err) } return parseXiaodianInt(string(payload[field])), nil } // parseXiaodianInt 将 QQ 道具接口中的字符串数值转为界面商品字段。 func parseXiaodianInt(value string) int { parsed, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil { return 0 } return parsed } // parseXiaodianOptionalInt 保留接口明确返回的 0;只有缺失或非法值才回退。 // platId=0 是合法的 iOS 平台标识,不能用 <=0 判断为缺失。 func parseXiaodianOptionalInt(value string, fallback int) int { value = strings.TrimSpace(value) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } // Score 查询账号积分余额。不同活动的积分接口方法/路径不同: // 和平手册用 POST /japi/oms/web/peace/credit/balance; // 电竞手册用 GET /wgapi/ordnc/activity/peacematch/score/balance。 func (c *DouyuClient) Score(cookie string) (int, error) { var result struct { types.APIResponse Data struct { Balance int `json:"balance"` } `json:"data"` } if c.Activity.ScoreMethod == "GET" { if err := c.getJSON(c.Activity.ScoreURL, cookie, "积分余额", &result); err != nil { return 0, err } } else { req, err := http.NewRequest("POST", c.Activity.ScoreURL, strings.NewReader(`{}`)) if err != nil { return 0, err } c.setJSONHeaders(req, cookie) req.Header.Set("Referer", c.Activity.BaseURL+"/") if _, err := c.doJSON(req, &result, "积分余额"); err != nil { return 0, err } } if result.Error != 0 { return 0, fmt.Errorf("积分接口失败: %s", result.Msg) } return result.Data.Balance, nil } // GenerateCSRF 生成当前活动需要的鉴权 token。 func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) { if c.Activity.IsXiaodian { return "", nil } if c.Activity.UseCTN { ctn, err := c.FetchCTN(cookie) if err != nil { return "", fmt.Errorf("获取CTN失败: %w", err) } return combineAuthToken("", ctn), nil } req, err := http.NewRequest("POST", c.Activity.CSRFURL, nil) if err != nil { return "", err } c.setBrowserHeaders(req, cookie) start := time.Now() reqBody := c.Trace.RequestBody(req) resp, err := c.HTTPClient.Do(req) if err != nil { c.Trace.Record("生成CSRF", req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "") c.logNetworkError("生成CSRF", req, time.Since(start), err) return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "") c.logNetworkError("生成CSRF", req, time.Since(start), err) return "", err } if resp.StatusCode != http.StatusOK { c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") c.logNetworkHTTPError("生成CSRF", req, resp.StatusCode, time.Since(start), body) return "", fmt.Errorf("CSRF HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 120)) } var api types.APIResponse if err := json.Unmarshal(body, &api); err != nil { c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "") c.logNetworkParseError("生成CSRF", req, time.Since(start), body, err) return "", fmt.Errorf("CSRF 响应解析失败: %w", err) } traceErr := "" if api.Error != 0 { traceErr = api.Msg c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, traceErr, api.Error, api.Msg) c.logNetworkAPIResult("生成CSRF", req, time.Since(start), api.Error, api.Msg) return "", fmt.Errorf("CSRF 接口失败: %s", api.Msg) } for _, ck := range resp.Cookies() { if ck.Name == "cvl_csrf_token" && ck.Value != "" { c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", api.Error, api.Msg) c.logNetworkSuccess("生成CSRF", req, time.Since(start), 0, "") return ck.Value, nil } } c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 cvl_csrf_token", api.Error, api.Msg) logger.LogEvent("网络", "warn", "接口返回异常: %s 耗时=%s msg=响应没有 cvl_csrf_token", c.networkDiagnostic("生成CSRF", req), utils.RoundDuration(time.Since(start))) return "", errors.New("CSRF 响应没有 cvl_csrf_token") } // FetchCTN 获取 acf_ccn(电竞手册用 ctn 鉴权)。 func (c *DouyuClient) FetchCTN(cookie string) (string, error) { if ctn := cookieValue(cookie, "acf_ccn"); ctn != "" { return ctn, nil } if strings.TrimSpace(c.Activity.CTNURL) == "" { logger.LogEvent("网络", "error", "CTN配置缺失: %s", c.networkDiagnostic("获取CTN", nil)) return "", errors.New("CTNURL 未配置") } req, err := http.NewRequest("GET", c.Activity.CTNURL, nil) if err != nil { return "", err } c.setBrowserHeaders(req, cookie) start := time.Now() reqBody := c.Trace.RequestBody(req) resp, err := c.HTTPClient.Do(req) if err != nil { c.Trace.Record("获取CTN", req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "") c.logNetworkError("获取CTN", req, time.Since(start), err) return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "") c.logNetworkError("获取CTN", req, time.Since(start), err) return "", err } if resp.StatusCode != http.StatusOK { c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") c.logNetworkHTTPError("获取CTN", req, resp.StatusCode, time.Since(start), body) return "", fmt.Errorf("CTN HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 120)) } for _, ck := range resp.Cookies() { if ck.Name == "acf_ccn" && ck.Value != "" { c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") c.logNetworkSuccess("获取CTN", req, time.Since(start), 0, "") return ck.Value, nil } } c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 acf_ccn", 0, "") logger.LogEvent("网络", "warn", "接口返回异常: %s 耗时=%s msg=响应没有 acf_ccn 响应=%s", c.networkDiagnostic("获取CTN", req), utils.RoundDuration(time.Since(start)), utils.TrimForLog(string(body), 120)) return "", errors.New("CTN 响应没有 acf_ccn") } func csrfFromResponse(resp *http.Response) string { if resp == nil { return "" } for _, ck := range resp.Cookies() { if ck.Name == "cvl_csrf_token" && ck.Value != "" { return ck.Value } } return "" } // BatchExchangeLimit 请求兑换限制接口。电竞手册不需要此步骤,直接跳过返回默认限制。 func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID string, csrf *string) (int, error) { if c.Activity.IsXiaodian { return 1, nil } if c.Activity.UseCTN { return 1, nil } currentCSRF := "" if csrf != nil { currentCSRF = *csrf } var result types.APIResponse if err := c.postMultipart(c.Activity.BatchLimitURL, cookie, csrf, "批量限制", map[string]string{ "manualID": c.Activity.ManualID, "commodityID": commodityID, "rid": c.Activity.RoomID, "csrfToken": currentCSRF, }, &result); err != nil { return 0, err } if result.Error != 0 { return 0, fmt.Errorf("批量限制接口失败: %s", result.Msg) } var data struct { Limit string `json:"limit"` } if err := json.Unmarshal(result.Data, &data); err != nil { return 0, fmt.Errorf("批量限制数据解析失败: %w", err) } limit, err := strconv.Atoi(data.Limit) if err != nil { return 0, fmt.Errorf("批量限制值无效: %q", data.Limit) } return limit, nil } // PreExchange 按抓包流程执行预兑换检查。电竞手册不需要此步骤,直接跳过。 func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) error { if c.Activity.IsXiaodian { return nil } if c.Activity.UseCTN { return nil } currentCSRF := "" if csrf != nil { currentCSRF = *csrf } var result types.APIResponse if err := c.postMultipart(c.Activity.PreExchangeURL, cookie, csrf, "预兑换", map[string]string{ "manualID": c.Activity.ManualID, "rid": c.Activity.RoomID, "commodityID": commodityID, "num": "1", "csrfToken": currentCSRF, }, &result); err != nil { return err } if result.Error != 0 { return fmt.Errorf("预兑换失败: %s", result.Msg) } return nil } // Exchange 正式兑换一个商品。 func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string, error) { if c.Activity.IsXiaodian { return c.xiaodianExchange(cookie, types.Product{CommodityID: commodityID}) } var result types.APIResponse if c.Activity.UseCTN { // 电竞手册:用 ctn(acf_ccn) 鉴权,multipart 表单(与浏览器抓包一致) ctn := "" if csrf != nil { parts := strings.SplitN(*csrf, "\x00", 2) if len(parts) > 1 { ctn = parts[1] } } if ctn == "" { // 缓存中没有 ctn,现场获取 ctnVal, err := c.FetchCTN(cookie) if err != nil { return "", fmt.Errorf("获取CTN失败: %w", err) } ctn = ctnVal if csrf != nil { *csrf = combineAuthToken(*csrf, ctn) } } if err := c.postMultipart(c.Activity.ExchangeURL, cookie, csrf, "正式兑换", map[string]string{ "manualID": c.Activity.ManualID, "commodityID": commodityID, "num": "1", "rid": c.Activity.RoomID, "ctn": ctn, }, &result); err != nil { return "", err } } else { // 精英手册:用 csrfToken 鉴权,multipart 表单 currentCSRF := "" if csrf != nil { currentCSRF = *csrf } if err := c.postMultipart(c.Activity.ExchangeURL, cookie, csrf, "正式兑换", map[string]string{ "manualID": c.Activity.ManualID, "commodityID": commodityID, "num": "1", "rid": c.Activity.RoomID, "csrfToken": currentCSRF, }, &result); err != nil { return "", err } } if result.Error != 0 { if strings.TrimSpace(result.Msg) == "" { return "", errors.New("兑换失败,接口未返回原因") } return "", fmt.Errorf("兑换失败: %s", result.Msg) } var data struct { ExchangeID int `json:"exchangeId"` ExchangeNum string `json:"exchangeNum"` CommodityTyp int `json:"commodityType"` } if err := json.Unmarshal(result.Data, &data); err != nil { return "", fmt.Errorf("兑换数据解析失败: %w", err) } return fmt.Sprintf("exchangeId=%d,num=%s", data.ExchangeID, data.ExchangeNum), nil } // ExchangeProduct 正式兑换一个商品。和平小店需要商品支付类型/价格,因此应优先走这个入口。 func (c *DouyuClient) ExchangeProduct(cookie string, product types.Product, csrf *string) (string, error) { if c.Activity.IsXiaodian { return c.xiaodianExchange(cookie, product) } return c.Exchange(cookie, product.CommodityID, csrf) } func (c *DouyuClient) xiaodianExchange(cookie string, product types.Product) (string, error) { if strings.TrimSpace(cookie) == "" { return "", errors.New("Cookie 为空") } if strings.TrimSpace(product.CommodityID) == "" { return "", errors.New("商品 ID 为空") } embedQuery, err := c.xiaodianEmbedQueryFresh(cookie) if err != nil { return "", err } payType := product.PayType if payType <= 0 { payType = 1 } payPrice := product.Score if payPrice < 0 { payPrice = 0 } endpoint, err := url.Parse(c.xiaodianLiveBuyURL()) if err != nil { return "", err } query := endpoint.Query() query.Set("_service", "buy.plug.svr.web") query.Set("_plug_id", "7200") query.Set("_jsvar", "buyInfo") query.Set("acctype", "livelink") query.Set("_app_id", "2123") 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) query.Set("sAnchorId", c.Activity.RoomID) query.Set("attach", `{"actId":"`+embedQuery.Get("actId")+`"}`) query.Set("apptype", "3") query.Set("_biz_code", "cjm") query.Set("_act_id", c.Activity.ActID) query.Set("authType", "delegate") query.Set("sLiveUserInfo", `{"sAnchorId":"`+c.Activity.RoomID+`","sVideoId":0}`) for _, key := range []string{"actId", "appId", "gameId", "livePlatId", "code", "timestamp", "v", "sig"} { query.Set(key, embedQuery.Get(key)) } appext, err := json.Marshal(map[string]any{ "user_price": payPrice, "sGoodsPic": product.Pic, "paytype": payType, }) if err != nil { return "", err } query.Set("appext", string(appext)) query.Set("_sid", "8") query.Set("set", "8") endpoint.RawQuery = query.Encode() req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) if err != nil { return "", err } c.setXiaodianHeaders(req) body, err := c.doText(req, "和平小店正式兑换", true) if err != nil { return "", err } var result xiaodianBuyInfo if err := parseXiaodianVariable(body, "buyInfo", &result); err != nil { return "", fmt.Errorf("和平小店兑换响应解析失败: %w", err) } if result.Ret != "0" { if strings.TrimSpace(result.Msg) == "" { return "", fmt.Errorf("和平小店兑换失败: ret=%s", result.Ret) } return "", fmt.Errorf("和平小店兑换失败: %s", result.Msg) } serial := result.Serial if serial == "" { serial = result.SerialNum } if serial == "" { return "paytype=" + strconv.Itoa(payType), nil } return fmt.Sprintf("serial=%s,paytype=%d", serial, payType), nil } func (c *DouyuClient) xiaodianLiveBuyURL() string { roleURL := strings.TrimSpace(c.Activity.RoleURL) if roleURL == "" { return "https://apps.game.qq.com/daoju/igw/livebuy/" } endpoint, err := url.Parse(roleURL) if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { return "https://apps.game.qq.com/daoju/igw/livebuy/" } endpoint.Path = "/daoju/igw/livebuy/" endpoint.RawQuery = "" endpoint.Fragment = "" return endpoint.String() } // RoleName 查询绑定角色名。 func (c *DouyuClient) RoleName(cookie string) (string, error) { var result struct { types.APIResponse Data struct { GameRole struct { RoleName string `json:"roleName"` } `json:"gameRole"` } `json:"data"` } if err := c.getJSON(c.Activity.BindInfoURL, cookie, "角色绑定", &result); err != nil { return "", err } if result.Error != 0 { return "", fmt.Errorf("角色接口失败: %s", result.Msg) } return result.Data.GameRole.RoleName, nil } // ExchangeListResponse 兑换列表响应。 type ExchangeListResponse struct { Error int `json:"error"` Msg string `json:"msg"` Data struct { List []types.ExchangeRecord `json:"list"` Count int `json:"count"` } `json:"data"` } // APIStatus 返回斗鱼 API 的 error/msg 状态,实现 apiResponder 接口。 func (r ExchangeListResponse) APIStatus() (int, string, bool) { return r.Error, r.Msg, true } // ExchangeList 查询兑换记录列表。 func (c *DouyuClient) ExchangeList(cookie string) ([]types.ExchangeRecord, error) { if c.Activity.IsXiaodian { xiaodianRecords, err := c.XiaodianExchangeList(cookie) if err != nil { return nil, err } records := make([]types.ExchangeRecord, 0, len(xiaodianRecords)) for _, record := range xiaodianRecords { records = append(records, types.ExchangeRecord{ CommodityID: record.CommodityID, CommodityName: record.CommodityName, ShowName: record.CommodityName, ExchangeNum: record.Quantity, Time: record.BuyTime, }) } return records, nil } var result ExchangeListResponse if err := c.getJSON(c.Activity.ExchangeListURL, cookie, "兑换记录", &result); err != nil { return nil, err } if result.Error != 0 { return nil, fmt.Errorf("兑换记录查询失败: %s", result.Msg) } return result.Data.List, nil } func (c *DouyuClient) getJSON(url, cookie, label string, out any) error { req, err := http.NewRequest("GET", url, nil) if err != nil { return err } c.setBrowserHeaders(req, cookie) _, err = c.doJSON(req, out, label) return err } func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label string, fields map[string]string, out any) error { var body strings.Builder writer := multipart.NewWriter(&body) for key, value := range fields { if err := writer.WriteField(key, value); err != nil { return err } } if err := writer.Close(); err != nil { return err } req, err := http.NewRequest("POST", url, strings.NewReader(body.String())) if err != nil { return err } req = withFormFieldDiagnostics(req, fields) // 处理组合 token:csrf\x00ctn(电竞手册用 ctn 鉴权时) // 拆分后按需把 cvl_csrf_token 和 acf_ccn 合并到 Cookie 头 currentCSRF := "" ctnVal := "" if csrf != nil { parts := strings.SplitN(*csrf, "\x00", 2) currentCSRF = parts[0] if len(parts) > 1 { ctnVal = parts[1] } } if ctnVal == "" && c.Activity.UseCTN { ctnVal = fields["ctn"] } mergedCookie := cookie if currentCSRF != "" { mergedCookie = utils.MergeCookie(mergedCookie, "cvl_csrf_token", currentCSRF) } if ctnVal != "" { mergedCookie = utils.MergeCookie(mergedCookie, "acf_ccn", ctnVal) } c.setBrowserHeaders(req, mergedCookie) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := c.doJSON(req, out, label) if resp != nil { if nextCSRF := csrfFromResponse(resp); nextCSRF != "" && csrf != nil { if nextCSRF != *csrf { logger.LogEvent("网络", "info", "响应刷新CSRF已同步: %s %s %s", label, req.Method, requestPath(req)) } if ctnVal != "" { *csrf = nextCSRF + "\x00" + ctnVal } else { *csrf = nextCSRF } } } return err } func (c *DouyuClient) doJSON(req *http.Request, out any, label string) (*http.Response, error) { start := time.Now() reqBody := c.Trace.RequestBody(req) resp, err := c.HTTPClient.Do(req) if err != nil { c.Trace.Record(label, req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "") c.logNetworkError(label, req, time.Since(start), err) return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "") c.logNetworkError(label, req, time.Since(start), err) return resp, err } if resp.StatusCode != http.StatusOK { c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") c.logNetworkHTTPError(label, req, resp.StatusCode, time.Since(start), body) return resp, fmt.Errorf("HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 160)) } if err := json.Unmarshal(body, out); err != nil { c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "") c.logNetworkParseError(label, req, time.Since(start), body, err) return resp, fmt.Errorf("JSON 解析失败: %w, body=%s", err, utils.TrimForLog(string(body), 160)) } if apiError, apiMsg, ok := extractAPIStatus(out); ok && apiError != 0 { c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, apiMsg, apiError, apiMsg) c.logNetworkAPIResult(label, req, time.Since(start), apiError, apiMsg) } else if shouldLogNetworkSuccess(label) { c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") c.logNetworkSuccess(label, req, time.Since(start), 0, "") } else { c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") } return resp, nil } // doText 执行返回 JavaScript 变量的接口请求,并复用统一网络诊断。 func (c *DouyuClient) doText(req *http.Request, label string, redactBody bool) ([]byte, error) { start := time.Now() reqBody := c.Trace.RequestBody(req) resp, err := c.HTTPClient.Do(req) if err != nil { c.Trace.Record(label, req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "") c.logNetworkError(label, req, time.Since(start), err) return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "") c.logNetworkError(label, req, time.Since(start), err) return nil, err } if resp.StatusCode != http.StatusOK { traceBody := body if redactBody { traceBody = []byte("") } c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, traceBody, "", 0, "") c.logNetworkHTTPError(label, req, resp.StatusCode, time.Since(start), body) return nil, fmt.Errorf("%s HTTP %d: %s", label, resp.StatusCode, utils.TrimForLog(string(body), 160)) } traceBody := body if redactBody { traceBody = []byte("") } c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, traceBody, "", 0, "") c.logNetworkSuccess(label, req, time.Since(start), 0, "") return body, nil } func shouldLogNetworkSuccess(label string) bool { return label != "服务时间" } func (c *DouyuClient) logNetworkSuccess(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) { if !shouldLogNetworkSuccess(label) { return } extra := "" if apiMsg != "" || apiError != 0 { extra = fmt.Sprintf(" apiError=%d msg=%s", apiError, utils.TrimForLog(apiMsg, 80)) } logger.LogEvent("网络", "info", "请求完成: %s 耗时=%s%s", c.networkDiagnostic(label, req), utils.RoundDuration(cost), extra) } func (c *DouyuClient) logNetworkAPIResult(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) { logger.LogEvent("网络", "warn", "接口返回异常: %s 耗时=%s apiError=%d msg=%s", c.networkDiagnostic(label, req), utils.RoundDuration(cost), apiError, utils.TrimForLog(apiMsg, 120)) } func (c *DouyuClient) logNetworkHTTPError(label string, req *http.Request, status int, cost time.Duration, body []byte) { logger.LogEvent("网络", "error", "HTTP异常: %s 状态=%d 耗时=%s 响应=%s", c.networkDiagnostic(label, req), status, utils.RoundDuration(cost), utils.TrimForLog(string(body), 160)) } func (c *DouyuClient) logNetworkParseError(label string, req *http.Request, cost time.Duration, body []byte, err error) { logger.LogEvent("网络", "error", "响应解析失败: %s 耗时=%s 错误=%v 响应=%s", c.networkDiagnostic(label, req), utils.RoundDuration(cost), err, utils.TrimForLog(string(body), 160)) } func (c *DouyuClient) logNetworkError(label string, req *http.Request, cost time.Duration, err error) { logger.LogEvent("网络", "error", "请求失败: %s 耗时=%s 错误=%v", c.networkDiagnostic(label, req), utils.RoundDuration(cost), err) } type networkRequestMetaKey struct{} type networkRequestMeta struct { formFields []string } func withFormFieldDiagnostics(req *http.Request, fields map[string]string) *http.Request { if req == nil { return nil } names := sortedFieldNames(fields) if len(names) == 0 { return req } meta := networkRequestMeta{formFields: names} return req.WithContext(context.WithValue(req.Context(), networkRequestMetaKey{}, meta)) } func sortedFieldNames(fields map[string]string) []string { if len(fields) == 0 { return nil } names := make([]string, 0, len(fields)) for key := range fields { names = append(names, key) } sort.Strings(names) return names } func formFieldNames(req *http.Request) []string { if req == nil { return nil } meta, ok := req.Context().Value(networkRequestMetaKey{}).(networkRequestMeta) if !ok || len(meta.formFields) == 0 { return nil } return meta.formFields } func (c *DouyuClient) networkDiagnostic(label string, req *http.Request) string { if strings.TrimSpace(label) == "" { label = "-" } activityName := "-" activityID := "-" manualID := "-" roomID := "-" actAlias := "-" useCTN := false if c != nil && c.Activity != nil { if c.Activity.DisplayName != "" { activityName = c.Activity.DisplayName } if c.Activity.ID != "" { activityID = c.Activity.ID } if c.Activity.ManualID != "" { manualID = c.Activity.ManualID } if c.Activity.RoomID != "" { roomID = c.Activity.RoomID } if c.Activity.ActAlias != "" { actAlias = c.Activity.ActAlias } useCTN = c.Activity.UseCTN } method := "-" if req != nil && req.Method != "" { method = req.Method } parts := []string{ fmt.Sprintf("活动=%s(%s)", activityName, activityID), fmt.Sprintf("UseCTN=%t", useCTN), fmt.Sprintf("label=%s", label), fmt.Sprintf("method=%s", method), fmt.Sprintf("path=%s", requestPath(req)), fmt.Sprintf("manualID=%s", manualID), fmt.Sprintf("rid=%s", roomID), fmt.Sprintf("actAlias=%s", actAlias), } if fields := formFieldNames(req); len(fields) > 0 { parts = append(parts, "fields="+strings.Join(fields, ",")) } return strings.Join(parts, " ") } func requestPath(req *http.Request) string { if req == nil || req.URL == nil { return "-" } return req.URL.Path } func cookieValue(cookie, name string) string { if strings.TrimSpace(cookie) == "" || strings.TrimSpace(name) == "" { return "" } for _, part := range strings.Split(cookie, ";") { key, value, ok := strings.Cut(strings.TrimSpace(part), "=") if !ok { continue } if strings.TrimSpace(key) == name { return strings.TrimSpace(value) } } return "" } func combineAuthToken(current, ctn string) string { if ctn == "" { return current } csrf := "" if current != "" { parts := strings.SplitN(current, "\x00", 2) csrf = parts[0] } if csrf == "" { return "\x00" + ctn } return csrf + "\x00" + ctn } func extractAPIStatus(out any) (int, string, bool) { if out == nil { return 0, "", false } if r, ok := out.(apiResponder); ok { return r.APIStatus() } return 0, "", false } // apiResponder 返回斗鱼 API 的 error/msg 状态。 // types.APIResponse 通过其方法满足该接口; // 未嵌入 APIResponse 但自带 Error/Msg 字段的结构体(如 ExchangeListResponse) // 通过实现 APIStatus 方法满足该接口,避免使用反射逐字段查找。 type apiResponder interface { APIStatus() (int, string, bool) } func (c *DouyuClient) setBrowserHeaders(req *http.Request, cookie string) { req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5") req.Header.Set("User-Agent", constants.UserAgent) req.Header.Set("Origin", c.Activity.BaseURL) req.Header.Set("Referer", c.Activity.ShopReferer) req.Header.Set("Sec-Fetch-Dest", "empty") req.Header.Set("Sec-Fetch-Mode", "cors") req.Header.Set("Sec-Fetch-Site", "same-origin") if strings.TrimSpace(cookie) != "" { req.Header.Set("Cookie", cookie) } } func (c *DouyuClient) setJSONHeaders(req *http.Request, cookie string) { c.setBrowserHeaders(req, cookie) req.Header.Set("Content-Type", "application/json") } // setXiaodianHeaders 设置和平小店商品列表请求所需的浏览器头。 func (c *DouyuClient) setXiaodianHeaders(req *http.Request) { req.Header.Set("Accept", "*/*") req.Header.Set("Accept-Language", "zh-CN,zh-Hans;q=0.9") req.Header.Set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148, Douyu_IOS") req.Header.Set("Referer", "https://app.daoju.qq.com/") req.Header.Set("Sec-Fetch-Dest", "script") req.Header.Set("Sec-Fetch-Mode", "no-cors") req.Header.Set("Sec-Fetch-Site", "same-site") }