package main import ( "bufio" "context" "fmt" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/golang/freetype/truetype" "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" "douyu_go/internal/utils" ) // App 应用主结构体,所有导出方法可被前端通过 Wails 绑定调用。 type App struct { ctx context.Context clientMu sync.RWMutex client *client.DouyuClient scheduler *seckill.Scheduler logStore *logger.MemoryLogStore trace *trace.Recorder paths types.AppPaths font *truetype.Font cfgMu sync.RWMutex cfg types.Config productsMu sync.RWMutex products []types.Product productsAt time.Time closeLog func() closeTrace func() } const exchangeLookupConcurrency = 8 // getClient 返回当前 client 指针快照(读锁保护)。 func (a *App) getClient() *client.DouyuClient { a.clientMu.RLock() defer a.clientMu.RUnlock() return a.client } // 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() } // 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 } type exchangeLookupResult struct { Account types.Account Records []types.ExchangeRecord Err error EmptyCookie bool } // 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 // 解析当前活动:配置中的 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.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.cfg.RetryStages, a.cfg.SeckillMode) 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() } 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.getClient().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: utils.MaskCookie(account.Cookie), }) } return list } // ReloadAccounts 重新加载账号文件。 func (a *App) ReloadAccounts() types.ReloadAccountsResult { a.scheduler.LoadAccounts() return types.ReloadAccountsResult{Success: true, Count: a.scheduler.AccountCount()} } // StartSeckill 立即启动抢购。 func (a *App) StartSeckill(accountProducts map[string]string) types.MessageResult { if len(accountProducts) == 0 { return types.MessageResult{Error: "请选择账号和商品"} } if !a.scheduler.TryStartRun() { return types.MessageResult{Error: "抢购任务正在运行,请勿重复提交"} } go a.scheduler.RunNow(utils.CloneStringMap(accountProducts)) return types.MessageResult{Success: true, Msg: "抢购已启动"} } // SetSchedule 设置定时抢购。 func (a *App) SetSchedule(req types.SeckillRequest) (types.ScheduleResult, error) { return a.scheduler.ScheduleRun(req) } // CancelSchedule 取消未触发的定时抢购。 func (a *App) CancelSchedule() types.CancelScheduleResult { stopped := a.scheduler.CancelSchedule() return types.CancelScheduleResult{Success: true, Stopped: stopped} } // GetResults 返回当前抢购结果。 func (a *App) GetResults() map[string]string { return a.scheduler.Results() } // GetLogs 返回后端内存日志,支持增量获取。 func (a *App) GetLogs(since int64) types.LogsResult { entries := a.logStore.Since(since) nextSince := since if len(entries) > 0 { nextSince = entries[len(entries)-1].ID } return types.LogsResult{ Success: true, Logs: entries, Since: nextSince, File: a.logStore.FilePath(), } } // GetTime 返回服务端时间和本地时间。 func (a *App) GetTime() types.TimeResult { serverTime, err := a.getClient().ServerTime() source := "currentTime" if err != nil { source = "localFallback" logger.LogEvent("时间", "warn", "时间接口降级: %v", err) } return types.TimeResult{ 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() types.SimpleResult { a.scheduler.ClearResults() return types.SimpleResult{Success: true} } // QueryScore 查询单个账号的积分。 func (a *App) QueryScore(accountName string) types.ScoreResult { if accountName == "" { return types.ScoreResult{Score: "缺少账号名"} } cookie, ok := a.scheduler.AccountCookieByName(accountName) if !ok || cookie == "" { return types.ScoreResult{Score: fmt.Sprintf("账号不存在或 Cookie 为空: %s", accountName)} } score, err := a.getClient().Score(cookie) if err != nil { return types.ScoreResult{Score: "查询失败: " + err.Error()} } return types.ScoreResult{Score: strconv.Itoa(score)} } // VerifyExchange 查询兑换记录,用于复验抢购结果。 func (a *App) VerifyExchange(accountName string) types.ExchangeRecordsResult { if accountName == "" { return types.ExchangeRecordsResult{Error: "缺少账号名"} } cookie, ok := a.scheduler.AccountCookieByName(accountName) if !ok || cookie == "" { return types.ExchangeRecordsResult{Error: fmt.Sprintf("账号不存在或 Cookie 为空: %s", accountName)} } records, err := a.getClient().ExchangeList(cookie) if err != nil { return types.ExchangeRecordsResult{Error: "查询失败: " + err.Error()} } matched := filterExchangeRecords(records) return types.ExchangeRecordsResult{ Success: true, Records: matched, Count: len(matched), } } // VerifyAllExchange 批量查询兑换记录,使用有限并发减少等待时间。 func (a *App) VerifyAllExchange() types.VerifyAllExchangeResult { accounts := a.scheduler.AccountSnapshot() results := make(map[string]types.AccountExchangeResult) lookups := a.lookupExchangeRecords("复验", accounts) for _, lookup := range lookups { if lookup.EmptyCookie { results[lookup.Account.Name] = types.AccountExchangeResult{Error: "Cookie 为空"} continue } if lookup.Err != nil { results[lookup.Account.Name] = types.AccountExchangeResult{Error: "查询失败: " + lookup.Err.Error()} continue } matched := filterExchangeRecords(lookup.Records) results[lookup.Account.Name] = types.AccountExchangeResult{ Success: true, Records: matched, Count: len(matched), } } return types.VerifyAllExchangeResult{Results: results} } // VerifyAndExport 复验并导出结果。 func (a *App) VerifyAndExport(commodityID string) types.VerifyAndExportResult { accounts := a.scheduler.AccountSnapshot() results := make(map[string]types.AccountHitResult) hitAccounts := make([]string, 0) missAccounts := make([]string, 0) lookups := a.lookupExchangeRecords("复验", accounts) for _, lookup := range lookups { if lookup.EmptyCookie { missAccounts = append(missAccounts, lookup.Account.Name) results[lookup.Account.Name] = types.AccountHitResult{Error: "Cookie 为空"} continue } if lookup.Err != nil { missAccounts = append(missAccounts, lookup.Account.Name) results[lookup.Account.Name] = types.AccountHitResult{Error: "查询失败: " + lookup.Err.Error()} continue } // 检查是否抢到了指定商品 hit := hasCommodityRecord(lookup.Records, commodityID, 0, 0) if hit { hitAccounts = append(hitAccounts, lookup.Account.Name) results[lookup.Account.Name] = types.AccountHitResult{Success: true, Hit: true} } else { missAccounts = append(missAccounts, lookup.Account.Name) results[lookup.Account.Name] = types.AccountHitResult{Success: true} } } return types.VerifyAndExportResult{ Success: true, HitCount: len(hitAccounts), MissCount: len(missAccounts), HitAccounts: hitAccounts, MissAccounts: missAccounts, Details: results, } } func (a *App) lookupExchangeRecords(category string, accounts []types.Account) []exchangeLookupResult { results := make([]exchangeLookupResult, len(accounts)) if len(accounts) == 0 { return results } workerCount := exchangeLookupConcurrency if len(accounts) < workerCount { workerCount = len(accounts) } start := time.Now() logger.LogEvent(category, "info", "兑换记录批量查询开始: 账号=%d 并发=%d", len(accounts), workerCount) jobs := make(chan int) var wg sync.WaitGroup for worker := 0; worker < workerCount; worker++ { wg.Add(1) go func() { defer wg.Done() for idx := range jobs { account := accounts[idx] lookup := exchangeLookupResult{Account: account} if strings.TrimSpace(account.Cookie) == "" { lookup.EmptyCookie = true results[idx] = lookup continue } records, err := a.getClient().ExchangeList(account.Cookie) lookup.Records = records lookup.Err = err results[idx] = lookup } }() } for idx := range accounts { jobs <- idx } close(jobs) wg.Wait() errors := 0 empty := 0 for _, result := range results { if result.EmptyCookie { empty++ } if result.Err != nil { errors++ } } level := "success" if errors > 0 { level = "warn" } logger.LogEvent(category, level, "兑换记录批量查询完成: 账号=%d 空CK=%d 失败=%d 耗时=%s", len(accounts), empty, errors, time.Since(start).Round(time.Millisecond)) return results } func filterExchangeRecords(records []types.ExchangeRecord) []types.ExchangeRecord { matched := make([]types.ExchangeRecord, 0, len(records)) for _, r := range records { if r.CommodityID != "" { matched = append(matched, r) } } return matched } func hasCommodityRecord(records []types.ExchangeRecord, commodityID string, startTime, endTime int64) bool { if commodityID == "" { return false } for _, r := range records { if r.CommodityID != commodityID { continue } if startTime == 0 && endTime == 0 { return true } recordTime, err := strconv.ParseInt(r.Time, 10, 64) if err != nil { continue } if startTime > 0 && recordTime < startTime { continue } if endTime > 0 && recordTime > endTime { continue } return true } return false } // accountLine 账号文件的一行解析结果。 type accountLine struct { Raw string // 原始行文本 Name string // 解析出的账号名(注释/空行为空) Comment bool // 是否为注释或空行 } // parseAccountFile 读取账号文件并按行解析账号名。 func parseAccountFile(path string) ([]accountLine, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var lines []accountLine scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { raw := scanner.Text() trimmed := strings.TrimSpace(raw) if trimmed == "" || strings.HasPrefix(trimmed, "#") { lines = append(lines, accountLine{Raw: raw, Comment: true}) continue } name := "" if parts := strings.SplitN(trimmed, "----", 3); len(parts) >= 1 { name = strings.TrimSpace(parts[0]) } lines = append(lines, accountLine{Raw: raw, Name: name}) } return lines, scanner.Err() } // ExportAccounts 导出指定账号到文件。 func (a *App) ExportAccounts(accountNames []string, filename string) types.ExportAccountsResult { if len(accountNames) == 0 { return types.ExportAccountsResult{Error: "没有要导出的账号"} } accountFile := a.scheduler.AccountFilePath() lines, err := parseAccountFile(accountFile) if err != nil { return types.ExportAccountsResult{Error: "读取账号文件失败: " + err.Error()} } nameSet := make(map[string]bool) for _, name := range accountNames { nameSet[name] = true } var matchedLines []string for _, line := range lines { if line.Comment { continue } if nameSet[line.Name] { matchedLines = append(matchedLines, line.Raw) } } if len(matchedLines) == 0 { return types.ExportAccountsResult{Error: "没有找到匹配的账号行"} } // 生成导出文件路径 exportPath := filepath.Join(filepath.Dir(accountFile), filename) content := strings.Join(matchedLines, "\n") + "\n" if err := os.WriteFile(exportPath, []byte(content), 0644); err != nil { return types.ExportAccountsResult{Error: "写入文件失败: " + err.Error()} } logger.LogEvent("导出", "success", "已导出 %d 个账号到: %s", len(matchedLines), exportPath) return types.ExportAccountsResult{ Success: true, Count: len(matchedLines), FilePath: exportPath, } } // ExportAccountsWithProduct 导出指定账号到文件,自动使用中文商品名和时间戳命名。 func (a *App) ExportAccountsWithProduct(accountNames []string, commodityID, commodityName string) types.ExportAccountsResult { // 生成文件名: user_已抢到_商品中文名_20260626_150405.txt now := time.Now() timestamp := now.Format("20060102_150405") // 清理商品名中的特殊字符(复用 utils.SafeFilename,避免与 utils 包重复实现) safeName := utils.SafeFilename(commodityName) filename := fmt.Sprintf("user_已抢到_%s_%s.txt", safeName, timestamp) return a.ExportAccounts(accountNames, filename) } // OrganizeAccounts 整理账号,把已抢到指定商品的账号分离出来。 func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, endTime int64) types.OrganizeAccountsResult { accounts := a.scheduler.AccountSnapshot() hitAccounts := make([]string, 0) missAccounts := make([]string, 0) lookups := a.lookupExchangeRecords("整理", accounts) for _, lookup := range lookups { if lookup.EmptyCookie || lookup.Err != nil { missAccounts = append(missAccounts, lookup.Account.Name) continue } if hasCommodityRecord(lookup.Records, commodityID, startTime, endTime) { hitAccounts = append(hitAccounts, lookup.Account.Name) } else { missAccounts = append(missAccounts, lookup.Account.Name) } } // 导出已抢到的账号 var exportResult types.ExportAccountsResult if len(hitAccounts) > 0 { exportResult = a.ExportAccountsWithProduct(hitAccounts, commodityID, commodityName) } // 更新原文件,只保留未抢到的 accountFile := a.scheduler.AccountFilePath() lines, err := parseAccountFile(accountFile) if err != nil { return types.OrganizeAccountsResult{Error: "读取账号文件失败: " + err.Error()} } missSet := make(map[string]bool) for _, name := range missAccounts { missSet[name] = true } var keepLines []string for _, line := range lines { if line.Comment { keepLines = append(keepLines, line.Raw) continue } if missSet[line.Name] { keepLines = append(keepLines, line.Raw) } } // 写回原文件 newContent := strings.Join(keepLines, "\n") + "\n" if err := os.WriteFile(accountFile, []byte(newContent), 0644); err != nil { return types.OrganizeAccountsResult{Error: "更新账号文件失败: " + err.Error()} } // 重新加载账号 a.scheduler.LoadAccounts() logger.LogEvent("整理", "success", "整理完成: 已抢到=%d, 未抢到=%d", len(hitAccounts), len(missAccounts)) return types.OrganizeAccountsResult{ Success: true, HitCount: len(hitAccounts), MissCount: len(missAccounts), HitAccounts: hitAccounts, MissAccounts: missAccounts, ExportResult: exportResult, } } // GetVersion 返回应用版本号。 func (a *App) GetVersion() string { if constants.BuildTime != "unknown" { return constants.Version + " (" + constants.BuildTime + ")" } return constants.Version } // GetActivities 返回所有已注册的手册活动,供前端下拉选择。 func (a *App) GetActivities() []types.ActivityInfo { list := activity.List() out := make([]types.ActivityInfo, 0, len(list)) for _, act := range list { out = append(out, types.ActivityInfo{ID: act.ID, DisplayName: act.DisplayName}) } return out } // GetCurrentActivity 返回当前生效的活动。 func (a *App) GetCurrentActivity() types.ActivityInfo { cli := a.getClient() act := cli.Activity return types.ActivityInfo{ID: act.ID, DisplayName: act.DisplayName} } // SwitchActivity 切换当前活动并持久化到配置;切换后重建客户端与时间基准。 func (a *App) SwitchActivity(id string) types.SwitchActivityResult { act := activity.Get(id) if act == nil { return types.SwitchActivityResult{Error: "未知活动: " + id} } cfg := a.getCfg() cfg.Activity = act.ID // 仅当用户未自定义时间校准接口时才回退默认值,避免切换活动静默覆盖用户在设置里填的 timeApiUrl。 if cfg.TimeAPIURL == "" { cfg.TimeAPIURL = constants.DefaultTimeAPI } if err := config.SaveConfig(a.paths.ConfigFile, cfg); err != nil { logger.LogEvent("活动", "error", "切换活动配置保存失败: %v", err) return types.SwitchActivityResult{Error: "配置保存失败: " + err.Error()} } a.setCfg(cfg) newClient := client.New(act, cfg.TimeAPIURL, a.trace) a.setClient(newClient) if !a.scheduler.UpdateClient(newClient) { logger.LogEvent("活动", "warn", "抢购任务正在运行,调度器客户端将在下次抢购时生效") } if sample, err := newClient.ServerTimeSample(); err == nil { logger.SetDouyuOffset(sample.Offset) } logger.LogEvent("活动", "success", "已切换到活动: %s", act.DisplayName) return types.SwitchActivityResult{Success: true, Activity: act.ID, DisplayName: act.DisplayName} } // GetSettings 返回当前配置。 func (a *App) GetSettings() types.Config { return a.getCfg() } // SaveSettings 保存配置并运行时生效。 func (a *App) SaveSettings(cfg types.Config) types.SimpleResult { // 校验 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 types.SimpleResult{} } // 运行时生效 a.setCfg(cfg) logger.SetMinLevel(cfg.LogLevel) a.scheduler.UpdateSettings(cfg.Concurrency, cfg.Attempts, cfg.RetryStages, cfg.SeckillMode) logger.LogEvent("配置", "success", "配置已保存并生效: 日志等级=%s 并发=%d 重试=%d 重试阶段=%d 模式=%d trace=%v", cfg.LogLevel, cfg.Concurrency, cfg.Attempts, len(cfg.RetryStages), cfg.SeckillMode, cfg.TraceEnabled) return types.SimpleResult{Success: true} } // ==================== 内部辅助方法 ==================== func (a *App) setProducts(products []types.Product, fetchedAt time.Time) { a.productsMu.Lock() a.products = products a.productsAt = fetchedAt a.productsMu.Unlock() if a.scheduler != nil { a.scheduler.UpdateProducts(products) } } 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 }