Files
dygou_go/internal/seckill/scheduler.go
T
yml2213 dce9ce2a5a 优化日志系统:网络请求降噪并按接口聚合,补齐双时间说明与实例标识,发布0.3.2
- 逐条网络日志(请求完成 / 接口返回异常 / 响应刷新CSRF已同步)从 info/warn 降为 debug:
  实测一次 6 账号的模拟抢购里 233 条日志有 174 条(74%)属于这类;真实抢购会产生几千条,
  把界面仅 800 条的内存日志冲掉,反而看不到关键信息。HTTP/解析/传输层错误仍为 error 级
- 新增按接口聚合的网络统计,抢购任务结束时输出一行,替代逐条刷屏:
  "接口统计: 锁定兑换商品 ×341 失败341 平均=95ms 最慢=312ms 最后错误=活动火爆,请稍后再试"
- 日志文件开头新增表头:实例名、数据目录、双时间格式说明(本地时间 + D 斗鱼估算时间)、
  并说明"校准完成前偏移为 0,只显示本地时间"。双时间一直存在却没有任何地方解释过,
  多实例日志复制或汇总后也分不清来源
- 界面日志面板:显示日志级别并按级别着色(此前级别只存在于 CSS class,复制出去就丢);
  新增"复制"按钮,按与日志文件一致的单行格式导出(此前直接复制会变成 5 行一条、
  1204 行里其实只有 233 条日志)
- 设置页"日志显示等级"补充说明:Info 起只显示关键日志,需要逐请求细节时切到 Debug
- 新增测试:TestRequestStatsAggregatesByLabel、TestLogFileHeaderDocumentsFormat、
  TestRunNowLogsAggregateInsteadOfPerRequest(断言必须有聚合行、且不得出现逐条请求日志)
2026-10-03 19:33:14 +08:00

1123 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package seckill
import (
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/golang/freetype/truetype"
"douyu_go/internal/client"
"douyu_go/internal/constants"
"douyu_go/internal/image"
"douyu_go/internal/logger"
"douyu_go/internal/types"
"douyu_go/internal/utils"
)
const (
warmupBeforeTrigger = 2 * time.Second
warmupMaxAge = 30 * time.Second
qualifiedMaxAge = 30 * time.Second
timeCalibrateRounds = 3
timeCalibrateWait = 1200 * time.Millisecond
resyncBeforeTrigger = 3 * time.Second
calibrationHistorySize = 3
minInternalAdvance = 50 * time.Millisecond
maxCooldownCount = 5
xiaodianDefaultDelay = 500 * time.Millisecond
)
// seckillMaxDuration 是手册类活动的抢购窗口:超过后不再发起新的锁单。
// 声明为变量是为了让测试能缩短窗口。
var seckillMaxDuration = 75 * time.Second
type roundOutcome struct {
successAccounts map[string]bool
stoppedAccounts map[string]string
stoppedProducts map[string]string
cooldownAccounts map[string]time.Time
results map[string]types.ExchangeResult
attempts int
}
type runSettings struct {
concurrency int
attempts int
retryStages []types.RetryStage
seckillMode types.SeckillMode
}
// Scheduler 抢购调度器。
// 具体职责拆分到同包文件:attempt.go(兑换执行)、warmup.go(预热)、
// schedule_timer.go(定时抢购)、account_store.go(账号管理)。
type Scheduler struct {
clientMu sync.RWMutex
client *client.DouyuClient
paths types.AppPaths
font *truetype.Font
successImages *image.SuccessImageSaver
settingsMu sync.RWMutex
concurrency int // 抢购并发数
attempts int // 单号重试次数
retryStages []types.RetryStage // 重试间隔阶段配置
seckillMode types.SeckillMode // 抢购模式
accounts *AccountStore
resultsMu sync.RWMutex
results map[string]string
reportMu sync.RWMutex
latestXiaodianReport *types.XiaodianRunReport
runMu sync.Mutex
running bool
stopRun chan struct{}
stopRequested bool
stateMu sync.RWMutex
onStateChange func(string)
// pendingClient 保存运行中收到的客户端更新,当前任务结束后自动应用。
pendingClient *client.DouyuClient
scheduleMu sync.Mutex
schedule *time.Timer
warmupSchedule *time.Timer
resyncSchedule *time.Timer
warmupGen int64
warmupsMu sync.RWMutex
warmups map[string]types.WarmupData
warmupRunMu sync.Mutex
attemptCacheMu sync.RWMutex
attemptCache map[string]*types.AttemptCache // 激进模式缓存
productsMu sync.RWMutex
products map[string]types.Product
calibrationMu sync.Mutex
calibrationOffsets []time.Duration // 最近N次校准偏移,用于中位数平滑
}
// SetStateChangeCallback 设置运行状态变化回调。
func (s *Scheduler) SetStateChangeCallback(cb func(string)) {
s.stateMu.Lock()
s.onStateChange = cb
s.stateMu.Unlock()
}
func (s *Scheduler) emitState(state string) {
s.stateMu.RLock()
cb := s.onStateChange
s.stateMu.RUnlock()
if cb != nil {
cb(state)
}
}
// New 创建抢购调度器。
func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, concurrency, attempts int, retryStages []types.RetryStage, seckillMode types.SeckillMode) *Scheduler {
if concurrency <= 0 {
concurrency = constants.DefaultConcurrency
}
if attempts <= 0 {
attempts = constants.DefaultAttempts
}
scheduler := &Scheduler{
client: cli,
paths: paths,
font: font,
accounts: NewAccountStore(paths.AccountFile, paths.LegacyAccountFile),
successImages: image.NewSuccessImageSaver(
font,
paths.SuccessDir,
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),
calibrationOffsets: make([]time.Duration, 0, calibrationHistorySize),
}
scheduler.loadLatestXiaodianReport()
return scheduler
}
// Close 关闭调度器的后台资源。
func (s *Scheduler) Close() {
if s.successImages != nil {
s.successImages.Close()
}
}
// clientSnapshot 返回当前 client 的指针快照(读锁保护,用于执行期间持有一个稳定引用)。
func (s *Scheduler) clientSnapshot() *client.DouyuClient {
s.clientMu.RLock()
defer s.clientMu.RUnlock()
return s.client
}
// UpdateClient 热替换底层 HTTP 客户端(用于活动切换)。
// 若有抢购任务正在运行则暂存新客户端,当前任务结束后自动应用,并返回 false。
func (s *Scheduler) UpdateClient(newClient *client.DouyuClient) bool {
s.runMu.Lock()
if s.running {
if s.pendingClient != nil && s.pendingClient.HTTPClient != nil {
s.pendingClient.HTTPClient.CloseIdleConnections()
}
s.pendingClient = newClient
s.runMu.Unlock()
return false
}
s.runMu.Unlock()
s.replaceClient(newClient)
return true
}
func (s *Scheduler) replaceClient(newClient *client.DouyuClient) {
s.clientMu.Lock()
defer s.clientMu.Unlock()
old := s.client
s.client = newClient
if old != nil && old.HTTPClient != nil {
old.HTTPClient.CloseIdleConnections()
}
}
// UpdateProducts 更新商品缓存,抢购成功图片会优先使用中文商品名。
func (s *Scheduler) UpdateProducts(products []types.Product) {
next := make(map[string]types.Product, len(products))
for _, product := range products {
if product.CommodityID == "" {
continue
}
if product.CommodityName == "" {
product.CommodityName = product.CommodityID
}
next[product.CommodityID] = product
}
s.productsMu.Lock()
s.products = next
s.productsMu.Unlock()
}
// UpdateSettings 运行时更新并发数、重试次数、重试间隔和抢购模式。
func (s *Scheduler) UpdateSettings(concurrency, attempts int, retryStages []types.RetryStage, seckillMode types.SeckillMode) {
s.settingsMu.Lock()
defer s.settingsMu.Unlock()
if concurrency > 0 {
s.concurrency = concurrency
}
if attempts > 0 {
s.attempts = attempts
}
if len(retryStages) > 0 {
s.retryStages = cloneRetryStages(retryStages)
}
s.seckillMode = normalizeSeckillMode(seckillMode)
}
func (s *Scheduler) settingsSnapshot() runSettings {
s.settingsMu.RLock()
settings := runSettings{
concurrency: s.concurrency,
attempts: s.attempts,
retryStages: cloneRetryStages(s.retryStages),
seckillMode: normalizeSeckillMode(s.seckillMode),
}
s.settingsMu.RUnlock()
if settings.concurrency <= 0 {
settings.concurrency = constants.DefaultConcurrency
}
if settings.attempts <= 0 {
settings.attempts = constants.DefaultAttempts
}
return settings
}
func cloneRetryStages(stages []types.RetryStage) []types.RetryStage {
if len(stages) == 0 {
return nil
}
out := make([]types.RetryStage, len(stages))
copy(out, stages)
return out
}
func normalizeSeckillMode(mode types.SeckillMode) types.SeckillMode {
switch mode {
case types.SeckillModeNormal, types.SeckillModeAggressive:
return mode
default:
return types.SeckillModeAggressive
}
}
// ==================== 账号转发(AccountStore) ====================
// LoadAccounts 从应用内部账号库加载账号。
func (s *Scheduler) LoadAccounts() {
s.accounts.Load()
}
// AccountCount 返回当前账号数。
func (s *Scheduler) AccountCount() int {
return s.accounts.Count()
}
// AccountFilePath 返回内部账号库路径。
func (s *Scheduler) AccountFilePath() string {
return s.accounts.FilePath()
}
// AccountSnapshot 复制一份账号列表。
func (s *Scheduler) AccountSnapshot() []types.Account {
return s.accounts.Snapshot()
}
// AccountCookieByName 按账号名查找 Cookie。
func (s *Scheduler) AccountCookieByName(name string) (string, bool) {
return s.accounts.CookieByName(name)
}
// ==================== 结果管理 ====================
// Results 返回当前抢购结果快照。
func (s *Scheduler) Results() map[string]string {
s.resultsMu.RLock()
defer s.resultsMu.RUnlock()
out := make(map[string]string, len(s.results))
for k, v := range s.results {
out[k] = v
}
return out
}
// ClearResults 清空抢购结果。
func (s *Scheduler) ClearResults() {
s.resultsMu.Lock()
defer s.resultsMu.Unlock()
s.results = make(map[string]string)
}
func (s *Scheduler) setResult(key, value string) {
s.resultsMu.Lock()
defer s.resultsMu.Unlock()
s.results[key] = value
}
// ==================== 运行控制 ====================
// TryStartRun 尝试开始抢购,返回 false 表示已有任务在运行。
func (s *Scheduler) TryStartRun() bool {
s.runMu.Lock()
if s.running {
s.runMu.Unlock()
return false
}
s.running = true
s.stopRequested = false
s.stopRun = make(chan struct{})
s.runMu.Unlock()
s.emitState("running")
return true
}
func (s *Scheduler) finishRun() {
s.runMu.Lock()
s.running = false
s.stopRequested = false
s.stopRun = nil
pendingClient := s.pendingClient
s.pendingClient = nil
s.runMu.Unlock()
if pendingClient != nil {
s.replaceClient(pendingClient)
logger.LogEvent("配置", "success", "抢购任务结束,已应用等待中的客户端配置")
}
s.emitState("idle")
}
// StopRun 请求停止当前正在执行的抢购任务。
func (s *Scheduler) StopRun() bool {
s.runMu.Lock()
if !s.running || s.stopRequested || s.stopRun == nil {
s.runMu.Unlock()
return false
}
s.stopRequested = true
close(s.stopRun)
s.runMu.Unlock()
s.emitState("stopping")
return true
}
// IsRunning 返回当前是否有抢购任务正在执行。
func (s *Scheduler) IsRunning() bool {
s.runMu.Lock()
defer s.runMu.Unlock()
return s.running
}
func (s *Scheduler) stopRunChan() <-chan struct{} {
s.runMu.Lock()
defer s.runMu.Unlock()
return s.stopRun
}
func (s *Scheduler) runStopped() bool {
s.runMu.Lock()
defer s.runMu.Unlock()
return s.stopRequested
}
func (s *Scheduler) interruptibleSleep(d time.Duration) bool {
if d <= 0 {
return s.runStopped()
}
stop := s.stopRunChan()
if stop == nil {
time.Sleep(d)
return false
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return false
case <-stop:
return true
}
}
// RunNow 立即执行手册类活动的抢购任务(精英手册锁单支付 / 电竞手册直接兑换)。
//
// 每个账号在自己的 goroutine 中独立重试,互不阻塞。早期版本用"轮次 + wg.Wait"
// 把所有账号绑在一起:3 个中签账号卡在 60 秒支付退避时,另外 31 个账号白白空转
// 39 秒(2026-10-03 日志实测),还出现过"某一轮只跑了 1 个账号、其余 33 个整轮
// 没被尝试"的退化。
//
// 窗口硬约束:到 seckillMaxDuration 后不再发起新的锁单;已经锁单成功的账号
// 仍会把支付做完(支付阶梯只受停止指令和锁单过期约束,放弃等于白锁)。
func (s *Scheduler) RunNow(accountProducts map[string]string) {
defer s.finishRun()
s.ClearResults()
settings := s.settingsSnapshot()
tasks := s.buildTasks(accountProducts)
if len(tasks) == 0 {
logger.LogEvent("抢购", "warn", "没有可执行的抢购任务")
return
}
start := time.Now()
deadline := start.Add(seckillMaxDuration)
ctl := newRunControl(s, start, deadline, settings.concurrency, len(tasks))
s.clearAttemptCache()
if settings.seckillMode == types.SeckillModeAggressive {
s.initCacheFromWarmup(tasks)
}
logger.LogEvent("抢购", "info", "开始抢购: %d 个账号,窗口=%v,截止=%s",
len(tasks), seckillMaxDuration, deadline.In(constants.CST).Format("15:04:05.000"))
for _, target := range productTargets(tasks) {
logger.LogEvent("抢购", "info", "抢购目标: %s", target)
}
// 逐条网络日志已降为 debug 级,这里开始统计每类接口的调用情况,
// 任务结束时输出一行聚合结果。
if cli := s.clientSnapshot(); cli != nil {
cli.ResetRequestStats()
}
outcomes := make([]accountOutcome, len(tasks))
var wg sync.WaitGroup
for i, task := range tasks {
wg.Add(1)
go func(index int, task types.ExchangeAttempt) {
defer wg.Done()
outcomes[index] = s.runAccountLoop(task, settings, ctl)
}(i, task)
}
wg.Wait()
s.logRunSummary(outcomes, ctl, time.Since(start))
s.logNetworkSummary(s.clientSnapshot())
}
// logNetworkSummary 输出本次任务期间各类接口的聚合统计,替代逐条刷屏的网络日志。
func (s *Scheduler) logNetworkSummary(cli *client.DouyuClient) {
if cli == nil {
return
}
for _, stat := range cli.RequestStats() {
avg := time.Duration(0)
if stat.Count > 0 {
avg = stat.Total / time.Duration(stat.Count)
}
if stat.LastErr != "" {
logger.LogEvent("网络", "info", "接口统计: %s ×%d 失败%d 平均=%s 最慢=%s 最后错误=%s",
stat.Label, stat.Count, stat.Failed, utils.RoundDuration(avg), utils.RoundDuration(stat.Slowest), stat.LastErr)
continue
}
logger.LogEvent("网络", "info", "接口统计: %s ×%d 失败%d 平均=%s 最慢=%s",
stat.Label, stat.Count, stat.Failed, utils.RoundDuration(avg), utils.RoundDuration(stat.Slowest))
}
}
// 账号最终结束状态。
const (
outcomeSuccess = "success"
outcomeRejected = "rejected" // 窗口结束时商品始终售罄
outcomeThrottled = "throttled" // 窗口内始终被请求级限流
outcomeTerminal = "terminal" // 命中账号级终止条件
outcomeLockedUnpaid = "lockedUnpaid" // 已锁单成功但支付未完成
outcomeStopped = "stopped"
outcomeExpired = "expired"
)
// 每账号重试节奏的上下限与递增倍数。
//
// 抢购是争分夺秒的,退避不能长:2026-10-03 的日志里,PC2 四个实例在
// 18:00:00.171~18:00:01.101 一直能锁到同一个礼包(库存至少活了 1.1 秒),
// 而 PC1 默认实例的 31 个号在 18:00:00.225 失败后一直睡到 18:01:09.379——
// 唯一有货的那段窗口被退避白白吃掉了。所以这里把任何一次等待都压在 2 秒以内。
const (
minRetryDelay = 100 * time.Millisecond
maxRetryDelay = 1500 * time.Millisecond
maxRetryEscalation = 4
)
// 首发请求的错开参数:第 0 个账号立即发出,其余按小步长依次错开,
// 整批最多铺开 firstWaveSpread(账号再多也不会拖长),单个步长不超过 firstWaveMaxStep。
const (
firstWaveSpread = 300 * time.Millisecond
firstWaveMaxStep = 8 * time.Millisecond
)
// stockOutWarnAfter 是同一账号连续收到"商品已售罄"多少次后,打一条"转为低频探测"
// 的提示日志。它只影响提示,不影响是否继续重试。
//
// 刻意不因售罄而停止:别人锁单后支付失败或锁单过期,占用的库存会回流,
// 窗口内必须继续探测(旧实现里就有"库存耗尽不停止,斗鱼可能会补货"的结论)。
// "活动火爆/网络繁忙"则连提示都不打——2026-10-03 日志里同一实例、同一秒内
// 3 个账号对同一个 commodityID 锁单成功而另外 31 个被拒,说明那只是请求级限流。
const stockOutWarnAfter = 8
// accountOutcome 是单个账号的最终结果。
type accountOutcome struct {
account string
kind string
reason string
locked bool
}
// runAccountLoop 是单个账号的独立重试循环:锁单失败按配置节奏重试,
// 达到连续被拒上限或账号级终止条件时退出。
func (s *Scheduler) runAccountLoop(task types.ExchangeAttempt, settings runSettings, ctl *runControl) accountOutcome {
out := accountOutcome{account: task.Account.Name}
// rejectStreak 只统计"该商品当前拿不到"的连续次数,用于停止判定;
// failStreak 统计所有连续失败,用于放大退避(两者混用会让频控/传输层错误
// 永远不递增,一路用配置里的最快节奏猛打)。
stockStreak := 0
stockOutWarned := false
failStreak := 0
attempts := 0
lastFailure := ""
lastStockOut := false
lastThrottled := false
for {
if ctl.stopped() {
out.kind, out.reason = outcomeStopped, "已收到停止指令"
return out
}
if ctl.expired() {
out.kind, out.reason = expiryOutcome(lastStockOut, lastThrottled, lastFailure)
if lastFailure != "" {
s.setResult(resultKey(task), "失败|"+out.reason)
}
return out
}
attempts++
task.Index = attempts
res := s.runAccountAttempt(task, settings.seckillMode, ctl)
out.locked = out.locked || res.locked
s.setResult(resultKey(task), res.result.Message)
if res.result.OK {
out.kind = outcomeSuccess
out.reason = res.result.Message
logger.LogAccountEvent("抢购", "success", out.account, "成功: 账号=%s 商品=%s 第%d次 %s",
out.account, res.result.Product, res.result.Attempt, res.result.Message)
s.enqueueSuccessImage(task.Account, task.Product, res.result.RoleName)
return out
}
if res.stopped {
out.kind, out.reason = outcomeStopped, "已收到停止指令"
return out
}
if res.terminal {
out.kind, out.reason = outcomeTerminal, failureReason(res.result.Message)
logger.LogAccountEvent("抢购", "warn", out.account, "账号停止重试: 账号=%s 原因=%s", out.account, out.reason)
return out
}
if res.locked {
// 已锁单成功但支付没有完成:不再新建锁单,避免重复下单。
out.kind, out.reason = outcomeLockedUnpaid, failureReason(res.result.Message)
logger.LogAccountEvent("抢购", "warn", out.account, "锁单已占用,不再新建锁单: 账号=%s 原因=%s", out.account, out.reason)
return out
}
failStreak++
lastFailure = failureReason(res.result.Message)
lastStockOut = res.stockOut
lastThrottled = res.throttled
if res.stockOut {
stockStreak++
} else {
stockStreak = 0
}
// 售罄不等于终态:别人锁单后支付失败或锁单过期,库存会回流,
// 所以窗口内一律继续探测,只是退避会随连续失败自动降到秒级。
if stockStreak == stockOutWarnAfter && !stockOutWarned {
stockOutWarned = true
logger.LogAccountEvent("抢购", "warn", out.account,
"商品疑似售罄,转为低频探测直到窗口结束: 账号=%s 商品=%s(%s) 连续%d次 %s",
out.account, task.Product.CommodityName, task.Product.CommodityID, stockStreak, lastFailure)
}
delay := retryDelay(settings.retryStages, ctl.elapsed(), failStreak)
logger.LogAccountEvent("抢购", "info", out.account, "第%d次未成功,%s 后重试: %s",
attempts, utils.RoundDuration(delay), lastFailure)
if !ctl.sleep(delay) {
if ctl.stopped() {
out.kind, out.reason = outcomeStopped, "已收到停止指令"
} else {
out.kind, out.reason = expiryOutcome(lastStockOut, lastThrottled, lastFailure)
// 把分类后的结束原因写进结果集,账号表才看得出"售罄/限流"而不是原始错误。
s.setResult(resultKey(task), "失败|"+out.reason)
}
return out
}
}
}
// expiryOutcome 生成"抢购窗口结束"的结束原因,并带上最后一次失败。
//
// 必须区分售罄 / 被限流 / 普通失败:否则界面上只有一个孤零零的 0 成功,
// 看不出到底发生了什么(这正是 2026-10-03 的排查痛点)。
// 注意这里不提前停止——售罄后库存可能回流,必须探测到窗口结束。
func expiryOutcome(lastStockOut, lastThrottled bool, lastFailure string) (string, string) {
switch {
case lastFailure == "":
return outcomeExpired, "抢购窗口已结束"
case lastStockOut:
return outcomeRejected, "抢购窗口结束(商品始终售罄: " + lastFailure + ")"
case lastThrottled:
return outcomeThrottled, "抢购窗口结束(最后仍被限流: " + lastFailure + ")"
default:
return outcomeExpired, "抢购窗口结束(最后失败: " + lastFailure + ")"
}
}
// retryDelay 计算账号下一次尝试前的等待时间。
//
// 基准节奏来自配置的"重试间隔策略"(按经过秒数分档)——早期版本把它只用在
// 轮与轮之间,单次锁单失败却硬编码睡 30 秒,配置形同虚设。同一账号连续失败时
// 再成倍放大(最多 ×4),避免单个账号猛打;并叠加 0-25% 抖动,
// 防止所有账号同步重试形成尖峰。
//
// 上限刻意压到 1.5 秒:抢购窗口只有 75 秒,库存随时可能因为别人锁单未支付而回流,
// 任何超过 2 秒的等待都可能正好错过它。
func retryDelay(stages []types.RetryStage, elapsed time.Duration, failStreak int) time.Duration {
base := retryIntervalFor(stages, elapsed)
if base <= 0 {
base = 900 * time.Millisecond
}
mult := 1
for i := 1; i < failStreak && mult < maxRetryEscalation; i++ {
mult <<= 1
}
delay := time.Duration(mult) * base
if delay > maxRetryDelay {
delay = maxRetryDelay
}
if delay < minRetryDelay {
delay = minRetryDelay
}
return jitterFraction(delay, 4)
}
// resultKey 生成结果键。
//
// 后缀保持历史上的 `_第N包` 形态:前端 useLogs.ts / ControlStrip.vue 用
// /_第\d+包$/ 归并出账号名。这里固定第 1 包,保证每个账号只占一个结果条目
// ——早期实现每次尝试都写一个新键,重试几十次后结果集会膨胀到几千条。
func resultKey(attempt types.ExchangeAttempt) string {
return fmt.Sprintf("%s_第1包", attempt.Account.Name)
}
// failureReason 去掉结果里的"失败|"前缀,便于汇总展示。
func failureReason(message string) string {
return strings.TrimPrefix(message, "失败|")
}
// productTargets 汇总本次抢购的商品,并把 commodityID 打进日志。
// 2026-10-03 的排查正卡在"日志里没有 commodityID",无法判断各实例是否选了同一个 SKU。
func productTargets(tasks []types.ExchangeAttempt) []string {
order := make([]string, 0, 2)
names := make(map[string]string, 2)
counts := make(map[string]int, 2)
for _, task := range tasks {
id := task.Product.CommodityID
if _, ok := counts[id]; !ok {
order = append(order, id)
names[id] = task.Product.CommodityName
}
counts[id]++
}
out := make([]string, 0, len(order))
for _, id := range order {
out = append(out, fmt.Sprintf("商品=%s(%s) 账号=%d", names[id], id, counts[id]))
}
return out
}
// logRunSummary 输出按结束原因分类的汇总与真实请求次数。
func (s *Scheduler) logRunSummary(outcomes []accountOutcome, ctl *runControl, cost time.Duration) {
var success, rejected, throttled, terminal, lockedUnpaid, stopped, expired int
reasons := make(map[string]int)
for _, out := range outcomes {
switch out.kind {
case outcomeSuccess:
success++
case outcomeRejected:
rejected++
case outcomeThrottled:
throttled++
case outcomeTerminal:
terminal++
case outcomeLockedUnpaid:
lockedUnpaid++
case outcomeStopped:
stopped++
default:
expired++
}
if out.kind != outcomeSuccess && out.reason != "" {
reasons[out.reason]++
}
}
lockReqs, payReqs, exchangeReqs := ctl.counts()
logger.LogEvent("抢购", "info",
"抢购任务结束: 成功=%d/%d,商品售罄=%d,被限流=%d,账号终止=%d,锁单未支付=%d,停止=%d,窗口结束=%d,耗时=%v",
success, len(outcomes), rejected, throttled, terminal, lockedUnpaid, stopped, expired, utils.RoundDuration(cost))
logger.LogEvent("抢购", "info", "抢购请求统计: 锁单=%d,支付=%d,直接兑换=%d", lockReqs, payReqs, exchangeReqs)
type reasonCount struct {
reason string
count int
}
ranked := make([]reasonCount, 0, len(reasons))
for reason, count := range reasons {
ranked = append(ranked, reasonCount{reason: reason, count: count})
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].count != ranked[j].count {
return ranked[i].count > ranked[j].count
}
return ranked[i].reason < ranked[j].reason
})
for _, item := range ranked {
logger.LogEvent("抢购", "warn", "未成功原因: %s × %d 个账号", item.reason, item.count)
}
}
// RunXiaodian 使用和平小店专用策略:固定次数、固定间隔、每轮直接调用 livebuy。
// 小店补货不依赖旧活动的 75 秒阶段窗口,也不执行手册的预检链路。
func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, delayMs int, reportSessionID string) {
defer s.finishRun()
s.ClearResults()
settings := s.settingsSnapshot()
attempts, delay := normalizeXiaodianRun(attempts, delayMs, settings.attempts)
accounts := make([]types.Account, 0, len(accountProducts))
products := make(map[string]types.Product, len(accountProducts))
for name := range accountProducts {
cookie, ok := s.AccountCookieByName(name)
if !ok {
continue
}
productID := accountProducts[name]
product := s.productForCommodityID(productID)
if product.CommodityID == "" {
product.CommodityID = productID
}
accounts = append(accounts, types.Account{Name: name, Cookie: cookie})
products[name] = product
}
if len(accounts) == 0 {
logger.LogEvent("抢购", "warn", "和平小店抢购没有可执行账号")
return
}
s.startXiaodianReport(accounts, products, reportSessionID)
stoppedAccounts := make(map[string]string)
cooldownAccounts := make(map[string]time.Time)
cooldownCount := make(map[string]int)
start := time.Now()
logger.LogEvent("抢购", "info", "和平小店抢购开始: %d 个账号,次数=%d,间隔=%dms",
len(accounts), attempts, delay.Milliseconds())
for round, waitBeforeRound := 1, false; round <= attempts; {
if s.runStopped() {
logger.LogEvent("抢购", "warn", "收到停止指令,和平小店抢购正在结束")
break
}
if waitBeforeRound && s.interruptibleSleep(delay) {
logger.LogEvent("抢购", "warn", "收到停止指令,和平小店抢购正在结束")
break
}
waitBeforeRound = false
roundAttempts := make([]types.ExchangeAttempt, 0, len(accounts))
for _, account := range accounts {
if stoppedAccounts[account.Name] != "" {
continue
}
product := products[account.Name]
roundAttempts = append(roundAttempts, types.ExchangeAttempt{
Account: account,
Product: product,
Index: round,
})
}
if len(roundAttempts) == 0 {
break
}
now := time.Now()
roundAttempts, nextReady := filterCooldownAttempts(roundAttempts, cooldownAccounts, now)
if len(roundAttempts) == 0 {
waitFor := nextReady.Sub(now)
if waitFor > 0 {
logger.LogEvent("抢购", "info", "和平小店第%d/%d轮账号冷却中,等待=%v", round, attempts, utils.RoundDuration(waitFor))
if s.interruptibleSleep(waitFor) {
logger.LogEvent("抢购", "warn", "收到停止指令,和平小店抢购正在结束")
break
}
}
continue
}
outcome := s.executeXiaodianRound(roundAttempts, settings)
s.updateXiaodianReport(outcome.results)
for name := range outcome.successAccounts {
stoppedAccounts[name] = "已抢到"
}
for name, reason := range outcome.stoppedAccounts {
if stoppedAccounts[name] == "" {
stoppedAccounts[name] = reason
logger.LogAccountEvent("抢购", "warn", name, "账号停止重试: 账号=%s 原因=%s", name, reason)
}
}
s.stopXiaodianReportAccounts(outcome.stoppedAccounts)
for name, until := range outcome.cooldownAccounts {
if stoppedAccounts[name] != "" || !until.After(cooldownAccounts[name]) {
continue
}
cooldownAccounts[name] = until
cooldownCount[name]++
logger.LogAccountEvent("抢购", "warn", name, "账号临时降频: 账号=%s 冷却至=%s (第%d次)",
name, until.In(constants.CST).Format("15:04:05.000"), cooldownCount[name])
if cooldownCount[name] >= maxCooldownCount {
stoppedAccounts[name] = "冷却次数过多"
logger.LogAccountEvent("抢购", "warn", name, "账号停止重试: 账号=%s 原因=冷却次数过多(%d次)", name, cooldownCount[name])
}
}
logger.LogEvent("抢购", "info", "和平小店第%d/%d轮完成: 成功=%d,账号停止=%d,冷却=%d,剩余账号=%d",
round, attempts, len(outcome.successAccounts), len(outcome.stoppedAccounts), len(outcome.cooldownAccounts), len(accounts)-len(stoppedAccounts))
if len(stoppedAccounts) >= len(accounts) {
break
}
round++
waitBeforeRound = true
}
logger.LogEvent("抢购", "info", "和平小店抢购结束: 成功或停止=%d/%d,耗时=%v",
len(stoppedAccounts), len(accounts), utils.RoundDuration(time.Since(start)))
s.finishXiaodianReport(s.runStopped())
}
func normalizeXiaodianRun(attempts, delayMs, fallbackAttempts int) (int, time.Duration) {
if attempts <= 0 {
attempts = fallbackAttempts
}
if attempts <= 0 {
attempts = 3
}
if attempts > 100000 {
attempts = 100000
}
if delayMs < 0 {
delayMs = 0
}
if delayMs == 0 {
delayMs = int(xiaodianDefaultDelay / time.Millisecond)
}
if delayMs > 60000 {
delayMs = 60000
}
return attempts, time.Duration(delayMs) * time.Millisecond
}
func filterCooldownAttempts(attempts []types.ExchangeAttempt, cooldownAccounts map[string]time.Time, now time.Time) ([]types.ExchangeAttempt, time.Time) {
out := make([]types.ExchangeAttempt, 0, len(attempts))
var nextReady time.Time
for _, attempt := range attempts {
until := cooldownAccounts[attempt.Account.Name]
if until.After(now) {
if nextReady.IsZero() || until.Before(nextReady) {
nextReady = until
}
continue
}
out = append(out, attempt)
}
return out, nextReady
}
func (s *Scheduler) executeXiaodianRound(attempts []types.ExchangeAttempt, settings runSettings) roundOutcome {
return s.executeRoundWithRunner(attempts, settings, s.runXiaodianAttempt)
}
func (s *Scheduler) executeRoundWithRunner(
attempts []types.ExchangeAttempt,
settings runSettings,
runner func(types.ExchangeAttempt) types.ExchangeResult,
) roundOutcome {
outcome := roundOutcome{
successAccounts: make(map[string]bool),
stoppedAccounts: make(map[string]string),
stoppedProducts: make(map[string]string),
cooldownAccounts: make(map[string]time.Time),
results: make(map[string]types.ExchangeResult),
}
var mu sync.Mutex
limit := make(chan struct{}, settings.concurrency)
var wg sync.WaitGroup
for _, attempt := range attempts {
if s.runStopped() {
break
}
wg.Add(1)
go func(attempt types.ExchangeAttempt) {
defer wg.Done()
if s.runStopped() {
return
}
limit <- struct{}{}
defer func() { <-limit }()
if s.runStopped() {
return
}
result := runner(attempt)
// 固定后缀:前端 useLogs/ControlStrip 按 /_第\d+包$/ 归并账号名。
// 用轮次做后缀时,抢购次数 >=10 会让前端按字典序取到中间某次的旧原因。
key := fmt.Sprintf("%s_第1包", result.Account)
s.setResult(key, result.Message)
if result.OK {
logger.LogAccountEvent("抢购", "success", result.Account, "成功: 账号=%s 商品=%s 第%d次 %s", result.Account, result.Product, result.Attempt, result.Message)
s.enqueueSuccessImage(attempt.Account, attempt.Product, result.RoleName)
} else {
logger.LogAccountEvent("抢购", "info", result.Account, "尝试: 账号=%s 商品=%s 第%d次 %s", result.Account, result.Product, result.Attempt, result.Message)
}
mu.Lock()
outcome.attempts++
outcome.results[attempt.Account.Name] = result
if result.OK {
outcome.successAccounts[attempt.Account.Name] = true
} else {
if cooldown := retryCooldownDuration(result.Message); cooldown > 0 {
outcome.cooldownAccounts[attempt.Account.Name] = time.Now().Add(jitterDuration(cooldown))
}
terminalAccount, terminalProduct, reason := classifyXiaodianRetryStop(result.Message)
if terminalAccount {
outcome.stoppedAccounts[attempt.Account.Name] = reason
}
if terminalProduct {
outcome.stoppedProducts[attempt.Product.CommodityID] = reason
}
}
mu.Unlock()
}(attempt)
}
wg.Wait()
return outcome
}
// ==================== 辅助函数 ====================
// retryIntervalFor 根据配置的重试阶段计算当前应该使用的重试间隔。
func retryIntervalFor(retryStages []types.RetryStage, elapsed time.Duration) time.Duration {
elapsedSec := int(elapsed.Seconds())
// 使用配置的阶段
for _, stage := range retryStages {
if stage.MaxElapsed > 0 && elapsedSec < stage.MaxElapsed && stage.Interval > 0 {
return time.Duration(stage.Interval) * time.Millisecond
}
}
// 默认值(如果没有配置或配置无效)
return 900 * time.Millisecond
}
func jitterDuration(base time.Duration) time.Duration {
return jitterFraction(base, 10)
}
func retryCooldownDuration(message string) time.Duration {
if exchangeRateLimited(message) {
return exchangeRateCooldown
}
if strings.Contains(message, "锁定兑换商品失败") || strings.Contains(message, "支付失败") {
return peaceLockRetryFallback
}
return 0
}
func classifyRetryStop(message string) (account bool, product bool, reason string) {
switch {
case strings.Contains(message, "商品本次活动库存已耗完"):
// 库存耗尽不停止,继续重试(斗鱼可能会补货)
return false, false, ""
case strings.Contains(message, "您已达本次活动兑换上限"):
return true, false, "已达兑换上限"
case strings.Contains(message, "未绑定账号"):
return true, false, "账号未绑定角色"
case strings.Contains(message, "需要开通精英手册"):
return true, false, "账号不满足精英手册条件"
default:
return false, false, ""
}
}
func classifyXiaodianRetryStop(message string) (account bool, product bool, reason string) {
switch {
case strings.Contains(message, "Livelink登录态校验失败"),
strings.Contains(message, "Livelink") && strings.Contains(message, "登录态校验失败"):
return true, false, "和平小店绑定已失效,需重新绑定"
case strings.Contains(message, "余额不足"),
strings.Contains(message, "点券不足"):
return true, false, "点券余额不足"
case strings.Contains(message, "已拥有该道具"),
strings.Contains(message, "已经拥有该道具"),
strings.Contains(message, "已拥有该商品"):
return true, false, "账号已拥有该道具"
case strings.Contains(message, "未绑定账号"):
return true, false, "账号未绑定角色"
case strings.Contains(message, "商品不存在"),
strings.Contains(message, "商品已下架"),
strings.Contains(message, "不支持该商品"):
return false, true, "商品不可兑换"
default:
return false, false, ""
}
}
// 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()
if len(s.calibrationOffsets) == 0 {
return 0
}
sorted := append([]time.Duration(nil), s.calibrationOffsets...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
return sorted[len(sorted)/2]
}
// dynamicAdvance 根据校准误差边界和 RTT 计算动态提前量,最少 50ms。
func dynamicAdvance(calibration types.TimeCalibration) time.Duration {
advance := calibration.ErrorBound + calibration.RTT/2
if advance < minInternalAdvance {
advance = minInternalAdvance
}
return advance
}
// buildTasks 为每个账号生成一个抢购任务。
//
// 锁单+支付链路下,一次任务就是一次完整的"锁单→支付",不需要再按"N 个包"
// 为同一账号重复生成任务(历史实现会因此把预热缓存日志打两遍)。
func (s *Scheduler) buildTasks(accountProducts map[string]string) []types.ExchangeAttempt {
accounts := s.AccountSnapshot()
tasks := make([]types.ExchangeAttempt, 0, len(accounts))
for _, account := range accounts {
commodityID := accountProducts[account.Name]
if commodityID == "" {
continue
}
tasks = append(tasks, types.ExchangeAttempt{
Account: account,
Product: s.productForCommodityID(commodityID),
Index: 1,
})
}
return tasks
}
func (s *Scheduler) productForCommodityID(commodityID string) types.Product {
s.productsMu.RLock()
product, ok := s.products[commodityID]
s.productsMu.RUnlock()
if ok {
if product.CommodityName == "" {
product.CommodityName = commodityID
}
return product
}
return types.Product{CommodityID: commodityID, CommodityName: commodityID}
}
func (s *Scheduler) enqueueSuccessImage(account types.Account, product types.Product, roleName string) {
if s.successImages == nil {
return
}
s.successImages.Enqueue(account, product, roleName)
}