Files
huya-go/internal/huya/launch_test.go
T
yml2213 36aadae90a 按 10.13 抓包对齐 WSS:host 自动解析、exp 回传、pingSignal 与兑换记录字段
- 新增 launch.queryHttpDns:类型定义 + HTTPClient.QueryHttpDns + WSSClient.resolveHost(TTL 缓存、多 host 轮询、失败回退默认 host);Host 留空或等于历史默认值即视为自动解析
- WSS 握手 baseinfo 补 tag5=exp 与 tag10 空 customHeaders;UpdateUserExpsReq(cmd23) 改为回传 getConfig 的版本 map(缓存供重连复用)
- 新增 launch.pingSignal:HTTP 预热改走 pingSignal,WSS 预热在建连后探测业务通道(失败仅记日志)
- UserPrizeInfo 补 tag12 orderId/tag14 图标/tag16 prizeId/tag17 shopItemId/tag19 itemId/tag20 ext/tag21 exchangeDate,前端复盘表加订单号列
- taf 补 WriteListString/ReadListString/ReadMapStringInt64/ReadMapStringStruct
- 新增 internal/huya/launch_test.go:用抓包真实报文做字节级夹具(baseinfo 122B、queryHttpDns 请求 93B、记录 308B 等)
- CLI -wss-host 默认改为留空;GUI 设置支持留空并给出占位提示;README/WSS_NOTES 同步协议细节
2026-10-13 20:19:14 +08:00

350 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package huya
import (
"context"
"encoding/base64"
"encoding/hex"
"net/url"
"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)
}
})
}
}