1. 合并 files-page 与 pdf-admin 为统一文件管理页 /api/files-page:
- 新增 GET /api/admin/files/with-pdf 接口,以 uploaded_files 为基础,
用 pdf_jobs.source_file_id / output_file_id 内存匹配,为关联文件标注
转换状态、用户软删标记与角色(源epub/产物PDF)
- PdfJobOut 补 source_file_id / output_file_id 字段
- file_browser 新增 PDF 任务列(状态徽标/软删标记/硬删任务按钮)
- 删除 /api/pdf-admin 路由及 static/pdf_admin.* 三个文件
2. 新增导航页 /api/index,卡片式收集所有页面入口;
各子页(upload/whiteboard/system_status/files-page)脚注加返回导航链接
3. 更新 docs/routes.md、docs/configuration.md 同步说明
测试: pytest tests/test_pdf_service.py 7 passed; 手动校验各页面路由与合并接口响应
253 lines
9.6 KiB
Python
253 lines
9.6 KiB
Python
"""FastAPI 应用工厂。
|
||
|
||
路由概览:
|
||
GET / -> 仅返回版本号
|
||
GET /docs -> Swagger UI(Basic Auth)
|
||
GET /redoc -> ReDoc (Basic Auth)
|
||
GET /openapi.json -> OpenAPI 文档(Basic Auth)
|
||
GET /health -> 存活探针(公开)
|
||
GET /api/index -> 导航页(公开,收集所有页面入口)
|
||
GET /api/upload -> 上传页面(公开 HTML)
|
||
GET /api/files-page -> 文件管理页(Basic Auth,同 docs;含 PDF 转换管理)
|
||
GET /api/wb/{id} -> 白板页面(公开,不存在则新建)
|
||
GET /api/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/CSS);HTML 壳由下面的具名路由返回,便于各自挂 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/index",
|
||
response_class=HTMLResponse,
|
||
tags=["pages"],
|
||
summary="导航页(公开)",
|
||
description="收集所有页面入口的卡片式导航页,各子页脚注可返回此处。",
|
||
)
|
||
def index_page() -> HTMLResponse:
|
||
return _serve_static_html("index.html")
|
||
|
||
@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=(
|
||
"列出 / 下载 / 删除已上传文件,并合并 PDF 转换管理:以 uploaded_files 为基础,"
|
||
"用 pdf_jobs 匹配标注关联文件的转换状态、用户软删标记,可硬删任务。"
|
||
"支持多选、批量下载删除与分页。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")
|
||
|
||
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()
|