From 36aadae90a907eaa7421b1d352d9aec7681d1a3d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 13 Oct 2026 20:19:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=2010.13=20=E6=8A=93=E5=8C=85=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20WSS=EF=BC=9Ahost=20=E8=87=AA=E5=8A=A8=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E3=80=81exp=20=E5=9B=9E=E4=BC=A0=E3=80=81pingSignal?= =?UTF-8?q?=20=E4=B8=8E=E5=85=91=E6=8D=A2=E8=AE=B0=E5=BD=95=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 同步协议细节 --- README.md | 14 +- WSS_NOTES.md | 54 ++++- cmd/huya-go/main.go | 2 +- frontend/src/App.tsx | 8 +- frontend/wailsjs/go/models.ts | 14 ++ internal/gui/app.go | 6 +- internal/huya/activity.go | 58 +++-- internal/huya/baseinfo.go | 12 +- internal/huya/client.go | 7 +- internal/huya/http_client.go | 39 ++++ internal/huya/launch.go | 267 +++++++++++++++++++++++ internal/huya/launch_test.go | 349 +++++++++++++++++++++++++++++++ internal/huya/wss_client.go | 173 +++++++++++++-- internal/huya/wss_client_test.go | 9 +- internal/taf/reader.go | 115 ++++++++++ internal/taf/writer.go | 15 ++ 16 files changed, 1097 insertions(+), 45 deletions(-) create mode 100644 internal/huya/launch.go create mode 100644 internal/huya/launch_test.go diff --git a/README.md b/README.md index 116295c..811d81a 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ CLI 示例: - `-cookie`:单账号 Cookie。 - `-cookie-file`:多账号 Cookie 文件。 - `-transport`:`http` 或 `wss`,默认 `http`;WSS 仍为实验通道。 -- `-wss-host`:WebSocket host。默认值来自 2026-07-06 手动抓包,失效时应从浏览器抓包或 `launch.queryHttpDns` 结果更新。 +- `-wss-host`:WebSocket host。默认留空,自动调用 `launch.queryHttpDns` 解析(带 TTL 缓存、多 host 轮询),解析失败时回退内置默认 host;需要固定时可显式指定。 - `-timeout`:单次调用超时秒数。 - `-loops`、`-delay`、`-concurrency`:轮询次数、轮询间隔、并发账号数。 - `-stop-after-success`:`monitor` 中任一账号成功后是否停止。 @@ -98,3 +98,15 @@ CLI 示例: - 消耗积分:`988` 远端配置里的子奖品代码不能直接当兑换 PID,实际兑换以 `scoreExchangePrize` 请求里的 `pid` 为准。 + +## 精英宝典/幸运币(2026-10-13 抓包) + +`m-shop.huya.com` 积分中心的兑换记录里确认到: + +- `sid=2277` +- `pid=13409` +- 奖品名:`幸运币礼包(小)` +- 消耗积分:`20` +- 订单号格式:`sOrderId_2277_13409__<时间戳>_<随机>` + +该活动的列表/积分/兑换接口与 `sid=2203` 走同一套 `webActUI`,可直接用 `-cmd list -sid 2277` 验证;同一次抓包里「开通精英宝典 / 购买精英币」属于商城付费链路(`shopMiddleUI.*` + 支付宝),本项目未实现,详见 `WSS_NOTES.md`。 diff --git a/WSS_NOTES.md b/WSS_NOTES.md index 5cf4e69..e8d490a 100644 --- a/WSS_NOTES.md +++ b/WSS_NOTES.md @@ -93,12 +93,14 @@ ws.send(WebSocketCommand.writeTo(JceOutputStream)) 3. 通过 WSS 调用 `launch.wsLaunch`,手动 HAR 中 requestId 为 `-1`。 4. 发送 `EWSCmdC2S_VerifyCookieReq(10)`,cookie 在这里进入 WSS 通道。 5. 通过 WSS 调用 `mobileui.getConfig`。 -6. 发送 `EWSCmdC2S_UpdateUserExpsReq(23)`,请求体是空 map。 +6. 发送 `EWSCmdC2S_UpdateUserExpsReq(23)`,请求体是 `getConfig` 下发的版本 map(实测 `{"58664":"107263"}`,Go 版已按此回传)。 7. 再发送一次 `EWSCmdC2S_RegisterGroupReq(16)`。 8. 发送 `EWSCmdC2S_HeartBeatReq(20)`,后续维持心跳。 9. `launch.queryHttpDns` 仍通过 HTTP `cdnws` 调用。 10. 后续 WUP 业务在连接可用时走 `signalSdk.sendWup`,连接不可用时可 fallback 到 `sendWupHttp`。 +握手 `baseinfo`(11 个字段)实测带 `exp`(tag5),值就是 getConfig 的版本串;cookie(tag8)/traceId(tag9) 为空,tag10 是空的 `customHeaders` map。 + 响应应按 WUP `iRequestId` 匹配回调,不能只用 FIFO。 ## 活动接口 @@ -135,8 +137,56 @@ tReq.isRole = 1 - `exchangeInfo.exchangeScore=988` - `exchangeInfo.desc=兑换奖品:套装-潮萌小黄鸭` +## launch.queryHttpDns(已实现) + +2026-10-13「精英宝典」抓包确认:`m-shop.huya.com` 页面用 HTTP `cdnws` 调它换取 WSS host,`iRequestId` 固定 `-1`。 + +请求 `tReq`: + +```text +tReq.0 uid int64 +tReq.1 ua string # webh5&0.0.1&websocket&&h5_/points.html +tReq.2 domains list # ["cdnws.api.huya.com"] +tReq.3 appSrc string # HUYA&ZH&2052 +tReq.4 "" string +tReq.5 0 int32 +tReq.6 1 int32 +``` + +响应 `tRsp`: + +```text +tRsp.0 map hosts, 1: int32 ttl秒, 2: list}> # 实测 ttl=1296000 +tRsp.1 int32 +``` + +Go 版:`HTTPClient.QueryHttpDns` + `WSSClient.resolveHost`(TTL 缓存、多 host 轮询、失败回退默认 host)。 + +## launch.pingSignal(已实现) + +页面在 HTTP `cdnws` 上调用它做链路探测,`iRequestId=0`: + +```text +tReq.0 "launch" tReq.1 "pingSignal" tReq.2 map 空 +tRsp.0 "launch" tRsp.1 "pingSignal" tRsp.2 map{signal_ap_start, signal_ap_end} +``` + +Go 版:`HTTPClient.PingSignal`(HTTP 预热)与 `WSSClient.PingSignal`(WSS 预热后探测业务通道),预热结果只记日志、不影响抢购。 + +## 商城链路(仅记录,未实现) + +同一抓包中「开通精英宝典 / 购买幸运币」属于商城付费链路,全部走 WSS 上的 `shopMiddleUI.*`: + +```text +getGoodsInfoV5 → getSupplierInfoV5 → listPayChannelV5 → checkHyProtocolV5 → acceptHyProtocolV5 +→ calcOrderPromotion → checkUserBuyAuth → createOrderV5 → payOrderSubmitV5(支付宝二维码) → orderDetailV5(轮询) +另有 createOrderV6(另一条下单通道)与 revenueWebUI.queryUserOrderList +``` + +注意函数名带版本后缀(V5/V6)。`webActUI` 在 WSS 上同样可用(实测 `checkUserBindGameAccount`、`getUserPrizeRecords`)。 + ## 待完善 - 使用真实 Cookie 联调 `list/score/exchange`,确认服务端不再超时。 -- 自动调用 `launch.queryHttpDns` 获取最新 WSS host。 +- WSS 侧对 `launch.pingSignal` 的实际可用性需真号验证(失败已降级为日志)。 - 补更多基于真实抓包摘要的 WSS 编解码单测。 diff --git a/cmd/huya-go/main.go b/cmd/huya-go/main.go index 4f78fad..135ae60 100644 --- a/cmd/huya-go/main.go +++ b/cmd/huya-go/main.go @@ -39,7 +39,7 @@ func main() { leadMS = flag.Int("lead", 2500, "monitor 相对 -at 提前启动毫秒") timeoutSec = flag.Int("timeout", 15, "单次请求超时秒数") transport = flag.String("transport", "http", "传输层:http/wss") - wssHost = flag.String("wss-host", huya.DefaultWSSHost, "WSS host,来自 launch.queryHttpDns 或抓包") + wssHost = flag.String("wss-host", "", "WSS host,留空则通过 launch.queryHttpDns 自动解析") prewarm = flag.Bool("prewarm", true, "monitor 开始前预热账号连接") directExchange = flag.Bool("direct-exchange", true, "monitor 已知 PID 时跳过列表直接兑换") listBefore = flag.Bool("list-before-exchange", false, "monitor 即使已知 PID 也先查询列表") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba9fd51..f8b3f4f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -138,6 +138,11 @@ type UserPrizeInfo = { new_score?: number status?: number create_time?: number + order_id?: string + img?: string + prize_id?: number + item_id?: number + exchange_date?: number } type RecordResult = { @@ -1065,6 +1070,7 @@ export default function Dashboard() { { title: '名称', dataIndex: 'name', minWidth: 220, ellipsis: true }, { title: '积分', width: 90, render: (_, row) => row.new_score || row.score || 0 }, { title: '状态', dataIndex: 'status', width: 84 }, + { title: '订单号', dataIndex: 'order_id', minWidth: 200, ellipsis: true }, { title: '时间', dataIndex: 'create_time', width: 130 }, ], [], @@ -1479,7 +1485,7 @@ export default function Dashboard() { /> - +
CK 来源 diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 20eb03b..1ad3d87 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -393,6 +393,13 @@ export namespace huya { account?: string; code?: string; common_prize_type: number; + order_id?: string; + img?: string; + prize_id?: number; + shop_item_id?: number; + item_id?: number; + ext?: Record; + exchange_date?: number; static createFrom(source: any = {}) { return new UserPrizeInfo(source); @@ -411,6 +418,13 @@ export namespace huya { this.account = source["account"]; this.code = source["code"]; this.common_prize_type = source["common_prize_type"]; + this.order_id = source["order_id"]; + this.img = source["img"]; + this.prize_id = source["prize_id"]; + this.shop_item_id = source["shop_item_id"]; + this.item_id = source["item_id"]; + this.ext = source["ext"]; + this.exchange_date = source["exchange_date"]; } } export class GetUserPrizeRecordsResp { diff --git a/internal/gui/app.go b/internal/gui/app.go index ea2b848..82df0cd 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -143,7 +143,7 @@ func (a *App) Shutdown(ctx context.Context) { func (a *App) DefaultConfig() Config { return Config{ Transport: "http", - WSSHost: huya.DefaultWSSHost, + WSSHost: "", SID: 2203, Loops: 20, DelayMS: 120, @@ -542,9 +542,7 @@ func normalizeConfig(cfg Config) Config { if strings.TrimSpace(cfg.Transport) == "" { cfg.Transport = "http" } - if strings.TrimSpace(cfg.WSSHost) == "" { - cfg.WSSHost = huya.DefaultWSSHost - } + // WSSHost 留空表示走 launch.queryHttpDns 自动解析,不在这里补默认值。 if cfg.Loops <= 0 { cfg.Loops = 1 } diff --git a/internal/huya/activity.go b/internal/huya/activity.go index 1e2ae3a..5c672b9 100644 --- a/internal/huya/activity.go +++ b/internal/huya/activity.go @@ -368,18 +368,31 @@ func (q *GetUserPrizeRecordsReq) ReadFrom(r *taf.Reader) error { return err } +// UserPrizeInfo 对应 getUserPrizeRecords 的一条兑换记录。 +// +// 除 tag0~tag10 外,2026-10-13 HAR 实测还有 tag11~tag23: +// 12=orderId、14=奖品图、16=奖品 pid、17=商城 itemId(数量级与 shopMiddleUI +// 的 itemId 一致,语义未在样本中最终确认)、19=prizeItemId、20=扩展 map +// (game/cjm/plat/isRole/liveLinkActId)、21=兑换日期(如 20261013)。 type UserPrizeInfo struct { - ID int64 `json:"id"` - Name string `json:"name"` - Score int64 `json:"score"` - CreateTime int64 `json:"create_time"` - Status int32 `json:"status"` - Type int32 `json:"type"` - IsReal int32 `json:"is_real"` - NewScore int64 `json:"new_score"` - Account string `json:"account,omitempty"` - Code string `json:"code,omitempty"` - CommonPrizeType int32 `json:"common_prize_type"` + ID int64 `json:"id"` + Name string `json:"name"` + Score int64 `json:"score"` + CreateTime int64 `json:"create_time"` + Status int32 `json:"status"` + Type int32 `json:"type"` + IsReal int32 `json:"is_real"` + NewScore int64 `json:"new_score"` + Account string `json:"account,omitempty"` + Code string `json:"code,omitempty"` + CommonPrizeType int32 `json:"common_prize_type"` + OrderID string `json:"order_id,omitempty"` + Image string `json:"img,omitempty"` + PrizeID int64 `json:"prize_id,omitempty"` + ShopItemID int64 `json:"shop_item_id,omitempty"` + ItemID int64 `json:"item_id,omitempty"` + Ext map[string]string `json:"ext,omitempty"` + ExchangeDate int32 `json:"exchange_date,omitempty"` } func (p *UserPrizeInfo) WriteTo(w *taf.Writer) error { return nil } @@ -416,7 +429,28 @@ func (p *UserPrizeInfo) ReadFrom(r *taf.Reader) error { if p.Code, err = r.ReadString(9, false, p.Code); err != nil { return err } - p.CommonPrizeType, err = r.ReadInt32(10, false, p.CommonPrizeType) + if p.CommonPrizeType, err = r.ReadInt32(10, false, p.CommonPrizeType); err != nil { + return err + } + if p.OrderID, err = r.ReadString(12, false, p.OrderID); err != nil { + return err + } + if p.Image, err = r.ReadString(14, false, p.Image); err != nil { + return err + } + if p.PrizeID, err = r.ReadInt64(16, false, p.PrizeID); err != nil { + return err + } + if p.ShopItemID, err = r.ReadInt64(17, false, p.ShopItemID); err != nil { + return err + } + if p.ItemID, err = r.ReadInt64(19, false, p.ItemID); err != nil { + return err + } + if p.Ext, err = r.ReadMapStringString(20, false); err != nil { + return err + } + p.ExchangeDate, err = r.ReadInt32(21, false, p.ExchangeDate) return err } diff --git a/internal/huya/baseinfo.go b/internal/huya/baseinfo.go index 1dd432b..701e770 100644 --- a/internal/huya/baseinfo.go +++ b/internal/huya/baseinfo.go @@ -102,9 +102,10 @@ func GenerateHTTPBaseinfo(uid int64, guid, cookie string) (string, error) { // GenerateWSSBaseinfo 生成浏览器 WSS 握手里的 baseinfo。 // -// 手动抓包里 WSS 握手只带 uid/guid/ua/appSrc 等连接信息,cookie -// 通过后续 VerifyCookie 命令发送。 -func GenerateWSSBaseinfo(uid int64, guid string) (string, error) { +// 手动抓包里 WSS 握手带 uid/guid/ua/appSrc/mid/exp/.../customHeaders 共 11 个字段, +// cookie 为空、通过后续 VerifyCookie 命令发送;exp 为 mobileui.getConfig 下发的 +// 版本串(形如 "58664.107263"),首次连接时为空。 +func GenerateWSSBaseinfo(uid int64, guid, exp string) (string, error) { w := taf.NewWriter() if err := w.WriteInt64(0, uid); err != nil { return "", err @@ -121,7 +122,7 @@ func GenerateWSSBaseinfo(uid int64, guid string) (string, error) { if err := w.WriteString(4, ""); err != nil { return "", err } - if err := w.WriteString(5, ""); err != nil { + if err := w.WriteString(5, exp); err != nil { return "", err } if err := w.WriteInt32(6, 0); err != nil { @@ -136,5 +137,8 @@ func GenerateWSSBaseinfo(uid int64, guid string) (string, error) { if err := w.WriteString(9, ""); err != nil { return "", err } + if err := w.WriteMapStringString(10, nil); err != nil { + return "", err + } return url.QueryEscape(base64.StdEncoding.EncodeToString(w.Bytes())), nil } diff --git a/internal/huya/client.go b/internal/huya/client.go index f2b174d..c6d5170 100644 --- a/internal/huya/client.go +++ b/internal/huya/client.go @@ -26,8 +26,11 @@ func NewActivityClient(transport, wssHost string, timeout time.Duration, logger return client, func() {}, nil case "wss", "ws", "websocket": client := NewWSSClient(logger) - if strings.TrimSpace(wssHost) != "" { - client.Host = strings.TrimSpace(wssHost) + // 留空(或沿用历史默认 host)表示交给 launch.queryHttpDns 自动解析, + // 只有显式指定的其它 host 才固定使用。 + host := strings.TrimSpace(wssHost) + if host != "" && host != DefaultWSSHost { + client.Host = host } if timeout > 0 { client.CallTimeout = timeout diff --git a/internal/huya/http_client.go b/internal/huya/http_client.go index 45dc25a..e1471b7 100644 --- a/internal/huya/http_client.go +++ b/internal/huya/http_client.go @@ -46,9 +46,48 @@ func (c *HTTPClient) logf(format string, args ...any) { } func (c *HTTPClient) PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error { + start := time.Now() + if _, err := c.PingSignal(ctx, uid, cookie); err != nil { + return err + } + c.logf("[HTTP] 连接预热完成 uid=%d 往返=%s", uid, time.Since(start).Round(time.Millisecond)) return nil } +// QueryHttpDns 调用 launch.queryHttpDns 获取 WSS host 列表(iRequestId 固定 -1)。 +func (c *HTTPClient) QueryHttpDns(ctx context.Context, uid int64, cookie string, domains []string) (*QueryHttpDnsResp, error) { + req := NewQueryHttpDnsReq(uid, HTTPHuyaUA, domains) + var resp QueryHttpDnsResp + _, err := c.CallRPC(ctx, ServantLaunch, FuncQueryHttpDns, req, &resp, CallOptions{ + UID: uid, + GUID: CookieValue(cookie, "guid"), + Cookie: cookie, + RequestID: -1, + }) + if err != nil { + return nil, err + } + c.logf("[HTTP] queryHttpDns 解析 %s -> %v", HttpDnsDomain, resp.WSSHosts(HttpDnsDomain)) + return &resp, nil +} + +// PingSignal 调用 launch.pingSignal 做链路探测,返回服务端时间戳与本地往返延迟。 +func (c *HTTPClient) PingSignal(ctx context.Context, uid int64, cookie string) (*PingSignalResp, error) { + req := &PingSignalReq{Extra: map[string]string{}} + var resp PingSignalResp + start := time.Now() + _, err := c.CallRPC(ctx, ServantLaunch, FuncPingSignal, req, &resp, CallOptions{ + UID: uid, + GUID: CookieValue(cookie, "guid"), + Cookie: cookie, + }) + if err != nil { + return nil, err + } + c.logf("[HTTP] pingSignal 往返=%s 服务端=%s", time.Since(start).Round(time.Millisecond), resp.ServerElapsed()) + return &resp, nil +} + func (c *HTTPClient) CallRPC(ctx context.Context, service, method string, reqStruct taf.Marshaler, respStruct taf.Unmarshaler, opt CallOptions) (*wup.Response, error) { if opt.RequestID == 0 { opt.RequestID = 1 diff --git a/internal/huya/launch.go b/internal/huya/launch.go new file mode 100644 index 0000000..526c82b --- /dev/null +++ b/internal/huya/launch.go @@ -0,0 +1,267 @@ +package huya + +import ( + "sort" + "strings" + "time" + + "huya-go/internal/taf" +) + +const ( + ServantLaunch = "launch" + + FuncWsLaunch = "wsLaunch" + FuncQueryHttpDns = "queryHttpDns" + FuncPingSignal = "pingSignal" + FuncGetConfig = "getConfig" + + // HttpDnsDomain 是 cdnws 入口域名,launch.queryHttpDns 用它换取 WSS host 列表。 + HttpDnsDomain = "cdnws.api.huya.com" + + // DefaultDNSTTL 是响应没有 ttl 时的兜底缓存时间。 + DefaultDNSTTL = 10 * time.Minute +) + +// QueryHttpDnsReq 对应浏览器 launch.queryHttpDns 的 tReq。 +// +// 实测字段(2026-10-13 HAR):0=uid, 1=ua, 2=域名列表, 3=appSrc, +// 4="", 5=0, 6=1,iRequestId 固定 -1。 +type QueryHttpDnsReq struct { + UID int64 + UA string + Domains []string + AppSrc string + Extra string + FlagType int32 + FlagVer int32 +} + +func NewQueryHttpDnsReq(uid int64, ua string, domains []string) *QueryHttpDnsReq { + if len(domains) == 0 { + domains = []string{HttpDnsDomain} + } + return &QueryHttpDnsReq{ + UID: uid, + UA: ua, + Domains: domains, + AppSrc: "HUYA&ZH&2052", + FlagType: 0, + FlagVer: 1, + } +} + +func (q *QueryHttpDnsReq) WriteTo(w *taf.Writer) error { + if err := w.WriteInt64(0, q.UID); err != nil { + return err + } + if err := w.WriteString(1, q.UA); err != nil { + return err + } + if err := w.WriteListString(2, q.Domains); err != nil { + return err + } + if err := w.WriteString(3, q.AppSrc); err != nil { + return err + } + if err := w.WriteString(4, q.Extra); err != nil { + return err + } + if err := w.WriteInt32(5, int64(q.FlagType)); err != nil { + return err + } + return w.WriteInt32(6, int64(q.FlagVer)) +} + +func (q *QueryHttpDnsReq) ReadFrom(r *taf.Reader) error { + var err error + if q.UID, err = r.ReadInt64(0, false, q.UID); err != nil { + return err + } + if q.UA, err = r.ReadString(1, false, q.UA); err != nil { + return err + } + if q.Domains, err = r.ReadListString(2, false); err != nil { + return err + } + if q.AppSrc, err = r.ReadString(3, false, q.AppSrc); err != nil { + return err + } + if q.Extra, err = r.ReadString(4, false, q.Extra); err != nil { + return err + } + if q.FlagType, err = r.ReadInt32(5, false, q.FlagType); err != nil { + return err + } + q.FlagVer, err = r.ReadInt32(6, false, q.FlagVer) + return err +} + +// HttpDnsHosts 是 tRsp tag0 里单个域名的解析结果:host 列表 + ttl 秒。 +type HttpDnsHosts struct { + Hosts []string + TTL int32 + Extra []string +} + +func (h *HttpDnsHosts) WriteTo(w *taf.Writer) error { return nil } + +func (h *HttpDnsHosts) ReadFrom(r *taf.Reader) error { + var err error + if h.Hosts, err = r.ReadListString(0, false); err != nil { + return err + } + if h.TTL, err = r.ReadInt32(1, false, h.TTL); err != nil { + return err + } + h.Extra, err = r.ReadListString(2, false) + return err +} + +// QueryHttpDnsResp 对应 launch.queryHttpDns 的 tRsp:tag0=域名->host 列表, tag1=状态。 +type QueryHttpDnsResp struct { + Hosts map[string]HttpDnsHosts + Status int32 +} + +func (r *QueryHttpDnsResp) WriteTo(w *taf.Writer) error { return nil } + +func (r *QueryHttpDnsResp) ReadFrom(rd *taf.Reader) error { + items, err := rd.ReadMapStringStruct(0, false, func() taf.Unmarshaler { return &HttpDnsHosts{} }) + if err != nil { + return err + } + r.Hosts = make(map[string]HttpDnsHosts, len(items)) + for key, item := range items { + if hosts, ok := item.(*HttpDnsHosts); ok { + r.Hosts[key] = *hosts + } + } + r.Status, err = rd.ReadInt32(1, false, r.Status) + return err +} + +// WSSHosts 返回指定域名解析到的 WSS host 列表,保持服务端下发顺序。 +func (r QueryHttpDnsResp) WSSHosts(domain string) []string { + item, ok := r.Hosts[domain] + if !ok { + return nil + } + out := make([]string, 0, len(item.Hosts)) + for _, host := range item.Hosts { + if trimmed := strings.TrimSpace(host); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// TTL 返回指定域名的缓存时间。 +func (r QueryHttpDnsResp) TTL(domain string) time.Duration { + item, ok := r.Hosts[domain] + if !ok || item.TTL <= 0 { + return DefaultDNSTTL + } + return time.Duration(item.TTL) * time.Second +} + +// PingSignalReq 对应 launch.pingSignal:0="launch", 1="pingSignal", 2=扩展 map。 +type PingSignalReq struct { + Extra map[string]string +} + +func (p *PingSignalReq) WriteTo(w *taf.Writer) error { + if err := w.WriteString(0, ServantLaunch); err != nil { + return err + } + if err := w.WriteString(1, FuncPingSignal); err != nil { + return err + } + return w.WriteMapStringString(2, p.Extra) +} + +func (p *PingSignalReq) ReadFrom(r *taf.Reader) error { + var err error + if _, err = r.ReadString(0, false, ServantLaunch); err != nil { + return err + } + if _, err = r.ReadString(1, false, FuncPingSignal); err != nil { + return err + } + p.Extra, err = r.ReadMapStringString(2, false) + return err +} + +// PingSignalResp 对应 launch.pingSignal 的 tRsp:tag2 是 signal_ap_start/end 等服务端时间戳。 +type PingSignalResp struct { + Servant string + Func string + Values map[string]int64 +} + +func (r *PingSignalResp) WriteTo(w *taf.Writer) error { return nil } + +func (r *PingSignalResp) ReadFrom(rd *taf.Reader) error { + var err error + if r.Servant, err = rd.ReadString(0, false, r.Servant); err != nil { + return err + } + if r.Func, err = rd.ReadString(1, false, r.Func); err != nil { + return err + } + r.Values, err = rd.ReadMapStringInt64(2, false) + return err +} + +// ServerElapsed 返回服务端 signal_ap_start/end 的差值(毫秒),不可得时返回 0。 +func (r PingSignalResp) ServerElapsed() time.Duration { + start, okStart := r.Values["signal_ap_start"] + end, okEnd := r.Values["signal_ap_end"] + if !okStart || !okEnd || end < start { + return 0 + } + return time.Duration(end-start) * time.Millisecond +} + +// GetConfigResp 对应 mobileui.getConfig 的 tRsp: +// tag0=平台开关 map,tag1=按平台下发的配置版本 map。 +type GetConfigResp struct { + Flags map[string]string + Exps map[string]string +} + +func (r *GetConfigResp) WriteTo(w *taf.Writer) error { return nil } + +func (r *GetConfigResp) ReadFrom(rd *taf.Reader) error { + var err error + if r.Flags, err = rd.ReadMapStringString(0, false); err != nil { + return err + } + r.Exps, err = rd.ReadMapStringString(1, false) + return err +} + +// Exp 返回握手 baseinfo.tag5 与 UpdateUserExps 使用的版本串。 +func (r GetConfigResp) Exp() string { + return FormatConfigExps(r.Exps) +} + +// FormatConfigExps 把 {"58664":"107263"} 拼成 "58664.107263"。 +// +// 2026-10-13 HAR 中 WSS 握手 baseinfo.tag5 与 UpdateUserExpsReq 都用这个形式; +// 多个平台时按 key 升序用 "." 连接(实测样本只有一个平台)。 +func FormatConfigExps(exps map[string]string) string { + if len(exps) == 0 { + return "" + } + keys := make([]string, 0, len(exps)) + for key := range exps { + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"."+exps[key]) + } + return strings.Join(parts, ".") +} diff --git a/internal/huya/launch_test.go b/internal/huya/launch_test.go new file mode 100644 index 0000000..8e80018 --- /dev/null +++ b/internal/huya/launch_test.go @@ -0,0 +1,349 @@ +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) + } + }) + } +} diff --git a/internal/huya/wss_client.go b/internal/huya/wss_client.go index 8957c30..dca2a30 100644 --- a/internal/huya/wss_client.go +++ b/internal/huya/wss_client.go @@ -28,7 +28,13 @@ const ( ) type WSSClient struct { - Host string + // Host 为空时(默认)自动通过 launch.queryHttpDns 解析 WSS host; + // 显式赋值则固定使用该 host。 + Host string + // AutoResolveHost 为 false 时不做 DNS 解析,直接回退 DefaultWSSHost。 + AutoResolveHost bool + // DNSEndpoint 是 queryHttpDns 使用的 cdnws 入口,为空时用 DefaultCDNWSEndpoint。 + DNSEndpoint string Origin string UserAgent string Dialer *websocket.Dialer @@ -42,6 +48,12 @@ type WSSClient struct { sessions map[string]*WSSSession // dialing 记录正在建连的 uid,避免并发调用重复握手。 dialing map[string]chan struct{} + // exps 缓存 uid -> getConfig 版本串,供后续握手 baseinfo.exp 使用。 + exps map[string]string + // dnsHosts 缓存 queryHttpDns 结果,按顺序轮询使用。 + dnsHosts []string + dnsIndex int + dnsExpireAt time.Time } type WSSSession struct { @@ -63,6 +75,8 @@ type WSSSession struct { nextWUPRequestID int32 launchGUID string launchIP string + // configExps 是 getConfig 下发的版本 map,UpdateUserExpsReq 要回传它。 + configExps map[string]string } type rpcResult struct { @@ -72,13 +86,14 @@ type rpcResult struct { func NewWSSClient(logger func(string)) *WSSClient { return &WSSClient{ - Host: DefaultWSSHost, - Origin: DefaultWSSOrigin, - UserAgent: PCUserAgent, - Logger: logger, - CallTimeout: 10 * time.Second, - sessions: map[string]*WSSSession{}, - dialing: map[string]chan struct{}{}, + AutoResolveHost: true, + Origin: DefaultWSSOrigin, + UserAgent: PCUserAgent, + Logger: logger, + CallTimeout: 10 * time.Second, + sessions: map[string]*WSSSession{}, + dialing: map[string]chan struct{}{}, + exps: map[string]string{}, } } @@ -107,8 +122,111 @@ func (c *WSSClient) Close() error { } func (c *WSSClient) PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error { - _, err := c.session(ctx, uid, cookie) - return err + if _, err := c.session(ctx, uid, cookie); err != nil { + return err + } + // pingSignal 只为确认业务通道可用,失败不影响抢购,仅记录日志。 + if _, err := c.PingSignal(ctx, uid, cookie); err != nil { + c.logf("[WSS] pingSignal 失败但连接可用: %v", err) + } + return nil +} + +// PingSignal 通过 WSS 通道调用 launch.pingSignal,用于预热后的延迟探测。 +func (c *WSSClient) PingSignal(ctx context.Context, uid int64, cookie string) (*PingSignalResp, error) { + session, err := c.session(ctx, uid, cookie) + if err != nil { + return nil, err + } + req := &PingSignalReq{Extra: map[string]string{}} + var resp PingSignalResp + start := time.Now() + if _, err := session.callRPC(ctx, ServantLaunch, FuncPingSignal, req, &resp); err != nil { + return nil, err + } + c.logf("[WSS] pingSignal 往返=%s 服务端=%s", time.Since(start).Round(time.Millisecond), resp.ServerElapsed()) + return &resp, nil +} + +// resolveHost 返回本次建连使用的 host。 +// +// 优先级:显式配置的 Host > queryHttpDns 结果(TTL 内缓存、多 host 轮询)> DefaultWSSHost。 +func (c *WSSClient) resolveHost(ctx context.Context, uid int64, cookie string) string { + if host := strings.TrimSpace(c.Host); host != "" { + return host + } + if !c.AutoResolveHost { + return DefaultWSSHost + } + if host := c.nextCachedDNSHost(); host != "" { + return host + } + resp, err := c.queryHttpDns(ctx, uid, cookie) + if err != nil { + c.logf("[WSS] queryHttpDns 失败,回退 %s: %v", DefaultWSSHost, err) + return DefaultWSSHost + } + hosts := resp.WSSHosts(HttpDnsDomain) + if len(hosts) == 0 { + c.logf("[WSS] queryHttpDns 未返回 host,回退 %s", DefaultWSSHost) + return DefaultWSSHost + } + c.storeDNSHosts(hosts, resp.TTL(HttpDnsDomain)) + return c.nextCachedDNSHost() +} + +func (c *WSSClient) queryHttpDns(ctx context.Context, uid int64, cookie string) (*QueryHttpDnsResp, error) { + endpoint := strings.TrimSpace(c.DNSEndpoint) + if endpoint == "" { + endpoint = DefaultCDNWSEndpoint + } + client := &HTTPClient{ + Endpoint: endpoint, + Client: &http.Client{Timeout: 5 * time.Second}, + Logger: c.Logger, + } + return client.QueryHttpDns(ctx, uid, cookie, []string{HttpDnsDomain}) +} + +func (c *WSSClient) nextCachedDNSHost() string { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.dnsHosts) == 0 || time.Now().After(c.dnsExpireAt) { + return "" + } + host := c.dnsHosts[c.dnsIndex%len(c.dnsHosts)] + c.dnsIndex++ + return host +} + +func (c *WSSClient) storeDNSHosts(hosts []string, ttl time.Duration) { + if ttl <= 0 { + ttl = DefaultDNSTTL + } + c.mu.Lock() + defer c.mu.Unlock() + c.dnsHosts = append([]string(nil), hosts...) + c.dnsIndex = 0 + c.dnsExpireAt = time.Now().Add(ttl) + c.logf("[WSS] queryHttpDns 缓存 %d 个 host(ttl=%s)", len(hosts), ttl) +} + +func (c *WSSClient) configExp(uid int64) string { + c.mu.Lock() + defer c.mu.Unlock() + return c.exps[strconv.FormatInt(uid, 10)] +} + +func (c *WSSClient) setConfigExp(uid int64, exp string) { + if exp == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if c.exps == nil { + c.exps = map[string]string{} + } + c.exps[strconv.FormatInt(uid, 10)] = exp } // session 返回 uid 对应的连接,必要时新建。 @@ -162,7 +280,7 @@ func (c *WSSClient) dial(ctx context.Context, uid int64, cookie string) (*WSSSes } func (c *WSSClient) dialSession(ctx context.Context, uid int64, cookie string) (*WSSSession, error) { - host := c.Host + host := c.resolveHost(ctx, uid, cookie) if host == "" { host = DefaultWSSHost } @@ -176,7 +294,7 @@ func (c *WSSClient) dialSession(ctx context.Context, uid int64, cookie string) ( } guid := CookieValue(cookie, "guid") - baseinfo, err := GenerateWSSBaseinfo(uid, guid) + baseinfo, err := GenerateWSSBaseinfo(uid, guid, c.configExp(uid)) if err != nil { return nil, err } @@ -413,12 +531,29 @@ func (s *WSSSession) callWSLaunch(ctx context.Context) error { func (s *WSSSession) callGetConfig(ctx context.Context) error { req := &getConfigReq{UID: s.uid, GUID: s.launchGUID, Cookie: s.cookie} - _, err := s.callWUP(ctx, "mobileui", "getConfig", req) - return err + resp, err := s.callWUP(ctx, "mobileui", FuncGetConfig, req) + if err != nil { + return err + } + var cfg GetConfigResp + ok, err := resp.ReadStruct("tRsp", &cfg) + if err != nil { + return err + } + if !ok { + return errors.New("getConfig 响应缺少 tRsp") + } + s.configExps = cfg.Exps + exp := cfg.Exp() + s.client.setConfigExp(s.uid, exp) + if exp != "" { + s.client.logf("[WSS] getConfig 版本=%s 开关=%d 项", exp, len(cfg.Flags)) + } + return nil } func (s *WSSSession) sendUpdateUserExps(ctx context.Context) error { - payload, err := encodeWSSStructCommand(WSSCmdUpdateUserExpsReq, &wsUpdateUserExpsReq{}) + payload, err := encodeWSSStructCommand(WSSCmdUpdateUserExpsReq, &wsUpdateUserExpsReq{Exps: s.configExps}) if err != nil { return err } @@ -656,10 +791,14 @@ func (r *wsLaunchReq) WriteTo(w *taf.Writer) error { return w.WriteStructEnd() } -type wsUpdateUserExpsReq struct{} +// wsUpdateUserExpsReq 对应 EWSCmdC2S_UpdateUserExpsReq(23): +// 回传 getConfig 下发的平台版本 map(实测 {"58664":"107263"})。 +type wsUpdateUserExpsReq struct { + Exps map[string]string +} func (r *wsUpdateUserExpsReq) WriteTo(w *taf.Writer) error { - return w.WriteMapStringString(0, nil) + return w.WriteMapStringString(0, r.Exps) } type wsVerifyCookieReq struct { diff --git a/internal/huya/wss_client_test.go b/internal/huya/wss_client_test.go index cfe0047..2193b80 100644 --- a/internal/huya/wss_client_test.go +++ b/internal/huya/wss_client_test.go @@ -40,7 +40,7 @@ func TestWSUpdateUserExpsReqEncoding(t *testing.T) { } func TestGenerateWSSBaseinfoMatchesBrowserShape(t *testing.T) { - encoded, err := GenerateWSSBaseinfo(123, "guid-value") + encoded, err := GenerateWSSBaseinfo(123, "guid-value", "58664.107263") if err != nil { t.Fatal(err) } @@ -70,6 +70,10 @@ func TestGenerateWSSBaseinfoMatchesBrowserShape(t *testing.T) { 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) @@ -82,6 +86,9 @@ func TestGenerateWSSBaseinfoMatchesBrowserShape(t *testing.T) { if uid != 123 || guid != "guid-value" || ua != WSSCookieUA || appSrc != "HUYA&ZH&2052" { t.Fatalf("unexpected baseinfo: uid=%d guid=%q ua=%q appSrc=%q", uid, guid, ua, appSrc) } + if exp != "58664.107263" { + t.Fatalf("baseinfo exp=%q, want 58664.107263", exp) + } if cookie != "" || traceID != "" { t.Fatalf("wss baseinfo should not carry cookie/trace: cookie=%q trace=%q", cookie, traceID) } diff --git a/internal/taf/reader.go b/internal/taf/reader.go index d384ea9..b632bfb 100644 --- a/internal/taf/reader.go +++ b/internal/taf/reader.go @@ -319,6 +319,121 @@ func (r *Reader) ReadMapStringString(tag int, required bool) (map[string]string, return out, nil } +// ReadMapStringInt64 读取 map,供 launch.pingSignal 这类响应使用。 +func (r *Reader) ReadMapStringInt64(tag int, required bool) (map[string]int64, error) { + typ, ok, err := r.findTag(tag, required) + if err != nil || !ok { + return map[string]int64{}, err + } + if typ != TypeMap { + return nil, fmt.Errorf("taf: tag=%d 不是 map: 0x%02x", tag, byte(typ)) + } + n, err := r.readLength() + if err != nil { + return nil, err + } + out := make(map[string]int64, n) + for i := int64(0); i < n; i++ { + _, kt, err := r.ReadHead() + if err != nil { + return nil, err + } + k, err := r.ReadStringValue(kt) + if err != nil { + return nil, err + } + _, vt, err := r.ReadHead() + if err != nil { + return nil, err + } + v, err := r.ReadIntValue(vt) + if err != nil { + return nil, err + } + out[k] = v + } + return out, nil +} + +// ReadMapStringStruct 读取 map,value 由 factory 创建后自行 ReadFrom。 +func (r *Reader) ReadMapStringStruct(tag int, required bool, factory func() Unmarshaler) (map[string]Unmarshaler, error) { + typ, ok, err := r.findTag(tag, required) + if err != nil || !ok { + return map[string]Unmarshaler{}, err + } + if typ != TypeMap { + return nil, fmt.Errorf("taf: tag=%d 不是 map: 0x%02x", tag, byte(typ)) + } + n, err := r.readLength() + if err != nil { + return nil, err + } + out := make(map[string]Unmarshaler, n) + for i := int64(0); i < n; i++ { + _, kt, err := r.ReadHead() + if err != nil { + return nil, err + } + k, err := r.ReadStringValue(kt) + if err != nil { + return nil, err + } + _, vt, err := r.ReadHead() + if err != nil { + return nil, err + } + if vt != TypeStructBegin { + if err := r.Skip(vt); err != nil { + return nil, err + } + continue + } + obj := factory() + if err := obj.ReadFrom(r); err != nil { + return nil, err + } + if err := r.SkipToStructEnd(); err != nil { + return nil, err + } + out[k] = obj + } + return out, nil +} + +// ReadListString 读取 list;非字符串元素会被跳过而不是报错。 +func (r *Reader) ReadListString(tag int, required bool) ([]string, error) { + typ, ok, err := r.findTag(tag, required) + if err != nil || !ok { + return nil, err + } + if typ != TypeList { + return nil, fmt.Errorf("taf: tag=%d 不是 list: 0x%02x", tag, byte(typ)) + } + n, err := r.readLength() + if err != nil { + return nil, err + } + out := make([]string, 0, n) + for i := int64(0); i < n; i++ { + _, it, err := r.ReadHead() + if err != nil { + return nil, err + } + if it != TypeString1 && it != TypeString4 { + if err := r.Skip(it); err != nil { + return nil, err + } + continue + } + value, err := r.ReadStringValue(it) + if err != nil { + return nil, err + } + out = append(out, value) + } + return out, nil +} + func (r *Reader) ReadMapStringBytes(tag int, required bool) (map[string][]byte, error) { typ, ok, err := r.findTag(tag, required) if err != nil || !ok { diff --git a/internal/taf/writer.go b/internal/taf/writer.go index 2453245..a4c74bd 100644 --- a/internal/taf/writer.go +++ b/internal/taf/writer.go @@ -216,6 +216,21 @@ func (w *Writer) WriteMapStringBytes(tag int, value map[string][]byte) error { return nil } +func (w *Writer) WriteListString(tag int, values []string) error { + if err := w.WriteHead(tag, TypeList); err != nil { + return err + } + if err := w.WriteInt32(0, int64(len(values))); err != nil { + return err + } + for _, value := range values { + if err := w.WriteString(0, value); err != nil { + return err + } + } + return nil +} + func (w *Writer) WriteListStruct(tag int, values []Marshaler) error { if err := w.WriteHead(tag, TypeList); err != nil { return err