from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """应用全局配置""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", ) # 应用基础配置 app_name: str = "DreamWeaver" debug: bool = False secret_key: str = Field(..., description="JWT 签名密钥") base_url: str = Field("http://localhost:8000", description="后端对外回调地址") # 数据库 database_url: str = Field(..., description="SQLAlchemy async URL") # OAuth - GitHub github_client_id: str = "" github_client_secret: str = "" # OAuth - Google google_client_id: str = "" google_client_secret: str = "" # AI Capability Keys text_api_key: str = "" tts_api_base: str = "" tts_api_key: str = "" image_api_key: str = "" # Additional Provider API Keys openai_api_key: str = "" elevenlabs_api_key: str = "" cqtai_api_key: str = "" minimax_api_key: str = "" minimax_group_id: str = "" antigravity_api_key: str = "" antigravity_api_base: str = "" # AI Model Configuration text_model: str = "gemini-2.0-flash" openai_model: str = "gpt-4o-mini" tts_model: str = "" image_model: str = "nano-banana-pro" tts_minimax_model: str = "speech-2.6-turbo" tts_elevenlabs_model: str = "eleven_multilingual_v2" tts_edge_voice: str = "zh-CN-XiaoxiaoNeural" antigravity_model: str = "gemini-3-pro-image" # Provider routing (ordered lists) text_providers: list[str] = Field(default_factory=lambda: ["gemini"]) image_providers: list[str] = Field(default_factory=lambda: ["cqtai"]) tts_providers: list[str] = Field(default_factory=lambda: ["minimax", "elevenlabs", "edge_tts"]) storybook_providers: list[str] = Field(default_factory=lambda: ["storybook_primary"]) enable_demo_providers: bool = Field( False, description="Enable local deterministic demo providers for portfolio demos", ) story_audio_cache_dir: str = Field( "storage/audio", description="Directory for cached story audio files", ) story_audio_cache_ttl_days: int = Field( 30, description="TTL in days before cached story audio is pruned", ) generation_job_stale_minutes: int = Field( 60, description="Minutes before a running generation job is considered stale", ) # Celery (Redis) celery_broker_url: str = Field("redis://localhost:6379/0") celery_result_backend: str = Field("redis://localhost:6379/0") # Generic Redis redis_url: str = Field("redis://localhost:6379/0", description="Redis connection URL") redis_sentinel_enabled: bool = Field(False, description="Whether to enable Redis Sentinel") redis_sentinel_nodes: str = Field( "", description="Comma-separated Redis Sentinel nodes, e.g. host1:26379,host2:26379", ) redis_sentinel_master_name: str = Field("mymaster", description="Redis Sentinel master name") redis_sentinel_password: str = Field("", description="Password for Redis Sentinel (optional)") redis_sentinel_db: int = Field(0, description="Redis DB index when using Sentinel") redis_sentinel_socket_timeout: float = Field( 0.5, description="Socket timeout in seconds for Sentinel clients", ) # Admin console enable_admin_console: bool = False admin_username: str = "admin" admin_password: str = "" # CORS cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"]) @model_validator(mode="after") def _require_core_settings(self) -> "Settings": # type: ignore[override] missing = [] if not self.secret_key or self.secret_key == "change-me-in-production": missing.append("SECRET_KEY") if not self.database_url: missing.append("DATABASE_URL") if self.redis_sentinel_enabled and not self.redis_sentinel_nodes.strip(): missing.append("REDIS_SENTINEL_NODES") if missing: raise ValueError(f"Missing required settings: {', '.join(missing)}") if self.enable_admin_console: weak_admin_passwords = {"", "admin", "admin123", "password", "change-me"} if not self.debug and self.admin_password in weak_admin_passwords: raise ValueError( "ADMIN_PASSWORD must be set to a strong value when admin console is enabled" ) return self @property def redis_sentinel_hosts(self) -> list[tuple[str, int]]: """Parse Redis Sentinel nodes into (host, port) tuples.""" nodes = [] raw = self.redis_sentinel_nodes.strip() if not raw: return nodes for item in raw.split(","): value = item.strip() if not value: continue if ":" not in value: raise ValueError(f"Invalid sentinel node format: {value}") host, port_text = value.rsplit(":", 1) if not host: raise ValueError(f"Invalid sentinel node host: {value}") try: port = int(port_text) except ValueError as exc: raise ValueError(f"Invalid sentinel node port: {value}") from exc nodes.append((host, port)) return nodes @property def redis_sentinel_urls(self) -> list[str]: """Build Celery-compatible Sentinel URLs with DB index.""" return [ f"sentinel://{host}:{port}/{self.redis_sentinel_db}" for host, port in self.redis_sentinel_hosts ] settings = Settings()