From 46c06180a59a0a87f15696286cad9d46d8be136d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 3 Oct 2026 19:09:19 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=94=81=E5=8D=95=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E9=87=8D=E8=AF=95=E5=86=85=E6=A0=B8=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=9A=E5=AE=9E=E4=BE=8B=E5=90=AF=E5=8A=A8=EF=BC=8C?= =?UTF-8?q?=E5=8F=91=E5=B8=830.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抢购内核改为"每账号独立重试循环",移除轮次屏障:旧实现在 3 个中签账号卡住 60 秒支付退避时,另外 31 个账号空转 39 秒,还出现过"某一轮只跑 1 个账号、其余整轮没被尝试" - 锁单退避改为读取配置的"重试间隔策略"(同一账号连续失败成倍放大,封顶 5 秒), 删掉硬编码 30 秒退避与"只重试一次" - 锁单失败不再提前停止:限流(活动火爆/网络繁忙)继续重试;售罄也继续降频探测 (别人锁单后支付失败或锁单过期,库存会回流),只在窗口结束时按售罄/限流/普通失败分类上报 - 支付重试按斗鱼错误码决策:1644430001/1644400002/1644400014 视为"订单已受理、结果未定" 并快重试(300ms 起),取代按文案把"订单操作进行中"误判成频控的 60 秒退避; 不再设次数上限,只受 120 秒预算与锁单过期约束,超出预算如实报"支付结果未知" - 已锁订单在 75 秒窗口结束后仍会支付完,不再受窗口约束 - 多实例:为每个实例分配独占的 WebView2 用户数据目录。Wails 默认所有实例共用 %APPDATA%\<可执行文件名>,而一个数据目录只能有一个浏览器进程,导致偶发开不出新实例、 必须全部关掉重开;实例占位因此提前到 wails.Run 之前完成 - 多实例:占位失败不再静默退回默认目录(会让两个进程共享 accounts.txt/config.json、 同秒启动还会写同一个日志文件),改为进程唯一的临时实例目录并记录降级原因 - 多实例:单个槽位锁被瞬时占用(杀软/索引器)时短暂重试,仍失败则继续试下一个槽位 而不是整个分配失败;日志文件改为独占创建,同秒启动自动追加 -2/-3 后缀 - 新增 %LOCALAPPDATA%\douyu-go\startup.log,记录每次启动与失败原因, 便于排查"窗口起不来"这类连应用日志都不会生成的问题 - 修复预热缓存日志每个账号打两遍、"账号尝试"计数误导(改为真实锁单/支付/直接兑换次数) - 修复前端把含"成功"字样的失败文案显示成抢到(改为只认"成功|"前缀) - 修复 jitterDuration 在基准小于 10ms 时 rand.Int63n(0) 会 panic 的隐患 - 设置页文案与作用域同步:重试间隔策略明确作用于锁单重试、"尝试包数"明确仅用于和平小店 --- .../src/components/modal/SettingsModal.vue | 17 +- frontend/src/composables/useLogs.ts | 5 +- internal/app/app.go | 24 +- internal/client/client.go | 29 +- internal/config/config.go | 3 +- internal/config/instance.go | 57 +- internal/config/instance_test.go | 58 ++ internal/constants/constants.go | 2 +- internal/logger/logger.go | 27 +- internal/logger/logger_test.go | 26 + internal/seckill/attempt.go | 533 +++++++++--- internal/seckill/run_control.go | 125 +++ internal/seckill/schedule_timer.go | 5 +- internal/seckill/scheduler.go | 591 ++++++------- internal/seckill/scheduler_test.go | 777 +++++++++++++++--- internal/types/types.go | 6 +- main.go | 66 +- wails.json | 4 +- 18 files changed, 1791 insertions(+), 564 deletions(-) create mode 100644 internal/seckill/run_control.go 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 秒) +
💡 抢购策略说明
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" } }