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:
@@ -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":
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""Whiteboard 的 DAO。
|
||||
"""Whiteboard 的 DAO(文本记事本)。
|
||||
|
||||
所有写操作均在该层 commit,service 不直接操作 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
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""共享白板实体。
|
||||
"""共享白板实体(文本记事本)。
|
||||
|
||||
一个白板由 board_id 唯一标识(用户可读的 url id),strokes 以 JSON 列保存全部笔画。
|
||||
白板长期留存,进程重启后仍可恢复;实时协作由 WebSocket hub 在内存中维护在线连接,
|
||||
笔画变更经 service 落库后由 hub 广播给同 board 的其它在线连接。
|
||||
一个白板由 board_id 唯一标识(用户可读的 url id),content 存完整文本,
|
||||
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}>"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 时携带的笔画对象")
|
||||
|
||||
@@ -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 的所有在线连接。"""
|
||||
|
||||
Reference in New Issue
Block a user