feat: 文件浏览页 + 共享白板 + 白板管理页(含补登记分片上传/隧道历史改动)

本次提交包含两批改动(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 全部通过。
This commit is contained in:
zikai
2026-07-21 14:28:13 +00:00
parent e5a725fc91
commit fffba79022
48 changed files with 3569 additions and 216 deletions

View File

@@ -0,0 +1,220 @@
"""白板接口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)
# ---------------- 管理 RESTBasic 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":"..."}
"""
# 路径层只做最基本校验,详细校验交给 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 并加载白板(不存在则新建)
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