Files
yml2213 46c06180a5 重构锁单支付重试内核并修复多实例启动,发布0.3.0
- 抢购内核改为"每账号独立重试循环",移除轮次屏障:旧实现在 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 的隐患
- 设置页文案与作用域同步:重试间隔策略明确作用于锁单重试、"尝试包数"明确仅用于和平小店
2026-10-03 19:09:19 +08:00

147 lines
4.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
"douyu_go/internal/constants"
"douyu_go/internal/types"
)
const maxInstanceSlots = 256
// 单次实例分配里允许的"意外错误"重试预算与间隔。
// 杀毒软件/索引器可能在锁文件刚创建时短暂独占它,稍后重试同一个槽位即可,
// 不该直接跳到别的槽位或让整个分配失败。
const (
slotLockRetryBudget = 12
slotLockRetryWait = 80 * time.Millisecond
)
// InstanceLease 代表当前进程持有的实例槽位;必须在程序退出时释放。
type InstanceLease struct {
paths types.AppPaths
name string
lock instanceLock
releaseOnce sync.Once
}
// 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 fallbackLease(baseDir, fmt.Errorf("创建实例锁目录失败: %w", err))
}
var scanErr error
scanRetries := slotLockRetryBudget
for slot := 1; slot <= maxInstanceSlots; 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 {
// 单个槽位异常不该让整个分配失败:记录原因后继续试下一个槽位。
if scanErr == nil {
scanErr = fmt.Errorf("锁定%s失败: %w", instanceName(slot), err)
}
continue
}
if !acquired {
continue
}
dataDir := baseDir
if slot > 1 {
dataDir = filepath.Join(instanceRoot, fmt.Sprintf("instance-%d", slot))
}
paths := appPathsForDataDir(dataDir, slot == 1)
if slot > 1 && isWailsDev() && os.Getenv("DOUYU_LOG_DIR") == "" {
paths.LogDir = resolvePath(filepath.Join(constants.DefaultLogDir, "instances", fmt.Sprintf("instance-%d", slot)))
}
return &InstanceLease{
paths: paths,
name: instanceName(slot),
lock: lock,
}, nil
}
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 返回当前实例独立的数据路径。
func (l *InstanceLease) Paths() types.AppPaths {
if l == nil {
return types.AppPaths{}
}
return l.paths
}
// Name 返回面向用户的实例名称。
func (l *InstanceLease) Name() string {
if l == nil {
return ""
}
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 {
return
}
l.releaseOnce.Do(func() {
_ = l.lock.Close()
})
}
func instanceName(slot int) string {
if slot <= 1 {
return "默认实例"
}
return fmt.Sprintf("实例 %d", slot)
}
func instanceLockFileName(slot int) string {
if slot <= 1 {
return "default.lock"
}
return fmt.Sprintf("instance-%d.lock", slot)
}