// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。 package config import ( "errors" "fmt" "os" "strconv" "strings" "time" "golang.org/x/crypto/bcrypt" ) const ( adminUsernameEnv = "CMBUYER_ADMIN_USERNAME" adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT" sessionSecretEnv = "CMBUYER_SESSION_SECRET" cookieSecureEnv = "CMBUYER_COOKIE_SECURE" databaseSourceEnv = "CMBUYER_DATABASE_SOURCE" authorizationTTLEnv = "CMBUYER_AUTHORIZATION_TTL" maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY" maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE" minimumSecretLength = 32 ) // Config 是启动采购服务所需的最小安全配置。 type Config struct { AdminUsername string AdminPasswordBcrypt string SessionSecret []byte CookieSecure bool DatabaseSource string AuthorizationTTL time.Duration MaxTaskQuantity int MaxTotalPrice string } // LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。 func LoadFromEnv() (Config, error) { return Load(os.LookupEnv) } // Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。 func Load(lookup func(string) (string, bool)) (Config, error) { username, err := required(lookup, adminUsernameEnv) if err != nil { return Config{}, err } passwordHash, err := required(lookup, adminPasswordBcryptEnv) if err != nil { return Config{}, err } if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil { return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv) } secret, err := required(lookup, sessionSecretEnv) if err != nil { return Config{}, err } if len([]byte(secret)) < minimumSecretLength { return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength) } cookieSecure := false if value, present := lookup(cookieSecureEnv); present { switch value { case "true": cookieSecure = true case "false": cookieSecure = false default: return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv) } } databaseSource, err := required(lookup, databaseSourceEnv) if err != nil { return Config{}, err } ttlText, err := required(lookup, authorizationTTLEnv) if err != nil { return Config{}, err } ttl, err := time.ParseDuration(ttlText) if err != nil || ttl <= 0 { return Config{}, fmt.Errorf("%s must be a positive duration", authorizationTTLEnv) } quantityText, err := required(lookup, maxTaskQuantityEnv) if err != nil { return Config{}, err } maxQuantity, err := strconv.Atoi(quantityText) if err != nil || maxQuantity < 1 { return Config{}, fmt.Errorf("%s must be a positive integer", maxTaskQuantityEnv) } maxPrice, err := required(lookup, maxTotalPriceEnv) if err != nil { return Config{}, err } if !canonicalMoney(maxPrice) { return Config{}, fmt.Errorf("%s must be a canonical positive decimal", maxTotalPriceEnv) } return Config{ AdminUsername: username, AdminPasswordBcrypt: passwordHash, SessionSecret: []byte(secret), CookieSecure: cookieSecure, DatabaseSource: databaseSource, AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice, }, nil } func canonicalMoney(value string) bool { parts := strings.Split(value, ".") if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') { return false } for _, part := range parts { for _, ch := range part { if ch < '0' || ch > '9' { return false } } } return strings.Trim(parts[0]+parts[1], "0") != "" } func required(lookup func(string) (string, bool), name string) (string, error) { value, present := lookup(name) if !present || strings.TrimSpace(value) == "" { return "", errors.New(name + " must be set") } return value, nil }