1765 lines
56 KiB
Go
1765 lines
56 KiB
Go
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 string `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"`
|
||
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)
|
||
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: parseXiaodianInt(item.GoodsLeft),
|
||
Pic: item.GoodsPic,
|
||
})
|
||
}
|
||
return products, nil
|
||
}
|
||
|
||
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 {
|
||
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().UnixMilli(), 10))
|
||
} else {
|
||
query.Set("coin_type", "1")
|
||
}
|
||
endpoint.RawQuery = query.Encode()
|
||
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
c.setXiaodianHeaders(req)
|
||
body, err := c.doText(req, "查询小店资产", true)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
var payload map[string]xiaodianFlexibleString
|
||
if err := parseXiaodianVariable(body, variable, &payload); err != nil {
|
||
return 0, fmt.Errorf("和平小店%s响应解析失败: %w", field, err)
|
||
}
|
||
return parseXiaodianInt(string(payload[field])), nil
|
||
}
|
||
|
||
// parseXiaodianInt 将 QQ 道具接口中的字符串数值转为界面商品字段。
|
||
func parseXiaodianInt(value string) int {
|
||
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
// parseXiaodianOptionalInt 保留接口明确返回的 0;只有缺失或非法值才回退。
|
||
// platId=0 是合法的 iOS 平台标识,不能用 <=0 判断为缺失。
|
||
func parseXiaodianOptionalInt(value string, fallback int) int {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return fallback
|
||
}
|
||
parsed, err := strconv.Atoi(value)
|
||
if err != nil {
|
||
return fallback
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
// Score 查询账号积分余额。不同活动的积分接口方法/路径不同:
|
||
// 和平手册用 POST /japi/oms/web/peace/credit/balance;
|
||
// 电竞手册用 GET /wgapi/ordnc/activity/peacematch/score/balance。
|
||
func (c *DouyuClient) Score(cookie string) (int, error) {
|
||
var result struct {
|
||
types.APIResponse
|
||
Data struct {
|
||
Balance int `json:"balance"`
|
||
} `json:"data"`
|
||
}
|
||
if c.Activity.ScoreMethod == "GET" {
|
||
if err := c.getJSON(c.Activity.ScoreURL, cookie, "积分余额", &result); err != nil {
|
||
return 0, err
|
||
}
|
||
} else {
|
||
req, err := http.NewRequest("POST", c.Activity.ScoreURL, strings.NewReader(`{}`))
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
c.setJSONHeaders(req, cookie)
|
||
req.Header.Set("Referer", c.Activity.BaseURL+"/")
|
||
if _, err := c.doJSON(req, &result, "积分余额"); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
if result.Error != 0 {
|
||
return 0, fmt.Errorf("积分接口失败: %s", result.Msg)
|
||
}
|
||
return result.Data.Balance, nil
|
||
}
|
||
|
||
// GenerateCSRF 生成当前活动需要的鉴权 token。
|
||
func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
|
||
if c.Activity.IsXiaodian {
|
||
return "", nil
|
||
}
|
||
if c.Activity.UseCTN {
|
||
ctn, err := c.FetchCTN(cookie)
|
||
if err != nil {
|
||
return "", fmt.Errorf("获取CTN失败: %w", err)
|
||
}
|
||
return combineAuthToken("", ctn), nil
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", c.Activity.CSRFURL, nil)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
c.setBrowserHeaders(req, cookie)
|
||
|
||
start := time.Now()
|
||
reqBody := c.Trace.RequestBody(req)
|
||
resp, err := c.HTTPClient.Do(req)
|
||
if err != nil {
|
||
c.Trace.Record("生成CSRF", req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||
c.logNetworkError("生成CSRF", req, time.Since(start), err)
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||
c.logNetworkError("生成CSRF", req, time.Since(start), err)
|
||
return "", err
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
|
||
c.logNetworkHTTPError("生成CSRF", req, resp.StatusCode, time.Since(start), body)
|
||
return "", fmt.Errorf("CSRF HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 120))
|
||
}
|
||
|
||
var api types.APIResponse
|
||
if err := json.Unmarshal(body, &api); err != nil {
|
||
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "")
|
||
c.logNetworkParseError("生成CSRF", req, time.Since(start), body, err)
|
||
return "", fmt.Errorf("CSRF 响应解析失败: %w", err)
|
||
}
|
||
traceErr := ""
|
||
if api.Error != 0 {
|
||
traceErr = api.Msg
|
||
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, traceErr, api.Error, api.Msg)
|
||
c.logNetworkAPIResult("生成CSRF", req, time.Since(start), api.Error, api.Msg)
|
||
return "", fmt.Errorf("CSRF 接口失败: %s", api.Msg)
|
||
}
|
||
for _, ck := range resp.Cookies() {
|
||
if ck.Name == "cvl_csrf_token" && ck.Value != "" {
|
||
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", api.Error, api.Msg)
|
||
c.logNetworkSuccess("生成CSRF", req, time.Since(start), 0, "")
|
||
return ck.Value, nil
|
||
}
|
||
}
|
||
c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 cvl_csrf_token", api.Error, api.Msg)
|
||
logger.LogEvent("网络", "warn", "接口返回异常: %s 耗时=%s msg=响应没有 cvl_csrf_token",
|
||
c.networkDiagnostic("生成CSRF", req), utils.RoundDuration(time.Since(start)))
|
||
return "", errors.New("CSRF 响应没有 cvl_csrf_token")
|
||
}
|
||
|
||
// FetchCTN 获取 acf_ccn(电竞手册用 ctn 鉴权)。
|
||
func (c *DouyuClient) FetchCTN(cookie string) (string, error) {
|
||
if ctn := cookieValue(cookie, "acf_ccn"); ctn != "" {
|
||
return ctn, nil
|
||
}
|
||
if strings.TrimSpace(c.Activity.CTNURL) == "" {
|
||
logger.LogEvent("网络", "error", "CTN配置缺失: %s", c.networkDiagnostic("获取CTN", nil))
|
||
return "", errors.New("CTNURL 未配置")
|
||
}
|
||
|
||
req, err := http.NewRequest("GET", c.Activity.CTNURL, nil)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
c.setBrowserHeaders(req, cookie)
|
||
|
||
start := time.Now()
|
||
reqBody := c.Trace.RequestBody(req)
|
||
resp, err := c.HTTPClient.Do(req)
|
||
if err != nil {
|
||
c.Trace.Record("获取CTN", req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||
c.logNetworkError("获取CTN", req, time.Since(start), err)
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||
c.logNetworkError("获取CTN", req, time.Since(start), err)
|
||
return "", err
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
|
||
c.logNetworkHTTPError("获取CTN", req, resp.StatusCode, time.Since(start), body)
|
||
return "", fmt.Errorf("CTN HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 120))
|
||
}
|
||
|
||
for _, ck := range resp.Cookies() {
|
||
if ck.Name == "acf_ccn" && ck.Value != "" {
|
||
c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
|
||
c.logNetworkSuccess("获取CTN", req, time.Since(start), 0, "")
|
||
return ck.Value, nil
|
||
}
|
||
}
|
||
c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 acf_ccn", 0, "")
|
||
logger.LogEvent("网络", "warn", "接口返回异常: %s 耗时=%s msg=响应没有 acf_ccn 响应=%s",
|
||
c.networkDiagnostic("获取CTN", req), utils.RoundDuration(time.Since(start)), utils.TrimForLog(string(body), 120))
|
||
return "", errors.New("CTN 响应没有 acf_ccn")
|
||
}
|
||
|
||
func csrfFromResponse(resp *http.Response) string {
|
||
if resp == nil {
|
||
return ""
|
||
}
|
||
for _, ck := range resp.Cookies() {
|
||
if ck.Name == "cvl_csrf_token" && ck.Value != "" {
|
||
return ck.Value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// BatchExchangeLimit 请求兑换限制接口。电竞手册不需要此步骤,直接跳过返回默认限制。
|
||
func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID string, csrf *string) (int, error) {
|
||
if c.Activity.IsXiaodian {
|
||
return 1, nil
|
||
}
|
||
if c.Activity.UseCTN {
|
||
return 1, nil
|
||
}
|
||
currentCSRF := ""
|
||
if csrf != nil {
|
||
currentCSRF = *csrf
|
||
}
|
||
var result types.APIResponse
|
||
if err := c.postMultipart(c.Activity.BatchLimitURL, cookie, csrf, "批量限制", map[string]string{
|
||
"manualID": c.Activity.ManualID,
|
||
"commodityID": commodityID,
|
||
"rid": c.Activity.RoomID,
|
||
"csrfToken": currentCSRF,
|
||
}, &result); err != nil {
|
||
return 0, err
|
||
}
|
||
if result.Error != 0 {
|
||
return 0, fmt.Errorf("批量限制接口失败: %s", result.Msg)
|
||
}
|
||
var data struct {
|
||
Limit string `json:"limit"`
|
||
}
|
||
if err := json.Unmarshal(result.Data, &data); err != nil {
|
||
return 0, fmt.Errorf("批量限制数据解析失败: %w", err)
|
||
}
|
||
limit, err := strconv.Atoi(data.Limit)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("批量限制值无效: %q", data.Limit)
|
||
}
|
||
return limit, nil
|
||
}
|
||
|
||
// PreExchange 按抓包流程执行预兑换检查。电竞手册不需要此步骤,直接跳过。
|
||
func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) error {
|
||
if c.Activity.IsXiaodian {
|
||
return nil
|
||
}
|
||
if c.Activity.UseCTN {
|
||
return nil
|
||
}
|
||
currentCSRF := ""
|
||
if csrf != nil {
|
||
currentCSRF = *csrf
|
||
}
|
||
var result types.APIResponse
|
||
if err := c.postMultipart(c.Activity.PreExchangeURL, cookie, csrf, "预兑换", map[string]string{
|
||
"manualID": c.Activity.ManualID,
|
||
"rid": c.Activity.RoomID,
|
||
"commodityID": commodityID,
|
||
"num": "1",
|
||
"csrfToken": currentCSRF,
|
||
}, &result); err != nil {
|
||
return err
|
||
}
|
||
if result.Error != 0 {
|
||
return fmt.Errorf("预兑换失败: %s", result.Msg)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Exchange 正式兑换一个商品。
|
||
func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string, error) {
|
||
if c.Activity.IsXiaodian {
|
||
return c.xiaodianExchange(cookie, types.Product{CommodityID: commodityID})
|
||
}
|
||
var result types.APIResponse
|
||
if c.Activity.UseCTN {
|
||
// 电竞手册:用 ctn(acf_ccn) 鉴权,multipart 表单(与浏览器抓包一致)
|
||
ctn := ""
|
||
if csrf != nil {
|
||
parts := strings.SplitN(*csrf, "\x00", 2)
|
||
if len(parts) > 1 {
|
||
ctn = parts[1]
|
||
}
|
||
}
|
||
if ctn == "" {
|
||
// 缓存中没有 ctn,现场获取
|
||
ctnVal, err := c.FetchCTN(cookie)
|
||
if err != nil {
|
||
return "", fmt.Errorf("获取CTN失败: %w", err)
|
||
}
|
||
ctn = ctnVal
|
||
if csrf != nil {
|
||
*csrf = combineAuthToken(*csrf, ctn)
|
||
}
|
||
}
|
||
if err := c.postMultipart(c.Activity.ExchangeURL, cookie, csrf, "正式兑换", map[string]string{
|
||
"manualID": c.Activity.ManualID,
|
||
"commodityID": commodityID,
|
||
"num": "1",
|
||
"rid": c.Activity.RoomID,
|
||
"ctn": ctn,
|
||
}, &result); err != nil {
|
||
return "", err
|
||
}
|
||
} else {
|
||
// 精英手册:用 csrfToken 鉴权,multipart 表单
|
||
currentCSRF := ""
|
||
if csrf != nil {
|
||
currentCSRF = *csrf
|
||
}
|
||
if err := c.postMultipart(c.Activity.ExchangeURL, cookie, csrf, "正式兑换", map[string]string{
|
||
"manualID": c.Activity.ManualID,
|
||
"commodityID": commodityID,
|
||
"num": "1",
|
||
"rid": c.Activity.RoomID,
|
||
"csrfToken": currentCSRF,
|
||
}, &result); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
if result.Error != 0 {
|
||
if strings.TrimSpace(result.Msg) == "" {
|
||
return "", errors.New("兑换失败,接口未返回原因")
|
||
}
|
||
return "", fmt.Errorf("兑换失败: %s", result.Msg)
|
||
}
|
||
var data struct {
|
||
ExchangeID int `json:"exchangeId"`
|
||
ExchangeNum string `json:"exchangeNum"`
|
||
CommodityTyp int `json:"commodityType"`
|
||
}
|
||
if err := json.Unmarshal(result.Data, &data); err != nil {
|
||
return "", fmt.Errorf("兑换数据解析失败: %w", err)
|
||
}
|
||
return fmt.Sprintf("exchangeId=%d,num=%s", data.ExchangeID, data.ExchangeNum), nil
|
||
}
|
||
|
||
// ExchangeProduct 正式兑换一个商品。和平小店需要商品支付类型/价格,因此应优先走这个入口。
|
||
func (c *DouyuClient) ExchangeProduct(cookie string, product types.Product, csrf *string) (string, error) {
|
||
if c.Activity.IsXiaodian {
|
||
return c.xiaodianExchange(cookie, product)
|
||
}
|
||
return c.Exchange(cookie, product.CommodityID, csrf)
|
||
}
|
||
|
||
func (c *DouyuClient) xiaodianExchange(cookie string, product types.Product) (string, error) {
|
||
if strings.TrimSpace(cookie) == "" {
|
||
return "", errors.New("Cookie 为空")
|
||
}
|
||
if strings.TrimSpace(product.CommodityID) == "" {
|
||
return "", errors.New("商品 ID 为空")
|
||
}
|
||
embedQuery, err := c.xiaodianEmbedQueryFresh(cookie)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
payType := product.PayType
|
||
if payType <= 0 {
|
||
payType = 1
|
||
}
|
||
payPrice := product.Score
|
||
if payPrice < 0 {
|
||
payPrice = 0
|
||
}
|
||
|
||
endpoint, err := url.Parse(c.xiaodianLiveBuyURL())
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
query := endpoint.Query()
|
||
query.Set("_service", "buy.plug.svr.web")
|
||
query.Set("_plug_id", "7200")
|
||
query.Set("_jsvar", "buyInfo")
|
||
query.Set("acctype", "livelink")
|
||
query.Set("_app_id", "2123")
|
||
actionID := strings.TrimSpace(product.ActionID)
|
||
if actionID == "" {
|
||
actionID = c.Activity.ActID
|
||
}
|
||
query.Set("iActionId", actionID)
|
||
query.Set("propid", product.CommodityID)
|
||
query.Set("paytype", strconv.Itoa(payType))
|
||
query.Set("source", c.Activity.RoomID)
|
||
query.Set("sAnchorId", c.Activity.RoomID)
|
||
query.Set("attach", `{"actId":"`+embedQuery.Get("actId")+`"}`)
|
||
query.Set("apptype", "3")
|
||
query.Set("_biz_code", "cjm")
|
||
query.Set("_act_id", c.Activity.ActID)
|
||
query.Set("authType", "delegate")
|
||
query.Set("sLiveUserInfo", `{"sAnchorId":"`+c.Activity.RoomID+`","sVideoId":0}`)
|
||
for _, key := range []string{"actId", "appId", "gameId", "livePlatId", "code", "timestamp", "v", "sig"} {
|
||
query.Set(key, embedQuery.Get(key))
|
||
}
|
||
appext, err := json.Marshal(map[string]any{
|
||
"user_price": payPrice,
|
||
"sGoodsPic": product.Pic,
|
||
"paytype": payType,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
query.Set("appext", string(appext))
|
||
query.Set("_sid", "8")
|
||
query.Set("set", "8")
|
||
endpoint.RawQuery = query.Encode()
|
||
|
||
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
c.setXiaodianHeaders(req)
|
||
body, err := c.doText(req, "和平小店正式兑换", true)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
var result xiaodianBuyInfo
|
||
if err := parseXiaodianVariable(body, "buyInfo", &result); err != nil {
|
||
return "", fmt.Errorf("和平小店兑换响应解析失败: %w", err)
|
||
}
|
||
if result.Ret != "0" {
|
||
if strings.TrimSpace(result.Msg) == "" {
|
||
return "", fmt.Errorf("和平小店兑换失败: ret=%s", result.Ret)
|
||
}
|
||
return "", fmt.Errorf("和平小店兑换失败: %s", result.Msg)
|
||
}
|
||
serial := result.Serial
|
||
if serial == "" {
|
||
serial = result.SerialNum
|
||
}
|
||
if serial == "" {
|
||
return "paytype=" + strconv.Itoa(payType), nil
|
||
}
|
||
return fmt.Sprintf("serial=%s,paytype=%d", serial, payType), nil
|
||
}
|
||
|
||
func (c *DouyuClient) xiaodianLiveBuyURL() string {
|
||
roleURL := strings.TrimSpace(c.Activity.RoleURL)
|
||
if roleURL == "" {
|
||
return "https://apps.game.qq.com/daoju/igw/livebuy/"
|
||
}
|
||
endpoint, err := url.Parse(roleURL)
|
||
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
|
||
return "https://apps.game.qq.com/daoju/igw/livebuy/"
|
||
}
|
||
endpoint.Path = "/daoju/igw/livebuy/"
|
||
endpoint.RawQuery = ""
|
||
endpoint.Fragment = ""
|
||
return endpoint.String()
|
||
}
|
||
|
||
// RoleName 查询绑定角色名。
|
||
func (c *DouyuClient) RoleName(cookie string) (string, error) {
|
||
var result struct {
|
||
types.APIResponse
|
||
Data struct {
|
||
GameRole struct {
|
||
RoleName string `json:"roleName"`
|
||
} `json:"gameRole"`
|
||
} `json:"data"`
|
||
}
|
||
if err := c.getJSON(c.Activity.BindInfoURL, cookie, "角色绑定", &result); err != nil {
|
||
return "", err
|
||
}
|
||
if result.Error != 0 {
|
||
return "", fmt.Errorf("角色接口失败: %s", result.Msg)
|
||
}
|
||
return result.Data.GameRole.RoleName, nil
|
||
}
|
||
|
||
// ExchangeListResponse 兑换列表响应。
|
||
type ExchangeListResponse struct {
|
||
Error int `json:"error"`
|
||
Msg string `json:"msg"`
|
||
Data struct {
|
||
List []types.ExchangeRecord `json:"list"`
|
||
Count int `json:"count"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
// APIStatus 返回斗鱼 API 的 error/msg 状态,实现 apiResponder 接口。
|
||
func (r ExchangeListResponse) APIStatus() (int, string, bool) {
|
||
return r.Error, r.Msg, true
|
||
}
|
||
|
||
// ExchangeList 查询兑换记录列表。
|
||
func (c *DouyuClient) ExchangeList(cookie string) ([]types.ExchangeRecord, error) {
|
||
if c.Activity.IsXiaodian {
|
||
xiaodianRecords, err := c.XiaodianExchangeList(cookie)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
records := make([]types.ExchangeRecord, 0, len(xiaodianRecords))
|
||
for _, record := range xiaodianRecords {
|
||
records = append(records, types.ExchangeRecord{
|
||
CommodityID: record.CommodityID, CommodityName: record.CommodityName,
|
||
ShowName: record.CommodityName, ExchangeNum: record.Quantity,
|
||
Time: record.BuyTime,
|
||
})
|
||
}
|
||
return records, nil
|
||
}
|
||
var result ExchangeListResponse
|
||
if err := c.getJSON(c.Activity.ExchangeListURL, cookie, "兑换记录", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
if result.Error != 0 {
|
||
return nil, fmt.Errorf("兑换记录查询失败: %s", result.Msg)
|
||
}
|
||
return result.Data.List, nil
|
||
}
|
||
|
||
func (c *DouyuClient) getJSON(url, cookie, label string, out any) error {
|
||
req, err := http.NewRequest("GET", url, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
c.setBrowserHeaders(req, cookie)
|
||
_, err = c.doJSON(req, out, label)
|
||
return err
|
||
}
|
||
|
||
func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label string, fields map[string]string, out any) error {
|
||
var body strings.Builder
|
||
writer := multipart.NewWriter(&body)
|
||
for key, value := range fields {
|
||
if err := writer.WriteField(key, value); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := writer.Close(); err != nil {
|
||
return err
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", url, strings.NewReader(body.String()))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req = withFormFieldDiagnostics(req, fields)
|
||
// 处理组合 token:csrf\x00ctn(电竞手册用 ctn 鉴权时)
|
||
// 拆分后按需把 cvl_csrf_token 和 acf_ccn 合并到 Cookie 头
|
||
currentCSRF := ""
|
||
ctnVal := ""
|
||
if csrf != nil {
|
||
parts := strings.SplitN(*csrf, "\x00", 2)
|
||
currentCSRF = parts[0]
|
||
if len(parts) > 1 {
|
||
ctnVal = parts[1]
|
||
}
|
||
}
|
||
if ctnVal == "" && c.Activity.UseCTN {
|
||
ctnVal = fields["ctn"]
|
||
}
|
||
mergedCookie := cookie
|
||
if currentCSRF != "" {
|
||
mergedCookie = utils.MergeCookie(mergedCookie, "cvl_csrf_token", currentCSRF)
|
||
}
|
||
if ctnVal != "" {
|
||
mergedCookie = utils.MergeCookie(mergedCookie, "acf_ccn", ctnVal)
|
||
}
|
||
c.setBrowserHeaders(req, mergedCookie)
|
||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||
resp, err := c.doJSON(req, out, label)
|
||
if resp != nil {
|
||
if nextCSRF := csrfFromResponse(resp); nextCSRF != "" && csrf != nil {
|
||
if nextCSRF != *csrf {
|
||
logger.LogEvent("网络", "info", "响应刷新CSRF已同步: %s %s %s", label, req.Method, requestPath(req))
|
||
}
|
||
if ctnVal != "" {
|
||
*csrf = nextCSRF + "\x00" + ctnVal
|
||
} else {
|
||
*csrf = nextCSRF
|
||
}
|
||
}
|
||
}
|
||
return err
|
||
}
|
||
|
||
func (c *DouyuClient) doJSON(req *http.Request, out any, label string) (*http.Response, error) {
|
||
start := time.Now()
|
||
reqBody := c.Trace.RequestBody(req)
|
||
resp, err := c.HTTPClient.Do(req)
|
||
if err != nil {
|
||
c.Trace.Record(label, req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||
c.logNetworkError(label, req, time.Since(start), err)
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||
c.logNetworkError(label, req, time.Since(start), err)
|
||
return resp, err
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
|
||
c.logNetworkHTTPError(label, req, resp.StatusCode, time.Since(start), body)
|
||
return resp, fmt.Errorf("HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 160))
|
||
}
|
||
if err := json.Unmarshal(body, out); err != nil {
|
||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "")
|
||
c.logNetworkParseError(label, req, time.Since(start), body, err)
|
||
return resp, fmt.Errorf("JSON 解析失败: %w, body=%s", err, utils.TrimForLog(string(body), 160))
|
||
}
|
||
if apiError, apiMsg, ok := extractAPIStatus(out); ok && apiError != 0 {
|
||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, apiMsg, apiError, apiMsg)
|
||
c.logNetworkAPIResult(label, req, time.Since(start), apiError, apiMsg)
|
||
} else if shouldLogNetworkSuccess(label) {
|
||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
|
||
c.logNetworkSuccess(label, req, time.Since(start), 0, "")
|
||
} else {
|
||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
// doText 执行返回 JavaScript 变量的接口请求,并复用统一网络诊断。
|
||
func (c *DouyuClient) doText(req *http.Request, label string, redactBody bool) ([]byte, error) {
|
||
start := time.Now()
|
||
reqBody := c.Trace.RequestBody(req)
|
||
resp, err := c.HTTPClient.Do(req)
|
||
if err != nil {
|
||
c.Trace.Record(label, req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||
c.logNetworkError(label, req, time.Since(start), err)
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||
c.logNetworkError(label, req, time.Since(start), err)
|
||
return nil, err
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
traceBody := body
|
||
if redactBody {
|
||
traceBody = []byte("<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")
|
||
}
|