新增服务端时间校准,定时抢购按服务端时钟对齐触发
- 协议事实确认: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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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", "定时等待已取消")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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("未校准时应退化为本机时间")
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user