Files
hfb_sys/backend/internal/config/config.go
T

246 lines
9.2 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 (
"errors"
"os"
"strconv"
"strings"
)
type Config struct {
AppEnv string
AppAddr string
MySQLDSN string
RedisAddr string
RedisPassword string
RedisDB int
JWTSecret string
PaymentConfigEncryptionKey string
FieldEncryptionKey string
FieldEncryptionLegacyKey string
ExternalUploadSecret string
ExternalUploadAllowedIPs []string
BootstrapAdminUsername string
BootstrapAdminPassword string
BootstrapAdminNickname string
Storage StorageConfig
StorageMirror StorageConfig
SMS SMSConfig
Realname RealnameConfig
Log LogConfig
RateLimit RateLimitConfig
}
type StorageConfig struct {
Endpoint string
Bucket string
AccessKeyID string
SecretAccessKey string
Region string
BucketLookup string
}
type SMSConfig struct {
Provider string
AliyunAccessKeyID string
AliyunAccessKeySecret string
AliyunEndpoint string
AliyunSignName string
AliyunLoginTemplateCode string
}
type RealnameConfig struct {
Provider string
CloudMarketURL string
CloudMarketAppCode string
}
type LogConfig struct {
Level string
Dir string
EnableConsole bool
EnableFile bool
// RetainDays 日志保留天数(含当天);过期的 .log / .log.gz 会被清理。0 表示不自动清理。
RetainDays int
}
type RateLimitConfig struct {
Enabled bool
RequestsPerMinute int
}
func Load() Config {
return Config{
AppEnv: getEnv("APP_ENV", "development"),
AppAddr: getEnv("APP_ADDR", ":18088"),
MySQLDSN: getEnv("MYSQL_DSN", "hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"),
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:16379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisDB: getEnvInt("REDIS_DB", 0),
JWTSecret: getEnv("JWT_SECRET", "change-me"),
PaymentConfigEncryptionKey: getEnv("PAYMENT_CONFIG_ENCRYPTION_KEY", ""),
FieldEncryptionKey: getEnv("FIELD_ENCRYPTION_KEY", ""),
// 旧密钥回退:生产环境必须显式设置(空 = 禁用回退,用于密钥轮换收敛);
// 非生产默认填历史硬编码密钥,兼容开发/测试库的存量密文。不能用 getEnv 的 fallback——
// 那样生产删掉 env 会重新注入已泄露的硬编码密钥,破坏轮换闭环。
FieldEncryptionLegacyKey: fieldEncryptionLegacyKey(getEnv("APP_ENV", "development")),
ExternalUploadSecret: getEnv("EXTERNAL_UPLOAD_SECRET", ""),
ExternalUploadAllowedIPs: getEnvList("EXTERNAL_UPLOAD_ALLOWED_IPS"),
BootstrapAdminUsername: getEnv("ADMIN_BOOTSTRAP_USERNAME", ""),
BootstrapAdminPassword: getEnv("ADMIN_BOOTSTRAP_PASSWORD", ""),
BootstrapAdminNickname: getEnv("ADMIN_BOOTSTRAP_NICKNAME", "超级管理员"),
Storage: StorageConfig{
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:19090"),
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
AccessKeyID: getEnv("STORAGE_ACCESS_KEY_ID", "minioadmin"),
SecretAccessKey: getEnv("STORAGE_SECRET_ACCESS_KEY", "minioadmin"),
Region: getEnv("STORAGE_REGION", ""),
BucketLookup: getEnv("STORAGE_BUCKET_LOOKUP", "auto"),
},
// StorageMirror 仅在对象存储迁移期间使用。镜像端配置完整时,新上传文件会同步写入两个存储端。
StorageMirror: StorageConfig{
Endpoint: getEnv("STORAGE_MIRROR_ENDPOINT", ""),
Bucket: getEnv("STORAGE_MIRROR_BUCKET", ""),
AccessKeyID: getEnv("STORAGE_MIRROR_ACCESS_KEY_ID", ""),
SecretAccessKey: getEnv("STORAGE_MIRROR_SECRET_ACCESS_KEY", ""),
Region: getEnv("STORAGE_MIRROR_REGION", ""),
BucketLookup: getEnv("STORAGE_MIRROR_BUCKET_LOOKUP", "auto"),
},
SMS: SMSConfig{
Provider: getEnv("SMS_PROVIDER", "mock"),
AliyunAccessKeyID: getEnv("ALIYUN_ACCESS_KEY_ID", ""),
AliyunAccessKeySecret: getEnv("ALIYUN_ACCESS_KEY_SECRET", ""),
AliyunEndpoint: getEnv("ALIYUN_SMS_ENDPOINT", "dysmsapi.aliyuncs.com"),
AliyunSignName: getEnv("ALIYUN_SMS_SIGN_NAME", ""),
AliyunLoginTemplateCode: getEnv("ALIYUN_SMS_LOGIN_TEMPLATE_CODE", ""),
},
Realname: RealnameConfig{
Provider: getEnv("REALNAME_PROVIDER", "mock"),
CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://sinocheck2.market.alicloudapi.com/fortest/ttttt"),
CloudMarketAppCode: getEnv("REALNAME_CLOUDMARKET_APPCODE", ""),
},
Log: LogConfig{
Level: getEnv("LOG_LEVEL", "info"),
Dir: getEnv("LOG_DIR", "logs"),
EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true),
EnableFile: getEnvBool("LOG_ENABLE_FILE", true),
RetainDays: getEnvInt("LOG_RETAIN_DAYS", 14),
},
RateLimit: RateLimitConfig{
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300),
},
}
}
func (c Config) ValidateProductionSecurity() error {
if !IsProductionEnv(c.AppEnv) {
return nil
}
if !strings.EqualFold(strings.TrimSpace(c.SMS.Provider), "aliyun") {
return errors.New("SMS_PROVIDER must be aliyun in production")
}
if strings.TrimSpace(c.SMS.AliyunAccessKeyID) == "" || strings.TrimSpace(c.SMS.AliyunAccessKeySecret) == "" ||
strings.TrimSpace(c.SMS.AliyunSignName) == "" || strings.TrimSpace(c.SMS.AliyunLoginTemplateCode) == "" {
return errors.New("aliyun SMS credentials, sign name, and template code are required in production")
}
if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 {
return errors.New("JWT_SECRET must be a non-default random value of at least 32 bytes in production")
}
keyLen := len([]byte(c.PaymentConfigEncryptionKey))
if isPlaceholder(c.PaymentConfigEncryptionKey) || (keyLen != 16 && keyLen != 24 && keyLen != 32) {
return errors.New("PAYMENT_CONFIG_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production")
}
fieldKeyLen := len([]byte(c.FieldEncryptionKey))
if c.FieldEncryptionKey == "" || isPlaceholder(c.FieldEncryptionKey) || (fieldKeyLen != 16 && fieldKeyLen != 24 && fieldKeyLen != 32) {
return errors.New("FIELD_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production")
}
legacyKeyLen := len([]byte(c.FieldEncryptionLegacyKey))
if c.FieldEncryptionLegacyKey != "" && (isPlaceholder(c.FieldEncryptionLegacyKey) || (legacyKeyLen != 16 && legacyKeyLen != 24 && legacyKeyLen != 32)) {
return errors.New("FIELD_ENCRYPTION_LEGACY_KEY must be empty or 16, 24, or 32 bytes in production")
}
if c.FieldEncryptionKey == c.FieldEncryptionLegacyKey {
return errors.New("FIELD_ENCRYPTION_KEY must differ from FIELD_ENCRYPTION_LEGACY_KEY in production (set a new primary key to rotate)")
}
if c.BootstrapAdminPassword != "" && isPlaceholder(c.BootstrapAdminPassword) {
return errors.New("ADMIN_BOOTSTRAP_PASSWORD must not use the example placeholder in production")
}
return nil
}
func getEnv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
// historicFieldEncryptionKey 是迁移前 pkg/crypto 的硬编码密钥,仅用于解密历史存量密文。
const historicFieldEncryptionKey = "hfb-sys-2024-secret-key-32bytes!"
// fieldEncryptionLegacyKey 解析 FIELD_ENCRYPTION_LEGACY_KEY
// - 生产环境:必须显式设置,空表示禁用回退(密钥轮换收敛后删除 env 即关闭旧密钥)。
// 不能用硬编码默认值,否则轮换闭环不成立。
// - 非生产环境:未设置时回退到历史硬编码密钥,兼容开发/测试库的存量密文(零配置)。
func fieldEncryptionLegacyKey(appEnv string) string {
if IsProductionEnv(appEnv) {
// 生产显式空 = 禁用;未设置也视为禁用。
return os.Getenv("FIELD_ENCRYPTION_LEGACY_KEY")
}
if v := os.Getenv("FIELD_ENCRYPTION_LEGACY_KEY"); v != "" {
return v
}
return historicFieldEncryptionKey
}
func getEnvInt(key string, fallback int) int {
value := os.Getenv(key)
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func getEnvBool(key string, fallback bool) bool {
value := os.Getenv(key)
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func getEnvList(key string) []string {
raw := os.Getenv(key)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value != "" {
values = append(values, value)
}
}
return values
}
func isPlaceholder(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "change-me" || strings.HasPrefix(normalized, "change-") || strings.Contains(normalized, "change-to-")
}
// IsProductionEnv 判断 AppEnv 是否为生产环境(与 ValidateProductionSecurity 同口径)。
func IsProductionEnv(appEnv string) bool {
return strings.ToLower(strings.TrimSpace(appEnv)) == "production"
}