- 协议事实确认: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 全部通过
216 lines
7.6 KiB
Go
216 lines
7.6 KiB
Go
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("未校准时应退化为本机时间")
|
||
}
|
||
}
|