Files
zTools2/app/controllers/whiteboard_controller.py
zikai ff2ad3fcb3 feat: 所有入口统一到 /api/ 前缀
将 zTools2 托管的页面/静态/探针/WebSocket 路由全部从顶级路径迁移到 /api/ 下:
- 页面:/pdf -> /api/pdf、/pdf-admin -> /api/pdf-admin、/upload -> /api/upload、
  /files -> /api/files-page、/wb/{id} -> /api/wb-page/{id}、/wb-admin -> /api/wb-admin
  (页面类加 -page 后缀以规避同名 REST API /api/files、/api/wb/{id})
- 静态资源:/static -> /api/static
- 探针:/health -> /api/health
- WebSocket:/ws/wb/{id} -> /api/ws/wb/{id}
- 前端 HTML 壳与 JS 中的资源/页间链接/WS URL 同步更新
- 手动测试脚本 BASE_WS 同步

这样反代与 vite proxy 只需一条 /api/ 规则即可转发全部入口;
前端 iframe 用同源相对路径 /api/pdf,与环境无关,不再误打到其它环境域名。
README 新增「路由约定」说明。
2026-07-27 15:42:47 +08:00

240 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""白板接口REST访问/管理)+ WebSocket实时同步
路由:
GET /api/wb/{board_id} 公开:访问记事本元数据,不存在则新建(前端 init 用)
WS /ws/wb/{board_id} 公开:实时协作 + 心跳
GET /api/admin/wb Basic Auth管理页列表
DELETE /api/admin/wb/{board_id} Basic Auth删除记事本
HTML 页面 /wb/{id} 与管理页 /wb-admin 由 main.py 直接返回静态文件,
不在此 controller 注册,避免与 REST 同路径冲突。
"""
from __future__ import annotations
import json
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from ..database import get_db
from ..dao.whiteboard_dao import WhiteboardDAO
from ..schemas.whiteboard import WhiteboardListResponse, WhiteboardOut
from ..security import require_docs_auth
from ..services.whiteboard_hub import Connection, get_hub
from ..services.whiteboard_service import WhiteboardService
logger = logging.getLogger("zikai.whiteboard")
router = APIRouter(tags=["whiteboard"])
def _service(db: Session = Depends(get_db)) -> WhiteboardService:
"""REST 路径的 service纯 DB 操作)。"""
return WhiteboardService(WhiteboardDAO(db))
# ---------------- 公开 REST ----------------
@router.get(
"/api/wb/{board_id}",
response_model=WhiteboardOut,
summary="访问记事本元数据(不存在则新建)",
description="前端打开 /wb/{id} 页面后调本接口拉取初始文本;不存在时自动创建空板。",
)
def get_whiteboard(board_id: str, service: WhiteboardService = Depends(_service)) -> WhiteboardOut:
return service.get_or_create(board_id)
# ---------------- 管理 RESTBasic Auth ----------------
@router.get(
"/api/admin/wb",
response_model=WhiteboardListResponse,
summary="列出所有记事本(需鉴权)",
description="供记事本管理页使用board_id / 创建时间 / 编辑次数 / 上次修改时间。",
)
def list_whiteboards(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
service: WhiteboardService = Depends(_service),
_: str = Depends(require_docs_auth),
) -> WhiteboardListResponse:
total, items = service.list_all(limit=limit, offset=offset)
return WhiteboardListResponse(total=total, items=items)
@router.delete(
"/api/admin/wb/{board_id}",
summary="删除记事本(需鉴权)",
description="删 DB 行,并关闭该 board 的所有在线 WebSocket 连接。",
)
async def delete_whiteboard(
board_id: str,
service: WhiteboardService = Depends(_service),
_: str = Depends(require_docs_auth),
) -> dict:
ok = service.delete(board_id)
if not ok:
raise HTTPException(404, "白板不存在")
# 删除成功后踢出该 board 的所有在线连接close_board 是 async须在事件循环中调用
await get_hub().close_board(board_id)
return {"deleted": True}
# ---------------- WebSocket公开实时同步 + 心跳) ----------------
@router.websocket("/api/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":"edit","content":"..."} debounce 后发完整文本,持久化并广播给他人
{"type":"clear"} 清空,持久化并广播给所有人
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":"..."}
"""
# 路径层只做最基本校验,详细校验交给 serviceservice 会查表)
hub = get_hub()
# 先 accept便于对非法 board_id 也回一条 error 再关闭
await websocket.accept()
# 读取首帧 hello或任意帧拿 client_id
try:
first = await websocket.receive_text()
except WebSocketDisconnect:
return
client_id = _extract_client_id(first) or uuid.uuid4().hex[:12]
# 校验 board_id 并加载白板(不存在则新建)
try:
board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db)).get_or_create(board_id))
except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
await _safe_close(websocket)
return
# 注册连接并下发 init
conn = Connection(websocket=websocket, board_id=board_id, client_id=client_id)
ok = await hub.register(conn)
if not ok:
await _safe_send(websocket, {"type": "error", "msg": "该记事本在线人数已满"})
await _safe_close(websocket)
return
await _safe_send(websocket, {
"type": "init",
"content": board.content,
"version": board.version,
"edit_count": board.edit_count,
})
# 主循环:收消息 -> 处理 -> 广播
# 单帧大小上限:与 content 限制对齐256KB 文本 + JSON 开销,留余量到 512KB
MAX_FRAME = 512 * 1024
try:
while True:
raw = await websocket.receive_text()
if len(raw) > MAX_FRAME:
await _safe_send(websocket, {"type": "error", "msg": "消息过大"})
continue
msg = _parse(raw)
if msg is None:
continue
mtype = msg.get("type")
if mtype == "ping":
conn.touch()
await _safe_send(websocket, {"type": "pong"})
continue
# 任何有效业务帧都视为活性证据
conn.touch()
if mtype == "edit":
content = msg.get("content")
if not isinstance(content, str):
await _safe_send(websocket, {"type": "error", "msg": "content 必须是字符串"})
continue
try:
out = _with_db(
lambda db: WhiteboardService(WhiteboardDAO(db)).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": "update", "content": out.content, "version": out.version, "client_id": client_id},
exclude=conn,
)
elif mtype == "clear":
try:
_with_db(lambda db: WhiteboardService(WhiteboardDAO(db)).clear(board_id))
except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
continue
# clear 广播给所有人(含发送者,用于确认)
await hub.broadcast(
board_id, {"type": "cleared", "client_id": client_id}
)
else:
await _safe_send(websocket, {"type": "error", "msg": f"未知消息类型 {mtype}"})
except WebSocketDisconnect:
pass
except Exception as exc: # pragma: no cover
logger.warning("白板 WS 异常 board=%s client=%s: %s", board_id, client_id, exc)
finally:
await hub.disconnect(conn)
# ---------------- helpers ----------------
def _with_db(fn):
"""在独立 Session 中执行 fn 并返回结果;用完即关。供 WS 路径每帧独立事务使用。"""
from ..database import get_session_local
db = get_session_local()()
try:
return fn(db)
finally:
db.close()
def _extract_client_id(raw: str) -> str | None:
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None
cid = data.get("client_id") if isinstance(data, dict) else None
if isinstance(cid, str) and cid:
return cid
return None
def _parse(raw: str) -> dict | None:
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None
return data if isinstance(data, dict) else None
async def _safe_send(ws: WebSocket, msg: dict) -> None:
try:
await ws.send_json(msg)
except Exception: # pragma: no cover
pass
async def _safe_close(ws: WebSocket) -> None:
try:
await ws.close()
except Exception: # pragma: no cover
pass