diff --git a/.gitignore b/.gitignore index c554f1f..e8b0755 100644 --- a/.gitignore +++ b/.gitignore @@ -3,11 +3,20 @@ *.iml logs/ success_images/ +build/ + +# Wails 自动生成的前端绑定 +frontend/wailsjs/ # 本地账号、Cookie 与抓包资料 -user.txt +data/user.txt ck 对比.md 软软马卡龙.txt # 本地字体文件 *.ttf + +# Go 编译输出 +*.exe +*.app +.DS_Store diff --git a/README.MD b/README.MD index f20de04..4d041ea 100644 --- a/README.MD +++ b/README.MD @@ -1 +1,58 @@ -DOUYU_TRACE=1 go run . +# 斗鱼抢购系统 + +Wails v2 桌面应用。 + +## 开发运行 + +```bash +wails dev +``` + +## 构建 + +```bash +# 构建当前平台 +wails build + +# 交叉编译 +GOOS=windows GOARCH=amd64 wails build +GOOS=darwin GOARCH=amd64 wails build +GOOS=darwin GOARCH=arm64 wails build +GOOS=linux GOARCH=amd64 wails build +``` + +构建产物在 `build/bin/` 目录。 + +## 项目结构 + +``` +├── main.go # Wails 入口 +├── app.go # App 结构体 + 前端可调用方法 +├── frontend/ +│ └── index.html # 前端页面 +├── internal/ +│ ├── client/ # 斗鱼接口客户端 +│ ├── seckill/ # 抢购调度器 +│ ├── logger/ # 日志系统 +│ ├── trace/ # HTTP 请求追踪 +│ ├── image/ # 成功图片生成 +│ ├── config/ # 配置管理 +│ ├── types/ # 类型定义 +│ ├── constants/ # 常量 +│ └── utils/ # 工具函数 +├── data/ +│ └── user.txt # 账号文件 +└── build/ # 构建输出(gitignore) +``` + +## 环境变量 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `DOUYU_CONFIG` | 配置文件路径 | `config.json` | +| `DOUYU_USERS` | 账号文件路径 | `data/user.txt` | +| `DOUYU_LOG_DIR` | 日志目录 | `logs` | +| `DOUYU_SUCCESS_DIR` | 成功图片目录 | `success_images` | +| `DOUYU_FONT` | 自定义字体路径 | 自动检测系统字体 | +| `DOUYU_TRACE` | 启用 HTTP 追踪 | 关闭 | +| `DOUYU_TRACE_MAX_BODY` | trace 正文最大字节数 | 无限制 | diff --git a/app.go b/app.go new file mode 100644 index 0000000..cdd0c25 --- /dev/null +++ b/app.go @@ -0,0 +1,346 @@ +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 +} diff --git a/ck 对比.md b/ck 对比.md deleted file mode 100644 index 00bc08f..0000000 --- a/ck 对比.md +++ /dev/null @@ -1,27 +0,0 @@ -cvl_csrf_token=62083f188904477e9bc1c5a70537145c; -acf_ccn=8b025ffcbf403d5e0d880ad6327bb3cf; -PHPSESSID=86k3denvoc1j16rl1fsv5l2mc2;acf_auth=5a8eUzBoDc8uyLasCP7LXaO9a2vUK%2FDaNFwYi0MVdUFMpUcMBfsoZfifJZKCQC8WgJuKq7WB78VZrRlRXnZUSt87TZM0tLNdnplqtuL87c7idAqo%2Fal57ls;acf_jwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjk0MjQxNjQ1MSwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImR5Iiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo2OTE2MTY4NSwic3RrIjoiNGZkNDY5ZDE2OWY3NzM0YiIsImV4cCI6MTc4MjMyMjc2OSwiaWF0IjoxNzgxNzE3OTcwLCJrZXkiOiJkeS1qd3QtbWQ1In0.NDc0YzMyNjQ0OGIzNTkxYzkxNGQ1NDk0Y2U0NTk1NTU; -acf_dmjwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQeyJ1aWQiOjk0MjQxNjQ1MSwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImRtIiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo2OTE2MTY4NSwic3RrIjoiNGZkNDY5ZDE2OWY3NzM0YiIsImV4cCI6MTc4MjMyMjc2OwiaWF0IjoxNzgxNzE3OTcwLCJrZXkiOiJkeS1qd3QtbWQ1In0.NWM1ZDgwZjE1N2ZhNTE3NDUxNmQ4ZjljMzI5ODQ0ZjM;dy_auth=29bfEdmmHp50M9Zr2gxQ3%2F2AJD2DAdCK6I%2FoX92U5n13i64R%2B%2FywJZf%2FqG4%2BCJvbUQmFaUSu3dFbJj4%2Be1gU1DpW6ligJYaJLnNuVbmKMeZCtayBmGtns6A; -wan_auth37wan=cf514c616a0fzsvhClbvRbju0sSZb4aDqHQQabpd66aLoVky4bvRRXYDfYMX3iSUNiDuh%2BHt%2B20QXW0f5JLZ6lBtOW8LIIwb31iX8OiZurGB0aztSKA; -acf_uid=942416451; -acf_username=942416451; -acf_nickname=%E7%94%A8%E6%88%B74512197651; -acf_own_room=0; -acf_groupid=1; -acf_phonestatus=1; -acf_avatar=https%3A%2F%2Fapic.douyucdn.cn%2Fupload%2Favatar%2Fdefault%2F18_; -acf_ct=0; -acf_ltkid=69161685; -acf_biz=1; -acf_stk=4fd469d169f7734b; -acf_devid=9869cce23d84da0fd8b1cd20162b3def--用户4512197651-- - - - - - - - - -acf_auth = a81bCldfVSQZYKOqZrnBkKFEjwUMqnbQ0Bgo % 2FlPrLZY9Fs % 2Fg4e9GXx4twrNdZ38vIxfEHDv26xk0KoVcxNHl5nkI4FCpTQO % 2FHQIUMxrQ55Ooft4FmULM4lc; -acf_jwt_token = eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjg5ODQyNDM5OSwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImR5Iiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo0MDExMDcyOSwic3RrIjoiZjc3ODg2ZmFiOWFjZWFmNiIsImV4cCI6MTc4MjM3MDk0NSwiaWF0IjoxNzgxNzY2MTQ1LCJrZXkiOiJkeS1qd3QtbWQ1In0.YjNhN2EwMTUxMjc5ODI3YzQ2YWQ0OWVlZmRiYzFlNDE \ No newline at end of file diff --git a/config.go b/config.go deleted file mode 100644 index 28148de..0000000 --- a/config.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "strconv" -) - -// defaultAppPaths 收集运行时路径,支持环境变量覆盖,方便不同平台和部署方式调整。 -func defaultAppPaths() appPaths { - return appPaths{ - ConfigFile: envOrDefault("DOUYU_CONFIG", defaultConfigFile), - AccountFile: envOrDefault("DOUYU_USERS", defaultAccountFile), - LogDir: envOrDefault("DOUYU_LOG_DIR", defaultLogDir), - SuccessDir: envOrDefault("DOUYU_SUCCESS_DIR", defaultSuccessDir), - FontFiles: collectFontCandidates(), - } -} - -func envOrDefault(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return filepath.Clean(value) - } - return fallback -} - -// loadTraceConfig 读取 trace 开关;默认关闭,避免保存敏感调试数据。 -func loadTraceConfig() TraceConfig { - return TraceConfig{ - Enabled: isTruthyEnv("DOUYU_TRACE"), - MaxBodyBytes: envInt("DOUYU_TRACE_MAX_BODY", 0), - } -} - -func isTruthyEnv(key string) bool { - switch os.Getenv(key) { - case "1", "true", "TRUE", "yes", "YES", "on", "ON": - return true - default: - return false - } -} - -func envInt(key string, fallback int) int { - raw := os.Getenv(key) - if raw == "" { - return fallback - } - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 { - return fallback - } - return value -} - -// loadConfig 读取配置文件;缺失时安静使用内置默认配置。 -func loadConfig(path string) Config { - cfg := Config{TimeAPIURL: defaultTimeAPI} - if path == "" { - path = defaultConfigFile - } - - f, err := os.Open(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - logEvent("配置", "info", "配置加载完成: 内置默认配置") - } else { - logEvent("配置", "warn", "读取 %s 出错,使用内置默认配置: %v", path, err) - } - return cfg - } - defer f.Close() - - if err := json.NewDecoder(f).Decode(&cfg); err != nil { - logEvent("配置", "warn", "解析 %s 失败,使用内置默认配置: %v", path, err) - return Config{TimeAPIURL: defaultTimeAPI} - } - if cfg.TimeAPIURL == "" { - cfg.TimeAPIURL = defaultTimeAPI - } - logEvent("配置", "success", "配置加载完成: %s", path) - return cfg -} diff --git a/constants.go b/constants.go deleted file mode 100644 index c3c1a8d..0000000 --- a/constants.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import "time" - -const ( - manualID = "G4KA4Qnz4LDp7" - roomID = "9263298" - actAlias = "20260120QYOOB" - - baseURL = "https://www.douyu.com" - shopReferer = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=" + roomID - defaultTimeAPI = baseURL + "/wgapi/ordnc/activity/peace/storedetail?manualID=" + manualID + "&commodityID=20260120QYOOB_bag274&rid=" + roomID - storeHomeURL = baseURL + "/wgapi/ordnc/activity/peace/storehome?manualID=" + manualID + "&rid=" + roomID - csrfURL = baseURL + "/japi/carnival/nc/common/generateCsrf" - batchLimitURL = baseURL + "/wgapi/ordnc/activity/peace/batchExchangeLimit" - preExchangeURL = baseURL + "/wgapi/ordnc/activity/peace/preExchange" - exchangeURL = baseURL + "/wgapi/ordnc/activity/peace/exchange" - bindInfoURL = baseURL + "/japi/carnivalApi/v2/tencent/bindInfo?actAlias=" + actAlias - scoreURL = baseURL + "/japi/oms/web/peace/credit/balance" - userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" - defaultAttempts = 1 - defaultConcurrency = 80 - productCacheTTL = 30 * time.Second - - serverAddr = "127.0.0.1:2169" - staticDir = "./static" - defaultConfigFile = "config.json" - defaultAccountFile = "user.txt" - defaultLogDir = "logs" - defaultSuccessDir = "success_images" -) - -var cst = time.FixedZone("CST", 8*3600) diff --git a/font.go b/font.go deleted file mode 100644 index 4cb973d..0000000 --- a/font.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -import ( - "errors" - "os" - "runtime" - - "github.com/golang/freetype" - "github.com/golang/freetype/truetype" - "golang.org/x/image/font/gofont/goregular" -) - -// collectFontCandidates 收集常见中文字体路径,允许用 DOUYU_FONT 指定自定义字体。 -func collectFontCandidates() []string { - candidates := []string{} - if custom := os.Getenv("DOUYU_FONT"); custom != "" { - candidates = append(candidates, custom) - } - candidates = append(candidates, - "Microsoft Yahei.ttf", - "Microsoft YaHei.ttf", - "NotoSansCJKsc-Regular.otf", - "NotoSansCJK-Regular.ttc", - ) - - switch runtime.GOOS { - case "darwin": - candidates = append(candidates, - "/System/Library/Fonts/PingFang.ttc", - "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", - "/System/Library/Fonts/Supplemental/Songti.ttc", - ) - case "windows": - candidates = append(candidates, - `C:\Windows\Fonts\msyh.ttc`, - `C:\Windows\Fonts\simhei.ttf`, - `C:\Windows\Fonts\simsun.ttc`, - ) - default: - candidates = append(candidates, - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - ) - } - return candidates -} - -// loadFont 预加载字体,优先使用本地或系统中文字体,缺失时使用内置字体兜底。 -func (s *appState) loadFont() { - s.fontMu.Lock() - defer s.fontMu.Unlock() - - source, font, err := parseFirstFont(s.paths.FontFiles) - if err != nil { - source = "内置 Go Regular 字体" - font, err = freetype.ParseFont(goregular.TTF) - if err != nil { - logEvent("字体", "error", "内置字体解析失败,成功图片功能将跳过: %v", err) - return - } - logEvent("字体", "warn", "未找到可用系统字体,使用内置字体兜底,中文可能显示不完整") - } - s.font = font - logEvent("字体", "success", "字体加载完成: %s", source) -} - -func parseFirstFont(candidates []string) (string, *truetype.Font, error) { - for _, path := range candidates { - if path == "" { - continue - } - fontBytes, err := os.ReadFile(path) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - logEvent("字体", "warn", "字体读取失败,已跳过: %s: %v", path, err) - } - continue - } - if len(fontBytes) == 0 { - continue - } - font, err := freetype.ParseFont(fontBytes) - if err != nil { - logEvent("字体", "warn", "字体解析失败,已跳过: %s: %v", path, err) - continue - } - return path, font, nil - } - return "", nil, errors.New("没有可用字体") -} diff --git a/static/index.html b/frontend/index.html similarity index 60% rename from static/index.html rename to frontend/index.html index 825118f..f17ad66 100644 --- a/static/index.html +++ b/frontend/index.html @@ -1,183 +1,321 @@ - - - - - - 斗鱼抢购系统 - - - - - -
- - + + + + + + 斗鱼抢购系统 + + + + + +
+ +
- +
库存未刷新
-
-
点击"加载商品"获取列表
-
-
- - -
-
- - - - 账号:0 - 选中商品: 未选 - - - -
- -
- - -- - - - - - (定时触发=目标-提前量) -
- -
- - - - - - - - - - - - -
账号名Cookie商品积分状态
-
- -
-
- 📋 运行日志(自动刷新) - -
-
-
[系统] 就绪,等待操作...
-
-
-
- - - + + init(); + + diff --git a/go.mod b/go.mod index 6ef6525..6e13607 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,41 @@ -module dygou_go +module douyu_go go 1.26.3 require github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 -require golang.org/x/image v0.42.0 +require ( + github.com/wailsapp/wails/v2 v2.12.0 + golang.org/x/image v0.42.0 +) + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/go.sum b/go.sum index 604fc6d..d06a1e6 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,87 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY= golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers.go b/handlers.go deleted file mode 100644 index 13b681b..0000000 --- a/handlers.go +++ /dev/null @@ -1,197 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" -) - -// registerRoutes 注册前端需要的所有接口。 -func (s *appState) registerRoutes(mux *http.ServeMux) { - mux.HandleFunc("/api/products", s.handleProducts) - mux.HandleFunc("/api/load-products", s.handleLoadProducts) - mux.HandleFunc("/api/accounts", s.handleAccounts) - mux.HandleFunc("/api/reload-accounts", s.handleReloadAccounts) - mux.HandleFunc("/api/seckill", s.handleSeckill) - mux.HandleFunc("/api/schedule", s.handleSchedule) - mux.HandleFunc("/api/cancel-schedule", s.handleCancelSchedule) - mux.HandleFunc("/api/results", s.handleResults) - mux.HandleFunc("/api/logs", s.handleLogs) - mux.HandleFunc("/api/time", s.handleTime) - mux.HandleFunc("/api/clear-results", s.handleClearResults) - mux.HandleFunc("/api/query-score", s.handleQueryScore) - mux.Handle("/", http.FileServer(http.Dir(staticDir))) -} - -// handleProducts 返回商品列表;带 refresh=1 时会从斗鱼实时刷新,失败不使用旧库存冒充成功。 -func (s *appState) handleProducts(w http.ResponseWriter, r *http.Request) { - refresh := r.URL.Query().Get("refresh") == "1" - if refresh { - products, err := s.client.Products() - if err != nil { - logEvent("商品", "error", "商品刷新失败: %v", err) - writeError(w, http.StatusBadGateway, err) - return - } - fetchedAt := time.Now() - s.setProducts(products, fetchedAt) - logEvent("商品", "success", "商品刷新完成: %d 个,刷新时间=%s", len(products), fetchedAt.In(cst).Format("15:04:05.000")) - writeJSON(w, s.productPayload("live")) - return - } - writeJSON(w, s.productPayload("cache")) -} - -// handleLoadProducts 兼容旧前端入口;新前端直接调用 /api/products?refresh=1。 -func (s *appState) handleLoadProducts(w http.ResponseWriter, _ *http.Request) { - products, err := s.client.Products() - if err != nil { - logEvent("商品", "error", "商品刷新失败: %v", err) - writeError(w, http.StatusBadGateway, err) - return - } - fetchedAt := time.Now() - s.setProducts(products, fetchedAt) - logEvent("商品", "success", "商品刷新完成: %d 个,刷新时间=%s", len(products), fetchedAt.In(cst).Format("15:04:05.000")) - writeJSON(w, s.productPayload("live")) -} - -// handleAccounts 返回账号列表。 -func (s *appState) handleAccounts(w http.ResponseWriter, _ *http.Request) { - accounts := s.accountSnapshot() - list := make([]AccountView, 0, len(accounts)) - for i, account := range accounts { - list = append(list, AccountView{ - Index: i, - Name: account.Name, - Cookie: maskCookie(account.Cookie), - }) - } - writeJSON(w, list) -} - -// handleReloadAccounts 重新加载账号文件。 -func (s *appState) handleReloadAccounts(w http.ResponseWriter, _ *http.Request) { - s.loadAccounts() - writeJSON(w, map[string]any{"success": true, "count": s.accountCount()}) -} - -// handleSeckill 立即启动抢购。 -func (s *appState) handleSeckill(w http.ResponseWriter, r *http.Request) { - var req SeckillRequest - if err := readJSON(r, &req); err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - if len(req.AccountProducts) == 0 { - writeError(w, http.StatusBadRequest, errors.New("请选择账号和商品")) - return - } - if !s.tryStartRun() { - writeError(w, http.StatusConflict, errors.New("抢购任务正在运行,请勿重复提交")) - return - } - go s.runNow(cloneStringMap(req.AccountProducts)) - writeJSON(w, map[string]any{"success": true, "msg": "抢购已启动"}) -} - -// handleSchedule 设置定时抢购。 -func (s *appState) handleSchedule(w http.ResponseWriter, r *http.Request) { - var req SeckillRequest - if err := readJSON(r, &req); err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - resp, err := s.scheduleRun(req) - if err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - writeJSON(w, resp) -} - -// handleCancelSchedule 取消未触发的定时抢购。 -func (s *appState) handleCancelSchedule(w http.ResponseWriter, _ *http.Request) { - stopped := s.cancelSchedule() - writeJSON(w, map[string]any{"success": true, "stopped": stopped}) -} - -// handleResults 返回当前抢购结果。 -func (s *appState) handleResults(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, s.resultsSnapshot()) -} - -// handleLogs 返回后端内存日志,支持用 since 参数增量获取。 -func (s *appState) handleLogs(w http.ResponseWriter, r *http.Request) { - var since int64 - if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { - parsed, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, fmt.Errorf("since 参数错误: %w", err)) - return - } - since = parsed - } - - entries := s.logs.Since(since) - nextSince := since - if len(entries) > 0 { - nextSince = entries[len(entries)-1].ID - } - writeJSON(w, map[string]any{ - "success": true, - "logs": entries, - "since": nextSince, - "file": s.logs.FilePath(), - }) -} - -// handleClearResults 清空抢购结果。 -func (s *appState) handleClearResults(w http.ResponseWriter, _ *http.Request) { - s.clearResults() - writeJSON(w, map[string]any{"success": true}) -} - -// handleTime 返回服务端时间和本地时间。 -func (s *appState) handleTime(w http.ResponseWriter, _ *http.Request) { - serverTime, err := s.client.ServerTime() - source := "currentTime" - if err != nil { - source = "localFallback" - logEvent("时间", "warn", "时间接口降级: %v", err) - } - writeJSON(w, map[string]any{ - "serverTime": serverTime.In(cst).Format("2006-01-02 15:04:05.000"), - "localTime": time.Now().In(cst).Format("2006-01-02 15:04:05.000"), - "source": source, - }) -} - -// handleQueryScore 查询单个账号的积分。 -func (s *appState) handleQueryScore(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - writeError(w, http.StatusBadRequest, err) - return - } - accountName := strings.TrimSpace(string(body)) - if accountName == "" { - writeError(w, http.StatusBadRequest, errors.New("缺少账号名")) - return - } - cookie, ok := s.accountCookieByName(accountName) - if !ok || strings.TrimSpace(cookie) == "" { - writeError(w, http.StatusBadRequest, fmt.Errorf("账号不存在或 Cookie 为空: %s", accountName)) - return - } - score, err := s.client.Score(cookie) - if err != nil { - writeJSON(w, map[string]any{"score": "查询失败: " + err.Error()}) - return - } - writeJSON(w, map[string]any{"score": score}) -} diff --git a/douyu_client.go b/internal/client/client.go similarity index 71% rename from douyu_client.go rename to internal/client/client.go index 6690783..15fe599 100644 --- a/douyu_client.go +++ b/internal/client/client.go @@ -1,7 +1,6 @@ -package main +package client import ( - "bytes" "encoding/json" "errors" "fmt" @@ -12,14 +11,27 @@ import ( "strconv" "strings" "time" + + "douyu_go/internal/constants" + "douyu_go/internal/logger" + "douyu_go/internal/trace" + "douyu_go/internal/types" + "douyu_go/internal/utils" ) -// NewDouyuClient 创建一个专门请求斗鱼接口的客户端。 -func NewDouyuClient(timeAPIURL string, trace *traceRecorder) *DouyuClient { +// DouyuClient 斗鱼接口客户端。 +type DouyuClient struct { + HTTPClient *http.Client + TimeAPIURL string + Trace *trace.Recorder +} + +// New 创建斗鱼客户端。 +func New(timeAPIURL string, tr *trace.Recorder) *DouyuClient { return &DouyuClient{ - timeAPIURL: timeAPIURL, - trace: trace, - httpClient: &http.Client{ + TimeAPIURL: timeAPIURL, + Trace: tr, + HTTPClient: &http.Client{ Timeout: 8 * time.Second, Transport: &http.Transport{ MaxConnsPerHost: 120, @@ -38,7 +50,7 @@ func (c *DouyuClient) ServerTime() (time.Time, error) { } // ServerTimeSample 从斗鱼秒级 currentTime 估算毫秒级服务端时间。 -func (c *DouyuClient) ServerTimeSample() (TimeSample, error) { +func (c *DouyuClient) ServerTimeSample() (types.TimeSample, error) { var lastErr error for i := 0; i < 3; i++ { sample, err := c.fetchServerTimeOnce() @@ -51,7 +63,7 @@ func (c *DouyuClient) ServerTimeSample() (TimeSample, error) { time.Sleep(100 * time.Millisecond) } now := time.Now() - return TimeSample{ + return types.TimeSample{ ServerTime: now.UTC(), LocalTime: now, Offset: 0, @@ -61,7 +73,7 @@ func (c *DouyuClient) ServerTimeSample() (TimeSample, error) { } // CalibratedServerTimeSample 通过等待 currentTime 跳秒估算本地与斗鱼秒边界的偏移。 -func (c *DouyuClient) CalibratedServerTimeSample(maxWait time.Duration) (TimeSample, error) { +func (c *DouyuClient) CalibratedServerTimeSample(maxWait time.Duration) (types.TimeSample, error) { first, err := c.ServerTimeSample() if err != nil || maxWait <= 0 { return first, err @@ -99,11 +111,11 @@ func (c *DouyuClient) CalibratedServerTimeSample(maxWait time.Duration) (TimeSam } // BestServerTimeCalibration 多次校准,选择误差范围最小、RTT更低的一次。 -func (c *DouyuClient) BestServerTimeCalibration(rounds int, maxWait time.Duration) (TimeCalibration, error) { +func (c *DouyuClient) BestServerTimeCalibration(rounds int, maxWait time.Duration) (types.TimeCalibration, error) { if rounds <= 0 { rounds = 1 } - var best TimeSample + var best types.TimeSample var lastErr error for i := 0; i < rounds; i++ { sample, err := c.CalibratedServerTimeSample(maxWait) @@ -120,13 +132,13 @@ func (c *DouyuClient) BestServerTimeCalibration(rounds int, maxWait time.Duratio } if best.LocalTime.IsZero() { now := time.Now() - return TimeCalibration{ + return types.TimeCalibration{ CreatedAt: now, Source: "localFallback", Fallback: true, }, fmt.Errorf("斗鱼时间校准失败: %w", lastErr) } - return TimeCalibration{ + return types.TimeCalibration{ Offset: best.Offset, ErrorBound: best.ErrorBound, RTT: best.RTT, @@ -137,7 +149,7 @@ func (c *DouyuClient) BestServerTimeCalibration(rounds int, maxWait time.Duratio }, lastErr } -func betterTimeSample(candidate, current TimeSample) bool { +func betterTimeSample(candidate, current types.TimeSample) bool { if candidate.ErrorBound != 0 && current.ErrorBound == 0 { return true } @@ -150,11 +162,10 @@ func betterTimeSample(candidate, current TimeSample) bool { return candidate.RTT < current.RTT } -// fetchServerTimeOnce 执行一次服务端时间请求。 -func (c *DouyuClient) fetchServerTimeOnce() (TimeSample, error) { - url := appendCacheBuster(c.timeAPIURL) +func (c *DouyuClient) fetchServerTimeOnce() (types.TimeSample, error) { + url := utils.AppendCacheBuster(c.TimeAPIURL) var result struct { - APIResponse + types.APIResponse Data struct { PreExchange struct { CurrentTime string `json:"currentTime"` @@ -163,19 +174,18 @@ func (c *DouyuClient) fetchServerTimeOnce() (TimeSample, error) { } start := time.Now() if err := c.getJSON(url, "", "服务时间", &result); err != nil { - return TimeSample{}, err + return types.TimeSample{}, err } end := time.Now() ts, err := strconv.ParseInt(result.Data.PreExchange.CurrentTime, 10, 64) if err != nil || ts <= 0 { - return TimeSample{}, fmt.Errorf("currentTime 无效: %q", result.Data.PreExchange.CurrentTime) + return types.TimeSample{}, fmt.Errorf("currentTime 无效: %q", result.Data.PreExchange.CurrentTime) } rtt := end.Sub(start) - // 斗鱼 currentTime 只有秒级;用响应到达时本地时钟的毫秒部分补齐,避免定时提前量被秒级截断吃掉。 estimatedLocal := start.Add(rtt / 2) serverTime := time.Unix(ts, 0).Add(time.Duration(estimatedLocal.Nanosecond())).UTC() - return TimeSample{ + return types.TimeSample{ ServerTime: serverTime, LocalTime: estimatedLocal, RTT: rtt, @@ -185,14 +195,14 @@ func (c *DouyuClient) fetchServerTimeOnce() (TimeSample, error) { } // Products 拉取商城商品列表。 -func (c *DouyuClient) Products() ([]Product, error) { +func (c *DouyuClient) Products() ([]types.Product, error) { var result struct { - APIResponse + types.APIResponse Data struct { - List []Product `json:"list"` + List []types.Product `json:"list"` } `json:"data"` } - if err := c.getJSON(storeHomeURL, "", "商品列表", &result); err != nil { + if err := c.getJSON(constants.StoreHomeURL, "", "商品列表", &result); err != nil { return nil, err } if result.Error != 0 { @@ -203,15 +213,15 @@ func (c *DouyuClient) Products() ([]Product, error) { // Score 查询账号积分余额。 func (c *DouyuClient) Score(cookie string) (int, error) { - req, err := http.NewRequest("POST", scoreURL, strings.NewReader(`{}`)) + req, err := http.NewRequest("POST", constants.ScoreURL, strings.NewReader(`{}`)) if err != nil { return 0, err } c.setJSONHeaders(req, cookie) - req.Header.Set("Referer", baseURL+"/") + req.Header.Set("Referer", constants.BaseURL+"/") var result struct { - APIResponse + types.APIResponse Data struct { Balance int `json:"balance"` } `json:"data"` @@ -227,17 +237,17 @@ func (c *DouyuClient) Score(cookie string) (int, error) { // GenerateCSRF 调用 generateCsrf,并从 Set-Cookie 中取 cvl_csrf_token。 func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) { - req, err := http.NewRequest("POST", csrfURL, nil) + req, err := http.NewRequest("POST", constants.CSRFURL, nil) if err != nil { return "", err } c.setBrowserHeaders(req, cookie) start := time.Now() - reqBody := traceRequestBody(c.trace, req) - resp, err := c.httpClient.Do(req) + reqBody := c.Trace.RequestBody(req) + resp, err := c.HTTPClient.Do(req) if err != nil { - c.trace.record("生成CSRF", req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "") + c.Trace.Record("生成CSRF", req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "") logNetworkError("生成CSRF", req, time.Since(start), err) return "", err } @@ -245,39 +255,39 @@ func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) { body, err := io.ReadAll(resp.Body) if err != nil { - c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "") + c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "") logNetworkError("生成CSRF", req, time.Since(start), err) return "", err } if resp.StatusCode != http.StatusOK { - c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") + c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") logNetworkHTTPError("生成CSRF", req, resp.StatusCode, time.Since(start), body) - return "", fmt.Errorf("CSRF HTTP %d: %s", resp.StatusCode, trimForLog(string(body), 120)) + return "", fmt.Errorf("CSRF HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 120)) } - var api APIResponse + var api types.APIResponse if err := json.Unmarshal(body, &api); err != nil { - c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "") + c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "") logNetworkParseError("生成CSRF", req, time.Since(start), body, err) return "", fmt.Errorf("CSRF 响应解析失败: %w", err) } traceErr := "" if api.Error != 0 { traceErr = api.Msg - c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, traceErr, api.Error, api.Msg) + c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, traceErr, api.Error, api.Msg) logNetworkAPIResult("生成CSRF", req, time.Since(start), api.Error, api.Msg) return "", fmt.Errorf("CSRF 接口失败: %s", api.Msg) } for _, ck := range resp.Cookies() { if ck.Name == "cvl_csrf_token" && ck.Value != "" { - c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", api.Error, api.Msg) + c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", api.Error, api.Msg) logNetworkSuccess("生成CSRF", req, time.Since(start), 0, "") return ck.Value, nil } } - c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 cvl_csrf_token", api.Error, api.Msg) - logEvent("网络", "warn", "接口返回异常: 生成CSRF %s %s 耗时=%s msg=响应没有 cvl_csrf_token", - req.Method, requestPath(req), roundDuration(time.Since(start))) + c.Trace.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 cvl_csrf_token", api.Error, api.Msg) + logger.LogEvent("网络", "warn", "接口返回异常: 生成CSRF %s %s 耗时=%s msg=响应没有 cvl_csrf_token", + req.Method, requestPath(req), utils.RoundDuration(time.Since(start))) return "", errors.New("CSRF 响应没有 cvl_csrf_token") } @@ -293,22 +303,22 @@ func csrfFromResponse(resp *http.Response) string { return "" } -// BatchExchangeLimit 请求兑换限制接口,返回本次允许的批量上限。 +// BatchExchangeLimit 请求兑换限制接口。 func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID string, csrf *string) (int, error) { currentCSRF := "" if csrf != nil { currentCSRF = *csrf } var result struct { - APIResponse + types.APIResponse Data struct { Limit string `json:"limit"` } `json:"data"` } - if err := c.postMultipart(batchLimitURL, cookie, csrf, "批量限制", map[string]string{ - "manualID": manualID, + if err := c.postMultipart(constants.BatchLimitURL, cookie, csrf, "批量限制", map[string]string{ + "manualID": constants.ManualID, "commodityID": commodityID, - "rid": roomID, + "rid": constants.RoomID, "csrfToken": currentCSRF, }, &result); err != nil { return 0, err @@ -329,10 +339,10 @@ func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) erro if csrf != nil { currentCSRF = *csrf } - var result APIResponse - if err := c.postMultipart(preExchangeURL, cookie, csrf, "预兑换", map[string]string{ - "manualID": manualID, - "rid": roomID, + var result types.APIResponse + if err := c.postMultipart(constants.PreExchangeURL, cookie, csrf, "预兑换", map[string]string{ + "manualID": constants.ManualID, + "rid": constants.RoomID, "commodityID": commodityID, "num": "1", "csrfToken": currentCSRF, @@ -345,25 +355,25 @@ func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) erro return nil } -// Exchange 正式兑换一个商品,num 固定为 1,和浏览器抓包一致。 +// Exchange 正式兑换一个商品。 func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string, error) { currentCSRF := "" if csrf != nil { currentCSRF = *csrf } var result struct { - APIResponse + types.APIResponse Data struct { ExchangeID int `json:"exchangeId"` ExchangeNum string `json:"exchangeNum"` CommodityTyp int `json:"commodityType"` } `json:"data"` } - if err := c.postMultipart(exchangeURL, cookie, csrf, "正式兑换", map[string]string{ - "manualID": manualID, + if err := c.postMultipart(constants.ExchangeURL, cookie, csrf, "正式兑换", map[string]string{ + "manualID": constants.ManualID, "commodityID": commodityID, "num": "1", - "rid": roomID, + "rid": constants.RoomID, "csrfToken": currentCSRF, }, &result); err != nil { return "", err @@ -380,14 +390,14 @@ func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string // RoleName 查询绑定角色名。 func (c *DouyuClient) RoleName(cookie string) (string, error) { var result struct { - APIResponse + types.APIResponse Data struct { GameRole struct { RoleName string `json:"roleName"` } `json:"gameRole"` } `json:"data"` } - if err := c.getJSON(bindInfoURL, cookie, "角色绑定", &result); err != nil { + if err := c.getJSON(constants.BindInfoURL, cookie, "角色绑定", &result); err != nil { return "", err } if result.Error != 0 { @@ -396,7 +406,6 @@ func (c *DouyuClient) RoleName(cookie string) (string, error) { return result.Data.GameRole.RoleName, nil } -// getJSON 发送 GET 请求并解析 JSON。 func (c *DouyuClient) getJSON(url, cookie, label string, out any) error { req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -407,9 +416,8 @@ func (c *DouyuClient) getJSON(url, cookie, label string, out any) error { return err } -// postMultipart 发送 multipart/form-data 请求并解析 JSON,并同步响应里的 CSRF Cookie。 func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label string, fields map[string]string, out any) error { - var body bytes.Buffer + var body strings.Builder writer := multipart.NewWriter(&body) for key, value := range fields { if err := writer.WriteField(key, value); err != nil { @@ -420,7 +428,7 @@ func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label stri return err } - req, err := http.NewRequest("POST", url, &body) + req, err := http.NewRequest("POST", url, strings.NewReader(body.String())) if err != nil { return err } @@ -428,13 +436,13 @@ func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label stri if csrf != nil { currentCSRF = *csrf } - c.setBrowserHeaders(req, mergeCookie(cookie, "cvl_csrf_token", currentCSRF)) + c.setBrowserHeaders(req, utils.MergeCookie(cookie, "cvl_csrf_token", currentCSRF)) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := c.doJSON(req, out, label) if resp != nil { if nextCSRF := csrfFromResponse(resp); nextCSRF != "" && csrf != nil { if nextCSRF != *csrf { - logEvent("网络", "info", "响应刷新CSRF已同步: %s %s %s", label, req.Method, requestPath(req)) + logger.LogEvent("网络", "info", "响应刷新CSRF已同步: %s %s %s", label, req.Method, requestPath(req)) } *csrf = nextCSRF } @@ -442,13 +450,12 @@ func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label stri return err } -// doJSON 执行请求、检查 HTTP 状态码并解析 JSON。 func (c *DouyuClient) doJSON(req *http.Request, out any, label string) (*http.Response, error) { start := time.Now() - reqBody := traceRequestBody(c.trace, req) - resp, err := c.httpClient.Do(req) + reqBody := c.Trace.RequestBody(req) + resp, err := c.HTTPClient.Do(req) if err != nil { - c.trace.record(label, req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "") + c.Trace.Record(label, req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "") logNetworkError(label, req, time.Since(start), err) return nil, err } @@ -456,28 +463,28 @@ func (c *DouyuClient) doJSON(req *http.Request, out any, label string) (*http.Re body, err := io.ReadAll(resp.Body) if err != nil { - c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "") + c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "") logNetworkError(label, req, time.Since(start), err) return resp, err } if resp.StatusCode != http.StatusOK { - c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") + c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") logNetworkHTTPError(label, req, resp.StatusCode, time.Since(start), body) - return resp, fmt.Errorf("HTTP %d: %s", resp.StatusCode, trimForLog(string(body), 160)) + return resp, fmt.Errorf("HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 160)) } if err := json.Unmarshal(body, out); err != nil { - c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "") + c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "") logNetworkParseError(label, req, time.Since(start), body, err) - return resp, fmt.Errorf("JSON 解析失败: %w, body=%s", err, trimForLog(string(body), 160)) + return resp, fmt.Errorf("JSON 解析失败: %w, body=%s", err, utils.TrimForLog(string(body), 160)) } if apiError, apiMsg, ok := extractAPIStatus(out); ok && apiError != 0 { - c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, apiMsg, apiError, apiMsg) + c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, apiMsg, apiError, apiMsg) logNetworkAPIResult(label, req, time.Since(start), apiError, apiMsg) } else if shouldLogNetworkSuccess(label) { - c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") + c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") logNetworkSuccess(label, req, time.Since(start), 0, "") } else { - c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") + c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "") } return resp, nil } @@ -492,29 +499,29 @@ func logNetworkSuccess(label string, req *http.Request, cost time.Duration, apiE } extra := "" if apiMsg != "" || apiError != 0 { - extra = fmt.Sprintf(" apiError=%d msg=%s", apiError, trimForLog(apiMsg, 80)) + extra = fmt.Sprintf(" apiError=%d msg=%s", apiError, utils.TrimForLog(apiMsg, 80)) } - logEvent("网络", "info", "请求完成: %s %s %s 耗时=%s%s", label, req.Method, requestPath(req), roundDuration(cost), extra) + logger.LogEvent("网络", "info", "请求完成: %s %s %s 耗时=%s%s", label, req.Method, requestPath(req), utils.RoundDuration(cost), extra) } func logNetworkAPIResult(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) { - logEvent("网络", "warn", "接口返回异常: %s %s %s 耗时=%s apiError=%d msg=%s", - label, req.Method, requestPath(req), roundDuration(cost), apiError, trimForLog(apiMsg, 120)) + logger.LogEvent("网络", "warn", "接口返回异常: %s %s %s 耗时=%s apiError=%d msg=%s", + label, req.Method, requestPath(req), utils.RoundDuration(cost), apiError, utils.TrimForLog(apiMsg, 120)) } func logNetworkHTTPError(label string, req *http.Request, status int, cost time.Duration, body []byte) { - logEvent("网络", "error", "HTTP异常: %s %s %s 状态=%d 耗时=%s 响应=%s", - label, req.Method, requestPath(req), status, roundDuration(cost), trimForLog(string(body), 160)) + logger.LogEvent("网络", "error", "HTTP异常: %s %s %s 状态=%d 耗时=%s 响应=%s", + label, req.Method, requestPath(req), status, utils.RoundDuration(cost), utils.TrimForLog(string(body), 160)) } func logNetworkParseError(label string, req *http.Request, cost time.Duration, body []byte, err error) { - logEvent("网络", "error", "响应解析失败: %s %s %s 耗时=%s 错误=%v 响应=%s", - label, req.Method, requestPath(req), roundDuration(cost), err, trimForLog(string(body), 160)) + logger.LogEvent("网络", "error", "响应解析失败: %s %s %s 耗时=%s 错误=%v 响应=%s", + label, req.Method, requestPath(req), utils.RoundDuration(cost), err, utils.TrimForLog(string(body), 160)) } func logNetworkError(label string, req *http.Request, cost time.Duration, err error) { - logEvent("网络", "error", "请求失败: %s %s %s 耗时=%s 错误=%v", - label, req.Method, requestPath(req), roundDuration(cost), err) + logger.LogEvent("网络", "error", "请求失败: %s %s %s 耗时=%s 错误=%v", + label, req.Method, requestPath(req), utils.RoundDuration(cost), err) } func requestPath(req *http.Request) string { @@ -524,10 +531,6 @@ func requestPath(req *http.Request) string { return req.URL.Path } -func roundDuration(d time.Duration) time.Duration { - return d.Round(time.Millisecond) -} - func extractAPIStatus(out any) (int, string, bool) { if out == nil { return 0, "", false @@ -542,15 +545,15 @@ func extractAPIStatus(out any) (int, string, bool) { if !v.IsValid() { return 0, "", false } - if v.Type() == reflect.TypeOf(APIResponse{}) { - api := v.Interface().(APIResponse) + if v.Type() == reflect.TypeOf(types.APIResponse{}) { + api := v.Interface().(types.APIResponse) return api.Error, api.Msg, true } if v.Kind() != reflect.Struct { return 0, "", false } if field := v.FieldByName("APIResponse"); field.IsValid() && field.CanInterface() { - if api, ok := field.Interface().(APIResponse); ok { + if api, ok := field.Interface().(types.APIResponse); ok { return api.Error, api.Msg, true } } @@ -562,13 +565,12 @@ func extractAPIStatus(out any) (int, string, bool) { return 0, "", false } -// setBrowserHeaders 设置接近浏览器抓包的通用请求头。 func (c *DouyuClient) setBrowserHeaders(req *http.Request, cookie string) { req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5") - req.Header.Set("User-Agent", userAgent) - req.Header.Set("Origin", baseURL) - req.Header.Set("Referer", shopReferer) + req.Header.Set("User-Agent", constants.UserAgent) + req.Header.Set("Origin", constants.BaseURL) + req.Header.Set("Referer", constants.ShopReferer) req.Header.Set("Sec-Fetch-Dest", "empty") req.Header.Set("Sec-Fetch-Mode", "cors") req.Header.Set("Sec-Fetch-Site", "same-origin") @@ -577,7 +579,6 @@ func (c *DouyuClient) setBrowserHeaders(req *http.Request, cookie string) { } } -// setJSONHeaders 设置 JSON POST 请求头。 func (c *DouyuClient) setJSONHeaders(req *http.Request, cookie string) { c.setBrowserHeaders(req, cookie) req.Header.Set("Content-Type", "application/json") diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c35d1d7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,128 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strconv" + + "douyu_go/internal/constants" + "douyu_go/internal/logger" + "douyu_go/internal/types" +) + +// DefaultAppPaths 收集运行时路径,支持环境变量覆盖。 +func DefaultAppPaths() types.AppPaths { + return types.AppPaths{ + ConfigFile: envOrDefault("DOUYU_CONFIG", constants.DefaultConfigFile), + AccountFile: envOrDefault("DOUYU_USERS", constants.DefaultAccountFile), + LogDir: envOrDefault("DOUYU_LOG_DIR", constants.DefaultLogDir), + SuccessDir: envOrDefault("DOUYU_SUCCESS_DIR", constants.DefaultSuccessDir), + FontFiles: CollectFontCandidates(), + } +} + +func envOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return filepath.Clean(value) + } + return fallback +} + +// LoadTraceConfig 读取 trace 开关;默认关闭,避免保存敏感调试数据。 +func LoadTraceConfig() types.TraceConfig { + return types.TraceConfig{ + Enabled: isTruthyEnv("DOUYU_TRACE"), + MaxBodyBytes: envInt("DOUYU_TRACE_MAX_BODY", 0), + } +} + +func isTruthyEnv(key string) bool { + switch os.Getenv(key) { + case "1", "true", "TRUE", "yes", "YES", "on", "ON": + return true + default: + return false + } +} + +func envInt(key string, fallback int) int { + raw := os.Getenv(key) + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return fallback + } + return value +} + +// LoadConfig 读取配置文件;缺失时安静使用内置默认配置。 +func LoadConfig(path string) types.Config { + cfg := types.Config{TimeAPIURL: constants.DefaultTimeAPI} + if path == "" { + path = constants.DefaultConfigFile + } + + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + logger.LogEvent("配置", "info", "配置加载完成: 内置默认配置") + } else { + logger.LogEvent("配置", "warn", "读取 %s 出错,使用内置默认配置: %v", path, err) + } + return cfg + } + defer f.Close() + + if err := json.NewDecoder(f).Decode(&cfg); err != nil { + logger.LogEvent("配置", "warn", "解析 %s 失败,使用内置默认配置: %v", path, err) + return types.Config{TimeAPIURL: constants.DefaultTimeAPI} + } + if cfg.TimeAPIURL == "" { + cfg.TimeAPIURL = constants.DefaultTimeAPI + } + logger.LogEvent("配置", "success", "配置加载完成: %s", path) + return cfg +} + +// CollectFontCandidates 收集常见中文字体路径,允许用 DOUYU_FONT 指定自定义字体。 +func CollectFontCandidates() []string { + candidates := []string{} + if custom := os.Getenv("DOUYU_FONT"); custom != "" { + candidates = append(candidates, custom) + } + candidates = append(candidates, + "Microsoft Yahei.ttf", + "Microsoft YaHei.ttf", + "NotoSansCJKsc-Regular.otf", + "NotoSansCJK-Regular.ttc", + ) + + switch runtime.GOOS { + case "darwin": + candidates = append(candidates, + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", + "/System/Library/Fonts/Supplemental/Songti.ttc", + ) + case "windows": + candidates = append(candidates, + `C:\Windows\Fonts\msyh.ttc`, + `C:\Windows\Fonts\simhei.ttf`, + `C:\Windows\Fonts\simsun.ttc`, + ) + default: + candidates = append(candidates, + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ) + } + return candidates +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..8e86126 --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,32 @@ +package constants + +import "time" + +const ( + ManualID = "G4KA4Qnz4LDp7" + RoomID = "9263298" + ActAlias = "20260120QYOOB" + + BaseURL = "https://www.douyu.com" + ShopReferer = BaseURL + "/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=" + RoomID + DefaultTimeAPI = BaseURL + "/wgapi/ordnc/activity/peace/storedetail?manualID=" + ManualID + "&commodityID=20260120QYOOB_bag274&rid=" + RoomID + StoreHomeURL = BaseURL + "/wgapi/ordnc/activity/peace/storehome?manualID=" + ManualID + "&rid=" + RoomID + CSRFURL = BaseURL + "/japi/carnival/nc/common/generateCsrf" + BatchLimitURL = BaseURL + "/wgapi/ordnc/activity/peace/batchExchangeLimit" + PreExchangeURL = BaseURL + "/wgapi/ordnc/activity/peace/preExchange" + ExchangeURL = BaseURL + "/wgapi/ordnc/activity/peace/exchange" + BindInfoURL = BaseURL + "/japi/carnivalApi/v2/tencent/bindInfo?actAlias=" + ActAlias + ScoreURL = BaseURL + "/japi/oms/web/peace/credit/balance" + UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" + DefaultAttempts = 1 + DefaultConcurrency = 80 + ProductCacheTTL = 30 * time.Second + + DefaultConfigFile = "config.json" + DefaultAccountFile = "data/user.txt" + DefaultLogDir = "logs" + DefaultSuccessDir = "success_images" +) + +// CST 中国标准时间 UTC+8。 +var CST = time.FixedZone("CST", 8*3600) diff --git a/internal/image/font.go b/internal/image/font.go new file mode 100644 index 0000000..d81f404 --- /dev/null +++ b/internal/image/font.go @@ -0,0 +1,53 @@ +package image + +import ( + "errors" + "os" + + "github.com/golang/freetype" + "github.com/golang/freetype/truetype" + "golang.org/x/image/font/gofont/goregular" + + "douyu_go/internal/logger" +) + +// LoadFont 预加载字体,优先使用本地或系统中文字体,缺失时使用内置字体兜底。 +func LoadFont(fontFiles []string) *truetype.Font { + source, font, err := parseFirstFont(fontFiles) + if err != nil { + source = "内置 Go Regular 字体" + font, err = freetype.ParseFont(goregular.TTF) + if err != nil { + logger.LogEvent("字体", "error", "内置字体解析失败,成功图片功能将跳过: %v", err) + return nil + } + logger.LogEvent("字体", "warn", "未找到可用系统字体,使用内置字体兜底,中文可能显示不完整") + } + logger.LogEvent("字体", "success", "字体加载完成: %s", source) + return font +} + +func parseFirstFont(candidates []string) (string, *truetype.Font, error) { + for _, path := range candidates { + if path == "" { + continue + } + fontBytes, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.LogEvent("字体", "warn", "字体读取失败,已跳过: %s: %v", path, err) + } + continue + } + if len(fontBytes) == 0 { + continue + } + font, err := freetype.ParseFont(fontBytes) + if err != nil { + logger.LogEvent("字体", "warn", "字体解析失败,已跳过: %s: %v", path, err) + continue + } + return path, font, nil + } + return "", nil, errors.New("没有可用字体") +} diff --git a/image.go b/internal/image/image.go similarity index 58% rename from image.go rename to internal/image/image.go index 5e6f3de..a3eba63 100644 --- a/image.go +++ b/internal/image/image.go @@ -1,4 +1,4 @@ -package main +package image import ( "fmt" @@ -10,13 +10,16 @@ import ( "time" "github.com/golang/freetype" + "github.com/golang/freetype/truetype" + + "douyu_go/internal/constants" + "douyu_go/internal/logger" + "douyu_go/internal/types" + "douyu_go/internal/utils" ) -// saveSuccessImage 保存成功兑换结果图片。 -func (s *appState) saveSuccessImage(account Account, product Product, roleName string) { - s.fontMu.Lock() - font := s.font - s.fontMu.Unlock() +// SaveSuccessImage 保存成功兑换结果图片。 +func SaveSuccessImage(account types.Account, product types.Product, roleName string, font *truetype.Font, successDir string) { if font == nil { return } @@ -56,8 +59,8 @@ func (s *appState) saveSuccessImage(account Account, product Product, roleName s Color color.Color }{ {productName, 20, 25, 42, white}, - {"兑换时间:" + time.Now().In(cst).Format("2006年01月02日 15:04:05"), 16, 25, 110, black}, - {"游戏昵称:" + emptyDefault(roleName, "未知"), 16, 25, 150, black}, + {"兑换时间:" + time.Now().In(constants.CST).Format("2006年01月02日 15:04:05"), 16, 25, 110, black}, + {"游戏昵称:" + utils.EmptyDefault(roleName, "未知"), 16, 25, 150, black}, {"兑换账号:" + account.Name, 16, 25, 190, black}, } for _, line := range lines { @@ -66,17 +69,17 @@ func (s *appState) saveSuccessImage(account Account, product Product, roleName s _, _ = ctx.DrawString(line.Text, freetype.Pt(line.X, line.Y)) } - name := safeFilename(fmt.Sprintf("%s_%s_%d.png", account.Name, productName, time.Now().Unix())) - path := filepath.Join(s.paths.SuccessDir, name) + name := utils.SafeFilename(fmt.Sprintf("%s_%s_%d.png", account.Name, productName, time.Now().Unix())) + path := filepath.Join(successDir, name) file, err := os.Create(path) if err != nil { - logEvent("图片", "error", "成功图片创建失败: %v", err) + logger.LogEvent("图片", "error", "成功图片创建失败: %v", err) return } defer file.Close() if err := png.Encode(file, img); err != nil { - logEvent("图片", "error", "成功图片保存失败: %v", err) + logger.LogEvent("图片", "error", "成功图片保存失败: %v", err) return } - logEvent("图片", "success", "成功图片已保存: %s", path) + logger.LogEvent("图片", "success", "成功图片已保存: %s", path) } diff --git a/internal/logger/log_store.go b/internal/logger/log_store.go new file mode 100644 index 0000000..3759166 --- /dev/null +++ b/internal/logger/log_store.go @@ -0,0 +1,92 @@ +package logger + +import ( + "strings" + "sync" + "time" + + "douyu_go/internal/constants" + "douyu_go/internal/types" +) + +// MemoryLogStore 内存日志缓冲,实现 io.Writer 接口。 +type MemoryLogStore struct { + mu sync.RWMutex + nextID int64 + max int + filePath string + entries []types.LogEntry +} + +// NewMemoryLogStore 创建内存日志缓冲,最多保留 max 条最新日志。 +func NewMemoryLogStore(max int) *MemoryLogStore { + if max <= 0 { + max = 500 + } + return &MemoryLogStore{ + max: max, + entries: make([]types.LogEntry, 0, max), + } +} + +// Write 实现 io.Writer,把标准 log 输出转存为内存日志。 +func (s *MemoryLogStore) Write(p []byte) (int, error) { + text := strings.TrimRight(string(p), "\r\n") + if text == "" { + return len(p), nil + } + + s.mu.Lock() + defer s.mu.Unlock() + for _, line := range strings.Split(text, "\n") { + line = strings.TrimRight(line, "\r") + if strings.TrimSpace(line) == "" { + continue + } + category, level := ParseLogMeta(line) + if level == "" { + level = DetectLogLevel(line) + } + s.nextID++ + s.entries = append(s.entries, types.LogEntry{ + ID: s.nextID, + Time: time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"), + Level: level, + Category: category, + Message: line, + }) + if len(s.entries) > s.max { + copy(s.entries, s.entries[len(s.entries)-s.max:]) + s.entries = s.entries[:s.max] + } + } + return len(p), nil +} + +// SetFilePath 保存当前日志文件路径。 +func (s *MemoryLogStore) SetFilePath(path string) { + s.mu.Lock() + defer s.mu.Unlock() + s.filePath = path +} + +// Since 返回 ID 大于 since 的日志。 +func (s *MemoryLogStore) Since(since int64) []types.LogEntry { + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]types.LogEntry, 0, len(s.entries)) + for _, entry := range s.entries { + if entry.ID > since { + out = append(out, entry) + } + } + return out +} + +// FilePath 返回当前日志文件路径。 +func (s *MemoryLogStore) FilePath() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.filePath +} diff --git a/log_store.go b/internal/logger/logger.go similarity index 51% rename from log_store.go rename to internal/logger/logger.go index 776d282..014faa8 100644 --- a/log_store.go +++ b/internal/logger/logger.go @@ -1,4 +1,4 @@ -package main +package logger import ( "fmt" @@ -7,57 +7,28 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "time" + + "douyu_go/internal/constants" ) -// newMemoryLogStore 创建内存日志缓冲,最多保留 max 条最新日志。 -func newMemoryLogStore(max int) *memoryLogStore { - if max <= 0 { - max = 500 - } - return &memoryLogStore{ - max: max, - entries: make([]LogEntry, 0, max), - } +// AsyncLogWriter 异步日志写入器,队列满时丢弃日志以保护主链路。 +type AsyncLogWriter struct { + dst io.Writer + ch chan []byte + wg sync.WaitGroup + closed uint32 + dropped uint64 } -// initLogging 初始化日志输出:终端、日志文件、内存缓冲三处同时写入。 -func initLogging(store *memoryLogStore, logDir string) func() { - if logDir == "" { - logDir = defaultLogDir - } - if err := os.MkdirAll(logDir, 0755); err != nil { - async := newAsyncLogWriter(io.MultiWriter(os.Stdout, store), 2048) - log.SetOutput(async) - log.Printf("日志目录创建失败,仅写入终端和内存: %v", err) - return func() { _ = async.Close() } - } - - filePath := filepath.Join(logDir, "douyu_"+time.Now().In(cst).Format("20060102_150405")+".log") - file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - async := newAsyncLogWriter(io.MultiWriter(os.Stdout, store), 2048) - log.SetOutput(async) - log.Printf("日志文件创建失败,仅写入终端和内存: %v", err) - return func() { _ = async.Close() } - } - - store.setFilePath(filePath) - async := newAsyncLogWriter(io.MultiWriter(os.Stdout, file, store), 2048) - log.SetOutput(async) - log.Printf("日志文件已启用: %s", filePath) - return func() { - _ = async.Close() - _ = file.Close() - } -} - -func newAsyncLogWriter(dst io.Writer, buffer int) *asyncLogWriter { +// NewAsyncLogWriter 创建异步日志写入器。 +func NewAsyncLogWriter(dst io.Writer, buffer int) *AsyncLogWriter { if buffer <= 0 { buffer = 1024 } - w := &asyncLogWriter{ + w := &AsyncLogWriter{ dst: dst, ch: make(chan []byte, buffer), } @@ -67,7 +38,7 @@ func newAsyncLogWriter(dst io.Writer, buffer int) *asyncLogWriter { } // Write 异步写日志;队列满时丢弃,避免抢购请求被磁盘 I/O 阻塞。 -func (w *asyncLogWriter) Write(p []byte) (int, error) { +func (w *AsyncLogWriter) Write(p []byte) (int, error) { if w == nil { return len(p), nil } @@ -83,18 +54,19 @@ func (w *asyncLogWriter) Write(p []byte) (int, error) { return len(p), nil } -func (w *asyncLogWriter) run() { +func (w *AsyncLogWriter) run() { defer w.wg.Done() for p := range w.ch { _, _ = w.dst.Write(p) } if dropped := atomic.LoadUint64(&w.dropped); dropped > 0 { _, _ = fmt.Fprintf(w.dst, "%s [日志][warn] 日志队列已丢弃 %d 条,已优先保护抢购链路\n", - time.Now().In(cst).Format("2006/01/02 15:04:05.000000"), dropped) + time.Now().In(constants.CST).Format("2006/01/02 15:04:05.000000"), dropped) } } -func (w *asyncLogWriter) Close() error { +// Close 关闭写入器,等待队列清空。 +func (w *AsyncLogWriter) Close() error { if w == nil { return nil } @@ -105,42 +77,39 @@ func (w *asyncLogWriter) Close() error { return nil } -// Write 实现 io.Writer,把标准 log 输出转存为内存日志。 -func (s *memoryLogStore) Write(p []byte) (int, error) { - text := strings.TrimRight(string(p), "\r\n") - if text == "" { - return len(p), nil +// InitLogging 初始化日志输出:终端、日志文件、内存缓冲三处同时写入。 +func InitLogging(store *MemoryLogStore, logDir string) func() { + if logDir == "" { + logDir = constants.DefaultLogDir + } + if err := os.MkdirAll(logDir, 0755); err != nil { + async := NewAsyncLogWriter(io.MultiWriter(os.Stdout, store), 2048) + log.SetOutput(async) + log.Printf("日志目录创建失败,仅写入终端和内存: %v", err) + return func() { _ = async.Close() } } - s.mu.Lock() - defer s.mu.Unlock() - for _, line := range strings.Split(text, "\n") { - line = strings.TrimRight(line, "\r") - if strings.TrimSpace(line) == "" { - continue - } - category, level := parseLogMeta(line) - if level == "" { - level = detectLogLevel(line) - } - s.nextID++ - s.entries = append(s.entries, LogEntry{ - ID: s.nextID, - Time: time.Now().In(cst).Format("2006-01-02 15:04:05.000"), - Level: level, - Category: category, - Message: line, - }) - if len(s.entries) > s.max { - copy(s.entries, s.entries[len(s.entries)-s.max:]) - s.entries = s.entries[:s.max] - } + filePath := filepath.Join(logDir, "douyu_"+time.Now().In(constants.CST).Format("20060102_150405")+".log") + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + async := NewAsyncLogWriter(io.MultiWriter(os.Stdout, store), 2048) + log.SetOutput(async) + log.Printf("日志文件创建失败,仅写入终端和内存: %v", err) + return func() { _ = async.Close() } + } + + store.SetFilePath(filePath) + async := NewAsyncLogWriter(io.MultiWriter(os.Stdout, file, store), 2048) + log.SetOutput(async) + log.Printf("日志文件已启用: %s", filePath) + return func() { + _ = async.Close() + _ = file.Close() } - return len(p), nil } -// logEvent 输出带分类和级别的结构化文本日志。 -func logEvent(category, level, format string, args ...any) { +// LogEvent 输出带分类和级别的结构化文本日志。 +func LogEvent(category, level, format string, args ...any) { if category == "" { category = "系统" } @@ -150,8 +119,8 @@ func logEvent(category, level, format string, args ...any) { log.Printf("[%s][%s] %s", category, level, fmt.Sprintf(format, args...)) } -// parseLogMeta 从标准日志行中提取类似 [网络][warn] 的分类和级别。 -func parseLogMeta(line string) (string, string) { +// ParseLogMeta 从标准日志行中提取类似 [网络][warn] 的分类和级别。 +func ParseLogMeta(line string) (string, string) { idx := strings.Index(line, "[") if idx < 0 { return "", "" @@ -161,7 +130,7 @@ func parseLogMeta(line string) (string, string) { return "", "" } level, _, ok := cutBracket(rest) - if !ok || !isLogLevel(level) { + if !ok || !IsLogLevel(level) { return category, "" } return category, level @@ -178,7 +147,8 @@ func cutBracket(s string) (string, string, bool) { return s[1:end], s[end+1:], true } -func isLogLevel(level string) bool { +// IsLogLevel 判断是否为有效日志级别。 +func IsLogLevel(level string) bool { switch strings.ToLower(level) { case "success", "error", "warn", "info": return true @@ -187,36 +157,8 @@ func isLogLevel(level string) bool { } } -// setFilePath 保存当前日志文件路径,供前端展示或排查使用。 -func (s *memoryLogStore) setFilePath(path string) { - s.mu.Lock() - defer s.mu.Unlock() - s.filePath = path -} - -// Since 返回 ID 大于 since 的日志。 -func (s *memoryLogStore) Since(since int64) []LogEntry { - s.mu.RLock() - defer s.mu.RUnlock() - - out := make([]LogEntry, 0, len(s.entries)) - for _, entry := range s.entries { - if entry.ID > since { - out = append(out, entry) - } - } - return out -} - -// FilePath 返回当前日志文件路径。 -func (s *memoryLogStore) FilePath() string { - s.mu.RLock() - defer s.mu.RUnlock() - return s.filePath -} - -// detectLogLevel 根据日志文本粗略分类,方便前端染色。 -func detectLogLevel(line string) string { +// DetectLogLevel 根据日志文本粗略分类,方便前端染色。 +func DetectLogLevel(line string) string { lower := strings.ToLower(line) switch { case strings.Contains(lower, "[success]"): diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go new file mode 100644 index 0000000..2813806 --- /dev/null +++ b/internal/seckill/scheduler.go @@ -0,0 +1,532 @@ +package seckill + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/golang/freetype/truetype" + + "douyu_go/internal/client" + "douyu_go/internal/constants" + "douyu_go/internal/image" + "douyu_go/internal/logger" + "douyu_go/internal/types" + "douyu_go/internal/utils" +) + +const ( + warmupBeforeTrigger = 2 * time.Second + warmupMaxAge = 30 * time.Second + timeCalibrateRounds = 3 + timeCalibrateWait = 1200 * time.Millisecond + resyncBeforeTrigger = 10 * time.Second +) + +// Scheduler 抢购调度器。 +type Scheduler struct { + client *client.DouyuClient + paths types.AppPaths + font *truetype.Font + + accountsMu sync.RWMutex + accounts []types.Account + + resultsMu sync.RWMutex + results map[string]string + + runMu sync.Mutex + running bool + + scheduleMu sync.Mutex + schedule *time.Timer + warmupSchedule *time.Timer + resyncSchedule *time.Timer + + warmupsMu sync.RWMutex + warmups map[string]types.WarmupData +} + +// New 创建抢购调度器。 +func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font) *Scheduler { + return &Scheduler{ + client: cli, + paths: paths, + font: font, + results: make(map[string]string), + } +} + +// LoadAccounts 从账号文件加载账号。 +func (s *Scheduler) LoadAccounts() { + path := s.paths.AccountFile + if path == "" { + path = constants.DefaultAccountFile + } + f, err := os.Open(path) + if err != nil { + logger.LogEvent("账号", "error", "读取账号文件失败: %s: %v", path, err) + s.accountsMu.Lock() + s.accounts = nil + s.accountsMu.Unlock() + return + } + defer f.Close() + + var list []types.Account + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "----", 3) + if len(parts) != 3 { + logger.LogEvent("账号", "warn", "账号行格式错误,已跳过: %s", line) + continue + } + list = append(list, types.Account{ + Name: strings.TrimSpace(parts[0]), + Password: strings.TrimSpace(parts[1]), + Cookie: strings.TrimSpace(parts[2]), + }) + } + if err := scanner.Err(); err != nil { + logger.LogEvent("账号", "error", "读取账号文件中断: %v", err) + } + + s.accountsMu.Lock() + s.accounts = list + s.accountsMu.Unlock() + logger.LogEvent("账号", "success", "账号加载完成: %d 个,来源=%s", len(list), path) +} + +// AccountCount 返回当前账号数。 +func (s *Scheduler) AccountCount() int { + s.accountsMu.RLock() + defer s.accountsMu.RUnlock() + return len(s.accounts) +} + +// AccountSnapshot 复制一份账号列表。 +func (s *Scheduler) AccountSnapshot() []types.Account { + s.accountsMu.RLock() + defer s.accountsMu.RUnlock() + list := make([]types.Account, len(s.accounts)) + copy(list, s.accounts) + return list +} + +// AccountCookieByName 按账号名查找 Cookie。 +func (s *Scheduler) AccountCookieByName(name string) (string, bool) { + s.accountsMu.RLock() + defer s.accountsMu.RUnlock() + for _, account := range s.accounts { + if account.Name == name { + return account.Cookie, true + } + } + return "", false +} + +// Results 返回当前抢购结果快照。 +func (s *Scheduler) Results() map[string]string { + s.resultsMu.RLock() + defer s.resultsMu.RUnlock() + out := make(map[string]string, len(s.results)) + for k, v := range s.results { + out[k] = v + } + return out +} + +// ClearResults 清空抢购结果。 +func (s *Scheduler) ClearResults() { + s.resultsMu.Lock() + defer s.resultsMu.Unlock() + s.results = make(map[string]string) +} + +func (s *Scheduler) setResult(key, value string) { + s.resultsMu.Lock() + defer s.resultsMu.Unlock() + s.results[key] = value +} + +// TryStartRun 尝试开始抢购,返回 false 表示已有任务在运行。 +func (s *Scheduler) TryStartRun() bool { + s.runMu.Lock() + defer s.runMu.Unlock() + if s.running { + return false + } + s.running = true + return true +} + +func (s *Scheduler) finishRun() { + s.runMu.Lock() + defer s.runMu.Unlock() + s.running = false +} + +// RunNow 立即执行一批抢购任务。 +func (s *Scheduler) RunNow(accountProducts map[string]string) { + defer s.finishRun() + s.ClearResults() + attempts := s.buildAttempts(accountProducts) + if len(attempts) == 0 { + logger.LogEvent("抢购", "warn", "没有可执行的抢购任务") + return + } + + logger.LogEvent("抢购", "info", "开始抢购: %d 个请求", len(attempts)) + limit := make(chan struct{}, constants.DefaultConcurrency) + var wg sync.WaitGroup + for _, attempt := range attempts { + wg.Add(1) + go func(attempt types.ExchangeAttempt) { + defer wg.Done() + limit <- struct{}{} + defer func() { <-limit }() + + result := s.runAttempt(attempt) + key := fmt.Sprintf("%s_第%d包", result.Account, result.Attempt) + s.setResult(key, result.Message) + if result.OK { + logger.LogEvent("抢购", "success", "成功: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message) + go image.SaveSuccessImage(attempt.Account, attempt.Product, result.RoleName, s.font, s.paths.SuccessDir) + } else { + logger.LogEvent("抢购", "error", "失败: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message) + } + }(attempt) + } + wg.Wait() + logger.LogEvent("抢购", "info", "抢购任务结束") +} + +func (s *Scheduler) buildAttempts(accountProducts map[string]string) []types.ExchangeAttempt { + accounts := s.AccountSnapshot() + var attempts []types.ExchangeAttempt + for _, account := range accounts { + commodityID := accountProducts[account.Name] + if commodityID == "" { + continue + } + product := types.Product{CommodityID: commodityID, CommodityName: commodityID} + for i := 1; i <= constants.DefaultAttempts; i++ { + attempts = append(attempts, types.ExchangeAttempt{ + Account: account, + Product: product, + Index: i, + }) + } + } + return attempts +} + +func warmupKey(attempt types.ExchangeAttempt) string { + return fmt.Sprintf("%s|%s|%d", attempt.Account.Name, attempt.Product.CommodityID, attempt.Index) +} + +func (s *Scheduler) setWarmup(key string, data types.WarmupData) { + s.warmupsMu.Lock() + defer s.warmupsMu.Unlock() + if s.warmups == nil { + s.warmups = make(map[string]types.WarmupData) + } + s.warmups[key] = data +} + +func (s *Scheduler) warmupFor(attempt types.ExchangeAttempt) (types.WarmupData, bool) { + s.warmupsMu.RLock() + data, ok := s.warmups[warmupKey(attempt)] + s.warmupsMu.RUnlock() + if !ok || data.CreatedAt.IsZero() || time.Since(data.CreatedAt) > warmupMaxAge { + return types.WarmupData{}, false + } + return data, true +} + +func (s *Scheduler) clearWarmups() { + s.warmupsMu.Lock() + defer s.warmupsMu.Unlock() + s.warmups = make(map[string]types.WarmupData) +} + +func (s *Scheduler) warmupAttempts(attempts []types.ExchangeAttempt) { + if len(attempts) == 0 { + return + } + s.clearWarmups() + logger.LogEvent("预热", "info", "开始预热: %d 个请求", len(attempts)) + + limit := make(chan struct{}, constants.DefaultConcurrency) + var wg sync.WaitGroup + for _, attempt := range attempts { + wg.Add(1) + go func(attempt types.ExchangeAttempt) { + defer wg.Done() + limit <- struct{}{} + defer func() { <-limit }() + + start := time.Now() + csrf, err := s.client.GenerateCSRF(attempt.Account.Cookie) + if err != nil { + logger.LogEvent("预热", "warn", "CSRF预热失败: 账号=%s 商品=%s err=%v", attempt.Account.Name, attempt.Product.CommodityName, err) + return + } + roleName, err := s.client.RoleName(attempt.Account.Cookie) + if err != nil { + logger.LogEvent("预热", "warn", "角色预热失败: 账号=%s err=%v", attempt.Account.Name, err) + } + s.setWarmup(warmupKey(attempt), types.WarmupData{ + CSRF: csrf, + RoleName: roleName, + CreatedAt: time.Now(), + }) + logger.LogEvent("预热", "success", "预热完成: 账号=%s 商品=%s 耗时=%s", attempt.Account.Name, attempt.Product.CommodityName, utils.RoundDuration(time.Since(start))) + }(attempt) + } + wg.Wait() + logger.LogEvent("预热", "info", "预热任务结束") +} + +func (s *Scheduler) calibrateDouyuTime(label string) types.TimeCalibration { + calibration, err := s.client.BestServerTimeCalibration(timeCalibrateRounds, timeCalibrateWait) + if err != nil { + logger.LogEvent("时间", "warn", "%s校准降级: %v", label, err) + } + logger.LogEvent("时间", "success", "%s校准完成: 斗鱼估算=%s 偏移=%dms 误差<=%dms RTT=%dms 采样=%d 来源=%s", + label, + calibration.EstimatedServerNow().In(constants.CST).Format("2006-01-02 15:04:05.000"), + calibration.Offset.Milliseconds(), + calibration.ErrorBound.Milliseconds(), + calibration.RTT.Milliseconds(), + calibration.Samples, + calibration.Source) + return calibration +} + +func (s *Scheduler) replaceTimerLocked(timer **time.Timer, d time.Duration, fn func()) { + if *timer != nil { + (*timer).Stop() + } + *timer = time.AfterFunc(d, fn) +} + +func (s *Scheduler) stopSchedulesLocked() { + if s.schedule != nil { + s.schedule.Stop() + s.schedule = nil + } + if s.warmupSchedule != nil { + s.warmupSchedule.Stop() + s.warmupSchedule = nil + } + if s.resyncSchedule != nil { + s.resyncSchedule.Stop() + s.resyncSchedule = nil + } +} + +func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResult { + result := types.ExchangeResult{ + Account: attempt.Account.Name, + Product: attempt.Product.CommodityName, + Attempt: attempt.Index, + } + + logger.LogEvent("抢购", "info", "执行兑换链路: 账号=%s 商品=%s 第%d包", attempt.Account.Name, attempt.Product.CommodityName, attempt.Index) + var roleName string + csrf := "" + if warmup, ok := s.warmupFor(attempt); ok { + csrf = warmup.CSRF + roleName = warmup.RoleName + logger.LogEvent("抢购", "info", "使用预热CSRF: 账号=%s 商品=%s 预热距今=%s", attempt.Account.Name, attempt.Product.CommodityName, utils.RoundDuration(time.Since(warmup.CreatedAt))) + } + if csrf == "" { + var err error + csrf, err = s.client.GenerateCSRF(attempt.Account.Cookie) + if err != nil { + result.Message = "失败|生成CSRF失败: " + err.Error() + return result + } + logger.LogEvent("抢购", "info", "CSRF已生成,本次兑换链路复用同一个 token: 账号=%s 商品=%s", attempt.Account.Name, attempt.Product.CommodityName) + } + + limit, err := s.client.BatchExchangeLimit(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf) + if err != nil { + result.Message = "失败|批量限制检查失败: " + err.Error() + return result + } + if limit <= 0 { + result.Message = "失败|当前商品限制兑换" + return result + } + logger.LogEvent("抢购", "info", "批量限制通过: 账号=%s 商品=%s limit=%d", attempt.Account.Name, attempt.Product.CommodityName, limit) + + if err := s.client.PreExchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf); err != nil { + result.Message = "失败|预兑换失败: " + err.Error() + return result + } + + exchangeInfo, err := s.client.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf) + if err != nil { + result.Message = "失败|" + err.Error() + return result + } + + if roleName == "" { + roleName, err = s.client.RoleName(attempt.Account.Cookie) + if err != nil { + logger.LogEvent("抢购", "warn", "%s 角色名查询失败: %v", attempt.Account.Name, err) + } + } + + result.OK = true + result.RoleName = roleName + if roleName != "" { + result.Message = "成功|" + roleName + "|" + exchangeInfo + } else { + result.Message = "成功|" + exchangeInfo + } + return result +} + +// ScheduleRun 注册一次定时抢购。 +func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (map[string]any, error) { + if len(req.AccountProducts) == 0 { + return nil, errors.New("请选择账号和商品") + } + if strings.TrimSpace(req.TargetTime) == "" { + return nil, errors.New("请设置目标时间") + } + + targetLocal, err := time.ParseInLocation("2006-01-02 15:04:05", req.TargetTime, constants.CST) + if err != nil { + return nil, fmt.Errorf("时间格式错误: %w", err) + } + targetTime := targetLocal.UTC() + + calibration := s.calibrateDouyuTime("定时") + serverTime := calibration.EstimatedServerNow() + + advance := time.Duration(req.Advance) * time.Millisecond + triggerTime := targetTime.Add(-advance) + remain := triggerTime.Sub(serverTime) + if remain <= 0 { + return nil, errors.New("目标时间已过") + } + + ap := utils.CloneStringMap(req.AccountProducts) + attempts := s.buildAttempts(ap) + s.clearWarmups() + s.scheduleMu.Lock() + s.stopSchedulesLocked() + s.scheduleMu.Unlock() + + s.scheduleTimers(ap, attempts, targetTime, triggerTime, calibration, false) + + resyncRemain := triggerTime.Sub(serverTime) - resyncBeforeTrigger + if resyncRemain < 0 { + resyncRemain = 0 + } + + s.scheduleMu.Lock() + s.replaceTimerLocked(&s.resyncSchedule, resyncRemain, func() { + newCalibration := s.calibrateDouyuTime("临近") + s.scheduleTimers(ap, attempts, targetTime, triggerTime, newCalibration, true) + }) + s.scheduleMu.Unlock() + + warmupRemain := remain - warmupBeforeTrigger + if warmupRemain < 0 { + warmupRemain = 0 + } + + logger.LogEvent("定时", "success", "定时已设置: 目标=%s 触发=%s 提前=%dms 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s 复校=%s", + targetTime.In(constants.CST).Format("2006-01-02 15:04:05.000"), + triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"), + req.Advance, + serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"), + calibration.Offset.Milliseconds(), + calibration.ErrorBound.Milliseconds(), + calibration.RTT.Milliseconds(), + time.Now().Add(warmupRemain).In(constants.CST).Format("15:04:05.000"), + time.Now().Add(resyncRemain).In(constants.CST).Format("15:04:05.000"), + ) + + return map[string]any{ + "success": true, + "targetTime": targetTime.In(constants.CST).Format("2006-01-02 15:04:05"), + "triggerTime": triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"), + "advance": req.Advance, + "remainSecond": int(remain.Seconds()), + "serverTime": serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"), + "offsetMs": calibration.Offset.Milliseconds(), + "errorBoundMs": calibration.ErrorBound.Milliseconds(), + "rttMs": calibration.RTT.Milliseconds(), + }, nil +} + +func (s *Scheduler) scheduleTimers(accountProducts map[string]string, attempts []types.ExchangeAttempt, targetTime, triggerTime time.Time, calibration types.TimeCalibration, resync bool) { + serverTime := calibration.EstimatedServerNow() + remain := triggerTime.Sub(serverTime) + if remain < 0 { + remain = 0 + } + warmupRemain := remain - warmupBeforeTrigger + if warmupRemain < 0 { + warmupRemain = 0 + } + + s.scheduleMu.Lock() + defer s.scheduleMu.Unlock() + s.replaceTimerLocked(&s.warmupSchedule, warmupRemain, func() { + logger.LogEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(constants.CST).Format("15:04:05.000")) + s.warmupAttempts(attempts) + }) + s.replaceTimerLocked(&s.schedule, remain, func() { + now := time.Now() + estimatedServer := now.Add(calibration.Offset) + logger.LogEvent("定时", "success", "定时触发: 本地=%s 斗鱼估算=%s 偏移=%dms 误差<=%dms", + now.In(constants.CST).Format("15:04:05.000"), + estimatedServer.In(constants.CST).Format("15:04:05.000"), + calibration.Offset.Milliseconds(), + calibration.ErrorBound.Milliseconds()) + if !s.TryStartRun() { + logger.LogEvent("抢购", "warn", "已有抢购任务正在运行,本次定时触发已跳过") + return + } + s.RunNow(accountProducts) + }) + if resync { + logger.LogEvent("定时", "success", "临近复校已应用: 目标=%s 触发=%s 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s", + targetTime.In(constants.CST).Format("2006-01-02 15:04:05.000"), + triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"), + serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"), + calibration.Offset.Milliseconds(), + calibration.ErrorBound.Milliseconds(), + calibration.RTT.Milliseconds(), + time.Now().Add(warmupRemain).In(constants.CST).Format("15:04:05.000")) + } +} + +// CancelSchedule 停止尚未触发的定时任务。 +func (s *Scheduler) CancelSchedule() bool { + s.scheduleMu.Lock() + defer s.scheduleMu.Unlock() + stopped := false + if s.schedule != nil { + stopped = s.schedule.Stop() + } + s.stopSchedulesLocked() + s.clearWarmups() + return stopped +} diff --git a/trace.go b/internal/trace/trace.go similarity index 65% rename from trace.go rename to internal/trace/trace.go index db31afd..13bdfd0 100644 --- a/trace.go +++ b/internal/trace/trace.go @@ -1,4 +1,4 @@ -package main +package trace import ( "bytes" @@ -14,37 +14,71 @@ import ( "strings" "sync/atomic" "time" + + "douyu_go/internal/constants" + "douyu_go/internal/logger" + "douyu_go/internal/types" + "douyu_go/internal/utils" ) -func initTrace(logDir string, cfg TraceConfig) (*traceRecorder, func()) { +// Recorder HTTP 请求追踪记录器。 +type Recorder struct { + Enabled bool + MaxBodyBytes int + writer *logger.AsyncLogWriter + filePath string + seq uint64 +} + +type entry struct { + Seq uint64 + Time string + Label string + Method string + URL string + Path string + Status int + Cost time.Duration + ReqHeader map[string][]string + RespHeader map[string][]string + ReqBody string + RespBody string + APIError int + APIMessage string + ErrorText string + MaxBodyBytes int +} + +// InitTrace 初始化追踪记录器。 +func InitTrace(logDir string, cfg types.TraceConfig) (*Recorder, func()) { if !cfg.Enabled { - return &traceRecorder{enabled: false}, func() {} + return &Recorder{Enabled: false}, func() {} } if logDir == "" { - logDir = defaultLogDir + logDir = constants.DefaultLogDir } if err := os.MkdirAll(logDir, 0755); err != nil { - logEvent("Trace", "error", "trace 目录创建失败,已关闭 trace: %v", err) - return &traceRecorder{enabled: false}, func() {} + logger.LogEvent("Trace", "error", "trace 目录创建失败,已关闭 trace: %v", err) + return &Recorder{Enabled: false}, func() {} } - path := filepath.Join(logDir, "douyu_trace_"+time.Now().In(cst).Format("20060102_150405")+".log") + path := filepath.Join(logDir, "douyu_trace_"+time.Now().In(constants.CST).Format("20060102_150405")+".log") file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { - logEvent("Trace", "error", "trace 文件创建失败,已关闭 trace: %v", err) - return &traceRecorder{enabled: false}, func() {} + logger.LogEvent("Trace", "error", "trace 文件创建失败,已关闭 trace: %v", err) + return &Recorder{Enabled: false}, func() {} } - recorder := &traceRecorder{ - enabled: true, - maxBodyBytes: cfg.MaxBodyBytes, - writer: newAsyncLogWriter(file, 2048), + recorder := &Recorder{ + Enabled: true, + MaxBodyBytes: cfg.MaxBodyBytes, + writer: logger.NewAsyncLogWriter(file, 2048), filePath: path, } if cfg.MaxBodyBytes > 0 { - logEvent("Trace", "warn", "trace 已启用: %s,单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值", path, cfg.MaxBodyBytes) + logger.LogEvent("Trace", "warn", "trace 已启用: %s,单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值", path, cfg.MaxBodyBytes) } else { - logEvent("Trace", "warn", "trace 已启用: %s,全量保留请求和响应;请求头不脱敏,响应 Set-Cookie 隐藏值", path) + logger.LogEvent("Trace", "warn", "trace 已启用: %s,全量保留请求和响应;请求头不脱敏,响应 Set-Cookie 隐藏值", path) } return recorder, func() { _ = recorder.Close() @@ -52,27 +86,30 @@ func initTrace(logDir string, cfg TraceConfig) (*traceRecorder, func()) { } } -func (t *traceRecorder) Close() error { +// Close 关闭记录器。 +func (t *Recorder) Close() error { if t == nil || t.writer == nil { return nil } return t.writer.Close() } -func (t *traceRecorder) record(label string, req *http.Request, status int, cost time.Duration, reqBody, respBody []byte, errText string, apiError int, apiMsg string) { - t.recordResponse(label, req, nil, status, cost, reqBody, respBody, errText, apiError, apiMsg) +// Record 记录一次请求。 +func (t *Recorder) Record(label string, req *http.Request, status int, cost time.Duration, reqBody, respBody []byte, errText string, apiError int, apiMsg string) { + t.RecordResponse(label, req, nil, status, cost, reqBody, respBody, errText, apiError, apiMsg) } -func (t *traceRecorder) recordResponse(label string, req *http.Request, resp *http.Response, status int, cost time.Duration, reqBody, respBody []byte, errText string, apiError int, apiMsg string) { - if t == nil || !t.enabled || req == nil || req.URL == nil { +// RecordResponse 记录一次请求和响应。 +func (t *Recorder) RecordResponse(label string, req *http.Request, resp *http.Response, status int, cost time.Duration, reqBody, respBody []byte, errText string, apiError int, apiMsg string) { + if t == nil || !t.Enabled || req == nil || req.URL == nil { return } if label == "服务时间" && status == http.StatusOK && errText == "" && apiError == 0 { return } - entry := traceEntry{ + e := entry{ Seq: atomic.AddUint64(&t.seq, 1), - Time: time.Now().In(cst).Format("2006-01-02 15:04:05.000"), + Time: time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"), Label: label, Method: req.Method, URL: req.URL.Redacted(), @@ -81,18 +118,19 @@ func (t *traceRecorder) recordResponse(label string, req *http.Request, resp *ht Cost: cost, ReqHeader: sanitizeHeader(req.Header), RespHeader: sanitizeResponseHeader(resp), - ReqBody: sanitizeBodyForTrace(reqBody, req.Header.Get("Content-Type"), t.maxBodyBytes), - RespBody: sanitizeResponseBody(respBody, t.maxBodyBytes), + ReqBody: sanitizeBodyForTrace(reqBody, req.Header.Get("Content-Type"), t.MaxBodyBytes), + RespBody: sanitizeResponseBody(respBody, t.MaxBodyBytes), APIError: apiError, APIMessage: apiMsg, ErrorText: errText, - MaxBodyBytes: t.maxBodyBytes, + MaxBodyBytes: t.MaxBodyBytes, } - _, _ = t.writer.Write([]byte(formatTraceEntry(entry))) + _, _ = t.writer.Write([]byte(formatEntry(e))) } -func traceRequestBody(t *traceRecorder, req *http.Request) []byte { - if t == nil || !t.enabled || req == nil || req.GetBody == nil { +// RequestBody 读取请求体用于追踪。 +func (t *Recorder) RequestBody(req *http.Request) []byte { + if t == nil || !t.Enabled || req == nil || req.GetBody == nil { return nil } body, err := req.GetBody() @@ -100,8 +138,8 @@ func traceRequestBody(t *traceRecorder, req *http.Request) []byte { return nil } defer body.Close() - if t.maxBodyBytes > 0 { - data, _ := io.ReadAll(io.LimitReader(body, int64(t.maxBodyBytes)+1)) + if t.MaxBodyBytes > 0 { + data, _ := io.ReadAll(io.LimitReader(body, int64(t.MaxBodyBytes)+1)) return data } data, _ := io.ReadAll(body) @@ -162,7 +200,7 @@ func summarizeSetCookies(resp *http.Response) []string { parts = append(parts, fmt.Sprintf("Max-Age=%d", ck.MaxAge)) } if !ck.Expires.IsZero() { - parts = append(parts, "Expires="+ck.Expires.In(cst).Format(time.RFC3339)) + parts = append(parts, "Expires="+ck.Expires.In(constants.CST).Format(time.RFC3339)) } if ck.HttpOnly { parts = append(parts, "HttpOnly") @@ -200,13 +238,13 @@ func sanitizeResponseBody(body []byte, max int) string { func sanitizeTextBody(body []byte, max int) string { raw := string(body) if pretty, ok := prettyJSON(raw); ok { - return truncateTraceText(pretty, max) + return truncateText(pretty, max) } - return truncateTraceText(raw, max) + return truncateText(raw, max) } func sanitizeMultipartBody(body []byte, boundary string, max int) string { - reader := multipartReader(bytes.NewReader(body), boundary) + reader := multipart.NewReader(bytes.NewReader(body), boundary) fields := make(map[string][]string) for { part, err := reader.NextPart() @@ -214,7 +252,7 @@ func sanitizeMultipartBody(body []byte, boundary string, max int) string { break } if err != nil { - return truncateTraceText(string(body), max) + return truncateText(string(body), max) } name := part.FormName() if name == "" { @@ -226,7 +264,7 @@ func sanitizeMultipartBody(body []byte, boundary string, max int) string { } else { valueBytes, _ = io.ReadAll(part) } - value := truncateTraceText(string(valueBytes), max) + value := truncateText(string(valueBytes), max) fields[name] = append(fields[name], value) } encoded, err := json.Marshal(fields) @@ -236,37 +274,14 @@ func sanitizeMultipartBody(body []byte, boundary string, max int) string { return string(encoded) } -func multipartReader(r io.Reader, boundary string) *multipart.Reader { - return multipart.NewReader(r, boundary) -} - -func truncateTraceText(s string, max int) string { +func truncateText(s string, max int) string { if max <= 0 || len(s) <= max { return s } return s[:max] + fmt.Sprintf("...", len(s)-max) } -type traceEntry struct { - Seq uint64 - Time string - Label string - Method string - URL string - Path string - Status int - Cost time.Duration - ReqHeader map[string][]string - RespHeader map[string][]string - ReqBody string - RespBody string - APIError int - APIMessage string - ErrorText string - MaxBodyBytes int -} - -func formatTraceEntry(e traceEntry) string { +func formatEntry(e entry) string { var b strings.Builder statusText := "成功" if e.Status < 200 || e.Status >= 300 || e.APIError != 0 || e.ErrorText != "" { @@ -276,9 +291,9 @@ func formatTraceEntry(e traceEntry) string { b.WriteString(strings.Repeat("=", 96)) b.WriteByte('\n') fmt.Fprintf(&b, "#%06d %s [%s] %s %s\n", e.Seq, e.Time, statusText, e.Method, e.URL) - fmt.Fprintf(&b, "接口: %s\n", emptyDefault(e.Label, "-")) - fmt.Fprintf(&b, "路径: %s\n", emptyDefault(e.Path, "-")) - fmt.Fprintf(&b, "结果: HTTP %d | 耗时 %s | apiError=%d", e.Status, roundDuration(e.Cost), e.APIError) + fmt.Fprintf(&b, "接口: %s\n", utils.EmptyDefault(e.Label, "-")) + fmt.Fprintf(&b, "路径: %s\n", utils.EmptyDefault(e.Path, "-")) + fmt.Fprintf(&b, "结果: HTTP %d | 耗时 %s | apiError=%d", e.Status, utils.RoundDuration(e.Cost), e.APIError) if e.APIMessage != "" { fmt.Fprintf(&b, " | msg=%s", e.APIMessage) } @@ -287,13 +302,13 @@ func formatTraceEntry(e traceEntry) string { fmt.Fprintf(&b, "错误: %s\n", e.ErrorText) } b.WriteString("\n[响应头]\n") - b.WriteString(formatTraceHeaders(e.RespHeader)) + b.WriteString(formatHeaders(e.RespHeader)) b.WriteString("\n[请求头]\n") - b.WriteString(formatTraceHeaders(e.ReqHeader)) + b.WriteString(formatHeaders(e.ReqHeader)) b.WriteString("\n[请求体]\n") - b.WriteString(formatTraceBody(e.ReqBody)) + b.WriteString(formatBody(e.ReqBody)) b.WriteString("\n[响应体]\n") - b.WriteString(formatTraceBody(e.RespBody)) + b.WriteString(formatBody(e.RespBody)) if e.MaxBodyBytes > 0 { fmt.Fprintf(&b, "\n提示: 单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值。\n", e.MaxBodyBytes) } else { @@ -303,7 +318,7 @@ func formatTraceEntry(e traceEntry) string { return b.String() } -func formatTraceHeaders(headers map[string][]string) string { +func formatHeaders(headers map[string][]string) string { if len(headers) == 0 { return "\n" } @@ -319,7 +334,7 @@ func formatTraceHeaders(headers map[string][]string) string { return b.String() } -func formatTraceBody(body string) string { +func formatBody(body string) string { body = strings.TrimSpace(body) if body == "" { return "\n" diff --git a/types.go b/internal/types/types.go similarity index 70% rename from types.go rename to internal/types/types.go index fd22c3c..45fbbfa 100644 --- a/types.go +++ b/internal/types/types.go @@ -1,20 +1,17 @@ -package main +package types import ( "encoding/json" - "io" - "net/http" - "sync" "time" - - "github.com/golang/freetype/truetype" ) +// Config 应用配置。 type Config struct { TimeAPIURL string `json:"timeApiUrl"` } -type appPaths struct { +// AppPaths 运行时路径。 +type AppPaths struct { ConfigFile string AccountFile string LogDir string @@ -22,17 +19,20 @@ type appPaths struct { FontFiles []string } +// TraceConfig 追踪配置。 type TraceConfig struct { Enabled bool MaxBodyBytes int } +// Account 账号信息。 type Account struct { Name string `json:"name"` Password string `json:"password"` Cookie string `json:"cookie"` } +// Product 商品信息。 type Product struct { CommodityID string `json:"commodityId"` CommodityName string `json:"commodityName"` @@ -47,6 +47,7 @@ type Product struct { EliteLimited int `json:"eliteLimited"` } +// ProductPayload 前端商品响应。 type ProductPayload struct { Success bool `json:"success"` Source string `json:"source"` @@ -57,24 +58,28 @@ type ProductPayload struct { Stale bool `json:"stale"` } +// AccountView 前端账号视图。 type AccountView struct { Index int `json:"index"` Name string `json:"name"` Cookie string `json:"cookie"` } +// SeckillRequest 抢购请求。 type SeckillRequest struct { AccountProducts map[string]string `json:"accountProducts"` TargetTime string `json:"targetTime"` Advance int `json:"advance"` } +// APIResponse 斗鱼 API 通用响应。 type APIResponse struct { Error int `json:"error"` Msg string `json:"msg"` Data json.RawMessage `json:"data"` } +// LogEntry 日志条目。 type LogEntry struct { ID int64 `json:"id"` Time string `json:"time"` @@ -83,66 +88,14 @@ type LogEntry struct { Message string `json:"message"` } -type appState struct { - config Config - paths appPaths - client *DouyuClient - logs *memoryLogStore - trace *traceRecorder - - accountsMu sync.RWMutex - accounts []Account - - productsMu sync.RWMutex - products []Product - productsAt time.Time - - resultsMu sync.RWMutex - results map[string]string - - runMu sync.Mutex - running bool - - scheduleMu sync.Mutex - schedule *time.Timer - warmupSchedule *time.Timer - resyncSchedule *time.Timer - - warmupsMu sync.RWMutex - warmups map[string]WarmupData - - fontMu sync.Mutex - font *truetype.Font -} - -type DouyuClient struct { - httpClient *http.Client - timeAPIURL string - trace *traceRecorder -} - -type traceRecorder struct { - enabled bool - maxBodyBytes int - writer *asyncLogWriter - filePath string - seq uint64 -} - -type asyncLogWriter struct { - dst io.Writer - ch chan []byte - wg sync.WaitGroup - closed uint32 - dropped uint64 -} - +// ExchangeAttempt 兑换尝试。 type ExchangeAttempt struct { Account Account Product Product Index int } +// ExchangeResult 兑换结果。 type ExchangeResult struct { OK bool Account string @@ -152,6 +105,7 @@ type ExchangeResult struct { Message string } +// TimeSample 时间采样。 type TimeSample struct { ServerTime time.Time LocalTime time.Time @@ -164,6 +118,15 @@ type TimeSample struct { Fallback bool } +// EstimatedServerNow 根据采样偏移估算当前斗鱼时间。 +func (t TimeSample) EstimatedServerNow() time.Time { + if t.LocalTime.IsZero() { + return t.ServerTime + } + return time.Now().Add(t.Offset).UTC() +} + +// TimeCalibration 时间校准结果。 type TimeCalibration struct { Offset time.Duration ErrorBound time.Duration @@ -174,6 +137,7 @@ type TimeCalibration struct { Fallback bool } +// EstimatedServerNow 根据校准偏移估算当前斗鱼时间。 func (c TimeCalibration) EstimatedServerNow() time.Time { if c.CreatedAt.IsZero() { return time.Now().UTC() @@ -181,33 +145,14 @@ func (c TimeCalibration) EstimatedServerNow() time.Time { return time.Now().Add(c.Offset).UTC() } +// IsZero 判断校准结果是否为零值。 func (c TimeCalibration) IsZero() bool { return c.CreatedAt.IsZero() } +// WarmupData 预热数据。 type WarmupData struct { CSRF string RoleName string CreatedAt time.Time } - -// EstimatedServerNow 根据采样偏移估算当前斗鱼时间。 -func (t TimeSample) EstimatedServerNow() time.Time { - if t.LocalTime.IsZero() { - return t.ServerTime - } - return time.Now().Add(t.Offset).UTC() -} - -type memoryLogStore struct { - mu sync.RWMutex - nextID int64 - max int - filePath string - entries []LogEntry -} - -var state = &appState{ - config: Config{TimeAPIURL: defaultTimeAPI}, - results: make(map[string]string), -} diff --git a/utils.go b/internal/utils/utils.go similarity index 62% rename from utils.go rename to internal/utils/utils.go index 5ed5af3..df6efb0 100644 --- a/utils.go +++ b/internal/utils/utils.go @@ -1,4 +1,4 @@ -package main +package utils import ( "encoding/json" @@ -9,30 +9,8 @@ import ( "time" ) -// readJSON 读取请求 JSON。 -func readJSON(r *http.Request, out any) error { - defer r.Body.Close() - return json.NewDecoder(r.Body).Decode(out) -} - -// writeJSON 写出 JSON 响应。 -func writeJSON(w http.ResponseWriter, value any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - _ = json.NewEncoder(w).Encode(value) -} - -// writeError 写出统一错误 JSON,同时设置 HTTP 状态码。 -func writeError(w http.ResponseWriter, status int, err error) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "error": err.Error(), - }) -} - -// appendCacheBuster 给 URL 追加时间戳,避免时间接口被缓存。 -func appendCacheBuster(rawURL string) string { +// AppendCacheBuster 给 URL 追加时间戳,避免时间接口被缓存。 +func AppendCacheBuster(rawURL string) string { sep := "?" if strings.Contains(rawURL, "?") { sep = "&" @@ -40,8 +18,8 @@ func appendCacheBuster(rawURL string) string { return rawURL + sep + "_=" + strconv.FormatInt(time.Now().UnixNano(), 10) } -// cloneStringMap 复制 map,避免异步任务读到调用方后续修改。 -func cloneStringMap(in map[string]string) map[string]string { +// CloneStringMap 复制 map,避免异步任务读到调用方后续修改。 +func CloneStringMap(in map[string]string) map[string]string { out := make(map[string]string, len(in)) for k, v := range in { out[k] = v @@ -49,8 +27,8 @@ func cloneStringMap(in map[string]string) map[string]string { return out } -// mergeCookie 把指定 Cookie 写入原 Cookie 字符串,已有同名 Cookie 会被覆盖。 -func mergeCookie(cookie, name, value string) string { +// MergeCookie 把指定 Cookie 写入原 Cookie 字符串,已有同名 Cookie 会被覆盖。 +func MergeCookie(cookie, name, value string) string { parts := strings.Split(cookie, ";") pairs := make([]string, 0, len(parts)+1) replaced := false @@ -73,8 +51,8 @@ func mergeCookie(cookie, name, value string) string { return strings.Join(pairs, "; ") } -// trimForLog 截断接口响应,避免日志刷屏。 -func trimForLog(s string, max int) string { +// TrimForLog 截断接口响应,避免日志刷屏。 +func TrimForLog(s string, max int) string { s = strings.TrimSpace(s) if len(s) <= max { return s @@ -82,22 +60,22 @@ func trimForLog(s string, max int) string { return s[:max] + "..." } -// emptyDefault 在字符串为空时返回默认值。 -func emptyDefault(value, fallback string) string { +// EmptyDefault 在字符串为空时返回默认值。 +func EmptyDefault(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value } -// safeFilename 替换文件名中的危险字符。 -func safeFilename(name string) string { +// SafeFilename 替换文件名中的危险字符。 +func SafeFilename(name string) string { replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") return replacer.Replace(name) } -// maskCookie 返回 Cookie 摘要,避免把完整凭据暴露给前端。 -func maskCookie(cookie string) string { +// MaskCookie 返回 Cookie 摘要,避免把完整凭据暴露给前端。 +func MaskCookie(cookie string) string { pairs := strings.Split(cookie, ";") masked := make([]string, 0, 3) total := 0 @@ -110,7 +88,7 @@ func maskCookie(cookie string) string { if len(masked) >= 3 { continue } - masked = append(masked, key+"="+maskValue(value)) + masked = append(masked, key+"="+MaskValue(value)) } if total == 0 { return "" @@ -121,8 +99,8 @@ func maskCookie(cookie string) string { return strings.Join(masked, "; ") } -// maskValue 保留少量首尾字符用于识别,其余替换为星号。 -func maskValue(value string) string { +// MaskValue 保留少量首尾字符用于识别,其余替换为星号。 +func MaskValue(value string) string { value = strings.TrimSpace(value) if value == "" { return "****" @@ -133,3 +111,30 @@ func maskValue(value string) string { } return string(runes[:4]) + "****" + string(runes[len(runes)-4:]) } + +// RoundDuration 将时间四舍五入到毫秒。 +func RoundDuration(d time.Duration) time.Duration { + return d.Round(time.Millisecond) +} + +// ReadJSON 读取请求 JSON。 +func ReadJSON(r *http.Request, out any) error { + defer r.Body.Close() + return json.NewDecoder(r.Body).Decode(out) +} + +// WriteJSON 写出 JSON 响应。 +func WriteJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(value) +} + +// WriteError 写出统一错误 JSON,同时设置 HTTP 状态码。 +func WriteError(w http.ResponseWriter, status int, err error) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": false, + "error": err.Error(), + }) +} diff --git a/main.go b/main.go index 54edb0b..f732465 100644 --- a/main.go +++ b/main.go @@ -1,41 +1,35 @@ package main import ( - "log" - "net/http" - "os" + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" ) +//go:embed frontend +var assets embed.FS + func main() { - log.SetFlags(log.LstdFlags | log.Lmicroseconds) + app := NewApp() - state.paths = defaultAppPaths() - state.logs = newMemoryLogStore(800) - closeLogFile := initLogging(state.logs, state.paths.LogDir) - defer closeLogFile() - trace, closeTrace := initTrace(state.paths.LogDir, loadTraceConfig()) - state.trace = trace - defer closeTrace() - - state.config = loadConfig(state.paths.ConfigFile) - state.client = NewDouyuClient(state.config.TimeAPIURL, state.trace) - state.loadAccounts() - state.loadFont() - if err := os.MkdirAll(state.paths.SuccessDir, 0755); err != nil { - logEvent("图片", "warn", "成功图片目录创建失败,图片保存功能可能不可用: %v", err) - } - - if serverTime, err := state.client.ServerTime(); err == nil { - logEvent("时间", "success", "服务端时间获取成功: %s", serverTime.In(cst).Format("2006-01-02 15:04:05.000")) - } else { - logEvent("时间", "error", "服务端时间获取失败: %v", err) - } - - mux := http.NewServeMux() - state.registerRoutes(mux) - - logEvent("系统", "success", "服务启动: http://%s | 账号: %d", serverAddr, state.accountCount()) - if err := http.ListenAndServe(serverAddr, mux); err != nil { - logEvent("系统", "error", "服务退出: %v", err) + err := wails.Run(&options.App{ + Title: "斗鱼抢购系统", + Width: 1280, + Height: 860, + MinWidth: 900, + MinHeight: 600, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + OnStartup: app.startup, + OnShutdown: app.shutdown, + Bind: []interface{}{ + app, + }, + }) + if err != nil { + println("Error:", err.Error()) } } diff --git a/seckill.go b/seckill.go deleted file mode 100644 index f351a1a..0000000 --- a/seckill.go +++ /dev/null @@ -1,401 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "strings" - "sync" - "time" -) - -const ( - warmupBeforeTrigger = 2 * time.Second - warmupMaxAge = 30 * time.Second - timeCalibrateRounds = 3 - timeCalibrateWait = 1200 * time.Millisecond - resyncBeforeTrigger = 10 * time.Second -) - -// runNow 立即执行一批抢购任务。 -func (s *appState) runNow(accountProducts map[string]string) { - defer s.finishRun() - s.clearResults() - attempts := s.buildAttempts(accountProducts) - if len(attempts) == 0 { - logEvent("抢购", "warn", "没有可执行的抢购任务") - return - } - - logEvent("抢购", "info", "开始抢购: %d 个请求", len(attempts)) - limit := make(chan struct{}, defaultConcurrency) - var wg sync.WaitGroup - for _, attempt := range attempts { - wg.Add(1) - go func(attempt ExchangeAttempt) { - defer wg.Done() - limit <- struct{}{} - defer func() { <-limit }() - - result := s.runAttempt(attempt) - key := fmt.Sprintf("%s_第%d包", result.Account, result.Attempt) - s.setResult(key, result.Message) - if result.OK { - logEvent("抢购", "success", "成功: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message) - go s.saveSuccessImage(attempt.Account, attempt.Product, result.RoleName) - } else { - logEvent("抢购", "error", "失败: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message) - } - }(attempt) - } - wg.Wait() - logEvent("抢购", "info", "抢购任务结束") -} - -func (s *appState) tryStartRun() bool { - s.runMu.Lock() - defer s.runMu.Unlock() - if s.running { - return false - } - s.running = true - return true -} - -func (s *appState) finishRun() { - s.runMu.Lock() - defer s.runMu.Unlock() - s.running = false -} - -// buildAttempts 根据前端传入的账号到商品映射,生成具体兑换尝试。 -func (s *appState) buildAttempts(accountProducts map[string]string) []ExchangeAttempt { - accounts := s.accountSnapshot() - products, _ := s.productSnapshot() - productByID := make(map[string]Product, len(products)) - for _, p := range products { - productByID[p.CommodityID] = p - } - - var attempts []ExchangeAttempt - for _, account := range accounts { - commodityID := accountProducts[account.Name] - if commodityID == "" { - continue - } - product := productByID[commodityID] - if product.CommodityID == "" { - product = Product{CommodityID: commodityID, CommodityName: commodityID} - } - for i := 1; i <= defaultAttempts; i++ { - attempts = append(attempts, ExchangeAttempt{ - Account: account, - Product: product, - Index: i, - }) - } - } - return attempts -} - -func warmupKey(attempt ExchangeAttempt) string { - return fmt.Sprintf("%s|%s|%d", attempt.Account.Name, attempt.Product.CommodityID, attempt.Index) -} - -func (s *appState) setWarmup(key string, data WarmupData) { - s.warmupsMu.Lock() - defer s.warmupsMu.Unlock() - if s.warmups == nil { - s.warmups = make(map[string]WarmupData) - } - s.warmups[key] = data -} - -func (s *appState) warmupFor(attempt ExchangeAttempt) (WarmupData, bool) { - s.warmupsMu.RLock() - data, ok := s.warmups[warmupKey(attempt)] - s.warmupsMu.RUnlock() - if !ok || data.CreatedAt.IsZero() || time.Since(data.CreatedAt) > warmupMaxAge { - return WarmupData{}, false - } - return data, true -} - -func (s *appState) clearWarmups() { - s.warmupsMu.Lock() - defer s.warmupsMu.Unlock() - s.warmups = make(map[string]WarmupData) -} - -func (s *appState) warmupAttempts(attempts []ExchangeAttempt) { - if len(attempts) == 0 { - return - } - s.clearWarmups() - logEvent("预热", "info", "开始预热: %d 个请求", len(attempts)) - - limit := make(chan struct{}, defaultConcurrency) - var wg sync.WaitGroup - for _, attempt := range attempts { - wg.Add(1) - go func(attempt ExchangeAttempt) { - defer wg.Done() - limit <- struct{}{} - defer func() { <-limit }() - - start := time.Now() - csrf, err := s.client.GenerateCSRF(attempt.Account.Cookie) - if err != nil { - logEvent("预热", "warn", "CSRF预热失败: 账号=%s 商品=%s err=%v", attempt.Account.Name, attempt.Product.CommodityName, err) - return - } - roleName, err := s.client.RoleName(attempt.Account.Cookie) - if err != nil { - logEvent("预热", "warn", "角色预热失败: 账号=%s err=%v", attempt.Account.Name, err) - } - s.setWarmup(warmupKey(attempt), WarmupData{ - CSRF: csrf, - RoleName: roleName, - CreatedAt: time.Now(), - }) - logEvent("预热", "success", "预热完成: 账号=%s 商品=%s 耗时=%s", attempt.Account.Name, attempt.Product.CommodityName, roundDuration(time.Since(start))) - }(attempt) - } - wg.Wait() - logEvent("预热", "info", "预热任务结束") -} - -func (s *appState) calibrateDouyuTime(label string) TimeCalibration { - calibration, err := s.client.BestServerTimeCalibration(timeCalibrateRounds, timeCalibrateWait) - if err != nil { - logEvent("时间", "warn", "%s校准降级: %v", label, err) - } - logEvent("时间", "success", "%s校准完成: 斗鱼估算=%s 偏移=%dms 误差<=%dms RTT=%dms 采样=%d 来源=%s", - label, - calibration.EstimatedServerNow().In(cst).Format("2006-01-02 15:04:05.000"), - calibration.Offset.Milliseconds(), - calibration.ErrorBound.Milliseconds(), - calibration.RTT.Milliseconds(), - calibration.Samples, - calibration.Source) - return calibration -} - -func (s *appState) replaceTimerLocked(timer **time.Timer, d time.Duration, fn func()) { - if *timer != nil { - (*timer).Stop() - } - *timer = time.AfterFunc(d, fn) -} - -func (s *appState) stopSchedulesLocked() { - if s.schedule != nil { - s.schedule.Stop() - s.schedule = nil - } - if s.warmupSchedule != nil { - s.warmupSchedule.Stop() - s.warmupSchedule = nil - } - if s.resyncSchedule != nil { - s.resyncSchedule.Stop() - s.resyncSchedule = nil - } -} - -// runAttempt 按抓包链路执行一次兑换:CSRF -> 限制 -> 预兑换 -> 正式兑换。 -// 同一次抢购尝试只生成一次 CSRF;如果后续响应刷新 token,则沿链路继续复用最新值。 -func (s *appState) runAttempt(attempt ExchangeAttempt) ExchangeResult { - result := ExchangeResult{ - Account: attempt.Account.Name, - Product: attempt.Product.CommodityName, - Attempt: attempt.Index, - } - - logEvent("抢购", "info", "执行兑换链路: 账号=%s 商品=%s 第%d包", attempt.Account.Name, attempt.Product.CommodityName, attempt.Index) - var roleName string - csrf := "" - if warmup, ok := s.warmupFor(attempt); ok { - csrf = warmup.CSRF - roleName = warmup.RoleName - logEvent("抢购", "info", "使用预热CSRF: 账号=%s 商品=%s 预热距今=%s", attempt.Account.Name, attempt.Product.CommodityName, roundDuration(time.Since(warmup.CreatedAt))) - } - if csrf == "" { - var err error - csrf, err = s.client.GenerateCSRF(attempt.Account.Cookie) - if err != nil { - result.Message = "失败|生成CSRF失败: " + err.Error() - return result - } - logEvent("抢购", "info", "CSRF已生成,本次兑换链路复用同一个 token: 账号=%s 商品=%s", attempt.Account.Name, attempt.Product.CommodityName) - } - - limit, err := s.client.BatchExchangeLimit(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf) - if err != nil { - result.Message = "失败|批量限制检查失败: " + err.Error() - return result - } - if limit <= 0 { - result.Message = "失败|当前商品限制兑换" - return result - } - logEvent("抢购", "info", "批量限制通过: 账号=%s 商品=%s limit=%d", attempt.Account.Name, attempt.Product.CommodityName, limit) - - if err := s.client.PreExchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf); err != nil { - result.Message = "失败|预兑换失败: " + err.Error() - return result - } - - exchangeInfo, err := s.client.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf) - if err != nil { - result.Message = "失败|" + err.Error() - return result - } - - if roleName == "" { - roleName, err = s.client.RoleName(attempt.Account.Cookie) - if err != nil { - logEvent("抢购", "warn", "%s 角色名查询失败: %v", attempt.Account.Name, err) - } - } - - result.OK = true - result.RoleName = roleName - if roleName != "" { - result.Message = "成功|" + roleName + "|" + exchangeInfo - } else { - result.Message = "成功|" + exchangeInfo - } - return result -} - -// scheduleRun 注册一次定时抢购。 -func (s *appState) scheduleRun(req SeckillRequest) (map[string]any, error) { - if len(req.AccountProducts) == 0 { - return nil, errors.New("请选择账号和商品") - } - if strings.TrimSpace(req.TargetTime) == "" { - return nil, errors.New("请设置目标时间") - } - - targetLocal, err := time.ParseInLocation("2006-01-02 15:04:05", req.TargetTime, cst) - if err != nil { - return nil, fmt.Errorf("时间格式错误: %w", err) - } - targetTime := targetLocal.UTC() - - calibration := s.calibrateDouyuTime("定时") - serverTime := calibration.EstimatedServerNow() - - advance := time.Duration(req.Advance) * time.Millisecond - triggerTime := targetTime.Add(-advance) - remain := triggerTime.Sub(serverTime) - if remain <= 0 { - return nil, errors.New("目标时间已过") - } - - ap := cloneStringMap(req.AccountProducts) - attempts := s.buildAttempts(ap) - s.clearWarmups() - s.scheduleMu.Lock() - s.stopSchedulesLocked() - s.scheduleMu.Unlock() - - s.scheduleTimers(ap, attempts, targetTime, triggerTime, calibration, false) - - resyncRemain := triggerTime.Sub(serverTime) - resyncBeforeTrigger - if resyncRemain < 0 { - resyncRemain = 0 - } - - s.scheduleMu.Lock() - s.replaceTimerLocked(&s.resyncSchedule, resyncRemain, func() { - newCalibration := s.calibrateDouyuTime("临近") - s.scheduleTimers(ap, attempts, targetTime, triggerTime, newCalibration, true) - }) - s.scheduleMu.Unlock() - - warmupRemain := remain - warmupBeforeTrigger - if warmupRemain < 0 { - warmupRemain = 0 - } - - logEvent("定时", "success", "定时已设置: 目标=%s 触发=%s 提前=%dms 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s 复校=%s", - targetTime.In(cst).Format("2006-01-02 15:04:05.000"), - triggerTime.In(cst).Format("2006-01-02 15:04:05.000"), - req.Advance, - serverTime.In(cst).Format("2006-01-02 15:04:05.000"), - calibration.Offset.Milliseconds(), - calibration.ErrorBound.Milliseconds(), - calibration.RTT.Milliseconds(), - time.Now().Add(warmupRemain).In(cst).Format("15:04:05.000"), - time.Now().Add(resyncRemain).In(cst).Format("15:04:05.000"), - ) - - return map[string]any{ - "success": true, - "targetTime": targetTime.In(cst).Format("2006-01-02 15:04:05"), - "triggerTime": triggerTime.In(cst).Format("2006-01-02 15:04:05.000"), - "advance": req.Advance, - "remainSecond": int(remain.Seconds()), - "serverTime": serverTime.In(cst).Format("2006-01-02 15:04:05.000"), - "offsetMs": calibration.Offset.Milliseconds(), - "errorBoundMs": calibration.ErrorBound.Milliseconds(), - "rttMs": calibration.RTT.Milliseconds(), - }, nil -} - -func (s *appState) scheduleTimers(accountProducts map[string]string, attempts []ExchangeAttempt, targetTime, triggerTime time.Time, calibration TimeCalibration, resync bool) { - serverTime := calibration.EstimatedServerNow() - remain := triggerTime.Sub(serverTime) - if remain < 0 { - remain = 0 - } - warmupRemain := remain - warmupBeforeTrigger - if warmupRemain < 0 { - warmupRemain = 0 - } - - s.scheduleMu.Lock() - defer s.scheduleMu.Unlock() - s.replaceTimerLocked(&s.warmupSchedule, warmupRemain, func() { - logEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(cst).Format("15:04:05.000")) - s.warmupAttempts(attempts) - }) - s.replaceTimerLocked(&s.schedule, remain, func() { - now := time.Now() - estimatedServer := now.Add(calibration.Offset) - logEvent("定时", "success", "定时触发: 本地=%s 斗鱼估算=%s 偏移=%dms 误差<=%dms", - now.In(cst).Format("15:04:05.000"), - estimatedServer.In(cst).Format("15:04:05.000"), - calibration.Offset.Milliseconds(), - calibration.ErrorBound.Milliseconds()) - if !s.tryStartRun() { - logEvent("抢购", "warn", "已有抢购任务正在运行,本次定时触发已跳过") - return - } - s.runNow(accountProducts) - }) - if resync { - logEvent("定时", "success", "临近复校已应用: 目标=%s 触发=%s 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s", - targetTime.In(cst).Format("2006-01-02 15:04:05.000"), - triggerTime.In(cst).Format("2006-01-02 15:04:05.000"), - serverTime.In(cst).Format("2006-01-02 15:04:05.000"), - calibration.Offset.Milliseconds(), - calibration.ErrorBound.Milliseconds(), - calibration.RTT.Milliseconds(), - time.Now().Add(warmupRemain).In(cst).Format("15:04:05.000")) - } -} - -// cancelSchedule 停止尚未触发的定时任务。 -func (s *appState) cancelSchedule() bool { - s.scheduleMu.Lock() - defer s.scheduleMu.Unlock() - stopped := false - if s.schedule != nil { - stopped = s.schedule.Stop() - } - s.stopSchedulesLocked() - s.clearWarmups() - return stopped -} diff --git a/state.go b/state.go deleted file mode 100644 index bbb8ba8..0000000 --- a/state.go +++ /dev/null @@ -1,150 +0,0 @@ -package main - -import ( - "bufio" - "os" - "strings" - "time" -) - -// loadAccounts 从账号文件加载账号,格式为“账号----密码----Cookie”。 -func (s *appState) loadAccounts() { - path := s.paths.AccountFile - if path == "" { - path = defaultAccountFile - } - f, err := os.Open(path) - if err != nil { - logEvent("账号", "error", "读取账号文件失败: %s: %v", path, err) - s.accountsMu.Lock() - s.accounts = nil - s.accountsMu.Unlock() - return - } - defer f.Close() - - var list []Account - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 1024), 1024*1024) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - parts := strings.SplitN(line, "----", 3) - if len(parts) != 3 { - logEvent("账号", "warn", "账号行格式错误,已跳过: %s", line) - continue - } - list = append(list, Account{ - Name: strings.TrimSpace(parts[0]), - Password: strings.TrimSpace(parts[1]), - Cookie: strings.TrimSpace(parts[2]), - }) - } - if err := scanner.Err(); err != nil { - logEvent("账号", "error", "读取账号文件中断: %v", err) - } - - s.accountsMu.Lock() - s.accounts = list - s.accountsMu.Unlock() - logEvent("账号", "success", "账号加载完成: %d 个,来源=%s", len(list), path) -} - -// accountCount 返回当前账号数。 -func (s *appState) accountCount() int { - s.accountsMu.RLock() - defer s.accountsMu.RUnlock() - return len(s.accounts) -} - -// accountSnapshot 复制一份账号列表,避免执行任务时长时间持锁。 -func (s *appState) accountSnapshot() []Account { - s.accountsMu.RLock() - defer s.accountsMu.RUnlock() - - list := make([]Account, len(s.accounts)) - copy(list, s.accounts) - return list -} - -// accountCookieByName 按账号名查找真实 Cookie,仅在后端内部使用。 -func (s *appState) accountCookieByName(name string) (string, bool) { - s.accountsMu.RLock() - defer s.accountsMu.RUnlock() - - for _, account := range s.accounts { - if account.Name == name { - return account.Cookie, true - } - } - return "", false -} - -// productSnapshot 复制一份商品列表和对应的刷新时间。 -func (s *appState) productSnapshot() ([]Product, time.Time) { - s.productsMu.RLock() - defer s.productsMu.RUnlock() - - list := make([]Product, len(s.products)) - copy(list, s.products) - return list, s.productsAt -} - -// setProducts 替换商品缓存,并记录这批库存数据的刷新时间。 -func (s *appState) setProducts(products []Product, fetchedAt time.Time) { - s.productsMu.Lock() - defer s.productsMu.Unlock() - s.products = products - s.productsAt = fetchedAt -} - -// productPayload 根据当前缓存生成前端商品响应。 -func (s *appState) productPayload(source string) ProductPayload { - products, fetchedAt := s.productSnapshot() - payload := ProductPayload{ - Success: true, - Source: source, - Products: products, - Count: len(products), - } - if !fetchedAt.IsZero() { - age := time.Since(fetchedAt) - if age < 0 { - age = 0 - } - payload.FetchedAt = fetchedAt.In(cst).Format("2006-01-02 15:04:05.000") - payload.AgeSecond = int(age.Seconds()) - payload.Stale = age > productCacheTTL - } else { - payload.Stale = true - } - return payload -} - -// clearResults 清空抢购结果。 -func (s *appState) clearResults() { - s.resultsMu.Lock() - defer s.resultsMu.Unlock() - s.results = make(map[string]string) -} - -// setResult 写入单次兑换结果。 -func (s *appState) setResult(key, value string) { - s.resultsMu.Lock() - defer s.resultsMu.Unlock() - s.results[key] = value -} - -// resultsSnapshot 复制当前结果。 -func (s *appState) resultsSnapshot() map[string]string { - s.resultsMu.RLock() - defer s.resultsMu.RUnlock() - - out := make(map[string]string, len(s.results)) - for k, v := range s.results { - out[k] = v - } - return out -} diff --git a/user.txt b/user.txt deleted file mode 100644 index 4354237..0000000 --- a/user.txt +++ /dev/null @@ -1 +0,0 @@ -用户5929226360----a778899----acf_auth=4500Vfy3oB26YvOW2pmisv3eizBte%2FBQkapdZOQd2tfxAzby5UJsggKtmq3rYwTOIf9D3qZ8apU5Y%2FIaoXagKmFEIRu8itYEy1XtHFqrH7QU3C0ULchY5zQ5qw; acf_jwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjEwMzA0MDE0MjEsImN0IjowLCJzdWIiOiJzdCIsImF1ZCI6WyJkeSIsIm5vbmUiXSwiYml6IjoxLCJsdGtpZCI6OTI0MDQxODAsInN0ayI6IjBmMTU3ODQzMDM3ODBkZmIiLCJleHAiOjE3ODI0NzEzMTAsImlhdCI6MTc4MTg2NjUxMCwia2V5IjoiZHktand0LW1kNSJ9.NTA2MTZiNzQyOTJjNjM1ZWY0ODZiNWU2MDdjYzdlNjY diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..976bc65 --- /dev/null +++ b/wails.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "douyu_go", + "outputfilename": "douyu_go", + "frontend:dir": "frontend", + "frontend:install": "", + "frontend:build": "", + "frontend:dev:watcher": "", + "frontend:dev:serverUrl": "", + "wailsjsdir": "frontend", + "author": { + "name": "yml", + "email": "yml@example.com" + } +}