706 lines
22 KiB
Go
706 lines
22 KiB
Go
package client
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"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() ([]types.Product, error) {
|
||
var result struct {
|
||
types.APIResponse
|
||
Data struct {
|
||
List []types.Product `json:"list"`
|
||
} `json:"data"`
|
||
}
|
||
if err := c.getJSON(c.Activity.StoreHomeURL, "", "商品列表", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
if result.Error != 0 {
|
||
return nil, fmt.Errorf("商品接口失败: %s", result.Msg)
|
||
}
|
||
return result.Data.List, nil
|
||
}
|
||
|
||
// 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 调用 generateCsrf,并从 Set-Cookie 中取 cvl_csrf_token。
|
||
func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
|
||
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, "")
|
||
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, "")
|
||
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, "")
|
||
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, "")
|
||
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)
|
||
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)
|
||
logNetworkSuccess("生成CSRF", req, time.Since(start), 0, "")
|
||
// 电竞手册还需获取 acf_ccn 作为 ctn,组合返回 csrf\x00ctn
|
||
if c.Activity.UseCTN {
|
||
ctn, err := c.FetchCTN(utils.MergeCookie(cookie, "cvl_csrf_token", ck.Value))
|
||
if err != nil {
|
||
return "", fmt.Errorf("获取CTN失败: %w", err)
|
||
}
|
||
return ck.Value + "\x00" + ctn, nil
|
||
}
|
||
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", "接口返回异常: 生成CSRF %s %s 耗时=%s msg=响应没有 cvl_csrf_token",
|
||
req.Method, requestPath(req), utils.RoundDuration(time.Since(start)))
|
||
return "", errors.New("CSRF 响应没有 cvl_csrf_token")
|
||
}
|
||
|
||
// FetchCTN 调用 csrfNlApi/getCsrfCookie 获取 acf_ccn(电竞手册用 ctn 鉴权)。
|
||
func (c *DouyuClient) FetchCTN(cookie string) (string, error) {
|
||
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, "")
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
io.ReadAll(resp.Body) // 消费 body
|
||
|
||
for _, ck := range resp.Cookies() {
|
||
if ck.Name == "acf_ccn" && ck.Value != "" {
|
||
c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, "", 0, "")
|
||
return ck.Value, nil
|
||
}
|
||
}
|
||
c.Trace.RecordResponse("获取CTN", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, "响应没有 acf_ccn", 0, "")
|
||
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 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) {
|
||
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
|
||
}
|
||
// 处理组合 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]
|
||
}
|
||
}
|
||
mergedCookie := utils.MergeCookie(cookie, "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, "")
|
||
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, "")
|
||
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, "")
|
||
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, "")
|
||
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)
|
||
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, "")
|
||
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
|
||
}
|
||
|
||
func shouldLogNetworkSuccess(label string) bool {
|
||
return label != "服务时间"
|
||
}
|
||
|
||
func 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 耗时=%s%s", label, req.Method, requestPath(req), utils.RoundDuration(cost), extra)
|
||
}
|
||
|
||
func logNetworkAPIResult(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) {
|
||
logger.LogEvent("网络", "warn", "接口返回异常: %s %s %s 耗时=%s apiError=%d msg=%s",
|
||
label, req.Method, requestPath(req), utils.RoundDuration(cost), apiError, utils.TrimForLog(apiMsg, 120))
|
||
}
|
||
|
||
func logNetworkHTTPError(label string, req *http.Request, status int, cost time.Duration, body []byte) {
|
||
logger.LogEvent("网络", "error", "HTTP异常: %s %s %s 状态=%d 耗时=%s 响应=%s",
|
||
label, req.Method, requestPath(req), status, utils.RoundDuration(cost), utils.TrimForLog(string(body), 160))
|
||
}
|
||
|
||
func logNetworkParseError(label string, req *http.Request, cost time.Duration, body []byte, err error) {
|
||
logger.LogEvent("网络", "error", "响应解析失败: %s %s %s 耗时=%s 错误=%v 响应=%s",
|
||
label, req.Method, requestPath(req), utils.RoundDuration(cost), err, utils.TrimForLog(string(body), 160))
|
||
}
|
||
|
||
func logNetworkError(label string, req *http.Request, cost time.Duration, err error) {
|
||
logger.LogEvent("网络", "error", "请求失败: %s %s %s 耗时=%s 错误=%v",
|
||
label, req.Method, requestPath(req), utils.RoundDuration(cost), err)
|
||
}
|
||
|
||
func requestPath(req *http.Request) string {
|
||
if req == nil || req.URL == nil {
|
||
return "-"
|
||
}
|
||
return req.URL.Path
|
||
}
|
||
|
||
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")
|
||
}
|