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() } }