Files
dygou_go/internal/seckill/schedule_timer.go
T
yml2213 46c06180a5 重构锁单支付重试内核并修复多实例启动,发布0.3.0
- 抢购内核改为"每账号独立重试循环",移除轮次屏障:旧实现在 3 个中签账号卡住
  60 秒支付退避时,另外 31 个账号空转 39 秒,还出现过"某一轮只跑 1 个账号、其余整轮没被尝试"
- 锁单退避改为读取配置的"重试间隔策略"(同一账号连续失败成倍放大,封顶 5 秒),
  删掉硬编码 30 秒退避与"只重试一次"
- 锁单失败不再提前停止:限流(活动火爆/网络繁忙)继续重试;售罄也继续降频探测
  (别人锁单后支付失败或锁单过期,库存会回流),只在窗口结束时按售罄/限流/普通失败分类上报
- 支付重试按斗鱼错误码决策:1644430001/1644400002/1644400014 视为"订单已受理、结果未定"
  并快重试(300ms 起),取代按文案把"订单操作进行中"误判成频控的 60 秒退避;
  不再设次数上限,只受 120 秒预算与锁单过期约束,超出预算如实报"支付结果未知"
- 已锁订单在 75 秒窗口结束后仍会支付完,不再受窗口约束
- 多实例:为每个实例分配独占的 WebView2 用户数据目录。Wails 默认所有实例共用
  %APPDATA%\<可执行文件名>,而一个数据目录只能有一个浏览器进程,导致偶发开不出新实例、
  必须全部关掉重开;实例占位因此提前到 wails.Run 之前完成
- 多实例:占位失败不再静默退回默认目录(会让两个进程共享 accounts.txt/config.json、
  同秒启动还会写同一个日志文件),改为进程唯一的临时实例目录并记录降级原因
- 多实例:单个槽位锁被瞬时占用(杀软/索引器)时短暂重试,仍失败则继续试下一个槽位
  而不是整个分配失败;日志文件改为独占创建,同秒启动自动追加 -2/-3 后缀
- 新增 %LOCALAPPDATA%\douyu-go\startup.log,记录每次启动与失败原因,
  便于排查"窗口起不来"这类连应用日志都不会生成的问题
- 修复预热缓存日志每个账号打两遍、"账号尝试"计数误导(改为真实锁单/支付/直接兑换次数)
- 修复前端把含"成功"字样的失败文案显示成抢到(改为只认"成功|"前缀)
- 修复 jitterDuration 在基准小于 10ms 时 rand.Int63n(0) 会 panic 的隐患
- 设置页文案与作用域同步:重试间隔策略明确作用于锁单重试、"尝试包数"明确仅用于和平小店
2026-10-03 19:09:19 +08:00

218 lines
6.8 KiB
Go

package seckill
import (
"errors"
"fmt"
"math"
"sync/atomic"
"time"
"douyu_go/internal/constants"
"douyu_go/internal/logger"
"douyu_go/internal/types"
"douyu_go/internal/utils"
)
// ScheduleRun 注册一次定时抢购。
func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (types.ScheduleResult, error) {
if len(req.AccountProducts) == 0 {
return types.ScheduleResult{}, errors.New("请选择账号和商品")
}
if req.TargetTime == "" {
return types.ScheduleResult{}, errors.New("请设置目标时间")
}
targetLocal, err := time.ParseInLocation("2006-01-02 15:04:05", req.TargetTime, constants.CST)
if err != nil {
return types.ScheduleResult{}, fmt.Errorf("时间格式错误: %w", err)
}
targetTime := targetLocal.UTC()
calibration := s.calibrateDouyuTime("定时")
serverTime := calibration.EstimatedServerNow()
advance := dynamicAdvance(calibration)
triggerTime := targetTime.Add(-advance)
remain := triggerTime.Sub(serverTime)
if remain <= 0 {
return types.ScheduleResult{}, errors.New("目标时间已过")
}
ap := utils.CloneStringMap(req.AccountProducts)
s.scheduleMu.Lock()
s.stopSchedulesLocked()
s.clearWarmups()
s.scheduleMu.Unlock()
s.scheduleTimers(ap, targetTime, triggerTime, calibration, req.Attempts, req.DelayMs, false)
resyncRemain := triggerTime.Sub(serverTime) - resyncBeforeTrigger
if resyncRemain < 0 {
resyncRemain = 0
}
s.scheduleMu.Lock()
s.replaceTimerLocked(&s.resyncSchedule, resyncRemain, func() {
if s.IsRunning() {
logger.LogEvent("定时", "info", "临近复校时抢购已开始,不再改写触发时刻")
return
}
newCalibration := s.calibrateDouyuTime("临近")
if s.IsRunning() {
logger.LogEvent("定时", "info", "临近复校完成时抢购已开始,保持当前任务")
return
}
newTriggerTime := targetTime.Add(-dynamicAdvance(newCalibration))
s.scheduleTimers(ap, targetTime, newTriggerTime, newCalibration, req.Attempts, req.DelayMs, true)
})
s.scheduleMu.Unlock()
warmupRemain := remain - warmupBeforeTrigger
if warmupRemain < 0 {
warmupRemain = 0
}
logger.LogEvent("定时", "success", "定时已设置: 目标=%s 触发=%s 提前=%dms 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s 复校=%s",
targetTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
advance.Milliseconds(),
serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
calibration.Offset.Milliseconds(),
calibration.ErrorBound.Milliseconds(),
calibration.RTT.Milliseconds(),
time.Now().Add(warmupRemain).In(constants.CST).Format("15:04:05.000"),
time.Now().Add(resyncRemain).In(constants.CST).Format("15:04:05.000"),
)
remainSecond := int(math.Ceil(triggerTime.Sub(serverTime).Seconds()))
if remainSecond < 0 {
remainSecond = 0
}
return types.ScheduleResult{
Success: true,
TargetTime: targetTime.In(constants.CST).Format("2006-01-02 15:04:05"),
TriggerTime: triggerTime.In(constants.CST).Format("2006-01-02 15:04:05"),
RemainSecond: remainSecond,
OffsetMs: calibration.Offset.Milliseconds(),
ErrorBoundMs: calibration.ErrorBound.Milliseconds(),
RttMs: calibration.RTT.Milliseconds(),
}, nil
}
func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime, triggerTime time.Time, calibration types.TimeCalibration, attempts, delayMs int, resync bool) {
serverTime := calibration.EstimatedServerNow()
remain := triggerTime.Sub(serverTime)
if remain < 0 {
remain = 0
}
s.scheduleMu.Lock()
defer s.scheduleMu.Unlock()
if !resync {
warmupRemain := remain - warmupBeforeTrigger
if warmupRemain < 0 {
warmupRemain = 0
}
gen := atomic.AddInt64(&s.warmupGen, 1)
s.replaceTimerLocked(&s.warmupSchedule, warmupRemain, func() {
if atomic.LoadInt64(&s.warmupGen) != gen {
return
}
if s.clientSnapshot().Activity.IsXiaodian {
return
}
logger.LogEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(constants.CST).Format("15:04:05.000"))
warmupTasks := s.buildTasks(accountProducts)
s.warmupAttempts(warmupTasks)
})
}
s.replaceTimerLocked(&s.schedule, remain, func() {
now := time.Now()
estimatedServer := now.Add(calibration.Offset)
logger.LogEvent("定时", "success", "定时触发: 本地=%s 斗鱼估算=%s 偏移=%dms 误差<=%dms",
now.In(constants.CST).Format("15:04:05.000"),
estimatedServer.In(constants.CST).Format("15:04:05.000"),
calibration.Offset.Milliseconds(),
calibration.ErrorBound.Milliseconds())
if !s.TryStartRun() {
logger.LogEvent("抢购", "warn", "已有抢购任务正在运行,本次定时触发已跳过")
return
}
if s.clientSnapshot().Activity.IsXiaodian {
s.RunXiaodian(accountProducts, attempts, delayMs, "")
return
}
s.RunNow(accountProducts)
})
if resync {
logger.LogEvent("定时", "success", "临近复校已应用: 目标=%s 触发=%s 斗鱼估算=%s 偏移=%dms 误差<=%dms RTT=%dms",
targetTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
calibration.Offset.Milliseconds(),
calibration.ErrorBound.Milliseconds(),
calibration.RTT.Milliseconds())
}
}
// CancelSchedule 停止尚未触发的定时任务。
func (s *Scheduler) CancelSchedule() bool {
s.scheduleMu.Lock()
defer s.scheduleMu.Unlock()
stopped := false
if s.schedule != nil {
stopped = s.schedule.Stop()
}
s.stopSchedulesLocked()
s.clearWarmups()
return stopped
}
func (s *Scheduler) calibrateDouyuTime(label string) types.TimeCalibration {
cli := s.clientSnapshot()
calibration, err := cli.BestServerTimeCalibration(timeCalibrateRounds, timeCalibrateWait)
if err != nil {
logger.LogEvent("时间", "warn", "%s校准降级: %v", label, err)
}
if !calibration.Fallback {
s.recordCalibrationOffset(calibration.Offset)
}
smoothed := s.smoothedOffset()
if smoothed == 0 && calibration.Fallback {
smoothed = calibration.Offset
}
calibration.Offset = smoothed
logger.SetDouyuOffset(smoothed)
logger.LogEvent("时间", "success", "%s校准完成: 斗鱼估算=%s 平滑偏移=%dms 误差<=%dms RTT=%dms 采样=%d 来源=%s",
label,
calibration.EstimatedServerNow().In(constants.CST).Format("2006-01-02 15:04:05.000"),
smoothed.Milliseconds(),
calibration.ErrorBound.Milliseconds(),
calibration.RTT.Milliseconds(),
calibration.Samples,
calibration.Source)
return calibration
}
func (s *Scheduler) replaceTimerLocked(timer **time.Timer, d time.Duration, fn func()) {
if *timer != nil {
(*timer).Stop()
}
*timer = time.AfterFunc(d, fn)
}
func (s *Scheduler) stopSchedulesLocked() {
if s.schedule != nil {
s.schedule.Stop()
s.schedule = nil
}
if s.warmupSchedule != nil {
s.warmupSchedule.Stop()
s.warmupSchedule = nil
}
if s.resyncSchedule != nil {
s.resyncSchedule.Stop()
s.resyncSchedule = nil
}
}