- 协议事实确认: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 全部通过
268 lines
8.6 KiB
Go
268 lines
8.6 KiB
Go
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()
|
||
}
|
||
}
|