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:
156
app/main.py
156
app/main.py
@@ -1,29 +1,103 @@
|
||||
"""FastAPI 应用工厂。
|
||||
|
||||
路由概览:
|
||||
GET / -> 仅返回版本号
|
||||
GET /docs -> Swagger UI(Basic Auth)
|
||||
GET /redoc -> ReDoc (Basic Auth)
|
||||
GET /openapi.json -> OpenAPI 文档(Basic Auth)
|
||||
GET /health -> 存活探针(公开)
|
||||
GET /api/... -> 业务接口
|
||||
GET / -> 仅返回版本号
|
||||
GET /docs -> Swagger UI(Basic Auth)
|
||||
GET /redoc -> ReDoc (Basic Auth)
|
||||
GET /openapi.json -> OpenAPI 文档(Basic Auth)
|
||||
GET /health -> 存活探针(公开)
|
||||
GET /upload -> 上传页面(公开 HTML)
|
||||
GET /files -> 文件浏览页(Basic Auth,同 docs)
|
||||
GET /whiteboard/{id} -> 白板页面(公开,不存在则新建)
|
||||
GET /whiteboard-admin -> 白板管理页(Basic Auth,同 docs)
|
||||
GET /api/... -> 业务接口
|
||||
WS /ws/whiteboard/{id} -> 白板实时同步(公开)
|
||||
/static/... -> 前端静态资源(JS/CSS)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .controllers import file_router, system_router
|
||||
from .controllers import (
|
||||
chunk_upload_router,
|
||||
file_admin_router,
|
||||
file_router,
|
||||
system_router,
|
||||
tunnel_router,
|
||||
whiteboard_router,
|
||||
)
|
||||
from .database import init_db_schema
|
||||
from .security import require_docs_auth
|
||||
from .services.chunk_upload_service import ChunkUploadService
|
||||
from .services.whiteboard_hub import get_hub
|
||||
from .views.upload_html import render as render_upload_html
|
||||
|
||||
logger = logging.getLogger("zikai")
|
||||
|
||||
# 后台 reaper 的扫描间隔(秒)。不依赖 start.sh,进程存活期间持续清理过期会话。
|
||||
_REAPER_INTERVAL_SECONDS = 60
|
||||
|
||||
_STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
|
||||
|
||||
def _reap_once() -> None:
|
||||
"""同步执行一次过期会话清理(在 worker 线程里跑,避免阻塞事件循环)。"""
|
||||
try:
|
||||
from .database import get_session_local
|
||||
from .dao.upload_session_dao import UploadSessionDAO
|
||||
from .dao.uploaded_file_dao import UploadedFileDAO
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
n = ChunkUploadService(UploadSessionDAO(db), UploadedFileDAO(db)).reap_stale_sessions()
|
||||
if n:
|
||||
logger.info("reaper 清理了 %d 个过期分片会话", n)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("reaper 执行失败:%s", exc)
|
||||
|
||||
|
||||
async def _reaper_loop(stop: asyncio.Event) -> None:
|
||||
"""周期性清理被放弃的分片上传会话。"""
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await asyncio.to_thread(_reap_once)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("reaper 循环异常:%s", exc)
|
||||
# 用 wait_for 实现「可被 stop 提前唤醒的 sleep」
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=_REAPER_INTERVAL_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
def _tunnel_reap_once() -> None:
|
||||
"""启动时清理 DB 里残留的 active 隧道会话(进程异常重启后的孤儿记录)。"""
|
||||
try:
|
||||
from .database import get_session_local
|
||||
from .dao.tunnel_session_dao import TunnelSessionDAO
|
||||
from .services.tunnel_service import TunnelService
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
n = TunnelService(TunnelSessionDAO(db)).reap_orphans()
|
||||
if n:
|
||||
logger.info("tunnel reaper 清理了 %d 个孤儿隧道会话", n)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("tunnel reaper 执行失败:%s", exc)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -32,7 +106,24 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("数据库表已就绪。")
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.error("初始化数据库失败:%s", exc)
|
||||
yield
|
||||
# 启动时清理残留的 active 隧道会话(进程重启后的孤儿记录)
|
||||
await asyncio.to_thread(_tunnel_reap_once)
|
||||
# 启动后台 reaper,清理被放弃的分片上传会话与临时文件
|
||||
stop = asyncio.Event()
|
||||
reaper = asyncio.create_task(_reaper_loop(stop))
|
||||
logger.info("分片会话 reaper 已启动(间隔 %ds)。", _REAPER_INTERVAL_SECONDS)
|
||||
# 启动白板心跳 reaper,清理失活的 WebSocket 连接
|
||||
wb_reaper = asyncio.create_task(get_hub().reap_loop(stop))
|
||||
logger.info("白板心跳 reaper 已启动。")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop.set()
|
||||
for task in (reaper, wb_reaper):
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError): # pragma: no cover
|
||||
task.cancel()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -54,34 +145,69 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.include_router(system_router)
|
||||
app.include_router(file_router)
|
||||
app.include_router(file_admin_router)
|
||||
app.include_router(chunk_upload_router)
|
||||
app.include_router(tunnel_router)
|
||||
app.include_router(whiteboard_router)
|
||||
|
||||
# 前端静态资源(JS/CSS);HTML 壳由下面的具名路由返回,便于各自挂 Basic Auth
|
||||
if _STATIC_DIR.is_dir():
|
||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
|
||||
# 受 Basic Auth 保护的文档接口
|
||||
@app.get("/openapi.json", include_in_schema=False)
|
||||
@app.get("/openapi.json")
|
||||
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse:
|
||||
return JSONResponse(app.openapi())
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
@app.get("/docs")
|
||||
def protected_docs(_: str = Depends(require_docs_auth)):
|
||||
return get_swagger_ui_html(
|
||||
openapi_url="/openapi.json", title="zikai docs", swagger_favicon_url=""
|
||||
)
|
||||
|
||||
@app.get("/redoc", include_in_schema=False)
|
||||
@app.get("/redoc")
|
||||
def protected_redoc(_: str = Depends(require_docs_auth)):
|
||||
return get_redoc_html(
|
||||
openapi_url="/openapi.json", title="zikai docs", redoc_favicon_url=""
|
||||
)
|
||||
|
||||
# 公开元信息(不暴露接口列表)
|
||||
@app.get("/", include_in_schema=False, response_class=PlainTextResponse)
|
||||
@app.get("/", response_class=PlainTextResponse)
|
||||
def root() -> PlainTextResponse:
|
||||
return PlainTextResponse(f"zikai {app.version}\n")
|
||||
|
||||
@app.get("/health", include_in_schema=False)
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/upload", response_class=HTMLResponse)
|
||||
def upload_page() -> HTMLResponse:
|
||||
"""拖拽 / 多文件 / 分片上传页面(公开,对齐 /api/files/upload)。"""
|
||||
return HTMLResponse(render_upload_html())
|
||||
|
||||
@app.get("/files", response_class=HTMLResponse)
|
||||
def files_page(_: str = Depends(require_docs_auth)) -> HTMLResponse:
|
||||
"""文件浏览页(Basic Auth,同 docs):列出/下载/删除已上传文件。"""
|
||||
return _serve_static_html("file_browser.html")
|
||||
|
||||
@app.get("/whiteboard-admin", response_class=HTMLResponse)
|
||||
def whiteboard_admin_page(_: str = Depends(require_docs_auth)) -> HTMLResponse:
|
||||
"""白板管理页(Basic Auth,同 docs):查看/删除白板。"""
|
||||
return _serve_static_html("whiteboard_admin.html")
|
||||
|
||||
@app.get("/whiteboard/{board_id}", response_class=HTMLResponse)
|
||||
def whiteboard_page(board_id: str) -> HTMLResponse:
|
||||
"""白板页面(公开):访问即协作,不存在则前端拉取时自动新建。"""
|
||||
return _serve_static_html("whiteboard.html")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _serve_static_html(filename: str) -> HTMLResponse:
|
||||
"""读取 static/ 下的 HTML 文件并返回;缺失时返回 404 文本。"""
|
||||
path = _STATIC_DIR / filename
|
||||
if not path.is_file():
|
||||
return HTMLResponse(f"<h1>未找到 {filename}</h1>", status_code=404)
|
||||
return HTMLResponse(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
Reference in New Issue
Block a user