Files
zTools2/app/main.py
zikai 7188c62d3a refactor: 白板路由改 /wb 前缀,消除 HTML 与 REST 同路径冲突
问题:GET /whiteboard/{board_id} 同时被 REST(返回 JSON)与 main.py 的 HTML 页面
注册,FastAPI 按注册顺序匹配到 REST,导致浏览器访问拿到 JSON 而非前端页面。

改为按职责分命名空间,避免冲突:
- HTML 页面:/wb/{id}(main.py)、/wb-admin(main.py,Basic Auth)
- 公开 REST:GET /api/wb/{id}(前端 init 拉取初始笔画)
- WS:/ws/wb/{id}(实时同步 + 心跳)
- 管理 REST:GET /api/admin/wb、DELETE /api/admin/wb/{id}(Basic Auth)

前端 whiteboard.js / whiteboard_admin.js、测试脚本、README 路径同步更新。
旧 /whiteboard/* 路径不再注册(404)。
2026-07-21 14:51:33 +00:00

214 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""FastAPI 应用工厂。
路由概览:
GET / -> 仅返回版本号
GET /docs -> Swagger UIBasic Auth
GET /redoc -> ReDoc Basic Auth
GET /openapi.json -> OpenAPI 文档Basic Auth
GET /health -> 存活探针(公开)
GET /upload -> 上传页面(公开 HTML
GET /files -> 文件浏览页Basic Auth同 docs
GET /wb/{id} -> 白板页面(公开,不存在则新建)
GET /wb-admin -> 白板管理页Basic Auth同 docs
GET /api/... -> 业务接口
WS /ws/wb/{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 HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
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):
try:
init_db_schema()
logger.info("数据库表已就绪。")
except Exception as exc: # pragma: no cover
logger.error("初始化数据库失败:%s", exc)
# 启动时清理残留的 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:
app = FastAPI(
title="zikai file service",
description=(
"f.zikai.wang 的文件上传与主机监控服务。\n\n"
"- `GET /api/system/status` — CPU / 内存 / 磁盘HTML 或 JSON\n"
"- `POST /api/files/upload` — 大文件流式上传\n"
"- 内置 SFTP 服务(详见 README\n\n"
"/docs 等接口需 Basic Auth凭据见 config.yaml 的 docs 段。"
),
version="1.0.0",
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=lifespan,
)
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/CSSHTML 壳由下面的具名路由返回,便于各自挂 Basic Auth
if _STATIC_DIR.is_dir():
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
# 受 Basic Auth 保护的文档接口
@app.get("/openapi.json")
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse:
return JSONResponse(app.openapi())
@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")
def protected_redoc(_: str = Depends(require_docs_auth)):
return get_redoc_html(
openapi_url="/openapi.json", title="zikai docs", redoc_favicon_url=""
)
@app.get("/", response_class=PlainTextResponse)
def root() -> PlainTextResponse:
return PlainTextResponse(f"zikai {app.version}\n")
@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("/wb-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("/wb/{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()