package config import ( "context" "fmt" "os" "path/filepath" "reflect" "strings" "sync" "time" "github.com/mitchellh/mapstructure" "go.uber.org/zap" sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config" configruntime "github.com/geo-platform/tenant-api/internal/shared/config/runtime" runtimeenv "github.com/geo-platform/tenant-api/internal/shared/config/runtime/env" runtimefile "github.com/geo-platform/tenant-api/internal/shared/config/runtime/file" ) type Config struct { Server sharedconfig.ServerConfig `mapstructure:"server"` Database sharedconfig.DatabaseConfig `mapstructure:"database"` MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"` Redis sharedconfig.RedisConfig `mapstructure:"redis"` Cache sharedconfig.CacheConfig `mapstructure:"cache"` RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"` ObjectStorage sharedconfig.ObjectStorageConfig `mapstructure:"object_storage"` Log sharedconfig.LogConfig `mapstructure:"log"` MediaSupply sharedconfig.MediaSupplyConfig `mapstructure:"media_supply"` JWT JWTConfig `mapstructure:"jwt"` Auth sharedconfig.AuthConfig `mapstructure:"auth"` DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"` AdminUsers AdminUsersConfig `mapstructure:"admin_users"` IPRegion IPRegionConfig `mapstructure:"ip_region"` } type JWTConfig struct { Secret string `mapstructure:"secret"` AccessTTL time.Duration `mapstructure:"access_ttl"` } type DefaultAdminConfig struct { Username string `mapstructure:"username"` Password string `mapstructure:"password"` DisplayName string `mapstructure:"display_name"` Email string `mapstructure:"email"` } type AdminUsersConfig struct { DefaultPlanCode string `mapstructure:"default_plan_code"` } type IPRegionConfig struct { V4XDBPath string `mapstructure:"v4_xdb_path"` V6XDBPath string `mapstructure:"v6_xdb_path"` } type Store struct { path string mu sync.RWMutex cfg *Config runtime configruntime.Config watchedFiles map[string]fileSnapshot updates chan struct{} } type ReloadEvent struct { Previous *Config Current *Config Changes []FieldChange } type FieldChange struct { Path string Hot bool } func Load(path string) (*Config, error) { cfg, err := load(path) if err != nil { return nil, err } return cfg, nil } func NewStore(path string) (*Store, error) { cfg, err := load(path) if err != nil { return nil, err } store := &Store{ path: path, cfg: cfg, watchedFiles: snapshotFiles(watchFiles(path)), updates: make(chan struct{}, 1), } store.runtime = store.newRuntimeConfig() return store, nil } func (s *Store) Current() *Config { if s == nil { return nil } s.mu.RLock() defer s.mu.RUnlock() return s.cfg } func (s *Store) Watch(ctx context.Context, logger *zap.Logger, onReload func(ReloadEvent)) error { if s == nil { return fmt.Errorf("ops config store is nil") } if s.runtime == nil { s.runtime = s.newRuntimeConfig() } if err := s.runtime.Load(); err != nil { return err } if err := s.runtime.Watch("", func(string, configruntime.Value) { s.notifyReload() }); err != nil { _ = s.runtime.Close() return err } if logger != nil { logger.Info("ops config hot reload watcher started", zap.String("file", s.path)) } for { select { case <-ctx.Done(): return nil case <-s.updates: s.reload(logger, onReload) } } } func (s *Store) Close() { if s == nil { return } if s.runtime != nil { _ = s.runtime.Close() } } func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) { next, err := load(s.path) if err != nil { if logger != nil { logger.Warn("ops config reload rejected", zap.Error(err)) } return } s.mu.Lock() previous := s.cfg s.cfg = next s.watchedFiles = snapshotFiles(watchFiles(s.path)) s.mu.Unlock() event := ReloadEvent{ Previous: previous, Current: next, Changes: Diff(previous, next), } if onReload != nil { onReload(event) } if logger != nil { restartRequired := RestartRequired(event.Changes) logger.Info("ops config reloaded", zap.Bool("restart_required", restartRequired)) if restartRequired { logger.Warn("ops config reload changed connection or listener settings; restart process to apply those fields") } } } func (s *Store) notifyReload() { if !s.consumeFileChange() { return } select { case s.updates <- struct{}{}: default: } } func (s *Store) consumeFileChange() bool { s.mu.Lock() defer s.mu.Unlock() next := snapshotFiles(watchFiles(s.path)) if len(s.watchedFiles) == 0 { s.watchedFiles = next return true } changed := !fileSnapshotsEqual(s.watchedFiles, next) if changed { s.watchedFiles = next } return changed } func (s *Store) newRuntimeConfig() configruntime.Config { return configruntime.New(configruntime.WithSource( runtimefile.NewSource(filepath.Dir(s.path), watchNames(s.path)...), runtimeenv.NewSource("OPS"), )) } func Diff(previous, current *Config) []FieldChange { if previous == nil || current == nil { return nil } changes := make([]FieldChange, 0) add := func(path string, hot bool) { changes = append(changes, FieldChange{Path: path, Hot: hot}) } if !reflect.DeepEqual(previous.Server, current.Server) { add("server", false) } if previous.Database != current.Database { add("database", false) } if previous.MonitoringDatabase != current.MonitoringDatabase { add("monitoring_database", false) } if previous.Redis != current.Redis { add("redis", false) } if previous.Cache != current.Cache { add("cache", false) } if previous.RabbitMQ != current.RabbitMQ { add("rabbitmq", false) } if previous.ObjectStorage != current.ObjectStorage { add("object_storage", false) } if previous.Log != current.Log { add("log", false) } if previous.MediaSupply.Worker.PollInterval != current.MediaSupply.Worker.PollInterval || previous.MediaSupply.Worker.BatchSize != current.MediaSupply.Worker.BatchSize { add("media_supply.worker", false) } else if !reflect.DeepEqual(previous.MediaSupply, current.MediaSupply) { add("media_supply", true) } if previous.IPRegion != current.IPRegion { add("ip_region", false) } if previous.JWT != current.JWT { add("jwt", true) } if previous.Auth != current.Auth { add("auth", false) } if previous.AdminUsers != current.AdminUsers { add("admin_users", true) } if previous.DefaultAdmin != current.DefaultAdmin { add("default_admin", true) } return changes } func RestartRequired(changes []FieldChange) bool { for _, change := range changes { if !change.Hot { return true } } return false } func load(path string) (*Config, error) { sources := []configruntime.Source{runtimefile.NewSource(path)} if localPath := localOverridePath(path); localPath != "" { sources = append(sources, runtimefile.NewSource(localPath)) } sources = append(sources, runtimeenv.NewSource("OPS")) resolved := configruntime.New(configruntime.WithSource(sources...)) defer resolved.Close() if err := resolved.Load(); err != nil { return nil, fmt.Errorf("read config: %w", err) } settings := defaultSettings() if err := resolved.Scan(&settings); err != nil { return nil, fmt.Errorf("scan config: %w", err) } applySharedDefaults(settings) var cfg Config decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ Result: &cfg, TagName: "mapstructure", WeaklyTypedInput: true, DecodeHook: mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToSliceHookFunc(","), ), }) if err != nil { return nil, err } if err := decoder.Decode(settings); err != nil { return nil, fmt.Errorf("unmarshal config: %w", err) } if err := applyEnvOverrides(&cfg); err != nil { return nil, err } normalizeConfig(&cfg) if cfg.JWT.Secret == "" { return nil, fmt.Errorf("jwt.secret is required (set jwt.secret in yaml or OPS_JWT_SECRET env)") } if cfg.MonitoringDatabase.DBName == "" { cfg.MonitoringDatabase = cfg.Database } return &cfg, nil } func applySharedDefaults(settings map[string]any) { serverSettings := ensureMapSetting(settings, "server") securityHeadersSettings := ensureMapSetting(serverSettings, "security_headers") if _, ok := securityHeadersSettings["enabled"]; !ok { securityHeadersSettings["enabled"] = true } cacheSettings := ensureMapSetting(settings, "cache") if _, ok := cacheSettings["metrics_enabled"]; !ok { cacheSettings["metrics_enabled"] = true } } func ensureMapSetting(settings map[string]any, key string) map[string]any { if settings == nil { return map[string]any{} } if existing, ok := settings[key].(map[string]any); ok { return existing } next := make(map[string]any) settings[key] = next return next } func normalizeConfig(cfg *Config) { if cfg.Server.Port == 0 { cfg.Server.Port = 8090 } if strings.TrimSpace(cfg.Server.Mode) == "" { cfg.Server.Mode = "debug" } sharedconfig.NormalizeServerConfig(&cfg.Server) if strings.TrimSpace(cfg.Redis.Addr) == "" { cfg.Redis.Addr = "localhost:6379" } sharedconfig.NormalizeRedisConfig(&cfg.Redis) if strings.TrimSpace(cfg.Cache.Driver) == "" { cfg.Cache.Driver = "redis" } sharedconfig.NormalizeCacheConfig(&cfg.Cache) normalizeOpsRabbitMQConfig(&cfg.RabbitMQ) sharedconfig.NormalizeMediaSupplyConfig(&cfg.MediaSupply) if strings.TrimSpace(cfg.Log.Level) == "" { cfg.Log.Level = "info" } if strings.TrimSpace(cfg.Log.Format) == "" { cfg.Log.Format = "json" } if cfg.JWT.AccessTTL <= 0 { cfg.JWT.AccessTTL = 8 * time.Hour } if strings.TrimSpace(cfg.DefaultAdmin.Username) == "" { cfg.DefaultAdmin.Username = "admin" } if strings.TrimSpace(cfg.DefaultAdmin.DisplayName) == "" { cfg.DefaultAdmin.DisplayName = "Administrator" } if strings.TrimSpace(cfg.AdminUsers.DefaultPlanCode) == "" { cfg.AdminUsers.DefaultPlanCode = "free" } } func defaultSettings() map[string]any { return map[string]any{ "server": map[string]any{ "port": 8090, "mode": "debug", "trusted_proxies": sharedconfig.DefaultTrustedProxies(), }, "redis": map[string]any{ "addr": "localhost:6379", "db": 0, }, "cache": map[string]any{ "driver": "redis", }, "rabbitmq": map[string]any{}, "media_supply": map[string]any{ "enabled": true, "default_markup_percent": 50, "minimum_markup_cents": 0, "worker": map[string]any{ "enabled": true, "poll_interval": 5 * time.Second, "batch_size": 1, "order_max_attempts": 3, "sync_max_attempts": 2, }, "meijiequan": map[string]any{ "base_url": "http://www.meijiequan.com", }, }, "log": map[string]any{ "level": "info", "format": "json", }, "jwt": map[string]any{ "access_ttl": 8 * time.Hour, }, "default_admin": map[string]any{ "username": "admin", "display_name": "Administrator", }, "admin_users": map[string]any{ "default_plan_code": "free", }, "ip_region": map[string]any{ "v4_xdb_path": "", "v6_xdb_path": "", }, } } func applyEnvOverrides(cfg *Config) error { if v := os.Getenv("OPS_JWT_SECRET"); v != "" { cfg.JWT.Secret = v } if v := os.Getenv("OPS_DEFAULT_ADMIN_USERNAME"); v != "" { cfg.DefaultAdmin.Username = v } if v := os.Getenv("OPS_DEFAULT_ADMIN_PASSWORD"); v != "" { cfg.DefaultAdmin.Password = v } if v := os.Getenv("OPS_DEFAULT_ADMIN_DISPLAY_NAME"); v != "" { cfg.DefaultAdmin.DisplayName = v } if v := os.Getenv("OPS_DEFAULT_ADMIN_EMAIL"); v != "" { cfg.DefaultAdmin.Email = v } if v := os.Getenv("OPS_ADMIN_USERS_DEFAULT_PLAN_CODE"); v != "" { cfg.AdminUsers.DefaultPlanCode = v } if v := os.Getenv("OPS_IP_REGION_V4_XDB_PATH"); v != "" { cfg.IPRegion.V4XDBPath = v } if v := os.Getenv("OPS_IP_REGION_V6_XDB_PATH"); v != "" { cfg.IPRegion.V6XDBPath = v } if v := os.Getenv("OPS_SERVER_TRUSTED_PROXIES"); v != "" { cfg.Server.TrustedProxies = strings.Split(v, ",") } if v := os.Getenv("OPS_SERVER_ALLOWED_ORIGINS"); v != "" { cfg.Server.AllowedOrigins = strings.Split(v, ",") } if v := os.Getenv("OPS_SERVER_SECURITY_HEADERS_ENABLED"); v != "" { enabled, err := parseBoolEnv("OPS_SERVER_SECURITY_HEADERS_ENABLED", v) if err != nil { return err } cfg.Server.SecurityHeaders.Enabled = enabled } if v := os.Getenv("OPS_AUTH_PASSWORD_CIPHER_ENABLED"); v != "" { enabled, err := parseBoolEnv("OPS_AUTH_PASSWORD_CIPHER_ENABLED", v) if err != nil { return err } cfg.Auth.PasswordCipher.Enabled = enabled } if v := os.Getenv("OPS_AUTH_PASSWORD_CIPHER_KEY_ID"); v != "" { cfg.Auth.PasswordCipher.KeyID = v } if v := os.Getenv("OPS_AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM"); v != "" { cfg.Auth.PasswordCipher.PrivateKeyPEM = v } if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" { cfg.RabbitMQ.URL = v } applyOpsObjectStorageEnvOverrides(cfg) return nil } func applyOpsObjectStorageEnvOverrides(cfg *Config) { if endpoint := firstNonEmptyEnv("OPS_OBJECT_STORAGE_ENDPOINT", "OBJECT_STORAGE_ENDPOINT"); endpoint != "" { cfg.ObjectStorage.Endpoint = endpoint } if accessKey := firstNonEmptyEnv("OPS_OBJECT_STORAGE_ACCESS_KEY", "OBJECT_STORAGE_ACCESS_KEY"); accessKey != "" { cfg.ObjectStorage.AccessKey = accessKey } if secretKey := firstNonEmptyEnv("OPS_OBJECT_STORAGE_SECRET_KEY", "OBJECT_STORAGE_SECRET_KEY"); secretKey != "" { cfg.ObjectStorage.SecretKey = secretKey } if bucket := firstNonEmptyEnv("OPS_OBJECT_STORAGE_BUCKET", "OBJECT_STORAGE_BUCKET"); bucket != "" { cfg.ObjectStorage.Bucket = bucket } if provider := firstNonEmptyEnv("OPS_OBJECT_STORAGE_PROVIDER", "OBJECT_STORAGE_PROVIDER"); provider != "" { cfg.ObjectStorage.Provider = provider } if publicBaseURL := firstNonEmptyEnv("OPS_OBJECT_STORAGE_PUBLIC_BASE_URL", "OBJECT_STORAGE_PUBLIC_BASE_URL"); publicBaseURL != "" { cfg.ObjectStorage.PublicBaseURL = publicBaseURL } if objectACL := firstNonEmptyEnv("OPS_OBJECT_STORAGE_OBJECT_ACL", "OBJECT_STORAGE_OBJECT_ACL"); objectACL != "" { cfg.ObjectStorage.ObjectACL = objectACL } if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_SIGNED_URL_TTL", "OBJECT_STORAGE_SIGNED_URL_TTL"); v != "" { if ttl, err := time.ParseDuration(v); err == nil { cfg.ObjectStorage.SignedURLTTL = ttl } } if region := firstNonEmptyEnv("OPS_OBJECT_STORAGE_REGION", "OBJECT_STORAGE_REGION"); region != "" { cfg.ObjectStorage.Region = region } if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_FORCE_PATH_STYLE", "OBJECT_STORAGE_FORCE_PATH_STYLE"); v != "" { if forcePathStyle, err := parseBoolEnv("OPS_OBJECT_STORAGE_FORCE_PATH_STYLE", v); err == nil { cfg.ObjectStorage.ForcePathStyle = forcePathStyle } } if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_USE_SSL", "OBJECT_STORAGE_USE_SSL"); v != "" { if useSSL, err := parseBoolEnv("OPS_OBJECT_STORAGE_USE_SSL", v); err == nil { cfg.ObjectStorage.UseSSL = useSSL } } } func firstNonEmptyEnv(names ...string) string { for _, name := range names { if value := strings.TrimSpace(os.Getenv(name)); value != "" { return value } } return "" } func parseBoolEnv(name, value string) (bool, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "y", "on": return true, nil case "0", "false", "no", "n", "off": return false, nil default: return false, fmt.Errorf("%s must be a boolean", name) } } func normalizeOpsRabbitMQConfig(cfg *sharedconfig.RabbitMQConfig) { if cfg == nil { return } if strings.TrimSpace(cfg.GenerationExchange) == "" { cfg.GenerationExchange = "generation.task" } if strings.TrimSpace(cfg.GenerationRoutingKey) == "" { cfg.GenerationRoutingKey = "generation.task.run" } if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" { cfg.DesktopTaskEventExchange = "desktop.task.event" } if strings.TrimSpace(cfg.DesktopDispatchExchange) == "" { cfg.DesktopDispatchExchange = "desktop.task.dispatch" } if strings.TrimSpace(cfg.DesktopDispatchPublishPrefix) == "" { cfg.DesktopDispatchPublishPrefix = "publish" } if strings.TrimSpace(cfg.DesktopDispatchMonitorPrefix) == "" { cfg.DesktopDispatchMonitorPrefix = "monitor" } if strings.TrimSpace(cfg.TemplateAssistExchange) == "" { cfg.TemplateAssistExchange = "template.assist" } if strings.TrimSpace(cfg.TemplateAssistRoutingKey) == "" { cfg.TemplateAssistRoutingKey = "template.assist.run" } if strings.TrimSpace(cfg.KolAssistExchange) == "" { cfg.KolAssistExchange = "kol.assist" } if strings.TrimSpace(cfg.KolAssistRoutingKey) == "" { cfg.KolAssistRoutingKey = "kol.assist.run" } if strings.TrimSpace(cfg.ComplianceReviewExchange) == "" { cfg.ComplianceReviewExchange = "compliance.review" } if strings.TrimSpace(cfg.ComplianceReviewRoutingKey) == "" { cfg.ComplianceReviewRoutingKey = "compliance.review.run" } if cfg.PublishChannelPoolSize <= 0 { cfg.PublishChannelPoolSize = 16 } } type fileSnapshot struct { size int64 modTime time.Time exists bool } func snapshotFiles(files []string) map[string]fileSnapshot { snapshots := make(map[string]fileSnapshot, len(files)) for _, file := range files { if strings.TrimSpace(file) == "" { continue } abs, err := filepath.Abs(file) if err == nil { file = abs } file = filepath.Clean(file) info, err := os.Stat(file) if err != nil { snapshots[file] = fileSnapshot{} continue } snapshots[file] = fileSnapshot{ size: info.Size(), modTime: info.ModTime(), exists: true, } } return snapshots } func fileSnapshotsEqual(a, b map[string]fileSnapshot) bool { if len(a) != len(b) { return false } for key, left := range a { right, ok := b[key] if !ok || left != right { return false } } return true } func watchFiles(path string) []string { if strings.TrimSpace(path) == "" { return nil } files := []string{path} if localPath := localOverridePath(path); localPath != "" { files = append(files, localPath) } return files } func watchNames(path string) []string { if strings.TrimSpace(path) == "" { return nil } names := []string{filepath.Base(path)} if localPath := localOverridePath(path); localPath != "" { names = append(names, filepath.Base(localPath)) } return names } func localOverridePath(path string) string { trimmed := strings.TrimSpace(path) if trimmed == "" { return "" } ext := filepath.Ext(trimmed) base := strings.TrimSuffix(trimmed, ext) if strings.HasSuffix(base, ".local") { return "" } localPath := base + ".local" + ext if _, err := os.Stat(localPath); err != nil { return "" } return localPath }