- 抢购内核改为"每账号独立重试循环",移除轮次屏障:旧实现在 3 个中签账号卡住 60 秒支付退避时,另外 31 个账号空转 39 秒,还出现过"某一轮只跑 1 个账号、其余整轮没被尝试" - 锁单退避改为读取配置的"重试间隔策略"(同一账号连续失败成倍放大,封顶 5 秒), 删掉硬编码 30 秒退避与"只重试一次" - 锁单失败不再提前停止:限流(活动火爆/网络繁忙)继续重试;售罄也继续降频探测 (别人锁单后支付失败或锁单过期,库存会回流),只在窗口结束时按售罄/限流/普通失败分类上报 - 支付重试按斗鱼错误码决策:1644430001/1644400002/1644400014 视为"订单已受理、结果未定" 并快重试(300ms 起),取代按文案把"订单操作进行中"误判成频控的 60 秒退避; 不再设次数上限,只受 120 秒预算与锁单过期约束,超出预算如实报"支付结果未知" - 已锁订单在 75 秒窗口结束后仍会支付完,不再受窗口约束 - 多实例:为每个实例分配独占的 WebView2 用户数据目录。Wails 默认所有实例共用 %APPDATA%\<可执行文件名>,而一个数据目录只能有一个浏览器进程,导致偶发开不出新实例、 必须全部关掉重开;实例占位因此提前到 wails.Run 之前完成 - 多实例:占位失败不再静默退回默认目录(会让两个进程共享 accounts.txt/config.json、 同秒启动还会写同一个日志文件),改为进程唯一的临时实例目录并记录降级原因 - 多实例:单个槽位锁被瞬时占用(杀软/索引器)时短暂重试,仍失败则继续试下一个槽位 而不是整个分配失败;日志文件改为独占创建,同秒启动自动追加 -2/-3 后缀 - 新增 %LOCALAPPDATA%\douyu-go\startup.log,记录每次启动与失败原因, 便于排查"窗口起不来"这类连应用日志都不会生成的问题 - 修复预热缓存日志每个账号打两遍、"账号尝试"计数误导(改为真实锁单/支付/直接兑换次数) - 修复前端把含"成功"字样的失败文案显示成抢到(改为只认"成功|"前缀) - 修复 jitterDuration 在基准小于 10ms 时 rand.Int63n(0) 会 panic 的隐患 - 设置页文案与作用域同步:重试间隔策略明确作用于锁单重试、"尝试包数"明确仅用于和平小店
257 lines
7.0 KiB
Go
257 lines
7.0 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"douyu_go/internal/activity"
|
|
"douyu_go/internal/constants"
|
|
imgpkg "douyu_go/internal/image"
|
|
"douyu_go/internal/logger"
|
|
"douyu_go/internal/types"
|
|
)
|
|
|
|
// DefaultAppPaths 收集运行时路径,支持环境变量覆盖。
|
|
// 默认数据存放在当前用户的应用配置目录,而非工作目录,避免 macOS 应用包目录无写权限。
|
|
func DefaultAppPaths() types.AppPaths {
|
|
return appPathsForDataDir(defaultDataDir(), true)
|
|
}
|
|
|
|
func defaultDataDir() string {
|
|
return resolvePath(envOrDefault("DOUYU_DATA_DIR", defaultAppDataDir()))
|
|
}
|
|
|
|
func appPathsForDataDir(dataDir string, useLegacyFiles bool) types.AppPaths {
|
|
configFile := filepath.Join(dataDir, constants.DefaultConfigFile)
|
|
accountFile := filepath.Join(dataDir, constants.DefaultAccountFile)
|
|
legacyConfigFile := ""
|
|
legacyAccountFile := ""
|
|
if useLegacyFiles {
|
|
legacyConfigFile = resolvePath(constants.LegacyDefaultConfigFile)
|
|
legacyAccountFile = resolvePath(constants.LegacyDefaultAccountFile)
|
|
}
|
|
|
|
if value := os.Getenv("DOUYU_CONFIG"); value != "" {
|
|
configFile = resolvePath(value)
|
|
legacyConfigFile = ""
|
|
}
|
|
if value := os.Getenv("DOUYU_USERS"); value != "" {
|
|
accountFile = resolvePath(value)
|
|
legacyAccountFile = ""
|
|
}
|
|
logDir := defaultLogDir(dataDir)
|
|
if value := os.Getenv("DOUYU_LOG_DIR"); value != "" {
|
|
logDir = resolvePath(value)
|
|
}
|
|
|
|
return types.AppPaths{
|
|
DataDir: dataDir,
|
|
ConfigFile: configFile,
|
|
AccountFile: accountFile,
|
|
LegacyConfigFile: legacyConfigFile,
|
|
LegacyAccountFile: legacyAccountFile,
|
|
LogDir: logDir,
|
|
SuccessDir: resolvePath(envOrDefault("DOUYU_SUCCESS_DIR", filepath.Join(dataDir, constants.DefaultSuccessDir))),
|
|
FontFiles: imgpkg.CollectFontCandidates(),
|
|
}
|
|
}
|
|
|
|
// defaultLogDir 在 wails dev 中把日志放在项目工作目录,便于开发时直接查看;
|
|
// 正式包始终使用用户应用数据目录,避免写入应用包或受限目录。
|
|
func defaultLogDir(dataDir string) string {
|
|
if isWailsDev() {
|
|
return resolvePath(constants.DefaultLogDir)
|
|
}
|
|
return filepath.Join(dataDir, constants.DefaultLogDir)
|
|
}
|
|
|
|
func isWailsDev() bool {
|
|
return os.Getenv("devserver") != "" || os.Getenv("frontenddevserverurl") != ""
|
|
}
|
|
|
|
func defaultAppDataDir() string {
|
|
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
|
|
return filepath.Join(dir, constants.DefaultAppDataDirName)
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
|
return filepath.Join(home, "."+constants.DefaultAppDataDirName)
|
|
}
|
|
return "." + constants.DefaultAppDataDirName
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// MigrateLegacyFile 在目标文件不存在时复制旧版本数据,不删除旧文件。
|
|
// 返回值表示是否完成了迁移。
|
|
func MigrateLegacyFile(target, legacy string) (bool, error) {
|
|
if target == "" || legacy == "" || samePath(target, legacy) {
|
|
return false, nil
|
|
}
|
|
if _, err := os.Stat(target); err == nil {
|
|
return false, nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return false, err
|
|
}
|
|
|
|
content, err := os.ReadFile(legacy)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
|
|
return false, err
|
|
}
|
|
if err := os.WriteFile(target, content, 0600); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func samePath(left, right string) bool {
|
|
return filepath.Clean(left) == filepath.Clean(right)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// defaultConfig 返回内置默认配置。
|
|
func defaultConfig() types.Config {
|
|
return types.Config{
|
|
Activity: activity.DefaultActivityID,
|
|
TimeAPIURL: constants.DefaultTimeAPI,
|
|
TraceEnabled: false,
|
|
TraceMaxBody: 0,
|
|
LogLevel: "info",
|
|
Concurrency: constants.DefaultConcurrency,
|
|
Attempts: constants.DefaultAttempts,
|
|
RetryStages: defaultRetryStages(),
|
|
SeckillMode: types.SeckillModeAggressive,
|
|
XiaodianPageSize: 200,
|
|
}
|
|
}
|
|
|
|
// defaultRetryStages 返回默认的锁单重试节奏:按经过秒数分档,越往后越慢。
|
|
// 手册链路的每个账号都按这份配置独立重试。
|
|
func defaultRetryStages() []types.RetryStage {
|
|
return []types.RetryStage{
|
|
{MaxElapsed: 4, Interval: 120},
|
|
{MaxElapsed: 12, Interval: 200},
|
|
{MaxElapsed: 30, Interval: 500},
|
|
{MaxElapsed: 75, Interval: 1000},
|
|
}
|
|
}
|
|
|
|
// LoadConfig 读取配置文件;缺失时安静使用内置默认配置。
|
|
func LoadConfig(path string) types.Config {
|
|
cfg := defaultConfig()
|
|
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 defaultConfig()
|
|
}
|
|
if cfg.Activity == "" {
|
|
cfg.Activity = activity.DefaultActivityID
|
|
}
|
|
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"
|
|
}
|
|
if len(cfg.RetryStages) == 0 {
|
|
cfg.RetryStages = defaultRetryStages()
|
|
}
|
|
normalizeXiaodianConfig(&cfg)
|
|
logger.LogEvent("配置", "success", "配置加载完成: %s", path)
|
|
return cfg
|
|
}
|
|
|
|
// normalizeXiaodianConfig 约束和平小店分页参数,接口单页最大为 200。
|
|
func normalizeXiaodianConfig(cfg *types.Config) {
|
|
if cfg.XiaodianPageSize <= 0 || cfg.XiaodianPageSize > 200 {
|
|
cfg.XiaodianPageSize = 200
|
|
}
|
|
}
|
|
|
|
// SaveConfig 将配置写入文件。
|
|
func SaveConfig(path string, cfg types.Config) error {
|
|
if path == "" {
|
|
path = constants.DefaultConfigFile
|
|
}
|
|
data, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0600)
|
|
}
|