本次提交包含两批改动(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 全部通过。
93 lines
3.5 KiB
Python
93 lines
3.5 KiB
Python
"""白板服务:CRUD + 笔画操作。
|
||
|
||
不持有 WebSocket 连接状态(那是 hub 的职责);删除白板时通过可选的 hub 回调
|
||
通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub
|
||
的硬依赖(保持低耦合)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from fastapi import HTTPException
|
||
|
||
from ..config import get_settings
|
||
from ..dao.whiteboard_dao import WhiteboardDAO
|
||
from ..schemas.whiteboard import WhiteboardListItem, WhiteboardOut
|
||
|
||
if TYPE_CHECKING: # 避免运行时循环导入
|
||
from .whiteboard_hub import WhiteboardHub
|
||
|
||
# board_id 合法字符集:字母数字下划线短横线
|
||
_BOARD_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
||
|
||
|
||
class WhiteboardService:
|
||
def __init__(self, dao: WhiteboardDAO, hub: "WhiteboardHub | None" = None) -> None:
|
||
self.dao = dao
|
||
self.hub = hub
|
||
cfg = get_settings().whiteboard
|
||
self.max_board_id_length = cfg.max_board_id_length
|
||
self.list_limit = cfg.list_limit
|
||
|
||
# ---------------- 校验 ----------------
|
||
|
||
def validate_board_id(self, board_id: str) -> None:
|
||
"""非法 board_id 直接 400(防路径穿越/注入)。"""
|
||
if (
|
||
not board_id
|
||
or len(board_id) > self.max_board_id_length
|
||
or not _BOARD_ID_RE.match(board_id)
|
||
):
|
||
raise HTTPException(400, "board_id 非法(仅允许字母数字下划线短横线,1-64 字符)")
|
||
|
||
# ---------------- 读 ----------------
|
||
|
||
def get_or_create(self, board_id: str) -> WhiteboardOut:
|
||
self.validate_board_id(board_id)
|
||
board = self.dao.get_or_create(board_id)
|
||
return WhiteboardOut.model_validate(board)
|
||
|
||
def list_all(self, limit: int = 100, offset: int = 0) -> tuple[int, list[WhiteboardListItem]]:
|
||
limit = min(max(limit, 0), self.list_limit) or self.list_limit
|
||
offset = max(offset, 0)
|
||
total = self.dao.count()
|
||
rows = self.dao.list_all(limit=limit, offset=offset)
|
||
items = [WhiteboardListItem.model_validate(r) for r in rows]
|
||
return total, items
|
||
|
||
# ---------------- 写 ----------------
|
||
|
||
def append_stroke(self, board_id: str, stroke: dict[str, Any]) -> WhiteboardOut:
|
||
"""追加一条笔画并返回最新状态。"""
|
||
self.validate_board_id(board_id)
|
||
board = self.dao.append_strokes(board_id, [stroke])
|
||
if board is None:
|
||
raise HTTPException(404, "白板不存在")
|
||
return WhiteboardOut.model_validate(board)
|
||
|
||
def clear(self, board_id: str) -> WhiteboardOut:
|
||
"""清空白板;stroke_count 仍自增以记录这次修改。"""
|
||
self.validate_board_id(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:
|
||
"""删除白板;同时通知 hub 踢出该 board 的所有在线连接。"""
|
||
self.validate_board_id(board_id)
|
||
ok = self.dao.delete(board_id)
|
||
if ok and self.hub is not None:
|
||
# hub.close_board 是 async,但删除走 REST 同步路径;安排到事件循环里执行
|
||
import asyncio
|
||
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
loop.create_task(self.hub.close_board(board_id))
|
||
except RuntimeError:
|
||
# 无运行中事件循环(如脚本调用):同步调用会报错,忽略即可
|
||
pass
|
||
return ok
|