279 lines
9.4 KiB
Go
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
|
|
}
|