diff --git a/.gitignore b/.gitignore index e8b0755..185a360 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ logs/ success_images/ build/ +config.json # Wails 自动生成的前端绑定 frontend/wailsjs/ diff --git a/app.go b/app.go index cdd0c25..689a71b 100644 --- a/app.go +++ b/app.go @@ -29,6 +29,7 @@ type App struct { trace *trace.Recorder paths types.AppPaths font *truetype.Font + cfg types.Config productsMu sync.RWMutex products []types.Product @@ -51,14 +52,21 @@ func (a *App) startup(ctx context.Context) { a.logStore = logger.NewMemoryLogStore(800) a.closeLog = logger.InitLogging(a.logStore, a.paths.LogDir) - tr, closeTrace := trace.InitTrace(a.paths.LogDir, config.LoadTraceConfig()) + // 加载配置并应用运行时设置 + a.cfg = config.LoadConfig(a.paths.ConfigFile) + logger.SetMinLevel(a.cfg.LogLevel) + + traceCfg := types.TraceConfig{ + Enabled: a.cfg.TraceEnabled, + MaxBodyBytes: a.cfg.TraceMaxBody, + } + tr, closeTrace := trace.InitTrace(a.paths.LogDir, traceCfg) a.trace = tr a.closeTrace = closeTrace - cfg := config.LoadConfig(a.paths.ConfigFile) - a.client = client.New(cfg.TimeAPIURL, a.trace) + a.client = client.New(a.cfg.TimeAPIURL, a.trace) a.font = imgpkg.LoadFont(a.paths.FontFiles) - a.scheduler = seckill.New(a.client, a.paths, a.font) + a.scheduler = seckill.New(a.client, a.paths, a.font, a.cfg.Concurrency, a.cfg.Attempts) a.scheduler.LoadAccounts() if err := os.MkdirAll(a.paths.SuccessDir, 0755); err != nil { @@ -202,6 +210,45 @@ func (a *App) QueryScore(accountName string) map[string]any { return map[string]any{"score": score} } +// GetVersion 返回应用版本号。 +func (a *App) GetVersion() string { + return constants.Version +} + +// GetSettings 返回当前配置。 +func (a *App) GetSettings() types.Config { + return a.cfg +} + +// SaveSettings 保存配置并运行时生效。 +func (a *App) SaveSettings(cfg types.Config) map[string]any { + // 校验 + if cfg.Concurrency <= 0 { + cfg.Concurrency = constants.DefaultConcurrency + } + if cfg.Attempts <= 0 { + cfg.Attempts = constants.DefaultAttempts + } + if cfg.LogLevel == "" { + cfg.LogLevel = "info" + } + + // 写入文件 + if err := config.SaveConfig(a.paths.ConfigFile, cfg); err != nil { + logger.LogEvent("配置", "error", "配置保存失败: %v", err) + return map[string]any{"success": false, "error": err.Error()} + } + + // 运行时生效 + a.cfg = cfg + logger.SetMinLevel(cfg.LogLevel) + a.scheduler.UpdateSettings(cfg.Concurrency, cfg.Attempts) + + logger.LogEvent("配置", "success", "配置已保存并生效: 日志等级=%s 并发=%d 重试=%d trace=%v", + cfg.LogLevel, cfg.Concurrency, cfg.Attempts, cfg.TraceEnabled) + return map[string]any{"success": true} +} + // ==================== 内部辅助方法 ==================== func (a *App) setProducts(products []types.Product, fetchedAt time.Time) { diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6e11e0b --- /dev/null +++ b/build.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# 构建带版本号的产物 +# 用法: ./build.sh # 构建当前平台 +# GOOS=windows ./build.sh # 构建 Windows +# GOOS=linux ./build.sh # 构建 Linux +set -e + +VERSION=$(grep '"version"' wails.json | head -1 | sed 's/.*"version": *"//;s/".*//') +echo "版本: v${VERSION}" + +if [ "$GOOS" = "windows" ]; then + wails build -platform windows/amd64 -o "douyu_go_v${VERSION}.exe" +elif [ "$GOOS" = "linux" ]; then + wails build -platform linux/amd64 -o "douyu_go_v${VERSION}" +else + wails build +fi + +echo "=== 构建产物 ===" +ls -lh build/bin/ diff --git a/frontend/index.html b/frontend/index.html index f17ad66..82a933b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -170,6 +170,7 @@ .log-e{color:var(--danger)} .log-i{color:var(--accent)} .log-w{color:var(--warning)} + .log-d{color:var(--muted-2)} .log-result{background:var(--panel-2);padding:5px 9px;margin:4px 0;border-radius:var(--radius-sm);word-break:break-all;border:1px solid var(--border)} .log-cat{display:inline-block;min-width:34px;text-align:center;margin-right:6px;padding:0 5px;border:1px solid var(--border);border-radius:5px;color:var(--muted);font-size:11px;line-height:1.4;background:var(--panel)} @@ -184,6 +185,35 @@ .toast-warn{border-left-color:var(--warning)}.toast-warn .toast-title{color:var(--warning)} .toast-error{border-left-color:var(--danger)}.toast-error .toast-title{color:var(--danger)} + /* ========== 设置弹窗 ========== */ + .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;display:none;align-items:center;justify-content:center} + .modal-overlay.show{display:flex} + .modal{background:var(--panel);border:1px solid var(--border);border-radius:12px;width:460px;max-width:92vw;max-height:85vh;overflow-y:auto;box-shadow:var(--shadow)} + .modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border)} + .modal-header h3{font-size:16px;color:var(--text-strong);margin:0} + .modal-close{width:30px;height:30px;border:0;border-radius:6px;background:transparent;color:var(--muted);cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center} + .modal-close:hover{background:var(--panel-2);color:var(--text-strong)} + .modal-body{padding:20px} + .form-group{margin-bottom:16px} + .form-group:last-child{margin-bottom:0} + .form-label{display:block;font-size:13px;font-weight:700;color:var(--muted);margin-bottom:6px} + .form-hint{font-size:12px;color:var(--muted-2);margin-top:4px} + .form-row{display:flex;gap:12px} + .form-row .form-group{flex:1} + .form-input{width:100%;height:36px;padding:0 10px;background:var(--bg);color:var(--text-strong);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px} + .form-input:focus{outline:0;border-color:var(--accent);box-shadow:var(--focus)} + .form-select{width:100%;height:36px;padding:0 10px;background:var(--bg);color:var(--text-strong);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;cursor:pointer} + .form-select:focus{outline:0;border-color:var(--accent)} + .toggle-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0} + .toggle-label{font-size:13px;color:var(--text-strong)} + .toggle{position:relative;width:42px;height:24px;cursor:pointer} + .toggle input{opacity:0;width:0;height:0} + .toggle-slider{position:absolute;inset:0;background:var(--border-strong);border-radius:999px;transition:.2s} + .toggle-slider::before{content:"";position:absolute;left:3px;top:3px;width:18px;height:18px;background:#fff;border-radius:50%;transition:.2s} + .toggle input:checked+.toggle-slider{background:var(--accent)} + .toggle input:checked+.toggle-slider::before{transform:translateX(18px)} + .modal-footer{padding:12px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px} + @media (max-width: 900px){ body{display:block;overflow:auto;height:auto;min-height:100vh} .left{width:100%;min-width:0;height:42vh;border-right:0;border-bottom:1px solid var(--border)} @@ -202,6 +232,58 @@
+ + +
@@ -229,11 +311,13 @@
账号 0 选中商品 未选 + v--
+
@@ -347,6 +431,8 @@ // 清空后端旧结果 await App.ClearResults(); + App.GetVersion().then(v => { document.getElementById('versionTag').textContent = 'v' + v; }).catch(()=>{}); + setInterval(updateTime,1000); setInterval(checkResults,2000); setInterval(fetchServerLogs,2000); @@ -758,6 +844,7 @@ if (item.level === 'success') type = 's'; else if (item.level === 'error') type = 'e'; else if (item.level === 'warn') type = 'w'; + else if (item.level === 'debug') type = 'd'; addServerLog(item.message, type, item.category); }); } catch (e) { } @@ -777,6 +864,49 @@ } + // ==================== 设置 ==================== + // openSettings 打开设置弹窗,从后端读取当前配置填充表单。 + async function openSettings() { + try { + let cfg = await App.GetSettings(); + document.getElementById('setTraceEnabled').checked = !!cfg.traceEnabled; + document.getElementById('setTraceMaxBody').value = cfg.traceMaxBody || 0; + document.getElementById('setLogLevel').value = cfg.logLevel || 'info'; + document.getElementById('setConcurrency').value = cfg.concurrency || 80; + document.getElementById('setAttempts').value = cfg.attempts || 1; + } catch(e) { + toast('❌ 读取设置失败', e.message, 'error'); + } + document.getElementById('settingsModal').classList.add('show'); + } + + // closeSettings 关闭设置弹窗。 + function closeSettings() { + document.getElementById('settingsModal').classList.remove('show'); + } + + // saveSettings 收集表单数据,提交给后端保存。 + async function saveSettings() { + let cfg = { + traceEnabled: document.getElementById('setTraceEnabled').checked, + traceMaxBody: parseInt(document.getElementById('setTraceMaxBody').value) || 0, + logLevel: document.getElementById('setLogLevel').value, + concurrency: parseInt(document.getElementById('setConcurrency').value) || 80, + attempts: parseInt(document.getElementById('setAttempts').value) || 1, + }; + try { + let d = await App.SaveSettings(cfg); + if (d.success) { + toast('✅ 设置已保存', '配置已生效', 'success'); + closeSettings(); + } else { + toast('❌ 保存失败', d.error || '', 'error'); + } + } catch(e) { + toast('❌ 保存失败', e.message, 'error'); + } + } + // buildAP 根据已勾选账号和账号绑定商品,生成后端需要的账号到商品 ID 映射。 function buildAP() { if (selAccs.size === 0) { diff --git a/internal/config/config.go b/internal/config/config.go index c35d1d7..f1f9fdf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,16 +14,28 @@ import ( ) // 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), + ConfigFile: resolvePath(envOrDefault("DOUYU_CONFIG", constants.DefaultConfigFile)), + AccountFile: resolvePath(envOrDefault("DOUYU_USERS", constants.DefaultAccountFile)), + LogDir: resolvePath(envOrDefault("DOUYU_LOG_DIR", constants.DefaultLogDir)), + SuccessDir: resolvePath(envOrDefault("DOUYU_SUCCESS_DIR", constants.DefaultSuccessDir)), FontFiles: CollectFontCandidates(), } } +// resolvePath 将相对路径解析为绝对路径。 +func resolvePath(path string) string { + if filepath.IsAbs(path) { + return path + } + if abs, err := filepath.Abs(path); err == nil { + return abs + } + return path +} + func envOrDefault(key, fallback string) string { if value := os.Getenv(key); value != "" { return filepath.Clean(value) @@ -60,9 +72,21 @@ func envInt(key string, fallback int) int { return value } +// defaultConfig 返回内置默认配置。 +func defaultConfig() types.Config { + return types.Config{ + TimeAPIURL: constants.DefaultTimeAPI, + TraceEnabled: false, + TraceMaxBody: 0, + LogLevel: "info", + Concurrency: constants.DefaultConcurrency, + Attempts: constants.DefaultAttempts, + } +} + // LoadConfig 读取配置文件;缺失时安静使用内置默认配置。 func LoadConfig(path string) types.Config { - cfg := types.Config{TimeAPIURL: constants.DefaultTimeAPI} + cfg := defaultConfig() if path == "" { path = constants.DefaultConfigFile } @@ -80,15 +104,36 @@ func LoadConfig(path string) types.Config { if err := json.NewDecoder(f).Decode(&cfg); err != nil { logger.LogEvent("配置", "warn", "解析 %s 失败,使用内置默认配置: %v", path, err) - return types.Config{TimeAPIURL: constants.DefaultTimeAPI} + return defaultConfig() } if cfg.TimeAPIURL == "" { cfg.TimeAPIURL = constants.DefaultTimeAPI } + if cfg.Concurrency <= 0 { + cfg.Concurrency = constants.DefaultConcurrency + } + if cfg.Attempts <= 0 { + cfg.Attempts = constants.DefaultAttempts + } + if cfg.LogLevel == "" { + cfg.LogLevel = "info" + } logger.LogEvent("配置", "success", "配置加载完成: %s", path) return cfg } +// SaveConfig 将配置写入文件。 +func SaveConfig(path string, cfg types.Config) error { + if path == "" { + path = constants.DefaultConfigFile + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + // CollectFontCandidates 收集常见中文字体路径,允许用 DOUYU_FONT 指定自定义字体。 func CollectFontCandidates() []string { candidates := []string{} diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 8e86126..ffbcdcb 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -2,6 +2,9 @@ package constants import "time" +// Version 应用版本号,与 wails.json version 同步。 +const Version = "0.0.2" + const ( ManualID = "G4KA4Qnz4LDp7" RoomID = "9263298" diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 014faa8..833f3cd 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -14,6 +14,29 @@ import ( "douyu_go/internal/constants" ) +// minLevel 日志最低等级,低于此等级的日志不输出。0=info, 1=warn, 2=error。 +var minLevel atomic.Int32 + +// SetMinLevel 设置日志最低显示等级。 +func SetMinLevel(level string) { + minLevel.Store(int32(levelPriority(level))) +} + +func levelPriority(level string) int { + switch strings.ToLower(level) { + case "debug": + return -1 + case "info": + return 0 + case "warn": + return 1 + case "error": + return 2 + default: + return 0 + } +} + // AsyncLogWriter 异步日志写入器,队列满时丢弃日志以保护主链路。 type AsyncLogWriter struct { dst io.Writer @@ -82,6 +105,10 @@ func InitLogging(store *MemoryLogStore, logDir string) func() { if logDir == "" { logDir = constants.DefaultLogDir } + + // 解析为绝对路径,避免打包后工作目录不同导致日志写到意外位置 + logDir = resolveAbsolutePath(logDir) + if err := os.MkdirAll(logDir, 0755); err != nil { async := NewAsyncLogWriter(io.MultiWriter(os.Stdout, store), 2048) log.SetOutput(async) @@ -108,7 +135,7 @@ func InitLogging(store *MemoryLogStore, logDir string) func() { } } -// LogEvent 输出带分类和级别的结构化文本日志。 +// LogEvent 输出带分类和级别的结构化文本日志。低于当前最低等级的日志会被丢弃。 func LogEvent(category, level, format string, args ...any) { if category == "" { category = "系统" @@ -116,6 +143,9 @@ func LogEvent(category, level, format string, args ...any) { if level == "" { level = "info" } + if levelPriority(level) < int(minLevel.Load()) { + return + } log.Printf("[%s][%s] %s", category, level, fmt.Sprintf(format, args...)) } @@ -150,17 +180,41 @@ func cutBracket(s string) (string, string, bool) { // IsLogLevel 判断是否为有效日志级别。 func IsLogLevel(level string) bool { switch strings.ToLower(level) { - case "success", "error", "warn", "info": + case "success", "error", "warn", "info", "debug": return true default: return false } } +// LogDebug 输出 debug 级别日志,仅在日志等级设为 debug 时可见。 +func LogDebug(category, format string, args ...any) { + LogEvent(category, "debug", format, args...) +} + +// resolveAbsolutePath 将相对路径解析为绝对路径。 +// 如果路径已经是绝对路径则直接返回;否则基于可执行文件所在目录解析。 +func resolveAbsolutePath(path string) string { + if filepath.IsAbs(path) { + return path + } + // 先尝试基于当前工作目录解析 + if abs, err := filepath.Abs(path); err == nil { + return abs + } + // 兜底:基于可执行文件所在目录 + if exe, err := os.Executable(); err == nil { + return filepath.Join(filepath.Dir(exe), path) + } + return path +} + // DetectLogLevel 根据日志文本粗略分类,方便前端染色。 func DetectLogLevel(line string) string { lower := strings.ToLower(line) switch { + case strings.Contains(lower, "[debug]"): + return "debug" case strings.Contains(lower, "[success]"): return "success" case strings.Contains(lower, "[error]"): diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go index 2813806..a3ccc2a 100644 --- a/internal/seckill/scheduler.go +++ b/internal/seckill/scheduler.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "sync" "time" @@ -33,6 +34,9 @@ type Scheduler struct { paths types.AppPaths font *truetype.Font + concurrency int // 抢购并发数 + attempts int // 单号重试次数 + accountsMu sync.RWMutex accounts []types.Account @@ -52,12 +56,30 @@ type Scheduler struct { } // New 创建抢购调度器。 -func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font) *Scheduler { +func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, concurrency, attempts int) *Scheduler { + if concurrency <= 0 { + concurrency = constants.DefaultConcurrency + } + if attempts <= 0 { + attempts = constants.DefaultAttempts + } return &Scheduler{ - client: cli, - paths: paths, - font: font, - results: make(map[string]string), + client: cli, + paths: paths, + font: font, + concurrency: concurrency, + attempts: attempts, + results: make(map[string]string), + } +} + +// UpdateSettings 运行时更新并发数和重试次数。 +func (s *Scheduler) UpdateSettings(concurrency, attempts int) { + if concurrency > 0 { + s.concurrency = concurrency + } + if attempts > 0 { + s.attempts = attempts } } @@ -67,6 +89,22 @@ func (s *Scheduler) LoadAccounts() { if path == "" { path = constants.DefaultAccountFile } + + // 文件不存在时自动创建目录和空文件,方便用户直接粘贴 Cookie + if _, err := os.Stat(path); os.IsNotExist(err) { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if mkErr := os.MkdirAll(dir, 0755); mkErr != nil { + logger.LogEvent("账号", "warn", "账号目录创建失败: %s: %v", dir, mkErr) + } + } + hint := "# 格式: 账号名----密码----Cookie\n# 每行一个账号,粘贴后保存即可\n" + if wrErr := os.WriteFile(path, []byte(hint), 0644); wrErr != nil { + logger.LogEvent("账号", "warn", "账号文件创建失败: %s: %v", path, wrErr) + } else { + logger.LogEvent("账号", "info", "已创建空账号文件: %s,请粘贴 Cookie 后重新加载", path) + } + } + f, err := os.Open(path) if err != nil { logger.LogEvent("账号", "error", "读取账号文件失败: %s: %v", path, err) @@ -186,7 +224,7 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) { } logger.LogEvent("抢购", "info", "开始抢购: %d 个请求", len(attempts)) - limit := make(chan struct{}, constants.DefaultConcurrency) + limit := make(chan struct{}, s.concurrency) var wg sync.WaitGroup for _, attempt := range attempts { wg.Add(1) @@ -219,7 +257,7 @@ func (s *Scheduler) buildAttempts(accountProducts map[string]string) []types.Exc continue } product := types.Product{CommodityID: commodityID, CommodityName: commodityID} - for i := 1; i <= constants.DefaultAttempts; i++ { + for i := 1; i <= s.attempts; i++ { attempts = append(attempts, types.ExchangeAttempt{ Account: account, Product: product, @@ -266,7 +304,7 @@ func (s *Scheduler) warmupAttempts(attempts []types.ExchangeAttempt) { s.clearWarmups() logger.LogEvent("预热", "info", "开始预热: %d 个请求", len(attempts)) - limit := make(chan struct{}, constants.DefaultConcurrency) + limit := make(chan struct{}, s.concurrency) var wg sync.WaitGroup for _, attempt := range attempts { wg.Add(1) diff --git a/internal/types/types.go b/internal/types/types.go index 45fbbfa..13c4a7f 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -7,7 +7,12 @@ import ( // Config 应用配置。 type Config struct { - TimeAPIURL string `json:"timeApiUrl"` + TimeAPIURL string `json:"timeApiUrl"` + TraceEnabled bool `json:"traceEnabled"` + TraceMaxBody int `json:"traceMaxBody"` + LogLevel string `json:"logLevel"` // debug / info / warn / error + Concurrency int `json:"concurrency"` // 抢购并发数 + Attempts int `json:"attempts"` // 单号重试次数 } // AppPaths 运行时路径。 diff --git a/wails.json b/wails.json index 976bc65..d0f1072 100644 --- a/wails.json +++ b/wails.json @@ -2,6 +2,7 @@ "$schema": "https://wails.io/schemas/config.v2.json", "name": "douyu_go", "outputfilename": "douyu_go", + "version": "0.0.2", "frontend:dir": "frontend", "frontend:install": "", "frontend:build": "",