Files
zTools2/app/main.py
zikai 374c3d150f fix: docs 分组与 summary 统一 + 记事本页加「复制链接」按钮
docs 乱:
- main.py 页面路由(/upload /files /wb-admin /wb/{id} /docs /redoc / /health)
  加 tags(pages/docs/meta)+ summary/description,Swagger UI 按 tag 分组显示。
- whiteboard_controller 残留「白板/笔画/修改次数」改为「记事本/文本/编辑次数」。

复制链接:
- 记事本页加「复制链接」按钮,复制 {origin}/wb/{id} 分享链接(保留「复制文本」)。
2026-07-21 15:04:02 +00:00

234 lines
8.7 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", tags=["docs"], summary="OpenAPI 文档(需鉴权)")
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse:
return JSONResponse(app.openapi())
@app.get("/docs", tags=["docs"], summary="Swagger UI需鉴权")
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", tags=["docs"], summary="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("/", tags=["meta"], summary="版本号")
def root() -> PlainTextResponse:
return PlainTextResponse(f"zikai {app.version}\n")
@app.get("/health", tags=["meta"], summary="存活探针")
def health() -> dict:
return {"status": "ok"}
@app.get(
"/upload",
response_class=HTMLResponse,
tags=["pages"],
summary="上传页面",
description="拖拽 / 多文件 / 分片4 MiB / 断点续传上传页面(公开)。",
)
def upload_page() -> HTMLResponse:
return HTMLResponse(render_upload_html())
@app.get(
"/files",
response_class=HTMLResponse,
tags=["pages"],
summary="文件浏览页(需鉴权)",
description="列出 / 下载 / 删除已上传文件支持多选、批量下载删除与分页。Basic Auth 同 docs。",
)
def files_page(_: str = Depends(require_docs_auth)) -> HTMLResponse:
return _serve_static_html("file_browser.html")
@app.get(
"/wb-admin",
response_class=HTMLResponse,
tags=["pages"],
summary="记事本管理页(需鉴权)",
description="查看所有记事本的创建时间 / 编辑次数 / 上次修改时间并可删除。Basic Auth 同 docs。",
)
def whiteboard_admin_page(_: str = Depends(require_docs_auth)) -> HTMLResponse:
return _serve_static_html("whiteboard_admin.html")
@app.get(
"/wb/{board_id}",
response_class=HTMLResponse,
tags=["pages"],
summary="记事本页面",
description="公开访问的共享文本记事本,不存在则自动新建;实时协作走 WS /ws/wb/{id}",
)
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()