完善电竞手册鉴权与诊断日志
This commit is contained in:
@@ -7,9 +7,9 @@ import "douyu_go/internal/constants"
|
||||
// 不同活动(精英手册 / 电竞手册)的 manualID、roomID、actAlias 以及接口路径段都不同,
|
||||
// 集中在这里可以让调度核心逻辑保持活动无关,新增活动只需在 registry 中加一项。
|
||||
//
|
||||
// 注意:peacematch 的 exchange / preExchange / batchExchangeLimit / storedetail 端点
|
||||
// 没有出现在抓包里,这里按 peace 的 1:1 路径映射推断(storehome、exchangeList 已验证
|
||||
// 确实落在 peacematch 路径下)。若实际抢购报错,优先核对这几个端点。
|
||||
// 注意:peacematch 的 storehome / storedetail / exchange / exchangeList / score / bindInfo
|
||||
// 已在 7.29「电竞手册-兑换并查询信息与积分.har」中验证;batchExchangeLimit / preExchange
|
||||
// 在 UseCTN=true 时运行时跳过,字段仅保留为旧流程兼容,不视为已验证端点。
|
||||
type Activity struct {
|
||||
ID string // 注册表 key,例如 "peace" / "peacematch"
|
||||
DisplayName string // 前端展示名
|
||||
@@ -34,10 +34,9 @@ type Activity struct {
|
||||
ScoreMethod string // "POST" 或 "GET"
|
||||
|
||||
// UseCTN 为 true 时(电竞手册),兑换接口用 ctn(acf_ccn) 鉴权而非 csrfToken:
|
||||
// - 表单发 ctn 字段(值=acf_ccn),不发 csrfToken
|
||||
// - cvl_csrf_token 只放 Cookie 头
|
||||
// - multipart 表单发 ctn 字段(值=acf_ccn),不发 csrfToken
|
||||
// - Cookie 已有 acf_ccn 时直接复用,缺失时再请求 CTNURL
|
||||
// - 跳过 batchExchangeLimit / preExchange(Python 实现直接调 exchange)
|
||||
// - Content-Type 用 application/x-www-form-urlencoded
|
||||
UseCTN bool
|
||||
CTNURL string // 获取 acf_ccn 的接口(UseCTN=true 时必填)
|
||||
}
|
||||
@@ -66,7 +65,7 @@ var peace = &Activity{
|
||||
}
|
||||
|
||||
// peacematch 电竞手册(新活动,ActAlias=20260226ZMGDG)。
|
||||
// 数据来源:/Users/yml/Desktop/抓包/douyu/7.29/电竞手册-基础信息.har 及 7.28 系列抓包。
|
||||
// 数据来源:/Users/yml/Desktop/抓包/douyu/7.29/电竞手册-兑换并查询信息与积分.har。
|
||||
var peacematch = &Activity{
|
||||
ID: "peacematch",
|
||||
DisplayName: "电竞手册",
|
||||
@@ -76,9 +75,9 @@ var peacematch = &Activity{
|
||||
BaseURL: douyuBase,
|
||||
ShopReferer: douyuBase + "/pages/live-peace-pel/web/shop?roomId=9263298&isAnchorSide=0",
|
||||
StoreHomeURL: douyuBase + "/wgapi/ordnc/activity/peacematch/storehome?manualID=1W0yGkOdDbAXb&rid=9263298",
|
||||
// generateCsrf 为嘉年华平台级接口,抓包未单独出现,沿用 peace 同款端点。
|
||||
// generateCsrf 为精英手册使用的嘉年华平台级接口;UseCTN=true 时不会调用。
|
||||
CSRFURL: douyuBase + "/japi/carnival/nc/common/generateCsrf",
|
||||
// 以下 4 个兑换类端点未在抓包中捕获,按 peace 的 1:1 路径映射推断,待实跑验证。
|
||||
// batchExchangeLimit / preExchange 在 UseCTN=true 时跳过;exchange 已由 7.29 HAR 验证。
|
||||
BatchLimitURL: douyuBase + "/wgapi/ordnc/activity/peacematch/batchExchangeLimit",
|
||||
PreExchangeURL: douyuBase + "/wgapi/ordnc/activity/peacematch/preExchange",
|
||||
ExchangeURL: douyuBase + "/wgapi/ordnc/activity/peacematch/exchange",
|
||||
@@ -87,7 +86,7 @@ var peacematch = &Activity{
|
||||
ScoreURL: douyuBase + "/wgapi/ordnc/activity/peacematch/score/balance",
|
||||
ScoreMethod: "GET",
|
||||
UseCTN: true,
|
||||
CTNURL: douyuBase + "/curl/csrfNlApi/getCsrfCookie",
|
||||
CTNURL: douyuBase + "/wgapi/livenc/liveweb/csrfApi/getCsrfCookie",
|
||||
}
|
||||
|
||||
var registry = map[string]*Activity{
|
||||
|
||||
+197
-43
@@ -1,12 +1,14 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -258,8 +260,16 @@ func (c *DouyuClient) Score(cookie string) (int, error) {
|
||||
return result.Data.Balance, nil
|
||||
}
|
||||
|
||||
// GenerateCSRF 调用 generateCsrf,并从 Set-Cookie 中取 cvl_csrf_token。
|
||||
// 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
|
||||
@@ -271,7 +281,7 @@ func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
|
||||
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)
|
||||
c.logNetworkError("生成CSRF", req, time.Since(start), err)
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -279,51 +289,51 @@ func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
|
||||
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)
|
||||
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, "")
|
||||
logNetworkHTTPError("生成CSRF", req, resp.StatusCode, time.Since(start), body)
|
||||
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, "")
|
||||
logNetworkParseError("生成CSRF", req, time.Since(start), body, err)
|
||||
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)
|
||||
logNetworkAPIResult("生成CSRF", req, time.Since(start), 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)
|
||||
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
|
||||
}
|
||||
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", "接口返回异常: 生成CSRF %s %s 耗时=%s msg=响应没有 cvl_csrf_token",
|
||||
req.Method, requestPath(req), utils.RoundDuration(time.Since(start)))
|
||||
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 调用 csrfNlApi/getCsrfCookie 获取 acf_ccn(电竞手册用 ctn 鉴权)。
|
||||
// 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
|
||||
@@ -335,18 +345,32 @@ func (c *DouyuClient) FetchCTN(cookie string) (string, error) {
|
||||
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()
|
||||
io.ReadAll(resp.Body) // 消费 body
|
||||
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, nil, "", 0, "")
|
||||
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, nil, "响应没有 acf_ccn", 0, "")
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -446,6 +470,9 @@ func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string
|
||||
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,
|
||||
@@ -553,8 +580,9 @@ func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label stri
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = withFormFieldDiagnostics(req, fields)
|
||||
// 处理组合 token:csrf\x00ctn(电竞手册用 ctn 鉴权时)
|
||||
// 拆分后 cvl_csrf_token 和 acf_ccn 都合并到 Cookie 头
|
||||
// 拆分后按需把 cvl_csrf_token 和 acf_ccn 合并到 Cookie 头
|
||||
currentCSRF := ""
|
||||
ctnVal := ""
|
||||
if csrf != nil {
|
||||
@@ -564,7 +592,13 @@ func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label stri
|
||||
ctnVal = parts[1]
|
||||
}
|
||||
}
|
||||
mergedCookie := utils.MergeCookie(cookie, "cvl_csrf_token", currentCSRF)
|
||||
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)
|
||||
}
|
||||
@@ -592,7 +626,7 @@ func (c *DouyuClient) doJSON(req *http.Request, out any, label string) (*http.Re
|
||||
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)
|
||||
c.logNetworkError(label, req, time.Since(start), err)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -600,25 +634,25 @@ func (c *DouyuClient) doJSON(req *http.Request, out any, label string) (*http.Re
|
||||
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)
|
||||
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, "")
|
||||
logNetworkHTTPError(label, req, resp.StatusCode, time.Since(start), body)
|
||||
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, "")
|
||||
logNetworkParseError(label, req, time.Since(start), body, err)
|
||||
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)
|
||||
logNetworkAPIResult(label, req, time.Since(start), 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, "")
|
||||
logNetworkSuccess(label, req, time.Since(start), 0, "")
|
||||
c.logNetworkSuccess(label, req, time.Since(start), 0, "")
|
||||
} else {
|
||||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
|
||||
}
|
||||
@@ -629,7 +663,7 @@ func shouldLogNetworkSuccess(label string) bool {
|
||||
return label != "服务时间"
|
||||
}
|
||||
|
||||
func logNetworkSuccess(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) {
|
||||
func (c *DouyuClient) logNetworkSuccess(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) {
|
||||
if !shouldLogNetworkSuccess(label) {
|
||||
return
|
||||
}
|
||||
@@ -637,27 +671,116 @@ func logNetworkSuccess(label string, req *http.Request, cost time.Duration, apiE
|
||||
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)
|
||||
logger.LogEvent("网络", "info", "请求完成: %s 耗时=%s%s", c.networkDiagnostic(label, 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 (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 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 (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 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 (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 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 (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 {
|
||||
@@ -667,6 +790,37 @@ func requestPath(req *http.Request) string {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"douyu_go/internal/activity"
|
||||
"douyu_go/internal/trace"
|
||||
"douyu_go/internal/types"
|
||||
)
|
||||
|
||||
func TestGenerateCSRFUseCTNReusesCookie(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called.Store(true)
|
||||
http.Error(w, "should not call ctn endpoint", http.StatusTeapot)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cli := testCTNClient(t, server.URL)
|
||||
token, err := cli.GenerateCSRF("other=1; acf_ccn=ctn-from-cookie")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSRF() error = %v", err)
|
||||
}
|
||||
if token != "\x00ctn-from-cookie" {
|
||||
t.Fatalf("GenerateCSRF() token = %q, want ctn token", token)
|
||||
}
|
||||
if called.Load() {
|
||||
t.Fatal("GenerateCSRF() called CTN endpoint despite acf_ccn in Cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostMultipartUseCTNPreservesCSRFCookie(t *testing.T) {
|
||||
var gotCookie string
|
||||
var gotContentType string
|
||||
gotFields := map[string]string{}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotCookie = r.Header.Get("Cookie")
|
||||
gotContentType = r.Header.Get("Content-Type")
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Errorf("ParseMultipartForm() error = %v", err)
|
||||
http.Error(w, "bad multipart", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for key, values := range r.MultipartForm.Value {
|
||||
if len(values) > 0 {
|
||||
gotFields[key] = values[0]
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"error":0,"msg":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cli := testCTNClient(t, server.URL)
|
||||
token := "\x00ctn-new"
|
||||
var result types.APIResponse
|
||||
err := cli.postMultipart(server.URL, "cvl_csrf_token=old; other=1", &token, "正式兑换", map[string]string{
|
||||
"manualID": "manual-1",
|
||||
"commodityID": "goods-1",
|
||||
"num": "1",
|
||||
"rid": "9263298",
|
||||
"ctn": "ctn-new",
|
||||
}, &result)
|
||||
if err != nil {
|
||||
t.Fatalf("postMultipart() error = %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(gotContentType, "multipart/form-data;") {
|
||||
t.Fatalf("Content-Type = %q, want multipart/form-data", gotContentType)
|
||||
}
|
||||
if !strings.Contains(gotCookie, "cvl_csrf_token=old") {
|
||||
t.Fatalf("Cookie = %q, want preserve old cvl_csrf_token", gotCookie)
|
||||
}
|
||||
if !strings.Contains(gotCookie, "acf_ccn=ctn-new") {
|
||||
t.Fatalf("Cookie = %q, want merged acf_ccn", gotCookie)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"manualID": "manual-1",
|
||||
"commodityID": "goods-1",
|
||||
"num": "1",
|
||||
"rid": "9263298",
|
||||
"ctn": "ctn-new",
|
||||
} {
|
||||
if gotFields[key] != want {
|
||||
t.Fatalf("field %s = %q, want %q", key, gotFields[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkDiagnosticIncludesActivityAndFieldNamesOnly(t *testing.T) {
|
||||
cli := testCTNClient(t, "https://www.douyu.com/wgapi/livenc/liveweb/csrfApi/getCsrfCookie")
|
||||
req, err := http.NewRequest("POST", "https://www.douyu.com/wgapi/ordnc/activity/peacematch/exchange", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest() error = %v", err)
|
||||
}
|
||||
req = withFormFieldDiagnostics(req, map[string]string{
|
||||
"manualID": "manual-secret",
|
||||
"commodityID": "goods-secret",
|
||||
"ctn": "ctn-secret",
|
||||
})
|
||||
|
||||
got := cli.networkDiagnostic("正式兑换", req)
|
||||
for _, want := range []string{
|
||||
"活动=电竞手册(peacematch)",
|
||||
"UseCTN=true",
|
||||
"label=正式兑换",
|
||||
"method=POST",
|
||||
"path=/wgapi/ordnc/activity/peacematch/exchange",
|
||||
"manualID=1W0yGkOdDbAXb",
|
||||
"rid=9263298",
|
||||
"actAlias=20260226ZMGDG",
|
||||
"fields=commodityID,ctn,manualID",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("networkDiagnostic() = %q, want contains %q", got, want)
|
||||
}
|
||||
}
|
||||
for _, leaked := range []string{"manual-secret", "goods-secret", "ctn-secret"} {
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("networkDiagnostic() leaked field value %q in %q", leaked, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testCTNClient(t *testing.T, ctnURL string) *DouyuClient {
|
||||
t.Helper()
|
||||
act := &activity.Activity{
|
||||
ID: "peacematch",
|
||||
DisplayName: "电竞手册",
|
||||
ManualID: "1W0yGkOdDbAXb",
|
||||
RoomID: "9263298",
|
||||
ActAlias: "20260226ZMGDG",
|
||||
BaseURL: "https://www.douyu.com",
|
||||
ShopReferer: "https://www.douyu.com/pages/live-peace-pel/web/shop?roomId=9263298&isAnchorSide=0",
|
||||
UseCTN: true,
|
||||
CTNURL: ctnURL,
|
||||
}
|
||||
cli := New(act, "", &trace.Recorder{})
|
||||
cli.HTTPClient = http.DefaultClient
|
||||
return cli
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"douyu_go/internal/utils"
|
||||
)
|
||||
|
||||
// runAttempt 标准模式兑换:完整执行 CSRF→BatchLimit→PreExchange→Exchange 链路。
|
||||
// runAttempt 标准模式兑换:完整执行鉴权 token→BatchLimit→PreExchange→Exchange 链路。
|
||||
func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResult {
|
||||
cli := s.clientSnapshot()
|
||||
result := types.ExchangeResult{
|
||||
@@ -24,16 +24,16 @@ func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResu
|
||||
if warmup, ok := s.warmupFor(attempt); ok {
|
||||
csrf = warmup.CSRF
|
||||
roleName = warmup.RoleName
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "使用预热CSRF: 账号=%s 商品=%s 预热距今=%s", attempt.Account.Name, attempt.Product.CommodityName, utils.RoundDuration(time.Since(warmup.CreatedAt)))
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "使用预热鉴权token: 账号=%s 商品=%s 预热距今=%s", attempt.Account.Name, attempt.Product.CommodityName, utils.RoundDuration(time.Since(warmup.CreatedAt)))
|
||||
}
|
||||
if csrf == "" {
|
||||
var err error
|
||||
csrf, err = cli.GenerateCSRF(attempt.Account.Cookie)
|
||||
if err != nil {
|
||||
result.Message = "失败|生成CSRF失败: " + err.Error()
|
||||
result.Message = "失败|生成鉴权token失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "CSRF已生成,本次兑换链路复用同一个 token: 账号=%s 商品=%s", attempt.Account.Name, attempt.Product.CommodityName)
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "鉴权token已生成,本次兑换链路复用同一个 token: 账号=%s 商品=%s", attempt.Account.Name, attempt.Product.CommodityName)
|
||||
}
|
||||
|
||||
limit, err := cli.BatchExchangeLimit(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
|
||||
@@ -87,7 +87,7 @@ func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResu
|
||||
result.Message = "成功|" + exchangeInfo
|
||||
}
|
||||
|
||||
// 缓存 CSRF 和角色名(激进模式使用)
|
||||
// 缓存鉴权 token 和角色名(激进模式使用)
|
||||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||||
CSRF: csrf,
|
||||
RoleName: roleName,
|
||||
@@ -98,7 +98,7 @@ func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResu
|
||||
return result
|
||||
}
|
||||
|
||||
// runAttemptFast 激进模式的兑换逻辑,复用缓存的 CSRF,跳过 BatchExchangeLimit 和 PreExchange。
|
||||
// runAttemptFast 激进模式的兑换逻辑,复用缓存的鉴权 token,跳过 BatchExchangeLimit 和 PreExchange。
|
||||
func (s *Scheduler) runAttemptFast(attempt types.ExchangeAttempt) types.ExchangeResult {
|
||||
cli := s.clientSnapshot()
|
||||
result := types.ExchangeResult{
|
||||
@@ -109,7 +109,7 @@ func (s *Scheduler) runAttemptFast(attempt types.ExchangeAttempt) types.Exchange
|
||||
|
||||
cache := s.getAttemptCache(attempt.Account.Name)
|
||||
if cache == nil || cache.CSRF == "" || time.Since(cache.LastCheck) >= qualifiedMaxAge {
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "激进模式执行资格预检: 账号=%s 原因=无缓存CSRF", attempt.Account.Name)
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "激进模式执行资格预检: 账号=%s 原因=无缓存鉴权token", attempt.Account.Name)
|
||||
return s.runAttempt(attempt)
|
||||
}
|
||||
if !cache.Qualified {
|
||||
|
||||
@@ -64,7 +64,7 @@ func (s *Scheduler) warmupAttempts(attempts []types.ExchangeAttempt) {
|
||||
start := time.Now()
|
||||
csrf, err := cli.GenerateCSRF(attempt.Account.Cookie)
|
||||
if err != nil {
|
||||
logger.LogAccountEvent("预热", "warn", attempt.Account.Name, "CSRF预热失败: 账号=%s 商品=%s err=%v", attempt.Account.Name, attempt.Product.CommodityName, err)
|
||||
logger.LogAccountEvent("预热", "warn", attempt.Account.Name, "鉴权token预热失败: 账号=%s 商品=%s err=%v", attempt.Account.Name, attempt.Product.CommodityName, err)
|
||||
return
|
||||
}
|
||||
roleName, err := cli.RoleName(attempt.Account.Cookie)
|
||||
|
||||
Reference in New Issue
Block a user