按 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 同步协议细节
This commit is contained in:
@@ -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_<uid>_<时间戳>_<随机>`
|
||||
|
||||
该活动的列表/积分/兑换接口与 `sid=2203` 走同一套 `webActUI`,可直接用 `-cmd list -sid 2277` 验证;同一次抓包里「开通精英宝典 / 购买精英币」属于商城付费链路(`shopMiddleUI.*` + 支付宝),本项目未实现,详见 `WSS_NOTES.md`。
|
||||
|
||||
+52
-2
@@ -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<string> # ["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<domain, {0: list<string> hosts, 1: int32 ttl秒, 2: list<string>}> # 实测 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<string,string> 空
|
||||
tRsp.0 "launch" tRsp.1 "pingSignal" tRsp.2 map<string,int64>{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 编解码单测。
|
||||
|
||||
+1
-1
@@ -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 也先查询列表")
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="wssHost" label="WSS Host">
|
||||
<Input allowClear />
|
||||
<Input allowClear placeholder="留空自动解析" />
|
||||
</Form.Item>
|
||||
<div className="settings-summary settings-wide">
|
||||
<Typography.Text type="secondary">CK 来源</Typography.Text>
|
||||
|
||||
@@ -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<string, string>;
|
||||
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 {
|
||||
|
||||
+2
-4
@@ -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
|
||||
}
|
||||
|
||||
+46
-12
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ".")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+156
-17
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -319,6 +319,121 @@ func (r *Reader) ReadMapStringString(tag int, required bool) (map[string]string,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadMapStringInt64 读取 map<string,int64>,供 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<string, struct>,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<string>;非字符串元素会被跳过而不是报错。
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user