增加设置界面、debug日志等级、版本号展示、构建脚本
- 新增设置弹窗: trace开关/正文上限、日志等级(debug/info/warn/error)、并发数、重试次数 - 配置运行时生效,持久化到 config.json - 日志系统支持 debug 级别过滤 - 工具栏展示版本号(v0.0.2),来源 wails.json + constants.go - 抢购并发数和重试次数改为读取配置,不再写死常量 - 新增 build.sh 构建脚本,Windows exe 文件名带版本号 - config.json 加入 .gitignore
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
logs/
|
||||
success_images/
|
||||
build/
|
||||
config.json
|
||||
|
||||
# Wails 自动生成的前端绑定
|
||||
frontend/wailsjs/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/
|
||||
@@ -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 @@
|
||||
<!-- 右侧滑入提示容器 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- ==================== 设置弹窗 ==================== -->
|
||||
<div class="modal-overlay" id="settingsModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>⚙ 设置</h3>
|
||||
<button class="modal-close" onclick="closeSettings()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">📡 Trace 日志(记录 HTTP 请求详情)</span>
|
||||
<label class="toggle"><input type="checkbox" id="setTraceEnabled"><span class="toggle-slider"></span></label>
|
||||
</div>
|
||||
<div class="form-hint">开启后会在 logs/ 目录生成 douyu_trace_*.log,文件较大</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Trace 正文上限(字节)</label>
|
||||
<input class="form-input" type="number" id="setTraceMaxBody" min="0" step="1024" placeholder="0=不限">
|
||||
<div class="form-hint">限制单段请求/响应正文保留大小,0 表示不限</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">日志显示等级</label>
|
||||
<select class="form-select" id="setLogLevel">
|
||||
<option value="debug">Debug(调试,最详细)</option>
|
||||
<option value="info">Info(全部)</option>
|
||||
<option value="warn">Warn(警告+错误)</option>
|
||||
<option value="error">Error(仅错误)</option>
|
||||
</select>
|
||||
<div class="form-hint">低于此等级的日志不输出到文件和前端</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">抢购并发数</label>
|
||||
<input class="form-input" type="number" id="setConcurrency" min="1" max="200" step="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">单号重试次数</label>
|
||||
<input class="form-input" type="number" id="setAttempts" min="1" max="10" step="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeSettings()">取消</button>
|
||||
<button class="btn btn-blue" onclick="saveSettings()">💾 保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 左侧商品列表 ==================== -->
|
||||
<div class="left">
|
||||
<div class="left-header">
|
||||
@@ -229,11 +311,13 @@
|
||||
<div class="summary-group">
|
||||
<span class="stat-pill">账号 <b id="accCnt">0</b></span>
|
||||
<span class="stat-pill">选中商品 <b class="selected-product" id="selProd">未选</b></span>
|
||||
<span class="stat-pill" style="opacity:.6" id="versionTag">v--</span>
|
||||
</div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<div class="utility-group">
|
||||
<button class="btn btn-blue" onclick="reloadAccounts()">🔄 加载账号</button>
|
||||
<button class="btn btn-blue" onclick="queryAllScore()">💰 查询积分</button>
|
||||
<button class="btn btn-ghost icon-btn" onclick="openSettings()" title="设置" aria-label="设置">⚙</button>
|
||||
<button class="btn btn-ghost icon-btn theme-toggle" id="btnTheme" onclick="toggleTheme()" title="切换亮色/暗色" aria-label="切换亮色/暗色">☾</button>
|
||||
</div>
|
||||
<span id="cdBadge" class="countdown-badge" style="display:none"></span>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -2,6 +2,9 @@ package constants
|
||||
|
||||
import "time"
|
||||
|
||||
// Version 应用版本号,与 wails.json version 同步。
|
||||
const Version = "0.0.2"
|
||||
|
||||
const (
|
||||
ManualID = "G4KA4Qnz4LDp7"
|
||||
RoomID = "9263298"
|
||||
|
||||
@@ -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]"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 运行时路径。
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user