死代码移除: - whiteboard_hub.py: 移除未引用的 reset_hub 单例重置函数 - tunnel_service.py: 移除未引用的 is_port_allowed (逻辑已在 sftp_server 内联) - tunnel_session_dao.py: 移除未引用的 get_active_by_port - pdf_job_dao.py: 移除未用 datetime 导入 - pdf_converter.py: 移除未用 shutil 导入 - pdf_service.py: 移除未用 PdfSubmitResponse 导入 + _do_convert 内未用 hashlib 导入 - upload_html.py: 移除未用 escape 导入 (JS 侧自有 escapeHtml) - pdf_controller.py: 移除 _resolve_cookie 内未用 cfg 局部变量 提前失败/分层修复: - database.py init_db_schema: 建表后用 inspector 校验既有表列与模型一致, 缺列即抛 RuntimeError (fail-fast on schema drift), 避免运行期才暴露 - whiteboard_dao.get_or_create: 仅 IntegrityError 才回滚重读, 其他异常向上抛 (原 except Exception 会掩盖 schema/连接等真实故障) - pdf_service.admin_delete/_safe_delete_file: 改用 PdfJobDAO.delete / UploadedFileDAO.delete, 不再直接操作 job_dao.db / file_dao.db (修复分层契约: DAO 头注释声明 service 不直接操作 session) - PdfJobDAO 新增 delete(job) 方法 日志补全 (8 处 silent catch): - whiteboard_hub.py disconnect/close_board 关闭 ws: logger.debug - whiteboard_controller _safe_send/_safe_close: logger.debug - sftp_server _close_tunnel_dao/读用户名: logger.debug - sftp_server validate_public_key: logger.warning (auth 路径, 避免静默失败) 文档: - 新增 docs/routes.md, docs/configuration.md, docs/error-handling.md - README.md 精简为简介/结构/外部依赖/apache2 配置/Ubuntu 安装/docs 链接
93 lines
2.9 KiB
Python
93 lines
2.9 KiB
Python
"""数据库引擎、Session 与 Declarative Base。
|
||
|
||
引擎采用懒初始化:首次访问 `get_engine()` 时才创建连接池,
|
||
避免 import 时的副作用,并支持 `dispose()` 后重新加载配置。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Generator
|
||
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||
|
||
from .config import get_settings
|
||
|
||
_engine: create_engine | None = None
|
||
_session_local: sessionmaker | None = None
|
||
|
||
|
||
def get_engine() -> create_engine:
|
||
"""返回全局 Engine 实例;首次调用时懒创建。"""
|
||
global _engine, _session_local
|
||
if _engine is None:
|
||
s = get_settings()
|
||
_engine = create_engine(
|
||
s.db_url(),
|
||
pool_pre_ping=True,
|
||
pool_size=s.database.pool_size,
|
||
pool_recycle=s.database.pool_recycle,
|
||
future=True,
|
||
)
|
||
_session_local = sessionmaker(
|
||
bind=_engine, autoflush=False, autocommit=False, future=True,
|
||
)
|
||
return _engine
|
||
|
||
|
||
def dispose_engine() -> None:
|
||
"""关闭连接池并清除缓存,下次访问时重新创建(配合 reload_settings 使用)。"""
|
||
global _engine, _session_local
|
||
if _engine is not None:
|
||
_engine.dispose()
|
||
_engine = None
|
||
_session_local = None
|
||
|
||
|
||
def get_session_local() -> sessionmaker:
|
||
"""返回全局 SessionLocal;确保 Engine 已初始化。"""
|
||
get_engine()
|
||
return _session_local # type: ignore[return-value]
|
||
|
||
|
||
class Base(DeclarativeBase):
|
||
pass
|
||
|
||
|
||
def get_db() -> Generator[Session, None, None]:
|
||
"""FastAPI 依赖:为每个请求产出一个 Session。"""
|
||
db = get_session_local()()
|
||
try:
|
||
yield db
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def init_db_schema() -> None:
|
||
"""按需建表(幂等)并校验既有表列与模型一致(fail-fast on schema drift)。
|
||
|
||
先导入 models 注册映射;create_all 用 IF NOT EXISTS 仅补缺失的表;
|
||
随后对每张已存在的表检查模型声明的列是否齐全,缺列即抛 RuntimeError,
|
||
避免运行期才以晦涩的 OperationalError 暴露 schema 漂移。
|
||
"""
|
||
from sqlalchemy import inspect
|
||
|
||
from . import models # noqa: F401
|
||
engine = get_engine()
|
||
Base.metadata.create_all(bind=engine)
|
||
|
||
inspector = inspect(engine)
|
||
missing: list[str] = []
|
||
for table, mapper in Base.registry.mappers.items():
|
||
if not inspector.has_table(table):
|
||
continue
|
||
db_cols = {c["name"] for c in inspector.get_columns(table)}
|
||
for model_col in mapper.columns.keys():
|
||
if model_col not in db_cols:
|
||
missing.append(f"{table}.{model_col}")
|
||
if missing:
|
||
raise RuntimeError(
|
||
"数据库 schema 与模型不一致,缺少列: " + ", ".join(missing)
|
||
+ "。请执行 sql/schema.sql 或迁移脚本更新表结构。"
|
||
)
|