Files
dygou_go/internal/client/client.go
T
yml2213 46c06180a5 重构锁单支付重试内核并修复多实例启动,发布0.3.0
- 抢购内核改为"每账号独立重试循环",移除轮次屏障:旧实现在 3 个中签账号卡住
  60 秒支付退避时,另外 31 个账号空转 39 秒,还出现过"某一轮只跑 1 个账号、其余整轮没被尝试"
- 锁单退避改为读取配置的"重试间隔策略"(同一账号连续失败成倍放大,封顶 5 秒),
  删掉硬编码 30 秒退避与"只重试一次"
- 锁单失败不再提前停止:限流(活动火爆/网络繁忙)继续重试;售罄也继续降频探测
  (别人锁单后支付失败或锁单过期,库存会回流),只在窗口结束时按售罄/限流/普通失败分类上报
- 支付重试按斗鱼错误码决策:1644430001/1644400002/1644400014 视为"订单已受理、结果未定"
  并快重试(300ms 起),取代按文案把"订单操作进行中"误判成频控的 60 秒退避;
  不再设次数上限,只受 120 秒预算与锁单过期约束,超出预算如实报"支付结果未知"
- 已锁订单在 75 秒窗口结束后仍会支付完,不再受窗口约束
- 多实例:为每个实例分配独占的 WebView2 用户数据目录。Wails 默认所有实例共用
  %APPDATA%\<可执行文件名>,而一个数据目录只能有一个浏览器进程,导致偶发开不出新实例、
  必须全部关掉重开;实例占位因此提前到 wails.Run 之前完成
- 多实例:占位失败不再静默退回默认目录(会让两个进程共享 accounts.txt/config.json、
  同秒启动还会写同一个日志文件),改为进程唯一的临时实例目录并记录降级原因
- 多实例:单个槽位锁被瞬时占用(杀软/索引器)时短暂重试,仍失败则继续试下一个槽位
  而不是整个分配失败;日志文件改为独占创建,同秒启动自动追加 -2/-3 后缀
- 新增 %LOCALAPPDATA%\douyu-go\startup.log,记录每次启动与失败原因,
  便于排查"窗口起不来"这类连应用日志都不会生成的问题
- 修复预热缓存日志每个账号打两遍、"账号尝试"计数误导(改为真实锁单/支付/直接兑换次数)
- 修复前端把含"成功"字样的失败文案显示成抢到(改为只认"成功|"前缀)
- 修复 jitterDuration 在基准小于 10ms 时 rand.Int63n(0) 会 panic 的隐患
- 设置页文案与作用域同步:重试间隔策略明确作用于锁单重试、"尝试包数"明确仅用于和平小店
2026-10-03 19:09:19 +08:00

1996 lines
64 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
}
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("网络", "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("<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 != "服务时间"
}
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")
}