package config import ( "encoding/json" "fmt" "os" "strings" "time" "github.com/spf13/viper" ) 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"` Redis RedisConfig `mapstructure:"redis"` Qdrant QdrantConfig `mapstructure:"qdrant"` ObjectStorage ObjectStorageConfig `mapstructure:"object_storage"` Cache CacheConfig `mapstructure:"cache"` JWT JWTConfig `mapstructure:"jwt"` Log LogConfig `mapstructure:"log"` LLM LLMConfig `mapstructure:"llm"` Retrieval RetrievalConfig `mapstructure:"retrieval"` Generation GenerationConfig `mapstructure:"generation"` } type ServerConfig struct { Port int `mapstructure:"port"` Mode string `mapstructure:"mode"` } 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 { return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", d.User, d.Password, d.Host, d.Port, d.DBName, d.SSLMode) } type RedisConfig struct { Addr string `mapstructure:"addr"` DB int `mapstructure:"db"` } 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"` 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"` } func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int { if strings.EqualFold(strings.TrimSpace(planCode), "free") { return c.FreeBrandLimit } return c.PaidBrandLimit } 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"` Region string `mapstructure:"region"` } type JWTConfig struct { Secret string `mapstructure:"secret"` AccessTTL time.Duration `mapstructure:"access_ttl"` RefreshTTL time.Duration `mapstructure:"refresh_ttl"` } 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 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" } type GenerationConfig struct { QueueSize int `mapstructure:"queue_size"` WorkerConcurrency int `mapstructure:"worker_concurrency"` StreamEnabled bool `mapstructure:"stream_enabled"` ArticleTimeout time.Duration `mapstructure:"article_timeout"` } func Load(configPath string) (*Config, error) { v := viper.New() v.AutomaticEnv() v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) if err := readConfigWithFallback(v, configPath); err != nil { return nil, fmt.Errorf("read config: %w", err) } // Allow local overrides _ = mergeLocalOverrideWithFallback(v, configPath) var cfg Config if err := v.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("unmarshal config: %w", err) } applyEnvOverrides(&cfg) normalizeRabbitMQConfig(&cfg.RabbitMQ) normalizeSchedulerConfig(&cfg.Scheduler) normalizeMonitoringConfig(&cfg.MonitoringWorkers) normalizeMonitoringDispatchConfig(&cfg.MonitoringDispatch) normalizeMembershipConfig(&cfg.Membership) normalizeBrandLibraryConfig(&cfg.BrandLibrary) return &cfg, nil } func readConfigWithFallback(v *viper.Viper, configPath string) error { var lastErr error for _, candidate := range candidateConfigPaths(configPath, false) { v.SetConfigFile(candidate) if err := v.ReadInConfig(); err == nil { return nil } else { lastErr = err } } return lastErr } func mergeLocalOverrideWithFallback(v *viper.Viper, configPath string) error { for _, candidate := range candidateConfigPaths(configPath, true) { v.SetConfigFile(candidate) if err := v.MergeInConfig(); err == nil { return nil } } return nil } func normalizeMonitoringDispatchConfig(cfg *MonitoringDispatchConfig) { if cfg == nil { return } if cfg.DesktopTasksRolloutPercentage < 0 { cfg.DesktopTasksRolloutPercentage = 0 } if cfg.DesktopTasksRolloutPercentage > 100 { cfg.DesktopTasksRolloutPercentage = 100 } } 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 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 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 region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok { cfg.ObjectStorage.Region = region } 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 } } 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 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 } } 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 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 }