- 日志落盘:新增 internal/gui/logfile.go,写到 <用户配置目录>/huya-go/logs/huya-go.log,单文件 5MB 后滚动保留 5 个备份;「日志」页签顶部显示路径并支持打开目录/复制路径;写盘失败自动降级为仅界面输出 - 修复重复兑换:界面选品会同时提交 pid、名称与 targetText 里的同一 pid,normalizeTargets 现按 pid/名称去重;runState 再按「账号+奖品」记账,多个 target 命中同一奖品时只兑换一次(新增 skip_duplicate 结果) - 修复 use of closed network connection:成功后的异步刷新改为 runState.refresh 等待,RunTargets 返回前不再提前关闭连接 - queryHttpDns 增加 single-flight 与失败冷却,多账号并发建连只解析一次 - 新增 internal/gui/logfile_test.go 与 sniper/huya 回归测试;README 补日志说明
440 lines
14 KiB
Go
440 lines
14 KiB
Go
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)
|
||
}
|
||
}
|