"""T+ 只读 SQL Server 连接。 T+ 是外部源库,业务接口不直接依赖它。连接采用延迟初始化,未配置或未启用 时不会影响主业务服务启动;适配器执行查询时才建立连接池。 """ import json from collections.abc import Generator from pathlib import Path from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError from sqlalchemy import Engine, create_engine, event from sqlalchemy.orm import Session, sessionmaker from app.core.config import Settings, get_settings _engine: Engine | None = None _session_factory: sessionmaker[Session] | None = None class _ConnectionFile(BaseModel): """连接文件仅允许源库字段,密码不会出现在模型表示中。""" model_config = ConfigDict(extra="forbid", strict=True) host: str = Field(min_length=1, max_length=255) port: int = Field(ge=1, le=65535) database: str = Field(min_length=1, max_length=128) user: str = Field(min_length=1, max_length=128) password: SecretStr driver: str = Field(min_length=1, max_length=128) def resolve_tplus_settings(settings: Settings) -> Settings: """解析显式配置的只读连接,返回副本,不修改应用全局配置。""" if not settings.t_plus_enabled: raise RuntimeError("T+ 数据源未启用,请设置 T_PLUS_ENABLED=true") resolved = settings if settings.t_plus_connection_file: try: path = Path(settings.t_plus_connection_file) if path.stat().st_size > 16384: raise ValueError("连接文件过大") config = _ConnectionFile.model_validate( json.loads(path.read_text(encoding="utf-8-sig")) ) except OSError: # 消息只区分故障类别,不回显路径内容;路径本身可在应用配置中核对。 raise RuntimeError("T+ 只读连接文件不存在或不可读") from None except (ValueError, ValidationError): raise RuntimeError("T+ 只读连接文件字段不正确") from None values = { "t_plus_" + key: getattr(config, key) for key in ("host", "port", "database", "user") } values.update( t_plus_password=config.password.get_secret_value(), t_plus_odbc_driver=config.driver ) resolved = settings.model_copy(update=values) allow_dev_sa = settings.app_env.strip().casefold() == "dev" and settings.t_plus_allow_dev_sa if resolved.t_plus_user.strip().casefold() == "sa" and not ( allow_dev_sa or settings.t_plus_allow_admin_account ): raise RuntimeError("T+ 管理员账号未获授权,请检查连接配置") if not resolved.t_plus_database or not resolved.t_plus_user or not resolved.t_plus_odbc_driver: raise RuntimeError("T+ 只读连接缺少数据库、账号或已验证的 ODBC 驱动") return resolved def get_tplus_engine() -> Engine: """获取 T+ 只读引擎;未启用或缺少 ODBC 驱动时给出可读错误。""" global _engine, _session_factory if _engine is not None: return _engine settings = resolve_tplus_settings(get_settings()) try: _engine = create_engine( settings.t_plus_database_url, pool_pre_ping=True, pool_recycle=1800, pool_timeout=settings.t_plus_connect_timeout, echo=False, hide_parameters=True, ) except ModuleNotFoundError as exc: raise RuntimeError( "T+ 连接需要 pyodbc(uv sync --extra tplus)和已验证的 SQL Server ODBC 驱动" ) from exc _session_factory = sessionmaker(_engine, expire_on_commit=False) def configure_timeout(connection, _record): connection.timeout = settings.t_plus_query_timeout event.listen(_engine, "connect", configure_timeout) return _engine def get_tplus_session_factory() -> sessionmaker[Session]: """获取 T+ SQLAlchemy 会话工厂。""" get_tplus_engine() assert _session_factory is not None return _session_factory def get_tplus_db() -> Generator[Session, None, None]: """提供只读 T+ 会话;调用方不得 commit 或执行写操作。""" factory = get_tplus_session_factory() with factory() as session: yield session def dispose_tplus_engine() -> None: """释放 T+ 连接池。""" global _engine, _session_factory if _engine is not None: _engine.dispose() _engine = None _session_factory = None