package config import ( "crypto/tls" "encoding/json" "fmt" "net" "net/url" "os" "strconv" "strings" "time" "github.com/mitchellh/mapstructure" 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 ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` MonitoringDatabase DatabaseConfig `mapstructure:"monitoring_database"` RabbitMQ RabbitMQConfig `mapstructure:"rabbitmq"` Scheduler SchedulerConfig `mapstructure:"scheduler"` MonitoringWorkers MonitoringConfig `mapstructure:"monitoring_workers"` MonitoringDispatch MonitoringDispatchConfig `mapstructure:"monitoring_dispatch"` Membership MembershipConfig `mapstructure:"membership"` BrandLibrary BrandLibraryConfig `mapstructure:"brand_library"` IPRegion IPRegionConfig `mapstructure:"ip_region"` Redis RedisConfig `mapstructure:"redis"` Qdrant QdrantConfig `mapstructure:"qdrant"` ObjectStorage ObjectStorageConfig `mapstructure:"object_storage"` Cache CacheConfig `mapstructure:"cache"` JWT JWTConfig `mapstructure:"jwt"` Auth AuthConfig `mapstructure:"auth"` Log LogConfig `mapstructure:"log"` LLM LLMConfig `mapstructure:"llm"` Compliance ComplianceConfig `mapstructure:"compliance"` Retrieval RetrievalConfig `mapstructure:"retrieval"` Generation GenerationConfig `mapstructure:"generation"` BrowserFetch BrowserFetchConfig `mapstructure:"browser_fetch"` MediaSupply MediaSupplyConfig `mapstructure:"media_supply"` } type ServerConfig struct { Port int `mapstructure:"port"` Mode string `mapstructure:"mode"` PublicBaseURL string `mapstructure:"public_base_url"` TrustedProxies []string `mapstructure:"trusted_proxies"` AllowedOrigins []string `mapstructure:"allowed_origins"` SecurityHeaders SecurityHeaders `mapstructure:"security_headers"` } type SecurityHeaders struct { Enabled bool `mapstructure:"enabled"` } type DatabaseConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` User string `mapstructure:"user"` Password string `mapstructure:"password"` DBName string `mapstructure:"dbname"` SSLMode string `mapstructure:"sslmode"` MaxOpenConns int `mapstructure:"max_open_conns"` MaxIdleConns int `mapstructure:"max_idle_conns"` } func (d DatabaseConfig) DSN() string { u := url.URL{ Scheme: "postgres", User: url.UserPassword(d.User, d.Password), Host: net.JoinHostPort(d.Host, fmt.Sprintf("%d", d.Port)), Path: "/" + d.DBName, } q := u.Query() q.Set("sslmode", d.SSLMode) u.RawQuery = q.Encode() return u.String() } type RedisConfig struct { Addr string `mapstructure:"addr"` Password string `mapstructure:"password"` DB int `mapstructure:"db"` DialTimeout time.Duration `mapstructure:"dial_timeout"` ReadTimeout time.Duration `mapstructure:"read_timeout"` WriteTimeout time.Duration `mapstructure:"write_timeout"` PoolSize int `mapstructure:"pool_size"` MinIdleConns int `mapstructure:"min_idle_conns"` MaxRetries int `mapstructure:"max_retries"` PoolTimeout time.Duration `mapstructure:"pool_timeout"` TLSEnabled bool `mapstructure:"tls_enabled"` } func (r RedisConfig) TLSConfig() *tls.Config { if !r.TLSEnabled { return nil } return &tls.Config{MinVersion: tls.VersionTLS12} } type RabbitMQConfig struct { URL string `mapstructure:"url"` MonitorResultExchange string `mapstructure:"monitor_result_exchange"` MonitorResultRoutingKey string `mapstructure:"monitor_result_routing_key"` MonitorResultQueue string `mapstructure:"monitor_result_queue"` MonitorResultDLX string `mapstructure:"monitor_result_dlx"` MonitorResultDLQ string `mapstructure:"monitor_result_dlq"` MonitorResultDLQRouteKey string `mapstructure:"monitor_result_dlq_routing_key"` MonitorProjectionExchange string `mapstructure:"monitor_projection_exchange"` MonitorProjectionRoutingKey string `mapstructure:"monitor_projection_routing_key"` MonitorProjectionQueue string `mapstructure:"monitor_projection_queue"` MonitorProjectionDLX string `mapstructure:"monitor_projection_dlx"` MonitorProjectionDLQ string `mapstructure:"monitor_projection_dlq"` MonitorProjectionDLQRouteKey string `mapstructure:"monitor_projection_dlq_routing_key"` GenerationExchange string `mapstructure:"generation_exchange"` GenerationRoutingKey string `mapstructure:"generation_routing_key"` GenerationQueue string `mapstructure:"generation_queue"` GenerationDLX string `mapstructure:"generation_dlx"` GenerationDLQ string `mapstructure:"generation_dlq"` GenerationDLQRouteKey string `mapstructure:"generation_dlq_routing_key"` GenerationEventExchange string `mapstructure:"generation_event_exchange"` DesktopTaskEventExchange string `mapstructure:"desktop_task_event_exchange"` DesktopDispatchExchange string `mapstructure:"desktop_dispatch_exchange"` DesktopDispatchPublishPrefix string `mapstructure:"desktop_dispatch_publish_prefix"` DesktopDispatchMonitorPrefix string `mapstructure:"desktop_dispatch_monitor_prefix"` DesktopAccountHealthExchange string `mapstructure:"desktop_account_health_exchange"` DesktopAccountHealthRoutingKey string `mapstructure:"desktop_account_health_routing_key"` DesktopAccountHealthQueue string `mapstructure:"desktop_account_health_queue"` DesktopAccountHealthDLX string `mapstructure:"desktop_account_health_dlx"` DesktopAccountHealthDLQ string `mapstructure:"desktop_account_health_dlq"` DesktopAccountHealthDLQRouteKey string `mapstructure:"desktop_account_health_dlq_routing_key"` TemplateAssistExchange string `mapstructure:"template_assist_exchange"` TemplateAssistRoutingKey string `mapstructure:"template_assist_routing_key"` TemplateAssistQueue string `mapstructure:"template_assist_queue"` TemplateAssistDLX string `mapstructure:"template_assist_dlx"` TemplateAssistDLQ string `mapstructure:"template_assist_dlq"` TemplateAssistDLQRouteKey string `mapstructure:"template_assist_dlq_routing_key"` KolAssistExchange string `mapstructure:"kol_assist_exchange"` KolAssistRoutingKey string `mapstructure:"kol_assist_routing_key"` KolAssistQueue string `mapstructure:"kol_assist_queue"` KolAssistDLX string `mapstructure:"kol_assist_dlx"` KolAssistDLQ string `mapstructure:"kol_assist_dlq"` KolAssistDLQRouteKey string `mapstructure:"kol_assist_dlq_routing_key"` ImageAssetExchange string `mapstructure:"image_asset_exchange"` ImageAssetRoutingKey string `mapstructure:"image_asset_routing_key"` ImageAssetQueue string `mapstructure:"image_asset_queue"` ImageAssetDLX string `mapstructure:"image_asset_dlx"` ImageAssetDLQ string `mapstructure:"image_asset_dlq"` ImageAssetDLQRouteKey string `mapstructure:"image_asset_dlq_routing_key"` ComplianceReviewExchange string `mapstructure:"compliance_review_exchange"` ComplianceReviewRoutingKey string `mapstructure:"compliance_review_routing_key"` ComplianceReviewQueue string `mapstructure:"compliance_review_queue"` ComplianceReviewDLX string `mapstructure:"compliance_review_dlx"` ComplianceReviewDLQ string `mapstructure:"compliance_review_dlq"` ComplianceReviewDLQRouteKey string `mapstructure:"compliance_review_dlq_routing_key"` ConsumerPrefetch int `mapstructure:"consumer_prefetch"` PublishChannelPoolSize int `mapstructure:"publish_channel_pool_size"` } type SchedulerConfig struct { HTTPHost string `mapstructure:"http_host"` HTTPPort int `mapstructure:"http_port"` InternalMetricsToken string `mapstructure:"internal_metrics_token"` DispatchInterval time.Duration `mapstructure:"dispatch_interval"` DispatchTimeout time.Duration `mapstructure:"dispatch_timeout"` DispatchBatchSize int `mapstructure:"dispatch_batch_size"` DispatchConcurrency int `mapstructure:"dispatch_concurrency"` GenerationQueueBackpressureLimit int `mapstructure:"generation_queue_backpressure_limit"` } type MonitoringConfig struct { ResultIngestConcurrency int `mapstructure:"result_ingest_concurrency"` ProjectionRebuildConcurrency int `mapstructure:"projection_rebuild_concurrency"` } type MonitoringDispatchConfig struct { DesktopTasksRolloutPercentage int `mapstructure:"desktop_tasks_rollout_percentage"` } const ( ArticleQuotaCycleLifetime = "lifetime" ArticleQuotaCycleMonthly = "monthly" ) type MembershipConfig struct { DefaultPlanCode string `mapstructure:"default_plan_code"` Plans []MembershipPlanConfig `mapstructure:"plans"` } type MembershipPlanConfig struct { Code string `mapstructure:"code"` Name string `mapstructure:"name"` ArticleGeneration int `mapstructure:"article_generation"` ArticleQuotaCycle string `mapstructure:"article_quota_cycle"` AIPointsMonthly int `mapstructure:"ai_points_monthly"` AIPointBaseChars int `mapstructure:"ai_point_base_chars"` ImageStorageBytes int64 `mapstructure:"image_storage_bytes"` CompanyLimit int `mapstructure:"company_limit"` SubscriptionDuration time.Duration `mapstructure:"subscription_duration"` ContactAdminOnExpiry bool `mapstructure:"contact_admin_on_expiry"` } type PlanQuotaPolicy struct { ArticleGeneration int `json:"article_generation"` ArticleQuotaCycle string `json:"article_quota_cycle"` AIPointsMonthly int `json:"ai_points_monthly"` AIPointBaseChars int `json:"ai_point_base_chars"` ImageStorageBytes int64 `json:"image_storage_bytes"` BrandLimit int `json:"brand_limit"` SubscriptionDurationSecs int64 `json:"subscription_duration_seconds,omitempty"` ContactAdminOnExpiry bool `json:"contact_admin_on_expiry,omitempty"` } func (c MembershipConfig) FindPlan(code string) (MembershipPlanConfig, bool) { normalizedCode := strings.ToLower(strings.TrimSpace(code)) for _, plan := range c.Plans { if strings.EqualFold(strings.TrimSpace(plan.Code), normalizedCode) { return plan, true } } return MembershipPlanConfig{}, false } func (p MembershipPlanConfig) QuotaPolicy() PlanQuotaPolicy { policy := PlanQuotaPolicy{ ArticleGeneration: maxInt(p.ArticleGeneration, 0), ArticleQuotaCycle: normalizeArticleQuotaCycle(p.ArticleQuotaCycle, p.Code), AIPointsMonthly: maxInt(p.AIPointsMonthly, 0), AIPointBaseChars: normalizeAIPointBaseChars(p.AIPointBaseChars), ImageStorageBytes: maxInt64(p.ImageStorageBytes, 0), BrandLimit: maxInt(p.CompanyLimit, 0), ContactAdminOnExpiry: p.ContactAdminOnExpiry, } if p.SubscriptionDuration > 0 { policy.SubscriptionDurationSecs = int64(p.SubscriptionDuration / time.Second) } return policy } func (p MembershipPlanConfig) QuotaPolicyJSON() ([]byte, error) { return json.Marshal(p.QuotaPolicy()) } type BrandLibraryConfig struct { FreeBrandLimit int `mapstructure:"free_brand_limit"` PaidBrandLimit int `mapstructure:"paid_brand_limit"` MaxKeywords int `mapstructure:"max_keywords"` MaxQuestionsPerKeyword int `mapstructure:"max_questions_per_keyword"` QuestionLimitsByPlan map[string]int `mapstructure:"question_limits_by_plan"` } type IPRegionConfig struct { V4XDBPath string `mapstructure:"v4_xdb_path"` V6XDBPath string `mapstructure:"v6_xdb_path"` } func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int { if strings.EqualFold(strings.TrimSpace(planCode), "free") { return c.FreeBrandLimit } return c.PaidBrandLimit } func (c BrandLibraryConfig) QuestionLimitForPlan(planCode string) int { limits := c.QuestionLimitsByPlan normalizedPlan := strings.ToLower(strings.TrimSpace(planCode)) if limits != nil { if value := limits[normalizedPlan]; value > 0 { return value } if value := limits["default"]; value > 0 { return value } } return 25 } type QdrantConfig struct { URL string `mapstructure:"url"` APIKey string `mapstructure:"api_key"` Collection string `mapstructure:"collection"` Timeout time.Duration `mapstructure:"timeout"` } type ObjectStorageConfig struct { Provider string `mapstructure:"provider"` Endpoint string `mapstructure:"endpoint"` AccessKey string `mapstructure:"access_key"` SecretKey string `mapstructure:"secret_key"` Bucket string `mapstructure:"bucket"` UseSSL bool `mapstructure:"use_ssl"` PublicBaseURL string `mapstructure:"public_base_url"` BucketACL string `mapstructure:"bucket_acl"` ObjectACL string `mapstructure:"object_acl"` SignedURLTTL time.Duration `mapstructure:"signed_url_ttl"` Region string `mapstructure:"region"` ForcePathStyle bool `mapstructure:"force_path_style"` } type JWTConfig struct { Secret string `mapstructure:"secret"` AccessTTL time.Duration `mapstructure:"access_ttl"` RefreshTTL time.Duration `mapstructure:"refresh_ttl"` } type AuthConfig struct { LoginGuard LoginGuardConfig `mapstructure:"login_guard"` PasswordCipher PasswordCipherConfig `mapstructure:"password_cipher"` } type LoginGuardConfig struct { Enabled bool `mapstructure:"enabled"` } type PasswordCipherConfig struct { Enabled bool `mapstructure:"enabled"` KeyID string `mapstructure:"key_id"` PrivateKeyPEM string `mapstructure:"private_key_pem"` } type LogConfig struct { Level string `mapstructure:"level"` Format string `mapstructure:"format"` } type LLMConfig struct { Provider string `mapstructure:"provider"` APIKey string `mapstructure:"api_key"` BaseURL string `mapstructure:"base_url"` Model string `mapstructure:"model"` KnowledgeURLModel string `mapstructure:"knowledge_url_model"` Timeout time.Duration `mapstructure:"timeout"` MaxOutputTokens int64 `mapstructure:"max_output_tokens"` Temperature float64 `mapstructure:"temperature"` TopP float64 `mapstructure:"top_p"` ReasoningEffort string `mapstructure:"reasoning_effort"` WebSearchLimit int32 `mapstructure:"web_search_limit"` } type ComplianceConfig struct { Enabled bool `mapstructure:"enabled"` LLMJudgeEnabled bool `mapstructure:"llm_judge_enabled"` SnapshotReloadInterval time.Duration `mapstructure:"snapshot_reload_interval"` PublishGateTimeout time.Duration `mapstructure:"publish_gate_timeout"` ReviewWorkerConcurrency int `mapstructure:"review_worker_concurrency"` ReviewWorkerTimeout time.Duration `mapstructure:"review_worker_timeout"` ReviewMaxAttempts int `mapstructure:"review_max_attempts"` } type RetrievalConfig struct { Provider string `mapstructure:"provider"` BaseURL string `mapstructure:"base_url"` APIKey string `mapstructure:"api_key"` EmbeddingModel string `mapstructure:"embedding_model"` RerankerModel string `mapstructure:"reranker_model"` Timeout time.Duration `mapstructure:"timeout"` ChunkSize int `mapstructure:"chunk_size"` ChunkOverlap int `mapstructure:"chunk_overlap"` EmbeddingBatchSize int `mapstructure:"embedding_batch_size"` RecallLimit int `mapstructure:"recall_limit"` RerankTopN int `mapstructure:"rerank_top_n"` MaxChunksPerDoc int `mapstructure:"max_chunks_per_doc"` OverlapTokens int `mapstructure:"overlap_tokens"` } type CacheConfig struct { Driver string `mapstructure:"driver"` // "redis" or "memory" Namespace string `mapstructure:"namespace"` JitterRatio float64 `mapstructure:"jitter_ratio"` MetricsEnabled bool `mapstructure:"metrics_enabled"` AsyncFillEnabled bool `mapstructure:"async_fill_enabled"` AsyncFillWorkers int `mapstructure:"async_fill_workers"` AsyncFillBuffer int `mapstructure:"async_fill_buffer"` AsyncFillTimeout time.Duration `mapstructure:"async_fill_timeout"` L1Enabled bool `mapstructure:"l1_enabled"` L1TTL time.Duration `mapstructure:"l1_ttl"` DeleteScanCount int64 `mapstructure:"delete_scan_count"` } type GenerationConfig struct { QueueSize int `mapstructure:"queue_size"` WorkerConcurrency int `mapstructure:"worker_concurrency"` StreamEnabled bool `mapstructure:"stream_enabled"` ArticleTimeout time.Duration `mapstructure:"article_timeout"` TaskLeaseTTL time.Duration `mapstructure:"task_lease_ttl"` TaskRecoveryInterval time.Duration `mapstructure:"task_recovery_interval"` TaskRecoveryTimeout time.Duration `mapstructure:"task_recovery_timeout"` TaskRecoveryBatchSize int `mapstructure:"task_recovery_batch_size"` TaskQueuedStaleAfter time.Duration `mapstructure:"task_queued_stale_after"` TaskMaxAttempts int `mapstructure:"task_max_attempts"` TaskStateCheckInterval time.Duration `mapstructure:"task_state_check_interval"` TaskStateCheckTimeout time.Duration `mapstructure:"task_state_check_timeout"` TaskStateCheckBatchSize int `mapstructure:"task_state_check_batch_size"` TaskStateCheckLookback time.Duration `mapstructure:"task_state_check_lookback"` } type BrowserFetchConfig struct { Enabled bool `mapstructure:"enabled"` Provider string `mapstructure:"provider"` ServiceURL string `mapstructure:"service_url"` Token string `mapstructure:"token"` QueueSize int `mapstructure:"queue_size"` WorkerConcurrency int `mapstructure:"worker_concurrency"` RequestTimeout time.Duration `mapstructure:"request_timeout"` CacheTTL time.Duration `mapstructure:"cache_ttl"` AllowedDomains []string `mapstructure:"allowed_domains"` TriggerStatus []int `mapstructure:"trigger_status"` } type MediaSupplyConfig struct { Enabled bool `mapstructure:"enabled"` DefaultMarkupPercent float64 `mapstructure:"default_markup_percent"` MinimumMarkupCents int64 `mapstructure:"minimum_markup_cents"` Worker MediaSupplyWorkerConfig `mapstructure:"worker"` Meijiequan MeijiequanConfig `mapstructure:"meijiequan"` } type MediaSupplyWorkerConfig struct { Enabled bool `mapstructure:"enabled"` PollInterval time.Duration `mapstructure:"poll_interval"` BatchSize int `mapstructure:"batch_size"` OrderMaxAttempts int `mapstructure:"order_max_attempts"` SyncMaxAttempts int `mapstructure:"sync_max_attempts"` BacklinkSyncInterval time.Duration `mapstructure:"backlink_sync_interval"` BacklinkBatchSize int `mapstructure:"backlink_batch_size"` } type MeijiequanConfig struct { BaseURL string `mapstructure:"base_url"` UserID string `mapstructure:"user_id"` Username string `mapstructure:"username"` Password string `mapstructure:"password"` SessionTTL time.Duration `mapstructure:"session_ttl"` RequestTimeout time.Duration `mapstructure:"request_timeout"` SyncPageDelay time.Duration `mapstructure:"sync_page_delay"` SyncPageSize int `mapstructure:"sync_page_size"` MaxSyncPages int `mapstructure:"max_sync_pages"` PublishedPageSize int `mapstructure:"published_page_size"` PublishedMaxPages int `mapstructure:"published_max_pages"` SearchOptionsTTL time.Duration `mapstructure:"search_options_ttl"` UpstreamLockTTL time.Duration `mapstructure:"upstream_lock_ttl"` UpstreamMinInterval time.Duration `mapstructure:"upstream_min_interval"` } func Load(configPath string) (*Config, error) { cfg, _, err := loadWithFiles(configPath) return cfg, err } func loadWithFiles(configPath string) (*Config, []string, error) { configFile, err := readConfigWithFallback(configPath) if err != nil { return nil, nil, fmt.Errorf("read config: %w", err) } // Allow local overrides localConfigFile, err := mergeLocalOverrideWithFallback(configPath) if err != nil { return nil, nil, fmt.Errorf("merge local config: %w", err) } cfg, err := decodeResolvedConfig(configFile, localConfigFile) if err != nil { return nil, nil, fmt.Errorf("unmarshal config: %w", err) } applyEnvOverrides(cfg) NormalizeServerConfig(&cfg.Server) NormalizeRedisConfig(&cfg.Redis) NormalizeCacheConfig(&cfg.Cache) normalizeRabbitMQConfig(&cfg.RabbitMQ) normalizeSchedulerConfig(&cfg.Scheduler) normalizeMonitoringConfig(&cfg.MonitoringWorkers) normalizeMonitoringDispatchConfig(&cfg.MonitoringDispatch) normalizeMembershipConfig(&cfg.Membership) normalizeBrandLibraryConfig(&cfg.BrandLibrary) NormalizeComplianceConfig(&cfg.Compliance) NormalizeGenerationConfig(&cfg.Generation) NormalizeBrowserFetchConfig(&cfg.BrowserFetch) NormalizeMediaSupplyConfig(&cfg.MediaSupply) files := []string{configFile} if localConfigFile != "" { files = append(files, localConfigFile) } return cfg, files, nil } func readConfigWithFallback(configPath string) (string, error) { var lastErr error for _, candidate := range candidateConfigPaths(configPath, false) { if _, err := os.Stat(candidate); err == nil { return candidate, nil } else { lastErr = err } } return "", lastErr } func mergeLocalOverrideWithFallback(configPath string) (string, error) { for _, candidate := range candidateConfigPaths(configPath, true) { if _, err := os.Stat(candidate); err != nil { continue } return candidate, nil } return "", nil } func decodeResolvedConfig(configFile, localConfigFile string) (*Config, error) { sources := []configruntime.Source{runtimefile.NewSource(configFile)} if localConfigFile != "" { sources = append(sources, runtimefile.NewSource(localConfigFile)) } sources = append(sources, runtimeenv.NewSource()) resolved := configruntime.New(configruntime.WithSource(sources...)) defer resolved.Close() if err := resolved.Load(); err != nil { return nil, err } settings := make(map[string]any) if err := resolved.Scan(&settings); err != nil { return nil, err } applyConfigDefaults(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, err } return &cfg, nil } func applyConfigDefaults(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 } complianceSettings := ensureMapSetting(settings, "compliance") if _, ok := complianceSettings["enabled"]; !ok { complianceSettings["enabled"] = true } authSettings := ensureMapSetting(settings, "auth") loginGuardSettings := ensureMapSetting(authSettings, "login_guard") if _, ok := loginGuardSettings["enabled"]; !ok { loginGuardSettings["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 DefaultTrustedProxies() []string { return []string{ "127.0.0.1", "::1", "10.42.0.0/16", "10.43.0.0/16", "172.16.0.0/12", } } func NormalizeServerConfig(cfg *ServerConfig) { if cfg == nil { return } cfg.Mode = strings.TrimSpace(cfg.Mode) cfg.AllowedOrigins = compactStringSlice(cfg.AllowedOrigins) if cfg.TrustedProxies == nil { cfg.TrustedProxies = DefaultTrustedProxies() } else { cfg.TrustedProxies = compactStringSlice(cfg.TrustedProxies) } } func compactStringSlice(values []string) []string { if values == nil { return nil } out := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value != "" { out = append(out, value) } } return out } func compactIntSlice(values []int) []int { if values == nil { return nil } out := make([]int, 0, len(values)) seen := make(map[int]struct{}, len(values)) for _, value := range values { if value <= 0 { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} out = append(out, value) } return out } func parseIntList(value string) []int { parts := strings.Split(value, ",") out := make([]int, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } n, err := strconv.Atoi(part) if err != nil { continue } out = append(out, n) } return out } func normalizeMonitoringDispatchConfig(cfg *MonitoringDispatchConfig) { if cfg == nil { return } if cfg.DesktopTasksRolloutPercentage < 0 { cfg.DesktopTasksRolloutPercentage = 0 } if cfg.DesktopTasksRolloutPercentage > 100 { cfg.DesktopTasksRolloutPercentage = 100 } } func NormalizeRedisConfig(cfg *RedisConfig) { if cfg == nil { return } cfg.Addr = strings.TrimSpace(cfg.Addr) if cfg.Addr == "" { cfg.Addr = "localhost:6379" } if cfg.DialTimeout <= 0 { cfg.DialTimeout = 5 * time.Second } if cfg.ReadTimeout <= 0 { cfg.ReadTimeout = 3 * time.Second } if cfg.WriteTimeout <= 0 { cfg.WriteTimeout = 3 * time.Second } if cfg.PoolSize <= 0 { cfg.PoolSize = 16 } if cfg.MinIdleConns < 0 { cfg.MinIdleConns = 0 } if cfg.MinIdleConns > cfg.PoolSize { cfg.MinIdleConns = cfg.PoolSize } if cfg.MaxRetries < 0 { cfg.MaxRetries = 0 } if cfg.PoolTimeout <= 0 { cfg.PoolTimeout = 4 * time.Second } } func NormalizeCacheConfig(cfg *CacheConfig) { if cfg == nil { return } cfg.Driver = strings.ToLower(strings.TrimSpace(cfg.Driver)) if cfg.Driver == "" { cfg.Driver = "redis" } if cfg.Driver != "redis" && cfg.Driver != "memory" { cfg.Driver = "memory" } cfg.Namespace = strings.Trim(strings.TrimSpace(cfg.Namespace), ":") if cfg.Namespace == "" { cfg.Namespace = "geo" } if cfg.JitterRatio <= 0 { cfg.JitterRatio = 0.1 } if cfg.JitterRatio > 1 { cfg.JitterRatio = 1 } if cfg.AsyncFillWorkers <= 0 { cfg.AsyncFillWorkers = 2 } if cfg.AsyncFillBuffer <= 0 { cfg.AsyncFillBuffer = 1024 } if cfg.AsyncFillTimeout <= 0 { cfg.AsyncFillTimeout = 2 * time.Second } if cfg.L1TTL <= 0 { cfg.L1TTL = 30 * time.Second } if cfg.DeleteScanCount <= 0 { cfg.DeleteScanCount = 500 } } func NormalizeGenerationConfig(cfg *GenerationConfig) { if cfg == nil { return } if cfg.QueueSize <= 0 { cfg.QueueSize = 128 } if cfg.WorkerConcurrency <= 0 { cfg.WorkerConcurrency = 1 } if cfg.ArticleTimeout <= 0 { cfg.ArticleTimeout = 8 * time.Minute } if cfg.TaskLeaseTTL <= 0 { cfg.TaskLeaseTTL = cfg.ArticleTimeout + 2*time.Minute } if cfg.TaskLeaseTTL < time.Minute { cfg.TaskLeaseTTL = time.Minute } if cfg.TaskRecoveryInterval <= 0 { cfg.TaskRecoveryInterval = time.Minute } if cfg.TaskRecoveryTimeout <= 0 { cfg.TaskRecoveryTimeout = 30 * time.Second } if cfg.TaskRecoveryBatchSize <= 0 { cfg.TaskRecoveryBatchSize = 100 } if cfg.TaskQueuedStaleAfter <= 0 { cfg.TaskQueuedStaleAfter = 2 * time.Minute } if cfg.TaskMaxAttempts <= 0 { cfg.TaskMaxAttempts = 3 } if cfg.TaskStateCheckInterval <= 0 { cfg.TaskStateCheckInterval = 5 * time.Minute } if cfg.TaskStateCheckTimeout <= 0 { cfg.TaskStateCheckTimeout = 10 * time.Second } if cfg.TaskStateCheckBatchSize <= 0 { cfg.TaskStateCheckBatchSize = 100 } if cfg.TaskStateCheckLookback <= 0 { cfg.TaskStateCheckLookback = 24 * time.Hour } } func NormalizeBrowserFetchConfig(cfg *BrowserFetchConfig) { if cfg == nil { return } cfg.Provider = strings.ToLower(strings.TrimSpace(cfg.Provider)) if cfg.Provider == "" { cfg.Provider = "lightpanda" } cfg.ServiceURL = strings.TrimRight(strings.TrimSpace(cfg.ServiceURL), "/") cfg.Token = strings.TrimSpace(cfg.Token) if cfg.QueueSize <= 0 { cfg.QueueSize = 64 } if cfg.WorkerConcurrency <= 0 { cfg.WorkerConcurrency = 1 } if cfg.RequestTimeout <= 0 { cfg.RequestTimeout = 45 * time.Second } if cfg.CacheTTL <= 0 { cfg.CacheTTL = 6 * time.Hour } cfg.AllowedDomains = compactStringSlice(cfg.AllowedDomains) cfg.TriggerStatus = compactIntSlice(cfg.TriggerStatus) if len(cfg.TriggerStatus) == 0 { cfg.TriggerStatus = []int{403, 429, 500, 502, 503, 504} } } func NormalizeMediaSupplyConfig(cfg *MediaSupplyConfig) { if cfg == nil { return } if cfg.DefaultMarkupPercent < 0 { cfg.DefaultMarkupPercent = 0 } if cfg.MinimumMarkupCents < 0 { cfg.MinimumMarkupCents = 0 } if cfg.Worker.PollInterval <= 0 { cfg.Worker.PollInterval = 5 * time.Second } if cfg.Worker.BatchSize <= 0 { cfg.Worker.BatchSize = 1 } if cfg.Worker.BatchSize > 10 { cfg.Worker.BatchSize = 10 } if cfg.Worker.OrderMaxAttempts <= 0 { cfg.Worker.OrderMaxAttempts = 3 } if cfg.Worker.SyncMaxAttempts <= 0 { cfg.Worker.SyncMaxAttempts = 2 } if cfg.Worker.BacklinkSyncInterval <= 0 { cfg.Worker.BacklinkSyncInterval = 30 * time.Minute } if cfg.Worker.BacklinkBatchSize <= 0 { cfg.Worker.BacklinkBatchSize = 20 } if cfg.Worker.BacklinkBatchSize > 100 { cfg.Worker.BacklinkBatchSize = 100 } cfg.Meijiequan.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.Meijiequan.BaseURL), "/") if cfg.Meijiequan.BaseURL == "" { cfg.Meijiequan.BaseURL = "http://www.meijiequan.com" } cfg.Meijiequan.UserID = strings.TrimSpace(cfg.Meijiequan.UserID) cfg.Meijiequan.Username = strings.TrimSpace(cfg.Meijiequan.Username) cfg.Meijiequan.Password = strings.TrimSpace(cfg.Meijiequan.Password) if cfg.Meijiequan.SessionTTL <= 0 { cfg.Meijiequan.SessionTTL = 4 * time.Hour } if cfg.Meijiequan.RequestTimeout <= 0 { cfg.Meijiequan.RequestTimeout = 30 * time.Second } if cfg.Meijiequan.SyncPageDelay <= 0 { cfg.Meijiequan.SyncPageDelay = 800 * time.Millisecond } if cfg.Meijiequan.SyncPageSize <= 0 { cfg.Meijiequan.SyncPageSize = 100 } if cfg.Meijiequan.SyncPageSize > 500 { cfg.Meijiequan.SyncPageSize = 500 } if cfg.Meijiequan.MaxSyncPages <= 0 { cfg.Meijiequan.MaxSyncPages = 200 } if cfg.Meijiequan.PublishedPageSize <= 0 { cfg.Meijiequan.PublishedPageSize = 20 } if cfg.Meijiequan.PublishedPageSize > 100 { cfg.Meijiequan.PublishedPageSize = 100 } if cfg.Meijiequan.PublishedMaxPages <= 0 { cfg.Meijiequan.PublishedMaxPages = 3 } if cfg.Meijiequan.PublishedMaxPages > 20 { cfg.Meijiequan.PublishedMaxPages = 20 } if cfg.Meijiequan.SearchOptionsTTL <= 0 { cfg.Meijiequan.SearchOptionsTTL = 12 * time.Hour } if cfg.Meijiequan.UpstreamLockTTL <= 0 { cfg.Meijiequan.UpstreamLockTTL = 2 * time.Minute } if cfg.Meijiequan.UpstreamMinInterval <= 0 { cfg.Meijiequan.UpstreamMinInterval = cfg.Meijiequan.SyncPageDelay } } func NormalizeComplianceConfig(cfg *ComplianceConfig) { if cfg == nil { return } if cfg.SnapshotReloadInterval <= 0 { cfg.SnapshotReloadInterval = 30 * time.Second } if cfg.PublishGateTimeout <= 0 { cfg.PublishGateTimeout = 800 * time.Millisecond } if cfg.ReviewWorkerConcurrency <= 0 { cfg.ReviewWorkerConcurrency = 1 } if cfg.ReviewWorkerTimeout <= 0 { cfg.ReviewWorkerTimeout = 45 * time.Second } if cfg.ReviewMaxAttempts <= 0 { cfg.ReviewMaxAttempts = 3 } } func candidateConfigPaths(configPath string, local bool) []string { trimmed := strings.TrimSpace(configPath) if trimmed == "" { return nil } paths := make([]string, 0, 2) switch { case strings.HasSuffix(trimmed, ".yaml"): if local { paths = append(paths, strings.TrimSuffix(trimmed, ".yaml")+".local.yaml") } else { paths = append(paths, trimmed) } alt := strings.TrimSuffix(trimmed, ".yaml") if local { paths = append(paths, alt+".local.yml") } else { paths = append(paths, alt+".yml") } case strings.HasSuffix(trimmed, ".yml"): if local { paths = append(paths, strings.TrimSuffix(trimmed, ".yml")+".local.yml") } else { paths = append(paths, trimmed) } alt := strings.TrimSuffix(trimmed, ".yml") if local { paths = append(paths, alt+".local.yaml") } else { paths = append(paths, alt+".yaml") } default: if local { paths = append(paths, trimmed+".local.yaml", trimmed+".local.yml") } else { paths = append(paths, trimmed, trimmed+".yaml", trimmed+".yml") } } return paths } func applyEnvOverrides(cfg *Config) { if cfg == nil { return } if secret, ok := lookupNonEmptyEnv("JWT_SECRET"); ok { cfg.JWT.Secret = secret } if ttl, ok := lookupDurationEnv("JWT_ACCESS_TTL"); ok { cfg.JWT.AccessTTL = ttl } if ttl, ok := lookupDurationEnv("JWT_REFRESH_TTL"); ok { cfg.JWT.RefreshTTL = ttl } if enabled, ok := lookupBoolEnv("AUTH_LOGIN_GUARD_ENABLED"); ok { cfg.Auth.LoginGuard.Enabled = enabled } if apiKey, ok := lookupNonEmptyEnv("LLM_API_KEY"); ok { cfg.LLM.APIKey = apiKey } if model, ok := lookupNonEmptyEnv("LLM_KNOWLEDGE_URL_MODEL"); ok { cfg.LLM.KnowledgeURLModel = model } if enabled, ok := lookupBoolEnv("COMPLIANCE_ENABLED"); ok { cfg.Compliance.Enabled = enabled } if enabled, ok := lookupBoolEnv("COMPLIANCE_LLM_ENABLED"); ok { cfg.Compliance.LLMJudgeEnabled = enabled } if d, ok := lookupDurationEnv("COMPLIANCE_SNAPSHOT_RELOAD_INTERVAL"); ok { cfg.Compliance.SnapshotReloadInterval = d } if d, ok := lookupDurationEnv("COMPLIANCE_PUBLISH_GATE_TIMEOUT"); ok { cfg.Compliance.PublishGateTimeout = d } if n, ok := lookupIntEnv("COMPLIANCE_REVIEW_WORKER_CONCURRENCY"); ok { cfg.Compliance.ReviewWorkerConcurrency = n } if d, ok := lookupDurationEnv("COMPLIANCE_REVIEW_WORKER_TIMEOUT"); ok { cfg.Compliance.ReviewWorkerTimeout = d } if n, ok := lookupIntEnv("COMPLIANCE_REVIEW_MAX_ATTEMPTS"); ok { cfg.Compliance.ReviewMaxAttempts = n } if d, ok := lookupDurationEnv("GENERATION_TASK_STATE_CHECK_INTERVAL"); ok { cfg.Generation.TaskStateCheckInterval = d } if d, ok := lookupDurationEnv("GENERATION_TASK_STATE_CHECK_TIMEOUT"); ok { cfg.Generation.TaskStateCheckTimeout = d } if n, ok := lookupIntEnv("GENERATION_TASK_STATE_CHECK_BATCH_SIZE"); ok { cfg.Generation.TaskStateCheckBatchSize = n } if d, ok := lookupDurationEnv("GENERATION_TASK_STATE_CHECK_LOOKBACK"); ok { cfg.Generation.TaskStateCheckLookback = d } if enabled, ok := lookupBoolEnv("BROWSER_FETCH_ENABLED"); ok { cfg.BrowserFetch.Enabled = enabled } if provider, ok := lookupNonEmptyEnv("BROWSER_FETCH_PROVIDER"); ok { cfg.BrowserFetch.Provider = provider } if serviceURL, ok := lookupFirstNonEmptyEnv("BROWSER_FETCH_SERVICE_URL", "LIGHTPANDA_BROWSER_FETCH_URL"); ok { cfg.BrowserFetch.ServiceURL = serviceURL } if token, ok := lookupNonEmptyEnv("BROWSER_FETCH_TOKEN"); ok { cfg.BrowserFetch.Token = token } if n, ok := lookupIntEnv("BROWSER_FETCH_QUEUE_SIZE"); ok { cfg.BrowserFetch.QueueSize = n } if n, ok := lookupIntEnv("BROWSER_FETCH_WORKER_CONCURRENCY"); ok { cfg.BrowserFetch.WorkerConcurrency = n } if d, ok := lookupDurationEnv("BROWSER_FETCH_REQUEST_TIMEOUT"); ok { cfg.BrowserFetch.RequestTimeout = d } if d, ok := lookupDurationEnv("BROWSER_FETCH_CACHE_TTL"); ok { cfg.BrowserFetch.CacheTTL = d } if domains, ok := lookupNonEmptyEnv("BROWSER_FETCH_ALLOWED_DOMAINS"); ok { cfg.BrowserFetch.AllowedDomains = strings.Split(domains, ",") } if statuses, ok := lookupNonEmptyEnv("BROWSER_FETCH_TRIGGER_STATUS"); ok { cfg.BrowserFetch.TriggerStatus = parseIntList(statuses) } if enabled, ok := lookupBoolEnv("MEDIA_SUPPLY_ENABLED"); ok { cfg.MediaSupply.Enabled = enabled } if markup, ok := lookupFloatEnv("MEDIA_SUPPLY_DEFAULT_MARKUP_PERCENT"); ok { cfg.MediaSupply.DefaultMarkupPercent = markup } if cents, ok := lookupInt64Env("MEDIA_SUPPLY_MINIMUM_MARKUP_CENTS"); ok { cfg.MediaSupply.MinimumMarkupCents = cents } if enabled, ok := lookupBoolEnv("MEDIA_SUPPLY_WORKER_ENABLED"); ok { cfg.MediaSupply.Worker.Enabled = enabled } if interval, ok := lookupDurationEnv("MEDIA_SUPPLY_WORKER_POLL_INTERVAL"); ok { cfg.MediaSupply.Worker.PollInterval = interval } if n, ok := lookupIntEnv("MEDIA_SUPPLY_WORKER_BATCH_SIZE"); ok { cfg.MediaSupply.Worker.BatchSize = n } if n, ok := lookupIntEnv("MEDIA_SUPPLY_ORDER_MAX_ATTEMPTS"); ok { cfg.MediaSupply.Worker.OrderMaxAttempts = n } if n, ok := lookupIntEnv("MEDIA_SUPPLY_SYNC_MAX_ATTEMPTS"); ok { cfg.MediaSupply.Worker.SyncMaxAttempts = n } if interval, ok := lookupDurationEnv("MEDIA_SUPPLY_BACKLINK_SYNC_INTERVAL"); ok { cfg.MediaSupply.Worker.BacklinkSyncInterval = interval } if n, ok := lookupIntEnv("MEDIA_SUPPLY_BACKLINK_BATCH_SIZE"); ok { cfg.MediaSupply.Worker.BacklinkBatchSize = n } if baseURL, ok := lookupNonEmptyEnv("MEIJIEQUAN_BASE_URL"); ok { cfg.MediaSupply.Meijiequan.BaseURL = baseURL } if userID, ok := lookupNonEmptyEnv("MEIJIEQUAN_USER_ID"); ok { cfg.MediaSupply.Meijiequan.UserID = userID } if username, ok := lookupNonEmptyEnv("MEIJIEQUAN_USERNAME"); ok { cfg.MediaSupply.Meijiequan.Username = username } if password, ok := lookupNonEmptyEnv("MEIJIEQUAN_PASSWORD"); ok { cfg.MediaSupply.Meijiequan.Password = password } if ttl, ok := lookupDurationEnv("MEIJIEQUAN_SESSION_TTL"); ok { cfg.MediaSupply.Meijiequan.SessionTTL = ttl } if timeout, ok := lookupDurationEnv("MEIJIEQUAN_REQUEST_TIMEOUT"); ok { cfg.MediaSupply.Meijiequan.RequestTimeout = timeout } if delay, ok := lookupDurationEnv("MEIJIEQUAN_SYNC_PAGE_DELAY"); ok { cfg.MediaSupply.Meijiequan.SyncPageDelay = delay } if n, ok := lookupIntEnv("MEIJIEQUAN_SYNC_PAGE_SIZE"); ok { cfg.MediaSupply.Meijiequan.SyncPageSize = n } if n, ok := lookupIntEnv("MEIJIEQUAN_MAX_SYNC_PAGES"); ok { cfg.MediaSupply.Meijiequan.MaxSyncPages = n } if n, ok := lookupIntEnv("MEIJIEQUAN_PUBLISHED_PAGE_SIZE"); ok { cfg.MediaSupply.Meijiequan.PublishedPageSize = n } if n, ok := lookupIntEnv("MEIJIEQUAN_PUBLISHED_MAX_PAGES"); ok { cfg.MediaSupply.Meijiequan.PublishedMaxPages = n } if ttl, ok := lookupDurationEnv("MEIJIEQUAN_SEARCH_OPTIONS_TTL"); ok { cfg.MediaSupply.Meijiequan.SearchOptionsTTL = ttl } if ttl, ok := lookupDurationEnv("MEIJIEQUAN_UPSTREAM_LOCK_TTL"); ok { cfg.MediaSupply.Meijiequan.UpstreamLockTTL = ttl } if interval, ok := lookupDurationEnv("MEIJIEQUAN_UPSTREAM_MIN_INTERVAL"); ok { cfg.MediaSupply.Meijiequan.UpstreamMinInterval = interval } if apiKey, ok := lookupNonEmptyEnv("QDRANT_API_KEY"); ok { cfg.Qdrant.APIKey = apiKey } if rabbitmqURL, ok := lookupNonEmptyEnv("RABBITMQ_URL"); ok { cfg.RabbitMQ.URL = rabbitmqURL } if url, ok := lookupNonEmptyEnv("QDRANT_URL"); ok { cfg.Qdrant.URL = url } if collection, ok := lookupNonEmptyEnv("QDRANT_COLLECTION"); ok { cfg.Qdrant.Collection = collection } if endpoint, ok := lookupNonEmptyEnv("OBJECT_STORAGE_ENDPOINT"); ok { cfg.ObjectStorage.Endpoint = endpoint } if accessKey, ok := lookupNonEmptyEnv("OBJECT_STORAGE_ACCESS_KEY"); ok { cfg.ObjectStorage.AccessKey = accessKey } if secretKey, ok := lookupNonEmptyEnv("OBJECT_STORAGE_SECRET_KEY"); ok { cfg.ObjectStorage.SecretKey = secretKey } if bucket, ok := lookupNonEmptyEnv("OBJECT_STORAGE_BUCKET"); ok { cfg.ObjectStorage.Bucket = bucket } if provider, ok := lookupNonEmptyEnv("OBJECT_STORAGE_PROVIDER"); ok { cfg.ObjectStorage.Provider = provider } if publicBaseURL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_PUBLIC_BASE_URL"); ok { cfg.ObjectStorage.PublicBaseURL = publicBaseURL } if bucketACL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_BUCKET_ACL"); ok { cfg.ObjectStorage.BucketACL = bucketACL } if objectACL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_OBJECT_ACL"); ok { cfg.ObjectStorage.ObjectACL = objectACL } if ttl, ok := lookupDurationEnv("OBJECT_STORAGE_SIGNED_URL_TTL"); ok { cfg.ObjectStorage.SignedURLTTL = ttl } if region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok { cfg.ObjectStorage.Region = region } if forcePathStyle, ok := lookupBoolEnv("OBJECT_STORAGE_FORCE_PATH_STYLE"); ok { cfg.ObjectStorage.ForcePathStyle = forcePathStyle } if useSSL, ok := lookupBoolEnv("OBJECT_STORAGE_USE_SSL"); ok { cfg.ObjectStorage.UseSSL = useSSL } if apiKey, ok := lookupFirstNonEmptyEnv("SILICONFLOW_API_KEY", "RETRIEVAL_API_KEY", "EMBEDDING_API_KEY", "RERANKER_API_KEY"); ok { cfg.Retrieval.APIKey = apiKey } if baseURL, ok := lookupFirstNonEmptyEnv("SILICONFLOW_BASE_URL", "RETRIEVAL_BASE_URL"); ok { cfg.Retrieval.BaseURL = baseURL } if model, ok := lookupFirstNonEmptyEnv("SILICONFLOW_EMBEDDING_MODEL", "RETRIEVAL_EMBEDDING_MODEL"); ok { cfg.Retrieval.EmbeddingModel = model } if model, ok := lookupFirstNonEmptyEnv("SILICONFLOW_RERANKER_MODEL", "RETRIEVAL_RERANKER_MODEL"); ok { cfg.Retrieval.RerankerModel = model } if host, ok := lookupNonEmptyEnv("SCHEDULER_HTTP_HOST"); ok { cfg.Scheduler.HTTPHost = host } if token, ok := lookupFirstNonEmptyEnv("SCHEDULER_INTERNAL_METRICS_TOKEN", "SCHEDULER_METRICS_TOKEN"); ok { cfg.Scheduler.InternalMetricsToken = token } if trustedProxies, ok := lookupNonEmptyEnv("SERVER_TRUSTED_PROXIES"); ok { cfg.Server.TrustedProxies = strings.Split(trustedProxies, ",") } if origins, ok := lookupNonEmptyEnv("SERVER_ALLOWED_ORIGINS"); ok { cfg.Server.AllowedOrigins = strings.Split(origins, ",") } if publicBaseURL, ok := lookupNonEmptyEnv("SERVER_PUBLIC_BASE_URL"); ok { cfg.Server.PublicBaseURL = publicBaseURL } if enabled, ok := lookupBoolEnv("SERVER_SECURITY_HEADERS_ENABLED"); ok { cfg.Server.SecurityHeaders.Enabled = enabled } if enabled, ok := lookupBoolEnv("AUTH_PASSWORD_CIPHER_ENABLED"); ok { cfg.Auth.PasswordCipher.Enabled = enabled } if keyID, ok := lookupNonEmptyEnv("AUTH_PASSWORD_CIPHER_KEY_ID"); ok { cfg.Auth.PasswordCipher.KeyID = keyID } if privateKey, ok := lookupNonEmptyEnv("AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM"); ok { cfg.Auth.PasswordCipher.PrivateKeyPEM = privateKey } if path, ok := lookupNonEmptyEnv("IP_REGION_V4_XDB_PATH"); ok { cfg.IPRegion.V4XDBPath = path } if path, ok := lookupNonEmptyEnv("IP_REGION_V6_XDB_PATH"); ok { cfg.IPRegion.V6XDBPath = path } } func normalizeRabbitMQConfig(cfg *RabbitMQConfig) { if cfg == nil { return } if strings.TrimSpace(cfg.MonitorResultExchange) == "" { cfg.MonitorResultExchange = "monitor.result" } if strings.TrimSpace(cfg.MonitorResultRoutingKey) == "" { cfg.MonitorResultRoutingKey = "monitor.result.ingest" } if strings.TrimSpace(cfg.MonitorResultQueue) == "" { cfg.MonitorResultQueue = "monitor.result.ingest" } if strings.TrimSpace(cfg.MonitorResultDLX) == "" { cfg.MonitorResultDLX = "monitor.result.dlx" } if strings.TrimSpace(cfg.MonitorResultDLQ) == "" { cfg.MonitorResultDLQ = "monitor.result.ingest.dlq" } if strings.TrimSpace(cfg.MonitorResultDLQRouteKey) == "" { cfg.MonitorResultDLQRouteKey = "monitor.result.ingest.dlq" } if strings.TrimSpace(cfg.MonitorProjectionExchange) == "" { cfg.MonitorProjectionExchange = "monitor.projection" } if strings.TrimSpace(cfg.MonitorProjectionRoutingKey) == "" { cfg.MonitorProjectionRoutingKey = "monitor.projection.rebuild" } if strings.TrimSpace(cfg.MonitorProjectionQueue) == "" { cfg.MonitorProjectionQueue = "monitor.projection.rebuild" } if strings.TrimSpace(cfg.MonitorProjectionDLX) == "" { cfg.MonitorProjectionDLX = "monitor.projection.dlx" } if strings.TrimSpace(cfg.MonitorProjectionDLQ) == "" { cfg.MonitorProjectionDLQ = "monitor.projection.rebuild.dlq" } if strings.TrimSpace(cfg.MonitorProjectionDLQRouteKey) == "" { cfg.MonitorProjectionDLQRouteKey = "monitor.projection.rebuild.dlq" } if strings.TrimSpace(cfg.GenerationExchange) == "" { cfg.GenerationExchange = "generation.task" } if strings.TrimSpace(cfg.GenerationRoutingKey) == "" { cfg.GenerationRoutingKey = "generation.task.run" } if strings.TrimSpace(cfg.GenerationQueue) == "" { cfg.GenerationQueue = "generation.task.run" } if strings.TrimSpace(cfg.GenerationDLX) == "" { cfg.GenerationDLX = "generation.task.dlx" } if strings.TrimSpace(cfg.GenerationDLQ) == "" { cfg.GenerationDLQ = "generation.task.run.dlq" } if strings.TrimSpace(cfg.GenerationDLQRouteKey) == "" { cfg.GenerationDLQRouteKey = "generation.task.run.dlq" } if strings.TrimSpace(cfg.GenerationEventExchange) == "" { cfg.GenerationEventExchange = "generation.event" } 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.DesktopAccountHealthExchange) == "" { cfg.DesktopAccountHealthExchange = "desktop.account.health" } if strings.TrimSpace(cfg.DesktopAccountHealthRoutingKey) == "" { cfg.DesktopAccountHealthRoutingKey = "desktop.account.health.report" } if strings.TrimSpace(cfg.DesktopAccountHealthQueue) == "" { cfg.DesktopAccountHealthQueue = "desktop.account.health.report" } if strings.TrimSpace(cfg.DesktopAccountHealthDLX) == "" { cfg.DesktopAccountHealthDLX = "desktop.account.health.dlx" } if strings.TrimSpace(cfg.DesktopAccountHealthDLQ) == "" { cfg.DesktopAccountHealthDLQ = "desktop.account.health.report.dlq" } if strings.TrimSpace(cfg.DesktopAccountHealthDLQRouteKey) == "" { cfg.DesktopAccountHealthDLQRouteKey = "desktop.account.health.report.dlq" } if strings.TrimSpace(cfg.TemplateAssistExchange) == "" { cfg.TemplateAssistExchange = "template.assist" } if strings.TrimSpace(cfg.TemplateAssistRoutingKey) == "" { cfg.TemplateAssistRoutingKey = "template.assist.run" } if strings.TrimSpace(cfg.TemplateAssistQueue) == "" { cfg.TemplateAssistQueue = "template.assist.run" } if strings.TrimSpace(cfg.TemplateAssistDLX) == "" { cfg.TemplateAssistDLX = "template.assist.dlx" } if strings.TrimSpace(cfg.TemplateAssistDLQ) == "" { cfg.TemplateAssistDLQ = "template.assist.run.dlq" } if strings.TrimSpace(cfg.TemplateAssistDLQRouteKey) == "" { cfg.TemplateAssistDLQRouteKey = "template.assist.run.dlq" } if strings.TrimSpace(cfg.KolAssistExchange) == "" { cfg.KolAssistExchange = "kol.assist" } if strings.TrimSpace(cfg.KolAssistRoutingKey) == "" { cfg.KolAssistRoutingKey = "kol.assist.run" } if strings.TrimSpace(cfg.KolAssistQueue) == "" { cfg.KolAssistQueue = "kol.assist.run" } if strings.TrimSpace(cfg.KolAssistDLX) == "" { cfg.KolAssistDLX = "kol.assist.dlx" } if strings.TrimSpace(cfg.KolAssistDLQ) == "" { cfg.KolAssistDLQ = "kol.assist.run.dlq" } if strings.TrimSpace(cfg.KolAssistDLQRouteKey) == "" { cfg.KolAssistDLQRouteKey = "kol.assist.run.dlq" } if strings.TrimSpace(cfg.ImageAssetExchange) == "" { cfg.ImageAssetExchange = "image.asset" } if strings.TrimSpace(cfg.ImageAssetRoutingKey) == "" { cfg.ImageAssetRoutingKey = "image.asset.finalize" } if strings.TrimSpace(cfg.ImageAssetQueue) == "" { cfg.ImageAssetQueue = "image.asset.finalize" } if strings.TrimSpace(cfg.ImageAssetDLX) == "" { cfg.ImageAssetDLX = "image.asset.dlx" } if strings.TrimSpace(cfg.ImageAssetDLQ) == "" { cfg.ImageAssetDLQ = "image.asset.finalize.dlq" } if strings.TrimSpace(cfg.ImageAssetDLQRouteKey) == "" { cfg.ImageAssetDLQRouteKey = "image.asset.finalize.dlq" } if strings.TrimSpace(cfg.ComplianceReviewExchange) == "" { cfg.ComplianceReviewExchange = "compliance.review" } if strings.TrimSpace(cfg.ComplianceReviewRoutingKey) == "" { cfg.ComplianceReviewRoutingKey = "compliance.review.run" } if strings.TrimSpace(cfg.ComplianceReviewQueue) == "" { cfg.ComplianceReviewQueue = "compliance.review.run" } if strings.TrimSpace(cfg.ComplianceReviewDLX) == "" { cfg.ComplianceReviewDLX = "compliance.review.dlx" } if strings.TrimSpace(cfg.ComplianceReviewDLQ) == "" { cfg.ComplianceReviewDLQ = "compliance.review.run.dlq" } if strings.TrimSpace(cfg.ComplianceReviewDLQRouteKey) == "" { cfg.ComplianceReviewDLQRouteKey = "compliance.review.run.dlq" } if cfg.PublishChannelPoolSize <= 0 { cfg.PublishChannelPoolSize = 16 } } func normalizeSchedulerConfig(cfg *SchedulerConfig) { if cfg == nil { return } if cfg.HTTPPort == 0 { cfg.HTTPPort = 8081 } cfg.HTTPHost = strings.TrimSpace(cfg.HTTPHost) if cfg.HTTPHost == "" { cfg.HTTPHost = "127.0.0.1" } cfg.InternalMetricsToken = strings.TrimSpace(cfg.InternalMetricsToken) if cfg.DispatchInterval <= 0 { cfg.DispatchInterval = 15 * time.Second } if cfg.DispatchTimeout <= 0 { cfg.DispatchTimeout = 30 * time.Second } if cfg.DispatchBatchSize <= 0 { cfg.DispatchBatchSize = 20 } if cfg.DispatchConcurrency <= 0 { cfg.DispatchConcurrency = 1 } } func normalizeMonitoringConfig(cfg *MonitoringConfig) { if cfg == nil { return } if cfg.ResultIngestConcurrency <= 0 { cfg.ResultIngestConcurrency = 1 } if cfg.ProjectionRebuildConcurrency <= 0 { cfg.ProjectionRebuildConcurrency = 1 } } func normalizeBrandLibraryConfig(cfg *BrandLibraryConfig) { if cfg == nil { return } if cfg.FreeBrandLimit <= 0 { cfg.FreeBrandLimit = 1 } if cfg.PaidBrandLimit <= 0 { cfg.PaidBrandLimit = 2 } if cfg.MaxKeywords <= 0 { cfg.MaxKeywords = 5 } if cfg.MaxQuestionsPerKeyword <= 0 { cfg.MaxQuestionsPerKeyword = 5 } if cfg.QuestionLimitsByPlan == nil { cfg.QuestionLimitsByPlan = map[string]int{} } normalized := make(map[string]int, len(cfg.QuestionLimitsByPlan)+4) for key, value := range cfg.QuestionLimitsByPlan { trimmed := strings.ToLower(strings.TrimSpace(key)) if trimmed != "" && value > 0 { normalized[trimmed] = value } } if normalized["default"] <= 0 { normalized["default"] = 25 } if normalized["free"] <= 0 { normalized["free"] = 5 } if normalized["plus"] <= 0 { normalized["plus"] = 25 } if normalized["pro"] <= 0 { normalized["pro"] = 50 } cfg.QuestionLimitsByPlan = normalized } func normalizeMembershipConfig(cfg *MembershipConfig) { if cfg == nil { return } if strings.TrimSpace(cfg.DefaultPlanCode) == "" { cfg.DefaultPlanCode = "free" } if len(cfg.Plans) == 0 { cfg.Plans = defaultMembershipPlans() } normalized := make([]MembershipPlanConfig, 0, len(cfg.Plans)) seen := make(map[string]struct{}, len(cfg.Plans)) for _, plan := range cfg.Plans { plan.Code = strings.ToLower(strings.TrimSpace(plan.Code)) if plan.Code == "" { continue } if _, exists := seen[plan.Code]; exists { continue } if strings.TrimSpace(plan.Name) == "" { plan.Name = defaultPlanName(plan.Code) } plan.ArticleQuotaCycle = normalizeArticleQuotaCycle(plan.ArticleQuotaCycle, plan.Code) if plan.ArticleGeneration < 0 { plan.ArticleGeneration = 0 } if plan.AIPointsMonthly < 0 { plan.AIPointsMonthly = 0 } plan.AIPointBaseChars = normalizeAIPointBaseChars(plan.AIPointBaseChars) if plan.ImageStorageBytes < 0 { plan.ImageStorageBytes = 0 } if plan.CompanyLimit < 0 { plan.CompanyLimit = 0 } if plan.SubscriptionDuration < 0 { plan.SubscriptionDuration = 0 } normalized = append(normalized, plan) seen[plan.Code] = struct{}{} } if len(normalized) == 0 { normalized = defaultMembershipPlans() } cfg.Plans = normalized if _, ok := cfg.FindPlan(cfg.DefaultPlanCode); !ok { cfg.DefaultPlanCode = cfg.Plans[0].Code } } func defaultMembershipPlans() []MembershipPlanConfig { return []MembershipPlanConfig{ { Code: "free", Name: "Free", ArticleGeneration: 3, ArticleQuotaCycle: ArticleQuotaCycleLifetime, AIPointBaseChars: 1000, ImageStorageBytes: 10 * 1024 * 1024, CompanyLimit: 1, SubscriptionDuration: 72 * time.Hour, ContactAdminOnExpiry: true, }, { Code: "plus", Name: "Plus", ArticleGeneration: 200, ArticleQuotaCycle: ArticleQuotaCycleMonthly, AIPointBaseChars: 1000, ImageStorageBytes: 500 * 1024 * 1024, CompanyLimit: 1, SubscriptionDuration: 720 * time.Hour, }, { Code: "pro", Name: "Pro", ArticleGeneration: 400, ArticleQuotaCycle: ArticleQuotaCycleMonthly, AIPointsMonthly: 1500, AIPointBaseChars: 1000, ImageStorageBytes: 1024 * 1024 * 1024, CompanyLimit: 2, SubscriptionDuration: 720 * time.Hour, }, } } func normalizeAIPointBaseChars(value int) int { if value <= 0 { return 1000 } return value } func normalizeArticleQuotaCycle(value, planCode string) string { switch strings.ToLower(strings.TrimSpace(value)) { case ArticleQuotaCycleLifetime: return ArticleQuotaCycleLifetime case ArticleQuotaCycleMonthly: return ArticleQuotaCycleMonthly default: if strings.EqualFold(strings.TrimSpace(planCode), "free") { return ArticleQuotaCycleLifetime } return ArticleQuotaCycleMonthly } } func defaultPlanName(code string) string { switch strings.ToLower(strings.TrimSpace(code)) { case "free": return "Free" case "plus": return "Plus" case "pro": return "Pro" default: return strings.ToUpper(strings.TrimSpace(code)) } } func lookupFirstNonEmptyEnv(keys ...string) (string, bool) { for _, key := range keys { if value, ok := lookupNonEmptyEnv(key); ok { return value, true } } return "", false } func lookupNonEmptyEnv(key string) (string, bool) { value, ok := os.LookupEnv(key) if !ok { return "", false } value = strings.TrimSpace(value) if value == "" { return "", false } return value, true } func lookupBoolEnv(key string) (bool, bool) { value, ok := os.LookupEnv(key) if !ok { return false, false } switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "on": return true, true case "0", "false", "no", "off": return false, true default: return false, false } } func lookupDurationEnv(key string) (time.Duration, bool) { value, ok := lookupNonEmptyEnv(key) if !ok { return 0, false } duration, err := time.ParseDuration(value) if err != nil { return 0, false } return duration, true } func lookupIntEnv(key string) (int, bool) { value, ok := lookupNonEmptyEnv(key) if !ok { return 0, false } number, err := strconv.Atoi(value) if err != nil { return 0, false } return number, true } func lookupInt64Env(key string) (int64, bool) { value, ok := lookupNonEmptyEnv(key) if !ok { return 0, false } number, err := strconv.ParseInt(value, 10, 64) if err != nil { return 0, false } return number, true } func lookupFloatEnv(key string) (float64, bool) { value, ok := lookupNonEmptyEnv(key) if !ok { return 0, false } number, err := strconv.ParseFloat(value, 64) if err != nil { return 0, false } return number, true } func maxInt(value, fallback int) int { if value < fallback { return fallback } return value } func maxInt64(value, fallback int64) int64 { if value < fallback { return fallback } return value }