Files
dygou_go/internal/app/app.go
T
yml2213 dce9ce2a5a 优化日志系统:网络请求降噪并按接口聚合,补齐双时间说明与实例标识,发布0.3.2
- 逐条网络日志(请求完成 / 接口返回异常 / 响应刷新CSRF已同步)从 info/warn 降为 debug:
  实测一次 6 账号的模拟抢购里 233 条日志有 174 条(74%)属于这类;真实抢购会产生几千条,
  把界面仅 800 条的内存日志冲掉,反而看不到关键信息。HTTP/解析/传输层错误仍为 error 级
- 新增按接口聚合的网络统计,抢购任务结束时输出一行,替代逐条刷屏:
  "接口统计: 锁定兑换商品 ×341 失败341 平均=95ms 最慢=312ms 最后错误=活动火爆,请稍后再试"
- 日志文件开头新增表头:实例名、数据目录、双时间格式说明(本地时间 + D 斗鱼估算时间)、
  并说明"校准完成前偏移为 0,只显示本地时间"。双时间一直存在却没有任何地方解释过,
  多实例日志复制或汇总后也分不清来源
- 界面日志面板:显示日志级别并按级别着色(此前级别只存在于 CSS class,复制出去就丢);
  新增"复制"按钮,按与日志文件一致的单行格式导出(此前直接复制会变成 5 行一条、
  1204 行里其实只有 233 条日志)
- 设置页"日志显示等级"补充说明:Info 起只显示关键日志,需要逐请求细节时切到 Debug
- 新增测试:TestRequestStatsAggregatesByLabel、TestLogFileHeaderDocumentsFormat、
  TestRunNowLogsAggregateInsteadOfPerRequest(断言必须有聚合行、且不得出现逐条请求日志)
2026-10-03 19:33:14 +08:00

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, 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
}