fix: 代码审查修复 + 精简重写 README

后端修复:
- 白板删除踢人失效:delete_whiteboard 改 async def,删除后直接 await
  hub.close_board()。原实现用 asyncio.get_running_loop() 在同步 REST handler
  (threadpool)里调用必抛 RuntimeError 被 except 吞掉,close_board 从不执行。
  同时移除 service 的 hub 依赖(close_board 改由 controller 调用,service 只管 DB)。
- delete_file 去重复查询:原先 get_out_with_disk_path + get_by_id 查两次,
  合并为一次;磁盘 unlink 失败加 logger.warning(原静默吞掉致磁盘泄漏无记录)。
- get_hub 单例加 threading.Lock 双重检查(防 REST threadpool 与 WS 事件循环
  并发首访各建一个 hub)。
- file_controller 公开 /api/files list 加 Query(ge=1, le=10000) 约束(原无上限可 DoS)。

前端修复:
- applyRemoteUpdate 有未发送编辑时重发:合并远端更新后若本地有 pending 编辑
  (editor.value !== lastSentText)重新 scheduleSend,避免被 lastSentText 短路丢弃。
- init 不覆盖未发送编辑:断线重连后若本地有未发送内容,作为新版本发上去而非被 init 覆盖。
- applyRemoteUpdate 仅在编辑器已有焦点时恢复焦点,避免抢按钮焦点。
- api() 401 时 location.reload() 触发浏览器 Basic Auth 弹窗(原只 toast 卡死)。

README:
- 精简重写,补全 Ubuntu 从 0 安装、Apache 反代(含 WS)、配置项表格、防火墙说明。
This commit is contained in:
zikai
2026-07-22 01:03:48 +00:00
parent 4de2648178
commit 30a263ed50
8 changed files with 222 additions and 254 deletions

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
@@ -43,8 +43,8 @@ async def upload_file(
@router.get("", response_model=FileListResponse, summary="列出已上传的文件")
def list_files(
limit: int = 100,
offset: int = 0,
limit: int = Query(100, ge=1, le=10000),
offset: int = Query(0, ge=0),
service: UploadService = Depends(_service),
) -> FileListResponse:
total, items = service.list_files(limit=limit, offset=offset)

View File

@@ -32,8 +32,8 @@ router = APIRouter(tags=["whiteboard"])
def _service(db: Session = Depends(get_db)) -> WhiteboardService:
"""REST 路径的 service:注入 hub 以便删除时踢出连接"""
return WhiteboardService(WhiteboardDAO(db), hub=get_hub())
"""REST 路径的 service(纯 DB 操作)"""
return WhiteboardService(WhiteboardDAO(db))
# ---------------- 公开 REST ----------------
@@ -71,7 +71,7 @@ def list_whiteboards(
summary="删除记事本(需鉴权)",
description="删 DB 行,并关闭该 board 的所有在线 WebSocket 连接。",
)
def delete_whiteboard(
async def delete_whiteboard(
board_id: str,
service: WhiteboardService = Depends(_service),
_: str = Depends(require_docs_auth),
@@ -79,6 +79,8 @@ def delete_whiteboard(
ok = service.delete(board_id)
if not ok:
raise HTTPException(404, "白板不存在")
# 删除成功后踢出该 board 的所有在线连接close_board 是 async须在事件循环中调用
await get_hub().close_board(board_id)
return {"deleted": True}
@@ -116,7 +118,7 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
# 校验 board_id 并加载白板(不存在则新建)
try:
board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).get_or_create(board_id))
board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db)).get_or_create(board_id))
except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
await _safe_close(websocket)
@@ -162,7 +164,7 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
continue
try:
out = _with_db(
lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).update_content(board_id, content)
lambda db: WhiteboardService(WhiteboardDAO(db)).update_content(board_id, content)
)
except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
@@ -175,7 +177,7 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
)
elif mtype == "clear":
try:
_with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).clear(board_id))
_with_db(lambda db: WhiteboardService(WhiteboardDAO(db)).clear(board_id))
except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
continue

View File

@@ -8,6 +8,7 @@ upload_root不再有 HTTP 登记接口。
from __future__ import annotations
import hashlib
import logging
import os
import uuid
from collections.abc import Iterator
@@ -21,6 +22,8 @@ from ..dao.uploaded_file_dao import UploadedFileDAO
from ..models.uploaded_file import UploadedFile
from ..schemas.file import FileUploadResponse, UploadedFileOut
logger = logging.getLogger("zikai.upload")
def hash_stream(chunks: Iterator[bytes]) -> tuple[int, str]:
"""对一段字节块序列流式计算 (size, sha256)。供多条上传路径复用。"""
@@ -95,18 +98,17 @@ class UploadService:
def delete_file(self, file_id: int) -> bool:
"""硬删除:删磁盘文件 + 删 DB 行。磁盘缺失不阻断 DB 清理。返回是否命中。
供单删/批删复用,保证删除语义一致。
供单删/批删复用,保证删除语义一致。磁盘删除失败仅记日志,仍清 DB 行
(保证列表不再显示),避免磁盘文件泄漏却无任何记录。
"""
_, path = self.get_out_with_disk_path(file_id)
row = self.dao.get_by_id(file_id)
if row is None:
out, path = self.get_out_with_disk_path(file_id)
if out is None:
return False
if path is not None:
try:
Path(path).unlink(missing_ok=True)
except Exception:
# 即使磁盘删除失败也继续清 DB 行,保证列表不再显示
pass
except Exception as exc:
logger.warning("删除磁盘文件失败 file_id=%s path=%s: %s", file_id, path, exc)
self.dao.delete(file_id)
return True

View File

@@ -18,6 +18,7 @@ from __future__ import annotations
import asyncio
import logging
import threading
import time
from dataclasses import dataclass, field
@@ -167,16 +168,21 @@ class WhiteboardHub:
# 进程内单例(由 main.py lifespan / controller 共享)
_hub: WhiteboardHub | None = None
_hub_lock = threading.Lock()
def get_hub() -> WhiteboardHub:
"""获取/创建进程内单例 hub。线程安全lifespan 预热后通常不再进锁)。"""
global _hub
if _hub is None:
_hub = WhiteboardHub()
with _hub_lock:
if _hub is None:
_hub = WhiteboardHub()
return _hub
def reset_hub() -> None:
"""测试用:重置单例。"""
global _hub
_hub = None
with _hub_lock:
_hub = None

View File

@@ -1,14 +1,13 @@
"""白板服务文本记事本CRUD + 文本更新 + 清空。
不持有 WebSocket 连接状态(那是 hub 的职责)删除白板时通过可选的 hub 回调
通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub
的硬依赖(保持低耦合
不持有 WebSocket 连接状态(那是 hub 的职责)删除白板时由 controller 层负责
通知 hub 踢出在线连接(因为 close_board 是 async需在事件循环中调用
service 只管 DB 层面的删除,保持低耦合。
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from fastapi import HTTPException
@@ -16,17 +15,13 @@ from ..config import get_settings
from ..dao.whiteboard_dao import WhiteboardDAO
from ..schemas.whiteboard import WhiteboardListItem, WhiteboardOut
if TYPE_CHECKING: # 避免运行时循环导入
from .whiteboard_hub import WhiteboardHub
# board_id 合法字符集:字母数字下划线短横线
_BOARD_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
class WhiteboardService:
def __init__(self, dao: WhiteboardDAO, hub: "WhiteboardHub | None" = None) -> None:
def __init__(self, dao: WhiteboardDAO) -> None:
self.dao = dao
self.hub = hub
cfg = get_settings().whiteboard
self.max_board_id_length = cfg.max_board_id_length
self.list_limit = cfg.list_limit
@@ -84,17 +79,6 @@ class WhiteboardService:
return self.update_content(board_id, "")
def delete(self, board_id: str) -> bool:
"""删除白板;同时通知 hub 踢出该 board 的所有在线连接"""
"""删除白板 DB 行。踢出在线连接由 controller 层负责close_board 是 async"""
self.validate_board_id(board_id)
ok = self.dao.delete(board_id)
if ok and self.hub is not None:
# hub.close_board 是 async但删除走 REST 同步路径;安排到事件循环里执行
import asyncio
try:
loop = asyncio.get_running_loop()
loop.create_task(self.hub.close_board(board_id))
except RuntimeError:
# 无运行中事件循环(如脚本调用):同步调用会报错,忽略即可
pass
return ok
return self.dao.delete(board_id)