From 828236eb1d7ec24c55c78e089d0dc5a253ad15d5 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 16 Oct 2026 16:13:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=A0=A1=E5=87=86=EF=BC=8C=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E6=8A=A2=E8=B4=AD=E6=8C=89=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=97=B6?= =?UTF-8?q?=E9=92=9F=E5=AF=B9=E9=BD=90=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 协议事实确认: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 全部通过 --- README.md | 16 ++ cmd/huya-go/main.go | 22 ++- frontend/src/App.tsx | 7 + internal/gui/app.go | 24 ++- internal/huya/http_client.go | 27 ++++ internal/huya/launch.go | 4 +- internal/huya/timecal.go | 267 ++++++++++++++++++++++++++++++++++ internal/huya/timecal_test.go | 215 +++++++++++++++++++++++++++ internal/huya/wss_client.go | 27 ++++ 9 files changed, 600 insertions(+), 9 deletions(-) create mode 100644 internal/huya/timecal.go create mode 100644 internal/huya/timecal_test.go diff --git a/README.md b/README.md index 5736d42..e7af78b 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签) - `-timeout`:单次调用超时秒数。 - `-loops`、`-delay`、`-concurrency`:轮询次数、轮询间隔、并发额度。`-concurrency` 限制的是**同时在途的请求数**(不是同时运行的账号数),账号在退避等待期间不占用额度。 - `-stop-after-success`:`monitor` 中任一账号成功后是否停止。 +- `-at`:定时开始时间,支持 `RFC3339`、`2006-01-02 15:04:05`、`HH:MM:SS`;开启时间校准时按**服务端时间**理解。 +- `-lead`:相对 `-at` 的提前启动量,默认 `2500ms`(用于覆盖网络往返,让请求在开抢瞬间已经在途)。 +- `-time-sync`:默认开启。定时前用 `launch.pingSignal` 校准服务端与本机的钟差,并把触发时刻对齐到服务端时钟(详见「时间校准」)。 - `-log-dir`:日志目录,默认使用应用数据目录下的 `logs`。 - `-log-level`:日志最低等级,`debug`/`info`/`warn`/`error`,默认 `info`。 - `-v`:输出调试日志(等价于 `-log-level debug`,并把网络日志同时打到 stderr)。 @@ -138,6 +141,19 @@ GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签) - **任务结束汇总**:每次监控结束输出一行汇总(轮次 / 账号 / 目标 / 成功账号 / 耗时 / 请求次数)与按次数排序的结局分布(`success`、`sold_out`、`too_frequent`、`not_found` 等),便于复盘。 - **HTTP 传输连接池**:`http` 传输补上 `MaxConnsPerHost=64`、`MaxIdleConnsPerHost=64`(Go 默认每主机只有 2 个空闲连接),多账号并发时不再反复重建连接。 +## 时间校准(2026-10-16 新增) + +定时抢购默认先做一次服务端时间校准(CLI `-time-sync`,GUI 设置里的「时间校准」开关,默认开): + +- **数据来源**:`launch.pingSignal` 响应里的 `signal_ap_start` / `signal_ap_end`。2026-10-13 抓包确认这两个字段是 Unix 毫秒时间戳(`start` 是服务端收到请求的时刻,`end` 是写出响应的时刻,实测相差 13ms);真实字节夹具见 `internal/huya/launch_test.go` 的 `realPingSignalRspHex`。 +- **钟差估计**:`偏移 = signal_ap_start − (发送时刻 + RTT/2)`,误差上界取 `RTT/2`——服务端打时间戳的具体位置落在发送与接收之间,无法再精确,按最坏情况估计。 +- **多轮取优**:默认 3 轮采样,取「误差上界最小、其次 RTT 最低」的样本;再对最近 3 次偏移取中位数平滑,压掉单轮网络抖动。 +- **生效方式**:`-at` / `startAt` 按服务端时间理解,触发时刻 = `目标时刻 − 提前量 − 偏移`。本机时钟比服务端慢 300ms 就自动提前 300ms,反之推后。 +- **失败降级**:一次样本都拿不到时只告警,仍按本机时钟触发;时间戳明显不合理(解析出来不是这个世纪的毫秒值)会被丢弃,避免按错误偏移整体挪走抢购窗口。 +- **日志**:`时间校准完成: 服务端=2026-10-16 20:00:01.234 本机偏移=+123ms 误差<=45ms RTT=90ms 样本=3 已平滑`。 + +校准每次只多花几百毫秒(3 轮 pingSignal),发生在预热之后、等待定时之前,因此不会挤占开抢窗口。 + ## 已确认的小黄鸭参数 2026-07-06 浏览器手动抢购抓包确认: diff --git a/cmd/huya-go/main.go b/cmd/huya-go/main.go index 7f686df..1e788a1 100644 --- a/cmd/huya-go/main.go +++ b/cmd/huya-go/main.go @@ -50,6 +50,7 @@ func main() { accountStop = flag.Bool("account-stop", true, "monitor 单账号成功后停止该账号该奖品") at = flag.String("at", "", "定时开始时间,支持 RFC3339 或 2006-01-02 15:04:05") leadMS = flag.Int("lead", 2500, "monitor 相对 -at 提前启动毫秒") + timeSync = flag.Bool("time-sync", true, "monitor 定时前用 pingSignal 校准服务端时间,并把 -at 视为服务端时间") timeoutSec = flag.Int("timeout", 15, "单次请求超时秒数") transport = flag.String("transport", "wss", "传输层:wss/http,默认 wss") wssHost = flag.String("wss-host", "", "WSS host,留空则通过 launch.queryHttpDns 自动解析") @@ -194,7 +195,7 @@ func main() { exitErr(err) } } - waitForMonitor(ctx, startAt, time.Duration(*leadMS)*time.Millisecond) + waitForMonitor(ctx, client, accounts[0], startAt, time.Duration(*leadMS)*time.Millisecond, *timeSync) err := runner.RunTargets(ctx, accounts, targets, cfg, emit) if err != nil && !errors.Is(err, context.Canceled) { exitErr(err) @@ -210,11 +211,28 @@ func waitForCommand(ctx context.Context, startAt time.Time) { } } -func waitForMonitor(ctx context.Context, startAt time.Time, lead time.Duration) { +// waitForMonitor 等待到定时开抢时刻。 +// +// -at 里的时间按「服务端时间」理解:开启 timeSync 时先用 launch.pingSignal 校准 +// 服务端与本机的钟差,再按偏移把触发时刻换算到本机时钟上,避免本机时钟偏快/偏慢 +// 导致提前或延后开抢(本机时钟误差常见在几百毫秒量级,正好覆盖开抢瞬间)。 +// 校准失败只告警,仍然按本机时钟触发。 +func waitForMonitor(ctx context.Context, client huya.ActivityClient, account sniper.Account, startAt time.Time, lead time.Duration, timeSync bool) { if startAt.IsZero() { return } when := startAt.Add(-lead) + if timeSync { + calibration, err := huya.TryCalibrateServerTime(ctx, client, account.UID, account.Cookie, huya.DefaultTimeCalibrationRounds) + if err != nil { + logger.LogEvent(categoryNet, logger.LevelWarn, "时间校准失败,按本机时钟触发: %v", err) + } else { + when = huya.AlignedTriggerTime(startAt, lead, calibration.Offset) + logger.LogEvent(categoryNet, logger.LevelInfo, "时间校准完成: %s", calibration.Summary()) + logger.LogEvent(categoryCLI, logger.LevelInfo, "触发时刻已对齐服务端时钟: 本机=%s 提前=%dms", + when.Format("2006-01-02 15:04:05.000"), lead.Milliseconds()) + } + } waitUntil(ctx, when) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 758e581..862f438 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -108,6 +108,8 @@ type UiConfig = { autoSelect: boolean refreshAfterSuccess: boolean leadMs: number + // timeSync 表示定时前用 pingSignal 校准服务端时间,并把 startAt 视为服务端时间。 + timeSync: boolean frequentBackoffMs: number errorBackoffMs: number timeoutSec: number @@ -592,6 +594,7 @@ function pickConfig(values: Partial): UiConfig { autoSelect: Boolean(values.autoSelect), refreshAfterSuccess: values.refreshAfterSuccess !== false, leadMs: Number(values.leadMs || 0), + timeSync: values.timeSync !== false, frequentBackoffMs: Number(values.frequentBackoffMs || 300), errorBackoffMs: Number(values.errorBackoffMs || 300), timeoutSec: Number(values.timeoutSec || 15), @@ -1139,6 +1142,7 @@ export default function Dashboard() { autoSelect: values.autoSelect, refreshAfterSuccess: values.refreshAfterSuccess, leadMs: values.leadMs, + timeSync: values.timeSync, frequentBackoffMs: values.frequentBackoffMs, errorBackoffMs: values.errorBackoffMs, timeoutSec: values.timeoutSec, @@ -1994,6 +1998,9 @@ export default function Dashboard() { + + + diff --git a/internal/gui/app.go b/internal/gui/app.go index e982d63..b1d38fe 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -77,10 +77,12 @@ type Config struct { AutoSelect bool `json:"autoSelect"` RefreshAfterSuccess bool `json:"refreshAfterSuccess"` LeadMS int `json:"leadMs"` - FrequentBackoffMS int `json:"frequentBackoffMs"` - ErrorBackoffMS int `json:"errorBackoffMs"` - TimeoutSec int `json:"timeoutSec"` - StartAt string `json:"startAt"` + // TimeSync 表示定时抢购前先用 pingSignal 校准服务端时间,并把 StartAt 当服务端时间对齐。 + TimeSync bool `json:"timeSync"` + FrequentBackoffMS int `json:"frequentBackoffMs"` + ErrorBackoffMS int `json:"errorBackoffMs"` + TimeoutSec int `json:"timeoutSec"` + StartAt string `json:"startAt"` } type AccountPreview struct { @@ -308,6 +310,7 @@ func (a *App) DefaultConfig() Config { AutoSelect: false, RefreshAfterSuccess: true, LeadMS: 2500, + TimeSync: true, FrequentBackoffMS: 300, ErrorBackoffMS: 300, TimeoutSec: 15, @@ -517,7 +520,18 @@ func (a *App) StartMonitor(cfg Config) error { } } if !startAt.IsZero() { - when := startAt.Add(-time.Duration(cfg.LeadMS) * time.Millisecond) + lead := time.Duration(cfg.LeadMS) * time.Millisecond + when := startAt.Add(-lead) + if cfg.TimeSync { + calibration, calErr := huya.TryCalibrateServerTime(ctx, client, accounts[0].UID, accounts[0].Cookie, huya.DefaultTimeCalibrationRounds) + if calErr != nil { + a.addLog("warn", "时间校准失败,按本机时钟触发: %v", calErr) + } else { + when = huya.AlignedTriggerTime(startAt, lead, calibration.Offset) + a.addLog("info", "时间校准完成: %s", calibration.Summary()) + a.addLog("info", "触发时刻已对齐服务端时钟: 本机=%s", when.Format("2006-01-02 15:04:05.000")) + } + } a.addLog("info", "等待定时开始: %s 提前=%dms", when.Format("2006-01-02 15:04:05.000"), cfg.LeadMS) if err := waitUntil(ctx, when); err != nil { a.addLog("info", "定时等待已取消") diff --git a/internal/huya/http_client.go b/internal/huya/http_client.go index ae41d94..7e844fb 100644 --- a/internal/huya/http_client.go +++ b/internal/huya/http_client.go @@ -19,6 +19,8 @@ type HTTPClient struct { Endpoint string Client *http.Client Logger func(string) + // timeHistory 保存最近几次校准偏移,用于中位数平滑。 + timeHistory OffsetHistory } type CallOptions struct { @@ -110,6 +112,31 @@ func (c *HTTPClient) PingSignal(ctx context.Context, uid int64, cookie string) ( 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 diff --git a/internal/huya/launch.go b/internal/huya/launch.go index 2406449..f12576e 100644 --- a/internal/huya/launch.go +++ b/internal/huya/launch.go @@ -217,8 +217,8 @@ func (r *PingSignalResp) ReadFrom(rd *taf.Reader) error { // ServerElapsed 返回服务端 signal_ap_start/end 的差值(毫秒),不可得时返回 0。 func (r PingSignalResp) ServerElapsed() time.Duration { - start, okStart := r.Values["signal_ap_start"] - end, okEnd := r.Values["signal_ap_end"] + start, okStart := r.Values[PingSignalStartKey] + end, okEnd := r.Values[PingSignalEndKey] if !okStart || !okEnd || end < start { return 0 } diff --git a/internal/huya/timecal.go b/internal/huya/timecal.go new file mode 100644 index 0000000..2da786c --- /dev/null +++ b/internal/huya/timecal.go @@ -0,0 +1,267 @@ +package huya + +import ( + "context" + "errors" + "fmt" + "sort" + "sync" + "time" +) + +const ( + // DefaultTimeCalibrationRounds 是默认采样轮数:3 轮 pingSignal 只有几百毫秒, + // 抢购准备阶段完全承受得起,换来的是「取最优样本 + 中位数平滑」。 + DefaultTimeCalibrationRounds = 3 + // timeCalibrationGap 是两轮采样之间的间隔,避免几次请求压在同一个毫秒里。 + timeCalibrationGap = 120 * time.Millisecond + // timeCalibrationHistorySize 是参与中位数平滑的历史偏移个数。 + timeCalibrationHistorySize = 3 + + minPlausibleServerYear = 2020 + maxPlausibleServerYear = 2100 + + // PingSignalStartKey / PingSignalEndKey 是 launch.pingSignal 响应里的服务端时间戳字段。 + PingSignalStartKey = "signal_ap_start" + PingSignalEndKey = "signal_ap_end" +) + +// TimeSample 是一次服务端时间采样的原始观测。 +type TimeSample struct { + ServerAt time.Time // 服务端墙钟时间(pingSignal 的 signal_ap_start) + SentAt time.Time // 本机发出请求的时刻 + RecvAt time.Time // 本机收到响应的时刻 +} + +// RTT 返回这次采样的往返耗时。 +func (s TimeSample) RTT() time.Duration { return s.RecvAt.Sub(s.SentAt) } + +// ErrorBound 返回这次采样的误差上界。 +// +// 服务端时间戳说明的是「请求到达服务端时」,具体落在 [SentAt, RecvAt] 的哪一点 +// 无法得知,按最坏情况取半个 RTT。 +func (s TimeSample) ErrorBound() time.Duration { return s.RTT() / 2 } + +// Offset 返回「服务端时间 - 本机时间」的估计值。 +// +// 服务端在收到请求时打时间戳,而本机对应的时刻约为 SentAt + RTT/2(单程延迟), +// 两者相减即为钟差。 +func (s TimeSample) Offset() time.Duration { + mid := s.SentAt.Add(s.RTT() / 2) + return s.ServerAt.Sub(mid) +} + +// TimeCalibration 是一次服务端时间校准的结果。 +type TimeCalibration struct { + // Offset 是「服务端时间 - 本机时间」的估计偏差。 + Offset time.Duration + // ErrorBound 是最优样本的误差上界(RTT/2)。 + ErrorBound time.Duration + // RTT 是最优样本的往返耗时。 + RTT time.Duration + // Samples 是实际成功的采样轮数。 + Samples int + // At 是校准完成的本机时刻。 + At time.Time + // Smoothed 表示 Offset 经过中位数平滑(多轮样本来回抖动时更稳)。 + Smoothed bool +} + +// ServerNow 返回按偏移换算的服务端当前时间(UTC)。 +func (c TimeCalibration) ServerNow() time.Time { + if c.At.IsZero() { + return time.Now().UTC() + } + return time.Now().Add(c.Offset).UTC() +} + +// Summary 输出一行可读摘要,供 CLI 与 GUI 日志复用。 +func (c TimeCalibration) Summary() string { + smoothed := "" + if c.Smoothed { + smoothed = " 已平滑" + } + return fmt.Sprintf("服务端=%s 本机偏移=%+dms 误差<=%dms RTT=%dms 样本=%d%s", + c.ServerNow().Format("2006-01-02 15:04:05.000"), + c.Offset.Milliseconds(), + c.ErrorBound.Milliseconds(), + c.RTT.Milliseconds(), + c.Samples, + smoothed, + ) +} + +// AlignedTriggerTime 把「按服务端时间理解的目标时刻」换算成本机应当触发的时刻。 +// +// offset 是「服务端 - 本机」的偏差:本机比服务端慢时为正,触发时刻要相应提前。 +// offset 为 0 时退化为原来的 target - lead。 +func AlignedTriggerTime(target time.Time, lead, offset time.Duration) time.Time { + return target.Add(-lead - offset) +} + +// TimeSampler 采一次服务端时间样本。 +type TimeSampler func(ctx context.Context) (TimeSample, error) + +// OffsetHistory 保存最近几次偏移用于中位数平滑。 +// +// 单次采样会被一次网络抖动带偏,取中位数能滤掉跳变(对齐 dygou_go 的 smoothedOffset)。 +type OffsetHistory struct { + mu sync.Mutex + values []time.Duration +} + +// Add 记录一次偏移,只保留最近 timeCalibrationHistorySize 个。 +func (h *OffsetHistory) Add(offset time.Duration) { + if h == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + h.values = append(h.values, offset) + if len(h.values) > timeCalibrationHistorySize { + h.values = h.values[len(h.values)-timeCalibrationHistorySize:] + } +} + +// Median 返回历史偏移的中位数与样本个数;没有历史时返回 (0, 0)。 +func (h *OffsetHistory) Median() (time.Duration, int) { + if h == nil { + return 0, 0 + } + h.mu.Lock() + defer h.mu.Unlock() + if len(h.values) == 0 { + return 0, 0 + } + sorted := append([]time.Duration(nil), h.values...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + return sorted[len(sorted)/2], len(sorted) +} + +// RunTimeCalibration 执行多轮采样并给出校准结果。 +// +// 取「误差上界最小、其次 RTT 最低」的那次作为本轮结果,再对最近几次偏移取中位数 +// 做平滑;一次成功样本都没有时返回错误,由调用方回退到本机时钟。 +func RunTimeCalibration(ctx context.Context, rounds int, history *OffsetHistory, sampler TimeSampler) (TimeCalibration, error) { + if rounds <= 0 { + rounds = DefaultTimeCalibrationRounds + } + var ( + best TimeSample + samples int + lastErr error + ) + for i := 0; i < rounds; i++ { + if i > 0 { + if err := sleepOrDone(ctx, timeCalibrationGap); err != nil { + lastErr = err + break + } + } + sample, err := sampler(ctx) + if err != nil { + lastErr = err + continue + } + samples++ + if best.ServerAt.IsZero() || betterTimeSample(sample, best) { + best = sample + } + } + if best.ServerAt.IsZero() { + if lastErr == nil { + lastErr = errors.New("没有可用的服务端时间样本") + } + return TimeCalibration{}, lastErr + } + + calibration := TimeCalibration{ + Offset: best.Offset(), + ErrorBound: best.ErrorBound(), + RTT: best.RTT(), + Samples: samples, + At: time.Now(), + } + history.Add(calibration.Offset) + if smoothed, count := history.Median(); count >= 2 { + calibration.Offset = smoothed + calibration.Smoothed = true + } + return calibration, nil +} + +// betterTimeSample 按「误差上界小者优先,其次 RTT 小者」挑选样本。 +func betterTimeSample(candidate, current TimeSample) bool { + if candidate.ErrorBound() != current.ErrorBound() { + return candidate.ErrorBound() < current.ErrorBound() + } + return candidate.RTT() < current.RTT() +} + +// TimeCalibrator 抽象服务端时间校准能力:HTTP 与 WSS 传输都实现它。 +type TimeCalibrator interface { + CalibrateServerTime(ctx context.Context, uid int64, cookie string, rounds int) (TimeCalibration, error) +} + +// TryCalibrateServerTime 对活动客户端做一次服务端时间校准。 +// 传输层不支持时返回错误,调用方应回退到本机时钟。 +func TryCalibrateServerTime(ctx context.Context, client ActivityClient, uid int64, cookie string, rounds int) (TimeCalibration, error) { + calibrator, ok := client.(TimeCalibrator) + if !ok { + return TimeCalibration{}, errors.New("当前传输层不支持服务端时间校准") + } + return calibrator.CalibrateServerTime(ctx, uid, cookie, rounds) +} + +// ServerTimestamp 返回 pingSignal 响应里的服务端墙钟时间。 +// +// 2026-10-13 抓包确认(夹具见 launch_test.go 的 realPingSignalRspHex): +// signal_ap_start / signal_ap_end 是 Unix 毫秒时间戳,两者相差 13ms——start 是 +// 服务端收到请求的时刻,end 是写出响应的时刻,校准用 start 更贴近单程中点。 +func (r PingSignalResp) ServerTimestamp() (time.Time, bool) { + for _, key := range []string{PingSignalStartKey, PingSignalEndKey} { + ms, ok := r.Values[key] + if !ok || ms <= 0 { + continue + } + at := time.UnixMilli(ms).UTC() + if year := at.Year(); year < minPlausibleServerYear || year > maxPlausibleServerYear { + // 单位或字段语义变了,宁可不用也不要按错误的偏移去对齐触发时刻。 + continue + } + return at, true + } + return time.Time{}, false +} + +// pingSignalSampleWith 在给定的 pingSignal 调用上采集一次时间样本。 +// HTTP 与 WSS 两条链路只有底层调用方式不同,采样与解析逻辑共用这一份。 +func pingSignalSampleWith(call func(req *PingSignalReq, resp *PingSignalResp) error) (TimeSample, error) { + req := &PingSignalReq{Extra: map[string]string{}} + var resp PingSignalResp + sentAt := time.Now() + if err := call(req, &resp); err != nil { + return TimeSample{}, err + } + recvAt := time.Now() + + serverAt, ok := resp.ServerTimestamp() + if !ok { + return TimeSample{}, fmt.Errorf("pingSignal 响应缺少可用的服务端时间戳: %v", resp.Values) + } + return TimeSample{ServerAt: serverAt, SentAt: sentAt, RecvAt: recvAt}, nil +} + +func sleepOrDone(ctx context.Context, d time.Duration) error { + if d <= 0 { + return ctx.Err() + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/huya/timecal_test.go b/internal/huya/timecal_test.go new file mode 100644 index 0000000..6663f12 --- /dev/null +++ b/internal/huya/timecal_test.go @@ -0,0 +1,215 @@ +package huya + +import ( + "context" + "errors" + "testing" + "time" +) + +// TestServerTimestampFromRealPingSignalPayload 用 2026-10-13 的真实报文夹具验证: +// signal_ap_* 是 Unix 毫秒时间戳,能被解析成正确的墙钟时间。 +func TestServerTimestampFromRealPingSignalPayload(t *testing.T) { + var resp PingSignalResp + readStructPayload(t, mustDecodeHex(t, realPingSignalRspHex), &resp) + + at, ok := resp.ServerTimestamp() + if !ok { + t.Fatalf("ServerTimestamp() 未识别真实报文里的时间戳: %v", resp.Values) + } + want := time.UnixMilli(1791891743288).UTC() + if !at.Equal(want) { + t.Fatalf("ServerTimestamp()=%s, want %s", at, want) + } + // 夹具来自 2026-10-13 的抓包,时间戳必须落在那天(北京时间为 19:42)。 + if year, month, day := at.In(time.FixedZone("CST", 8*3600)).Date(); year != 2026 || month != time.October || day != 13 { + t.Fatalf("服务端时间日期=%d-%02d-%02d, want 2026-10-13", year, month, day) + } +} + +func TestServerTimestampRejectsImplausibleValues(t *testing.T) { + tests := []struct { + name string + values map[string]int64 + wantOK bool + }{ + {name: "empty", values: map[string]int64{}, wantOK: false}, + {name: "nil", values: nil, wantOK: false}, + // 5 位数字明显不是 Unix 毫秒(会被解析成 1970 年),必须拒绝, + // 否则会按错误偏移把触发时刻整体挪走。 + {name: "tiny value", values: map[string]int64{PingSignalStartKey: 12345}, wantOK: false}, + {name: "seconds instead of ms", values: map[string]int64{PingSignalStartKey: 1791891743}, wantOK: false}, + {name: "start wins", values: map[string]int64{ + PingSignalStartKey: 1791891743288, + PingSignalEndKey: 1791891743301, + }, wantOK: true}, + {name: "end fallback", values: map[string]int64{PingSignalEndKey: 1791891743288}, wantOK: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := PingSignalResp{Values: tt.values} + at, ok := resp.ServerTimestamp() + if ok != tt.wantOK { + t.Fatalf("ServerTimestamp() ok=%v, want %v (values=%v)", ok, tt.wantOK, tt.values) + } + if ok && at.Equal(time.Time{}) { + t.Fatalf("ok=true 但时间为零值") + } + }) + } +} + +func TestTimeSampleOffsetUsesHalfRTT(t *testing.T) { + base := time.Date(2026, 10, 16, 19, 0, 0, 0, time.UTC) + // 本机比服务端慢 300ms:服务端在「本机发送后 40ms」打的时间戳 = base+300ms+40ms。 + sample := TimeSample{ + ServerAt: base.Add(300*time.Millisecond + 40*time.Millisecond), + SentAt: base, + RecvAt: base.Add(80 * time.Millisecond), + } + if got := sample.RTT(); got != 80*time.Millisecond { + t.Fatalf("RTT()=%s, want 80ms", got) + } + if got := sample.ErrorBound(); got != 40*time.Millisecond { + t.Fatalf("ErrorBound()=%s, want 40ms", got) + } + if got := sample.Offset(); got != 300*time.Millisecond { + t.Fatalf("Offset()=%s, want 300ms", got) + } +} + +func TestRunTimeCalibrationPicksLowestErrorSample(t *testing.T) { + base := time.Date(2026, 10, 16, 19, 0, 0, 0, time.UTC) + rtts := []time.Duration{200 * time.Millisecond, 40 * time.Millisecond, 120 * time.Millisecond} + index := 0 + sampler := func(ctx context.Context) (TimeSample, error) { + rtt := rtts[index] + index++ + return TimeSample{ + ServerAt: base.Add(500*time.Millisecond + rtt/2), + SentAt: base, + RecvAt: base.Add(rtt), + }, nil + } + + var history OffsetHistory + calibration, err := RunTimeCalibration(context.Background(), 3, &history, sampler) + if err != nil { + t.Fatal(err) + } + if calibration.Samples != 3 { + t.Fatalf("Samples=%d, want 3", calibration.Samples) + } + if calibration.RTT != 40*time.Millisecond || calibration.ErrorBound != 20*time.Millisecond { + t.Fatalf("应取误差最小的样本: RTT=%s ErrorBound=%s", calibration.RTT, calibration.ErrorBound) + } + if calibration.Offset != 500*time.Millisecond { + t.Fatalf("Offset=%s, want 500ms", calibration.Offset) + } + if calibration.Smoothed { + t.Fatalf("首轮只有一个样本时不应标记为已平滑") + } +} + +// TestRunTimeCalibrationSmoothsOutlierWithMedian 验证中位数平滑能过滤单轮跳变。 +func TestRunTimeCalibrationSmoothsOutlierWithMedian(t *testing.T) { + base := time.Date(2026, 10, 16, 19, 0, 0, 0, time.UTC) + offsets := []time.Duration{100 * time.Millisecond, 900 * time.Millisecond, 100 * time.Millisecond} + var history OffsetHistory + var calibration TimeCalibration + for _, offset := range offsets { + offset := offset + sampler := func(ctx context.Context) (TimeSample, error) { + return TimeSample{ + ServerAt: base.Add(offset), + SentAt: base, + RecvAt: base, // RTT=0,Offset 就等于偏移本身 + }, nil + } + var err error + calibration, err = RunTimeCalibration(context.Background(), 1, &history, sampler) + if err != nil { + t.Fatal(err) + } + } + if !calibration.Smoothed { + t.Fatalf("多轮采样后应标记为已平滑") + } + if calibration.Offset != 100*time.Millisecond { + t.Fatalf("Offset=%s, want 100ms(900ms 的跳变应被中位数滤掉)", calibration.Offset) + } +} + +func TestRunTimeCalibrationFallsBackWhenAllSamplesFail(t *testing.T) { + failure := errors.New("pingSignal 不可用") + sampler := func(ctx context.Context) (TimeSample, error) { return TimeSample{}, failure } + + var history OffsetHistory + calibration, err := RunTimeCalibration(context.Background(), 2, &history, sampler) + if !errors.Is(err, failure) { + t.Fatalf("err=%v, want %v", err, failure) + } + if !calibration.At.IsZero() || calibration.Samples != 0 { + t.Fatalf("失败时不应返回可用校准: %+v", calibration) + } +} + +func TestRunTimeCalibrationToleratesPartialFailure(t *testing.T) { + base := time.Date(2026, 10, 16, 19, 0, 0, 0, time.UTC) + round := 0 + sampler := func(ctx context.Context) (TimeSample, error) { + round++ + if round != 2 { + return TimeSample{}, errors.New("偶发失败") + } + return TimeSample{ServerAt: base.Add(150 * time.Millisecond), SentAt: base, RecvAt: base}, nil + } + + var history OffsetHistory + calibration, err := RunTimeCalibration(context.Background(), 3, &history, sampler) + if err != nil { + t.Fatal(err) + } + if calibration.Samples != 1 { + t.Fatalf("Samples=%d, want 1", calibration.Samples) + } + if calibration.Offset != 150*time.Millisecond { + t.Fatalf("Offset=%s, want 150ms", calibration.Offset) + } +} + +func TestAlignedTriggerTimeCompensatesClockSkew(t *testing.T) { + target := time.Date(2026, 10, 16, 20, 0, 0, 0, time.Local) + tests := []struct { + name string + lead time.Duration + offset time.Duration + want time.Time + }{ + {name: "no skew", lead: 2500 * time.Millisecond, offset: 0, want: target.Add(-2500 * time.Millisecond)}, + // 本机比服务端慢 300ms:服务端的 20:00:00 发生在本机 19:59:59.700,再提前 lead。 + {name: "local behind", lead: 2500 * time.Millisecond, offset: 300 * time.Millisecond, want: target.Add(-2800 * time.Millisecond)}, + // 本机比服务端快 300ms:本机 20:00:00.300 才是服务端的 20:00:00。 + {name: "local ahead", lead: 2500 * time.Millisecond, offset: -300 * time.Millisecond, want: target.Add(-2200 * time.Millisecond)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := AlignedTriggerTime(target, tt.lead, tt.offset); !got.Equal(tt.want) { + t.Fatalf("AlignedTriggerTime()=%s, want %s", got, tt.want) + } + }) + } +} + +func TestTimeCalibrationServerNowAppliesOffset(t *testing.T) { + calibration := TimeCalibration{Offset: 2 * time.Second, At: time.Now()} + before := time.Now().Add(2 * time.Second).UTC() + got := calibration.ServerNow() + if got.Before(before.Add(-100*time.Millisecond)) || got.After(before.Add(100*time.Millisecond)) { + t.Fatalf("ServerNow()=%s, 偏离预期 %s 太多", got, before) + } + var zero TimeCalibration + if zero.ServerNow().IsZero() { + t.Fatalf("未校准时应退化为本机时间") + } +} diff --git a/internal/huya/wss_client.go b/internal/huya/wss_client.go index c972879..4c9e3ae 100644 --- a/internal/huya/wss_client.go +++ b/internal/huya/wss_client.go @@ -58,6 +58,8 @@ type WSSClient struct { dnsInflight chan struct{} // dnsFailedAt 记录解析失败时间,冷却期内不再重复请求。 dnsFailedAt time.Time + // timeHistory 保存最近几次校准偏移,用于中位数平滑。 + timeHistory OffsetHistory } type WSSSession struct { @@ -152,6 +154,31 @@ func (c *WSSClient) PingSignal(ctx context.Context, uid int64, cookie string) (* return &resp, nil } +// PingSignalSample 通过 WSS 通道调一次 pingSignal 并返回可用于时间校准的样本。 +func (c *WSSClient) PingSignalSample(ctx context.Context, uid int64, cookie string) (TimeSample, error) { + session, err := c.session(ctx, uid, cookie) + if err != nil { + return TimeSample{}, err + } + return pingSignalSampleWith(func(req *PingSignalReq, resp *PingSignalResp) error { + _, err := session.callRPC(ctx, ServantLaunch, FuncPingSignal, req, resp) + return err + }) +} + +// CalibrateServerTime 用 pingSignal 的服务端时间戳做多轮校准(实现 TimeCalibrator)。 +func (c *WSSClient) 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("[WSS] 时间校准失败: %v", err) + return calibration, err + } + c.logf("[WSS] 时间校准完成: %s", calibration.Summary()) + return calibration, nil +} + // resolveHost 返回本次建连使用的 host。 // // 优先级:显式配置的 Host > queryHttpDns 结果(TTL 内缓存、多 host 轮询)> DefaultWSSHost。