381 lines
14 KiB
Go
381 lines
14 KiB
Go
package client
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"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 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 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
|
|
}
|