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 }