原实现是 Canvas 画笔画板,与「文字白板/记事本」需求不符。重做为纯文本实时协作: - model:strokes JSON -> content TEXT + version INT(乐观锁)+ edit_count; schema/dao/service 同步重构,append_stroke/replace_strokes -> update_content。 - WS 协议:stroke -> edit(发完整文本,debounce 400ms);init 下发 content/version。 update 帧广播给他人,cleared 广播给所有人。 - 前端:canvas -> textarea;收到远端 update 用最长公共前后缀算变更区间, 仅替换该区间并保留光标(区间前不动/后平移/内移末尾);清空/复制文本按钮。 - schema.sql 更新 whiteboard 表 DDL;DB 旧表 DROP 重建(开发环境)。 - 测试脚本与 README 同步更新。
101 lines
3.9 KiB
Python
101 lines
3.9 KiB
Python
"""白板服务(文本记事本):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
|