Files
huya-go/internal/huya/launch_test.go
T
yml2213 41565566c5 GUI 日志落盘,修复同一奖品重复兑换与监控结束竞态
- 日志落盘:新增 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 补日志说明
2026-10-13 20:34:43 +08:00

440 lines
14 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/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)
}
}