Files
zTools2/app/main.py
zikai ff2ad3fcb3 feat: 所有入口统一到 /api/ 前缀
将 zTools2 托管的页面/静态/探针/WebSocket 路由全部从顶级路径迁移到 /api/ 下:
- 页面:/pdf -> /api/pdf、/pdf-admin -> /api/pdf-admin、/upload -> /api/upload、
  /files -> /api/files-page、/wb/{id} -> /api/wb-page/{id}、/wb-admin -> /api/wb-admin
  (页面类加 -page 后缀以规避同名 REST API /api/files、/api/wb/{id})
- 静态资源:/static -> /api/static
- 探针:/health -> /api/health
- WebSocket:/ws/wb/{id} -> /api/ws/wb/{id}
- 前端 HTML 壳与 JS 中的资源/页间链接/WS URL 同步更新
- 手动测试脚本 BASE_WS 同步

这样反代与 vite proxy 只需一条 /api/ 规则即可转发全部入口;
前端 iframe 用同源相对路径 /api/pdf,与环境无关,不再误打到其它环境域名。
README 新增「路由约定」说明。
2026-07-27 15:42:47 +08:00

258 lines
9.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,
pdf_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)
app.include_router(pdf_router)
# 前端静态资源JS/CSSHTML 壳由下面的具名路由返回,便于各自挂 Basic Auth
# 统一 /api/ 前缀:所有 zTools2 入口(页面/静态/探针/WS/API都在 /api/ 下,
# 反代与 vite proxy 只需一条 /api/ 规则即可转发,与环境无关
if _STATIC_DIR.is_dir():
app.mount("/api/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("/api/health", tags=["meta"], summary="存活探针")
def health() -> dict:
return {"status": "ok"}
@app.get(
"/api/upload",
response_class=HTMLResponse,
tags=["pages"],
summary="上传页面",
description="拖拽 / 多文件 / 分片4 MiB / 断点续传上传页面(公开)。",
)
def upload_page() -> HTMLResponse:
return HTMLResponse(render_upload_html())
@app.get(
"/api/files-page",
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(
"/api/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(
"/api/wb-page/{board_id}",
response_class=HTMLResponse,
tags=["pages"],
summary="记事本页面",
description="公开访问的共享文本记事本,不存在则自动新建;实时协作走 WS /api/ws/wb/{id}",
)
def whiteboard_page(board_id: str) -> HTMLResponse:
return _serve_static_html("whiteboard.html")
@app.get(
"/api/pdf",
response_class=HTMLResponse,
tags=["pages"],
summary="PDF 转换页面",
description="上传 epub 文件转为 PDF显示进度并下载凭 cookie 查看自己的任务。",
)
def pdf_page() -> HTMLResponse:
return _serve_static_html("pdf.html")
@app.get(
"/api/pdf-admin",
response_class=HTMLResponse,
tags=["pages"],
summary="PDF 转换管理页(需鉴权)",
description="查看全部转换任务含用户已软删的标注是否已删除并可硬删。Basic Auth 同 docs。",
)
def pdf_admin_page(_: str = Depends(require_docs_auth)) -> HTMLResponse:
return _serve_static_html("pdf_admin.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()