原先 OSError、ValueError、ValidationError 统一归并为「不可用 或字段不正确」,文件不存在和字段写错无法区分,排障困难。 拆分为两条消息分别指示故障类别,仍不回显文件内容与路径。 Co-Authored-By: Claude Code <noreply@anthropic.com>
116 lines
4.3 KiB
Python
116 lines
4.3 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:
|
||
# 消息只区分故障类别,不回显路径内容;路径本身可在应用配置中核对。
|
||
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)
|
||
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
|