后端修复: - 白板删除踢人失效: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)、配置项表格、防火墙说明。
85 lines
3.3 KiB
Python
85 lines
3.3 KiB
Python
"""白板服务(文本记事本):CRUD + 文本更新 + 清空。
|
||
|
||
不持有 WebSocket 连接状态(那是 hub 的职责)。删除白板时由 controller 层负责
|
||
通知 hub 踢出在线连接(因为 close_board 是 async,需在事件循环中调用),
|
||
service 只管 DB 层面的删除,保持低耦合。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
from fastapi import HTTPException
|
||
|
||
from ..config import get_settings
|
||
from ..dao.whiteboard_dao import WhiteboardDAO
|
||
from ..schemas.whiteboard import WhiteboardListItem, WhiteboardOut
|
||
|
||
# board_id 合法字符集:字母数字下划线短横线
|
||
_BOARD_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
||
|
||
|
||
class WhiteboardService:
|
||
def __init__(self, dao: WhiteboardDAO) -> None:
|
||
self.dao = dao
|
||
cfg = get_settings().whiteboard
|
||
self.max_board_id_length = cfg.max_board_id_length
|
||
self.list_limit = cfg.list_limit
|
||
# 文本内容长度上限(防滥用)
|
||
self.max_content_length = 256 * 1024
|
||
|
||
# ---------------- 校验 ----------------
|
||
|
||
def validate_board_id(self, board_id: str) -> None:
|
||
"""非法 board_id 直接 400(防路径穿越/注入)。"""
|
||
if (
|
||
not board_id
|
||
or len(board_id) > self.max_board_id_length
|
||
or not _BOARD_ID_RE.match(board_id)
|
||
):
|
||
raise HTTPException(400, "board_id 非法(仅允许字母数字下划线短横线,1-64 字符)")
|
||
|
||
def validate_content(self, content: str) -> None:
|
||
if not isinstance(content, str):
|
||
raise HTTPException(400, "content 必须是字符串")
|
||
if len(content) > self.max_content_length:
|
||
raise HTTPException(
|
||
413,
|
||
f"文本过长({len(content)} > {self.max_content_length}),请缩减内容",
|
||
)
|
||
|
||
# ---------------- 读 ----------------
|
||
|
||
def get_or_create(self, board_id: str) -> WhiteboardOut:
|
||
self.validate_board_id(board_id)
|
||
board = self.dao.get_or_create(board_id)
|
||
return WhiteboardOut.model_validate(board)
|
||
|
||
def list_all(self, limit: int = 100, offset: int = 0) -> tuple[int, list[WhiteboardListItem]]:
|
||
limit = min(max(limit, 0), self.list_limit) or self.list_limit
|
||
offset = max(offset, 0)
|
||
total = self.dao.count()
|
||
rows = self.dao.list_all(limit=limit, offset=offset)
|
||
items = [WhiteboardListItem.model_validate(r) for r in rows]
|
||
return total, items
|
||
|
||
# ---------------- 写 ----------------
|
||
|
||
def update_content(self, board_id: str, content: str) -> WhiteboardOut:
|
||
"""整体替换文本内容(客户端 debounce 后发完整文本)。"""
|
||
self.validate_board_id(board_id)
|
||
self.validate_content(content)
|
||
board = self.dao.update_content(board_id, content)
|
||
if board is None:
|
||
raise HTTPException(404, "白板不存在")
|
||
return WhiteboardOut.model_validate(board)
|
||
|
||
def clear(self, board_id: str) -> WhiteboardOut:
|
||
"""清空白板(内容置空),edit_count 仍自增以记录这次修改。"""
|
||
return self.update_content(board_id, "")
|
||
|
||
def delete(self, board_id: str) -> bool:
|
||
"""删除白板 DB 行。踢出在线连接由 controller 层负责(close_board 是 async)。"""
|
||
self.validate_board_id(board_id)
|
||
return self.dao.delete(board_id)
|