按 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:
yml2213
2026-10-13 20:19:14 +08:00
parent e8bb4eb571
commit 36aadae90a
16 changed files with 1097 additions and 45 deletions
+13 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 也先查询列表")
+7 -1
View File
@@ -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>
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+8 -4
View File
@@ -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
}
+5 -2
View File
@@ -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
+39
View File
@@ -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
+267
View File
@@ -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, ".")
}
+349
View File
@@ -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
View File
@@ -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 {
+8 -1
View File
@@ -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)
}
+115
View File
@@ -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 {
+15
View File
@@ -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