"""白板服务(文本记事本):CRUD + 文本更新 + 清空。 不持有 WebSocket 连接状态(那是 hub 的职责);删除白板时通过可选的 hub 回调 通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub 的硬依赖(保持低耦合)。 """ from __future__ import annotations import re from typing import TYPE_CHECKING from fastapi import HTTPException 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: 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 # 文本内容长度上限(防滥用) 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: """删除白板;同时通知 hub 踢出该 board 的所有在线连接。""" 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