Files
dygou_go/internal/client/client.go
T

1408 lines
45 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"
"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
}
// 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,
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"`
Category xiaodianFlexibleString `json:"iCategoryId"`
Price string `json:"iPrice"`
GoodsLeft string `json:"iGoodsLeft"`
GoodsPic string `json:"sGoodsPic"`
ShowBegin string `json:"dtShowBeginTime"`
ShowEnd string `json:"dtShowEndTime"`
ActionID string `json:"iActionId"`
}
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"`
Type xiaodianFlexibleString `json:"type"`
Area xiaodianFlexibleString `json:"area"`
PlatID xiaodianFlexibleString `json:"platId"`
}
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"`
GoodsRaw string `json:"sGoodsInfo"`
}
type xiaodianOrderGoods struct {
GoodsID string `json:"iGoodsId"`
GoodsName string `json:"sGoodsName"`
Quantity xiaodianFlexibleString `json:"iQuantity"`
}
// xiaodianEmbedQuery 获取斗鱼为当前账号签发的短时道具城参数。
func (c *DouyuClient) xiaodianEmbedQuery(cookie string) (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,无法获取和平小店授权")
}
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")
}
return h5.Query(), nil
}
// 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{}{}
products = append(products, types.Product{
CommodityID: item.GoodsID,
CommodityName: item.GoodsName,
Category: string(item.Category),
Score: parseXiaodianInt(item.Price),
Count: parseXiaodianInt(item.GoodsLeft),
Pic: item.GoodsPic,
})
}
return products, nil
}
// 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 := parseXiaodianInt(string(role.PlatID))
if platID <= 0 {
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
}
// 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 {
all = append(all, types.XiaodianExchangeRecord{
CommodityID: item.GoodsID, CommodityName: item.GoodsName,
Quantity: parseXiaodianInt(string(item.Quantity)), BuyTime: order.BuyTime,
PayTime: order.PayTime, 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().Unix(), 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
}
// Score 查询账号积分余额。不同活动的积分接口方法/路径不同:
// 和平手册用 POST /japi/oms/web/peace/credit/balance;
// 电竞手册用 GET /wgapi/ordnc/activity/peacematch/score/balance。
func (c *DouyuClient) Score(cookie string) (int, error) {
var result struct {
types.APIResponse
Data struct {
Balance int `json:"balance"`
} `json:"data"`
}
if c.Activity.ScoreMethod == "GET" {
if err := c.getJSON(c.Activity.ScoreURL, cookie, "积分余额", &result); err != nil {
return 0, err
}
} else {
req, err := http.NewRequest("POST", c.Activity.ScoreURL, strings.NewReader(`{}`))
if err != nil {
return 0, err
}
c.setJSONHeaders(req, cookie)
req.Header.Set("Referer", c.Activity.BaseURL+"/")
if _, err := c.doJSON(req, &result, "积分余额"); err != nil {
return 0, err
}
}
if result.Error != 0 {
return 0, fmt.Errorf("积分接口失败: %s", result.Msg)
}
return result.Data.Balance, nil
}
// GenerateCSRF 生成当前活动需要的鉴权 token。
func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
if c.Activity.UseCTN {
ctn, err := c.FetchCTN(cookie)
if err != nil {
return "", fmt.Errorf("获取CTN失败: %w", err)
}
return combineAuthToken("", ctn), nil
}
req, err := http.NewRequest("POST", c.Activity.CSRFURL, nil)
if err != nil {
return "", err
}
c.setBrowserHeaders(req, cookie)
start := time.Now()
reqBody := c.Trace.RequestBody(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
c.Trace.Record("生成CSRF", req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "")
c.logNetworkError("生成CSRF", req, time.Since(start), err)
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
c.logNetworkError("生成CSRF", req, time.Since(start), err)
return "", err
}
if resp.StatusCode != http.StatusOK {
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
c.logNetworkHTTPError("生成CSRF", req, resp.StatusCode, time.Since(start), body)
return "", fmt.Errorf("CSRF HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 120))
}
var api types.APIResponse
if err := json.Unmarshal(body, &api); err != nil {
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "")
c.logNetworkParseError("生成CSRF", req, time.Since(start), body, err)
return "", fmt.Errorf("CSRF 响应解析失败: %w", err)
}
traceErr := ""
if api.Error != 0 {
traceErr = api.Msg
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, traceErr, api.Error, api.Msg)
c.logNetworkAPIResult("生成CSRF", req, time.Since(start), api.Error, api.Msg)
return "", fmt.Errorf("CSRF 接口失败: %s", api.Msg)
}
for _, ck := range resp.Cookies() {
if ck.Name == "cvl_csrf_token" && ck.Value != "" {
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", api.Error, api.Msg)
c.logNetworkSuccess("生成CSRF", req, time.Since(start), 0, "")
return ck.Value, nil
}
}
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 cvl_csrf_token", api.Error, api.Msg)
logger.LogEvent("网络", "warn", "接口返回异常: %s 耗时=%s msg=响应没有 cvl_csrf_token",
c.networkDiagnostic("生成CSRF", req), utils.RoundDuration(time.Since(start)))
return "", errors.New("CSRF 响应没有 cvl_csrf_token")
}
// FetchCTN 获取 acf_ccn(电竞手册用 ctn 鉴权)。
func (c *DouyuClient) FetchCTN(cookie string) (string, error) {
if ctn := cookieValue(cookie, "acf_ccn"); ctn != "" {
return ctn, nil
}
if strings.TrimSpace(c.Activity.CTNURL) == "" {
logger.LogEvent("网络", "error", "CTN配置缺失: %s", c.networkDiagnostic("获取CTN", nil))
return "", errors.New("CTNURL 未配置")
}
req, err := http.NewRequest("GET", c.Activity.CTNURL, nil)
if err != nil {
return "", err
}
c.setBrowserHeaders(req, cookie)
start := time.Now()
reqBody := c.Trace.RequestBody(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
c.Trace.Record("获取CTN", req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "")
c.logNetworkError("获取CTN", req, time.Since(start), err)
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
c.logNetworkError("获取CTN", req, time.Since(start), err)
return "", err
}
if resp.StatusCode != http.StatusOK {
c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
c.logNetworkHTTPError("获取CTN", req, resp.StatusCode, time.Since(start), body)
return "", fmt.Errorf("CTN HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 120))
}
for _, ck := range resp.Cookies() {
if ck.Name == "acf_ccn" && ck.Value != "" {
c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
c.logNetworkSuccess("获取CTN", req, time.Since(start), 0, "")
return ck.Value, nil
}
}
c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 acf_ccn", 0, "")
logger.LogEvent("网络", "warn", "接口返回异常: %s 耗时=%s msg=响应没有 acf_ccn 响应=%s",
c.networkDiagnostic("获取CTN", req), utils.RoundDuration(time.Since(start)), utils.TrimForLog(string(body), 120))
return "", errors.New("CTN 响应没有 acf_ccn")
}
func csrfFromResponse(resp *http.Response) string {
if resp == nil {
return ""
}
for _, ck := range resp.Cookies() {
if ck.Name == "cvl_csrf_token" && ck.Value != "" {
return ck.Value
}
}
return ""
}
// BatchExchangeLimit 请求兑换限制接口。电竞手册不需要此步骤,直接跳过返回默认限制。
func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID string, csrf *string) (int, error) {
if c.Activity.UseCTN {
return 1, nil
}
currentCSRF := ""
if csrf != nil {
currentCSRF = *csrf
}
var result struct {
types.APIResponse
Data struct {
Limit string `json:"limit"`
} `json:"data"`
}
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)
}
limit, err := strconv.Atoi(result.Data.Limit)
if err != nil {
return 0, fmt.Errorf("批量限制值无效: %q", result.Data.Limit)
}
return limit, nil
}
// PreExchange 按抓包流程执行预兑换检查。电竞手册不需要此步骤,直接跳过。
func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) error {
if c.Activity.UseCTN {
return nil
}
currentCSRF := ""
if csrf != nil {
currentCSRF = *csrf
}
var result types.APIResponse
if err := c.postMultipart(c.Activity.PreExchangeURL, cookie, csrf, "预兑换", map[string]string{
"manualID": c.Activity.ManualID,
"rid": c.Activity.RoomID,
"commodityID": commodityID,
"num": "1",
"csrfToken": currentCSRF,
}, &result); err != nil {
return err
}
if result.Error != 0 {
return fmt.Errorf("预兑换失败: %s", result.Msg)
}
return nil
}
// Exchange 正式兑换一个商品。
func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string, error) {
var result struct {
types.APIResponse
Data struct {
ExchangeID int `json:"exchangeId"`
ExchangeNum string `json:"exchangeNum"`
CommodityTyp int `json:"commodityType"`
} `json:"data"`
}
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)
}
return fmt.Sprintf("exchangeId=%d,num=%s", result.Data.ExchangeID, result.Data.ExchangeNum), nil
}
// 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")
}