.env 中 JWT_SECRET 仍逐字节等于 .env.example 的示例值,且 DATA_ENCRYPTION_KEY 未设置、按 config.go 的回退逻辑实际等于 JWT_SECRET。任何见过仓库的人都能伪造 管理员 JWT,并解密库中全部 API Client / 回调密钥密文。 - JWT_SECRET 与 DATA_ENCRYPTION_KEY 改为各自独立的强随机值 - 新增 Config.SecurityWarnings():示例密钥 / 未分离 / release 下开调试日志均告警 - 新增 cmd/reencrypt_secrets:默认只预演,-apply 才写库;幂等,坏数据不中断整轮 - 新增 docs/密钥轮换说明.md;.gitignore 忽略 .env.bak* - release 模式下关闭 OPEN_API_DEBUG
229 lines
9.0 KiB
Go
229 lines
9.0 KiB
Go
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
|
||
|
||
// 乐玩开放平台配置
|
||
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),
|
||
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 中的占位密钥。这些值对所有人公开,一旦被带到真实部署,
|
||
// JWT 可被伪造、落库密文可被解密,因此启动时必须显式告警。
|
||
const (
|
||
exampleJWTSecret = "your_very_secure_jwt_secret_here_change_me_in_production"
|
||
exampleDataKey = "your_32_plus_char_data_encryption_key_change_me"
|
||
)
|
||
|
||
// SecurityWarnings 返回启动时的密钥配置风险清单;返回空切片表示配置正常。
|
||
// 刻意只告警不阻断:升级过程直接 fatal 会让存量部署起不来,风险由运维可见性兜住。
|
||
func (c *Config) SecurityWarnings() []string {
|
||
var warnings []string
|
||
if c.JWTSecret == exampleJWTSecret {
|
||
warnings = append(warnings, "JWT_SECRET 仍是 .env.example 里的示例值,任何见过仓库的人都能伪造管理员 Token")
|
||
}
|
||
if os.Getenv("DATA_ENCRYPTION_KEY") == "" {
|
||
warnings = append(warnings, "DATA_ENCRYPTION_KEY 未显式设置,已回退到 JWT_SECRET;请设置独立密钥,更换后需用 cmd/reencrypt_secrets 重加密历史密文")
|
||
} else if c.DataEncryptionKey == exampleDataKey {
|
||
warnings = append(warnings, "DATA_ENCRYPTION_KEY 仍是 .env.example 里的示例值,落库的 API/回调密钥可被解密")
|
||
}
|
||
if c.DataEncryptionKey == c.JWTSecret {
|
||
warnings = append(warnings, "DATA_ENCRYPTION_KEY 与 JWT_SECRET 相同,建议分离以便独立轮换")
|
||
}
|
||
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
|
||
}
|