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

216 lines
7.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"
"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("未校准时应退化为本机时间")
}
}