Files
dygou_go/internal/client/client.go
T
yml2213 dce9ce2a5a 优化日志系统:网络请求降噪并按接口聚合,补齐双时间说明与实例标识,发布0.3.2
- 逐条网络日志(请求完成 / 接口返回异常 / 响应刷新CSRF已同步)从 info/warn 降为 debug:
  实测一次 6 账号的模拟抢购里 233 条日志有 174 条(74%)属于这类;真实抢购会产生几千条,
  把界面仅 800 条的内存日志冲掉,反而看不到关键信息。HTTP/解析/传输层错误仍为 error 级
- 新增按接口聚合的网络统计,抢购任务结束时输出一行,替代逐条刷屏:
  "接口统计: 锁定兑换商品 ×341 失败341 平均=95ms 最慢=312ms 最后错误=活动火爆,请稍后再试"
- 日志文件开头新增表头:实例名、数据目录、双时间格式说明(本地时间 + D 斗鱼估算时间)、
  并说明"校准完成前偏移为 0,只显示本地时间"。双时间一直存在却没有任何地方解释过,
  多实例日志复制或汇总后也分不清来源
- 界面日志面板:显示日志级别并按级别着色(此前级别只存在于 CSS class,复制出去就丢);
  新增"复制"按钮,按与日志文件一致的单行格式导出(此前直接复制会变成 5 行一条、
  1204 行里其实只有 233 条日志)
- 设置页"日志显示等级"补充说明:Info 起只显示关键日志,需要逐请求细节时切到 Debug
- 新增测试:TestRequestStatsAggregatesByLabel、TestLogFileHeaderDocumentsFormat、
  TestRunNowLogsAggregateInsteadOfPerRequest(断言必须有聚合行、且不得出现逐条请求日志)
2026-10-03 19:33:14 +08:00

2066 lines
66 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
statsMu sync.Mutex
reqStats map[string]*RequestStat
}
// RequestStat 累计某一类接口的调用情况。
//
// 逐条网络日志现在降为 debug 级(一次真实抢购会产生几千条),靠这份聚合结果
// 保留诊断价值:哪类接口调了多少次、失败多少、平均/最慢多久、最后一次错在哪。
type RequestStat struct {
Label string
Count int
Failed int
Total time.Duration
Slowest time.Duration
LastErr string
}
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。
// 精英手册优先复用 Cookie 里的 cvl_csrf_token,缺失时才请求生成接口。
func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
return c.generateCSRF(cookie, false)
}
func (c *DouyuClient) generateCSRF(cookie string, forceRefresh bool) (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
}
if !forceRefresh {
if token := cookieValue(cookie, "cvl_csrf_token"); token != "" {
return token, 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
}
// OrderError 是锁单/支付接口返回的业务错误,保留斗鱼错误码。
//
// 调用方需要按"错误码"而不是"文案"决定重试策略:支付链路上
// 1644430001(订单操作进行中)、1644400002(斗鱼服务端 3 秒超时)、
// 1644400014(服务当前不可用)都表示订单已受理、结果未定,应当快重试,
// 而不是当成频控长退避。Error() 文案与历史版本保持一致,避免影响日志与前端展示。
type OrderError struct {
Kind string // 接口标签,如 "锁定兑换商品失败"
Code int // 斗鱼错误码
Msg string // 斗鱼错误文案
}
func (e *OrderError) Error() string {
if strings.TrimSpace(e.Msg) == "" {
return e.Kind
}
return e.Kind + ": " + e.Msg
}
// LockedExchangeOrder 是精英手册锁单结果。
type LockedExchangeOrder struct {
OrderID string
ExpiresAt time.Time
}
// CreateExchangeOrder 锁定精英手册商品,不查询商品详情或预兑状态。
func (c *DouyuClient) CreateExchangeOrder(cookie, commodityID string, num int, csrf *string) (LockedExchangeOrder, error) {
if strings.TrimSpace(c.Activity.CreateOrderURL) == "" {
return LockedExchangeOrder{}, errors.New("当前活动没有锁单接口")
}
if num < 1 {
num = 1
}
result, err := c.postCSRFForm(c.Activity.CreateOrderURL, cookie, "锁定兑换商品", map[string]string{
"manualID": c.Activity.ManualID,
"rid": c.Activity.RoomID,
"commodityID": commodityID,
"num": strconv.Itoa(num),
}, csrf)
if err != nil {
return LockedExchangeOrder{}, err
}
if result.Error != 0 {
return LockedExchangeOrder{}, &OrderError{Kind: "锁定兑换商品失败", Code: result.Error, Msg: result.Msg}
}
var data struct {
OrderID string `json:"orderId"`
ExpireSeconds json.RawMessage `json:"expireSeconds"`
}
if err := json.Unmarshal(result.Data, &data); err != nil {
return LockedExchangeOrder{}, fmt.Errorf("锁单数据解析失败: %w", err)
}
orderID := strings.TrimSpace(data.OrderID)
if orderID == "" {
return LockedExchangeOrder{}, errors.New("锁定兑换商品成功,但响应中没有 orderId")
}
lockedAt := time.Now()
expiresAt := lockedAt.Add(300 * time.Second)
if seconds := rawInt(data.ExpireSeconds); seconds > 0 {
expiresAt = lockedAt.Add(time.Duration(seconds) * time.Second)
}
return LockedExchangeOrder{OrderID: orderID, ExpiresAt: expiresAt}, nil
}
// PayExchangeOrder 支付一笔已锁定的精英手册订单。
func (c *DouyuClient) PayExchangeOrder(cookie, orderID string, csrf *string) (string, error) {
if strings.TrimSpace(c.Activity.PayOrderURL) == "" {
return "", errors.New("当前活动没有锁单支付接口")
}
orderID = strings.TrimSpace(orderID)
if orderID == "" {
return "", errors.New("锁单订单号不能为空")
}
result, err := c.postCSRFForm(c.Activity.PayOrderURL, cookie, "支付锁单", map[string]string{
"orderID": orderID,
"manualID": c.Activity.ManualID,
}, csrf)
if err != nil {
return "", err
}
if result.Error != 0 {
if alreadyPaid(result.Msg) {
return "orderId=" + orderID + ",alreadyPaid", nil
}
return "", &OrderError{Kind: "支付锁单失败", Code: result.Error, Msg: result.Msg}
}
var data struct {
ExchangeID json.RawMessage `json:"exchangeId"`
ExchangeNum json.RawMessage `json:"exchangeNum"`
}
if err := json.Unmarshal(result.Data, &data); err != nil {
return "", fmt.Errorf("支付数据解析失败: %w", err)
}
exchangeID := rawString(data.ExchangeID)
exchangeNum := rawString(data.ExchangeNum)
if exchangeID == "" && exchangeNum == "" {
return "", errors.New("支付数据解析失败: 响应中没有兑换结果")
}
return fmt.Sprintf("exchangeId=%s,num=%s", exchangeID, exchangeNum), nil
}
func alreadyPaid(message string) bool {
return strings.Contains(message, "订单已支付") || strings.Contains(message, "请勿重复支付")
}
func (c *DouyuClient) postCSRFForm(endpoint, cookie, label string, fields map[string]string, csrf *string) (types.APIResponse, error) {
var result types.APIResponse
token := ""
if csrf != nil {
token = strings.SplitN(*csrf, "\x00", 2)[0]
}
if token == "" {
var err error
token, err = c.GenerateCSRF(cookie)
if err != nil {
return result, err
}
if csrf != nil {
*csrf = token
}
}
fields["csrfToken"] = token
if err := c.postMultipart(endpoint, cookie, csrf, label, fields, &result); err != nil {
return result, err
}
if result.Error != 0 && csrfError(result.Msg) {
logger.LogEvent("网络", "info", "%s: csrfToken 校验失败,刷新后重试一次", label)
token, err := c.generateCSRF(cookie, true)
if err != nil {
return result, err
}
if csrf != nil {
*csrf = token
}
fields["csrfToken"] = token
result = types.APIResponse{}
if err := c.postMultipart(endpoint, cookie, csrf, label, fields, &result); err != nil {
return result, err
}
}
return result, nil
}
func csrfError(message string) bool {
return strings.Contains(strings.ToLower(message), "csrf")
}
func rawString(raw json.RawMessage) string {
raw = bytesTrimJSON(raw)
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return strings.TrimSpace(s)
}
return string(raw)
}
func rawInt(raw json.RawMessage) int {
raw = bytesTrimJSON(raw)
if len(raw) == 0 || string(raw) == "null" {
return 0
}
var n int
if err := json.Unmarshal(raw, &n); err == nil {
return n
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
n, _ = strconv.Atoi(strings.TrimSpace(s))
return n
}
return 0
}
func bytesTrimJSON(raw json.RawMessage) json.RawMessage {
return json.RawMessage(strings.TrimSpace(string(raw)))
}
// 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("网络", "debug", "响应刷新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("<redacted>")
}
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("<redacted>")
}
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 != "服务时间"
}
// recordRequest 记录一次请求结果(每类接口一条聚合)。
func (c *DouyuClient) recordRequest(label string, cost time.Duration, failed bool, errMsg string) {
c.statsMu.Lock()
defer c.statsMu.Unlock()
if c.reqStats == nil {
c.reqStats = make(map[string]*RequestStat)
}
stat := c.reqStats[label]
if stat == nil {
stat = &RequestStat{Label: label}
c.reqStats[label] = stat
}
stat.Count++
stat.Total += cost
if cost > stat.Slowest {
stat.Slowest = cost
}
if failed {
stat.Failed++
if errMsg != "" {
stat.LastErr = utils.TrimForLog(errMsg, 60)
}
}
}
// ResetRequestStats 清空聚合统计(每次抢购任务开始时调用)。
func (c *DouyuClient) ResetRequestStats() {
c.statsMu.Lock()
c.reqStats = make(map[string]*RequestStat)
c.statsMu.Unlock()
}
// RequestStats 返回按调用次数降序排列的聚合统计。
func (c *DouyuClient) RequestStats() []RequestStat {
c.statsMu.Lock()
out := make([]RequestStat, 0, len(c.reqStats))
for _, stat := range c.reqStats {
out = append(out, *stat)
}
c.statsMu.Unlock()
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Label < out[j].Label
})
return out
}
func (c *DouyuClient) logNetworkSuccess(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) {
c.recordRequest(label, cost, apiError != 0, apiMsg)
if !shouldLogNetworkSuccess(label) {
return
}
extra := ""
if apiMsg != "" || apiError != 0 {
extra = fmt.Sprintf(" apiError=%d msg=%s", apiError, utils.TrimForLog(apiMsg, 80))
}
logger.LogEvent("网络", "debug", "请求完成: %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) {
c.recordRequest(label, cost, true, apiMsg)
logger.LogEvent("网络", "debug", "接口返回异常: %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) {
c.recordRequest(label, cost, true, fmt.Sprintf("HTTP %d", status))
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) {
c.recordRequest(label, cost, true, 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) {
c.recordRequest(label, cost, true, 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")
}