package huya import ( "context" "encoding/base64" "encoding/binary" "encoding/hex" "net/http" "net/http/httptest" "net/url" "strings" "sync" "sync/atomic" "testing" "time" "huya-go/internal/taf" ) // 下面这些报文都来自 2026-10-13「开通精英宝典-购买幸运币」抓包, // 作为真实字节夹具,防止编码/解码漂移。 const ( realBrowserUA = "webh5&0.0.1&websocket&&h5_/points.html" realQueryHttpDnsReqHex = "0a030000011751d20a201626776562683526302e302e3126776562736f636b6574262668355f2f706f696e74732e68746d6c290001061263646e77732e6170692e687579612e636f6d360c48555941265a48263230353246005c60010b" realQueryHttpDnsRspHex = "0a080001061263646e77732e6170692e687579612e636f6d1a090004061764333562663230312d77732e76612e687579612e636f6d061764333562663337332d77732e76612e687579612e636f6d061764333562663335632d77732e76612e687579612e636f6d061764636361313539642d77732e76612e687579612e636f6d120013c680290c0b1c0b" realPingSignalRspHex = "0a06066c61756e6368160a70696e675369676e616c280002060d7369676e616c5f61705f656e6413000001a135122245060f7369676e616c5f61705f737461727413000001a1351222380b" realGetConfigRspHex = "0a0800010613656e61626c655f706c6174666f726d5f7669701601311800010605353836363416063130373236330b" realUpdateUserExpsHex = "080001060535383636341606313037323633" realWSSBaseinfoHex = "030000011751d20a20162030613839393561663932313863653661333430323631393436323934613965372626776562683526302e302e3126776562736f636b6574262668355f2f706f696e74732e68746d6c360c48555941265a4826323035324600560c35383636342e3130373236336c760086009600a80c" realRecordsRspHex = "0a0100c8160ce8afb7e6b182e68890e58a9f2900010a020097117d1618e5b9b8e8bf90e5b881e7a4bce58c85efbc88e5b08fefbc892014326ace19f140015c6c7c86009600a011b001c630734f7264657249645f323237375f31333430395f313139393636383539353233325f313739313839313935335f363833d600e65868747470733a2f2f66696c657365727665722e63646e2e687579612e636f6d2f6c61646465725f6e6f726d616c55706c6f61642f63393930316530616532303634343436613135636134303061333566656261642e706e67f00f01f1103461f2110082a4d3fc12f2130002ce95f8140004060467616d651603636a6d060367696416043332303306066973526f6c65160131060d6c6976654c696e6b416374496416053137303936f21501352895f61600fc170b0b" ) func mustDecodeHex(t *testing.T, text string) []byte { t.Helper() raw, err := hex.DecodeString(text) if err != nil { t.Fatalf("hex decode: %v", err) } return raw } // decodeBaseinfo 还原 query 里 url-escape + base64 的 baseinfo。 func decodeBaseinfo(encoded string) ([]byte, error) { unescaped, err := url.QueryUnescape(encoded) if err != nil { return nil, err } return base64.StdEncoding.DecodeString(unescaped) } // readStructPayload 按 wup.Response 的方式解出 tRsp 结构体。 func readStructPayload(t *testing.T, raw []byte, obj taf.Unmarshaler) { t.Helper() reader := taf.NewReader(raw) _, typ, err := reader.ReadHead() if err != nil { t.Fatalf("read head: %v", err) } if typ != taf.TypeStructBegin { t.Fatalf("payload 不是结构体: 0x%02x", byte(typ)) } if err := obj.ReadFrom(reader); err != nil { t.Fatalf("ReadFrom: %v", err) } } func TestQueryHttpDnsReqMatchesBrowserBytes(t *testing.T) { req := &QueryHttpDnsReq{ UID: 1199668595232, UA: realBrowserUA, Domains: []string{HttpDnsDomain}, AppSrc: "HUYA&ZH&2052", FlagType: 0, FlagVer: 1, } w := taf.NewWriter() if err := w.WriteStruct(0, req); err != nil { t.Fatal(err) } if got, want := hex.EncodeToString(w.Bytes()), realQueryHttpDnsReqHex; got != want { t.Fatalf("queryHttpDns tReq\n got=%s\nwant=%s", got, want) } } func TestQueryHttpDnsRespParsesRealPayload(t *testing.T) { var resp QueryHttpDnsResp readStructPayload(t, mustDecodeHex(t, realQueryHttpDnsRspHex), &resp) hosts := resp.WSSHosts(HttpDnsDomain) want := []string{ "d35bf201-ws.va.huya.com", "d35bf373-ws.va.huya.com", "d35bf35c-ws.va.huya.com", "dcca159d-ws.va.huya.com", } if len(hosts) != len(want) { t.Fatalf("hosts=%v, want %v", hosts, want) } for i := range want { if hosts[i] != want[i] { t.Fatalf("hosts[%d]=%s, want %s", i, hosts[i], want[i]) } } if ttl := resp.TTL(HttpDnsDomain); ttl != 1296000*time.Second { t.Fatalf("ttl=%s, want 1296000s", ttl) } if resp.Status != 0 { t.Fatalf("status=%d, want 0", resp.Status) } } func TestPingSignalRespParsesRealPayload(t *testing.T) { var resp PingSignalResp readStructPayload(t, mustDecodeHex(t, realPingSignalRspHex), &resp) if resp.Servant != ServantLaunch || resp.Func != FuncPingSignal { t.Fatalf("unexpected servant/func: %q %q", resp.Servant, resp.Func) } if got := resp.Values["signal_ap_start"]; got != 1791891743288 { t.Fatalf("signal_ap_start=%d", got) } if got := resp.ServerElapsed(); got != 13*time.Millisecond { t.Fatalf("ServerElapsed=%s, want 13ms", got) } } func TestPingSignalReqMatchesBrowserBytes(t *testing.T) { req := &PingSignalReq{} w := taf.NewWriter() if err := w.WriteStruct(0, req); err != nil { t.Fatal(err) } // 浏览器 tReq:0a 06 06 "launch" 16 0a "pingSignal" 28 0c 0b want := "0a06066c61756e6368160a70696e675369676e616c280c0b" if got := hex.EncodeToString(w.Bytes()); got != want { t.Fatalf("pingSignal tReq\n got=%s\nwant=%s", got, want) } } func TestGetConfigRespParsesRealPayload(t *testing.T) { var resp GetConfigResp readStructPayload(t, mustDecodeHex(t, realGetConfigRspHex), &resp) if resp.Flags["enable_platform_vip"] != "1" { t.Fatalf("flags=%v", resp.Flags) } if resp.Exps["58664"] != "107263" { t.Fatalf("exps=%v", resp.Exps) } if got := resp.Exp(); got != "58664.107263" { t.Fatalf("exp=%q, want 58664.107263", got) } } func TestUpdateUserExpsReqMatchesBrowserBytes(t *testing.T) { w := taf.NewWriter() req := &wsUpdateUserExpsReq{Exps: map[string]string{"58664": "107263"}} if err := req.WriteTo(w); err != nil { t.Fatal(err) } if got := hex.EncodeToString(w.Bytes()); got != realUpdateUserExpsHex { t.Fatalf("updateUserExps\n got=%s\nwant=%s", got, realUpdateUserExpsHex) } empty := taf.NewWriter() if err := (&wsUpdateUserExpsReq{}).WriteTo(empty); err != nil { t.Fatal(err) } if got := hex.EncodeToString(empty.Bytes()); got != "080c" { t.Fatalf("empty updateUserExps=%s, want 080c", got) } } func TestFormatConfigExps(t *testing.T) { cases := []struct { in map[string]string want string }{ {nil, ""}, {map[string]string{}, ""}, {map[string]string{"58664": "107263"}, "58664.107263"}, {map[string]string{"b": "2", "a": "1"}, "a.1.b.2"}, } for _, tt := range cases { if got := FormatConfigExps(tt.in); got != tt.want { t.Fatalf("FormatConfigExps(%v)=%q, want %q", tt.in, got, tt.want) } } } func TestGenerateWSSBaseinfoCarriesExpAndCustomHeaders(t *testing.T) { encoded, err := GenerateWSSBaseinfo(123, "guid-value", "58664.107263") if err != nil { t.Fatal(err) } raw, err := decodeBaseinfo(encoded) if err != nil { t.Fatal(err) } r := taf.NewReader(raw) exp, err := r.ReadString(5, true, "") if err != nil { t.Fatal(err) } headers, err := r.ReadMapStringString(10, true) if err != nil { t.Fatal(err) } if exp != "58664.107263" { t.Fatalf("exp=%q, want 58664.107263", exp) } if len(headers) != 0 { t.Fatalf("customHeaders=%v, want empty", headers) } if _, _, err := r.ReadHead(); err == nil { t.Fatal("baseinfo 应该只有 11 个字段") } } func TestWSSBaseinfoRealBrowserPayloadParses(t *testing.T) { r := taf.NewReader(mustDecodeHex(t, realWSSBaseinfoHex)) uid, err := r.ReadInt64(0, true, 0) if err != nil { t.Fatal(err) } guid, err := r.ReadString(1, true, "") if err != nil { t.Fatal(err) } exp, err := r.ReadString(5, true, "") if err != nil { t.Fatal(err) } cookie, err := r.ReadString(8, false, "not-empty") if err != nil { t.Fatal(err) } headers, err := r.ReadMapStringString(10, true) if err != nil { t.Fatal(err) } if uid != 1199668595232 || guid != "0a8995af9218ce6a340261946294a9e7" { t.Fatalf("uid=%d guid=%q", uid, guid) } if exp != "58664.107263" { t.Fatalf("exp=%q", exp) } if cookie != "" || len(headers) != 0 { t.Fatalf("cookie=%q headers=%v", cookie, headers) } } func TestUserPrizeRecordsParsesRealPayload(t *testing.T) { var resp GetUserPrizeRecordsResp readStructPayload(t, mustDecodeHex(t, realRecordsRspHex), &resp) if resp.Status != 200 || resp.Msg != "请求成功" { t.Fatalf("status=%d msg=%q", resp.Status, resp.Msg) } if len(resp.Records) != 1 { t.Fatalf("records=%d, want 1", len(resp.Records)) } record := resp.Records[0] if record.ID != 9900413 || record.Name != "幸运币礼包(小)" || record.Score != 20 { t.Fatalf("unexpected record: %+v", record) } if record.OrderID != "sOrderId_2277_13409_1199668595232_1791891953_683" { t.Fatalf("orderId=%q", record.OrderID) } if record.PrizeID != 13409 || record.ShopItemID != 8561875 || record.ItemID != 183957 { t.Fatalf("prizeId=%d shopItemId=%d itemId=%d", record.PrizeID, record.ShopItemID, record.ItemID) } if record.Image == "" || record.ExchangeDate != 20261013 { t.Fatalf("image=%q exchangeDate=%d", record.Image, record.ExchangeDate) } if record.Ext["liveLinkActId"] != "17096" || len(record.Ext) != 4 { t.Fatalf("ext=%v", record.Ext) } } func TestResolveHostPrefersExplicitHost(t *testing.T) { client := &WSSClient{Host: "pinned-ws.va.huya.com"} if got := client.resolveHost(context.Background(), 1, "yyuid=1"); got != "pinned-ws.va.huya.com" { t.Fatalf("host=%q", got) } } func TestResolveHostFallsBackWhenDNSDisabled(t *testing.T) { client := &WSSClient{} if got := client.resolveHost(context.Background(), 1, "yyuid=1"); got != DefaultWSSHost { t.Fatalf("host=%q, want %s", got, DefaultWSSHost) } } func TestCachedDNSHostsRotate(t *testing.T) { client := &WSSClient{} client.storeDNSHosts([]string{"a-ws.va.huya.com", "b-ws.va.huya.com"}, time.Minute) want := []string{"a-ws.va.huya.com", "b-ws.va.huya.com", "a-ws.va.huya.com"} for i, expected := range want { if got := client.nextCachedDNSHost(); got != expected { t.Fatalf("第 %d 次解析 host=%q, want %q", i+1, got, expected) } } } func TestCachedDNSHostsExpire(t *testing.T) { client := &WSSClient{} client.storeDNSHosts([]string{"a-ws.va.huya.com"}, time.Minute) client.mu.Lock() client.dnsExpireAt = time.Now().Add(-time.Second) client.mu.Unlock() if got := client.nextCachedDNSHost(); got != "" { t.Fatalf("过期缓存应返回空, got %q", got) } } func TestStoreDNSHostsClampsNonPositiveTTL(t *testing.T) { client := &WSSClient{} client.storeDNSHosts([]string{"a-ws.va.huya.com"}, 0) if got := client.nextCachedDNSHost(); got != "a-ws.va.huya.com" { t.Fatalf("ttl<=0 时应使用兜底 TTL, got %q", got) } } func TestNewActivityClientWSSHostSemantics(t *testing.T) { cases := []struct { name string wssHost string want string }{ {name: "empty means auto", wssHost: "", want: ""}, {name: "legacy default means auto", wssHost: DefaultWSSHost, want: ""}, {name: "explicit host is pinned", wssHost: "custom-ws.va.huya.com", want: "custom-ws.va.huya.com"}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { client, closeClient, err := NewActivityClient("wss", tt.wssHost, time.Second, nil) if err != nil { t.Fatal(err) } defer closeClient() wss, ok := client.(*WSSClient) if !ok { t.Fatalf("client 类型=%T", client) } if wss.Host != tt.want { t.Fatalf("Host=%q, want %q", wss.Host, tt.want) } }) } } // buildWupResponse 用 TAF 拼一个最小 WUP 响应,用于本地 httptest 桩服务。 func buildWupResponse(t *testing.T, servant, fn string, requestID int32, payload []byte) []byte { t.Helper() inner := taf.NewWriter() if err := inner.WriteMapStringBytes(0, map[string][]byte{"tRsp": payload}); err != nil { t.Fatal(err) } body := taf.NewWriter() for _, err := range []error{ body.WriteInt16(1, 3), body.WriteInt8(2, 0), body.WriteInt32(3, 0), body.WriteInt32(4, int64(requestID)), body.WriteString(5, servant), body.WriteString(6, fn), body.WriteBytes(7, inner.Bytes()), } { if err != nil { t.Fatal(err) } } raw := body.Bytes() out := make([]byte, 0, len(raw)+4) var size [4]byte binary.BigEndian.PutUint32(size[:], uint32(len(raw)+4)) out = append(out, size[:]...) return append(out, raw...) } func TestResolveHostSingleFlightAndCache(t *testing.T) { // 先准备好响应,避免在 handler 里调用 t.Fatal。 responseBytes := buildWupResponse(t, ServantLaunch, FuncQueryHttpDns, -1, mustDecodeHex(t, realQueryHttpDnsRspHex)) var hits atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) // 留出时间让并发协程都进入等待分支。 time.Sleep(30 * time.Millisecond) w.Header().Set("Content-Type", "application/octet-stream") _, _ = w.Write(responseBytes) })) defer server.Close() client := NewWSSClient(nil) client.DNSEndpoint = server.URL + "/" const callers = 4 hosts := make([]string, callers) var wg sync.WaitGroup for i := 0; i < callers; i++ { wg.Add(1) go func(i int) { defer wg.Done() hosts[i] = client.resolveHost(context.Background(), int64(i+1), "yyuid=1") }(i) } wg.Wait() if got := hits.Load(); got != 1 { t.Fatalf("queryHttpDns 请求次数=%d, want 1(并发建连应共用一次解析)", got) } for i, host := range hosts { if !strings.HasSuffix(host, "-ws.va.huya.com") { t.Fatalf("caller %d 解析结果异常: %q", i, host) } } // TTL 内命中缓存,不再请求解析接口。 if got := client.resolveHost(context.Background(), 99, "yyuid=1"); !strings.HasSuffix(got, "-ws.va.huya.com") { t.Fatalf("缓存 host 异常: %q", got) } if got := hits.Load(); got != 1 { t.Fatalf("TTL 内重复请求解析接口: %d 次", got) } // 多 host 轮询:连续 4 次应覆盖响应里的全部 4 个 host。 seen := map[string]bool{} for i := 0; i < 4; i++ { seen[client.resolveHost(context.Background(), 99, "yyuid=1")] = true } if len(seen) != 4 { t.Fatalf("host 轮询未覆盖全部节点: %v", seen) } }