diff --git a/internal/activity/activity.go b/internal/activity/activity.go index 48e1887..21a17f0 100644 --- a/internal/activity/activity.go +++ b/internal/activity/activity.go @@ -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{ diff --git a/internal/client/client.go b/internal/client/client.go index e1b1e3c..7877e02 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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 diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..5a66f4a --- /dev/null +++ b/internal/client/client_test.go @@ -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 +} diff --git a/internal/seckill/attempt.go b/internal/seckill/attempt.go index c7893e5..a270f86 100644 --- a/internal/seckill/attempt.go +++ b/internal/seckill/attempt.go @@ -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 { diff --git a/internal/seckill/warmup.go b/internal/seckill/warmup.go index 2d4e383..ff444b5 100644 --- a/internal/seckill/warmup.go +++ b/internal/seckill/warmup.go @@ -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)