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
- 选中商品: 未选
-
-
-
-
-
-
-
- --
-
-
-
-
- (定时触发=目标-提前量)
-
-
-
-
-
-
-
-
-
+
+ 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"
+ }
+}