Files
affiliate_dash/backend/internal/config/config.go
T
yml2213 bab522bcb3 feat(auth): 接入 DCAuth v2 登录并完善成员管理
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone
- 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号
- 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码
- 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
2026-10-21 22:37:49 +08:00

234 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}