Files
yml2213 288109de2f 按 10-23 三机抢购日志复盘修正重试节奏与状态口径,发布0.3.8
10-23 三台机器 13 个实例抢同一次 18:00 的精英手册,把当天日志翻完发现
四个问题都指向"请求发得太猛、发得太早、结论分得太粗":

- 被限流的账号仍按 100ms 节奏猛打。单实例锁单 950~1650 次(约 20 QPS)时
  18~24 个账号被"您点击太快",而锁单仅 148 次的实例 30 个账号一次全中——
  可见"点击太快/活动火爆"是账号级封禁,继续打只会加深封禁。
- 定时器提前约 100ms 触发,想用这点提前量抵消 RTT,结果 148 次请求
  被明确拒绝:"不在时间范围内 不能兑换"。它们全部落在斗鱼估算时间
  -0.026s ~ +0.172s,也就是活动还没开闸就冲进去了。
- 积分基线写盘用 os.Rename 覆盖旧文件,Windows 上会报 Access is denied,
  三机日志里出现 281 次"积分基线写入失败",基线没落盘导致 pc-1 四个实例
  0 条核对记录,且基线为 0 时会误报"积分未见减少"。
- "已达本次活动兑换上限"被记成账号级终止失败。这其实是"该账号在本次活动
  里已经换到过了",10-23 日志里这些账号紧接着就报了"积分已扣减,兑换确认"。

- 限流改为单账号阶梯降频:新增 throttleBackoff,第 1 次等 1~2s、第 2 次
  2~3s、第 3 次 3~4s……上限 5s,区间内随机取值避免同步醒来。throttleStreak
  独立计数,非限流的失败会把它归零——只有"连续被限流"才越等越久。
  每个账号跑在自己的 goroutine 里,降频只压它自己的下一次尝试,
  不占用并发额度、不影响其它账号的节奏。
- 触发时刻改为精确命中:dynamicAdvance 返回 0,不再提前。另加兜底守卫——
  服务端回"不在时间范围内"时该次尝试不计失败、不消耗 attempts/failStreak、
  不触发降频,用 50~150ms 快速重试直到活动真正开始。结果集里的
  "失败|不在时间范围内…"同步改写成"等待活动开始",避免界面闪红。
- 积分基线改异步写盘:flushCh 队列 + 后台协程串行写,抢购收尾不再等写盘;
  新增 Flush 供需要确定落盘的场合。另按 build tag 分平台解决 Windows
  os.Rename 不能覆盖已有文件的问题(replace_file_unix/windows.go)。
- "已达兑换上限"单列:新增 outcomeLimitReached,结果集写"已抢到|"前缀,
  汇总行加"已达兑换上限=N",既不计入失败也不混进"账号终止"。
- 账号不做全局去重:同一账号可以买多个皮肤,不同实例也可以抢不同皮肤。
- 新增测试 throttle_backoff_test.go(12 个用例):降频阶梯递增、单账号级
  隔离、非限流失败重置阶梯、兑换上限不计失败且立即收手、精确命中不提前、
  活动未开始不触发降频且不记失败、Flush 同步落盘。
- 三个原有测试因降频语义变化需要调整时间尺度:新增 speedUpThrottleBackoff
  只压缩阶梯的时间尺度,不改变阶梯结构。
2026-10-24 12:27:21 +08:00

760 lines
27 KiB
Go
Raw Permalink 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 (
"errors"
"fmt"
"math/rand"
"strings"
"time"
"douyu_go/internal/client"
"douyu_go/internal/logger"
"douyu_go/internal/types"
"douyu_go/internal/utils"
)
var errPurchaseStopped = errors.New("已收到停止指令,锁单可能仍有效")
const (
peaceLockTTL = 300 * time.Second
peaceLockExpiryMargin = 15 * time.Second
// peaceLockRetryFallback 用于和平小店的账号冷却;手册链路的退避已经
// 改由外层"每账号独立循环"按配置的重试节奏计算。
peaceLockRetryFallback = 3 * time.Second
// exchangeRateCooldown 是和平小店把频控账号临时降频的时长。
exchangeRateCooldown = 30 * time.Second
)
// maxPayDuration 是单笔支付的总时间预算。抢购当天(2026-10-03)斗鱼的支付处理
// 整体卡到 18:01:07 才恢复,137 单全在那时结算(锁单在 18:00:00,即服务端
// 堵塞约 65 秒),所以预算必须明显大于这个量级——用次数上限会在 ~37 秒就放弃,
// 反而丢掉旧版能确认的成功单。真正放弃前一律如实报"结果未知"。
//
// 既然"锁单优先、支付从容",预算也不必卡太紧:180 秒仍远小于锁单 300 秒有效期,
// 但足够扛住两次 10-03 级别的服务端堵塞。
// 声明为变量是为了让测试能缩短。
var maxPayDuration = 180 * time.Second
// retryLadder 是一类错误的重试退避阶梯:base 起跳、逐次翻倍、limit 封顶。
type retryLadder struct {
base time.Duration
limit time.Duration
}
// 支付退避阶梯。订单已受理但结果未定时必须快重试,只有明确频控才退避稍长。
// 声明为变量是为了让测试能缩短。
// 支付退避阶梯。
//
// 设计前提:**锁单优先,支付从容**。锁单一旦成功就占住了库存(斗鱼锁单有效期 300 秒),
// 支付晚几秒没有任何损失,所以这里刻意不做"快重试"——斗鱼对锁单→支付有最短间隔,
// 打得太密只会收到"您点击太快"、把账号往风控上推。宁可每次多等一两秒。
var (
payRetryInFlight = retryLadder{base: 500 * time.Millisecond, limit: 2 * time.Second}
payRetryRate = retryLadder{base: 1500 * time.Millisecond, limit: 4 * time.Second}
payRetryOther = retryLadder{base: 800 * time.Millisecond, limit: 2500 * time.Millisecond}
)
var (
// 锁单成功后到第一次支付的等待。斗鱼对"锁单→支付"有最短间隔:
// 2026-10-03 19:38 的模拟里,+0.50s/+0.60s/+1.37s/+1.68s 发起的支付一律被
// "您点击太快,请稍等再试"拒绝,而 +2.56s/+3.30s 发起的都成功;19:20 那次
// 在 +2.1s~+3.9s 发起的 6 笔全部一次通过。阈值落在 (1.68s, 2.1s] 之间,
// 这里取 2.3-3.0 秒留出余量:抢在 2 秒前支付只会白挨拒绝、多花时间。
peaceLockPayMin = 2500 * time.Millisecond
peaceLockPayMax = 3500 * time.Millisecond
)
// 斗鱼订单接口错误码。
//
// 实测(2026-10-03 八实例日志):支付接口第一次请求会被斗鱼服务端自己的 3 秒
// 超时打断(1644400002 context deadline exceeded,耗时恒为 3.05s),紧接着
// 重试必然收到 1644430001「订单操作进行中」。这三类都表示订单已受理、结果未定,
// 必须快重试;把它们当频控退避 60 秒,会让每一单白等 68 秒(163/165 单命中)。
const (
apiOrderInProgress = 1644430001 // 订单操作进行中,请稍后再试
apiServerTimeout = 1644400002 // context deadline exceeded(斗鱼服务端超时)
apiServiceUnavail = 1644400014 // 服务当前不可用
apiActivityHot = 1644400007 // 活动火爆 / 当前网络繁忙
apiClickTooFast = 1644400011 // 您点击太快,请稍等再试
apiStockExhausted = 1644402023 // 商品本次活动库存已耗完
)
// orderErrorKind 把订单接口错误归成重试决策需要的几类。
type orderErrorKind int
const (
orderErrTransient orderErrorKind = iota // 网络抖动、数据解析失败等,短退避重试
orderErrInFlight // 订单已受理、服务端仍在处理,快重试
orderErrRateLimited // 明确频控,退避稍长
orderErrThrottled // 请求级限流(火爆/繁忙/点击太快),继续重试
orderErrStockOut // 商品售罄,连续多次即可判定不可抢
orderErrTerminal // 账号级终止条件,不必再试
)
// attemptOutcome 是一次"账号尝试"的结果,供外层每账号独立循环做重试决策。
type attemptOutcome struct {
result types.ExchangeResult
locked bool // 已锁单成功(无论支付是否完成)
stockOut bool // 锁单阶段服务端明确表示商品已售罄
throttled bool // 锁单阶段被请求级限流(火爆/繁忙/点击太快)
terminal bool // 命中账号级终止条件
stopped bool // 收到停止指令
// limitReached 表示服务端回复"您已达本次活动兑换上限"。
//
// 它既不是失败也不是普通的账号终止:账号在本次活动里已经换到过东西了
// (2026-10-23 日志里这类账号紧接着就报了"积分已扣减,兑换确认"),
// 所以必须单独归类,既不能计入失败,也不能当成"没抢到"。
limitReached bool
// notStarted 表示服务端回复"不在时间范围内",即活动尚未开始。
//
// 这不是"没抢到",而是"发早了"。它不该消耗账号的重试节奏,
// 更不该触发限流降频——否则有效尝试次数会白白浪费在活动开始之前。
notStarted bool
}
// runAccountAttempt 按活动执行一次完整尝试。
func (s *Scheduler) runAccountAttempt(attempt types.ExchangeAttempt, mode types.SeckillMode, ctl *runControl) attemptOutcome {
cli := s.clientSnapshot()
switch {
case cli.Activity.IsXiaodian:
// 和平小店正常走 RunXiaodian;这里保留兜底分类,避免万一被路由进来时
// "点券不足/已拥有/绑定失效"这些终止条件失效而一路重试到窗口结束。
result := s.runXiaodianAttempt(attempt)
accountStop, productStop, _ := classifyXiaodianRetryStop(result.Message)
return attemptOutcome{result: result, terminal: accountStop || productStop}
case !cli.Activity.UseCTN:
return s.runPeaceLockPay(attempt, ctl)
default:
return s.runDirectExchange(attempt, mode == types.SeckillModeAggressive, ctl)
}
}
// runPeaceLockPay 执行一次"锁单→支付"。
//
// 锁单失败立即返回,等待与重试交给外层每账号独立循环,因此单个账号的长退避
// 不会阻塞其它账号;锁单成功后的支付阶梯不受 75 秒抢购窗口约束。
func (s *Scheduler) runPeaceLockPay(attempt types.ExchangeAttempt, ctl *runControl) attemptOutcome {
cli := s.clientSnapshot()
out := attemptOutcome{result: types.ExchangeResult{
Account: attempt.Account.Name,
Product: attempt.Product.CommodityName,
Attempt: attempt.Index,
}}
roleName, csrf := s.cachedAuth(attempt)
if csrf == "" {
token, err := cli.GenerateCSRF(attempt.Account.Cookie)
if err != nil {
out.result.Message = "失败|生成鉴权token失败: " + err.Error()
return out
}
csrf = token
}
logger.LogAccountEvent("抢购", "info", attempt.Account.Name,
"精英手册锁单支付: 账号=%s 商品=%s(%s) 第%d次",
attempt.Account.Name, attempt.Product.CommodityName, attempt.Product.CommodityID, attempt.Index)
// 首发请求立刻发出:第 0 个账号不等待,其余按几毫秒步长错开(整批不超过 0.3 秒)。
// 定时器现在精确命中目标时刻(不再提前),这里再抖几百毫秒会把开闸时刻整个错过。
if attempt.Index == 1 && ctl != nil {
slot, step := ctl.firstWaveSlot()
delay := time.Duration(slot) * step
if step > 0 {
delay += time.Duration(rand.Int63n(int64(step)))
}
if !ctl.sleep(delay) {
out.stopped = ctl.stopped()
if out.stopped {
out.result.Message = "失败|已收到停止指令"
} else {
out.result.Message = "失败|抢购窗口已结束"
}
return out
}
}
locked, err := s.lockExchangeOrder(cli, attempt, &csrf, ctl)
if err != nil {
out.result.Message = "失败|" + err.Error()
kind := lockErrorKind(err)
out.stockOut = kind == orderErrStockOut
out.throttled = kind == orderErrThrottled
out.terminal = kind == orderErrTerminal
if isLimitReachedMessage(err.Error()) {
// "已达本次活动兑换上限"不是账号级终止失败,而是"这次活动已经换到过了"。
// 单独归类,既不算失败,也不触发普通终止分支。
out.limitReached = true
out.terminal = false
}
if isNotInTimeRangeMessage(err.Error()) {
// "不在时间范围内"= 活动还没开始,不是没抢到,也不该降频。
out.notStarted = true
out.throttled = false
out.stockOut = false
}
if s.runStopped() || errors.Is(err, errPurchaseStopped) {
out.stopped = true
out.result.Message = "失败|已收到停止指令"
}
return out
}
out.locked = true
s.rememberAuth(attempt.Account.Name, csrf, roleName)
delay := randomSpan(peaceLockPayMin, peaceLockPayMax)
logger.LogAccountEvent("抢购", "info", attempt.Account.Name,
"锁单成功(订单 %s),%s 后支付", locked.OrderID, delay.Round(100*time.Millisecond))
// 支付阶段用"锁单临近过期"作为截止时间,而不是抢购窗口。
if !s.payStartSleep(ctl, locked, delay) {
out.result.RoleName = roleName
if s.runStopped() {
out.stopped = true
out.result.Message = "失败|已收到停止指令,锁单可能仍有效"
} else {
out.result.Message = fmt.Sprintf("失败|锁单 %s 临近过期,未支付", locked.OrderID)
}
return out
}
exchangeInfo, err := s.payLockedOrder(cli, attempt, locked, &csrf, ctl)
if err != nil {
out.result.RoleName = roleName
out.result.Message = "失败|" + err.Error()
// 用户按下停止时按"停止"归类,而不是记成"锁单未支付"。
out.stopped = errors.Is(err, errPurchaseStopped)
return out
}
return s.finishExchange(cli, attempt, out, csrf, roleName, exchangeInfo)
}
// payStartSleep 等待进入支付;返回 false 表示已停止或锁单临近过期。
func (s *Scheduler) payStartSleep(ctl *runControl, locked client.LockedExchangeOrder, d time.Duration) bool {
if ctl == nil {
time.Sleep(d)
return true
}
return ctl.sleepUntil(locked.ExpiresAt.Add(-peaceLockExpiryMargin), d)
}
// lockExchangeOrder 发起一次锁单请求,并计入并发闸门与请求计数。
func (s *Scheduler) lockExchangeOrder(cli *client.DouyuClient, attempt types.ExchangeAttempt, csrf *string, ctl *runControl) (client.LockedExchangeOrder, error) {
if ctl == nil {
return cli.CreateExchangeOrder(attempt.Account.Cookie, attempt.Product.CommodityID, 1, csrf)
}
if !ctl.enter() {
return client.LockedExchangeOrder{}, errPurchaseStopped
}
defer ctl.leave()
ctl.countLockReq()
return cli.CreateExchangeOrder(attempt.Account.Cookie, attempt.Product.CommodityID, 1, csrf)
}
// payLockedOrder 把一笔已锁定的订单支付到成功。
//
// 设计前提:**锁单优先,支付从容**。锁单成功即占住库存(有效期 300 秒),
// 支付晚几秒无损失,所以这里不追求快——斗鱼对"锁单→支付"有最短间隔
// (实测阈值落在 (1.68s, 2.1s]),打太密只会拿到"您点击太快"并把账号往风控推。
// 只有"结果不确定"时才需要频繁确认。
//
// 支付重试针对同一个 orderID,是幂等的(斗鱼用「订单已支付,请勿重复支付」
// 兜底)。
//
// 只受三道闸约束:停止指令、maxPayDuration 时间预算、锁单临近过期(285 秒)。
// 刻意不设次数上限——抢购当天服务端堵塞了约 65 秒才结算,用次数上限会在
// 结算前就放弃,把已经锁到、也确实支付成功的订单报成失败。
func (s *Scheduler) payLockedOrder(cli *client.DouyuClient, attempt types.ExchangeAttempt, locked client.LockedExchangeOrder, csrf *string, ctl *runControl) (string, error) {
payDeadline := locked.ExpiresAt.Add(-peaceLockExpiryMargin)
budget := time.Now().Add(maxPayDuration)
for try := 1; ; try++ {
if s.runStopped() {
return "", errPurchaseStopped
}
if time.Now().After(budget) {
return "", errPayUncertain(locked.OrderID, try-1)
}
info, err := s.payOnce(cli, attempt, locked, csrf, ctl)
if errors.Is(err, errPurchaseStopped) {
return "", err
}
if err == nil {
return info, nil
}
kind := payErrorKind(err)
if kind == orderErrTerminal {
return "", errPayFailed(locked.OrderID, try, err)
}
delay := payRetryDelay(kind, try)
if time.Now().Add(delay).After(payDeadline) {
return "", errPayNearExpiry(locked.OrderID, time.Until(locked.ExpiresAt), err)
}
logger.LogAccountEvent("抢购", "info", attempt.Account.Name,
"锁单 %s 支付第%d次未成功,%s 后重试: %s",
locked.OrderID, try, utils.RoundDuration(delay), err.Error())
if ctl == nil {
time.Sleep(delay)
continue
}
if !ctl.sleepUntil(payDeadline, delay) {
if s.runStopped() {
return "", errPurchaseStopped
}
return "", errPayNearExpiry(locked.OrderID, time.Until(locked.ExpiresAt), err)
}
}
}
// payOnce 发起一次支付请求,并计入并发闸门与请求计数。
func (s *Scheduler) payOnce(cli *client.DouyuClient, attempt types.ExchangeAttempt, locked client.LockedExchangeOrder, csrf *string, ctl *runControl) (string, error) {
if ctl == nil {
return cli.PayExchangeOrder(attempt.Account.Cookie, locked.OrderID, csrf)
}
if !ctl.enter() {
return "", errPurchaseStopped
}
defer ctl.leave()
ctl.countPayReq()
return cli.PayExchangeOrder(attempt.Account.Cookie, locked.OrderID, csrf)
}
// runDirectExchange 电竞手册直接兑换。
func (s *Scheduler) runDirectExchange(attempt types.ExchangeAttempt, reuseCache bool, ctl *runControl) attemptOutcome {
cli := s.clientSnapshot()
out := attemptOutcome{result: types.ExchangeResult{
Account: attempt.Account.Name,
Product: attempt.Product.CommodityName,
Attempt: attempt.Index,
}}
roleName, csrf := s.cachedAuth(attempt)
if reuseCache {
cache := s.getAttemptCache(attempt.Account.Name)
if cache == nil || cache.CSRF == "" || time.Since(cache.LastCheck) >= qualifiedMaxAge {
return s.runDirectExchange(attempt, false, ctl)
}
csrf = cache.CSRF
if cache.RoleName != "" {
roleName = cache.RoleName
}
}
if csrf == "" {
token, err := cli.GenerateCSRF(attempt.Account.Cookie)
if err != nil {
out.result.Message = "失败|生成鉴权token失败: " + err.Error()
return out
}
csrf = token
}
logger.LogAccountEvent("抢购", "info", attempt.Account.Name,
"直接兑换: 账号=%s 商品=%s(%s) 第%d次",
attempt.Account.Name, attempt.Product.CommodityName, attempt.Product.CommodityID, attempt.Index)
exchangeInfo, err := s.exchangeOnce(cli, attempt, &csrf, ctl)
if errors.Is(err, errPurchaseStopped) {
out.stopped = true
out.result.Message = "失败|已收到停止指令"
return out
}
if err != nil {
s.rememberAuth(attempt.Account.Name, csrf, roleName)
errMsg := err.Error()
if reuseCache && shouldFallbackStandard(errMsg) {
logger.LogAccountEvent("抢购", "warn", attempt.Account.Name, "激进模式重新取鉴权后重试: 账号=%s 原因=%s", attempt.Account.Name, utils.TrimForLog(errMsg, 80))
s.setAttemptCache(attempt.Account.Name, nil)
return s.runDirectExchange(attempt, false, ctl)
}
out.result.RoleName = roleName
out.result.Message = "失败|" + errMsg
out.terminal = isTerminalAccountMessage(errMsg)
kind := lockErrorKind(err)
out.stockOut = kind == orderErrStockOut
out.throttled = kind == orderErrThrottled
if isLimitReachedMessage(errMsg) {
// 与锁单链路一致:"已达兑换上限"单独归类,不算失败。
out.limitReached = true
out.terminal = false
}
if isNotInTimeRangeMessage(errMsg) {
// 与锁单链路一致:"不在时间范围内"= 活动还没开始,不降频。
out.notStarted = true
out.throttled = false
out.stockOut = false
}
return out
}
return s.finishExchange(cli, attempt, out, csrf, roleName, exchangeInfo)
}
// exchangeOnce 发起一次直接兑换请求,并计入并发闸门与请求计数。
func (s *Scheduler) exchangeOnce(cli *client.DouyuClient, attempt types.ExchangeAttempt, csrf *string, ctl *runControl) (string, error) {
if ctl == nil {
return cli.ExchangeProduct(attempt.Account.Cookie, attempt.Product, csrf)
}
if !ctl.enter() {
return "", errPurchaseStopped
}
defer ctl.leave()
ctl.countExchangeReq()
return cli.ExchangeProduct(attempt.Account.Cookie, attempt.Product, csrf)
}
func (s *Scheduler) finishExchange(cli *client.DouyuClient, attempt types.ExchangeAttempt, out attemptOutcome, csrf, roleName, exchangeInfo string) attemptOutcome {
if roleName == "" {
name, err := cli.RoleName(attempt.Account.Cookie)
if err != nil {
logger.LogAccountEvent("抢购", "warn", attempt.Account.Name, "角色名查询失败: 账号=%s err=%v", attempt.Account.Name, err)
} else {
roleName = name
}
}
s.rememberAuth(attempt.Account.Name, csrf, roleName)
out.result.OK = true
out.result.RoleName = roleName
if roleName != "" {
out.result.Message = "成功|" + roleName + "|" + exchangeInfo
} else {
out.result.Message = "成功|" + exchangeInfo
}
return out
}
func (s *Scheduler) cachedAuth(attempt types.ExchangeAttempt) (roleName, csrf string) {
if warmup, ok := s.warmupFor(attempt); ok {
roleName = warmup.RoleName
csrf = warmup.CSRF
}
if cache := s.getAttemptCache(attempt.Account.Name); cache != nil && cache.CSRF != "" && time.Since(cache.LastCheck) < qualifiedMaxAge {
if csrf == "" {
csrf = cache.CSRF
}
if roleName == "" {
roleName = cache.RoleName
}
}
return roleName, csrf
}
func (s *Scheduler) rememberAuth(accountName, csrf, roleName string) {
s.setAttemptCache(accountName, &types.AttemptCache{
CSRF: csrf,
RoleName: roleName,
Qualified: true,
LastCheck: time.Now(),
})
}
// runXiaodianAttempt 和平小店专用兑换:跳过手册鉴权、批量限制和预兑换,直接调用 livebuy。
func (s *Scheduler) runXiaodianAttempt(attempt types.ExchangeAttempt) types.ExchangeResult {
cli := s.clientSnapshot()
result := types.ExchangeResult{
Account: attempt.Account.Name,
Product: attempt.Product.CommodityName,
Attempt: attempt.Index,
}
if s.runStopped() {
result.Message = "失败|已收到停止指令"
return result
}
logger.LogAccountEvent("抢购", "info", attempt.Account.Name,
"和平小店直接兑换: 账号=%s 商品=%s 第%d次",
attempt.Account.Name, attempt.Product.CommodityName, attempt.Index)
exchangeInfo, err := cli.ExchangeProduct(attempt.Account.Cookie, attempt.Product, nil)
if err != nil {
result.Message = "失败|" + err.Error()
return result
}
role, err := cli.XiaodianRole(attempt.Account.Cookie)
if err != nil {
logger.LogAccountEvent("抢购", "warn", attempt.Account.Name,
"角色名查询失败: 账号=%s err=%v", attempt.Account.Name, err)
}
roleName := role.RoleName
result.OK = true
result.RoleName = roleName
if roleName != "" {
result.Message = "成功|" + roleName + "|" + exchangeInfo
} else {
result.Message = "成功|" + exchangeInfo
}
return result
}
// payRetryDelay 计算支付重试间隔。
//
// 订单已受理但结果未定时用 300ms 起跳的短阶梯;明确频控才退避稍长。
// 上限只有几秒,避免出现"白等 60 秒"。
func payRetryDelay(kind orderErrorKind, try int) time.Duration {
ladder := payRetryOther
switch kind {
case orderErrInFlight:
ladder = payRetryInFlight
case orderErrRateLimited:
ladder = payRetryRate
}
if try < 1 {
try = 1
}
shift := try - 1
if shift > 4 {
shift = 4
}
delay := ladder.base << uint(shift)
if delay > ladder.limit {
delay = ladder.limit
}
return jitterFraction(delay, 4)
}
// orderErrorInfo 取出订单接口错误码与原始服务端文案。
func orderErrorInfo(err error) (code int, msg string) {
var orderErr *client.OrderError
if errors.As(err, &orderErr) {
if strings.TrimSpace(orderErr.Msg) != "" {
return orderErr.Code, orderErr.Msg
}
return orderErr.Code, orderErr.Error()
}
if err == nil {
return 0, ""
}
return 0, err.Error()
}
func lockErrorKind(err error) orderErrorKind {
if err == nil {
return orderErrTransient
}
code, msg := orderErrorInfo(err)
switch code {
case apiStockExhausted:
return orderErrStockOut
case apiActivityHot, apiClickTooFast:
return orderErrThrottled
}
if isTerminalAccountMessage(msg) {
return orderErrTerminal
}
// 没有错误码时(传输层错误、直接兑换接口)按文案兜底判断。
if stockOutHint(msg) {
return orderErrStockOut
}
if throttleHint(msg) {
return orderErrThrottled
}
if exchangeRateLimited(msg) {
return orderErrRateLimited
}
return orderErrTransient
}
// stockOutHint 判断服务端是否明确表示该商品已售罄。
// 只有这一类才允许判定"商品当前不可抢"。
func stockOutHint(message string) bool {
for _, hint := range []string{
"库存已耗完", "库存不足", "库存耗尽", "已售完", "已抢光", "已售罄", "已兑完", "已兑光",
} {
if strings.Contains(message, hint) {
return true
}
}
return false
}
// isLimitReachedMessage 判断服务端是否回复"已达本次活动兑换上限"。
//
// 这条回复代表该账号在本次活动里已经兑换成功过,不是失败:2026-10-23 的日志里
// 这类账号随后就报出了"积分已扣减,兑换确认"。因此它必须走独立结局,
// 既不计入失败数,也不进入"账号终止"分支。
func isLimitReachedMessage(message string) bool {
return strings.Contains(message, "已达本次活动兑换上限") ||
strings.Contains(message, "已达兑换上限")
}
// isNotInTimeRangeMessage 判断服务端是否回复"不在时间范围内"。
//
// 这是"活动还没开始"而不是"没抢到":2026-10-23 三机日志里 148 次该回复,
// 全部集中在斗鱼估算时间 -0.026s ~ +0.172s,说明请求确实发早了。
// 这类失败不该消耗账号的重试节奏(更不该触发降频),否则会把账号的
// 有效尝试次数白白耗在活动开始之前。
func isNotInTimeRangeMessage(message string) bool {
return strings.Contains(message, "不在时间范围内") ||
strings.Contains(message, "活动未开始") ||
strings.Contains(message, "活动尚未开始")
}
// throttleHint 判断服务端是否在请求级限流。
//
// 依据 2026-10-03 日志:同一个实例、同一秒内,3 个账号对同一个 commodityID 锁单成功,
// 另外 31 个账号拿到 1644400007「活动火爆」—— 说明这是请求级/账号级限流,
// 不是商品没了,必须继续重试到窗口结束。
func throttleHint(message string) bool {
for _, hint := range []string{
"火爆", "繁忙", "太热", "点击太快", "人多", "排队", "手慢", "稍后再试",
} {
if strings.Contains(message, hint) {
return true
}
}
return false
}
func payErrorKind(err error) orderErrorKind {
if err == nil {
return orderErrTransient
}
code, msg := orderErrorInfo(err)
switch code {
case apiOrderInProgress, apiServerTimeout, apiServiceUnavail:
return orderErrInFlight
}
if payTerminalMessage(msg) {
return orderErrTerminal
}
if exchangeRateLimited(msg) {
return orderErrRateLimited
}
return orderErrTransient
}
// isTerminalAccountMessage 判断账号级终止条件(继续重试没有意义)。
func isTerminalAccountMessage(message string) bool {
account, product, _ := classifyRetryStop(message)
return account || product
}
// payTerminalMessage 判断订单级终止条件(订单已作废,重试不会成功)。
func payTerminalMessage(message string) bool {
for _, hint := range []string{"订单不存在", "订单已过期", "订单过期", "已取消", "超过有效期", "无效订单", "订单已失效"} {
if strings.Contains(message, hint) {
return true
}
}
return false
}
func exchangeRateLimited(message string) bool {
for _, hint := range []string{
"火爆", "频繁", "稍后", "太热", "限流", "繁忙", "人多", "排队", "手慢",
"用户行为异常", "点击太快", "请求过多", "请求太频繁", "频控",
} {
if strings.Contains(message, hint) {
return true
}
}
return false
}
func randomSpan(min, max time.Duration) time.Duration {
if max <= min {
if min < 0 {
return 0
}
return min
}
return min + time.Duration(rand.Int63n(int64(max-min)+1))
}
// jitterFraction 返回 base 加上最多 base/parts 的随机抖动,用于打散同步重试。
func jitterFraction(base time.Duration, parts int64) time.Duration {
if base <= 0 || parts <= 0 {
return base
}
span := int64(base) / parts
if span <= 0 {
return base
}
return base + time.Duration(rand.Int63n(span+1))
}
func errPayFailed(orderID string, tries int, err error) error {
if err == nil {
return fmt.Errorf("锁单 %s 支付失败(已重试%d次)", orderID, tries)
}
return fmt.Errorf("锁单 %s 支付失败(已重试%d次): %w", orderID, tries, err)
}
func errPayNearExpiry(orderID string, remaining time.Duration, err error) error {
return fmt.Errorf("锁单 %s 支付失败且临近过期(%ds): %w", orderID, int(remaining.Seconds()), err)
}
// errPayUncertain 表示支付结果未知:服务端长时间回"处理中",
// 订单很可能已经支付成功(2026-09-30 日志里就出现过实际已支付却报失败的情况)。
func errPayUncertain(orderID string, tries int) error {
return fmt.Errorf("锁单 %s 支付结果未知(已重试%d次,订单可能已支付,请在兑换记录中核实)", orderID, tries)
}
func shouldFallbackStandard(errMsg string) bool {
lower := strings.ToLower(errMsg)
switch {
case strings.Contains(lower, "csrf"):
return true
case strings.Contains(lower, "cvl_csrf_token"):
return true
case strings.Contains(lower, "preexchange"):
return true
case strings.Contains(lower, "limit"):
return true
case strings.Contains(errMsg, "预兑换"):
return true
case strings.Contains(errMsg, "限制检查"):
return true
default:
return false
}
}
// ========== 激进模式缓存管理 ==========
func (s *Scheduler) getAttemptCache(accountName string) *types.AttemptCache {
s.attemptCacheMu.RLock()
defer s.attemptCacheMu.RUnlock()
return s.attemptCache[accountName]
}
// initCacheFromWarmup 从预热数据初始化激进模式缓存。
// 入参是按账号唯一的任务列表,因此每个账号只会写入一条日志。
func (s *Scheduler) initCacheFromWarmup(attempts []types.ExchangeAttempt) {
seen := make(map[string]struct{}, len(attempts))
for _, attempt := range attempts {
if _, ok := seen[attempt.Account.Name]; ok {
continue
}
warmup, ok := s.warmupFor(attempt)
if !ok {
continue
}
seen[attempt.Account.Name] = struct{}{}
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
CSRF: warmup.CSRF,
RoleName: warmup.RoleName,
Qualified: warmup.Qualified,
LastCheck: time.Now(),
})
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "从预热初始化缓存: 账号=%s", attempt.Account.Name)
}
}
func (s *Scheduler) setAttemptCache(accountName string, cache *types.AttemptCache) {
s.attemptCacheMu.Lock()
defer s.attemptCacheMu.Unlock()
if cache == nil {
delete(s.attemptCache, accountName)
return
}
s.attemptCache[accountName] = cache
}
func (s *Scheduler) clearAttemptCache() {
s.attemptCacheMu.Lock()
defer s.attemptCacheMu.Unlock()
s.attemptCache = make(map[string]*types.AttemptCache)
}