113 lines
4.1 KiB
Python
113 lines
4.1 KiB
Python
|
|
"""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, 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)
|
|||
|
|
if resolved.t_plus_user.strip().casefold() == "sa":
|
|||
|
|
raise RuntimeError("应用必须使用 T+ 专用只读账号,不能使用 sa")
|
|||
|
|
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
|