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, a.instanceName, a.paths.DataDir) 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 }