package main import ( "context" "fmt" "os" "sync" "time" "github.com/golang/freetype/truetype" "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 绑定调用。 type App struct { ctx context.Context client *client.DouyuClient scheduler *seckill.Scheduler logStore *logger.MemoryLogStore trace *trace.Recorder paths types.AppPaths font *truetype.Font cfg types.Config productsMu sync.RWMutex products []types.Product productsAt time.Time closeLog func() closeTrace func() } // NewApp 创建 App 实例。 func NewApp() *App { return &App{} } // startup Wails 应用启动时调用。 func (a *App) startup(ctx context.Context) { a.ctx = ctx a.paths = config.DefaultAppPaths() a.logStore = logger.NewMemoryLogStore(800) a.closeLog = logger.InitLogging(a.logStore, a.paths.LogDir) // 加载配置并应用运行时设置 a.cfg = config.LoadConfig(a.paths.ConfigFile) logger.SetMinLevel(a.cfg.LogLevel) traceCfg := types.TraceConfig{ Enabled: a.cfg.TraceEnabled, MaxBodyBytes: a.cfg.TraceMaxBody, } tr, closeTrace := trace.InitTrace(a.paths.LogDir, traceCfg) a.trace = tr a.closeTrace = closeTrace a.client = client.New(a.cfg.TimeAPIURL, a.trace) a.font = imgpkg.LoadFont(a.paths.FontFiles) a.scheduler = seckill.New(a.client, a.paths, a.font, a.cfg.Concurrency, a.cfg.Attempts) a.scheduler.LoadAccounts() if err := os.MkdirAll(a.paths.SuccessDir, 0755); err != nil { logger.LogEvent("图片", "warn", "成功图片目录创建失败,图片保存功能可能不可用: %v", err) } if serverTime, err := a.client.ServerTime(); err == nil { logger.LogEvent("时间", "success", "服务端时间获取成功: %s", serverTime.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.closeTrace != nil { a.closeTrace() } if a.closeLog != nil { a.closeLog() } } // ==================== 前端可调用方法 ==================== // LoadProducts 刷新商品列表。refresh=true 时从斗鱼实时拉取。 func (a *App) LoadProducts(refresh bool) types.ProductPayload { if refresh { products, err := a.client.Products() if err != nil { logger.LogEvent("商品", "error", "商品刷新失败: %v", err) return types.ProductPayload{Success: false} } fetchedAt := time.Now() a.setProducts(products, fetchedAt) logger.LogEvent("商品", "success", "商品刷新完成: %d 个,刷新时间=%s", len(products), fetchedAt.In(constants.CST).Format("15:04:05.000")) return a.productPayload("live") } return a.productPayload("cache") } // GetAccounts 返回账号列表。 func (a *App) GetAccounts() []types.AccountView { accounts := a.scheduler.AccountSnapshot() list := make([]types.AccountView, 0, len(accounts)) for i, account := range accounts { list = append(list, types.AccountView{ Index: i, Name: account.Name, Cookie: maskCookie(account.Cookie), }) } return list } // ReloadAccounts 重新加载账号文件。 func (a *App) ReloadAccounts() map[string]any { a.scheduler.LoadAccounts() return map[string]any{"success": true, "count": a.scheduler.AccountCount()} } // StartSeckill 立即启动抢购。 func (a *App) StartSeckill(accountProducts map[string]string) map[string]any { if len(accountProducts) == 0 { return map[string]any{"success": false, "error": "请选择账号和商品"} } if !a.scheduler.TryStartRun() { return map[string]any{"success": false, "error": "抢购任务正在运行,请勿重复提交"} } go a.scheduler.RunNow(cloneMap(accountProducts)) return map[string]any{"success": true, "msg": "抢购已启动"} } // SetSchedule 设置定时抢购。 func (a *App) SetSchedule(req types.SeckillRequest) (map[string]any, error) { return a.scheduler.ScheduleRun(req) } // CancelSchedule 取消未触发的定时抢购。 func (a *App) CancelSchedule() map[string]any { stopped := a.scheduler.CancelSchedule() return map[string]any{"success": true, "stopped": stopped} } // GetResults 返回当前抢购结果。 func (a *App) GetResults() map[string]string { return a.scheduler.Results() } // GetLogs 返回后端内存日志,支持增量获取。 func (a *App) GetLogs(since int64) map[string]any { entries := a.logStore.Since(since) nextSince := since if len(entries) > 0 { nextSince = entries[len(entries)-1].ID } return map[string]any{ "success": true, "logs": entries, "since": nextSince, "file": a.logStore.FilePath(), } } // GetTime 返回服务端时间和本地时间。 func (a *App) GetTime() map[string]any { serverTime, err := a.client.ServerTime() source := "currentTime" if err != nil { source = "localFallback" logger.LogEvent("时间", "warn", "时间接口降级: %v", err) } return map[string]any{ "serverTime": serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"), "localTime": time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"), "source": source, } } // ClearResults 清空抢购结果。 func (a *App) ClearResults() map[string]any { a.scheduler.ClearResults() return map[string]any{"success": true} } // QueryScore 查询单个账号的积分。 func (a *App) QueryScore(accountName string) map[string]any { if accountName == "" { return map[string]any{"score": "缺少账号名"} } cookie, ok := a.scheduler.AccountCookieByName(accountName) if !ok || cookie == "" { return map[string]any{"score": fmt.Sprintf("账号不存在或 Cookie 为空: %s", accountName)} } score, err := a.client.Score(cookie) if err != nil { return map[string]any{"score": "查询失败: " + err.Error()} } return map[string]any{"score": score} } // VerifyExchange 查询兑换记录,用于复验抢购结果。 func (a *App) VerifyExchange(accountName string) map[string]any { if accountName == "" { return map[string]any{"success": false, "error": "缺少账号名"} } cookie, ok := a.scheduler.AccountCookieByName(accountName) if !ok || cookie == "" { return map[string]any{"success": false, "error": fmt.Sprintf("账号不存在或 Cookie 为空: %s", accountName)} } records, err := a.client.ExchangeList(cookie) if err != nil { return map[string]any{"success": false, "error": "查询失败: " + err.Error()} } // 过滤当前活动的记录 var matched []client.ExchangeRecord for _, r := range records { if r.CommodityID != "" { matched = append(matched, r) } } return map[string]any{ "success": true, "records": matched, "count": len(matched), } } // GetVersion 返回应用版本号。 func (a *App) GetVersion() string { return constants.Version } // GetSettings 返回当前配置。 func (a *App) GetSettings() types.Config { return a.cfg } // SaveSettings 保存配置并运行时生效。 func (a *App) SaveSettings(cfg types.Config) map[string]any { // 校验 if cfg.Concurrency <= 0 { cfg.Concurrency = constants.DefaultConcurrency } if cfg.Attempts <= 0 { cfg.Attempts = constants.DefaultAttempts } if cfg.LogLevel == "" { cfg.LogLevel = "info" } // 写入文件 if err := config.SaveConfig(a.paths.ConfigFile, cfg); err != nil { logger.LogEvent("配置", "error", "配置保存失败: %v", err) return map[string]any{"success": false, "error": err.Error()} } // 运行时生效 a.cfg = cfg logger.SetMinLevel(cfg.LogLevel) a.scheduler.UpdateSettings(cfg.Concurrency, cfg.Attempts) logger.LogEvent("配置", "success", "配置已保存并生效: 日志等级=%s 并发=%d 重试=%d trace=%v", cfg.LogLevel, cfg.Concurrency, cfg.Attempts, cfg.TraceEnabled) return map[string]any{"success": true} } // ==================== 内部辅助方法 ==================== func (a *App) setProducts(products []types.Product, fetchedAt time.Time) { a.productsMu.Lock() defer a.productsMu.Unlock() a.products = products a.productsAt = fetchedAt } func (a *App) productPayload(source string) types.ProductPayload { a.productsMu.RLock() defer a.productsMu.RUnlock() payload := types.ProductPayload{ Success: true, Source: source, Products: a.products, Count: len(a.products), } if !a.productsAt.IsZero() { age := time.Since(a.productsAt) if age < 0 { age = 0 } payload.FetchedAt = a.productsAt.In(constants.CST).Format("2006-01-02 15:04:05.000") payload.AgeSecond = int(age.Seconds()) payload.Stale = age > constants.ProductCacheTTL } else { payload.Stale = true } return payload } func cloneMap(in map[string]string) map[string]string { out := make(map[string]string, len(in)) for k, v := range in { out[k] = v } return out } func maskCookie(cookie string) string { pairs := splitCookie(cookie) masked := make([]string, 0, 3) total := 0 for _, pair := range pairs { key, value := splitCookiePair(pair) if key == "" { continue } total++ if len(masked) >= 3 { continue } masked = append(masked, key+"="+maskValue(value)) } if total == 0 { return "" } if total > len(masked) { masked = append(masked, fmt.Sprintf("...共%d项", total)) } return joinStrings(masked, "; ") } func splitCookie(cookie string) []string { var result []string for _, part := range splitString(cookie, ";") { trimmed := trimSpace(part) if trimmed != "" { result = append(result, trimmed) } } return result } func splitCookiePair(pair string) (string, string) { idx := indexOf(pair, "=") if idx < 0 { return trimSpace(pair), "" } return trimSpace(pair[:idx]), pair[idx+1:] } func maskValue(value string) string { value = trimSpace(value) if value == "" { return "****" } runes := []rune(value) if len(runes) <= 8 { return "****" } return string(runes[:4]) + "****" + string(runes[len(runes)-4:]) } // 简单的字符串工具函数,避免引入额外包 func splitString(s, sep string) []string { var result []string start := 0 for i := 0; i+len(sep) <= len(s); { if s[i:i+len(sep)] == sep { result = append(result, s[start:i]) start = i + len(sep) i = start } else { i++ } } result = append(result, s[start:]) return result } func trimSpace(s string) string { for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') { s = s[1:] } for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { s = s[:len(s)-1] } return s } func indexOf(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 } func joinStrings(parts []string, sep string) string { if len(parts) == 0 { return "" } result := parts[0] for _, p := range parts[1:] { result += sep + p } return result }