本次提交包含两批改动(7月2日遗留未入库 + 本次新功能),分述如下:
【补登记:7月2日已上线但未提交的功能】
- 分片上传:chunk_upload_controller/service/dao + UploadSession model/schema,
支持 4MiB 分片、乱序、断点续传、去重、幂等 complete;后台 reaper 清理过期会话。
- 反向隧道:tunnel_controller/service/dao + TunnelSession model/schema,
SSH remote forward 经 /api/userPort/{userName} 反代到 user 本地服务。
- 上传页:views/upload_html.py(拖拽/多文件/分片/断点续传 UI)。
- config.py:StorageConfig.chunk_session_dir/ttl、TunnelConfig;
requirements.txt 加 httpx;start.sh 清理 .work/ 残留;
schema.sql 加 upload_session/tunnel_session 表;sftp_server 承载隧道转发。
【本次新功能】
- 文件浏览页:GET /files(Basic Auth 同 docs)+ /api/admin/files(list/get/download/DELETE)。
硬删除(DB 行 + 磁盘文件),删除后列表不再显示。前端 static/file_browser.*。
- 共享白板:GET /whiteboard/{id}(公开,不存在则新建)+ WS /ws/whiteboard/{id}。
MySQL 持久化(whiteboard 表),Canvas 实时同步,心跳 3s/5 次失活移除,
清空/复制按钮,移动端兼容。WhiteboardHub 管理 {board_id: set[Connection]},
disconnect 幂等 + 空 set 清理防泄漏,broadcast 失败连接自动移除。
- 白板管理页:GET /whiteboard-admin(Basic Auth)+ /api/admin/whiteboards(list/DELETE)。
删除时 hub.close_board 踢出在线连接。
- 清理:合并 UploadService.get_out_with_disk_path(下载/删除复用,消除重复 DB 读),
移除无用 resolve_disk_path。
- config.py:WhiteboardConfig(heartbeat/threshold/board_id 长度/list_limit);
schema.sql 加 whiteboard 表;README 补新接口与心跳/内存说明。
- 验证:tests/manual_whiteboard_hub.py / _ws.py / _kick.py 全部通过。
221 lines
7.7 KiB
Python
221 lines
7.7 KiB
Python
"""白板接口: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
|