重构: 应用代码移入 internal/app 包,根目录只留入口
根目录原 7 个 app*.go 平铺,现收进 internal/app/ 子包: - package main → package app - NewApp → New(导出) - startup/shutdown → Startup/Shutdown(导出,供 main.go 引用) - main.go 改为薄入口,仅导入 internal/app 并装配 Wails 前端同步更新(Wails 绑定路径 go/main → go/app): - window.go.main.App → window.go.app.App(10 处) - env.d.ts 类型声明路径 wailsjs/go/main/App → wailsjs/go/app/App 根目录 Go 文件从 7 个减至 1 个(main.go),元文件更清晰
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"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"
|
||||
)
|
||||
|
||||
// App 应用主结构体,所有导出方法可被前端通过 Wails 绑定调用。
|
||||
// 绑定方法按领域拆分到 app_*.go 文件。
|
||||
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()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// New 创建 App 实例。
|
||||
func New() *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()
|
||||
}
|
||||
}
|
||||
|
||||
// GetVersion 返回应用版本号。
|
||||
func (a *App) GetVersion() string {
|
||||
if constants.BuildTime != "unknown" {
|
||||
return constants.Version + " (" + constants.BuildTime + ")"
|
||||
}
|
||||
return constants.Version
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"douyu_go/internal/activity"
|
||||
"douyu_go/internal/client"
|
||||
"douyu_go/internal/config"
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/types"
|
||||
)
|
||||
|
||||
// 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}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/types"
|
||||
"douyu_go/internal/utils"
|
||||
)
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/types"
|
||||
"douyu_go/internal/utils"
|
||||
)
|
||||
|
||||
// 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()}
|
||||
}
|
||||
|
||||
// ==================== 内部辅助方法 ====================
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/types"
|
||||
"douyu_go/internal/utils"
|
||||
)
|
||||
|
||||
// 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}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/types"
|
||||
)
|
||||
|
||||
const exchangeLookupConcurrency = 8
|
||||
|
||||
type exchangeLookupResult struct {
|
||||
Account types.Account
|
||||
Records []types.ExchangeRecord
|
||||
Err error
|
||||
EmptyCookie bool
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user