Files
dygou_go/internal/seckill/attempt.go
T
yml2213 43270e04a6 收紧重试退避并把首发请求改成立即发出,发布0.3.1
- 退避上限从 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 秒"、首发错开说明
2026-10-03 19:21:26 +08:00

689 lines
23 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 (
"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)
}