纯 Python 转换(ebooklib 解析 epub + weasyprint 渲染 HTML/CSS 为 PDF), 无需 calibre/xvfb 系统依赖。复用 UploadedFile 存储(原始文件与产物 PDF)。 - model/dao/schema:PdfJob 记录转换任务(owner_cookie/source/output/进度/状态) - pdf_converter:epub->PDF 转换器(按 spine 顺序拼 HTML,OPF 目录解析相对资源) - pdf_service:上传落盘 + asyncio 后台转换(独立 Session)+ 进度追踪 + 两级删除 · 用户软删(user_deleted,管理页仍可见)· 管理员硬删(真正删磁盘+记录) - pdf_controller:/api/pdf/*(用户,cookie)+ /api/admin/pdf/*(Basic Auth) - /pdf、/pdf-admin 页面(原生 JS,复用 common.css/js) - 配置 pdf 段(max_size 250MB、转换超时、cookie) - pytest 7 项(SQLite→MySQL 隔离测试库):上传/转换/下载/软删/硬删/超限/越权
256 lines
9.5 KiB
Python
256 lines
9.5 KiB
Python
"""FastAPI 应用工厂。
|
||
|
||
路由概览:
|
||
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 /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/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", 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")
|
||
|
||
@app.get(
|
||
"/pdf",
|
||
response_class=HTMLResponse,
|
||
tags=["pages"],
|
||
summary="PDF 转换页面",
|
||
description="上传 epub 文件转为 PDF,显示进度并下载;凭 cookie 查看自己的任务。",
|
||
)
|
||
def pdf_page() -> HTMLResponse:
|
||
return _serve_static_html("pdf.html")
|
||
|
||
@app.get(
|
||
"/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()
|