Files
huya-go/internal/huya/timecal.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

268 lines
8.6 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 (
"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()
}
}