diff --git a/frontend/src/components/modal/SettingsModal.vue b/frontend/src/components/modal/SettingsModal.vue
index 4910770..84429c8 100644
--- a/frontend/src/components/modal/SettingsModal.vue
+++ b/frontend/src/components/modal/SettingsModal.vue
@@ -152,7 +152,7 @@ defineExpose({ open, close })
同时发起的请求数量,建议 50-200
@@ -229,15 +231,20 @@ defineExpose({ open, close })
+ 添加阶段
- 按经过秒数从小到大排列,每个阶段设置不同的重试频率
+
+ 按经过秒数从小到大排列,控制精英/电竞手册的锁单重试节奏;
+ 同一账号连续失败会在此基础上成倍放大(最长 5 秒)
+
💡 抢购策略说明
- - 抢购窗口:75 秒,自动循环重试
+ - 抢购窗口:75 秒;窗口内每个账号各自独立重试,互不阻塞
- 默认:阶段 2 为 200ms,阶段 3 为 500ms,其余阶段 1000ms
- - 尝试包数 > 1 时,每轮使用不同的包
+ - 已锁单的订单即使窗口结束也会把支付重试完(最长 120 秒)
+ - "活动火爆/网络繁忙"是请求级限流(同一秒别的账号仍能锁单),会继续重试
+ - "库存已耗完"也不提前停止:别人锁单未支付时库存会回流,会降频探测到窗口结束
diff --git a/frontend/src/composables/useLogs.ts b/frontend/src/composables/useLogs.ts
index e444274..0eb4211 100644
--- a/frontend/src/composables/useLogs.ts
+++ b/frontend/src/composables/useLogs.ts
@@ -31,7 +31,10 @@ const resultAccountName = (key: string): string => {
}
const resultSucceeded = (message: string): boolean => {
- return /成功|兑换上限|已抢到/.test(String(message || ''))
+ const value = String(message || '')
+ // 只认后端约定的成功前缀("成功|角色名|...")。不能用 /成功/ 全文匹配:
+ // 失败文案"锁定兑换商品成功,但响应中没有 orderId"也含"成功",会被误判成抢到。
+ return value.startsWith('成功|') || /兑换上限|已抢到/.test(value)
}
const formatServerLogTime = (time: string): string => {
diff --git a/internal/app/app.go b/internal/app/app.go
index 0a57032..f02aee5 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -35,6 +35,7 @@ type App struct {
paths types.AppPaths
font *truetype.Font
instanceLease *config.InstanceLease
+ instanceErr error
instanceName string
cfgMu sync.RWMutex
@@ -115,17 +116,27 @@ func cloneConfig(c types.Config) types.Config {
return cp
}
-// New 创建 App 实例。
-func New() *App {
- return &App{}
+// New 创建 App 实例。lease 由 main 在 wails.Run 之前占用并注入。
+//
+// 为什么要在 wails.Run 之前占位:WebView2 的用户数据目录必须在创建窗口之前确定,
+// 而它必须按实例区分(见 config.InstanceLease.WebviewUserDataDir)。
+// 用构造器参数而不是导出的 setter,是为了避免它被 Wails 当成前端可调用方法生成绑定。
+func New(lease *config.InstanceLease, instanceErr error) *App {
+ return &App{instanceLease: lease, instanceErr: instanceErr}
}
// Startup Wails 应用启动时调用。
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
- lease, instanceErr := config.AcquireInstancePaths()
- if instanceErr != nil {
+ lease, instanceErr := a.instanceLease, a.instanceErr
+ if lease == nil {
+ // 其它入口(测试、非 Wails 启动)没有提前占位时在这里补一次。
+ lease, instanceErr = config.AcquireInstancePaths()
+ }
+ if lease == nil {
+ // 兜底:占位实现已保证返回可用目录,这里只是防御性分支。
+ logger.LogEvent("系统", "warn", "实例占位不可用,已回退默认目录(多开可能互相覆盖)")
a.paths = config.DefaultAppPaths()
a.instanceName = "默认实例"
} else {
@@ -137,7 +148,8 @@ func (a *App) Startup(ctx context.Context) {
a.logStore = logger.NewMemoryLogStore(800)
a.closeLog = logger.InitLogging(a.logStore, a.paths.LogDir)
if instanceErr != nil {
- logger.LogEvent("系统", "warn", "实例隔离初始化失败,已回退默认目录: %v", instanceErr)
+ // 数据目录仍然是本实例独享的(临时实例),但隔离能力已降级,必须让人看见。
+ logger.LogEvent("系统", "warn", "实例隔离已降级(%s,数据目录仍独立): %v", a.instanceName, instanceErr)
} else {
logger.LogEvent("系统", "success", "已启动%s,数据目录: %s", a.instanceName, a.paths.DataDir)
}
diff --git a/internal/client/client.go b/internal/client/client.go
index 2ac0fbc..bd5b652 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -1222,6 +1222,25 @@ func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) erro
return nil
}
+// OrderError 是锁单/支付接口返回的业务错误,保留斗鱼错误码。
+//
+// 调用方需要按"错误码"而不是"文案"决定重试策略:支付链路上
+// 1644430001(订单操作进行中)、1644400002(斗鱼服务端 3 秒超时)、
+// 1644400014(服务当前不可用)都表示订单已受理、结果未定,应当快重试,
+// 而不是当成频控长退避。Error() 文案与历史版本保持一致,避免影响日志与前端展示。
+type OrderError struct {
+ Kind string // 接口标签,如 "锁定兑换商品失败"
+ Code int // 斗鱼错误码
+ Msg string // 斗鱼错误文案
+}
+
+func (e *OrderError) Error() string {
+ if strings.TrimSpace(e.Msg) == "" {
+ return e.Kind
+ }
+ return e.Kind + ": " + e.Msg
+}
+
// LockedExchangeOrder 是精英手册锁单结果。
type LockedExchangeOrder struct {
OrderID string
@@ -1246,10 +1265,7 @@ func (c *DouyuClient) CreateExchangeOrder(cookie, commodityID string, num int, c
return LockedExchangeOrder{}, err
}
if result.Error != 0 {
- if strings.TrimSpace(result.Msg) == "" {
- return LockedExchangeOrder{}, errors.New("锁定兑换商品失败")
- }
- return LockedExchangeOrder{}, fmt.Errorf("锁定兑换商品失败: %s", result.Msg)
+ return LockedExchangeOrder{}, &OrderError{Kind: "锁定兑换商品失败", Code: result.Error, Msg: result.Msg}
}
var data struct {
OrderID string `json:"orderId"`
@@ -1290,10 +1306,7 @@ func (c *DouyuClient) PayExchangeOrder(cookie, orderID string, csrf *string) (st
if alreadyPaid(result.Msg) {
return "orderId=" + orderID + ",alreadyPaid", nil
}
- if strings.TrimSpace(result.Msg) == "" {
- return "", errors.New("支付锁单失败")
- }
- return "", fmt.Errorf("支付锁单失败: %s", result.Msg)
+ return "", &OrderError{Kind: "支付锁单失败", Code: result.Error, Msg: result.Msg}
}
var data struct {
ExchangeID json.RawMessage `json:"exchangeId"`
diff --git a/internal/config/config.go b/internal/config/config.go
index 6eab75e..7507caf 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -177,7 +177,8 @@ func defaultConfig() types.Config {
}
}
-// defaultRetryStages 返回默认重试间隔阶段配置。
+// defaultRetryStages 返回默认的锁单重试节奏:按经过秒数分档,越往后越慢。
+// 手册链路的每个账号都按这份配置独立重试。
func defaultRetryStages() []types.RetryStage {
return []types.RetryStage{
{MaxElapsed: 4, Interval: 120},
diff --git a/internal/config/instance.go b/internal/config/instance.go
index f40e7cd..70643bb 100644
--- a/internal/config/instance.go
+++ b/internal/config/instance.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sync"
+ "time"
"douyu_go/internal/constants"
"douyu_go/internal/types"
@@ -12,6 +13,14 @@ import (
const maxInstanceSlots = 256
+// 单次实例分配里允许的"意外错误"重试预算与间隔。
+// 杀毒软件/索引器可能在锁文件刚创建时短暂独占它,稍后重试同一个槽位即可,
+// 不该直接跳到别的槽位或让整个分配失败。
+const (
+ slotLockRetryBudget = 12
+ slotLockRetryWait = 80 * time.Millisecond
+)
+
// InstanceLease 代表当前进程持有的实例槽位;必须在程序退出时释放。
type InstanceLease struct {
paths types.AppPaths
@@ -22,18 +31,35 @@ type InstanceLease struct {
// AcquireInstancePaths 按顺序占用空闲实例。第一个进程使用默认实例,
// 后续进程分别使用实例 2、实例 3,以隔离账号、配置与运行数据。
+//
+// 返回的 lease 一定可用:即使所有槽位都拿不到(锁目录不可写、槽位全部异常等),
+// 也会退回到一个"进程唯一"的临时实例目录,并把原因作为 error 返回供上层记录。
+// 绝不退回默认实例目录——那样两个进程会共享 accounts.txt、config.json,
+// 同秒启动时还会写进同一个日志文件,导致账号互相覆盖与日志交错。
func AcquireInstancePaths() (*InstanceLease, error) {
baseDir := defaultDataDir()
instanceRoot := filepath.Join(baseDir, "instances")
lockDir := filepath.Join(instanceRoot, ".locks")
if err := osMkdirAll(lockDir); err != nil {
- return nil, fmt.Errorf("创建实例锁目录失败: %w", err)
+ return fallbackLease(baseDir, fmt.Errorf("创建实例锁目录失败: %w", err))
}
+ var scanErr error
+ scanRetries := slotLockRetryBudget
for slot := 1; slot <= maxInstanceSlots; slot++ {
- lock, acquired, err := tryAcquireInstanceLock(filepath.Join(lockDir, instanceLockFileName(slot)))
+ lockPath := filepath.Join(lockDir, instanceLockFileName(slot))
+ lock, acquired, err := tryAcquireInstanceLock(lockPath)
+ for err != nil && scanRetries > 0 {
+ scanRetries--
+ time.Sleep(slotLockRetryWait)
+ lock, acquired, err = tryAcquireInstanceLock(lockPath)
+ }
if err != nil {
- return nil, fmt.Errorf("锁定%s失败: %w", instanceName(slot), err)
+ // 单个槽位异常不该让整个分配失败:记录原因后继续试下一个槽位。
+ if scanErr == nil {
+ scanErr = fmt.Errorf("锁定%s失败: %w", instanceName(slot), err)
+ }
+ continue
}
if !acquired {
continue
@@ -53,7 +79,17 @@ func AcquireInstancePaths() (*InstanceLease, error) {
lock: lock,
}, nil
}
- return nil, fmt.Errorf("最多支持同时运行 %d 个实例", maxInstanceSlots)
+
+ return fallbackLease(baseDir, fmt.Errorf("最多支持同时运行 %d 个实例(%v)", maxInstanceSlots, scanErr))
+}
+
+// fallbackLease 构造兜底实例:使用进程唯一的临时目录,保证与其它实例互不干扰。
+func fallbackLease(baseDir string, cause error) (*InstanceLease, error) {
+ dataDir := filepath.Join(baseDir, "instances", fmt.Sprintf("instance-tmp-%d", os.Getpid()))
+ return &InstanceLease{
+ paths: appPathsForDataDir(dataDir, false),
+ name: "临时实例",
+ }, cause
}
// Paths 返回当前实例独立的数据路径。
@@ -72,6 +108,19 @@ func (l *InstanceLease) Name() string {
return l.name
}
+// WebviewUserDataDir 返回当前实例独占的 WebView2 用户数据目录。
+//
+// Wails 在 WebviewUserDataPath 为空时会把用户数据目录放在 %APPDATA%\<可执行文件名>,
+// 所有实例共用同一个目录;而 WebView2 的一个用户数据目录只能有一个浏览器进程,
+// 第二个实例创建环境时会偶发失败 —— 表现就是"有时候开不出新实例,必须全部关掉重开"。
+// 这里给每个实例一个独立目录,从根上消除这个竞争。
+func (l *InstanceLease) WebviewUserDataDir() string {
+ if l == nil || l.paths.DataDir == "" {
+ return ""
+ }
+ return filepath.Join(l.paths.DataDir, "webview2")
+}
+
// Release 释放实例锁,允许新启动的程序复用该实例。
func (l *InstanceLease) Release() {
if l == nil || l.lock == nil {
diff --git a/internal/config/instance_test.go b/internal/config/instance_test.go
index 5d0af6d..5563f39 100644
--- a/internal/config/instance_test.go
+++ b/internal/config/instance_test.go
@@ -1,7 +1,9 @@
package config
import (
+ "os"
"path/filepath"
+ "strings"
"testing"
)
@@ -47,3 +49,59 @@ func TestAcquireInstancePathsSeparatesConcurrentInstances(t *testing.T) {
t.Fatalf("reused instance = %q, %q", reused.Name(), reused.Paths().DataDir)
}
}
+
+// TestAcquireInstancePathsNeverSharesDefaultDir 保证实例占位失败时也不会退回默认目录。
+// 退回默认目录会让两个进程共享 accounts.txt/config.json,同秒启动还会写进同一个日志文件。
+func TestAcquireInstancePathsNeverSharesDefaultDir(t *testing.T) {
+ baseDir := t.TempDir()
+ // 用一个普通文件占住 ,让 /instances/.locks 无法创建。
+ blocked := filepath.Join(baseDir, "blocked")
+ if err := os.WriteFile(blocked, []byte("x"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("DOUYU_DATA_DIR", blocked)
+
+ lease, err := AcquireInstancePaths()
+ if lease == nil {
+ t.Fatal("实例占位必须始终返回可用目录")
+ }
+ defer lease.Release()
+ if err == nil {
+ t.Fatal("降级路径应当把原因返回给上层记录")
+ }
+ if lease.Name() != "临时实例" {
+ t.Fatalf("降级实例名 = %q, want 临时实例", lease.Name())
+ }
+ if lease.Paths().DataDir == blocked || !strings.Contains(lease.Paths().DataDir, "instance-tmp-") {
+ t.Fatalf("降级目录必须与本进程绑定且独立: %q", lease.Paths().DataDir)
+ }
+}
+
+// TestInstanceWebviewDirsAreIsolated WebView2 一个用户数据目录只能有一个浏览器进程,
+// 多个实例共用会让第二个实例偶发创建环境失败(必须全部关掉才能重开)。
+func TestInstanceWebviewDirsAreIsolated(t *testing.T) {
+ baseDir := t.TempDir()
+ t.Setenv("DOUYU_DATA_DIR", baseDir)
+
+ first, err := AcquireInstancePaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer first.Release()
+ second, err := AcquireInstancePaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer second.Release()
+
+ firstDir, secondDir := first.WebviewUserDataDir(), second.WebviewUserDataDir()
+ if firstDir == "" || secondDir == "" {
+ t.Fatalf("WebView2 目录不能为空: %q %q", firstDir, secondDir)
+ }
+ if firstDir == secondDir {
+ t.Fatalf("两个实例的 WebView2 目录不能相同: %q", firstDir)
+ }
+ if !strings.HasSuffix(firstDir, "webview2") {
+ t.Fatalf("WebView2 目录应在实例数据目录下: %q", firstDir)
+ }
+}
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index 545eba7..7b6d399 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -3,7 +3,7 @@ package constants
import "time"
// Version 应用版本号,与 wails.json version 同步。
-const Version = "0.2.2"
+const Version = "0.3.0"
// BuildTime 构建时间,由编译时 ldflags 注入。
var BuildTime = "unknown"
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
index c81ef93..b46cb69 100644
--- a/internal/logger/logger.go
+++ b/internal/logger/logger.go
@@ -1,6 +1,7 @@
package logger
import (
+ "errors"
"fmt"
"io"
"log"
@@ -157,8 +158,8 @@ func InitLogging(store *MemoryLogStore, logDir string) func() {
return func() {}
}
- filePath := filepath.Join(logDir, "douyu_"+time.Now().In(constants.CST).Format("20060102_150405")+".log")
- file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ base := filepath.Join(logDir, "douyu_"+time.Now().In(constants.CST).Format("20060102_150405"))
+ file, filePath, err := createUniqueLogFile(base)
if err != nil {
globalSink = &sink{store: store, stdout: true}
LogEvent("日志", "warn", "日志文件创建失败,仅写入内存: %v", err)
@@ -180,6 +181,28 @@ func InitLogging(store *MemoryLogStore, logDir string) func() {
}
}
+// createUniqueLogFile 以 O_EXCL 递增后缀创建日志文件。
+//
+// 早期实现用 O_CREATE|O_APPEND + 秒级时间戳:同一目录下同秒启动的两个进程会
+// 打开同一个文件并交错写入,日志既分不清实例,也会互相污染。现在改用独占创建,
+// 冲突时自动追加 -2 / -3 后缀。
+func createUniqueLogFile(base string) (*os.File, string, error) {
+ for i := 1; i <= 50; i++ {
+ path := base + ".log"
+ if i > 1 {
+ path = fmt.Sprintf("%s-%d.log", base, i)
+ }
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
+ if err == nil {
+ return file, path, nil
+ }
+ if !errors.Is(err, os.ErrExist) {
+ return nil, "", err
+ }
+ }
+ return nil, "", fmt.Errorf("日志文件名连续冲突: %s", base)
+}
+
// LogEvent 输出带分类和级别的结构化文本日志。低于当前最低等级的日志会被丢弃。
func LogEvent(category, level, format string, args ...any) {
logStructuredEvent(category, level, "", format, args...)
diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go
index b71949d..6ec3c95 100644
--- a/internal/logger/logger_test.go
+++ b/internal/logger/logger_test.go
@@ -1,6 +1,7 @@
package logger
import (
+ "path/filepath"
"strings"
"testing"
)
@@ -72,3 +73,28 @@ func TestStructuredLogging(t *testing.T) {
}
SetMinLevel("info") // 恢复,避免影响后续测试
}
+
+// TestCreateUniqueLogFileAvoidsCollision 同一目录同秒启动的两个进程不能写同一个文件,
+// 否则日志会交错、分不清实例。
+func TestCreateUniqueLogFileAvoidsCollision(t *testing.T) {
+ dir := t.TempDir()
+ base := filepath.Join(dir, "douyu_20261003_160856")
+
+ first, firstPath, err := createUniqueLogFile(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = first.Close()
+ if got := filepath.Base(firstPath); got != "douyu_20261003_160856.log" {
+ t.Fatalf("first = %q", got)
+ }
+
+ second, secondPath, err := createUniqueLogFile(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = second.Close()
+ if got := filepath.Base(secondPath); got != "douyu_20261003_160856-2.log" {
+ t.Fatalf("second = %q, want 同目录 -2 后缀", got)
+ }
+}
diff --git a/internal/seckill/attempt.go b/internal/seckill/attempt.go
index b35467a..a604a37 100644
--- a/internal/seckill/attempt.go
+++ b/internal/seckill/attempt.go
@@ -16,10 +16,36 @@ import (
var errPurchaseStopped = errors.New("已收到停止指令,锁单可能仍有效")
const (
- peaceLockTTL = 300 * time.Second
- peaceLockExpiryMargin = 15 * time.Second
+ peaceLockTTL = 300 * time.Second
+ peaceLockExpiryMargin = 15 * time.Second
+
+ // peaceLockRetryFallback 用于和平小店的账号冷却;手册链路的退避已经
+ // 改由外层"每账号独立循环"按配置的重试节奏计算。
peaceLockRetryFallback = 3 * time.Second
- exchangeRateCooldown = 30 * 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: time.Second, limit: 3 * time.Second}
+ payRetryOther = retryLadder{base: 500 * time.Millisecond, limit: 2 * time.Second}
)
var (
@@ -29,137 +55,241 @@ var (
peaceLockPayMax = 4 * time.Second
)
-var peaceRateLimitBackoffs = []time.Duration{30 * time.Second, 60 * time.Second, 90 * time.Second}
-var peaceGenericBackoffs = []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 20 * time.Second}
+// 斗鱼订单接口错误码。
+//
+// 实测(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 // 商品本次活动库存已耗完
+)
-// runAttempt 按活动兑换。精英手册锁单后支付;电竞手册直接兑换。两者都不做商品预检。
-func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResult {
- cli := s.clientSnapshot()
- if cli.Activity.IsXiaodian {
- return s.runXiaodianAttempt(attempt)
- }
- if !cli.Activity.UseCTN {
- return s.runPeaceLockPay(attempt)
- }
- return s.runDirectExchange(attempt, false)
+// 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 // 收到停止指令
}
-// runAttemptFast 激进模式。精英手册与标准模式相同;电竞手册复用鉴权后直接兑换。
-func (s *Scheduler) runAttemptFast(attempt types.ExchangeAttempt) types.ExchangeResult {
+// runAccountAttempt 按活动执行一次完整尝试。
+func (s *Scheduler) runAccountAttempt(attempt types.ExchangeAttempt, mode types.SeckillMode, ctl *runControl) attemptOutcome {
cli := s.clientSnapshot()
- if cli.Activity.IsXiaodian {
- return s.runXiaodianAttempt(attempt)
+ 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)
}
- if !cli.Activity.UseCTN {
- return s.runPeaceLockPay(attempt)
- }
- return s.runDirectExchange(attempt, true)
}
-func (s *Scheduler) runPeaceLockPay(attempt types.ExchangeAttempt) types.ExchangeResult {
+// runPeaceLockPay 执行一次"锁单→支付"。
+//
+// 锁单失败立即返回,等待与重试交给外层每账号独立循环,因此单个账号的长退避
+// 不会阻塞其它账号;锁单成功后的支付阶梯不受 75 秒抢购窗口约束。
+func (s *Scheduler) runPeaceLockPay(attempt types.ExchangeAttempt, ctl *runControl) attemptOutcome {
cli := s.clientSnapshot()
- result := types.ExchangeResult{
+ out := attemptOutcome{result: types.ExchangeResult{
Account: attempt.Account.Name,
Product: attempt.Product.CommodityName,
Attempt: attempt.Index,
- }
+ }}
+
roleName, csrf := s.cachedAuth(attempt)
if csrf == "" {
- var err error
- csrf, err = cli.GenerateCSRF(attempt.Account.Cookie)
+ token, err := cli.GenerateCSRF(attempt.Account.Cookie)
if err != nil {
- result.Message = "失败|生成鉴权token失败: " + err.Error()
- return result
+ 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)
+
+ // 首发请求按 300-1200ms 抖动打散;后续重试的间隔由外层节奏计算。
+ if attempt.Index == 1 && ctl != nil {
+ if !ctl.sleep(randomSpan(peacePreLockMin, peacePreLockMax)) {
+ out.stopped = ctl.stopped()
+ if out.stopped {
+ out.result.Message = "失败|已收到停止指令"
+ } else {
+ out.result.Message = "失败|抢购窗口已结束"
+ }
+ return out
}
}
- logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "精英手册锁单支付: 账号=%s 商品=%s 第%d包", attempt.Account.Name, attempt.Product.CommodityName, attempt.Index)
- if s.interruptibleSleep(randomSpan(peacePreLockMin, peacePreLockMax)) {
- result.Message = "失败|已收到停止指令"
- return result
- }
-
- locked, err := cli.CreateExchangeOrder(attempt.Account.Cookie, attempt.Product.CommodityID, 1, &csrf)
+ locked, err := s.lockExchangeOrder(cli, attempt, &csrf, ctl)
if err != nil {
- delay := peaceLockRetryDelay(err.Error())
- logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "锁定兑换商品失败(%s),%s 后重试一次", err.Error(), delay)
- if s.interruptibleSleep(delay) {
- result.Message = "失败|已收到停止指令"
- return result
- }
- locked, err = cli.CreateExchangeOrder(attempt.Account.Cookie, attempt.Product.CommodityID, 1, &csrf)
- if err != nil {
- result.RoleName = roleName
- result.Message = "失败|锁定兑换商品失败: " + err.Error()
- return result
+ 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.interruptibleSleep(delay) {
- result.RoleName = roleName
- result.Message = "失败|已收到停止指令,锁单可能仍有效"
- return result
+ 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)
+ exchangeInfo, err := s.payLockedOrder(cli, attempt, locked, &csrf, ctl)
if err != nil {
- result.RoleName = roleName
- result.Message = "失败|" + err.Error()
- return result
+ out.result.RoleName = roleName
+ out.result.Message = "失败|" + err.Error()
+ // 用户按下停止时按"停止"归类,而不是记成"锁单未支付"。
+ out.stopped = errors.Is(err, errPurchaseStopped)
+ return out
}
- return s.finishExchange(cli, attempt, result, csrf, roleName, exchangeInfo)
+ return s.finishExchange(cli, attempt, out, csrf, roleName, exchangeInfo)
}
-func (s *Scheduler) payLockedOrder(cli *client.DouyuClient, attempt types.ExchangeAttempt, locked client.LockedExchangeOrder, csrf *string) (string, error) {
- for try := 0; try < 6; try++ {
+// 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
}
- info, err := cli.PayExchangeOrder(attempt.Account.Cookie, locked.OrderID, csrf)
+ 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
}
- backoff, ok := peacePayBackoff(try, exchangeRateLimited(err.Error()))
- if !ok {
- if exchangeRateLimited(err.Error()) {
- return "", errPayRateLimited(locked.OrderID, try, err)
- }
+
+ kind := payErrorKind(err)
+ if kind == orderErrTerminal {
return "", errPayFailed(locked.OrderID, try, err)
}
- remaining := time.Until(locked.ExpiresAt)
- if remaining <= 0 {
- remaining = peaceLockTTL
+ delay := payRetryDelay(kind, try)
+ if time.Now().Add(delay).After(payDeadline) {
+ return "", errPayNearExpiry(locked.OrderID, time.Until(locked.ExpiresAt), err)
}
- if remaining-backoff < peaceLockExpiryMargin {
- return "", errPayNearExpiry(locked.OrderID, remaining, 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
}
- kind := "错误"
- if exchangeRateLimited(err.Error()) {
- kind = "频控"
- }
- logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "锁单 %s 支付%s退避 %s 后重试: %s", locked.OrderID, kind, backoff, err.Error())
- if s.interruptibleSleep(backoff) {
- return "", errPurchaseStopped
+ if !ctl.sleepUntil(payDeadline, delay) {
+ if s.runStopped() {
+ return "", errPurchaseStopped
+ }
+ return "", errPayNearExpiry(locked.OrderID, time.Until(locked.ExpiresAt), err)
}
}
- return "", errPayFailed(locked.OrderID, len(peaceGenericBackoffs), nil)
}
-func (s *Scheduler) runDirectExchange(attempt types.ExchangeAttempt, reuseCache bool) types.ExchangeResult {
+// 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()
- result := types.ExchangeResult{
+ 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)
+ return s.runDirectExchange(attempt, false, ctl)
}
csrf = cache.CSRF
if cache.RoleName != "" {
@@ -167,31 +297,57 @@ func (s *Scheduler) runDirectExchange(attempt types.ExchangeAttempt, reuseCache
}
}
if csrf == "" {
- var err error
- csrf, err = cli.GenerateCSRF(attempt.Account.Cookie)
+ token, err := cli.GenerateCSRF(attempt.Account.Cookie)
if err != nil {
- result.Message = "失败|生成鉴权token失败: " + err.Error()
- return result
+ 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
}
- 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, &csrf)
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)
+ return s.runDirectExchange(attempt, false, ctl)
}
- result.RoleName = roleName
- result.Message = "失败|" + errMsg
- return result
+ 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, result, csrf, roleName, exchangeInfo)
+ return s.finishExchange(cli, attempt, out, csrf, roleName, exchangeInfo)
}
-func (s *Scheduler) finishExchange(cli *client.DouyuClient, attempt types.ExchangeAttempt, result types.ExchangeResult, csrf, roleName, exchangeInfo string) types.ExchangeResult {
+// 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 {
@@ -201,14 +357,14 @@ func (s *Scheduler) finishExchange(cli *client.DouyuClient, attempt types.Exchan
}
}
s.rememberAuth(attempt.Account.Name, csrf, roleName)
- result.OK = true
- result.RoleName = roleName
+ out.result.OK = true
+ out.result.RoleName = roleName
if roleName != "" {
- result.Message = "成功|" + roleName + "|" + exchangeInfo
+ out.result.Message = "成功|" + roleName + "|" + exchangeInfo
} else {
- result.Message = "成功|" + exchangeInfo
+ out.result.Message = "成功|" + exchangeInfo
}
- return result
+ return out
}
func (s *Scheduler) cachedAuth(attempt types.ExchangeAttempt) (roleName, csrf string) {
@@ -275,22 +431,135 @@ func (s *Scheduler) runXiaodianAttempt(attempt types.ExchangeAttempt) types.Exch
return result
}
-func peacePayBackoff(attempt int, rateLimited bool) (time.Duration, bool) {
- table := peaceGenericBackoffs
- if rateLimited {
- table = peaceRateLimitBackoffs
+// payRetryDelay 计算支付重试间隔。
+//
+// 订单已受理但结果未定时用 300ms 起跳的短阶梯;明确频控才退避稍长。
+// 上限只有几秒,避免出现"白等 60 秒"。
+func payRetryDelay(kind orderErrorKind, try int) time.Duration {
+ ladder := payRetryOther
+ switch kind {
+ case orderErrInFlight:
+ ladder = payRetryInFlight
+ case orderErrRateLimited:
+ ladder = payRetryRate
}
- if attempt < 0 || attempt >= len(table) {
- return 0, false
+ if try < 1 {
+ try = 1
}
- return table[attempt], true
+ shift := try - 1
+ if shift > 4 {
+ shift = 4
+ }
+ delay := ladder.base << uint(shift)
+ if delay > ladder.limit {
+ delay = ladder.limit
+ }
+ return jitterFraction(delay, 4)
}
-func peaceLockRetryDelay(message string) time.Duration {
- if exchangeRateLimited(message) && len(peaceRateLimitBackoffs) > 0 {
- return peaceRateLimitBackoffs[0]
+// 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()
}
- return peaceLockRetryFallback
+ 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 {
@@ -315,8 +584,16 @@ func randomSpan(min, max time.Duration) time.Duration {
return min + time.Duration(rand.Int63n(int64(max-min)+1))
}
-func errPayRateLimited(orderID string, tries int, err error) error {
- return fmt.Errorf("锁单 %s 支付被频控拦截(已退避重试%d次): %w", orderID, tries, err)
+// 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 {
@@ -330,6 +607,12 @@ 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 {
@@ -359,17 +642,25 @@ func (s *Scheduler) getAttemptCache(accountName string) *types.AttemptCache {
}
// initCacheFromWarmup 从预热数据初始化激进模式缓存。
+// 入参是按账号唯一的任务列表,因此每个账号只会写入一条日志。
func (s *Scheduler) initCacheFromWarmup(attempts []types.ExchangeAttempt) {
+ seen := make(map[string]struct{}, len(attempts))
for _, attempt := range attempts {
- if warmup, ok := s.warmupFor(attempt); ok {
- 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)
+ 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)
}
}
diff --git a/internal/seckill/run_control.go b/internal/seckill/run_control.go
new file mode 100644
index 0000000..32c0ec2
--- /dev/null
+++ b/internal/seckill/run_control.go
@@ -0,0 +1,125 @@
+package seckill
+
+import (
+ "sync"
+ "time"
+)
+
+// runControl 承载一次抢购任务的共享状态:窗口截止时间、并发闸门、请求计数。
+//
+// 手册链路(精英手册锁单支付 / 电竞手册直接兑换)下,每个账号在各自的 goroutine
+// 中独立重试,互不阻塞。并发闸门作用在"单次 HTTP 请求"上而不是整个账号循环上,
+// 这样账号在退避等待期间不会占着并发额度。
+type runControl struct {
+ s *Scheduler
+ start time.Time
+ deadline time.Time
+ stop <-chan struct{}
+ limit chan struct{}
+
+ mu sync.Mutex
+ lockReqs int
+ payReqs int
+ exchangeReqs int
+}
+
+// newRunControl 创建运行控制块。concurrency 限制同时在途的请求数。
+func newRunControl(s *Scheduler, start time.Time, deadline time.Time, concurrency int) *runControl {
+ if concurrency <= 0 {
+ concurrency = 1
+ }
+ return &runControl{
+ s: s,
+ start: start,
+ deadline: deadline,
+ stop: s.stopRunChan(),
+ limit: make(chan struct{}, concurrency),
+ }
+}
+
+// enter 占用一个并发额度;返回 false 表示任务已被停止。
+func (c *runControl) enter() bool {
+ select {
+ case c.limit <- struct{}{}:
+ return true
+ default:
+ }
+ select {
+ case c.limit <- struct{}{}:
+ return true
+ case <-c.stop:
+ return false
+ }
+}
+
+// leave 释放并发额度。
+func (c *runControl) leave() {
+ <-c.limit
+}
+
+// stopped 返回是否已收到停止指令。
+func (c *runControl) stopped() bool {
+ return c.s.runStopped()
+}
+
+// expired 返回抢购窗口是否已经结束(结束后不再发起新的锁单)。
+func (c *runControl) expired() bool {
+ return !time.Now().Before(c.deadline)
+}
+
+// elapsed 返回任务开始至今的时间,用于按配置的重试阶段取节奏。
+func (c *runControl) elapsed() time.Duration {
+ return time.Since(c.start)
+}
+
+// sleep 在抢购窗口内等待,等待时间会被剩余窗口裁剪。
+// 返回 false 表示该账号应当结束循环(窗口结束或收到停止指令)。
+func (c *runControl) sleep(d time.Duration) bool {
+ return c.sleepUntil(c.deadline, d) && !c.stopped()
+}
+
+// sleepUntil 在指定截止时间前等待。
+// 返回 false 表示到达截止时间或收到停止指令,调用方应当结束当前流程。
+//
+// 支付阶段用锁单过期时间作为截止,而不是抢购窗口:锁单已经占用库存,
+// 窗口结束后仍要把已锁订单支付完,中途放弃等于白锁。
+func (c *runControl) sleepUntil(limit time.Time, d time.Duration) bool {
+ if d > 0 {
+ remaining := time.Until(limit)
+ if remaining <= 0 {
+ return false
+ }
+ if d > remaining {
+ d = remaining
+ }
+ if c.s.interruptibleSleep(d) {
+ return false
+ }
+ }
+ return time.Now().Before(limit) && !c.stopped()
+}
+
+func (c *runControl) countLockReq() {
+ c.mu.Lock()
+ c.lockReqs++
+ c.mu.Unlock()
+}
+
+func (c *runControl) countPayReq() {
+ c.mu.Lock()
+ c.payReqs++
+ c.mu.Unlock()
+}
+
+func (c *runControl) countExchangeReq() {
+ c.mu.Lock()
+ c.exchangeReqs++
+ c.mu.Unlock()
+}
+
+// counts 返回锁单 / 支付 / 直接兑换的真实请求次数,用于汇总日志。
+func (c *runControl) counts() (lock, pay, exchange int) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.lockReqs, c.payReqs, c.exchangeReqs
+}
diff --git a/internal/seckill/schedule_timer.go b/internal/seckill/schedule_timer.go
index 1d3fb13..1296081 100644
--- a/internal/seckill/schedule_timer.go
+++ b/internal/seckill/schedule_timer.go
@@ -122,9 +122,8 @@ func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime
return
}
logger.LogEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(constants.CST).Format("15:04:05.000"))
- settings := s.settingsSnapshot()
- attempts := s.buildAttempts(accountProducts, settings.attempts)
- s.warmupAttempts(attempts)
+ warmupTasks := s.buildTasks(accountProducts)
+ s.warmupAttempts(warmupTasks)
})
}
s.replaceTimerLocked(&s.schedule, remain, func() {
diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go
index 79b0808..dc14977 100644
--- a/internal/seckill/scheduler.go
+++ b/internal/seckill/scheduler.go
@@ -2,7 +2,6 @@ package seckill
import (
"fmt"
- "math/rand"
"sort"
"strings"
"sync"
@@ -26,12 +25,15 @@ const (
timeCalibrateWait = 1200 * time.Millisecond
resyncBeforeTrigger = 3 * time.Second
calibrationHistorySize = 3
- seckillMaxDuration = 75 * time.Second
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
@@ -400,216 +402,308 @@ func (s *Scheduler) interruptibleSleep(d time.Duration) bool {
}
}
-// RunNow 立即执行抢购任务,持续循环直到超时或全部成功。
+// 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()
- allAttempts := s.buildAttempts(accountProducts, settings.attempts)
- if len(allAttempts) == 0 {
+ tasks := s.buildTasks(accountProducts)
+ if len(tasks) == 0 {
logger.LogEvent("抢购", "warn", "没有可执行的抢购任务")
return
}
- successAccounts := make(map[string]bool)
- stoppedAccounts := make(map[string]string)
- stoppedProducts := make(map[string]string)
- cooldownAccounts := make(map[string]time.Time)
- cooldownCount := make(map[string]int)
- totalAccounts := uniqueAttemptAccounts(allAttempts)
- totalAttempts := 0
- startTime := time.Now()
- round := 0
+ start := time.Now()
+ deadline := start.Add(seckillMaxDuration)
+ ctl := newRunControl(s, start, deadline, settings.concurrency)
s.clearAttemptCache()
if settings.seckillMode == types.SeckillModeAggressive {
- s.initCacheFromWarmup(allAttempts)
+ s.initCacheFromWarmup(tasks)
}
- logger.LogEvent("抢购", "info", "开始抢购: %d 个账号,窗口=%v", totalAccounts, seckillMaxDuration)
-
- for {
- if s.runStopped() {
- logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
- break
- }
- elapsed := time.Since(startTime)
- if elapsed >= seckillMaxDuration {
- logger.LogEvent("抢购", "info", "已达到抢购窗口 %v,停止抢购", seckillMaxDuration)
- break
- }
-
- round++
- remaining := seckillMaxDuration - elapsed
-
- pendingAttempts := s.filterPendingAttempts(allAttempts, stoppedAccounts, stoppedProducts)
- if len(pendingAttempts) == 0 {
- logger.LogEvent("抢购", "info", "所有账号都已成功或遇到终止条件,停止抢购")
- break
- }
-
- roundAttempts := pickRoundAttempts(pendingAttempts, round, settings.attempts)
- if len(roundAttempts) == 0 {
- logger.LogEvent("抢购", "info", "第%d轮无可执行账号,停止抢购", round)
- break
- }
-
- now := time.Now()
- roundAttempts, nextReady := filterCooldownAttempts(roundAttempts, cooldownAccounts, now)
- if len(roundAttempts) == 0 {
- sleepFor := nextReady.Sub(now)
- if sleepFor <= 0 {
- sleepFor = retryIntervalFor(settings.retryStages, time.Since(startTime))
- }
- if sleepFor > remaining {
- sleepFor = remaining
- }
- if sleepFor > 0 {
- logger.LogEvent("抢购", "info", "第%d轮账号冷却中,等待=%v", round, utils.RoundDuration(sleepFor))
- if s.interruptibleSleep(sleepFor) {
- logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
- break
- }
- }
- continue
- }
-
- logger.LogEvent("抢购", "info", "第%d轮: 待抢购 %d 个账号,本轮执行=%d,剩余时间=%v,模式=%s",
- round, uniqueAttemptAccounts(pendingAttempts), len(roundAttempts), utils.RoundDuration(remaining),
- modeName(settings.seckillMode))
-
- outcome := s.executeRound(roundAttempts, settings)
- totalAttempts += outcome.attempts
-
- for account := range outcome.successAccounts {
- successAccounts[account] = true
- stoppedAccounts[account] = "已抢到"
- }
- for account, reason := range outcome.stoppedAccounts {
- if stoppedAccounts[account] == "" {
- stoppedAccounts[account] = reason
- logger.LogAccountEvent("抢购", "warn", account, "账号停止重试: 账号=%s 原因=%s", account, reason)
- }
- }
- for product, reason := range outcome.stoppedProducts {
- if stoppedProducts[product] == "" {
- stoppedProducts[product] = reason
- logger.LogEvent("抢购", "warn", "商品停止重试: %s,原因=%s", product, reason)
- }
- }
- for account, until := range outcome.cooldownAccounts {
- if until.After(cooldownAccounts[account]) {
- cooldownAccounts[account] = until
- cooldownCount[account]++
- logger.LogAccountEvent("抢购", "warn", account, "账号临时降频: 账号=%s 冷却至=%s (第%d次)",
- account, until.In(constants.CST).Format("15:04:05.000"), cooldownCount[account])
- if cooldownCount[account] >= maxCooldownCount {
- stoppedAccounts[account] = "冷却次数过多"
- logger.LogAccountEvent("抢购", "warn", account, "账号停止重试: 账号=%s 原因=冷却次数过多(%d次)", account, cooldownCount[account])
- }
- }
- }
-
- if s.runStopped() {
- logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
- break
- }
- if len(outcome.successAccounts) > 0 {
- logger.LogEvent("抢购", "success", "第%d轮成功: %d 个账号", round, len(outcome.successAccounts))
- }
- if len(stoppedAccounts) >= totalAccounts {
- logger.LogEvent("抢购", "info", "所有账号都已成功或停止重试,停止抢购")
- break
- }
-
- retryInterval := retryIntervalFor(settings.retryStages, time.Since(startTime))
- if retryInterval > 0 && time.Since(startTime)+retryInterval < seckillMaxDuration {
- if s.interruptibleSleep(jitterDuration(retryInterval)) {
- logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
- break
- }
- }
+ 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)
}
- logger.LogEvent("抢购", "info", "抢购任务结束: 成功=%d/%d,停止=%d,账号尝试=%d,耗时=%v",
- len(successAccounts), totalAccounts, len(stoppedAccounts)-len(successAccounts), totalAttempts, utils.RoundDuration(time.Since(startTime)))
+ 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))
}
-// RunFixed 按固定轮数和轮间隔抢购,账号成功后不再参与后续轮次。
-func (s *Scheduler) RunFixed(accountProducts map[string]string, attempts, delayMs int) {
- defer s.finishRun()
- s.ClearResults()
- settings := s.settingsSnapshot()
- if attempts < 1 {
- attempts = 1
+// 账号最终结束状态。
+const (
+ outcomeSuccess = "success"
+ outcomeRejected = "rejected" // 窗口结束时商品始终售罄
+ outcomeThrottled = "throttled" // 窗口内始终被请求级限流
+ outcomeTerminal = "terminal" // 命中账号级终止条件
+ outcomeLockedUnpaid = "lockedUnpaid" // 已锁单成功但支付未完成
+ outcomeStopped = "stopped"
+ outcomeExpired = "expired"
+)
+
+// 每账号重试节奏的上下限与递增倍数。
+const (
+ minRetryDelay = 100 * time.Millisecond
+ maxRetryDelay = 5 * time.Second
+ maxRetryEscalation = 8
+)
+
+// 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
+ }
}
- if attempts > 100000 {
- attempts = 100000
+}
+
+// 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 + ")"
}
- if delayMs < 0 {
- delayMs = 0
+}
+
+// retryDelay 计算账号下一次尝试前的等待时间。
+//
+// 基准节奏来自配置的"重试间隔策略"(按经过秒数分档)——早期版本把它只用在
+// 轮与轮之间,单次锁单失败却硬编码睡 30 秒,配置形同虚设。同一账号连续失败时
+// 再成倍放大,避免单个账号猛打;并叠加抖动,防止所有账号同步重试形成尖峰。
+func retryDelay(stages []types.RetryStage, elapsed time.Duration, failStreak int) time.Duration {
+ base := retryIntervalFor(stages, elapsed)
+ if base <= 0 {
+ base = 900 * time.Millisecond
}
- if delayMs > 60000 {
- delayMs = 60000
+ 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, 2)
+}
+
+// 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]++
+ }
}
- accounts := make([]types.Account, 0, len(accountProducts))
- for name, commodityID := range accountProducts {
- cookie, ok := s.AccountCookieByName(name)
- if !ok {
- continue
- }
- product := s.productForCommodityID(commodityID)
- if product.CommodityID == "" {
- product.CommodityID = commodityID
- }
- accounts = append(accounts, types.Account{Name: name, Cookie: cookie})
- }
- if len(accounts) == 0 {
- logger.LogEvent("抢购", "warn", "固定次数抢购没有可执行账号")
- return
- }
+ 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)
- stopped := make(map[string]bool)
- start := time.Now()
- logger.LogEvent("抢购", "info", "固定次数抢购开始: %d 个账号,次数=%d,间隔=%dms", len(accounts), attempts, delayMs)
- for round := 1; round <= attempts; round++ {
- if s.runStopped() {
- logger.LogEvent("抢购", "warn", "收到停止指令,固定次数抢购正在结束")
- break
+ 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
}
- roundAttempts := make([]types.ExchangeAttempt, 0, len(accounts))
- for _, account := range accounts {
- if stopped[account.Name] {
- continue
- }
- commodityID := accountProducts[account.Name]
- product := s.productForCommodityID(commodityID)
- if product.CommodityID == "" {
- product.CommodityID = commodityID
- }
- roundAttempts = append(roundAttempts, types.ExchangeAttempt{Account: account, Product: product, Index: round})
- }
- if len(roundAttempts) == 0 {
- break
- }
- if round > 1 && delayMs > 0 {
- if s.interruptibleSleep(time.Duration(delayMs) * time.Millisecond) {
- logger.LogEvent("抢购", "warn", "收到停止指令,固定次数抢购正在结束")
- break
- }
- }
- outcome := s.executeRound(roundAttempts, settings)
- for name := range outcome.successAccounts {
- stopped[name] = true
- }
- for name := range outcome.stoppedAccounts {
- stopped[name] = true
- }
- logger.LogEvent("抢购", "info", "固定抢购第%d/%d轮完成: 成功=%d,剩余账号=%d", round, attempts, len(outcome.successAccounts), len(accounts)-len(stopped))
+ return ranked[i].reason < ranked[j].reason
+ })
+ for _, item := range ranked {
+ logger.LogEvent("抢购", "warn", "未成功原因: %s × %d 个账号", item.reason, item.count)
}
- logger.LogEvent("抢购", "info", "固定次数抢购结束: 成功=%d/%d,耗时=%v", len(stopped), len(accounts), utils.RoundDuration(time.Since(start)))
}
// RunXiaodian 使用和平小店专用策略:固定次数、固定间隔、每轮直接调用 livebuy。
@@ -750,21 +844,6 @@ func normalizeXiaodianRun(attempts, delayMs, fallbackAttempts int) (int, time.Du
return attempts, time.Duration(delayMs) * time.Millisecond
}
-// filterPendingAttempts 过滤掉已终止账号和已确认无库存的商品。
-func (s *Scheduler) filterPendingAttempts(allAttempts []types.ExchangeAttempt, stoppedAccounts, stoppedProducts map[string]string) []types.ExchangeAttempt {
- var pending []types.ExchangeAttempt
- for _, attempt := range allAttempts {
- if stoppedAccounts[attempt.Account.Name] != "" {
- continue
- }
- if stoppedProducts[attempt.Product.CommodityID] != "" {
- continue
- }
- pending = append(pending, attempt)
- }
- return pending
-}
-
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
@@ -781,46 +860,6 @@ func filterCooldownAttempts(attempts []types.ExchangeAttempt, cooldownAccounts m
return out, nextReady
}
-// pickRoundAttempts 每轮每个账号最多执行一次,避免同账号并发过猛触发频控。
-func pickRoundAttempts(attempts []types.ExchangeAttempt, round, attemptsPerAccount int) []types.ExchangeAttempt {
- if len(attempts) == 0 {
- return nil
- }
- preferredIndex := 1
- if attemptsPerAccount > 0 {
- preferredIndex = ((round - 1) % attemptsPerAccount) + 1
- }
-
- byAccount := make(map[string]types.ExchangeAttempt)
- fallback := make(map[string]types.ExchangeAttempt)
- order := make([]string, 0, len(attempts))
- for _, attempt := range attempts {
- name := attempt.Account.Name
- if _, ok := fallback[name]; !ok {
- fallback[name] = attempt
- order = append(order, name)
- }
- if attempt.Index == preferredIndex {
- byAccount[name] = attempt
- }
- }
-
- out := make([]types.ExchangeAttempt, 0, len(order))
- for _, name := range order {
- if attempt, ok := byAccount[name]; ok {
- out = append(out, attempt)
- } else {
- out = append(out, fallback[name])
- }
- }
- return out
-}
-
-// executeRound 执行一轮抢购,并返回成功和终止重试的账号。
-func (s *Scheduler) executeRound(attempts []types.ExchangeAttempt, settings runSettings) roundOutcome {
- return s.executeRoundWithRunner(attempts, settings, nil)
-}
-
func (s *Scheduler) executeXiaodianRound(attempts []types.ExchangeAttempt, settings runSettings) roundOutcome {
return s.executeRoundWithRunner(attempts, settings, s.runXiaodianAttempt)
}
@@ -858,23 +897,18 @@ func (s *Scheduler) executeRoundWithRunner(
return
}
- var result types.ExchangeResult
- if runner != nil {
- result = runner(attempt)
- } else if settings.seckillMode == types.SeckillModeAggressive {
- result = s.runAttemptFast(attempt)
- } else {
- result = s.runAttempt(attempt)
- }
+ result := runner(attempt)
- key := fmt.Sprintf("%s_第%d包", result.Account, result.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)
+ 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)
+ logger.LogAccountEvent("抢购", "info", result.Account, "尝试: 账号=%s 商品=%s 第%d次 %s", result.Account, result.Product, result.Attempt, result.Message)
}
mu.Lock()
@@ -886,10 +920,7 @@ func (s *Scheduler) executeRoundWithRunner(
if cooldown := retryCooldownDuration(result.Message); cooldown > 0 {
outcome.cooldownAccounts[attempt.Account.Name] = time.Now().Add(jitterDuration(cooldown))
}
- terminalAccount, terminalProduct, reason := classifyRetryStop(result.Message)
- if runner != nil {
- terminalAccount, terminalProduct, reason = classifyXiaodianRetryStop(result.Message)
- }
+ terminalAccount, terminalProduct, reason := classifyXiaodianRetryStop(result.Message)
if terminalAccount {
outcome.stoppedAccounts[attempt.Account.Name] = reason
}
@@ -907,14 +938,6 @@ func (s *Scheduler) executeRoundWithRunner(
// ==================== 辅助函数 ====================
-// modeName 返回抢购模式名称。
-func modeName(mode types.SeckillMode) string {
- if mode == types.SeckillModeAggressive {
- return "激进"
- }
- return "标准"
-}
-
// retryIntervalFor 根据配置的重试阶段计算当前应该使用的重试间隔。
func retryIntervalFor(retryStages []types.RetryStage, elapsed time.Duration) time.Duration {
elapsedSec := int(elapsed.Seconds())
@@ -931,11 +954,7 @@ func retryIntervalFor(retryStages []types.RetryStage, elapsed time.Duration) tim
}
func jitterDuration(base time.Duration) time.Duration {
- if base <= 0 {
- return 0
- }
- jitter := time.Duration(rand.Int63n(int64(base / 10)))
- return base + jitter
+ return jitterFraction(base, 10)
}
func retryCooldownDuration(message string) time.Duration {
@@ -1018,35 +1037,25 @@ func dynamicAdvance(calibration types.TimeCalibration) time.Duration {
return advance
}
-func uniqueAttemptAccounts(attempts []types.ExchangeAttempt) int {
- seen := make(map[string]struct{}, len(attempts))
- for _, attempt := range attempts {
- seen[attempt.Account.Name] = struct{}{}
- }
- return len(seen)
-}
-
-func (s *Scheduler) buildAttempts(accountProducts map[string]string, attemptsPerAccount int) []types.ExchangeAttempt {
- if attemptsPerAccount <= 0 {
- attemptsPerAccount = constants.DefaultAttempts
- }
+// buildTasks 为每个账号生成一个抢购任务。
+//
+// 锁单+支付链路下,一次任务就是一次完整的"锁单→支付",不需要再按"N 个包"
+// 为同一账号重复生成任务(历史实现会因此把预热缓存日志打两遍)。
+func (s *Scheduler) buildTasks(accountProducts map[string]string) []types.ExchangeAttempt {
accounts := s.AccountSnapshot()
- var attempts []types.ExchangeAttempt
+ tasks := make([]types.ExchangeAttempt, 0, len(accounts))
for _, account := range accounts {
commodityID := accountProducts[account.Name]
if commodityID == "" {
continue
}
- product := s.productForCommodityID(commodityID)
- for i := 1; i <= attemptsPerAccount; i++ {
- attempts = append(attempts, types.ExchangeAttempt{
- Account: account,
- Product: product,
- Index: i,
- })
- }
+ tasks = append(tasks, types.ExchangeAttempt{
+ Account: account,
+ Product: s.productForCommodityID(commodityID),
+ Index: 1,
+ })
}
- return attempts
+ return tasks
}
func (s *Scheduler) productForCommodityID(commodityID string) types.Product {
diff --git a/internal/seckill/scheduler_test.go b/internal/seckill/scheduler_test.go
index 7cc3069..f5369d9 100644
--- a/internal/seckill/scheduler_test.go
+++ b/internal/seckill/scheduler_test.go
@@ -1,8 +1,15 @@
package seckill
import (
+ "errors"
+ "fmt"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
"testing"
"time"
@@ -23,60 +30,28 @@ func makeAttempts(names ...string) []types.ExchangeAttempt {
return out
}
-func TestPeacePayBackoff(t *testing.T) {
- if got, ok := peacePayBackoff(0, true); !ok || got != 30*time.Second {
- t.Fatalf("first rate backoff = %v ok=%v", got, ok)
- }
- if _, ok := peacePayBackoff(len(peaceRateLimitBackoffs), true); ok {
- t.Fatal("rate backoff should stop after the configured attempts")
- }
- if got, ok := peacePayBackoff(1, false); !ok || got != 5*time.Second {
- t.Fatalf("second generic backoff = %v ok=%v", got, ok)
- }
-}
-
-func TestPeacePurchaseLocksThenPaysWithoutChecks(t *testing.T) {
- oldMin, oldMax := peacePreLockMin, peacePreLockMax
+// speedUpPeace 把锁单/支付前后的抖动压到 0,并缩短抢购窗口,
+// 让测试只关注重试逻辑本身。
+func speedUpPeace(t *testing.T) {
+ t.Helper()
+ oldPreMin, oldPreMax := peacePreLockMin, peacePreLockMax
oldPayMin, oldPayMax := peaceLockPayMin, peaceLockPayMax
+ oldDuration := seckillMaxDuration
peacePreLockMin, peacePreLockMax = 0, 0
peaceLockPayMin, peaceLockPayMax = 0, 0
+ seckillMaxDuration = 2 * time.Second
t.Cleanup(func() {
- peacePreLockMin, peacePreLockMax = oldMin, oldMax
+ peacePreLockMin, peacePreLockMax = oldPreMin, oldPreMax
peaceLockPayMin, peaceLockPayMax = oldPayMin, oldPayMax
+ seckillMaxDuration = oldDuration
})
+}
- var calls []string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- calls = append(calls, r.URL.Path)
- w.Header().Set("Content-Type", "application/json")
- switch r.URL.Path {
- case "/create":
- if err := r.ParseMultipartForm(1 << 20); err != nil {
- t.Errorf("ParseMultipartForm() error = %v", err)
- return
- }
- if _, ok := r.MultipartForm.Value["orderID"]; ok {
- t.Error("锁单请求不应携带支付订单号")
- }
- _, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-9","expireSeconds":300}}`))
- case "/pay":
- if err := r.ParseMultipartForm(1 << 20); err != nil {
- t.Errorf("ParseMultipartForm() error = %v", err)
- return
- }
- if got := r.MultipartForm.Value["orderID"][0]; got != "order-9" {
- t.Errorf("orderID = %q", got)
- }
- _, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
- case "/bind":
- _, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
- default:
- t.Errorf("不应请求预检或单步兑换接口: %s", r.URL.Path)
- http.NotFound(w, r)
- }
- }))
- defer server.Close()
-
+// newOrderTestScheduler 用测试服务器替换锁单/支付/角色接口。
+func newOrderTestScheduler(t *testing.T, handler http.HandlerFunc) *Scheduler {
+ t.Helper()
+ server := httptest.NewServer(handler)
+ t.Cleanup(server.Close)
act := *activity.Get("peace")
act.CreateOrderURL = server.URL + "/create"
act.PayOrderURL = server.URL + "/pay"
@@ -84,26 +59,216 @@ func TestPeacePurchaseLocksThenPaysWithoutChecks(t *testing.T) {
act.CSRFURL = server.URL + "/csrf"
cli := client.New(&act, "", &trace.Recorder{})
cli.HTTPClient = server.Client()
- scheduler := New(cli, types.AppPaths{}, nil, 1, 1, nil, types.SeckillModeAggressive)
+ return New(cli, types.AppPaths{}, nil, 8, 1, nil, types.SeckillModeAggressive)
+}
- result := scheduler.runPeaceLockPay(types.ExchangeAttempt{
- Account: types.Account{Name: "a", Cookie: "cvl_csrf_token=tok"},
+// requestAccount 取出请求对应的测试账号标识(csrfToken 等于账号名)。
+func requestAccount(t *testing.T, r *http.Request) string {
+ t.Helper()
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ t.Errorf("ParseMultipartForm() error = %v", err)
+ return ""
+ }
+ if v := r.MultipartForm.Value["csrfToken"]; len(v) > 0 {
+ return v[0]
+ }
+ return ""
+}
+
+func peaceAttempt(name string) types.ExchangeAttempt {
+ return types.ExchangeAttempt{
+ Account: types.Account{Name: name, Cookie: "cvl_csrf_token=" + name},
Product: types.Product{CommodityID: "goods-1", CommodityName: "测试商品"},
Index: 1,
- })
- if !result.OK || result.RoleName != "测试角色" {
- t.Fatalf("result = %#v", result)
}
- if len(calls) < 2 || calls[0] != "/create" || calls[1] != "/pay" {
- t.Fatalf("calls = %v, want create then pay", calls)
+}
+
+func writeAccountFile(t *testing.T, names ...string) types.AppPaths {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "accounts.txt")
+ var b strings.Builder
+ for _, name := range names {
+ b.WriteString(name + "----pw----cvl_csrf_token=" + name + "\n")
}
- for _, path := range calls {
- switch path {
- case "/create", "/pay", "/bind":
- default:
- t.Fatalf("unexpected call %s in %v", path, calls)
+ if err := os.WriteFile(path, []byte(b.String()), 0600); err != nil {
+ t.Fatal(err)
+ }
+ return types.AppPaths{AccountFile: path}
+}
+
+// ========== 错误分类 ==========
+
+func TestOrderErrorClassification(t *testing.T) {
+ lockCases := []struct {
+ err error
+ want orderErrorKind
+ }{
+ {&client.OrderError{Kind: "锁定兑换商品失败", Code: apiActivityHot, Msg: "活动火爆,请稍后再试"}, orderErrThrottled},
+ {&client.OrderError{Kind: "锁定兑换商品失败", Code: apiClickTooFast, Msg: "您点击太快,请稍等再试"}, orderErrThrottled},
+ {&client.OrderError{Kind: "锁定兑换商品失败", Code: apiStockExhausted, Msg: "商品本次活动库存已耗完"}, orderErrStockOut},
+ {&client.OrderError{Kind: "锁定兑换商品失败", Code: 1, Msg: "您已达本次活动兑换上限"}, orderErrTerminal},
+ {&client.OrderError{Kind: "锁定兑换商品失败", Code: 1, Msg: "未绑定账号"}, orderErrTerminal},
+ {errors.New("锁定兑换商品失败: 请求太频繁"), orderErrRateLimited},
+ {errors.New("dial tcp: connection reset"), orderErrTransient},
+ }
+ for _, c := range lockCases {
+ if got := lockErrorKind(c.err); got != c.want {
+ t.Errorf("lockErrorKind(%v) = %v, want %v", c.err, got, c.want)
}
}
+
+ // 支付链路上"订单已受理、结果未定"的三种表现都必须算在途,而不是频控。
+ payCases := []struct {
+ err error
+ want orderErrorKind
+ }{
+ {&client.OrderError{Kind: "支付锁单失败", Code: apiOrderInProgress, Msg: "订单操作进行中,请稍后再试"}, orderErrInFlight},
+ {&client.OrderError{Kind: "支付锁单失败", Code: apiServerTimeout, Msg: "context deadline exceeded"}, orderErrInFlight},
+ {&client.OrderError{Kind: "支付锁单失败", Code: apiServiceUnavail, Msg: "服务当前不可用"}, orderErrInFlight},
+ {&client.OrderError{Kind: "支付锁单失败", Code: 1, Msg: "订单已过期"}, orderErrTerminal},
+ {&client.OrderError{Kind: "支付锁单失败", Code: 1, Msg: "操作频繁,请稍后再试"}, orderErrRateLimited},
+ }
+ for _, c := range payCases {
+ if got := payErrorKind(c.err); got != c.want {
+ t.Errorf("payErrorKind(%v) = %v, want %v", c.err, got, c.want)
+ }
+ }
+}
+
+// TestPayRetryDelayStaysShort 锁住"不再出现 30s/60s 长退避"这一关键修复。
+// 覆盖到很大的重试序号,确认阶梯只封顶不增长。
+func TestPayRetryDelayStaysShort(t *testing.T) {
+ for try := 1; try <= 500; try++ {
+ inFlight := payRetryDelay(orderErrInFlight, try)
+ if inFlight > 1300*time.Millisecond {
+ t.Fatalf("在途重试第%d次退避 %v,太长了", try, inFlight)
+ }
+ rateLimited := payRetryDelay(orderErrRateLimited, try)
+ if rateLimited > 4*time.Second {
+ t.Fatalf("频控重试第%d次退避 %v,太长了", try, rateLimited)
+ }
+ }
+ // 首次在途重试必须在 200-500ms 区间(用户选定的快重试档位)。
+ if got := payRetryDelay(orderErrInFlight, 1); got < 300*time.Millisecond || got > 400*time.Millisecond {
+ t.Fatalf("首次在途重试 = %v, want 300ms±抖动", got)
+ }
+}
+
+// speedUpPayLadder 把支付退避阶梯与总预算压到毫秒级,便于测试长时间重试路径。
+func speedUpPayLadder(t *testing.T) {
+ t.Helper()
+ oldInFlight, oldRate, oldOther := payRetryInFlight, payRetryRate, payRetryOther
+ oldBudget := maxPayDuration
+ payRetryInFlight = retryLadder{base: time.Millisecond, limit: 2 * time.Millisecond}
+ payRetryRate = retryLadder{base: time.Millisecond, limit: 2 * time.Millisecond}
+ payRetryOther = retryLadder{base: time.Millisecond, limit: 2 * time.Millisecond}
+ t.Cleanup(func() {
+ payRetryInFlight, payRetryRate, payRetryOther = oldInFlight, oldRate, oldOther
+ maxPayDuration = oldBudget
+ })
+}
+
+// TestPayKeepsRetryingBeyondOldAttemptCap 回归复核发现的严重缺陷:
+// 之前用 maxPayAttempts=30 做上限,而 2026-10-03 斗鱼的支付处理堵塞了约 65 秒
+// 才结算(137 单全部在 18:01:07 左右才确认),30 次会在 ~37 秒就放弃,
+// 把已经锁到、也确实支付成功的订单报成"结果未知"。
+func TestPayKeepsRetryingBeyondOldAttemptCap(t *testing.T) {
+ speedUpPeace(t)
+ speedUpPayLadder(t)
+
+ const inFlightTimes = 45 // 明显超过旧上限 30
+ var payCalls int32
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/create":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-1","expireSeconds":300}}`))
+ case "/pay":
+ if atomic.AddInt32(&payCalls, 1) <= inFlightTimes {
+ _, _ = w.Write([]byte(`{"error":1644430001,"msg":"订单操作进行中,请稍后再试"}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
+ case "/bind":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
+ }
+ })
+
+ ctl := newRunControl(scheduler, time.Now(), time.Now().Add(30*time.Second), 4)
+ out := scheduler.runPeaceLockPay(peaceAttempt("a"), ctl)
+ if !out.result.OK {
+ t.Fatalf("服务端在 %d 次「处理中」之后结算,应当继续重试到成功, result=%#v", inFlightTimes, out.result)
+ }
+ if got := atomic.LoadInt32(&payCalls); got != inFlightTimes+1 {
+ t.Fatalf("支付请求 = %d, want %d", got, inFlightTimes+1)
+ }
+}
+
+// TestPayGivesUpHonestlyAtBudget 超过时间预算时必须如实报"结果未知",
+// 而不是让用户以为订单一定失败了。
+func TestPayGivesUpHonestlyAtBudget(t *testing.T) {
+ speedUpPeace(t)
+ speedUpPayLadder(t)
+ maxPayDuration = 20 * time.Millisecond
+
+ var payCalls int32
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/create":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-1","expireSeconds":300}}`))
+ case "/pay":
+ atomic.AddInt32(&payCalls, 1)
+ _, _ = w.Write([]byte(`{"error":1644430001,"msg":"订单操作进行中,请稍后再试"}`))
+ }
+ })
+
+ ctl := newRunControl(scheduler, time.Now(), time.Now().Add(30*time.Second), 4)
+ out := scheduler.runPeaceLockPay(peaceAttempt("a"), ctl)
+ if out.result.OK {
+ t.Fatal("预算内不可能成功,不应报成功")
+ }
+ if !strings.Contains(out.result.Message, "支付结果未知") || !strings.Contains(out.result.Message, "可能已支付") {
+ t.Fatalf("结果 = %q, want 提示支付结果未知", out.result.Message)
+ }
+ if atomic.LoadInt32(&payCalls) == 0 {
+ t.Fatal("应当至少尝试过一次支付")
+ }
+}
+
+// ========== 重试节奏 ==========
+
+func TestRetryDelayFollowsConfiguredStages(t *testing.T) {
+ stages := []types.RetryStage{
+ {MaxElapsed: 4, Interval: 120},
+ {MaxElapsed: 12, Interval: 200},
+ {MaxElapsed: 30, Interval: 500},
+ {MaxElapsed: 75, Interval: 1000},
+ }
+ cases := []struct {
+ elapsed time.Duration
+ streak int
+ min time.Duration
+ max time.Duration
+ }{
+ {1 * time.Second, 0, 120 * time.Millisecond, 180 * time.Millisecond},
+ {5 * time.Second, 0, 200 * time.Millisecond, 300 * time.Millisecond},
+ {31 * time.Second, 0, time.Second, 1500 * time.Millisecond},
+ // 连续失败成倍放大,但不超过 maxRetryDelay
+ {1 * time.Second, 3, 480 * time.Millisecond, 720 * time.Millisecond},
+ {31 * time.Second, 10, 5 * time.Second, 5*time.Second + 2500*time.Millisecond},
+ }
+ for _, c := range cases {
+ got := retryDelay(stages, c.elapsed, c.streak)
+ if got < c.min || got > c.max {
+ t.Errorf("retryDelay(elapsed=%v, streak=%d) = %v, want [%v,%v]", c.elapsed, c.streak, got, c.min, c.max)
+ }
+ }
+ // 空配置也要给出可用节奏,不能是 0(0 会变成忙等)。
+ if got := retryDelay(nil, time.Second, 0); got < minRetryDelay {
+ t.Errorf("retryDelay(nil) = %v, want >= %v", got, minRetryDelay)
+ }
}
func TestRetryIntervalFor(t *testing.T) {
@@ -131,7 +296,6 @@ func TestRetryIntervalFor(t *testing.T) {
}
func TestRetryIntervalForEmpty(t *testing.T) {
- // 空阶段或无效阶段都回退到默认 900ms
if got := retryIntervalFor(nil, 1*time.Second); got != 900*time.Millisecond {
t.Errorf("retryIntervalFor(nil) = %v, want 900ms", got)
}
@@ -140,6 +304,454 @@ func TestRetryIntervalForEmpty(t *testing.T) {
}
}
+// ========== 锁单 + 支付 ==========
+
+func TestPeacePurchaseLocksThenPaysWithoutChecks(t *testing.T) {
+ speedUpPeace(t)
+
+ var calls []string
+ var mu sync.Mutex
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ calls = append(calls, r.URL.Path)
+ mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/create":
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ t.Errorf("ParseMultipartForm() error = %v", err)
+ return
+ }
+ if _, ok := r.MultipartForm.Value["orderID"]; ok {
+ t.Error("锁单请求不应携带支付订单号")
+ }
+ _, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-9","expireSeconds":300}}`))
+ case "/pay":
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ t.Errorf("ParseMultipartForm() error = %v", err)
+ return
+ }
+ if got := r.MultipartForm.Value["orderID"][0]; got != "order-9" {
+ t.Errorf("orderID = %q", got)
+ }
+ _, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
+ case "/bind":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
+ default:
+ t.Errorf("不应请求预检或单步兑换接口: %s", r.URL.Path)
+ http.NotFound(w, r)
+ }
+ })
+
+ ctl := newRunControl(scheduler, time.Now(), time.Now().Add(time.Minute), 4)
+ out := scheduler.runPeaceLockPay(peaceAttempt("a"), ctl)
+ if !out.result.OK || out.result.RoleName != "测试角色" {
+ t.Fatalf("result = %#v", out)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if len(calls) < 2 || calls[0] != "/create" || calls[1] != "/pay" {
+ t.Fatalf("calls = %v, want create then pay", calls)
+ }
+ for _, path := range calls {
+ switch path {
+ case "/create", "/pay", "/bind":
+ default:
+ t.Fatalf("unexpected call %s in %v", path, calls)
+ }
+ }
+}
+
+// TestPayInFlightRetriesQuickly 复现线上形态:
+// 第一次支付被斗鱼服务端 3 秒超时打断,第二次返回"订单操作进行中",
+// 第三次成功。修复前这两次会退避 2s + 60s,修复后应在 1 秒出头完成。
+func TestPayInFlightRetriesQuickly(t *testing.T) {
+ speedUpPeace(t)
+
+ var payCalls int32
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/create":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-1","expireSeconds":300}}`))
+ case "/pay":
+ switch atomic.AddInt32(&payCalls, 1) {
+ case 1:
+ _, _ = w.Write([]byte(`{"error":1644400002,"msg":"context deadline exceeded"}`))
+ case 2:
+ _, _ = w.Write([]byte(`{"error":1644430001,"msg":"订单操作进行中,请稍后再试"}`))
+ default:
+ _, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
+ }
+ case "/bind":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
+ }
+ })
+
+ ctl := newRunControl(scheduler, time.Now(), time.Now().Add(30*time.Second), 4)
+ start := time.Now()
+ out := scheduler.runPeaceLockPay(peaceAttempt("a"), ctl)
+ elapsed := time.Since(start)
+
+ if !out.result.OK {
+ t.Fatalf("应支付成功, result=%#v", out.result)
+ }
+ if got := atomic.LoadInt32(&payCalls); got != 3 {
+ t.Fatalf("支付请求 = %d, want 3", got)
+ }
+ if elapsed > 3*time.Second {
+ t.Fatalf("两次在途重试耗时 %v;历史 bug 会退避 2s+60s", elapsed)
+ }
+}
+
+// TestPayFinishesAfterWindowClosed 抢购窗口在支付期间到期时,
+// 已锁单的订单仍必须支付完(放弃等于白锁)。
+func TestPayFinishesAfterWindowClosed(t *testing.T) {
+ speedUpPeace(t)
+
+ var payCalls int32
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/create":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-1","expireSeconds":300}}`))
+ case "/pay":
+ if atomic.AddInt32(&payCalls, 1) == 1 {
+ time.Sleep(300 * time.Millisecond) // 让抢购窗口在这笔支付期间过期
+ _, _ = w.Write([]byte(`{"error":1644400014,"msg":"服务当前不可用"}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
+ case "/bind":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
+ }
+ })
+
+ ctl := newRunControl(scheduler, time.Now(), time.Now().Add(50*time.Millisecond), 4)
+ out := scheduler.runPeaceLockPay(peaceAttempt("a"), ctl)
+ if !out.result.OK {
+ t.Fatalf("窗口到期后仍应把已锁订单支付完, result=%#v", out.result)
+ }
+ if got := atomic.LoadInt32(&payCalls); got != 2 {
+ t.Fatalf("支付请求 = %d, want 2", got)
+ }
+ if !ctl.expired() {
+ t.Fatal("测试前提不成立:抢购窗口应当已经在支付期间过期")
+ }
+}
+
+// TestSlowPayDoesNotBlockOtherAccounts 回归"轮次屏障"缺陷:
+// 一个账号卡在慢支付时,其它账号必须继续按自己的节奏重试。
+func TestSlowPayDoesNotBlockOtherAccounts(t *testing.T) {
+ speedUpPeace(t)
+
+ var bCreate, aPay int32
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/create":
+ if requestAccount(t, r) == "a" {
+ _, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-a","expireSeconds":300}}`))
+ return
+ }
+ atomic.AddInt32(&bCreate, 1)
+ _, _ = w.Write([]byte(`{"error":1644400007,"msg":"活动火爆,请稍后再试"}`))
+ case "/pay":
+ // 账号 a 的支付一直在途,旧实现会让整轮在这里干等。
+ time.Sleep(200 * time.Millisecond)
+ if atomic.AddInt32(&aPay, 1) >= 3 {
+ _, _ = w.Write([]byte(`{"error":1644430009,"msg":"订单已过期"}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{"error":1644430001,"msg":"订单操作进行中,请稍后再试"}`))
+ }
+ })
+
+ start := time.Now()
+ ctl := newRunControl(scheduler, start, start.Add(2500*time.Millisecond), 8)
+ settings := runSettings{
+ concurrency: 8,
+ seckillMode: types.SeckillModeAggressive,
+ retryStages: []types.RetryStage{{MaxElapsed: 75, Interval: 50}},
+ }
+
+ var wg sync.WaitGroup
+ for _, name := range []string{"a", "b"} {
+ wg.Add(1)
+ go func(name string) {
+ defer wg.Done()
+ scheduler.runAccountLoop(peaceAttempt(name), settings, ctl)
+ }(name)
+ }
+ wg.Wait()
+
+ if got := atomic.LoadInt32(&bCreate); got < 4 {
+ t.Fatalf("账号 b 在窗口内只尝试了 %d 次;慢支付账号不应阻塞其它账号", got)
+ }
+ if !ctl.expired() {
+ t.Fatal("账号 b 应当一直重试到窗口结束")
+ }
+}
+
+// TestStockOutKeepsProbingToWindowEnd 售罄不是终态:别人锁单后支付失败或锁单过期,
+// 占用的库存会回流,所以窗口内必须继续探测,只在结束时把结论分类上报。
+func TestStockOutKeepsProbingToWindowEnd(t *testing.T) {
+ speedUpPeace(t)
+
+ var creates int32
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.URL.Path == "/create" {
+ atomic.AddInt32(&creates, 1)
+ _, _ = w.Write([]byte(`{"error":1644402023,"msg":"商品本次活动库存已耗完"}`))
+ }
+ })
+
+ start := time.Now()
+ ctl := newRunControl(scheduler, start, start.Add(1200*time.Millisecond), 4)
+ settings := runSettings{
+ concurrency: 4,
+ seckillMode: types.SeckillModeAggressive,
+ retryStages: []types.RetryStage{{MaxElapsed: 75, Interval: 20}},
+ }
+ out := scheduler.runAccountLoop(peaceAttempt("a"), settings, ctl)
+
+ if out.kind != outcomeRejected {
+ t.Fatalf("kind = %q, want %q (reason=%s)", out.kind, outcomeRejected, out.reason)
+ }
+ if !strings.Contains(out.reason, "售罄") || !strings.Contains(out.reason, "库存已耗完") {
+ t.Fatalf("结束原因应说明商品始终售罄: %q", out.reason)
+ }
+ if !ctl.expired() {
+ t.Fatal("售罄后应当一直探测到窗口结束")
+ }
+ if got := atomic.LoadInt32(&creates); got < 3 {
+ t.Fatalf("锁单请求 = %d,售罄后仍应持续探测", got)
+ }
+}
+
+// TestStockOutAfterThrottleStillReported 复现 PC1 实例3 的形态:
+// 先被限流("活动火爆"),随后服务端改口"库存已耗完"。两种情况都不能提前停止,
+// 结束时要能区分出"商品始终售罄"而不是含糊的失败。
+func TestStockOutAfterThrottleStillReported(t *testing.T) {
+ speedUpPeace(t)
+
+ var creates int32
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.URL.Path != "/create" {
+ return
+ }
+ if n := atomic.AddInt32(&creates, 1); n <= 2 {
+ _, _ = w.Write([]byte(`{"error":1644400007,"msg":"活动火爆,请稍后再试"}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{"error":1644402023,"msg":"商品本次活动库存已耗完"}`))
+ })
+
+ start := time.Now()
+ ctl := newRunControl(scheduler, start, start.Add(1200*time.Millisecond), 4)
+ settings := runSettings{
+ concurrency: 4,
+ seckillMode: types.SeckillModeAggressive,
+ retryStages: []types.RetryStage{{MaxElapsed: 75, Interval: 20}},
+ }
+ out := scheduler.runAccountLoop(peaceAttempt("a"), settings, ctl)
+
+ if out.kind != outcomeRejected {
+ t.Fatalf("kind = %q, want %q (reason=%s)", out.kind, outcomeRejected, out.reason)
+ }
+ if !strings.Contains(out.reason, "售罄") || !strings.Contains(out.reason, "库存已耗完") {
+ t.Fatalf("结束原因应说明商品始终售罄: %q", out.reason)
+ }
+ if got := int(atomic.LoadInt32(&creates)); got <= 2 {
+ t.Fatalf("锁单请求 = %d,跨入售罄后仍应持续探测", got)
+ }
+}
+
+// TestThrottledKeepsRetryingToWindowEnd 限流(活动火爆)不能触发"商品不可抢":
+// 日志证明同一秒内同实例别的账号还能锁单成功,说明那只是请求级限流。
+// 阈值故意调到 2,账号仍必须重试到窗口结束,并在结束原因里说明是被限流。
+func TestThrottledKeepsRetryingToWindowEnd(t *testing.T) {
+ speedUpPeace(t)
+
+ var creates int32
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.URL.Path == "/create" {
+ atomic.AddInt32(&creates, 1)
+ _, _ = w.Write([]byte(`{"error":1644400007,"msg":"活动火爆,请稍后再试"}`))
+ }
+ })
+
+ start := time.Now()
+ ctl := newRunControl(scheduler, start, start.Add(1200*time.Millisecond), 4)
+ settings := runSettings{
+ concurrency: 4,
+ seckillMode: types.SeckillModeAggressive,
+ retryStages: []types.RetryStage{{MaxElapsed: 75, Interval: 20}},
+ }
+ out := scheduler.runAccountLoop(peaceAttempt("a"), settings, ctl)
+
+ if out.kind != outcomeThrottled {
+ t.Fatalf("kind = %q, want %q (reason=%s)", out.kind, outcomeThrottled, out.reason)
+ }
+ if !strings.Contains(out.reason, "被限流") {
+ t.Fatalf("结束原因应说明被限流: %q", out.reason)
+ }
+ if got := atomic.LoadInt32(&creates); got < 3 {
+ t.Fatalf("锁单请求 = %d,限流后仍应持续探测到窗口结束", got)
+ }
+ if !ctl.expired() {
+ t.Fatal("被限流的账号应当一直重试到窗口结束")
+ }
+}
+
+// ========== RunNow 端到端 ==========
+
+func TestRunNowEndToEnd(t *testing.T) {
+ speedUpPeace(t)
+ seckillMaxDuration = 1500 * time.Millisecond
+
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ name := ""
+ if r.URL.Path != "/bind" {
+ name = requestAccount(t, r)
+ }
+ switch r.URL.Path {
+ case "/create":
+ if name == "winner" {
+ _, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-w","expireSeconds":300}}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{"error":1644402023,"msg":"商品本次活动库存已耗完"}`))
+ case "/pay":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
+ case "/bind":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
+ }
+ })
+ scheduler.paths = writeAccountFile(t, "winner", "loser")
+ scheduler.accounts = NewAccountStore(scheduler.paths.AccountFile)
+ scheduler.LoadAccounts()
+ scheduler.UpdateProducts([]types.Product{{CommodityID: "goods-1", CommodityName: "测试商品"}})
+
+ start := time.Now()
+ scheduler.RunNow(map[string]string{"winner": "goods-1", "loser": "goods-1"})
+ elapsed := time.Since(start)
+
+ // 售罄的账号会一直探测到窗口结束,但必须及时收手、不能拖长。
+ if elapsed > seckillMaxDuration+2*time.Second {
+ t.Fatalf("窗口结束后应及时收手,实际耗时 %v(窗口 %v)", elapsed, seckillMaxDuration)
+ }
+
+ results := scheduler.Results()
+ winnerKey := resultKey(types.ExchangeAttempt{Account: types.Account{Name: "winner"}, Index: 1})
+ if got := results[winnerKey]; !strings.HasPrefix(got, "成功|") {
+ t.Fatalf("winner 结果 = %q, want 成功", got)
+ }
+ loserKey := resultKey(types.ExchangeAttempt{Account: types.Account{Name: "loser"}})
+ if got := results[loserKey]; !strings.Contains(got, "售罄") || !strings.Contains(got, "库存已耗完") {
+ t.Fatalf("loser 结果 = %q, want 售罄判定", got)
+ }
+}
+
+// TestBuildTasksOnePerAccount 每个账号只生成一个任务。
+// 历史实现按"包数"为同一账号重复生成任务,把预热缓存日志打了两遍。
+func TestBuildTasksOnePerAccount(t *testing.T) {
+ scheduler := New(nil, writeAccountFile(t, "a", "b", "c"), nil, 8, 2, nil, types.SeckillModeAggressive)
+ scheduler.LoadAccounts()
+ scheduler.UpdateProducts([]types.Product{{CommodityID: "goods-1", CommodityName: "测试商品"}})
+
+ tasks := scheduler.buildTasks(map[string]string{"a": "goods-1", "b": "goods-1"})
+ if len(tasks) != 2 {
+ t.Fatalf("buildTasks 生成 %d 个任务, want 2", len(tasks))
+ }
+ for _, task := range tasks {
+ if task.Index != 1 {
+ t.Fatalf("每个账号应只有一个任务, got 第%d次 for %s", task.Index, task.Account.Name)
+ }
+ }
+}
+
+// TestRunNowMatchesPCLogShape 用 2026-10-03 日志里实测到的形态做端到端回归:
+// 锁单全部秒成,支付需要 3 步(服务端 3 秒超时 → 订单操作进行中 → 成功)。
+// 修复前每一单要 68 秒才确认,12 个账号会拖到 1 分多钟。
+func TestRunNowMatchesPCLogShape(t *testing.T) {
+ speedUpPeace(t)
+ seckillMaxDuration = 30 * time.Second
+
+ const accounts = 12
+ names := make([]string, 0, accounts)
+ accountProducts := make(map[string]string, accounts)
+ for i := 0; i < accounts; i++ {
+ name := fmt.Sprintf("acct-%02d", i)
+ names = append(names, name)
+ accountProducts[name] = "goods-1"
+ }
+
+ var mu sync.Mutex
+ payTries := make(map[string]int, accounts)
+ scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/create":
+ name := requestAccount(t, r)
+ _, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-` + name + `","expireSeconds":300}}`))
+ case "/pay":
+ name := requestAccount(t, r)
+ mu.Lock()
+ payTries[name]++
+ try := payTries[name]
+ mu.Unlock()
+ switch try {
+ case 1:
+ // 斗鱼服务端自己的 3 秒超时(日志里耗时恒为 3.05s)
+ time.Sleep(150 * time.Millisecond)
+ _, _ = w.Write([]byte(`{"error":1644400002,"msg":"context deadline exceeded"}`))
+ case 2:
+ _, _ = w.Write([]byte(`{"error":1644430001,"msg":"订单操作进行中,请稍后再试"}`))
+ default:
+ _, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
+ }
+ case "/bind":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
+ }
+ })
+ scheduler.paths = writeAccountFile(t, names...)
+ scheduler.accounts = NewAccountStore(scheduler.paths.AccountFile)
+ scheduler.LoadAccounts()
+ scheduler.UpdateProducts([]types.Product{{CommodityID: "goods-1", CommodityName: "测试商品"}})
+
+ start := time.Now()
+ scheduler.RunNow(accountProducts)
+ elapsed := time.Since(start)
+
+ results := scheduler.Results()
+ if got := len(results); got != accounts {
+ t.Fatalf("结果条目 = %d, want %d(每个账号只应占一条)", got, accounts)
+ }
+ for _, name := range names {
+ key := resultKey(types.ExchangeAttempt{Account: types.Account{Name: name}})
+ if got := results[key]; !strings.HasPrefix(got, "成功|") {
+ t.Fatalf("%s 结果 = %q, want 成功", name, got)
+ }
+ mu.Lock()
+ tries := payTries[name]
+ mu.Unlock()
+ if tries != 3 {
+ t.Fatalf("%s 支付请求 = %d, want 3", name, tries)
+ }
+ }
+ // 12 个账号并发,每个约 (0.15s + 0.3s + 0.6s) ≈ 1.1s;留足余量也不要 68 秒。
+ if elapsed > 8*time.Second {
+ t.Fatalf("端到端耗时 %v;支付退避又变长了(历史 bug 是每单 68 秒)", elapsed)
+ }
+}
+
+// ========== 和平小店链路(改动面之外,保留回归覆盖) ==========
+
func TestNormalizeXiaodianRun(t *testing.T) {
attempts, delay := normalizeXiaodianRun(0, 0, 4)
if attempts != 4 || delay != xiaodianDefaultDelay {
@@ -189,12 +801,11 @@ func TestClassifyRetryStop(t *testing.T) {
if !acc || prod || reason != "账号不满足精英手册条件" {
t.Errorf("classifyRetryStop(精英手册) = (%v,%v,%q), want (true,false,'账号不满足精英手册条件')", acc, prod, reason)
}
- // 库存耗尽不停止
+ // 库存耗尽不立即停止:由"连续被拒上限"决定何时收手
acc, prod, reason = classifyRetryStop("商品本次活动库存已耗完")
if acc || prod || reason != "" {
t.Errorf("classifyRetryStop(库存耗完) = (%v,%v,%q), want (false,false,'')", acc, prod, reason)
}
- // 普通错误不停止
acc, prod, reason = classifyRetryStop("网络超时")
if acc || prod || reason != "" {
t.Errorf("classifyRetryStop(普通错误) = (%v,%v,%q), want (false,false,'')", acc, prod, reason)
@@ -258,43 +869,3 @@ func TestFilterCooldownAttempts(t *testing.T) {
t.Errorf("nextReady should be after now")
}
}
-
-func TestPickRoundAttempts(t *testing.T) {
- // 账号 A 有 3 个包,账号 B 有 3 个包
- attempts := []types.ExchangeAttempt{
- {Account: types.Account{Name: "A"}, Index: 1},
- {Account: types.Account{Name: "A"}, Index: 2},
- {Account: types.Account{Name: "A"}, Index: 3},
- {Account: types.Account{Name: "B"}, Index: 1},
- {Account: types.Account{Name: "B"}, Index: 2},
- {Account: types.Account{Name: "B"}, Index: 3},
- }
- // 第 1 轮每账号选第 1 包
- out := pickRoundAttempts(attempts, 1, 3)
- if len(out) != 2 {
- t.Fatalf("pickRoundAttempts round1 length = %d, want 2", len(out))
- }
- for _, a := range out {
- if a.Index != 1 {
- t.Errorf("round1 should pick index 1, got %d for %s", a.Index, a.Account.Name)
- }
- }
- // 第 2 轮每账号选第 2 包
- out = pickRoundAttempts(attempts, 2, 3)
- for _, a := range out {
- if a.Index != 2 {
- t.Errorf("round2 should pick index 2, got %d for %s", a.Index, a.Account.Name)
- }
- }
- // attemptsPerAccount=0 时回退取各自首个
- out = pickRoundAttempts(attempts, 1, 0)
- for _, a := range out {
- if a.Index != 1 {
- t.Errorf("round with 0 perAccount should pick index 1, got %d", a.Index)
- }
- }
- // 空输入
- if out := pickRoundAttempts(nil, 1, 3); out != nil {
- t.Errorf("pickRoundAttempts(nil) = %v, want nil", out)
- }
-}
diff --git a/internal/types/types.go b/internal/types/types.go
index 3f1804a..27f76a4 100644
--- a/internal/types/types.go
+++ b/internal/types/types.go
@@ -12,9 +12,9 @@ type Config struct {
TraceEnabled bool `json:"traceEnabled"`
TraceMaxBody int `json:"traceMaxBody"`
LogLevel string `json:"logLevel"` // debug / info / warn / error
- Concurrency int `json:"concurrency"` // 抢购并发数
- Attempts int `json:"attempts"` // 单号重试次数
- RetryStages []RetryStage `json:"retryStages"` // 重试间隔阶段配置
+ Concurrency int `json:"concurrency"` // 同时在途的请求数上限
+ Attempts int `json:"attempts"` // 仅和平小店:抢购次数缺省值(手册链路按窗口重试,不受此项影响)
+ RetryStages []RetryStage `json:"retryStages"` // 精英/电竞手册的锁单重试节奏(按经过秒数分档)
SeckillMode SeckillMode `json:"seckillMode"` // 抢购模式:0=标准, 1=激进
XiaodianPageSize int `json:"xiaodianPageSize"`
}
diff --git a/main.go b/main.go
index c6c03c6..0fc0bc2 100644
--- a/main.go
+++ b/main.go
@@ -2,24 +2,33 @@ package main
import (
"embed"
+ "fmt"
"log"
"os"
"path/filepath"
+ "time"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
+ "github.com/wailsapp/wails/v2/pkg/options/windows"
"douyu_go/internal/app"
+ "douyu_go/internal/config"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
- a := app.New()
+ // 实例槽位必须在 wails.Run 之前占用:WebView2 的用户数据目录要按实例区分,
+ // 而它只能在创建窗口之前设定。占位结果随后注入 App,避免重复占位。
+ lease, leaseErr := config.AcquireInstancePaths()
+ writeStartupTrace(fmt.Sprintf("启动: 实例=%s 数据目录=%s", lease.Name(), lease.Paths().DataDir), leaseErr)
- err := wails.Run(&options.App{
+ application := app.New(lease, leaseErr)
+
+ appOptions := &options.App{
Title: "斗鱼抢购系统",
Width: 1600,
Height: 960,
@@ -28,20 +37,51 @@ func main() {
AssetServer: &assetserver.Options{
Assets: assets,
},
- OnStartup: a.Startup,
- OnShutdown: a.Shutdown,
+ OnStartup: application.Startup,
+ OnShutdown: application.Shutdown,
Bind: []interface{}{
- a,
+ application,
},
- })
- if err != nil {
- log.Printf("启动失败: %v", err)
- // GUI 应用无控制台,把启动错误写入崩溃日志文件,便于用户事后排查。
- if dir, derr := os.UserCacheDir(); derr == nil {
- path := filepath.Join(dir, "douyu-go", "startup_error.log")
- _ = os.MkdirAll(filepath.Dir(path), 0o755)
- _ = os.WriteFile(path, []byte("斗鱼抢购系统启动失败:\n"+err.Error()+"\n"), 0o644)
+ }
+ // 每个实例一个独立的 WebView2 用户数据目录。Wails 默认用
+ // %APPDATA%\<可执行文件名>,所有实例共用,而 WebView2 的一个数据目录
+ // 只能有一个浏览器进程,第二个实例会偶发创建环境失败。
+ if webviewDir := lease.WebviewUserDataDir(); webviewDir != "" {
+ if err := os.MkdirAll(webviewDir, 0o700); err != nil {
+ writeStartupTrace("WebView2 用户数据目录创建失败: "+err.Error(), nil)
+ } else {
+ appOptions.Windows = &windows.Options{WebviewUserDataPath: webviewDir}
}
+ }
+
+ if err := wails.Run(appOptions); err != nil {
+ log.Printf("启动失败: %v", err)
+ writeStartupTrace("启动失败: "+err.Error(), nil)
os.Exit(1)
}
}
+
+// writeStartupTrace 把每次启动/失败追加到 <用户缓存目录>/douyu-go/startup.log。
+//
+// GUI 应用没有控制台,而 WebView2 环境创建失败时 OnStartup 根本不会执行、
+// 应用自己的日志文件也不会生成——那时只有这份文件能说明"窗口为什么没起来"。
+func writeStartupTrace(line string, cause error) {
+ dir, err := os.UserCacheDir()
+ if err != nil || dir == "" {
+ return
+ }
+ path := filepath.Join(dir, "douyu-go", "startup.log")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return
+ }
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err != nil {
+ return
+ }
+ defer func() { _ = file.Close() }()
+
+ if cause != nil {
+ line = fmt.Sprintf("%s (原因: %v)", line, cause)
+ }
+ _, _ = fmt.Fprintf(file, "%s pid=%d %s\n", time.Now().Format("2006-01-02 15:04:05.000"), os.Getpid(), line)
+}
diff --git a/wails.json b/wails.json
index 9b394a8..54f3725 100644
--- a/wails.json
+++ b/wails.json
@@ -2,7 +2,7 @@
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "douyu_go",
"outputfilename": "douyu_go",
- "version": "0.2.2",
+ "version": "0.3.0",
"frontend:dir": "frontend",
"frontend:install": "npm install",
"frontend:build": "npm run build",
@@ -15,6 +15,6 @@
"email": "yml@example.com"
},
"info": {
- "productVersion": "0.2.2"
+ "productVersion": "0.3.0"
}
}