Files
huya-go/internal/huya/launch.go
T
yml2213 828236eb1d 新增服务端时间校准,定时抢购按服务端时钟对齐触发
- 协议事实确认:launch.pingSignal 的 signal_ap_start/signal_ap_end 是 Unix 毫秒墙钟时间戳。真实夹具 signal_ap_start=1791891743288 对应北京时间 2026-10-13 19:42:23,与同批抓包订单号里的秒级时间戳 1791891953 同日;start 是服务端收到请求的时刻、end 是写出响应的时刻,实测相差 13ms
- 新增 internal/huya/timecal.go:TimeSample/TimeCalibration/OffsetHistory/RunTimeCalibration/AlignedTriggerTime/TryCalibrateServerTime。偏移 = signal_ap_start − (SentAt + RTT/2),误差上界取 RTT/2;默认 3 轮采样取「误差上界最小、其次 RTT 最低」的样本,再对最近 3 次偏移取中位数平滑;ServerTimestamp 丢弃解析后不在 2020-2100 的时间戳,避免字段单位变化时按错误偏移挪走抢购窗口
- HTTPClient 与 WSSClient 各自实现 TimeCalibrator(PingSignalSample 共用 pingSignalSampleWith 采样逻辑),各自持有 OffsetHistory
- launch.go:ServerElapsed 改用 PingSignalStartKey/PingSignalEndKey 常量
- CLI:新增 -time-sync(默认开),-at 按服务端时间理解,触发时刻 = 目标 − lead − 偏移;校准日志输出服务端时间、本机偏移、误差上界、RTT 与样本数
- GUI:Config.TimeSync(默认 true)+ 对齐触发与校准日志;前端设置面板新增「时间校准」开关并纳入配置读写
- 测试:新增 internal/huya/timecal_test.go,覆盖真实夹具时间戳换算、秒级/异常值拒绝、RTT/2 中点公式、多轮取优、中位数滤跳变、全失败降级与部分失败容错、跨钟差的触发时刻换算;README 补「时间校准」一节
- 验证:gofmt 干净、go build ./...、go test ./...、go test -race(huya/sniper/gui)、前端 npm run typecheck/build 全部通过
2026-10-16 16:13:29 +08:00

270 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// dnsFailureCooldown 是 queryHttpDns 失败后的冷却时间。
dnsFailureCooldown = 30 * time.Second
)
// 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[PingSignalStartKey]
end, okEnd := r.Values[PingSignalEndKey]
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, ".")
}