refactor: 白板从画笔画板改为文本记事本

原实现是 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 同步更新。
This commit is contained in:
zikai
2026-07-21 15:00:25 +00:00
parent 7bacc33679
commit 655e039aad
13 changed files with 240 additions and 271 deletions

View File

@@ -86,18 +86,18 @@ def delete_whiteboard(
@router.websocket("/ws/wb/{board_id}")
async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
"""白板实时协作端点。
"""白板实时协作端点(文本记事本)
协议JSON 文本帧):
client -> server:
{"type":"hello","client_id":"..."} 首帧(可选,未带则服务端生成)
{"type":"ping"} 心跳,服务端回 pong 并刷新计时
{"type":"stroke","stroke":{...}} 新增笔画,持久化并广播给他人
{"type":"edit","content":"..."} debounce 后发完整文本,持久化并广播给他人
{"type":"clear"} 清空,持久化并广播给所有人
server -> client:
{"type":"init","strokes":[...],"stroke_count":n}
{"type":"init","content":"...","version":n,"edit_count":m}
{"type":"pong"}
{"type":"stroke","stroke":{...},"client_id":"..."}
{"type":"update","content":"...","version":n,"client_id":"..."} 文本变更
{"type":"cleared","client_id":"..."}
{"type":"error","msg":"..."}
"""
@@ -115,7 +115,6 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
client_id = _extract_client_id(first) or uuid.uuid4().hex[:12]
# 校验 board_id 并加载白板(不存在则新建)
from ..database import get_session_local
try:
board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).get_or_create(board_id))
except HTTPException as exc:
@@ -128,8 +127,9 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
await hub.register(conn)
await _safe_send(websocket, {
"type": "init",
"strokes": board.strokes,
"stroke_count": board.stroke_count,
"content": board.content,
"version": board.version,
"edit_count": board.edit_count,
})
# 主循环:收消息 -> 处理 -> 广播
@@ -146,17 +146,22 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
continue
# 任何有效业务帧都视为活性证据
conn.touch()
if mtype == "stroke":
stroke = msg.get("stroke") or {}
if mtype == "edit":
content = msg.get("content")
if not isinstance(content, str):
await _safe_send(websocket, {"type": "error", "msg": "content 必须是字符串"})
continue
try:
_with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).append_stroke(board_id, stroke))
out = _with_db(
lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).update_content(board_id, content)
)
except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
continue
# 广播给他人(发送者本地已,不回推)
# 广播给他人(发送者本地已更新,不回推)
await hub.broadcast(
board_id,
{"type": "stroke", "stroke": stroke, "client_id": client_id},
{"type": "update", "content": out.content, "version": out.version, "client_id": client_id},
exclude=conn,
)
elif mtype == "clear":

View File

@@ -1,13 +1,11 @@
"""Whiteboard 的 DAO。
"""Whiteboard 的 DAO(文本记事本)
所有写操作均在该层 commitservice 不直接操作 session。
get_or_create 用于「访问即新建」语义(路由 GET /whiteboard/{id} 不存在则建)。
get_or_create 用于「访问即新建」语义(路由 GET /api/wb/{id} 不存在则建)。
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -33,7 +31,7 @@ class WhiteboardDAO:
board = self.get(board_id)
if board is not None:
return board
board = Whiteboard(board_id=board_id, strokes=[], stroke_count=0)
board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0)
try:
return self.create(board)
except Exception:
@@ -41,24 +39,14 @@ class WhiteboardDAO:
self.db.rollback()
return self.get(board_id) # type: ignore[return-value]
def append_strokes(self, board_id: str, new_strokes: list[Any]) -> Whiteboard | None:
"""把新笔画追加到 strokes 数组尾部stroke_count 自增"""
def update_content(self, board_id: str, content: str) -> Whiteboard | None:
"""整体替换文本内容version +1、edit_count +1"""
board = self.get(board_id)
if board is None:
return None
board.strokes = [*board.strokes, *new_strokes]
board.stroke_count = (board.stroke_count or 0) + len(new_strokes)
self.db.commit()
self.db.refresh(board)
return board
def replace_strokes(self, board_id: str, strokes: list[Any]) -> Whiteboard | None:
"""整体替换 strokes清空时传 []stroke_count 自增 1。"""
board = self.get(board_id)
if board is None:
return None
board.strokes = list(strokes)
board.stroke_count = (board.stroke_count or 0) + 1
board.content = content
board.version = (board.version or 0) + 1
board.edit_count = (board.edit_count or 0) + 1
self.db.commit()
self.db.refresh(board)
return board

View File

@@ -1,15 +1,16 @@
"""共享白板实体。
"""共享白板实体(文本记事本)
一个白板由 board_id 唯一标识(用户可读的 url idstrokes 以 JSON 列保存全部笔画。
白板长期留存,进程重启后仍可恢复;实时协作由 WebSocket hub 在内存中维护在线连接,
笔画变更经 service 落库后由 hub 广播给同 board 的其它在线连接。
一个白板由 board_id 唯一标识(用户可读的 url idcontent 存完整文本,
version 是乐观锁版本号(每次编辑 +1白板长期留存,进程重启后仍可恢复;
实时协作由 WebSocket hub 在内存中维护在线连接,文本变更经 service 落库后
由 hub 广播给同 board 的其它在线连接。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Integer, JSON, String, func
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from ..database import Base
@@ -21,10 +22,12 @@ class Whiteboard(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
# 用户可读的 url id[a-zA-Z0-9_-]{1,64}),全局唯一
board_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
# 笔画数组 [{points:[[x,y],...], color, width}, ...]
strokes: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# 修改次数:每次新增笔画或清空 +1供管理页统计
stroke_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 白板文本内容(记事本)
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 乐观锁版本号:每次编辑 +1
version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 编辑次数(累计修改次数,含清空),供管理页统计
edit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
@@ -35,5 +38,5 @@ class Whiteboard(Base):
def __repr__(self) -> str: # pragma: no cover
return (
f"<Whiteboard board_id={self.board_id!r} "
f"strokes={len(self.strokes)} mods={self.stroke_count}>"
f"len={len(self.content)} v={self.version} mods={self.edit_count}>"
)

View File

@@ -10,8 +10,6 @@ from .file import FileListResponse, FileUploadResponse, UploadedFileOut
from .system import DiskUsage, MemoryUsage, SystemStatus
from .tunnel import TunnelStatusResponse
from .whiteboard import (
Stroke,
StrokeOp,
WhiteboardListItem,
WhiteboardListResponse,
WhiteboardOut,
@@ -26,8 +24,6 @@ __all__ = [
"FileUploadResponse",
"MemoryUsage",
"SessionStatusResponse",
"Stroke",
"StrokeOp",
"SystemStatus",
"TunnelStatusResponse",
"UploadedFileOut",

View File

@@ -1,27 +1,19 @@
"""白板接口 DTO。"""
"""白板接口 DTO(文本记事本)"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class Stroke(BaseModel):
"""一条笔画:点序列 + 样式。结构宽松Any以兼容前端扩展字段。"""
points: list[list[float]] = Field(default_factory=list, description="[[x,y],...]")
color: str = Field(default="#1565c0", description="笔画颜色")
width: float = Field(default=3, description="笔画宽度")
class WhiteboardOut(BaseModel):
"""白板完整内容GET /whiteboard/{id} 与 WS init 帧)。"""
"""白板完整内容GET /api/wb/{id} 与 WS init 帧)。"""
board_id: str
strokes: list[Any] = Field(default_factory=list, description="笔画数组")
stroke_count: int = Field(0, description="累计修改次数")
content: str = Field("", description="白板文本内容")
version: int = Field(0, description="乐观锁版本号,每次编辑 +1")
edit_count: int = Field(0, description="累计编辑次数")
created_at: datetime
updated_at: datetime
@@ -32,7 +24,7 @@ class WhiteboardListItem(BaseModel):
"""管理页列表项。"""
board_id: str
stroke_count: int
edit_count: int
created_at: datetime
updated_at: datetime
@@ -44,10 +36,3 @@ class WhiteboardListResponse(BaseModel):
total: int
items: list[WhiteboardListItem]
class StrokeOp(BaseModel):
"""WS 笔画操作type=stroke 时携带)。"""
type: str = Field(..., description="add / clear")
stroke: dict[str, Any] | None = Field(None, description="type=add 时携带的笔画对象")

View File

@@ -1,4 +1,4 @@
"""白板服务CRUD + 笔画操作
"""白板服务(文本记事本)CRUD + 文本更新 + 清空
不持有 WebSocket 连接状态(那是 hub 的职责);删除白板时通过可选的 hub 回调
通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub
@@ -8,7 +8,7 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from fastapi import HTTPException
@@ -30,6 +30,8 @@ class WhiteboardService:
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
# ---------------- 校验 ----------------
@@ -42,6 +44,15 @@ class WhiteboardService:
):
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:
@@ -59,21 +70,18 @@ class WhiteboardService:
# ---------------- 写 ----------------
def append_stroke(self, board_id: str, stroke: dict[str, Any]) -> WhiteboardOut:
"""追加一条笔画并返回最新状态"""
def update_content(self, board_id: str, content: str) -> WhiteboardOut:
"""整体替换文本内容(客户端 debounce 后发完整文本)"""
self.validate_board_id(board_id)
board = self.dao.append_strokes(board_id, [stroke])
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:
"""清空白板stroke_count 仍自增以记录这次修改。"""
self.validate_board_id(board_id)
board = self.dao.replace_strokes(board_id, [])
if board is None:
raise HTTPException(404, "白板不存在")
return WhiteboardOut.model_validate(board)
"""清空白板内容置空edit_count 仍自增以记录这次修改。"""
return self.update_content(board_id, "")
def delete(self, board_id: str) -> bool:
"""删除白板;同时通知 hub 踢出该 board 的所有在线连接。"""