package config import ( "os" "path/filepath" "strconv" "strings" "github.com/joho/godotenv" ) type Config struct { Port string JWTSecret string DatabaseURL string Mode string // debug / release // DataEncryptionKey 用于加密保存 API/回调密钥;为空时由 JWT_SECRET 派生。 DataEncryptionKey string // OpenAPIKey 皮肤源头开放接口标识(Header: X-Api-Key,可公开给对接方) OpenAPIKey string // OpenAPISecret 签名密钥(仅用于 HMAC,不走 Header) OpenAPISecret string // OpenSignSkew 签名时间戳允许偏差(秒) OpenSignSkew int64 // OpenAPIDebug 开放接口详细调试日志 OpenAPIDebug bool // LogLevel 日志级别: debug / info / warn / error(默认 info) LogLevel string // LogFile 日志文件路径;非空时同时写控制台与文件(按天分割) LogFile string // LogRetainDays 日志文件保留天数(按天分割后自动清理旧文件) LogRetainDays int // DeliveryBaseURL 用户侧发货页面基础地址;为空时前端可自行按当前域名拼接。 DeliveryBaseURL string // DeliveryBFFBaseURL 上游发货 BFF 地址。 DeliveryBFFBaseURL string // DeliveryChannel 上游测试发货渠道前缀,如 dlc。 DeliveryChannel string // DeliveryLinkSecret 发货链接签名密钥。 DeliveryLinkSecret string // DeliveryLinkTTLMinutes 发货链接有效分钟数;<=0 表示永久有效。 DeliveryLinkTTLMinutes int // FulfillmentProcessingTimeoutMinutes 发货中订单超过该分钟数自动标记失败;<=0 表示关闭。 FulfillmentProcessingTimeoutMinutes int // FulfillmentTimeoutScanIntervalSeconds 发货超时巡检间隔秒数。 FulfillmentTimeoutScanIntervalSeconds int // CallbackMaxAttempts 回调推送总尝试次数(首次 + 重试),默认 16(微信式)。 CallbackMaxAttempts int // CallbackPushTimeoutSeconds 单次回调推送 HTTP 超时秒数。 CallbackPushTimeoutSeconds int // CallbackRetryScheduleSeconds 回调失败重试间隔序列(秒,逗号分隔,微信/支付宝式固定退避)。 // 为空时使用默认微信式序列。 CallbackRetryScheduleSeconds []int // UploadDir 凭证等上传文件的存储目录(默认 data/uploads,通过 /uploads 公开访问)。 UploadDir string // CORSAllowedOrigins 跨域允许的来源(逗号分隔);为空时仅放行常见本地开发端口。 // 生产前后端同域(Caddy 统一入口)时无需配置;仅在前后端分离部署时设置。 CORSAllowedOrigins []string // 登录短信渠道(WORKER_SMS_*)。provider=mock 时只写日志,不做每日限额。 SMSProvider string SMSAccessKeyID string SMSAccessKeySecret string SMSSignName string SMSTemplateCode string SMSCodeTTLSeconds int SMSCooldownSeconds int SMSDailyLimit int // 乐玩开放平台配置 LewanBaseURL string LewanApplicationID string LewanAccessKey string LewanSecretKey string LewanWebhookSecret string } // Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高) func Load() *Config { loadDotEnv() mode := getEnv("GIN_MODE", "debug") // OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启 debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "") return &Config{ Port: getEnv("PORT", "18080"), JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"), DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:15432/affiliate_dash?sslmode=disable&TimeZone=Asia/Shanghai"), Mode: mode, DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")), OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"), OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"), OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)), OpenAPIDebug: debugOpen, LogLevel: getEnv("LOG_LEVEL", "info"), LogFile: getEnv("LOG_FILE", "logs/app.log"), LogRetainDays: getEnvInt("LOG_RETAIN_DAYS", 30), DeliveryBaseURL: getEnv("DELIVERY_BASE_URL", ""), DeliveryBFFBaseURL: getEnv("DELIVERY_BFF_BASE_URL", "https://www.jxya.top/bff"), DeliveryChannel: getEnv("DELIVERY_CHANNEL", "dlc"), DeliveryLinkSecret: getEnv("DELIVERY_LINK_SECRET", getEnv("OPEN_API_SECRET", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"))), DeliveryLinkTTLMinutes: getEnvInt("DELIVERY_LINK_TTL_MINUTES", 0), FulfillmentProcessingTimeoutMinutes: getEnvInt("FULFILLMENT_PROCESSING_TIMEOUT_MINUTES", 30), FulfillmentTimeoutScanIntervalSeconds: getEnvInt("FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS", 60), CallbackMaxAttempts: getEnvInt("CALLBACK_MAX_ATTEMPTS", 16), CallbackPushTimeoutSeconds: getEnvInt("CALLBACK_PUSH_TIMEOUT_SECONDS", 15), CallbackRetryScheduleSeconds: getEnvIntList("CALLBACK_RETRY_SCHEDULE", nil), UploadDir: getEnv("UPLOAD_DIR", "data/uploads"), CORSAllowedOrigins: getEnvList("CORS_ALLOWED_ORIGINS", nil), SMSProvider: getEnv("WORKER_SMS_PROVIDER", "mock"), SMSAccessKeyID: getEnv("WORKER_SMS_ACCESS_KEY_ID", ""), SMSAccessKeySecret: getEnv("WORKER_SMS_ACCESS_KEY_SECRET", ""), SMSSignName: getEnv("WORKER_SMS_SIGN_NAME", ""), SMSTemplateCode: getEnv("WORKER_SMS_TEMPLATE_CODE", ""), SMSCodeTTLSeconds: getEnvInt("WORKER_SMS_CODE_TTL_SECONDS", 300), SMSCooldownSeconds: getEnvInt("WORKER_SMS_COOLDOWN_SECONDS", 60), SMSDailyLimit: getEnvInt("WORKER_SMS_DAILY_LIMIT", 10), LewanBaseURL: getEnv("LEWAN_BASE_URL", "https://shop.cloudtentacles.com"), LewanApplicationID: getEnv("LEWAN_APPLICATION_ID", ""), LewanAccessKey: getEnv("LEWAN_ACCESS_KEY", ""), LewanSecretKey: getEnv("LEWAN_SECRET_KEY", ""), LewanWebhookSecret: getEnv("LEWAN_WEBHOOK_SECRET", ""), } } // 仓库 .env.example 中的占位密钥。这些值对所有人公开,一旦被带到真实部署, const exampleJWTSecret = "your_very_secure_jwt_secret_here_change_me_in_production" // SecurityWarnings 返回启动时的配置风险清单;返回空切片表示配置正常。 func (c *Config) SecurityWarnings() []string { var warnings []string if c.JWTSecret == exampleJWTSecret { warnings = append(warnings, "JWT_SECRET 仍是 .env.example 里的示例值,建议生成生产随机密钥以防 Token 伪造") } if c.Mode == "release" && c.OpenAPIDebug { warnings = append(warnings, "GIN_MODE=release 下开启了 OPEN_API_DEBUG,开放接口报文详情会持续写盘") } return warnings } // getEnvList 解析逗号分隔的字符串列表(如 CORS 来源);空串返回默认值。 func getEnvList(key string, def []string) []string { v := os.Getenv(key) if v == "" { return def } var out []string for _, part := range strings.Split(v, ",") { if s := strings.TrimSpace(part); s != "" { out = append(out, s) } } if len(out) == 0 { return def } return out } // getEnvIntList 解析逗号分隔的整数列表(如 "15,15,30,180");空串返回 nil。 func getEnvIntList(key string, def []int) []int { v := os.Getenv(key) if v == "" { return def } var out []int for _, part := range strings.Split(v, ",") { n, err := strconv.Atoi(strings.TrimSpace(part)) if err != nil || n < 1 { continue } out = append(out, n) } if len(out) == 0 { return def } return out } func getEnvBool(key string, def bool) bool { v := os.Getenv(key) if v == "" { return def } switch strings.ToLower(strings.TrimSpace(v)) { case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: return def } } // loadDotEnv 依次尝试常见路径,不覆盖已有环境变量 func loadDotEnv() { var paths []string if wd, err := os.Getwd(); err == nil { paths = append(paths, filepath.Join(wd, ".env"), filepath.Join(wd, "..", ".env"), // 在 backend/ 下启动时读仓库根 .env filepath.Join(wd, "backend", ".env"), // 在仓库根启动时 ) } paths = append(paths, ".env") seen := map[string]struct{}{} for _, p := range paths { abs, err := filepath.Abs(p) if err != nil { continue } if _, ok := seen[abs]; ok { continue } seen[abs] = struct{}{} _ = godotenv.Load(abs) // 忽略不存在;不覆盖已在环境中的变量 } } func getEnv(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func getEnvInt(key string, def int) int { if v := os.Getenv(key); v != "" { if n, err := strconv.Atoi(v); err == nil { return n } } return def }