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 { dataDir := resolvePath(envOrDefault("DOUYU_DATA_DIR", defaultAppDataDir())) configFile := filepath.Join(dataDir, constants.DefaultConfigFile) accountFile := filepath.Join(dataDir, constants.DefaultAccountFile) 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 = "" } return types.AppPaths{ DataDir: dataDir, ConfigFile: configFile, AccountFile: accountFile, LegacyConfigFile: legacyConfigFile, LegacyAccountFile: legacyAccountFile, LogDir: resolvePath(envOrDefault("DOUYU_LOG_DIR", filepath.Join(dataDir, constants.DefaultLogDir))), SuccessDir: resolvePath(envOrDefault("DOUYU_SUCCESS_DIR", filepath.Join(dataDir, constants.DefaultSuccessDir))), FontFiles: imgpkg.CollectFontCandidates(), } } 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) }