新增服务端时间校准,定时抢购按服务端时钟对齐触发

- 协议事实确认: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 全部通过
This commit is contained in:
yml2213
2026-10-16 16:13:29 +08:00
parent 0294606807
commit 828236eb1d
9 changed files with 600 additions and 9 deletions
+16
View File
@@ -121,6 +121,9 @@ GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签)
- `-timeout`:单次调用超时秒数。
- `-loops`、`-delay`、`-concurrency`:轮询次数、轮询间隔、并发额度。`-concurrency` 限制的是**同时在途的请求数**(不是同时运行的账号数),账号在退避等待期间不占用额度。
- `-stop-after-success`:`monitor` 中任一账号成功后是否停止。
- `-at`:定时开始时间,支持 `RFC3339`、`2006-01-02 15:04:05`、`HH:MM:SS`;开启时间校准时按**服务端时间**理解。
- `-lead`:相对 `-at` 的提前启动量,默认 `2500ms`(用于覆盖网络往返,让请求在开抢瞬间已经在途)。
- `-time-sync`:默认开启。定时前用 `launch.pingSignal` 校准服务端与本机的钟差,并把触发时刻对齐到服务端时钟(详见「时间校准」)。
- `-log-dir`:日志目录,默认使用应用数据目录下的 `logs`。
- `-log-level`:日志最低等级,`debug`/`info`/`warn`/`error`,默认 `info`。
- `-v`:输出调试日志(等价于 `-log-level debug`,并把网络日志同时打到 stderr)。
@@ -138,6 +141,19 @@ GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签)
- **任务结束汇总**:每次监控结束输出一行汇总(轮次 / 账号 / 目标 / 成功账号 / 耗时 / 请求次数)与按次数排序的结局分布(`success`、`sold_out`、`too_frequent`、`not_found` 等),便于复盘。
- **HTTP 传输连接池**:`http` 传输补上 `MaxConnsPerHost=64`、`MaxIdleConnsPerHost=64`(Go 默认每主机只有 2 个空闲连接),多账号并发时不再反复重建连接。
## 时间校准(2026-10-16 新增)
定时抢购默认先做一次服务端时间校准(CLI `-time-sync`,GUI 设置里的「时间校准」开关,默认开):
- **数据来源**:`launch.pingSignal` 响应里的 `signal_ap_start` / `signal_ap_end`。2026-10-13 抓包确认这两个字段是 Unix 毫秒时间戳(`start` 是服务端收到请求的时刻,`end` 是写出响应的时刻,实测相差 13ms);真实字节夹具见 `internal/huya/launch_test.go` 的 `realPingSignalRspHex`。
- **钟差估计**:`偏移 = signal_ap_start − (发送时刻 + RTT/2)`,误差上界取 `RTT/2`——服务端打时间戳的具体位置落在发送与接收之间,无法再精确,按最坏情况估计。
- **多轮取优**:默认 3 轮采样,取「误差上界最小、其次 RTT 最低」的样本;再对最近 3 次偏移取中位数平滑,压掉单轮网络抖动。
- **生效方式**:`-at` / `startAt` 按服务端时间理解,触发时刻 = `目标时刻 − 提前量 − 偏移`。本机时钟比服务端慢 300ms 就自动提前 300ms,反之推后。
- **失败降级**:一次样本都拿不到时只告警,仍按本机时钟触发;时间戳明显不合理(解析出来不是这个世纪的毫秒值)会被丢弃,避免按错误偏移整体挪走抢购窗口。
- **日志**:`时间校准完成: 服务端=2026-10-16 20:00:01.234 本机偏移=+123ms 误差<=45ms RTT=90ms 样本=3 已平滑`。
校准每次只多花几百毫秒(3 轮 pingSignal),发生在预热之后、等待定时之前,因此不会挤占开抢窗口。
## 已确认的小黄鸭参数
2026-07-06 浏览器手动抢购抓包确认:
+20 -2
View File
@@ -50,6 +50,7 @@ func main() {
accountStop = flag.Bool("account-stop", true, "monitor 单账号成功后停止该账号该奖品")
at = flag.String("at", "", "定时开始时间,支持 RFC3339 或 2006-01-02 15:04:05")
leadMS = flag.Int("lead", 2500, "monitor 相对 -at 提前启动毫秒")
timeSync = flag.Bool("time-sync", true, "monitor 定时前用 pingSignal 校准服务端时间,并把 -at 视为服务端时间")
timeoutSec = flag.Int("timeout", 15, "单次请求超时秒数")
transport = flag.String("transport", "wss", "传输层:wss/http,默认 wss")
wssHost = flag.String("wss-host", "", "WSS host,留空则通过 launch.queryHttpDns 自动解析")
@@ -194,7 +195,7 @@ func main() {
exitErr(err)
}
}
waitForMonitor(ctx, startAt, time.Duration(*leadMS)*time.Millisecond)
waitForMonitor(ctx, client, accounts[0], startAt, time.Duration(*leadMS)*time.Millisecond, *timeSync)
err := runner.RunTargets(ctx, accounts, targets, cfg, emit)
if err != nil && !errors.Is(err, context.Canceled) {
exitErr(err)
@@ -210,11 +211,28 @@ func waitForCommand(ctx context.Context, startAt time.Time) {
}
}
func waitForMonitor(ctx context.Context, startAt time.Time, lead time.Duration) {
// waitForMonitor 等待到定时开抢时刻。
//
// -at 里的时间按「服务端时间」理解:开启 timeSync 时先用 launch.pingSignal 校准
// 服务端与本机的钟差,再按偏移把触发时刻换算到本机时钟上,避免本机时钟偏快/偏慢
// 导致提前或延后开抢(本机时钟误差常见在几百毫秒量级,正好覆盖开抢瞬间)。
// 校准失败只告警,仍然按本机时钟触发。
func waitForMonitor(ctx context.Context, client huya.ActivityClient, account sniper.Account, startAt time.Time, lead time.Duration, timeSync bool) {
if startAt.IsZero() {
return
}
when := startAt.Add(-lead)
if timeSync {
calibration, err := huya.TryCalibrateServerTime(ctx, client, account.UID, account.Cookie, huya.DefaultTimeCalibrationRounds)
if err != nil {
logger.LogEvent(categoryNet, logger.LevelWarn, "时间校准失败,按本机时钟触发: %v", err)
} else {
when = huya.AlignedTriggerTime(startAt, lead, calibration.Offset)
logger.LogEvent(categoryNet, logger.LevelInfo, "时间校准完成: %s", calibration.Summary())
logger.LogEvent(categoryCLI, logger.LevelInfo, "触发时刻已对齐服务端时钟: 本机=%s 提前=%dms",
when.Format("2006-01-02 15:04:05.000"), lead.Milliseconds())
}
}
waitUntil(ctx, when)
}
+7
View File
@@ -108,6 +108,8 @@ type UiConfig = {
autoSelect: boolean
refreshAfterSuccess: boolean
leadMs: number
// timeSync 表示定时前用 pingSignal 校准服务端时间,并把 startAt 视为服务端时间。
timeSync: boolean
frequentBackoffMs: number
errorBackoffMs: number
timeoutSec: number
@@ -592,6 +594,7 @@ function pickConfig(values: Partial<UiConfig>): UiConfig {
autoSelect: Boolean(values.autoSelect),
refreshAfterSuccess: values.refreshAfterSuccess !== false,
leadMs: Number(values.leadMs || 0),
timeSync: values.timeSync !== false,
frequentBackoffMs: Number(values.frequentBackoffMs || 300),
errorBackoffMs: Number(values.errorBackoffMs || 300),
timeoutSec: Number(values.timeoutSec || 15),
@@ -1139,6 +1142,7 @@ export default function Dashboard() {
autoSelect: values.autoSelect,
refreshAfterSuccess: values.refreshAfterSuccess,
leadMs: values.leadMs,
timeSync: values.timeSync,
frequentBackoffMs: values.frequentBackoffMs,
errorBackoffMs: values.errorBackoffMs,
timeoutSec: values.timeoutSec,
@@ -1994,6 +1998,9 @@ export default function Dashboard() {
<Form.Item name="refreshAfterSuccess" label="成功刷新" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="timeSync" label="时间校准" valuePropName="checked" tooltip="定时前用 pingSignal 校准服务端时间,按钟差对齐触发时刻">
<Switch />
</Form.Item>
</div>
</div>
</Modal>
+19 -5
View File
@@ -77,10 +77,12 @@ type Config struct {
AutoSelect bool `json:"autoSelect"`
RefreshAfterSuccess bool `json:"refreshAfterSuccess"`
LeadMS int `json:"leadMs"`
FrequentBackoffMS int `json:"frequentBackoffMs"`
ErrorBackoffMS int `json:"errorBackoffMs"`
TimeoutSec int `json:"timeoutSec"`
StartAt string `json:"startAt"`
// TimeSync 表示定时抢购前先用 pingSignal 校准服务端时间,并把 StartAt 当服务端时间对齐。
TimeSync bool `json:"timeSync"`
FrequentBackoffMS int `json:"frequentBackoffMs"`
ErrorBackoffMS int `json:"errorBackoffMs"`
TimeoutSec int `json:"timeoutSec"`
StartAt string `json:"startAt"`
}
type AccountPreview struct {
@@ -308,6 +310,7 @@ func (a *App) DefaultConfig() Config {
AutoSelect: false,
RefreshAfterSuccess: true,
LeadMS: 2500,
TimeSync: true,
FrequentBackoffMS: 300,
ErrorBackoffMS: 300,
TimeoutSec: 15,
@@ -517,7 +520,18 @@ func (a *App) StartMonitor(cfg Config) error {
}
}
if !startAt.IsZero() {
when := startAt.Add(-time.Duration(cfg.LeadMS) * time.Millisecond)
lead := time.Duration(cfg.LeadMS) * time.Millisecond
when := startAt.Add(-lead)
if cfg.TimeSync {
calibration, calErr := huya.TryCalibrateServerTime(ctx, client, accounts[0].UID, accounts[0].Cookie, huya.DefaultTimeCalibrationRounds)
if calErr != nil {
a.addLog("warn", "时间校准失败,按本机时钟触发: %v", calErr)
} else {
when = huya.AlignedTriggerTime(startAt, lead, calibration.Offset)
a.addLog("info", "时间校准完成: %s", calibration.Summary())
a.addLog("info", "触发时刻已对齐服务端时钟: 本机=%s", when.Format("2006-01-02 15:04:05.000"))
}
}
a.addLog("info", "等待定时开始: %s 提前=%dms", when.Format("2006-01-02 15:04:05.000"), cfg.LeadMS)
if err := waitUntil(ctx, when); err != nil {
a.addLog("info", "定时等待已取消")
+27
View File
@@ -19,6 +19,8 @@ type HTTPClient struct {
Endpoint string
Client *http.Client
Logger func(string)
// timeHistory 保存最近几次校准偏移,用于中位数平滑。
timeHistory OffsetHistory
}
type CallOptions struct {
@@ -110,6 +112,31 @@ func (c *HTTPClient) PingSignal(ctx context.Context, uid int64, cookie string) (
return &resp, nil
}
// PingSignalSample 调一次 launch.pingSignal 并返回可用于时间校准的样本。
func (c *HTTPClient) PingSignalSample(ctx context.Context, uid int64, cookie string) (TimeSample, error) {
return pingSignalSampleWith(func(req *PingSignalReq, resp *PingSignalResp) error {
_, err := c.CallRPC(ctx, ServantLaunch, FuncPingSignal, req, resp, CallOptions{
UID: uid,
GUID: CookieValue(cookie, "guid"),
Cookie: cookie,
})
return err
})
}
// CalibrateServerTime 用 pingSignal 的服务端时间戳做多轮校准(实现 TimeCalibrator)。
func (c *HTTPClient) CalibrateServerTime(ctx context.Context, uid int64, cookie string, rounds int) (TimeCalibration, error) {
calibration, err := RunTimeCalibration(ctx, rounds, &c.timeHistory, func(ctx context.Context) (TimeSample, error) {
return c.PingSignalSample(ctx, uid, cookie)
})
if err != nil {
c.logf("[HTTP] 时间校准失败: %v", err)
return calibration, err
}
c.logf("[HTTP] 时间校准完成: %s", calibration.Summary())
return calibration, nil
}
func (c *HTTPClient) CallRPC(ctx context.Context, service, method string, reqStruct taf.Marshaler, respStruct taf.Unmarshaler, opt CallOptions) (*wup.Response, error) {
if opt.RequestID == 0 {
opt.RequestID = 1
+2 -2
View File
@@ -217,8 +217,8 @@ func (r *PingSignalResp) ReadFrom(rd *taf.Reader) error {
// ServerElapsed 返回服务端 signal_ap_start/end 的差值(毫秒),不可得时返回 0。
func (r PingSignalResp) ServerElapsed() time.Duration {
start, okStart := r.Values["signal_ap_start"]
end, okEnd := r.Values["signal_ap_end"]
start, okStart := r.Values[PingSignalStartKey]
end, okEnd := r.Values[PingSignalEndKey]
if !okStart || !okEnd || end < start {
return 0
}
+267
View File
@@ -0,0 +1,267 @@
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()
}
}
+215
View File
@@ -0,0 +1,215 @@
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("未校准时应退化为本机时间")
}
}
+27
View File
@@ -58,6 +58,8 @@ type WSSClient struct {
dnsInflight chan struct{}
// dnsFailedAt 记录解析失败时间,冷却期内不再重复请求。
dnsFailedAt time.Time
// timeHistory 保存最近几次校准偏移,用于中位数平滑。
timeHistory OffsetHistory
}
type WSSSession struct {
@@ -152,6 +154,31 @@ func (c *WSSClient) PingSignal(ctx context.Context, uid int64, cookie string) (*
return &resp, nil
}
// PingSignalSample 通过 WSS 通道调一次 pingSignal 并返回可用于时间校准的样本。
func (c *WSSClient) PingSignalSample(ctx context.Context, uid int64, cookie string) (TimeSample, error) {
session, err := c.session(ctx, uid, cookie)
if err != nil {
return TimeSample{}, err
}
return pingSignalSampleWith(func(req *PingSignalReq, resp *PingSignalResp) error {
_, err := session.callRPC(ctx, ServantLaunch, FuncPingSignal, req, resp)
return err
})
}
// CalibrateServerTime 用 pingSignal 的服务端时间戳做多轮校准(实现 TimeCalibrator)。
func (c *WSSClient) CalibrateServerTime(ctx context.Context, uid int64, cookie string, rounds int) (TimeCalibration, error) {
calibration, err := RunTimeCalibration(ctx, rounds, &c.timeHistory, func(ctx context.Context) (TimeSample, error) {
return c.PingSignalSample(ctx, uid, cookie)
})
if err != nil {
c.logf("[WSS] 时间校准失败: %v", err)
return calibration, err
}
c.logf("[WSS] 时间校准完成: %s", calibration.Summary())
return calibration, nil
}
// resolveHost 返回本次建连使用的 host。
//
// 优先级:显式配置的 Host > queryHttpDns 结果(TTL 内缓存、多 host 轮询)> DefaultWSSHost。