fix: 电竞手册用 ctn(acf_ccn) 鉴权替代 csrfToken

peacematch 与 peace 的兑换接口鉴权机制完全不同:
- peace: 表单发 csrfToken 字段,multipart 格式
- peacematch: 表单发 ctn 字段(值=acf_ccn),urlencoded 格式,
  cvl_csrf_token 只放 Cookie,不需要 batchExchangeLimit/preExchange

改动:
- Activity 加 UseCTN/CTNURL 字段
- GenerateCSRF 对 UseCTN 活动额外 FetchCTN 获取 acf_ccn,组合返回
- 新增 postForm(urlencoded) 方法用于 peacematch 兑换
- Exchange 按 UseCTN 分流:peacematch 发 ctn 字段,peace 发 csrfToken
- BatchExchangeLimit/PreExchange 对 UseCTN 活动跳过(返回默认值)
- 参考 /Users/yml/codes/douyu_login_py 中 Python 实现
This commit is contained in:
yml2213
2026-07-29 11:05:46 +08:00
parent 8e52e12005
commit 599f9d71f0
2 changed files with 138 additions and 14 deletions
+11
View File
@@ -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{
+127 -14
View File
@@ -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)