- 退避上限从 5 秒压到 1.5 秒(含抖动最长约 1.9 秒),连续失败放大倍数 8→4,抖动 50%→25%: 2026-10-03 实测库存只活了约 1.1 秒(PC2 四个实例 18:00:00.171~18:00:01.101 一直能锁到 同一个礼包),而 PC1 默认实例的 31 个号在 18:00:00.225 失败后一直睡到 18:01:09.379, 唯一有货的那段窗口被退避吃掉;超过 2 秒的等待都可能正好错过库存回流 - 首发请求不再随机等 150-700 毫秒,改为"第 0 个账号立即发出 + 其余按 8ms 步长错开", 整批铺开不超过 300 毫秒(账号再多也不会拖长)。定时器本来就提前约 100ms 触发以补偿 RTT, 再抖几百毫秒等于把这个提前量整个抵消,请求实际落到 t+150ms~t+650ms - 锁单成功到首次支付的等待从 2-4 秒压到 300-700 毫秒:这段纯属把关键路径上的时间白送出去, "订单操作进行中"现在按"已受理、结果未定"快重试,不需要靠等待规避 - 支付退避上限同步收紧:频控 3s→2s,其它 2s→1.2s(在途仍为 300ms→1s) - 新增 TestRetryDelayNeverBacksOffLong:遍历 75 秒 × 40 次连续失败,断言任何一次退避都 小于 2 秒,并覆盖"重试间隔策略被配到 5000ms"的极端配置 - 新增 TestFirstWaveStartsImmediately:单账号步长必须为 0、位次从 0 递增、 2/34/100/500/2000 个账号下步长 ≤8ms 且整批铺开 ≤300ms - 设置页文案同步:退避"任何一次等待都不超过约 2 秒"、首发错开说明
689 lines
23 KiB
Go
689 lines
23 KiB
Go
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 秒就放弃,
|
||
// 反而丢掉旧版能确认的成功单。真正放弃前一律如实报"结果未知"。
|
||
// 声明为变量是为了让测试能缩短。
|
||
var maxPayDuration = 120 * time.Second
|
||
|
||
// retryLadder 是一类错误的重试退避阶梯:base 起跳、逐次翻倍、limit 封顶。
|
||
type retryLadder struct {
|
||
base time.Duration
|
||
limit time.Duration
|
||
}
|
||
|
||
// 支付退避阶梯。订单已受理但结果未定时必须快重试,只有明确频控才退避稍长。
|
||
// 声明为变量是为了让测试能缩短。
|
||
var (
|
||
payRetryInFlight = retryLadder{base: 300 * time.Millisecond, limit: time.Second}
|
||
payRetryRate = retryLadder{base: 800 * time.Millisecond, limit: 2 * time.Second}
|
||
payRetryOther = retryLadder{base: 400 * time.Millisecond, limit: 1200 * time.Millisecond}
|
||
)
|
||
|
||
var (
|
||
// 锁单成功后到第一次支付的等待。原来是 2-4 秒,纯属把关键路径上的时间
|
||
// 白送出去——"订单操作进行中"现在按"已受理、结果未定"快重试处理,
|
||
// 不需要靠等待来规避。
|
||
peaceLockPayMin = 300 * time.Millisecond
|
||
peaceLockPayMax = 700 * 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 // 收到停止指令
|
||
}
|
||
|
||
// 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 秒)。
|
||
// 定时器已提前约 100ms 触发以补偿 RTT,这里再抖几百毫秒会把这套提前量抵消掉。
|
||
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 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 把一笔已锁定的订单支付到成功。
|
||
//
|
||
// 支付重试针对同一个 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
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
}
|