1408 lines
45 KiB
Go
1408 lines
45 KiB
Go
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")
|
||
}
|