refactor: 清理死代码/提前失败/日志/高内聚低耦合
死代码移除: - 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 链接
This commit is contained in:
@@ -39,7 +39,6 @@ def _resolve_cookie(request: Request, zk_pdf: str | None = Cookie(default=None))
|
||||
|
||||
cookie 缺失时把新值挂到 request.state,供响应阶段 set_cookie。
|
||||
"""
|
||||
cfg = get_settings().pdf
|
||||
if zk_pdf and len(zk_pdf) == 32:
|
||||
return zk_pdf
|
||||
new = new_owner_cookie()
|
||||
|
||||
@@ -228,12 +228,12 @@ def _parse(raw: str) -> dict | None:
|
||||
async def _safe_send(ws: WebSocket, msg: dict) -> None:
|
||||
try:
|
||||
await ws.send_json(msg)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("发送 WS 消息失败: %s", exc)
|
||||
|
||||
|
||||
async def _safe_close(ws: WebSocket) -> None:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("关闭 WS 失败: %s", exc)
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -33,6 +31,11 @@ class PdfJobDAO:
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
def delete(self, job: PdfJob) -> None:
|
||||
"""硬删 PdfJob 行(管理视角真正删除,不可恢复)。"""
|
||||
self.db.delete(job)
|
||||
self.db.commit()
|
||||
|
||||
def list_for_user(self, owner_cookie: str, limit: int = 100, offset: int = 0) -> list[PdfJob]:
|
||||
"""用户视角:仅未软删的任务,按创建时间倒序。"""
|
||||
stmt = (
|
||||
|
||||
@@ -31,15 +31,6 @@ class TunnelSessionDAO:
|
||||
)
|
||||
return self.db.scalars(stmt).first()
|
||||
|
||||
def get_active_by_port(self, tunnel_port: int) -> TunnelSession | None:
|
||||
stmt = (
|
||||
select(TunnelSession)
|
||||
.where(TunnelSession.tunnel_port == tunnel_port)
|
||||
.where(TunnelSession.status == "active")
|
||||
.limit(1)
|
||||
)
|
||||
return self.db.scalars(stmt).first()
|
||||
|
||||
def list_active(self) -> list[TunnelSession]:
|
||||
stmt = select(TunnelSession).where(TunnelSession.status == "active")
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
@@ -6,11 +6,16 @@ get_or_create 用于「访问即新建」语义(路由 GET /api/wb/{id} 不存
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.whiteboard import Whiteboard
|
||||
|
||||
logger = logging.getLogger("zikai.whiteboard")
|
||||
|
||||
|
||||
class WhiteboardDAO:
|
||||
def __init__(self, db: Session) -> None:
|
||||
@@ -27,14 +32,18 @@ class WhiteboardDAO:
|
||||
return self.db.scalars(stmt).first()
|
||||
|
||||
def get_or_create(self, board_id: str) -> Whiteboard:
|
||||
"""存在则返回,否则新建空板。利用 unique 约束兜底并发首访。"""
|
||||
"""存在则返回,否则新建空板。利用 unique 约束兜底并发首访。
|
||||
|
||||
仅 IntegrityError(并发下另一事务已插入违反唯一约束)才回滚重读;
|
||||
其他异常向上抛,避免掩盖 schema/连接等真实故障。
|
||||
"""
|
||||
board = self.get(board_id)
|
||||
if board is not None:
|
||||
return board
|
||||
board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0)
|
||||
try:
|
||||
return self.create(board)
|
||||
except Exception:
|
||||
except IntegrityError:
|
||||
# 并发下另一事务已插入:回滚后重新读
|
||||
self.db.rollback()
|
||||
return self.get(board_id) # type: ignore[return-value]
|
||||
|
||||
@@ -64,7 +64,29 @@ def get_db() -> Generator[Session, None, None]:
|
||||
|
||||
|
||||
def init_db_schema() -> None:
|
||||
"""按需建表(幂等)。先导入 models 以注册映射。"""
|
||||
"""按需建表(幂等)并校验既有表列与模型一致(fail-fast on schema drift)。
|
||||
|
||||
先导入 models 注册映射;create_all 用 IF NOT EXISTS 仅补缺失的表;
|
||||
随后对每张已存在的表检查模型声明的列是否齐全,缺列即抛 RuntimeError,
|
||||
避免运行期才以晦涩的 OperationalError 暴露 schema 漂移。
|
||||
"""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from . import models # noqa: F401
|
||||
get_engine()
|
||||
Base.metadata.create_all(bind=_engine)
|
||||
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 或迁移脚本更新表结构。"
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,7 +26,7 @@ from ..config import get_settings
|
||||
from ..dao.pdf_job_dao import PdfJobDAO
|
||||
from ..dao.uploaded_file_dao import UploadedFileDAO
|
||||
from ..models.pdf_job import PdfJob
|
||||
from ..schemas.pdf import PdfJobOut, PdfJobListResponse, PdfSubmitResponse
|
||||
from ..schemas.pdf import PdfJobOut, PdfJobListResponse
|
||||
from . import pdf_converter
|
||||
from .upload_service import UploadService
|
||||
|
||||
@@ -172,7 +172,6 @@ class PdfService:
|
||||
|
||||
# 落产物 UploadedFile(复用 commit_entity 的原子改名 + 入库)
|
||||
from ..models.uploaded_file import UploadedFile
|
||||
import hashlib
|
||||
size = part_path.stat().st_size
|
||||
sha256 = self._hash_file(part_path)
|
||||
entity = UploadedFile(
|
||||
@@ -277,8 +276,7 @@ class PdfService:
|
||||
if job.output_file_id is not None:
|
||||
self._safe_delete_file(job.output_file_id)
|
||||
# 删 PdfJob 行
|
||||
self.job_dao.db.delete(job)
|
||||
self.job_dao.db.commit()
|
||||
self.job_dao.delete(job)
|
||||
logger.info("管理员硬删 PDF 任务 job=%s", job_id)
|
||||
return True
|
||||
|
||||
@@ -293,7 +291,6 @@ class PdfService:
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("删除文件失败 file_id=%s path=%s: %s", file_id, path, exc)
|
||||
try:
|
||||
self.file_dao.db.delete(row)
|
||||
self.file_dao.db.commit()
|
||||
self.file_dao.delete(file_id)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("删除文件 DB 行失败 file_id=%s: %s", file_id, exc)
|
||||
|
||||
@@ -27,8 +27,9 @@ class ZikaiSFTPServer(asyncssh.SFTPServer):
|
||||
super().__init__(chan, chroot=str(upload_root).encode())
|
||||
try:
|
||||
self._username = chan.get_extra_info("username") or "unknown"
|
||||
except Exception: # pragma: no cover
|
||||
except Exception as exc: # pragma: no cover
|
||||
self._username = "unknown"
|
||||
logger.debug("读取 SFTP 会话用户名失败: %s", exc)
|
||||
logger.info("SFTP 会话开始 user=%s chroot=%s", self._username, upload_root)
|
||||
|
||||
def exit(self) -> None:
|
||||
@@ -45,8 +46,8 @@ def _tunnel_dao():
|
||||
def _close_tunnel_dao(dao) -> None:
|
||||
try:
|
||||
dao.db.close()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("关闭隧道 DAO 会话失败: %s", exc)
|
||||
|
||||
|
||||
class ZikaiSSHServer(asyncssh.SSHServer):
|
||||
@@ -101,7 +102,8 @@ class ZikaiSSHServer(asyncssh.SSHServer):
|
||||
try:
|
||||
# asyncssh 命中返回 dict(可能为空),未命中返回 None
|
||||
result = self._authorized_keys.validate(key, client_host=addr, client_addr=addr)
|
||||
except Exception:
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("公钥校验异常 user=%s: %s", username, exc)
|
||||
result = None
|
||||
ok = result is not None
|
||||
if ok:
|
||||
|
||||
@@ -52,11 +52,6 @@ class TunnelService:
|
||||
def get_active(self, user_name: str) -> TunnelSession | None:
|
||||
return self.dao.get_active_by_user(user_name)
|
||||
|
||||
def is_port_allowed(self, user_name: str, tunnel_port: int) -> bool:
|
||||
"""校验该 user 是否被允许绑定该隧道端口(防 user 乱绑端口)。"""
|
||||
user = self.settings.find_user(user_name)
|
||||
return user is not None and user.tunnel_port == tunnel_port
|
||||
|
||||
def reap_orphans(self) -> int:
|
||||
"""兜底清理:关闭所有 active 会话(进程重启时 DB 里残留的孤儿记录)。
|
||||
|
||||
|
||||
@@ -92,8 +92,9 @@ class WhiteboardHub:
|
||||
# 尽力关闭 websocket(可能已关闭)
|
||||
try:
|
||||
await conn.websocket.close()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("关闭 websocket 时出错 board=%s client=%s: %s",
|
||||
conn.board_id, conn.client_id, exc)
|
||||
logger.info("连接移除 board=%s client=%s(剩余 %d 人)",
|
||||
conn.board_id, conn.client_id, self.connection_count(conn.board_id))
|
||||
|
||||
@@ -161,8 +162,9 @@ class WhiteboardHub:
|
||||
for conn in conns:
|
||||
try:
|
||||
await conn.websocket.close()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("关闭 websocket 时出错 board=%s client=%s: %s",
|
||||
conn.board_id, conn.client_id, exc)
|
||||
logger.info("关闭白板 board=%s,踢出 %d 个连接", board_id, len(conns))
|
||||
|
||||
|
||||
@@ -179,10 +181,3 @@ def get_hub() -> WhiteboardHub:
|
||||
if _hub is None:
|
||||
_hub = WhiteboardHub()
|
||||
return _hub
|
||||
|
||||
|
||||
def reset_hub() -> None:
|
||||
"""测试用:重置单例。"""
|
||||
global _hub
|
||||
with _hub_lock:
|
||||
_hub = None
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
|
||||
# 默认分片大小 4 MiB:大于 Apache 300s 限制下单片可数秒传完,小到内存恒定。
|
||||
DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
|
||||
# 同一文件分片并发数
|
||||
|
||||
Reference in New Issue
Block a user