Files
dygou_go/app.go
T
2026-07-29 10:36:44 +08:00

773 lines
22 KiB
Go

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"
)
// 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()
}
const exchangeLookupConcurrency = 8
type exchangeLookupResult struct {
Account types.Account
Records []client.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.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()}
}
matched := filterExchangeRecords(records)
return map[string]any{
"success": true,
"records": matched,
"count": len(matched),
}
}
// VerifyAllExchange 批量查询兑换记录,使用有限并发减少等待时间。
func (a *App) VerifyAllExchange() map[string]any {
accounts := a.scheduler.AccountSnapshot()
results := make(map[string]any)
lookups := a.lookupExchangeRecords("复验", accounts)
for _, lookup := range lookups {
if lookup.EmptyCookie {
results[lookup.Account.Name] = map[string]any{
"success": false,
"error": "Cookie 为空",
}
continue
}
if lookup.Err != nil {
results[lookup.Account.Name] = map[string]any{
"success": false,
"error": "查询失败: " + lookup.Err.Error(),
}
continue
}
matched := filterExchangeRecords(lookup.Records)
results[lookup.Account.Name] = map[string]any{
"success": true,
"records": matched,
"count": len(matched),
}
}
return results
}
// VerifyAndExport 复验并导出结果。
func (a *App) VerifyAndExport(commodityID string) map[string]any {
accounts := a.scheduler.AccountSnapshot()
results := make(map[string]any)
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] = map[string]any{
"success": false,
"error": "Cookie 为空",
}
continue
}
if lookup.Err != nil {
missAccounts = append(missAccounts, lookup.Account.Name)
results[lookup.Account.Name] = map[string]any{
"success": false,
"error": "查询失败: " + lookup.Err.Error(),
}
continue
}
// 检查是否抢到了指定商品
hit := hasCommodityRecord(lookup.Records, commodityID, 0, 0)
if hit {
hitAccounts = append(hitAccounts, lookup.Account.Name)
results[lookup.Account.Name] = map[string]any{
"success": true,
"hit": true,
}
} else {
missAccounts = append(missAccounts, lookup.Account.Name)
results[lookup.Account.Name] = map[string]any{
"success": true,
"hit": false,
}
}
}
return map[string]any{
"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.client.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 []client.ExchangeRecord) []client.ExchangeRecord {
matched := make([]client.ExchangeRecord, 0, len(records))
for _, r := range records {
if r.CommodityID != "" {
matched = append(matched, r)
}
}
return matched
}
func hasCommodityRecord(records []client.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
}
// ExportAccounts 导出指定账号到文件。
func (a *App) ExportAccounts(accountNames []string, filename string) map[string]any {
if len(accountNames) == 0 {
return map[string]any{"success": false, "error": "没有要导出的账号"}
}
// 读取原账号文件
accountFile := a.scheduler.AccountFilePath()
lines, err := os.ReadFile(accountFile)
if err != nil {
return map[string]any{"success": false, "error": "读取账号文件失败: " + err.Error()}
}
// 解析账号行
nameSet := make(map[string]bool)
for _, name := range accountNames {
nameSet[name] = true
}
var matchedLines []string
scanner := bufio.NewScanner(strings.NewReader(string(lines)))
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
parts := strings.SplitN(trimmed, "----", 3)
if len(parts) >= 1 {
name := strings.TrimSpace(parts[0])
if nameSet[name] {
matchedLines = append(matchedLines, line)
}
}
}
if len(matchedLines) == 0 {
return map[string]any{"success": false, "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 map[string]any{"success": false, "error": "写入文件失败: " + err.Error()}
}
logger.LogEvent("导出", "success", "已导出 %d 个账号到: %s", len(matchedLines), exportPath)
return map[string]any{
"success": true,
"count": len(matchedLines),
"filePath": exportPath,
}
}
// ExportAccountsWithProduct 导出指定账号到文件,自动使用中文商品名和时间戳命名。
func (a *App) ExportAccountsWithProduct(accountNames []string, commodityID, commodityName string) map[string]any {
// 生成文件名: user_已抢到_商品中文名_20260626_150405.txt
now := time.Now()
timestamp := now.Format("20060102_150405")
// 清理商品名中的特殊字符
safeName := strings.ReplaceAll(commodityName, "/", "_")
safeName = strings.ReplaceAll(safeName, "\\", "_")
safeName = strings.ReplaceAll(safeName, ":", "_")
safeName = strings.ReplaceAll(safeName, "*", "_")
safeName = strings.ReplaceAll(safeName, "?", "_")
safeName = strings.ReplaceAll(safeName, "\"", "_")
safeName = strings.ReplaceAll(safeName, "<", "_")
safeName = strings.ReplaceAll(safeName, ">", "_")
safeName = strings.ReplaceAll(safeName, "|", "_")
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) map[string]any {
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 map[string]any
if len(hitAccounts) > 0 {
exportResult = a.ExportAccountsWithProduct(hitAccounts, commodityID, commodityName)
}
// 更新原文件,只保留未抢到的
accountFile := a.scheduler.AccountFilePath()
lines, err := os.ReadFile(accountFile)
if err != nil {
return map[string]any{"success": false, "error": "读取账号文件失败: " + err.Error()}
}
missSet := make(map[string]bool)
for _, name := range missAccounts {
missSet[name] = true
}
var keepLines []string
scanner := bufio.NewScanner(strings.NewReader(string(lines)))
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
keepLines = append(keepLines, line)
continue
}
parts := strings.SplitN(trimmed, "----", 3)
if len(parts) >= 1 {
name := strings.TrimSpace(parts[0])
if missSet[name] {
keepLines = append(keepLines, line)
}
}
}
// 写回原文件
newContent := strings.Join(keepLines, "\n") + "\n"
if err := os.WriteFile(accountFile, []byte(newContent), 0644); err != nil {
return map[string]any{"success": false, "error": "更新账号文件失败: " + err.Error()}
}
// 重新加载账号
a.scheduler.LoadAccounts()
logger.LogEvent("整理", "success", "整理完成: 已抢到=%d, 未抢到=%d", len(hitAccounts), len(missAccounts))
return map[string]any{
"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 {
act := a.client.Activity
return types.ActivityInfo{ID: act.ID, DisplayName: act.DisplayName}
}
// SwitchActivity 切换当前活动并持久化到配置;切换后重建客户端与时间基准。
func (a *App) SwitchActivity(id string) map[string]any {
act := activity.Get(id)
if act == nil {
return map[string]any{"success": false, "error": "未知活动: " + id}
}
a.cfg.Activity = act.ID
a.cfg.TimeAPIURL = act.TimeAPIURL
if err := config.SaveConfig(a.paths.ConfigFile, a.cfg); err != nil {
logger.LogEvent("活动", "error", "切换活动配置保存失败: %v", err)
return map[string]any{"success": false, "error": "配置保存失败: " + err.Error()}
}
a.client = client.New(act, a.cfg.TimeAPIURL, a.trace)
if sample, err := a.client.ServerTimeSample(); err == nil {
logger.SetDouyuOffset(sample.Offset)
}
logger.LogEvent("活动", "success", "已切换到活动: %s", act.DisplayName)
return map[string]any{"success": true, "activity": act.ID, "displayName": act.DisplayName}
}
// 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, 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 map[string]any{"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
}
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 strings.Join(masked, "; ")
}
func splitCookie(cookie string) []string {
var result []string
for _, part := range strings.Split(cookie, ";") {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func splitCookiePair(pair string) (string, string) {
idx := strings.Index(pair, "=")
if idx < 0 {
return strings.TrimSpace(pair), ""
}
return strings.TrimSpace(pair[:idx]), pair[idx+1:]
}
func maskValue(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "****"
}
runes := []rune(value)
if len(runes) <= 8 {
return "****"
}
return string(runes[:4]) + "****" + string(runes[len(runes)-4:])
}