Files
dygou_go/app.go
T
2026-06-24 19:11:02 +08:00

347 lines
8.8 KiB
Go

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
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)
tr, closeTrace := trace.InitTrace(a.paths.LogDir, config.LoadTraceConfig())
a.trace = tr
a.closeTrace = closeTrace
cfg := config.LoadConfig(a.paths.ConfigFile)
a.client = client.New(cfg.TimeAPIURL, a.trace)
a.font = imgpkg.LoadFont(a.paths.FontFiles)
a.scheduler = seckill.New(a.client, a.paths, a.font)
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}
}
// ==================== 内部辅助方法 ====================
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
}