- 抢购内核改为"每账号独立重试循环",移除轮次屏障:旧实现在 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 的隐患 - 设置页文案与作用域同步:重试间隔策略明确作用于锁单重试、"尝试包数"明确仅用于和平小店
233 lines
7.0 KiB
Go
233 lines
7.0 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang/freetype/truetype"
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"douyu_go/internal/activity"
|
|
"douyu_go/internal/client"
|
|
"douyu_go/internal/config"
|
|
"douyu_go/internal/constants"
|
|
imgpkg "douyu_go/internal/image"
|
|
"douyu_go/internal/logger"
|
|
"douyu_go/internal/seckill"
|
|
"douyu_go/internal/trace"
|
|
"douyu_go/internal/types"
|
|
)
|
|
|
|
// App 应用主结构体,所有导出方法可被前端通过 Wails 绑定调用。
|
|
// 绑定方法按领域拆分到 app_*.go 文件。
|
|
type App struct {
|
|
ctx context.Context
|
|
|
|
clientMu sync.RWMutex
|
|
client *client.DouyuClient
|
|
scheduler *seckill.Scheduler
|
|
logStore *logger.MemoryLogStore
|
|
|
|
traceMu sync.RWMutex
|
|
trace *trace.Recorder
|
|
paths types.AppPaths
|
|
font *truetype.Font
|
|
instanceLease *config.InstanceLease
|
|
instanceErr error
|
|
instanceName string
|
|
|
|
cfgMu sync.RWMutex
|
|
cfg types.Config
|
|
|
|
productsMu sync.RWMutex
|
|
products []types.Product
|
|
productsAt time.Time
|
|
|
|
// accountsMu 串行化内部账号库修改,避免并发导入、编辑或删除互相覆盖。
|
|
accountsMu sync.Mutex
|
|
|
|
closeLog func()
|
|
closeTrace func()
|
|
}
|
|
|
|
// getClient 返回当前 client 指针快照(读锁保护)。
|
|
func (a *App) getClient() *client.DouyuClient {
|
|
a.clientMu.RLock()
|
|
defer a.clientMu.RUnlock()
|
|
return a.client
|
|
}
|
|
|
|
// getTrace 返回当前 trace 记录器快照(读锁保护)。
|
|
func (a *App) getTrace() *trace.Recorder {
|
|
a.traceMu.RLock()
|
|
defer a.traceMu.RUnlock()
|
|
return a.trace
|
|
}
|
|
|
|
// getCfg 返回当前配置的深拷贝(读锁保护)。
|
|
func (a *App) getCfg() types.Config {
|
|
a.cfgMu.RLock()
|
|
defer a.cfgMu.RUnlock()
|
|
return cloneConfig(a.cfg)
|
|
}
|
|
|
|
// setClient 替换 client(写锁保护)。
|
|
func (a *App) setClient(c *client.DouyuClient) {
|
|
a.clientMu.Lock()
|
|
a.client = c
|
|
a.clientMu.Unlock()
|
|
}
|
|
|
|
// setCfg 替换配置(写锁保护)。
|
|
func (a *App) setCfg(c types.Config) {
|
|
a.cfgMu.Lock()
|
|
a.cfg = c
|
|
a.cfgMu.Unlock()
|
|
}
|
|
|
|
// replaceTrace 根据配置替换 trace 记录器,旧记录器在锁外关闭,避免阻塞读路径。
|
|
func (a *App) replaceTrace(cfg types.Config) {
|
|
traceCfg := types.TraceConfig{
|
|
Enabled: cfg.TraceEnabled,
|
|
MaxBodyBytes: cfg.TraceMaxBody,
|
|
}
|
|
tr, closeTrace := trace.InitTrace(a.paths.LogDir, traceCfg)
|
|
|
|
a.traceMu.Lock()
|
|
oldClose := a.closeTrace
|
|
a.trace = tr
|
|
a.closeTrace = closeTrace
|
|
a.traceMu.Unlock()
|
|
|
|
if oldClose != nil {
|
|
oldClose()
|
|
}
|
|
}
|
|
|
|
// cloneConfig 深拷贝配置,避免 RetryStages 切片共享引用。
|
|
func cloneConfig(c types.Config) types.Config {
|
|
cp := c
|
|
if c.RetryStages != nil {
|
|
cp.RetryStages = make([]types.RetryStage, len(c.RetryStages))
|
|
copy(cp.RetryStages, c.RetryStages)
|
|
}
|
|
return cp
|
|
}
|
|
|
|
// 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 := 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 {
|
|
a.paths = lease.Paths()
|
|
a.instanceLease = lease
|
|
a.instanceName = lease.Name()
|
|
}
|
|
configMigrated, configMigrationErr := config.MigrateLegacyFile(a.paths.ConfigFile, a.paths.LegacyConfigFile)
|
|
a.logStore = logger.NewMemoryLogStore(800)
|
|
a.closeLog = logger.InitLogging(a.logStore, a.paths.LogDir)
|
|
if instanceErr != nil {
|
|
// 数据目录仍然是本实例独享的(临时实例),但隔离能力已降级,必须让人看见。
|
|
logger.LogEvent("系统", "warn", "实例隔离已降级(%s,数据目录仍独立): %v", a.instanceName, instanceErr)
|
|
} else {
|
|
logger.LogEvent("系统", "success", "已启动%s,数据目录: %s", a.instanceName, a.paths.DataDir)
|
|
}
|
|
if configMigrationErr != nil {
|
|
logger.LogEvent("配置", "warn", "旧配置迁移失败: %s: %v", a.paths.LegacyConfigFile, configMigrationErr)
|
|
} else if configMigrated {
|
|
logger.LogEvent("配置", "success", "已迁移旧配置到应用内部目录: %s", a.paths.ConfigFile)
|
|
}
|
|
|
|
// 加载配置并应用运行时设置
|
|
a.cfg = config.LoadConfig(a.paths.ConfigFile)
|
|
logger.SetMinLevel(a.cfg.LogLevel)
|
|
|
|
a.replaceTrace(a.cfg)
|
|
|
|
// 解析当前活动:配置中的 activity ID 决定使用哪套接口参数
|
|
act := activity.Get(a.cfg.Activity)
|
|
if act == nil {
|
|
act = activity.Default()
|
|
logger.LogEvent("活动", "warn", "配置中的活动 %q 未识别,已回退到默认活动 %s", a.cfg.Activity, act.DisplayName)
|
|
}
|
|
a.client = client.New(act, a.cfg.TimeAPIURL, a.getTrace())
|
|
a.font = imgpkg.LoadFont(a.paths.FontFiles)
|
|
a.scheduler = seckill.New(a.client, a.paths, a.font, a.cfg.Concurrency, a.cfg.Attempts, a.cfg.RetryStages, a.cfg.SeckillMode)
|
|
a.scheduler.SetStateChangeCallback(func(state string) {
|
|
runtime.EventsEmit(a.ctx, seckillStateEvent, state)
|
|
})
|
|
a.scheduler.LoadAccounts()
|
|
|
|
if err := os.MkdirAll(a.paths.SuccessDir, 0755); err != nil {
|
|
logger.LogEvent("图片", "warn", "成功图片目录创建失败,图片保存功能可能不可用: %v", err)
|
|
}
|
|
|
|
if sample, err := a.client.ServerTimeSample(); err == nil {
|
|
logger.SetDouyuOffset(sample.Offset)
|
|
logger.LogEvent("时间", "success", "服务端时间获取成功: %s", sample.EstimatedServerNow().In(constants.CST).Format("2006-01-02 15:04:05.000"))
|
|
} else {
|
|
logger.LogEvent("时间", "error", "服务端时间获取失败: %v", err)
|
|
}
|
|
|
|
logger.LogEvent("系统", "success", "应用启动完成 | 账号: %d", a.scheduler.AccountCount())
|
|
}
|
|
|
|
// Shutdown Wails 应用关闭时调用。
|
|
func (a *App) Shutdown(ctx context.Context) {
|
|
if a.scheduler != nil {
|
|
a.scheduler.Close()
|
|
}
|
|
a.traceMu.Lock()
|
|
closeTrace := a.closeTrace
|
|
a.closeTrace = nil
|
|
a.trace = nil
|
|
a.traceMu.Unlock()
|
|
if closeTrace != nil {
|
|
closeTrace()
|
|
}
|
|
if a.closeLog != nil {
|
|
a.closeLog()
|
|
}
|
|
if a.instanceLease != nil {
|
|
a.instanceLease.Release()
|
|
a.instanceLease = nil
|
|
}
|
|
}
|
|
|
|
// GetVersion 返回应用版本号。
|
|
func (a *App) GetVersion() string {
|
|
if constants.BuildTime != "unknown" {
|
|
return constants.Version + " (" + constants.BuildTime + ")"
|
|
}
|
|
return constants.Version
|
|
}
|
|
|
|
// GetInstanceName 返回当前自动分配的本地实例名称。
|
|
func (a *App) GetInstanceName() string {
|
|
if a.instanceName == "" {
|
|
return "默认实例"
|
|
}
|
|
return a.instanceName
|
|
}
|