完善电竞手册鉴权与诊断日志

This commit is contained in:
yml2213
2026-07-29 14:24:26 +08:00
parent f64838f8db
commit c052a589d1
5 changed files with 359 additions and 61 deletions
+9 -10
View File
@@ -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
View File
@@ -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
+145
View File
@@ -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
}
+7 -7
View File
@@ -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 {
+1 -1
View File
@@ -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)