diff --git a/internal/activity/activity.go b/internal/activity/activity.go index a0c06d2..a957bbf 100644 --- a/internal/activity/activity.go +++ b/internal/activity/activity.go @@ -32,6 +32,14 @@ type Activity struct { BindInfoURL string ScoreURL string ScoreMethod string // "POST" 或 "GET" + + // UseCTN 为 true 时(电竞手册),兑换接口用 ctn(acf_ccn) 鉴权而非 csrfToken: + // - 表单发 ctn 字段(值=acf_ccn),不发 csrfToken + // - cvl_csrf_token 只放 Cookie 头 + // - 跳过 batchExchangeLimit / preExchange(Python 实现直接调 exchange) + // - Content-Type 用 application/x-www-form-urlencoded + UseCTN bool + CTNURL string // 获取 acf_ccn 的接口(UseCTN=true 时必填) } const douyuBase = constants.BaseURL @@ -54,6 +62,7 @@ var peace = &Activity{ BindInfoURL: douyuBase + "/japi/carnivalApi/v2/tencent/bindInfo?actAlias=20260120QYOOB", ScoreURL: douyuBase + "/japi/oms/web/peace/credit/balance", ScoreMethod: "POST", + UseCTN: false, } // peacematch 电竞手册(新活动,ActAlias=20260226ZMGDG)。 @@ -77,6 +86,8 @@ var peacematch = &Activity{ BindInfoURL: douyuBase + "/wgapi/ordnc/live/memberthird/tencent/bindInfo?actAlias=20260226ZMGDG", ScoreURL: douyuBase + "/wgapi/ordnc/activity/peacematch/score/balance", ScoreMethod: "GET", + UseCTN: true, + CTNURL: douyuBase + "/curl/csrfNlApi/getCsrfCookie", } var registry = map[string]*Activity{ diff --git a/internal/client/client.go b/internal/client/client.go index c773bcb..ebfe203 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -7,6 +7,7 @@ import ( "io" "mime/multipart" "net/http" + "net/url" "reflect" "strconv" "strings" @@ -303,6 +304,14 @@ func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) { 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 } } @@ -312,6 +321,34 @@ func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) { 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 "" @@ -324,8 +361,11 @@ func csrfFromResponse(resp *http.Response) string { return "" } -// BatchExchangeLimit 请求兑换限制接口。 +// 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 @@ -354,8 +394,11 @@ func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID string, csrf *strin return limit, nil } -// PreExchange 按抓包流程执行预兑换检查。 +// PreExchange 按抓包流程执行预兑换检查。电竞手册不需要此步骤,直接跳过。 func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) error { + if c.Activity.UseCTN { + return nil + } currentCSRF := "" if csrf != nil { currentCSRF = *csrf @@ -378,10 +421,6 @@ func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) erro // Exchange 正式兑换一个商品。 func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string, error) { - currentCSRF := "" - if csrf != nil { - currentCSRF = *csrf - } var result struct { types.APIResponse Data struct { @@ -390,14 +429,47 @@ func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string CommodityTyp int `json:"commodityType"` } `json:"data"` } - 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 c.Activity.UseCTN { + // 电竞手册:用 ctn(acf_ccn) 鉴权,urlencoded 表单 + 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.postForm(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) == "" { @@ -504,6 +576,47 @@ func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label stri return err } +// postForm 发送 application/x-www-form-urlencoded POST 请求。 +// 用于电竞手册等使用 ctn 鉴权的活动。 +// csrf 参数为组合值 "cvl_csrf_token\x00acf_ccn",两者都会合并到 Cookie 头。 +func (c *DouyuClient) postForm(targetURL, cookie string, csrf *string, label string, fields map[string]string, out any) error { + vals := url.Values{} + for k, v := range fields { + vals.Set(k, v) + } + req, err := http.NewRequest("POST", targetURL, strings.NewReader(vals.Encode())) + if err != nil { + return err + } + // 拆分组合 token,合并到 Cookie + csrfVal := "" + ctnVal := "" + if csrf != nil { + parts := strings.SplitN(*csrf, "\x00", 2) + csrfVal = parts[0] + if len(parts) > 1 { + ctnVal = parts[1] + } + } + mergedCookie := utils.MergeCookie(cookie, "cvl_csrf_token", csrfVal) + if ctnVal != "" { + mergedCookie = utils.MergeCookie(mergedCookie, "acf_ccn", ctnVal) + } + c.setBrowserHeaders(req, mergedCookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + resp, err := c.doJSON(req, out, label) + if resp != nil && csrf != nil { + if nextCSRF := csrfFromResponse(resp); nextCSRF != "" { + 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)