- 抢购内核改为"每账号独立重试循环",移除轮次屏障:旧实现在 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 的隐患 - 设置页文案与作用域同步:重试间隔策略明确作用于锁单重试、"尝试包数"明确仅用于和平小店
147 lines
4.6 KiB
Go
147 lines
4.6 KiB
Go
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)
|
||
}
|