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

@@ -15,9 +15,9 @@
- **文件浏览页** `GET /files`Basic Auth同 docs列出/下载/**硬删除**已上传文件;删除后不再显示。 - **文件浏览页** `GET /files`Basic Auth同 docs列出/下载/**硬删除**已上传文件;删除后不再显示。
管理 API`GET /api/admin/files``GET /api/admin/files/{id}``GET /api/admin/files/{id}/download` 管理 API`GET /api/admin/files``GET /api/admin/files/{id}``GET /api/admin/files/{id}/download`
`DELETE /api/admin/files/{id}`(均 Basic Auth `DELETE /api/admin/files/{id}`(均 Basic Auth
- **共享白板** `GET /wb/{id}`(公开,不存在则新建):Canvas 实时协作 + **清空 / 复制链接**,兼容移动端。 - **共享记事本(白板** `GET /wb/{id}`(公开,不存在则新建):纯文本实时协作 + **清空 / 复制文本**,兼容移动端。
实时同步走 `WS /ws/wb/{id}`**心跳 3s连续 5 次丢失判失活并移除**)。 实时同步走 `WS /ws/wb/{id}`**心跳 3s连续 5 次丢失判失活并移除**)。
- **白板管理页** `GET /wb-admin`Basic Auth同 docs查看创建时间/修改次数/上次修改时间/删除。 - **白板管理页** `GET /wb-admin`Basic Auth同 docs查看创建时间/编辑次数/上次修改时间/删除。
管理 API`GET /api/admin/wb``DELETE /api/admin/wb/{id}`(均 Basic Auth 管理 API`GET /api/admin/wb``DELETE /api/admin/wb/{id}`(均 Basic Auth
- **反向隧道反代**`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。 - **反向隧道反代**`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。
- **内置 SFTP/SSH 服务器**asyncssh支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。 - **内置 SFTP/SSH 服务器**asyncssh支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。
@@ -168,20 +168,24 @@ SSH 服务器2022同时承载 SFTP 文件暂存与反向隧道。隧道 us
- **删除后不再显示**:列表每次进入或删除后重新 fetch前端不缓存DB 行已删,列表自然不含。 - **删除后不再显示**:列表每次进入或删除后重新 fetch前端不缓存DB 行已删,列表自然不含。
- 公开 `/api/files` 系列user.py 依赖的查重/查询/下载)保留不变。 - 公开 `/api/files` 系列user.py 依赖的查重/查询/下载)保留不变。
## 共享白板 ## 共享记事本(白板
白板无鉴权,任何人凭 `/wb/{id}` 即可访问并实时协作;`{id}` 须匹配 白板无鉴权,任何人凭 `/wb/{id}` 即可访问并实时协作;`{id}` 须匹配
`[a-zA-Z0-9_-]{1,64}`,非法返回 400。访问不存在的 id 自动新建空板。白板长期留存 `[a-zA-Z0-9_-]{1,64}`,非法返回 400。访问不存在的 id 自动新建空板。白板长期留存
(存 MySQL `whiteboard` 表),进程重启后内容仍在。 (存 MySQL `whiteboard``content` TEXT 列),进程重启后内容仍在。
### 实时同步与心跳 ### 实时同步与心跳
- 连接:`WS /ws/wb/{id}`公开。JSON 文本帧协议: - 连接:`WS /ws/wb/{id}`公开。JSON 文本帧协议:
- client -> server`{"type":"hello","client_id":"..."}`(首帧,可选)、 - client -> server`{"type":"hello","client_id":"..."}`(首帧,可选)、
`{"type":"ping"}`(心跳)、`{"type":"stroke","stroke":{points,color,width}}``{"type":"clear"}` `{"type":"ping"}`(心跳)、`{"type":"edit","content":"..."}`debounce 后发完整文本)、
- server -> client`{"type":"init","strokes":[...],"stroke_count":n}``{"type":"pong"}` `{"type":"clear"}`
`{"type":"stroke","stroke":{...},"client_id":"..."}`(广播给他人,不含发送者) - server -> client`{"type":"init","content":"...","version":n,"edit_count":m}`
`{"type":"pong"}``{"type":"update","content":"...","version":n,"client_id":"..."}`(广播给他人,不含发送者)、
`{"type":"cleared","client_id":"..."}`(广播给所有人)、`{"type":"error","msg":"..."}` `{"type":"cleared","client_id":"..."}`(广播给所有人)、`{"type":"error","msg":"..."}`
- **同步策略**:客户端本地编辑后 debounce 400ms 发完整文本服务端存为新版本version+1
并广播给同 board 的其他在线连接。其他端用最长公共前后缀算出变更区间,仅替换该区间并
保留本地光标位置(在变更区间前不动,在后平移,在区间内移到末尾)。
- **心跳**:客户端每 `whiteboard.heartbeat_interval_seconds`(默认 3s发一次 `ping`,服务端回 `pong` - **心跳**:客户端每 `whiteboard.heartbeat_interval_seconds`(默认 3s发一次 `ping`,服务端回 `pong`
并刷新计时。后台 reaper 每秒扫描,连续 `heartbeat_miss_threshold`(默认 5次未收到心跳 并刷新计时。后台 reaper 每秒扫描,连续 `heartbeat_miss_threshold`(默认 5次未收到心跳
(即 15s判失活**关闭该连接并从 hub 移除**。 (即 15s判失活**关闭该连接并从 hub 移除**。
@@ -197,7 +201,7 @@ SSH 服务器2022同时承载 SFTP 文件暂存与反向隧道。隧道 us
- `GET /wb-admin`Basic Auth同 docs渲染 `static/whiteboard_admin.html` - `GET /wb-admin`Basic Auth同 docs渲染 `static/whiteboard_admin.html`
- 管理 API均 Basic Auth - 管理 API均 Basic Auth
- `GET /api/admin/wb?limit=&offset=` -> `{total, items:[{board_id, stroke_count, created_at, updated_at}]}` - `GET /api/admin/wb?limit=&offset=` -> `{total, items:[{board_id, edit_count, created_at, updated_at}]}`
- `DELETE /api/admin/wb/{id}` -> 删 DB 行 + 关闭该 board 所有在线 WS 连接。 - `DELETE /api/admin/wb/{id}` -> 删 DB 行 + 关闭该 board 所有在线 WS 连接。
## 临时文件清理 ## 临时文件清理

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,19 @@
"""白板接口 DTO。""" """白板接口 DTO(文本记事本)"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field 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): class WhiteboardOut(BaseModel):
"""白板完整内容GET /whiteboard/{id} 与 WS init 帧)。""" """白板完整内容GET /api/wb/{id} 与 WS init 帧)。"""
board_id: str board_id: str
strokes: list[Any] = Field(default_factory=list, description="笔画数组") content: str = Field("", description="白板文本内容")
stroke_count: int = Field(0, description="累计修改次数") version: int = Field(0, description="乐观锁版本号,每次编辑 +1")
edit_count: int = Field(0, description="累计编辑次数")
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -32,7 +24,7 @@ class WhiteboardListItem(BaseModel):
"""管理页列表项。""" """管理页列表项。"""
board_id: str board_id: str
stroke_count: int edit_count: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -44,10 +36,3 @@ class WhiteboardListResponse(BaseModel):
total: int total: int
items: list[WhiteboardListItem] 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 回调 不持有 WebSocket 连接状态(那是 hub 的职责);删除白板时通过可选的 hub 回调
通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub 通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub
@@ -8,7 +8,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
from fastapi import HTTPException from fastapi import HTTPException
@@ -30,6 +30,8 @@ class WhiteboardService:
cfg = get_settings().whiteboard cfg = get_settings().whiteboard
self.max_board_id_length = cfg.max_board_id_length self.max_board_id_length = cfg.max_board_id_length
self.list_limit = cfg.list_limit 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 字符)") 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: 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) 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: if board is None:
raise HTTPException(404, "白板不存在") raise HTTPException(404, "白板不存在")
return WhiteboardOut.model_validate(board) return WhiteboardOut.model_validate(board)
def clear(self, board_id: str) -> WhiteboardOut: def clear(self, board_id: str) -> WhiteboardOut:
"""清空白板stroke_count 仍自增以记录这次修改。""" """清空白板内容置空edit_count 仍自增以记录这次修改。"""
self.validate_board_id(board_id) return self.update_content(board_id, "")
board = self.dao.replace_strokes(board_id, [])
if board is None:
raise HTTPException(404, "白板不存在")
return WhiteboardOut.model_validate(board)
def delete(self, board_id: str) -> bool: def delete(self, board_id: str) -> bool:
"""删除白板;同时通知 hub 踢出该 board 的所有在线连接。""" """删除白板;同时通知 hub 踢出该 board 的所有在线连接。"""

View File

@@ -53,13 +53,15 @@ CREATE TABLE IF NOT EXISTS `tunnel_session` (
KEY `idx_status` (`status`) KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 共享白板表。由 app/services/whiteboard_service.py 使用。 -- 共享白板表(文本记事本)。由 app/services/whiteboard_service.py 使用。
-- 白板长期留存strokes 以 JSON 保存全部笔画;实时同步由 WebSocket hub 在内存维护 -- content 存完整文本version 是乐观锁版本号(每次编辑 +1edit_count 累计编辑次数
-- 白板长期留存,实时同步由 WebSocket hub 在内存维护在线连接。
CREATE TABLE IF NOT EXISTS `whiteboard` ( CREATE TABLE IF NOT EXISTS `whiteboard` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`board_id` VARCHAR(64) NOT NULL COMMENT '用户可读的 url id[a-zA-Z0-9_-]{1,64}', `board_id` VARCHAR(64) NOT NULL COMMENT '用户可读的 url id[a-zA-Z0-9_-]{1,64}',
`strokes` JSON NOT NULL COMMENT '笔画数组 [{points,color,width}, ...]', `content` MEDIUMTEXT NOT NULL COMMENT '白板文本内容',
`stroke_count` INT NOT NULL DEFAULT 0 COMMENT '累计修改次数(新增笔画/清空各 +1', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次编辑 +1',
`edit_count` INT NOT NULL DEFAULT 0 COMMENT '累计编辑次数(含清空)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),

View File

@@ -1,4 +1,4 @@
/* 白板页专属样式:全屏画布、悬浮工具栏、移动端适配。 */ /* 记事本页专属样式:全屏 textarea、悬浮工具栏、移动端适配。 */
:root { --bar-h: 52px; } :root { --bar-h: 52px; }
body { overflow: hidden; background: var(--bg); } body { overflow: hidden; background: var(--bg); }
.wb-app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; } .wb-app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
@@ -14,22 +14,20 @@ body { overflow: hidden; background: var(--bg); }
.wb-id { background: var(--surface-2); padding: 0.15em 0.5em; border-radius: 6px; font-size: 0.82em; color: var(--text-dim); } .wb-id { background: var(--surface-2); padding: 0.15em 0.5em; border-radius: 6px; font-size: 0.82em; color: var(--text-dim); }
.wb-online { color: var(--success); font-size: 0.7em; } .wb-online { color: var(--success); font-size: 0.7em; }
.wb-online.off { color: var(--text-dim); } .wb-online.off { color: var(--text-dim); }
.wb-tool { display: inline-flex; align-items: center; gap: 0.3em; font-size: 0.85em; color: var(--text-dim); }
.wb-tool input[type="color"] { width: 28px; height: 28px; padding: 0; border: 1px solid var(--border); border-radius: 6px; background: transparent; cursor: pointer; }
.wb-tool input[type="range"] { width: 80px; accent-color: var(--primary); }
.wb-width-val { width: 1.4em; text-align: center; }
.wb-bar .btn { padding: 0.4em 0.9em; font-size: 0.86em; } .wb-bar .btn { padding: 0.4em 0.9em; font-size: 0.86em; }
.wb-stage { position: relative; flex: 1; overflow: hidden; } .wb-stage { position: relative; flex: 1; overflow: hidden; }
#canvas { .wb-editor {
position: absolute; inset: 0; width: 100%; height: 100%; position: absolute; inset: 0;
display: block; touch-action: none; cursor: crosshair; width: 100%; height: 100%;
background: display: block; resize: none; border: none; outline: none;
linear-gradient(var(--border) 1px, transparent 1px) 0 0 / 24px 24px, padding: 1em 1.2em;
linear-gradient(90deg, var(--border) 1px, transparent 1px) 0 0 / 24px 24px, font-family: ui-monospace, "SF Mono", Menlo, Consolas, "JetBrains Mono", monospace;
var(--surface); font-size: 14px; line-height: 1.6;
background-blend-mode: normal; background: var(--surface); color: var(--text);
touch-action: manipulation;
} }
.wb-editor::placeholder { color: var(--text-dim); }
.wb-status { .wb-status {
position: absolute; bottom: 0.8em; left: 50%; transform: translateX(-50%); position: absolute; bottom: 0.8em; left: 50%; transform: translateX(-50%);
background: var(--surface); border: 1px solid var(--border); background: var(--surface); border: 1px solid var(--border);
@@ -44,7 +42,7 @@ body { overflow: hidden; background: var(--bg); }
@media (max-width: 640px) { @media (max-width: 640px) {
.wb-bar { padding: 0.4em 0.5em; gap: 0.4em; } .wb-bar { padding: 0.4em 0.5em; gap: 0.4em; }
.wb-id { max-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wb-id { max-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wb-tool input[type="range"] { width: 56px; }
.wb-bar .btn { padding: 0.45em 0.7em; } .wb-bar .btn { padding: 0.45em 0.7em; }
.wb-title { display: none; } .wb-title { display: none; }
.wb-editor { font-size: 15px; padding: 0.8em; }
} }

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="theme-color" content="#1565c0"> <meta name="theme-color" content="#1565c0">
<title>白板 - zikai</title> <title>记事本 - zikai</title>
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/whiteboard.css"> <link rel="stylesheet" href="/static/whiteboard.css">
</head> </head>
@@ -12,24 +12,17 @@
<div class="wb-app"> <div class="wb-app">
<header class="wb-bar"> <header class="wb-bar">
<div class="wb-bar-left"> <div class="wb-bar-left">
<span class="wb-title">白板</span> <span class="wb-title">记事本</span>
<code class="wb-id mono" id="boardId" title="白板 ID"></code> <code class="wb-id mono" id="boardId" title="记事本 ID"></code>
<span class="wb-online" id="online" title="在线人数"></span> <span class="wb-online" id="online" title="在线人数"></span>
</div> </div>
<div class="wb-bar-right"> <div class="wb-bar-right">
<label class="wb-tool" title="笔画颜色"> <button class="btn" id="copyBtn" title="复制全部文本">复制文本</button>
<input type="color" id="color" value="#1565c0"> <button class="btn danger" id="clearBtn" title="清空全部内容(所有人)">清空</button>
</label>
<label class="wb-tool" title="笔画粗细">
<input type="range" id="width" min="1" max="24" value="3">
<span class="wb-width-val mono" id="widthVal">3</span>
</label>
<button class="btn" id="copyBtn" title="复制分享链接">复制链接</button>
<button class="btn danger" id="clearBtn" title="清空白板(所有人)">清空</button>
</div> </div>
</header> </header>
<main class="wb-stage"> <main class="wb-stage">
<canvas id="canvas"></canvas> <textarea id="editor" class="wb-editor" placeholder="在此输入文本,所有人会实时看到你的编辑…" spellcheck="false" autocomplete="off"></textarea>
<div class="wb-status" id="status">连接中…</div> <div class="wb-status" id="status">连接中…</div>
</main> </main>
</div> </div>

View File

@@ -1,35 +1,26 @@
/* 白板Canvas 绘画 + WebSocket 实时同步 + 心跳。 /* 记事本textarea + WebSocket 实时同步 + 心跳。
- 笔画以 {points:[[x,y],...], color, width} 表示,增量广播 - 文本以整文本 debounce 400ms 后发服务端,服务端存为新版本并广播给其他端
- 心跳 3s 一次 ping服务端 15s 无心跳判失活会主动断连,前端据此重连 - 收到他人 update 时用 diff 应用变更,保留本地光标位置(按相对偏移调整)
- 收到他人 stroke 增量重绘该笔;收到 cleared 清空本地画布 - 收到 cleared 清空本地 textarea
- 兼容鼠标 + 触摸:统一用 pointer eventstouch-action:none 防滚动缩放。 */ - 心跳 3s 一次 ping服务端 15s 无心跳判失活会主动断连,前端据此重连。 */
(function () { (function () {
"use strict"; "use strict";
const { el, toast, copyText } = window.ZK; const { toast, copyText } = window.ZK;
// ---------- 从 URL 解析 board_id ---------- // ---------- 从 URL 解析 board_id ----------
// 路径形如 /wb/{id}id 为 [a-zA-Z0-9_-]{1,64}
const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/); const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/);
let boardId = m ? decodeURIComponent(m[1]) : "default"; let boardId = m ? decodeURIComponent(m[1]) : "default";
// 合法性兜底:前端非法字符直接回退到 default真正校验在服务端
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default"; if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default";
document.getElementById("boardId").textContent = boardId; document.getElementById("boardId").textContent = boardId;
// ---------- DOM ---------- // ---------- DOM ----------
const canvas = document.getElementById("canvas"); const editor = document.getElementById("editor");
const ctx = canvas.getContext("2d");
const colorInput = document.getElementById("color");
const widthInput = document.getElementById("width");
const widthVal = document.getElementById("widthVal");
const clearBtn = document.getElementById("clearBtn"); const clearBtn = document.getElementById("clearBtn");
const copyBtn = document.getElementById("copyBtn"); const copyBtn = document.getElementById("copyBtn");
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const onlineEl = document.getElementById("online"); const onlineEl = document.getElementById("online");
// ---------- 状态 ---------- // ---------- 状态 ----------
let strokes = []; // 已确认的笔画
let current = null; // 正在画的笔画(本地未提交)
let drawing = false;
let ws = null; let ws = null;
let clientId = localStorage.getItem("wb_cid") || ""; let clientId = localStorage.getItem("wb_cid") || "";
if (!clientId) { if (!clientId) {
@@ -39,111 +30,99 @@
let heartbeatTimer = null; let heartbeatTimer = null;
let reconnectTimer = null; let reconnectTimer = null;
let connected = false; let connected = false;
let lastSentText = ""; // 上次发到服务端的文本(避免无变更时重复发)
let suppressInput = false; // 应用远端更新时抑制 input 事件,防回环
let debounceTimer = null;
// ---------- 画布尺寸 ---------- // ---------- 本地编辑 -> debounce -> 发送 ----------
function resize() { editor.addEventListener("input", () => {
const dpr = window.devicePixelRatio || 1; if (suppressInput) return;
const w = canvas.clientWidth; scheduleSend();
const h = canvas.clientHeight;
canvas.width = Math.max(1, Math.floor(w * dpr));
canvas.height = Math.max(1, Math.floor(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
redraw();
}
window.addEventListener("resize", resize);
// ---------- 绘制 ----------
function drawStroke(s) {
if (!s || !s.points || s.points.length < 1) return;
ctx.strokeStyle = s.color || "#1565c0";
ctx.lineWidth = Number(s.width) || 3;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const pts = s.points;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
if (pts.length === 1) {
// 单点:画一个小圆点
ctx.arc(pts[0][0], pts[0][1], (ctx.lineWidth || 3) / 2, 0, Math.PI * 2);
ctx.fillStyle = ctx.strokeStyle;
ctx.fill();
return;
}
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.stroke();
}
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const s of strokes) drawStroke(s);
if (current) drawStroke(current);
}
// ---------- 指针事件 ----------
function pos(e) {
const r = canvas.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
canvas.addEventListener("pointerdown", (e) => {
if (!connected) { flashStatus("未连接,正在重连…"); return; }
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
drawing = true;
current = { points: [pos(e)], color: colorInput.value, width: Number(widthInput.value) };
drawStroke(current);
});
canvas.addEventListener("pointermove", (e) => {
if (!drawing) return;
e.preventDefault();
const p = pos(e);
const last = current.points[current.points.length - 1];
// 跳过过近的点,减少数据量
if (Math.hypot(p[0] - last[0], p[1] - last[1]) < 1.5) return;
current.points.push(p);
// 增量画最后一段
ctx.strokeStyle = current.color;
ctx.lineWidth = current.width;
ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(last[0], last[1]);
ctx.lineTo(p[0], p[1]);
ctx.stroke();
});
function endStroke(e) {
if (!drawing) return;
drawing = false;
if (e && e.pointerId !== undefined) {
try { canvas.releasePointerCapture(e.pointerId); } catch {}
}
if (current && current.points.length) {
strokes.push(current);
send({ type: "stroke", stroke: current });
}
current = null;
}
canvas.addEventListener("pointerup", endStroke);
canvas.addEventListener("pointercancel", endStroke);
canvas.addEventListener("pointerleave", (e) => {
// 仅在抬起时结束离开但按住不放不结束pointer capture 已处理)
if (!drawing) return;
if (e.buttons === 0) endStroke(e);
}); });
widthInput.addEventListener("input", () => (widthVal.textContent = widthInput.value)); function scheduleSend() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
const text = editor.value;
if (text === lastSentText) return;
lastSentText = text;
send({ type: "edit", content: text });
}, 400);
}
function flushSend() {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
const text = editor.value;
if (text !== lastSentText) {
lastSentText = text;
send({ type: "edit", content: text });
}
}
}
clearBtn.addEventListener("click", () => { clearBtn.addEventListener("click", () => {
if (!connected) { flashStatus("未连接"); return; } if (!connected) { flashStatus("未连接"); return; }
if (!confirm("确定清空白板?所有人的内容都会被清除。")) return; if (!confirm("确定清空全部内容?所有人的内容都会被清除。")) return;
send({ type: "clear" }); send({ type: "clear" });
}); });
copyBtn.addEventListener("click", async () => { copyBtn.addEventListener("click", async () => {
const url = `${location.origin}/wb/${boardId}`; const text = editor.value;
const ok = await copyText(url); if (!text) { toast("内容为空"); return; }
toast(ok ? "链接已复制" : "复制失败"); const ok = await copyText(text);
toast(ok ? "已复制全部文本" : "复制失败");
}); });
// ---------- 应用远端更新(保留光标) ----------
// 策略:用最长公共前后缀算出变更区间,仅替换该区间,光标按相对位置调整。
function applyRemoteUpdate(newText) {
const oldText = editor.value;
if (newText === oldText) return;
const selStart = editor.selectionStart;
const selEnd = editor.selectionEnd;
// 算公共前缀
let prefix = 0;
const minLen = Math.min(oldText.length, newText.length);
while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix++;
// 算公共后缀(不能与前缀重叠)
let suffixOld = oldText.length;
let suffixNew = newText.length;
while (suffixOld > prefix && suffixNew > prefix && oldText[suffixOld - 1] === newText[suffixNew - 1]) {
suffixOld--;
suffixNew--;
}
suppressInput = true;
// 用 setRangeText 替换 [prefix, suffixOld) 为 newText[prefix, suffixNew)
editor.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, "end");
suppressInput = false;
lastSentText = editor.value;
// 调整光标:若光标在变更区间之前,不动;在之后,平移差值;在区间内,移到区间末尾
const delta = (suffixNew - prefix) - (suffixOld - prefix);
let newStart = selStart, newEnd = selEnd;
if (selStart <= prefix) {
// 光标在变更前,不变
} else if (selStart >= suffixOld) {
// 光标在变更后,平移
newStart = selStart + delta;
newEnd = selEnd + delta;
} else {
// 光标在变更区间内,移到区间末尾
newStart = newEnd = suffixNew;
}
try {
editor.setSelectionRange(newStart, newEnd);
} catch {}
editor.focus();
}
// ---------- WebSocket ---------- // ---------- WebSocket ----------
function wsUrl() { function wsUrl() {
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
@@ -160,7 +139,7 @@
} }
ws.onopen = () => { ws.onopen = () => {
connected = true; connected = true;
setStatus("已连接", true); setStatus("已连接", false);
onlineEl.classList.remove("off"); onlineEl.classList.remove("off");
send({ type: "hello", client_id: clientId }); send({ type: "hello", client_id: clientId });
startHeartbeat(); startHeartbeat();
@@ -175,23 +154,26 @@
try { msg = JSON.parse(raw); } catch { return; } try { msg = JSON.parse(raw); } catch { return; }
switch (msg.type) { switch (msg.type) {
case "init": case "init":
strokes = Array.isArray(msg.strokes) ? msg.strokes : []; suppressInput = true;
redraw(); editor.value = msg.content || "";
setStatus(`已同步 ${strokes.length}`, true); lastSentText = editor.value;
suppressInput = false;
editor.focus();
setStatus("已同步", false);
break; break;
case "pong": case "pong":
// 心跳回声,保持连接
break; break;
case "stroke": case "update":
if (msg.client_id === clientId) break; // 自己的,已本地 if (msg.client_id === clientId) break; // 自己的,已本地更新
strokes.push(msg.stroke); applyRemoteUpdate(msg.content || "");
drawStroke(msg.stroke); flashStatus("对方有更新");
break; break;
case "cleared": case "cleared":
strokes = []; suppressInput = true;
current = null; editor.value = "";
redraw(); lastSentText = "";
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了白板"); suppressInput = false;
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了内容");
break; break;
case "error": case "error":
flashStatus(msg.msg || "错误", true); flashStatus(msg.msg || "错误", true);
@@ -244,26 +226,31 @@
// ---------- 启动 ---------- // ---------- 启动 ----------
// 先 GET /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS // 先 GET /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS
fetch(`/api/wb/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } }) fetch(`/api/wb/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } })
.then((r) => r.ok ? r.json() : null) .then((r) => (r.ok ? r.json() : null))
.then((body) => { .then((body) => {
if (body && Array.isArray(body.strokes)) { if (body && typeof body.content === "string") {
strokes = body.strokes; editor.value = body.content;
redraw(); lastSentText = body.content;
} }
resize();
connect(); connect();
}) })
.catch(() => { resize(); connect(); }); .catch(() => { connect(); });
// 页面隐藏时不发心跳可被服务端判失活;重新可见时主动重连 // 页面可见性:重新可见时若已断连则主动重连;隐藏时 flush 未发送的编辑
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && (!ws || ws.readyState !== WebSocket.OPEN)) { if (document.visibilityState === "visible") {
if (!ws || ws.readyState !== WebSocket.OPEN) {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
connect(); connect();
} }
} else {
flushSend();
}
}); });
// 离开前 flush 未发送编辑
window.addEventListener("beforeunload", () => { window.addEventListener("beforeunload", () => {
flushSend();
stopHeartbeat(); stopHeartbeat();
if (reconnectTimer) clearTimeout(reconnectTimer); if (reconnectTimer) clearTimeout(reconnectTimer);
try { ws && ws.close(); } catch {} try { ws && ws.close(); } catch {}

View File

@@ -49,7 +49,7 @@
el("td", { class: "col-id" }, el("td", { class: "col-id" },
el("a", { class: "bid link", href: `/wb/${b.board_id}`, target: "_blank" }, b.board_id) el("a", { class: "bid link", href: `/wb/${b.board_id}`, target: "_blank" }, b.board_id)
), ),
el("td", { class: "col-mods mono" }, String(b.stroke_count ?? 0)), el("td", { class: "col-mods mono" }, String(b.edit_count ?? 0)),
el("td", { class: "col-created muted" }, fmtTime(b.created_at)), el("td", { class: "col-created muted" }, fmtTime(b.created_at)),
el("td", { class: "col-updated muted" }, fmtTime(b.updated_at)), el("td", { class: "col-updated muted" }, fmtTime(b.updated_at)),
el("td", { class: "col-act" }, el("td", { class: "col-act" },

View File

@@ -1,18 +1,20 @@
"""白板 WebSocket 端到端烟测:两客户端实时同步 + 心跳 + 清空。 """白板 WebSocket 端到端烟测(文本记事本):两客户端实时同步 + 心跳 + 清空。
验证: 验证:
1. 客户端 A 连入 -> 收到 init 1. 客户端 A 连入 -> 收到 init(含 content/version
2. 客户端 B 连入 -> 收到 init 2. 客户端 B 连入 -> 收到 init
3. A 画一笔 -> B 收到 stroke 广播A 不收自己的) 3. A 编辑文本 -> B 收到 updateA 不收自己的)
4. 心跳 ping -> pong 4. 心跳 ping -> pong
5. A 清空 -> A、B 都收到 cleared 5. A 清空 -> A、B 都收到 cleared
6. 停发心跳的连接会被服务端 reaper 移除15s这里只验证 ping/pong 即可reaper 已在 hub 单测覆盖) 6. 持久化:重连后 init 应返回清空后的内容
7. edit_count 累计
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json import json
import urllib.request
import websockets import websockets
@@ -31,7 +33,6 @@ async def recv_msg(ws, timeout=2.0) -> dict | None:
async def main() -> None: async def main() -> None:
async with websockets.connect(f"{BASE_WS}/{BOARD}") as a, \ async with websockets.connect(f"{BASE_WS}/{BOARD}") as a, \
websockets.connect(f"{BASE_WS}/{BOARD}") as b: websockets.connect(f"{BASE_WS}/{BOARD}") as b:
# hello
await a.send(json.dumps({"type": "hello", "client_id": "A"})) await a.send(json.dumps({"type": "hello", "client_id": "A"}))
await b.send(json.dumps({"type": "hello", "client_id": "B"})) await b.send(json.dumps({"type": "hello", "client_id": "B"}))
@@ -42,18 +43,18 @@ async def main() -> None:
assert init_a and init_a["type"] == "init" assert init_a and init_a["type"] == "init"
assert init_b and init_b["type"] == "init" assert init_b and init_b["type"] == "init"
# A 画一笔 # A 编辑文本
stroke = {"points": [[10, 10], [20, 20]], "color": "#1565c0", "width": 3} text = "Hello, this is a shared note.\nSecond line."
await a.send(json.dumps({"type": "stroke", "stroke": stroke})) await a.send(json.dumps({"type": "edit", "content": text}))
# A 不应收到自己的(排除发送者) # A 不应收到自己的 update
echo = await recv_msg(a, timeout=1.0) echo = await recv_msg(a, timeout=1.0)
print("A self-echo (expect None):", echo) print("A self-echo (expect None):", echo)
assert echo is None, "发送者不应收到自己的 stroke" assert echo is None, "发送者不应收到自己的 update"
# B 应收到 # B 应收到 update
got = await recv_msg(b) got = await recv_msg(b)
print("B recv:", got.get("type") if got else None, "client_id=", got.get("client_id") if got else None) print("B recv:", got.get("type") if got else None, "content=", repr(got.get("content")) if got else None)
assert got and got["type"] == "stroke" and got["client_id"] == "A" assert got and got["type"] == "update" and got["client_id"] == "A"
assert got["stroke"] == stroke assert got["content"] == text
# 心跳 # 心跳
await a.send(json.dumps({"type": "ping"})) await a.send(json.dumps({"type": "ping"}))
@@ -70,20 +71,19 @@ async def main() -> None:
assert cleared_b and cleared_b["type"] == "cleared" and cleared_b["client_id"] == "B" assert cleared_b and cleared_b["type"] == "cleared" and cleared_b["client_id"] == "B"
assert cleared_a and cleared_a["type"] == "cleared" and cleared_a["client_id"] == "B" assert cleared_a and cleared_a["type"] == "cleared" and cleared_a["client_id"] == "B"
# 验证持久化:重连后 init 应 strokes 为空(已清空) # 验证持久化:重连后 init 应 content 为空(已清空)
async with websockets.connect(f"{BASE_WS}/{BOARD}") as c: async with websockets.connect(f"{BASE_WS}/{BOARD}") as c:
await c.send(json.dumps({"type": "hello", "client_id": "C"})) await c.send(json.dumps({"type": "hello", "client_id": "C"}))
init_c = await recv_msg(c) init_c = await recv_msg(c)
print("C init after clear, strokes=", init_c.get("strokes") if init_c else None) print("C init after clear, content=", repr(init_c.get("content")) if init_c else None)
assert init_c and init_c["type"] == "init" assert init_c and init_c["type"] == "init"
assert init_c["strokes"] == [], "清空后重连应得到空 strokes" assert init_c["content"] == "", "清空后重连应得到空 content"
# 验证 stroke_count 累计(之前 1 笔 + 1 次清空 = 2 # 验证 edit_count 累计(1 次编辑 + 1 次清空 = 2
import urllib.request
with urllib.request.urlopen(f"http://127.0.0.1:6867/api/wb/{BOARD}") as r: with urllib.request.urlopen(f"http://127.0.0.1:6867/api/wb/{BOARD}") as r:
meta = json.load(r) meta = json.load(r)
print("stroke_count after ops:", meta["stroke_count"]) print("edit_count after ops:", meta["edit_count"])
assert meta["stroke_count"] == 2, "1 + 1 清空 = 2 次修改" assert meta["edit_count"] == 2, "1 编辑 + 1 清空 = 2 次"
print("\nWS 端到端全部通过 ✅") print("\nWS 端到端全部通过 ✅")