Files
huya-go/internal/huya/http_client.go
T

279 lines
9.4 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)
// timeHistory 保存最近几次校准偏移,用于中位数平滑。
timeHistory OffsetHistory
}
type CallOptions struct {
UID int64
GUID string
Cookie string
RequestID int32
Timeout time.Duration
}
// DefaultMaxConnsPerHost 是多账号抢购时允许的每主机连接数。
// Go 默认 Transport 的 MaxIdleConnsPerHost 只有 2,多账号并发时连接会被反复
// 建立/丢弃(参考 dygou_go 的 http.Transport 调参),这里放宽到能覆盖常见并发档位。
const (
DefaultMaxConnsPerHost = 64
DefaultMaxIdleConns = 128
DefaultMaxIdleConnsPerHost = 64
)
func NewHTTPClient(logger func(string)) *HTTPClient {
return &HTTPClient{
Endpoint: DefaultCDNWSEndpoint,
Client: &http.Client{
Timeout: 15 * time.Second,
Transport: newDefaultTransport(),
},
Logger: logger,
}
}
// newDefaultTransport 构造带连接池参数的 Transport,避免多账号并发时退化成
// 每次请求都新建连接。
func newDefaultTransport() *http.Transport {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxConnsPerHost = DefaultMaxConnsPerHost
transport.MaxIdleConns = DefaultMaxIdleConns
transport.MaxIdleConnsPerHost = DefaultMaxIdleConnsPerHost
transport.IdleConnTimeout = 30 * time.Second
transport.ForceAttemptHTTP2 = true
return transport
}
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
}
// PingSignalSample 调一次 launch.pingSignal 并返回可用于时间校准的样本。
func (c *HTTPClient) PingSignalSample(ctx context.Context, uid int64, cookie string) (TimeSample, error) {
return pingSignalSampleWith(func(req *PingSignalReq, resp *PingSignalResp) error {
_, err := c.CallRPC(ctx, ServantLaunch, FuncPingSignal, req, resp, CallOptions{
UID: uid,
GUID: CookieValue(cookie, "guid"),
Cookie: cookie,
})
return err
})
}
// CalibrateServerTime 用 pingSignal 的服务端时间戳做多轮校准(实现 TimeCalibrator)。
func (c *HTTPClient) CalibrateServerTime(ctx context.Context, uid int64, cookie string, rounds int) (TimeCalibration, error) {
calibration, err := RunTimeCalibration(ctx, rounds, &c.timeHistory, func(ctx context.Context) (TimeSample, error) {
return c.PingSignalSample(ctx, uid, cookie)
})
if err != nil {
c.logf("[HTTP] 时间校准失败: %v", err)
return calibration, err
}
c.logf("[HTTP] 时间校准完成: %s", calibration.Summary())
return calibration, 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)]))
}
// debug 级打印原始帧前缀:协议异常(缺少 tRsp)时这是唯一的现场证据。
c.logf("[HTTP] 响应 %s.%s status=%d %dB head=%s",
service, method, httpResp.StatusCode, len(respBody), wup.HexHead(respBody, rawFrameHeadBytes))
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, protocolResponseError(service, method, &resp)
}
}
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项 类目=%s msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.CategorySummary(), 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
}