Files
geo/server/internal/shared/config/config.go
T
root 794a2d89db feat(server/membership): add ai_points plan policy fields
Introduces ai_points_monthly and ai_point_base_chars on the membership
plan policy, exposes them through TenantPlanAccess and MembershipInfo,
and seeds Pro with a 1500-point monthly allowance across the local,
server, and deploy configs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:33:30 +08:00

867 lines
29 KiB
Go

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
}