package client import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "sync/atomic" "testing" "time" "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 TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) { var goodsRequests []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/embed": if !strings.Contains(r.Header.Get("Cookie"), "acf_uid=uid") { t.Error("授权请求未携带斗鱼 Cookie") } if got := r.URL.Query().Get("token"); got != "uid_1_stk_0_ltkid" { t.Errorf("token = %q", got) } _, _ = w.Write([]byte(`{"error":0,"data":{"txurlH5":"https://app.daoju.qq.com/?gameId=cjm&actId=18882&appId=bp_cf&livePlatId=douyu&code=code×tamp=1&v=2.0&sig=sig"}}`)) case "/role": if got := r.URL.Query().Get("code"); got != "code" { t.Errorf("code = %q", got) } if got := r.URL.Query().Get("_act_id"); got != "46195" { t.Errorf("_act_id = %q", got) } _, _ = w.Write([]byte(`var info = {"gameOpenId":"openid","roleId":1192083858,"roleName":"测试角色","type":"qq","area":1,"platId":0};`)) case "/role/": if got := r.URL.Query().Get("plat"); got != "0" { t.Errorf("plat = %q, want iOS platform 0", got) } switch r.URL.Query().Get("_jsvar") { case "banlanceInfo": if got := r.URL.Query().Get("_time"); len(got) != 13 { t.Errorf("_time = %q, want millisecond timestamp", got) } _, _ = w.Write([]byte(`var banlanceInfo={"balance":8800};`)) case "jbInfos": _, _ = w.Write([]byte(`var jbInfos={"jb2":"36"};`)) default: http.Error(w, "unknown asset query", http.StatusBadRequest) } case "/goods": offset := r.URL.Query().Get("page_begin") category := r.URL.Query().Get("category") exclude := r.URL.Query().Get("excludeFields") goodsRequests = append(goodsRequests, category+":"+exclude+":"+offset) if got := r.URL.Query().Get("areaid"); got != "2" { t.Errorf("areaid = %q", got) } if got := r.URL.Query().Get("page_size"); got != "2" { t.Errorf("page_size = %q", got) } items := []map[string]string{} if category == "76" { switch offset { case "0": items = []map[string]string{{"iGoodsId": "g3", "sGoodsName": "热门商品", "iCategoryId": "76", "iPrice": "30", "sPayType": "qpqb", "iGoodsLeft": "0"}} } } else { switch offset { case "0": items = []map[string]string{{"iGoodsId": "g1", "iActionId": "act-g1", "sGoodsName": "商品一", "iCategoryId": "1", "iPrice": "10", "sPayType": "qpqb", "iGoodsLeft": "999+", "sGoodsPic": "http://ossweb-img.qq.com/images/test.png"}, {"iGoodsId": "g2", "sGoodsName": "特惠商品", "iCategoryId": "45", "iPrice": "0", "iJb2Price": "20", "sPayType": "jb2", "iGoodsLeft": "3"}} case "2": items = []map[string]string{{"iGoodsId": "g2", "sGoodsName": "重复商品", "iCategoryId": "45", "iPrice": "0", "iJb2Price": "20", "sPayType": "jb2", "iGoodsLeft": "3"}, {"iGoodsId": "g4", "sGoodsName": "商品四", "iCategoryId": "1", "iPrice": "40", "sPayType": "qpqb", "iGoodsLeft": "0"}} } } body, _ := json.Marshal(map[string]any{"data": map[string]any{"client_data": map[string]any{"itemsdetail": items}}}) _, _ = w.Write(append([]byte("var recommend="), append(body, ';')...)) default: http.NotFound(w, r) } })) defer server.Close() act := &activity.Activity{ ID: "xiaodian", DisplayName: "和平小店", RoomID: "9263298", ActAlias: "20260623KDQFH", ActID: "46195", BaseURL: server.URL, ShopReferer: server.URL, RoleParamURL: server.URL + "/embed", RoleURL: server.URL + "/role", StoreHomeURL: server.URL + "/goods", IsXiaodian: true, } cli := New(act, "", &trace.Recorder{}) cli.HTTPClient = server.Client() products, err := cli.Products("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid", types.XiaodianProductListOptions{PageSize: 2}) if err != nil { t.Fatalf("Products() error = %v", err) } if len(products) != 4 { t.Fatalf("商品数量 = %d,期望 4", len(products)) } if products[0].CommodityID != "g1" || products[3].CommodityID != "g3" { t.Fatalf("商品顺序 = %#v", products) } if products[1].Category != "45" || products[3].Score != 30 { t.Fatalf("商品字段 = %#v", products) } if products[0].ActionID != "act-g1" { t.Fatalf("商品动作 ID = %q", products[0].ActionID) } if products[0].Count != 999 || products[0].StockLabel != "库存 999+" || products[0].Pic != "https://ossweb-img.qq.com/images/test.png" { t.Fatalf("商品库存或图片 = %#v", products[0]) } if products[0].PayType != 1 || products[0].PayLabel != "点券" || products[1].PayType != 5 || products[1].PayLabel != "扭蛋碎片" || products[1].Score != 20 { t.Fatalf("支付字段 = %#v", products) } if got := strings.Join(goodsRequests, ","); got != ":iCategoryId_76:0,:iCategoryId_76:2,:iCategoryId_76:4,76::0" { t.Fatalf("分类分页请求 = %s", got) } assets, err := cli.XiaodianAssets("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid") if err != nil { t.Fatalf("XiaodianAssets() error = %v", err) } if assets.Coupons != 8800 || assets.Fragments != 36 { t.Fatalf("资产 = %#v", assets) } role, err := cli.XiaodianRole("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid") if err != nil || role.RoleName != "测试角色" || role.AreaID != 2 || role.PlatID != 0 { t.Fatalf("角色 = %#v, err=%v", role, err) } } func TestXiaodianExchangeProductUsesLiveBuyPayTypes(t *testing.T) { var requests []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/embed": _, _ = w.Write([]byte(`{"error":0,"data":{"txurlH5":"https://app.daoju.qq.com/act/a20260602cjmshop/index.html?gameId=cjm&actId=18882&appId=bp_cf&livePlatId=douyu&code=code×tamp=1&v=2.0&sig=sig"}}`)) case "/daoju/igw/livebuy/": requests = append(requests, r.URL.RawQuery) if got := r.URL.Query().Get("_service"); got != "buy.plug.svr.web" { t.Errorf("_service = %q", got) } if got := r.URL.Query().Get("iActionId"); got != "46195" { t.Errorf("iActionId = %q", got) } _, _ = w.Write([]byte(`var buyInfo={"ret":"0","msg":"ok","serial":"CJM-1","sSerialNum":"CJM-1"};`)) default: http.NotFound(w, r) } })) defer server.Close() act := &activity.Activity{ ID: "xiaodian", DisplayName: "和平小店", RoomID: "9263298", ActAlias: "20260623KDQFH", ActID: "46195", RoleParamURL: server.URL + "/embed", RoleURL: server.URL + "/daoju/igw/live", IsXiaodian: true, } cli := New(act, "", &trace.Recorder{}) cli.HTTPClient = server.Client() got, err := cli.ExchangeProduct("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid", types.Product{ CommodityID: "g-dq", Score: 8800, PayType: 1, Pic: "pic-dq", }, nil) if err != nil { t.Fatalf("点券 ExchangeProduct() error = %v", err) } if !strings.Contains(got, "serial=CJM-1") { t.Fatalf("点券返回 = %q", got) } got, err = cli.ExchangeProduct("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid", types.Product{ CommodityID: "g-jb2", Score: 108, PayType: 5, Pic: "pic-jb2", }, nil) if err != nil { t.Fatalf("碎片 ExchangeProduct() error = %v", err) } if !strings.Contains(got, "paytype=5") { t.Fatalf("碎片返回 = %q", got) } if len(requests) != 2 { t.Fatalf("请求数量 = %d", len(requests)) } first, _ := url.ParseQuery(requests[0]) second, _ := url.ParseQuery(requests[1]) if first.Get("paytype") != "1" || first.Get("propid") != "g-dq" { t.Fatalf("点券请求 = %s", requests[0]) } if second.Get("paytype") != "5" || second.Get("propid") != "g-jb2" { t.Fatalf("碎片请求 = %s", requests[1]) } var appext map[string]any if err := json.Unmarshal([]byte(second.Get("appext")), &appext); err != nil { t.Fatalf("appext 解析失败: %v", err) } if appext["user_price"].(float64) != 108 || appext["paytype"].(float64) != 5 { t.Fatalf("碎片 appext = %#v", appext) } } func TestXiaodianExchangeListUsesOrderListAndParsesGoods(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/embed": _, _ = w.Write([]byte(`{"error":0,"data":{"txurlH5":"https://app.daoju.qq.com/?gameId=cjm&actId=18882&appId=bp_cf&livePlatId=douyu&code=code×tamp=1&v=2.0&sig=sig"}}`)) case "/role/": if got := r.URL.Query().Get("_service"); got != "order.list" { t.Errorf("_service = %q", got) } if got := r.URL.Query().Get("code"); got != "code" { t.Errorf("code = %q", got) } if got := r.URL.Query().Get("_sid"); got != "8" { t.Errorf("_sid = %q", got) } _, _ = w.Write([]byte(`var info={"data":{"cnt":1,"list":[{"dtBuyTime":"2026-08-08 01:00:42","dtPayTime":"2026-08-08 01:00:55","iStatus":"3","iPrice":"200","iPayAmount":"180","sRoleName":"测试角色","sGoodsInfo":"{\"list\":[{\"iGoodsId\":\"1565395\",\"sGoodsName\":\"改名卡\",\"iQuantity\":\"2\"}]}"}]}};`)) default: http.NotFound(w, r) } })) defer server.Close() act := &activity.Activity{ ID: "xiaodian", DisplayName: "和平小店", RoomID: "9263298", ActAlias: "20260623KDQFH", ActID: "46195", RoleParamURL: server.URL + "/embed", RoleURL: server.URL + "/role", IsXiaodian: true, } cli := New(act, "", &trace.Recorder{}) cli.HTTPClient = server.Client() records, err := cli.XiaodianExchangeList("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid") if err != nil { t.Fatalf("XiaodianExchangeList() error = %v", err) } if len(records) != 1 { t.Fatalf("记录数量 = %d", len(records)) } got := records[0] if got.CommodityID != "1565395" || got.CommodityName != "改名卡" || got.Quantity != 2 || got.Status != "3" { t.Fatalf("记录解析异常: %#v", got) } if got.Price != 180 || got.RoleName != "测试角色" { t.Fatalf("订单扩展字段异常: %#v", got) } } func TestGenerateCSRFReusesPeaceCookie(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("不应请求鉴权接口: %s", r.URL.Path) })) defer server.Close() act := *activity.Get("peace") act.CSRFURL = server.URL + "/csrf" cli := New(&act, "", &trace.Recorder{}) cli.HTTPClient = server.Client() token, err := cli.GenerateCSRF("foo=1; cvl_csrf_token=from-cookie") if err != nil { t.Fatalf("GenerateCSRF() error = %v", err) } if token != "from-cookie" { t.Fatalf("token = %q, want from-cookie", token) } } func TestCreateExchangeOrderRefreshesCSRFOnce(t *testing.T) { var creates int var csrfCalls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/csrf": csrfCalls++ http.SetCookie(w, &http.Cookie{Name: "cvl_csrf_token", Value: "fresh"}) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"error":0,"msg":"ok"}`)) case "/create": if err := r.ParseMultipartForm(1 << 20); err != nil { t.Errorf("ParseMultipartForm() error = %v", err) http.Error(w, "bad multipart", http.StatusBadRequest) return } creates++ w.Header().Set("Content-Type", "application/json") if creates == 1 { if got := r.MultipartForm.Value["csrfToken"][0]; got != "stale" { t.Errorf("first csrfToken = %q", got) } _, _ = w.Write([]byte(`{"error":1,"msg":"csrfToken invalid"}`)) return } if got := r.MultipartForm.Value["csrfToken"][0]; got != "fresh" { t.Errorf("retry csrfToken = %q", got) } _, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-1","expireSeconds":120}}`)) default: t.Errorf("意外请求 %s", r.URL.Path) } })) defer server.Close() act := *activity.Get("peace") act.CSRFURL = server.URL + "/csrf" act.CreateOrderURL = server.URL + "/create" cli := New(&act, "", &trace.Recorder{}) cli.HTTPClient = server.Client() token := "stale" order, err := cli.CreateExchangeOrder("cvl_csrf_token=stale", "goods-1", 1, &token) if err != nil { t.Fatalf("CreateExchangeOrder() error = %v", err) } if order.OrderID != "order-1" { t.Fatalf("order = %#v", order) } if creates != 2 || csrfCalls != 1 { t.Fatalf("creates=%d csrfCalls=%d, want 2 and 1", creates, csrfCalls) } if token != "fresh" { t.Fatalf("token = %q, want fresh", token) } } func TestPayExchangeOrderAcceptsStringExchangeID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":"E100","exchangeNum":"1"}}`)) })) defer server.Close() act := *activity.Get("peace") act.PayOrderURL = server.URL cli := New(&act, "", &trace.Recorder{}) cli.HTTPClient = server.Client() token := "tok" got, err := cli.PayExchangeOrder("cvl_csrf_token=tok", "74650", &token) if err != nil { t.Fatalf("PayExchangeOrder() error = %v", err) } if got != "exchangeId=E100,num=1" { t.Fatalf("PayExchangeOrder() = %q", got) } } func TestPayExchangeOrderTreatsDuplicatePaymentAsSuccess(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"error":1644430006,"msg":"订单已支付,请勿重复支付"}`)) })) defer server.Close() act := *activity.Get("peace") act.PayOrderURL = server.URL cli := New(&act, "", &trace.Recorder{}) cli.HTTPClient = server.Client() token := "tok" got, err := cli.PayExchangeOrder("cvl_csrf_token=tok", "74650", &token) if err != nil { t.Fatalf("PayExchangeOrder() error = %v", err) } if !strings.Contains(got, "alreadyPaid") || !strings.Contains(got, "74650") { t.Fatalf("PayExchangeOrder() = %q", 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 } // TestRequestStatsAggregatesByLabel 验证网络日志降噪后仍保留诊断价值: // 同类接口聚合成一条统计(次数/失败/平均/最慢/最后错误),并可按任务重置。 func TestRequestStatsAggregatesByLabel(t *testing.T) { cli := &DouyuClient{} cli.recordRequest("锁定兑换商品", 80*time.Millisecond, true, "活动火爆,请稍后再试") cli.recordRequest("锁定兑换商品", 120*time.Millisecond, true, "当前网络繁忙,请稍后再试") cli.recordRequest("锁定兑换商品", 60*time.Millisecond, false, "") cli.recordRequest("支付锁单", 300*time.Millisecond, false, "") stats := cli.RequestStats() if len(stats) != 2 { t.Fatalf("统计条目 = %d, want 2: %#v", len(stats), stats) } // 次数多的排前面 if stats[0].Label != "锁定兑换商品" { t.Fatalf("首条应为调用最多的接口, got %q", stats[0].Label) } lock := stats[0] if lock.Count != 3 || lock.Failed != 2 { t.Fatalf("锁定兑换商品 次数=%d 失败=%d, want 3/2", lock.Count, lock.Failed) } if lock.Slowest != 120*time.Millisecond { t.Fatalf("最慢 = %v, want 120ms", lock.Slowest) } if lock.Total != 260*time.Millisecond { t.Fatalf("总耗时 = %v, want 260ms", lock.Total) } if lock.LastErr != "当前网络繁忙,请稍后再试" { t.Fatalf("最后一次错误 = %q", lock.LastErr) } if stats[1].Failed != 0 || stats[1].LastErr != "" { t.Fatalf("支付锁单不应有失败记录: %#v", stats[1]) } cli.ResetRequestStats() if got := cli.RequestStats(); len(got) != 0 { t.Fatalf("重置后应为空, got %#v", got) } }