"""应用配置。 从 `backend/.env` 读取配置项,字段名与 `.env` 中的变量名一一对应 (pydantic-settings 默认大小写不敏感,`app_secret_key` ←→ `APP_SECRET_KEY`)。 设计取舍: - `APP_SECRET_KEY` 无默认值 —— 缺失时启动即失败。空密钥签发 JWT 属安全问题 而非可用性问题,必须 fail-fast,不能容忍"后端起得来但令牌可被伪造"。 - 数据库/Redis 连接不在此处建立 —— 依赖不可用时仍应能启动应用、查看接口文档。 """ from functools import lru_cache from pathlib import Path from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy import URL # backend/ 目录:本文件位于 backend/app/core/config.py,向上三级 BACKEND_DIR = Path(__file__).resolve().parents[2] ENV_FILE = BACKEND_DIR / ".env" class Settings(BaseSettings): """应用配置项。""" model_config = SettingsConfigDict( env_file=str(ENV_FILE), # Windows 默认编码为 cp936,不显式指定会导致中文值解析出错 env_file_encoding="utf-8", case_sensitive=False, # .env 中可能含本服务用不到的变量(如 docker 编排专用项),忽略而非报错 extra="ignore", ) # ===== 应用 ===== app_env: str = "dev" app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符") # ===== 鉴权 ===== # 规范第 13 节:Token 有效期 ≤ 2 小时 jwt_expire_minutes: int = Field(default=120, ge=1, le=120) # 规范第 6 节:验证码 300s captcha_expire_seconds: int = Field(default=300, ge=60, le=300) login_fail_limit: int = Field(default=5, ge=1) login_lock_minutes: int = Field(default=15, ge=1) # ===== MySQL ===== mysql_host: str = "127.0.0.1" mysql_port: int = 3306 mysql_database: str = "payroll" mysql_user: str = "payroll" mysql_password: str = "" # ===== Redis ===== redis_host: str = "127.0.0.1" redis_port: int = 6379 redis_password: str = "" redis_db: int = 0 @field_validator("app_secret_key") @classmethod def _check_secret_key(cls, v: str) -> str: """校验密钥强度:过短的密钥可被暴力破解,直接拒绝启动。""" if len(v) < 32: raise ValueError("APP_SECRET_KEY 长度不足 32 字符,请在 backend/.env 中重新生成") return v @property def database_url(self) -> URL: """数据库连接 URL。运行期与 Alembic 迁移共用同一份。 用 SQLAlchemy 的 URL.create 而非手工拼接字符串 —— 密码含 @ : / 等 特殊字符时手工拼接会产生语法错误。 驱动为同步的 pymysql:本项目并发极低,异步无收益;而绩效核算与 pandas 报表都是同步阻塞代码,同步方案不会与之冲突。 """ return URL.create( drivername="mysql+pymysql", username=self.mysql_user, password=self.mysql_password, host=self.mysql_host, port=self.mysql_port, database=self.mysql_database, query={"charset": "utf8mb4"}, ) @property def redis_url(self) -> str: """Redis 连接 URL。密码为空时不拼接认证段。""" auth = f":{self.redis_password}@" if self.redis_password else "" return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}" @lru_cache def get_settings() -> Settings: """获取配置单例。 进程内只解析一次 .env;测试中如需覆盖,调用方应使用 `get_settings.cache_clear()` 配合环境变量注入。 """ return Settings() # type: ignore[call-arg]