"""白板接口:REST(访问/管理)+ WebSocket(实时同步)。 路由: GET /whiteboard/{board_id} 公开:访问白板,不存在则新建 WS /ws/whiteboard/{board_id} 公开:实时协作 + 心跳 GET /api/admin/whiteboards Basic Auth:管理页列表 DELETE /api/admin/whiteboards/{id} Basic Auth:删除白板 """ 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:注入 hub 以便删除时踢出连接。""" return WhiteboardService(WhiteboardDAO(db), hub=get_hub()) # ---------------- 公开 REST ---------------- @router.get( "/whiteboard/{board_id}", response_model=WhiteboardOut, summary="访问白板(不存在则新建)", description="任何人凭 board_id 即可访问;不存在时自动创建空板并返回。", ) def get_whiteboard(board_id: str, service: WhiteboardService = Depends(_service)) -> WhiteboardOut: return service.get_or_create(board_id) # ---------------- 管理 REST(Basic Auth) ---------------- @router.get( "/api/admin/whiteboards", 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/whiteboards/{board_id}", summary="删除白板(需鉴权)", description="删 DB 行,并关闭该 board 的所有在线 WebSocket 连接。", ) 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, "白板不存在") return {"deleted": True} # ---------------- WebSocket(公开,实时同步 + 心跳) ---------------- @router.websocket("/ws/whiteboard/{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":"clear"} 清空,持久化并广播给所有人 server -> client: {"type":"init","strokes":[...],"stroke_count":n} {"type":"pong"} {"type":"stroke","stroke":{...},"client_id":"..."} {"type":"cleared","client_id":"..."} {"type":"error","msg":"..."} """ # 路径层只做最基本校验,详细校验交给 service(service 会查表) 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 并加载白板(不存在则新建) 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: 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) await hub.register(conn) await _safe_send(websocket, { "type": "init", "strokes": board.strokes, "stroke_count": board.stroke_count, }) # 主循环:收消息 -> 处理 -> 广播 try: while True: raw = await websocket.receive_text() 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 == "stroke": stroke = msg.get("stroke") or {} try: _with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).append_stroke(board_id, stroke)) 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}, exclude=conn, ) elif mtype == "clear": try: _with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).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