Files
Cyrene_v2/backend/internal/config/config.go
T
Aska 7943e290ec feat(internal): add foundational packages — config, logger, errors
- config: env-based loading with startup validation, 13 tests
- logger: structured slog wrapper with redaction, 10 tests
- errors: unified AppError model with HTTP mapping, 18 tests
- 41 tests total, build + vet clean
2026-08-09 20:35:42 +08:00

312 lines
7.2 KiB
Go

// Package config provides configuration loading with environment variable binding and startup validation.
// All configuration values are loaded from environment variables with sensible defaults.
// Required fields that are missing will cause a fatal error at startup.
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
// Config holds all application configuration.
// Fields are populated from environment variables during Load().
type Config struct {
// Environment
Env string // "development" | "production" | "test"
LogLevel string // "debug" | "info" | "warn" | "error"
LogFmt string // "text" | "json"
// Server
Port int
Host string
GinMode string // "debug" | "release" | "test"
// Database
DatabaseURL string // Full connection string (takes priority)
PostgresHost string
PostgresPort int
PostgresUser string
PostgresPassword string
PostgresDB string
PostgresSSLMode string
// NATS
NATSUrl string
// Redis
RedisHost string
RedisPort int
// LLM
LLMBaseURL string
LLMAPIKey string
LLMModel string
LLMFallbackModel string
// Embedding
EmbeddingAPIURL string
EmbeddingModel string
EmbeddingAPIKey string
// Voice
DashScopeAPIKey string
TTSProvider string // "edge-tts" | "espeak"
ASRProvider string // "dashscope" | "whisper-local"
// Auth
JWTSecret string
JWTExpiryHours int
AdminUsername string
AdminPassword string
RegistrationOpen bool
// Internal Service
InternalServiceToken string
// CORS
AllowedOrigins []string
// Background Thinking
EnableBackgroundThinking bool
ThinkIntervalSec int
ThinkSilenceTimeoutSec int
// Platform Bridge
PlatformBridgeEnabled bool
PlatformChannels string
// IoT
IoTProvider string // "mock" | "homeassistant" | "mqtt" | "none"
// IoT Debug Service URL (legacy, kept for v1 compat)
IoTServiceURL string
// RAG
PersonaDir string
KnowledgeDir string
DataDir string
// Search
SearXNGURL string
// Host Exec
HostExecBackend string // "wsl" | "docker" | "direct" | ""
// Caddy (production)
Domain string
ACMEEmail string
// Ethend (deprecated, kept for compat)
EthendPort int
// WebSocket
WSMaxConnections int
SessionIdleTimeoutMin int
// Timezone
TZ string
}
// DefaultConfig returns a Config populated with safe default values.
func DefaultConfig() *Config {
return &Config{
Env: "development",
LogLevel: "info",
LogFmt: "text",
Port: 8080,
Host: "0.0.0.0",
GinMode: "debug",
PostgresHost: "localhost",
PostgresPort: 5432,
PostgresUser: "cyrene",
PostgresPassword: "cyrene_pass",
PostgresDB: "cyrene_ai",
PostgresSSLMode: "disable",
NATSUrl: "nats://localhost:4222",
RedisHost: "localhost",
RedisPort: 6379,
LLMBaseURL: "https://api.openai.com/v1",
LLMModel: "gpt-4o",
LLMFallbackModel: "gpt-4o-mini",
EmbeddingModel: "text-embedding-3-small",
TTSProvider: "edge-tts",
ASRProvider: "dashscope",
JWTExpiryHours: 720,
AdminUsername: "admin",
RegistrationOpen: true,
EnableBackgroundThinking: true,
ThinkIntervalSec: 300,
ThinkSilenceTimeoutSec: 120,
IoTProvider: "mock",
PersonaDir: "./internal/persona",
KnowledgeDir: "./data/knowledge",
DataDir: "./data",
HostExecBackend: "",
EthendPort: 9090,
WSMaxConnections: 1000,
SessionIdleTimeoutMin: 30,
TZ: "Asia/Shanghai",
}
}
// DatabaseURL returns the constructed database URL if no explicit DATABASE_URL was set.
func (c *Config) GetDatabaseURL() string {
if c.DatabaseURL != "" {
return c.DatabaseURL
}
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
c.PostgresUser, c.PostgresPassword, c.PostgresHost, c.PostgresPort, c.PostgresDB, c.PostgresSSLMode)
}
// IsDevelopment returns true when running in development mode.
func (c *Config) IsDevelopment() bool {
return c.Env == "development"
}
// IsProduction returns true when running in production mode.
func (c *Config) IsProduction() bool {
return c.Env == "production"
}
// validate runs all validation checks on the loaded config.
// Returns a list of validation errors.
func (c *Config) validate() []string {
var errs []string
// Required: LLM API Key (only required in production or when not using mock)
if c.LLMAPIKey == "" {
errs = append(errs, "LLM_API_KEY is required")
}
// Required: JWT Secret
if c.JWTSecret == "" {
errs = append(errs, "JWT_SECRET is required")
}
if len(c.JWTSecret) < 32 {
errs = append(errs, "JWT_SECRET must be at least 32 characters")
}
// Required: Admin Password
if c.AdminPassword == "" {
errs = append(errs, "ADMIN_PASSWORD is required")
}
if len(c.AdminPassword) < 8 {
errs = append(errs, "ADMIN_PASSWORD must be at least 8 characters")
}
// Required: Internal Service Token
if c.InternalServiceToken == "" && c.IsProduction() {
errs = append(errs, "INTERNAL_SERVICE_TOKEN is required in production")
}
// IoT Provider must be valid
validIoTProviders := map[string]bool{"mock": true, "homeassistant": true, "mqtt": true, "none": true}
if !validIoTProviders[c.IoTProvider] {
errs = append(errs, fmt.Sprintf("IOT_PROVIDER must be one of: mock, homeassistant, mqtt, none (got: %s)", c.IoTProvider))
}
return errs
}
// ValidateOrFatal validates the config and calls log.Fatal if any required fields are missing.
func (c *Config) ValidateOrFatal() {
errs := c.validate()
if len(errs) > 0 {
fmt.Fprintf(os.Stderr, "=== Configuration validation errors ===\n")
for _, e := range errs {
fmt.Fprintf(os.Stderr, " - %s\n", e)
}
fmt.Fprintf(os.Stderr, "=======================================\n")
fmt.Fprintf(os.Stderr, "Fix the above errors and restart.\n")
os.Exit(1)
}
}
// --- Helper functions for loading from environment ---
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
func getEnvInt(key string, defaultVal int) int {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
i, err := strconv.Atoi(v)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid integer for %s=%q, using default %d\n", key, v, defaultVal)
return defaultVal
}
return i
}
func getEnvBool(key string, defaultVal bool) bool {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
fmt.Fprintf(os.Stderr, "WARNING: invalid boolean for %s=%q, using default %v\n", key, v, defaultVal)
return defaultVal
}
}
func getEnvSlice(key string, defaultVal []string) []string {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
parts := strings.Split(v, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
if len(result) == 0 {
return defaultVal
}
return result
}
func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
d, err := time.ParseDuration(v)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid duration for %s=%q, using default %v\n", key, v, defaultVal)
return defaultVal
}
return d
}