"""配置以项目位置解析,开发密钥持久化才能保证重启后令牌仍可验证。""" from pathlib import Path import secrets from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict ROOT = Path(__file__).resolve().parents[2] class Settings(BaseSettings): """关键参数有范围限制,错误配置在启动阶段暴露而非拖到审核过程中。""" model_config = SettingsConfigDict(env_file=ROOT / ".env", extra="ignore", case_sensitive=False) app_env: str = "development" # 生产环境启用额外密钥与演示数据校验。 host: str = "127.0.0.1" port: int = Field(default=8000, ge=1, le=65535) api_prefix: str = "/api/v1" data_dir: str = "data" database_url: str = "" jwt_secret: str = "" access_minutes: int = Field(default=15, ge=1, le=1440) refresh_days: int = Field(default=7, ge=1, le=30) cors_origins: list[str] = ["http://localhost:5173", "http://127.0.0.1:5173"] business_timezone: str = "Asia/Shanghai" seed_demo: bool = True demo_password: str = "Demo@2026!" max_upload_mb: int = Field(default=10, ge=1, le=100) max_batch_files: int = Field(default=20, ge=1, le=100) max_pdf_pages: int = Field(default=5, ge=1, le=30) worker_enabled: bool = True worker_interval: float = Field(default=0.5, ge=0.1, le=30) outbox_attempts: int = Field(default=3, ge=1, le=10) approval_timeout_hours: int = Field(default=48, ge=1, le=720) timeout_scan_seconds: int = Field(default=60, ge=1, le=3600) rate_limit_per_minute: int = Field(default=120, ge=10, le=10000) llm_base_url: str = "" llm_model: str = "" vlm_base_url: str = "" vlm_model: str = "" ai_api_key: str = "" embedding_base_url: str = "" embedding_model: str = "bge-large-zh-v1.5" embedding_dimension: int = Field(default=1024, ge=1, le=8192) rerank_base_url: str = "" ai_timeout: float = Field(default=20, ge=1, le=120) ai_concurrency: int = Field(default=3, ge=1, le=10) ai_failure_threshold: int = Field(default=5, ge=1, le=100) ai_recovery_seconds: int = Field(default=60, ge=1, le=600) pdf_font_path: str = "" @model_validator(mode="after") def validate_environment(self): """限制生产弱配置;允许本地演示启动但不把它当作生产部署。""" if not self.api_prefix.startswith("/") or self.api_prefix.endswith("/"): raise ValueError("API_PREFIX 必须以 / 开头且末尾不能是 /") if self.app_env == "production" and (self.seed_demo or len(self.jwt_secret) < 32): raise ValueError("生产必须关闭 SEED_DEMO 并设置至少 32 字符 JWT_SECRET") return self @property def data_path(self) -> Path: """外部路径只由服务端配置提供,前端不能传入任意文件系统路径。""" return (ROOT / self.data_dir).resolve() @property def db_url(self) -> str: return self.database_url or "sqlite+aiosqlite:///" + (self.data_path / "audit.db").as_posix() def secret(self) -> str: """开发密钥仅写入运行数据目录,打包时排除以免传播实际凭据。""" if self.jwt_secret: return self.jwt_secret path = self.data_path / "jwt.secret" path.parent.mkdir(parents=True, exist_ok=True) if not path.exists(): path.write_text(secrets.token_hex(32), encoding="utf-8") return path.read_text(encoding="utf-8").strip() settings = Settings()