Files
huya-go/internal/huya/http_client.go
T
yml2213 36aadae90a 按 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 同步协议细节
2026-10-13 20:19:14 +08:00

227 lines
7.1 KiB
Go

package huya
import (
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
"time"
"huya-go/internal/taf"
"huya-go/internal/wup"
)
const DefaultCDNWSEndpoint = "https://cdnws.api.huya.com/"
type HTTPClient struct {
Endpoint string
Client *http.Client
Logger func(string)
}
type CallOptions struct {
UID int64
GUID string
Cookie string
RequestID int32
Timeout time.Duration
}
func NewHTTPClient(logger func(string)) *HTTPClient {
return &HTTPClient{
Endpoint: DefaultCDNWSEndpoint,
Client: &http.Client{
Timeout: 15 * time.Second,
},
Logger: logger,
}
}
func (c *HTTPClient) logf(format string, args ...any) {
if c.Logger != nil {
c.Logger(fmt.Sprintf(format, args...))
}
}
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
}
reqWup := wup.NewRequest(service, method, opt.RequestID)
if err := reqWup.WriteStruct("tReq", reqStruct); err != nil {
return nil, err
}
body, err := reqWup.Encode()
if err != nil {
return nil, err
}
baseinfo, err := GenerateHTTPBaseinfo(opt.UID, opt.GUID, opt.Cookie)
if err != nil {
return nil, err
}
endpoint := c.Endpoint
if endpoint == "" {
endpoint = DefaultCDNWSEndpoint
}
url := endpoint + "?baseinfo=" + baseinfo
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("User-Agent", PCUserAgent)
httpReq.Header.Set("Origin", "https://zt.huya.com")
httpReq.Header.Set("Referer", "https://zt.huya.com/")
httpReq.Header.Set("Content-Type", "application/octet-stream")
httpReq.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
client := c.Client
if client == nil {
client = http.DefaultClient
}
if opt.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opt.Timeout)
defer cancel()
httpReq = httpReq.WithContext(ctx)
}
c.logf("[HTTP] 请求 %s.%s", service, method)
httpResp, err := client.Do(httpReq)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
respBody, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
if len(respBody) >= 2 && respBody[0] == 0x1f && respBody[1] == 0x8b {
gz, err := gzip.NewReader(bytes.NewReader(respBody))
if err != nil {
return nil, err
}
respBody, err = io.ReadAll(gz)
_ = gz.Close()
if err != nil {
return nil, err
}
}
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("http status %d: %s", httpResp.StatusCode, string(respBody[:min(len(respBody), 200)]))
}
var resp wup.Response
if err := resp.Decode(respBody); err != nil {
return nil, err
}
if respStruct != nil {
ok, err := resp.ReadStruct("tRsp", respStruct)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("%s.%s 响应缺少 tRsp/tResp", service, method)
}
}
return &resp, nil
}
func (c *HTTPClient) buildUser(uid int64, cookie, ua string) ActivityUserID {
return NewActivityUserID(uid, cookie, ua)
}
func (c *HTTPClient) QueryUserScore(ctx context.Context, uid int64, cookie string, sid int32) (*GetUserScoreResp, error) {
req := &GetUserScoreReq{User: c.buildUser(uid, cookie, ActivityWebUA), SID: sid}
var resp GetUserScoreResp
_, err := c.CallRPC(ctx, ServantWebActUI, FuncGetUserScore, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 2})
if err == nil {
c.logf("[HTTP] 积分已解码 uid=%d sid=%d status=%d 可用=%d msg=%s", uid, sid, resp.Status, resp.AvailableScore(), resp.Msg)
}
return &resp, err
}
func (c *HTTPClient) GetActPrizeList(ctx context.Context, uid int64, cookie string, sid int32) (*GetActPrizeListResp, error) {
req := &GetActPrizeListReq{User: c.buildUser(uid, cookie, ActivityWebUA), SID: sid}
var resp GetActPrizeListResp
_, err := c.CallRPC(ctx, ServantWebActUI, FuncPrizeList, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 1})
if err == nil {
left, total, leftKnown, totalKnown := prizeListInventorySummary(resp.Prizes)
c.logf("[HTTP] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg)
}
return &resp, err
}
func (c *HTTPClient) GetUserPrizeRecords(ctx context.Context, uid int64, cookie string, sid int32) (*GetUserPrizeRecordsResp, error) {
user := c.buildUser(uid, cookie, ActivityRecordUA)
user.GUID = CookieValue(cookie, "guid")
req := &GetUserPrizeRecordsReq{User: user, SID: sid}
var resp GetUserPrizeRecordsResp
_, err := c.CallRPC(ctx, ServantWebActUI, FuncPrizeRecords, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 6})
if err == nil {
c.logf("[HTTP] 兑换记录已解码 uid=%d sid=%d status=%d count=%d msg=%s", uid, sid, resp.Status, len(resp.Records), resp.Msg)
}
return &resp, err
}
func (c *HTTPClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie string, sid, pid int32) (*ScoreExchangePrizeResp, error) {
req := &ScoreExchangePrizeReq{
User: c.buildUser(uid, cookie, ActivityWebUA),
SID: sid,
PID: pid,
ClientEnv: map[string]string{},
IsRole: 1,
}
var resp ScoreExchangePrizeResp
_, err := c.CallRPC(ctx, ServantWebActUI, FuncExchangePrize, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 1})
if err == nil {
c.logf("[HTTP] 兑换结果已解码 uid=%d sid=%d pid=%d status=%d order=%s msg=%s", uid, sid, pid, resp.Status, resp.OrderID, exchangeResponseMessage(&resp))
}
return &resp, err
}