Files
Cyrene_v2/backend/internal/config/loader.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

172 lines
5.8 KiB
Go

package config
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/joho/godotenv"
)
// Load reads configuration from environment variables, with optional .env file support.
// It returns a fully populated Config or an error if the .env file failed to load.
// Use ValidateOrFatal() after loading to enforce required fields.
func Load() (*Config, error) {
cfg := DefaultConfig()
// Attempt to load .env file from multiple locations
_ = loadEnvFile()
// Merge environment variables over defaults
cfg.mergeEnv()
return cfg, nil
}
// loadEnvFile tries to load .env from the project root.
// It does not return an error; missing .env is acceptable.
func loadEnvFile() error {
// Try current directory first
if err := godotenv.Load(); err == nil {
return nil
}
// Try relative to executable
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
candidates := []string{
filepath.Join(exeDir, ".env"),
filepath.Join(exeDir, "..", ".env"),
filepath.Join(exeDir, "..", "..", ".env"),
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
_ = godotenv.Load(path)
return nil
}
}
}
return fmt.Errorf(".env not found")
}
// mergeEnv reads all environment variables and overrides config defaults.
func (c *Config) mergeEnv() {
// Environment
c.Env = getEnv("ENV", c.Env)
c.LogLevel = getEnv("LOG_LEVEL", c.LogLevel)
c.LogFmt = getEnv("LOG_FORMAT", c.LogFmt)
// Server
c.Port = getEnvInt("PORT", c.Port)
c.Host = getEnv("HOST", c.Host)
c.GinMode = getEnv("GIN_MODE", c.GinMode)
// Database
c.DatabaseURL = os.Getenv("DATABASE_URL")
c.PostgresHost = getEnv("POSTGRES_HOST", c.PostgresHost)
c.PostgresPort = getEnvInt("POSTGRES_PORT", c.PostgresPort)
c.PostgresUser = getEnv("POSTGRES_USER", c.PostgresUser)
c.PostgresPassword = getEnv("POSTGRES_PASSWORD", c.PostgresPassword)
c.PostgresDB = getEnv("POSTGRES_DB", c.PostgresDB)
c.PostgresSSLMode = getEnv("POSTGRES_SSLMODE", c.PostgresSSLMode)
// NATS
c.NATSUrl = getEnv("NATS_URL", c.NATSUrl)
// Redis
c.RedisHost = getEnv("REDIS_HOST", c.RedisHost)
c.RedisPort = getEnvInt("REDIS_PORT", c.RedisPort)
// LLM
c.LLMBaseURL = getEnv("LLM_API_URL", c.LLMBaseURL)
c.LLMAPIKey = getEnv("LLM_API_KEY", c.LLMAPIKey)
c.LLMModel = getEnv("LLM_MODEL", c.LLMModel)
c.LLMFallbackModel = getEnv("LLM_FALLBACK_MODEL", c.LLMFallbackModel)
// Embedding
c.EmbeddingAPIURL = getEnv("EMBEDDING_API_URL", c.EmbeddingAPIURL)
c.EmbeddingModel = getEnv("EMBEDDING_MODEL", c.EmbeddingModel)
c.EmbeddingAPIKey = getEnv("EMBEDDING_API_KEY", c.EmbeddingAPIKey)
// Voice
c.DashScopeAPIKey = getEnv("DASHSCOPE_API_KEY", c.DashScopeAPIKey)
c.TTSProvider = getEnv("TTS_PROVIDER", c.TTSProvider)
c.ASRProvider = getEnv("ASR_PROVIDER", c.ASRProvider)
// Auth
c.JWTSecret = getEnv("JWT_SECRET", c.JWTSecret)
c.JWTExpiryHours = getEnvInt("JWT_EXPIRY_HOURS", c.JWTExpiryHours)
c.AdminUsername = getEnv("ADMIN_USERNAME", c.AdminUsername)
c.AdminPassword = getEnv("ADMIN_PASSWORD", c.AdminPassword)
c.RegistrationOpen = getEnvBool("REGISTRATION_ENABLED", c.RegistrationOpen)
// Internal Service
c.InternalServiceToken = getEnv("INTERNAL_SERVICE_TOKEN", c.InternalServiceToken)
// CORS
c.AllowedOrigins = getEnvSlice("ALLOWED_ORIGINS", c.AllowedOrigins)
// Background Thinking
c.EnableBackgroundThinking = getEnvBool("ENABLE_BACKGROUND_THINKING", c.EnableBackgroundThinking)
c.ThinkIntervalSec = getEnvInt("THINK_INTERVAL_SEC", c.ThinkIntervalSec)
c.ThinkSilenceTimeoutSec = getEnvInt("THINK_SILENCE_TIMEOUT_SEC", c.ThinkSilenceTimeoutSec)
// Platform Bridge
c.PlatformBridgeEnabled = getEnvBool("PLATFORM_BRIDGE_ENABLED", c.PlatformBridgeEnabled)
c.PlatformChannels = getEnv("PLATFORM_CHANNELS", c.PlatformChannels)
// IoT
c.IoTProvider = getEnv("IOT_PROVIDER", c.IoTProvider)
c.IoTServiceURL = getEnv("IOT_SERVICE_URL", c.IoTServiceURL)
// RAG & Data
c.PersonaDir = getEnv("PERSONA_DIR", c.PersonaDir)
c.KnowledgeDir = getEnv("KNOWLEDGE_DIR", c.KnowledgeDir)
c.DataDir = getEnv("DATA_DIR", c.DataDir)
// Search
c.SearXNGURL = getEnv("SEARXNG_URL", c.SearXNGURL)
// Host Exec
c.HostExecBackend = getEnv("HOST_EXEC_BACKEND", c.HostExecBackend)
// Caddy (production)
c.Domain = getEnv("DOMAIN", c.Domain)
c.ACMEEmail = getEnv("ACME_EMAIL", c.ACMEEmail)
// Ethend (deprecated)
c.EthendPort = getEnvInt("ETHEND_PORT", c.EthendPort)
// WebSocket
c.WSMaxConnections = getEnvInt("WS_MAX_CONNECTIONS", c.WSMaxConnections)
c.SessionIdleTimeoutMin = getEnvInt("SESSION_IDLE_TIMEOUT_MIN", c.SessionIdleTimeoutMin)
// Timezone
c.TZ = getEnv("TZ", c.TZ)
}
// logSanitized prints the loaded config, with sensitive fields redacted.
func (c *Config) LogSanitized() {
log.Printf("[config] ENV=%s LOG_LEVEL=%s LOG_FORMAT=%s", c.Env, c.LogLevel, c.LogFmt)
log.Printf("[config] PORT=%d GIN_MODE=%s", c.Port, c.GinMode)
log.Printf("[config] DB_HOST=%s DB_PORT=%d DB_USER=%s DB_NAME=%s",
c.PostgresHost, c.PostgresPort, c.PostgresUser, c.PostgresDB)
log.Printf("[config] NATS_URL=%s REDIS=%s:%d", c.NATSUrl, c.RedisHost, c.RedisPort)
log.Printf("[config] LLM_URL=%s LLM_MODEL=%s FALLBACK=%s",
c.LLMBaseURL, c.LLMModel, c.LLMFallbackModel)
log.Printf("[config] LLM_API_KEY=**** (len=%d)", len(c.LLMAPIKey))
if c.EmbeddingAPIURL != "" {
log.Printf("[config] EMBEDDING_URL=%s MODEL=%s", c.EmbeddingAPIURL, c.EmbeddingModel)
} else {
log.Printf("[config] EMBEDDING=disabled (using n-gram fallback)")
}
log.Printf("[config] IOT_PROVIDER=%s", c.IoTProvider)
log.Printf("[config] TTS=%s ASR=%s", c.TTSProvider, c.ASRProvider)
log.Printf("[config] BACKGROUND_THINKING=%v INTERVAL=%ds SILENCE=%ds",
c.EnableBackgroundThinking, c.ThinkIntervalSec, c.ThinkSilenceTimeoutSec)
log.Printf("[config] JWT_SECRET=**** (len=%d)", len(c.JWTSecret))
log.Printf("[config] ADMIN=%s REGISTRATION=%v", c.AdminUsername, c.RegistrationOpen)
}