From d1b8fa0e462a74a943f434fcb0ae6c986f2ae419 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 28 Jun 2026 15:15:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=91=E6=8D=A2?= =?UTF-8?q?=E5=93=8D=E5=BA=94JSON=E8=A7=A3=E6=9E=90Bug=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=97=B6=E9=97=B4=E6=A0=A1=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复Exchange/BatchExchangeLimit响应data为空字符串时JSON解析失败,导致"点击太快"等错误无法被冷却机制捕获 - 复校时间从触发前10秒改为3秒,减少偏移漂移 - 新增校准偏移中位数平滑,保留最近3次取中位数过滤跳变异常 - 提前量从固定50ms改为动态值 max(50ms, errorBound + RTT/2) - 复校时重新计算触发时间,适应校准偏移变化 --- internal/client/client.go | 36 +++++++------ internal/seckill/scheduler.go | 98 +++++++++++++++++++++++++---------- 2 files changed, 91 insertions(+), 43 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index f1b4797..1e0f92e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -313,12 +313,7 @@ func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID string, csrf *strin if csrf != nil { currentCSRF = *csrf } - var result struct { - types.APIResponse - Data struct { - Limit string `json:"limit"` - } `json:"data"` - } + var result types.APIResponse if err := c.postMultipart(constants.BatchLimitURL, cookie, csrf, "批量限制", map[string]string{ "manualID": constants.ManualID, "commodityID": commodityID, @@ -330,9 +325,15 @@ func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID string, csrf *strin if result.Error != 0 { return 0, fmt.Errorf("批量限制接口失败: %s", result.Msg) } - limit, err := strconv.Atoi(result.Data.Limit) + var data struct { + Limit string `json:"limit"` + } + if err := json.Unmarshal(result.Data, &data); err != nil { + return 0, fmt.Errorf("批量限制数据解析失败: %w", err) + } + limit, err := strconv.Atoi(data.Limit) if err != nil { - return 0, fmt.Errorf("批量限制值无效: %q", result.Data.Limit) + return 0, fmt.Errorf("批量限制值无效: %q", data.Limit) } return limit, nil } @@ -365,14 +366,7 @@ func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string if csrf != nil { currentCSRF = *csrf } - var result struct { - types.APIResponse - Data struct { - ExchangeID int `json:"exchangeId"` - ExchangeNum string `json:"exchangeNum"` - CommodityTyp int `json:"commodityType"` - } `json:"data"` - } + var result types.APIResponse if err := c.postMultipart(constants.ExchangeURL, cookie, csrf, "正式兑换", map[string]string{ "manualID": constants.ManualID, "commodityID": commodityID, @@ -388,7 +382,15 @@ func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string } return "", fmt.Errorf("兑换失败: %s", result.Msg) } - return fmt.Sprintf("exchangeId=%d,num=%s", result.Data.ExchangeID, result.Data.ExchangeNum), nil + var data struct { + ExchangeID int `json:"exchangeId"` + ExchangeNum string `json:"exchangeNum"` + CommodityTyp int `json:"commodityType"` + } + if err := json.Unmarshal(result.Data, &data); err != nil { + return "", fmt.Errorf("兑换数据解析失败: %w", err) + } + return fmt.Sprintf("exchangeId=%d,num=%s", data.ExchangeID, data.ExchangeNum), nil } // RoleName 查询绑定角色名。 diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go index 41c2db3..c07b821 100644 --- a/internal/seckill/scheduler.go +++ b/internal/seckill/scheduler.go @@ -8,6 +8,7 @@ import ( "math/rand" "os" "path/filepath" + "sort" "strings" "sync" "sync/atomic" @@ -24,17 +25,18 @@ import ( ) const ( - warmupBeforeTrigger = 2 * time.Second - warmupMaxAge = 30 * time.Second - qualifiedMaxAge = 30 * time.Second - timeCalibrateRounds = 3 - timeCalibrateWait = 1200 * time.Millisecond - resyncBeforeTrigger = 10 * time.Second - seckillMaxDuration = 75 * time.Second - behaviorCooldown = 5 * time.Second - clickFastCooldown = 3 * time.Second - internalAdvance = 50 * time.Millisecond - maxCooldownCount = 5 + warmupBeforeTrigger = 2 * time.Second + warmupMaxAge = 30 * time.Second + qualifiedMaxAge = 30 * time.Second + timeCalibrateRounds = 3 + timeCalibrateWait = 1200 * time.Millisecond + resyncBeforeTrigger = 3 * time.Second + calibrationHistorySize = 3 + seckillMaxDuration = 75 * time.Second + behaviorCooldown = 5 * time.Second + clickFastCooldown = 3 * time.Second + minInternalAdvance = 50 * time.Millisecond + maxCooldownCount = 5 ) type roundOutcome struct { @@ -91,6 +93,9 @@ type Scheduler struct { productsMu sync.RWMutex products map[string]types.Product + + calibrationMu sync.Mutex + calibrationOffsets []time.Duration // 最近N次校准偏移,用于中位数平滑 } // New 创建抢购调度器。 @@ -111,13 +116,14 @@ func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, con 1, 256, ), - concurrency: concurrency, - attempts: attempts, - retryStages: cloneRetryStages(retryStages), - seckillMode: normalizeSeckillMode(seckillMode), - results: make(map[string]string), - attemptCache: make(map[string]*types.AttemptCache), - products: make(map[string]types.Product), + concurrency: concurrency, + attempts: attempts, + retryStages: cloneRetryStages(retryStages), + seckillMode: normalizeSeckillMode(seckillMode), + results: make(map[string]string), + attemptCache: make(map[string]*types.AttemptCache), + products: make(map[string]types.Product), + calibrationOffsets: make([]time.Duration, 0, calibrationHistorySize), } } @@ -866,16 +872,54 @@ func uniqueWarmupAttempts(attempts []types.ExchangeAttempt) []types.ExchangeAtte return out } +// recordCalibrationOffset 记录校准偏移,保留最近N次用于中位数平滑。 +func (s *Scheduler) recordCalibrationOffset(offset time.Duration) { + s.calibrationMu.Lock() + defer s.calibrationMu.Unlock() + s.calibrationOffsets = append(s.calibrationOffsets, offset) + if len(s.calibrationOffsets) > calibrationHistorySize { + s.calibrationOffsets = s.calibrationOffsets[len(s.calibrationOffsets)-calibrationHistorySize:] + } +} + +// smoothedOffset 返回最近校准偏移的中位数,过滤跳变异常。 +func (s *Scheduler) smoothedOffset() time.Duration { + s.calibrationMu.Lock() + defer s.calibrationMu.Unlock() + n := len(s.calibrationOffsets) + if n == 0 { + return 0 + } + sorted := make([]time.Duration, n) + copy(sorted, s.calibrationOffsets) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + return sorted[n/2] +} + +// dynamicAdvance 根据校准误差边界和 RTT 计算动态提前量,最少 50ms。 +func dynamicAdvance(calibration types.TimeCalibration) time.Duration { + adv := calibration.ErrorBound + calibration.RTT/2 + if adv < minInternalAdvance { + adv = minInternalAdvance + } + return adv +} + func (s *Scheduler) calibrateDouyuTime(label string) types.TimeCalibration { calibration, err := s.client.BestServerTimeCalibration(timeCalibrateRounds, timeCalibrateWait) if err != nil { logger.LogEvent("时间", "warn", "%s校准降级: %v", label, err) } - logger.SetDouyuOffset(calibration.Offset) - logger.LogEvent("时间", "success", "%s校准完成: 斗鱼估算=%s 偏移=%dms 误差<=%dms RTT=%dms 采样=%d 来源=%s", + if !calibration.Fallback { + s.recordCalibrationOffset(calibration.Offset) + } + smoothed := s.smoothedOffset() + logger.SetDouyuOffset(smoothed) + logger.LogEvent("时间", "success", "%s校准完成: 斗鱼估算=%s 偏移=%dms 平滑偏移=%dms 误差<=%dms RTT=%dms 采样=%d 来源=%s", label, calibration.EstimatedServerNow().In(constants.CST).Format("2006-01-02 15:04:05.000"), calibration.Offset.Milliseconds(), + smoothed.Milliseconds(), calibration.ErrorBound.Milliseconds(), calibration.RTT.Milliseconds(), calibration.Samples, @@ -1100,7 +1144,8 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (map[string]any, error calibration := s.calibrateDouyuTime("定时") serverTime := calibration.EstimatedServerNow() - triggerTime := targetTime.Add(-internalAdvance) + advance := dynamicAdvance(calibration) + triggerTime := targetTime.Add(-advance) remain := triggerTime.Sub(serverTime) if remain <= 0 { return nil, errors.New("目标时间已过") @@ -1122,7 +1167,9 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (map[string]any, error s.scheduleMu.Lock() s.replaceTimerLocked(&s.resyncSchedule, resyncRemain, func() { newCalibration := s.calibrateDouyuTime("临近") - s.scheduleTimers(ap, targetTime, triggerTime, newCalibration, true) + newAdvance := dynamicAdvance(newCalibration) + newTriggerTime := targetTime.Add(-newAdvance) + s.scheduleTimers(ap, targetTime, newTriggerTime, newCalibration, true) }) s.scheduleMu.Unlock() @@ -1131,12 +1178,11 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (map[string]any, error warmupRemain = 0 } - logger.LogEvent("定时", "success", "定时已设置: 目标=%s 触发=%s 提前=%dms 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s 复校=%s", + logger.LogEvent("定时", "success", "定时已设置: 目标=%s 触发=%s 提前=%dms 平滑偏移=%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"), - internalAdvance.Milliseconds(), - serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"), - calibration.Offset.Milliseconds(), + advance.Milliseconds(), + s.smoothedOffset().Milliseconds(), calibration.ErrorBound.Milliseconds(), calibration.RTT.Milliseconds(), time.Now().Add(warmupRemain).In(constants.CST).Format("15:04:05.000"), From b0862c55fc597bf6748ea5c283c52583bfe77c06 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 28 Jun 2026 15:23:16 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E6=8A=A2=E8=B4=AD=E5=89=8D=E7=AB=AF=E6=98=BE=E7=A4=BA=E2=80=9C?= =?UTF-8?q?=E5=89=A9=E4=BD=99=20undefined=20=E7=A7=92=E2=80=9D=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前移除手动提前量时误删了 remainSecond 返回值,前端仍依赖该字段显示倒计时 --- internal/seckill/scheduler.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go index c07b821..df1067a 100644 --- a/internal/seckill/scheduler.go +++ b/internal/seckill/scheduler.go @@ -5,6 +5,7 @@ import ( "bytes" "errors" "fmt" + "math" "math/rand" "os" "path/filepath" @@ -1193,6 +1194,7 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (map[string]any, error "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": int(math.Ceil(remain.Seconds())), "offsetMs": calibration.Offset.Milliseconds(), "errorBoundMs": calibration.ErrorBound.Milliseconds(), "rttMs": calibration.RTT.Milliseconds(), From 1996b3a3fc031d38bcc32972634c623c6f49c00f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 28 Jun 2026 15:28:59 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=A0=A1=E5=87=86=EF=BC=9A=E9=81=BF=E5=85=8D=E9=A2=84=E7=83=AD?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E3=80=81=E5=B9=B3=E6=BB=91=E5=81=8F=E7=A7=BB?= =?UTF-8?q?=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 复校时跳过预热定时器,初始调用已完成预热 - calibrateDouyuTime 返回平滑后的偏移值,定时器调度使用中位数过滤跳变 --- internal/seckill/scheduler.go | 37 ++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go index df1067a..84345b8 100644 --- a/internal/seckill/scheduler.go +++ b/internal/seckill/scheduler.go @@ -916,10 +916,10 @@ func (s *Scheduler) calibrateDouyuTime(label string) types.TimeCalibration { } smoothed := s.smoothedOffset() logger.SetDouyuOffset(smoothed) - logger.LogEvent("时间", "success", "%s校准完成: 斗鱼估算=%s 偏移=%dms 平滑偏移=%dms 误差<=%dms RTT=%dms 采样=%d 来源=%s", + calibration.Offset = 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"), - calibration.Offset.Milliseconds(), smoothed.Milliseconds(), calibration.ErrorBound.Milliseconds(), calibration.RTT.Milliseconds(), @@ -1207,23 +1207,25 @@ func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime if remain < 0 { remain = 0 } - warmupRemain := remain - warmupBeforeTrigger - if warmupRemain < 0 { - warmupRemain = 0 - } s.scheduleMu.Lock() defer s.scheduleMu.Unlock() - gen := atomic.AddInt64(&s.warmupGen, 1) - s.replaceTimerLocked(&s.warmupSchedule, warmupRemain, func() { - if atomic.LoadInt64(&s.warmupGen) != gen { - return + if !resync { + warmupRemain := remain - warmupBeforeTrigger + if warmupRemain < 0 { + warmupRemain = 0 } - logger.LogEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(constants.CST).Format("15:04:05.000")) - settings := s.settingsSnapshot() - attempts := s.buildAttempts(accountProducts, settings.attempts) - s.warmupAttempts(attempts) - }) + gen := atomic.AddInt64(&s.warmupGen, 1) + s.replaceTimerLocked(&s.warmupSchedule, warmupRemain, func() { + if atomic.LoadInt64(&s.warmupGen) != gen { + return + } + logger.LogEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(constants.CST).Format("15:04:05.000")) + settings := s.settingsSnapshot() + attempts := s.buildAttempts(accountProducts, settings.attempts) + s.warmupAttempts(attempts) + }) + } s.replaceTimerLocked(&s.schedule, remain, func() { now := time.Now() estimatedServer := now.Add(calibration.Offset) @@ -1239,14 +1241,13 @@ func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime s.RunNow(accountProducts) }) if resync { - logger.LogEvent("定时", "success", "临近复校已应用: 目标=%s 触发=%s 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s", + 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(), - time.Now().Add(warmupRemain).In(constants.CST).Format("15:04:05.000")) + calibration.RTT.Milliseconds()) } } From c61be33d77585c8f1a42e113d674520db1b8f6ca Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 28 Jun 2026 15:30:23 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8F=B7=200.1.2=20?= =?UTF-8?q?=E2=86=92=200.1.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/constants/constants.go | 2 +- wails.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 119e0a8..896afd8 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -3,7 +3,7 @@ package constants import "time" // Version 应用版本号,与 wails.json version 同步。 -const Version = "0.1.2" +const Version = "0.1.3" // BuildTime 构建时间,由编译时 ldflags 注入。 var BuildTime = "unknown" diff --git a/wails.json b/wails.json index f023125..f8b8ac7 100644 --- a/wails.json +++ b/wails.json @@ -2,7 +2,7 @@ "$schema": "https://wails.io/schemas/config.v2.json", "name": "douyu_go", "outputfilename": "douyu_go", - "version": "0.1.2", + "version": "0.1.3", "frontend:dir": "frontend", "frontend:install": "npm install", "frontend:build": "npm run build",