- 新增 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 同步协议细节
350 lines
11 KiB
Go
350 lines
11 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|