feat: 新增 PDF 转换服务(epub->pdf,cookie 用户隔离,软删/硬删两级删除)

纯 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 隔离测试库):上传/转换/下载/软删/硬删/超限/越权
This commit is contained in:
2026-07-27 11:33:43 +08:00
parent fa980e74e0
commit aa08a1cf60
21 changed files with 1549 additions and 6 deletions

View File

@@ -3,6 +3,7 @@
from .chunk_upload_controller import router as chunk_upload_router
from .file_admin_controller import router as file_admin_router
from .file_controller import router as file_router
from .pdf_controller import router as pdf_router
from .system_controller import router as system_router
from .tunnel_controller import router as tunnel_router
from .whiteboard_controller import router as whiteboard_router
@@ -11,6 +12,7 @@ __all__ = [
"chunk_upload_router",
"file_admin_router",
"file_router",
"pdf_router",
"system_router",
"tunnel_router",
"whiteboard_router",

View File

@@ -0,0 +1,182 @@
"""PDF 转换接口用户侧cookie 标识)+ 管理侧Basic Auth
路由:
POST /api/pdf/jobs 用户上传文件并提交转换(首次无 cookie 则下发)
GET /api/pdf/jobs 当前用户任务列表(仅未软删)
GET /api/pdf/jobs/{id} 单任务状态(轮询进度)
GET /api/pdf/jobs/{id}/download 下载产物 PDF
DELETE /api/pdf/jobs/{id} 用户软删(不再对用户展示,管理页仍可见)
GET /api/admin/pdf/jobs 管理页列表(全部,含已软删标记)
DELETE /api/admin/pdf/jobs/{id} 管理员硬删(真正删除磁盘与 DB
HTML 页面 /pdf用户与 /pdf-admin管理由 main.py 返回静态文件,
不在此 controller 注册,避免与 REST 同路径冲突。
"""
from __future__ import annotations
from fastapi import APIRouter, Cookie, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from sqlalchemy.orm import Session
from ..config import get_settings
from ..database import get_db
from ..dao.pdf_job_dao import PdfJobDAO
from ..dao.uploaded_file_dao import UploadedFileDAO
from ..schemas.pdf import DeleteResult, PdfJobListResponse, PdfJobOut, PdfSubmitResponse
from ..security import require_docs_auth
from ..services.pdf_service import PdfService, new_owner_cookie
router = APIRouter(tags=["pdf"])
def _service(db: Session = Depends(get_db)) -> PdfService:
return PdfService(PdfJobDAO(db), UploadedFileDAO(db))
def _resolve_cookie(request: Request, zk_pdf: str | None = Cookie(default=None)) -> str:
"""解析用户 cookie无则生成新值由 controller 写入响应头)。
cookie 缺失时把新值挂到 request.state供响应阶段 set_cookie。
"""
cfg = get_settings().pdf
if zk_pdf and len(zk_pdf) == 32:
return zk_pdf
new = new_owner_cookie()
request.state.new_pdf_cookie = new
return new
@router.post(
"/api/pdf/jobs",
response_model=PdfSubmitResponse,
summary="上传文件并提交 PDF 转换",
description=(
"multipart/form-data 上传 epub 文件≤250MB流式落盘后创建 pending 转换任务,"
"后台异步转换。用户凭 zk_pdf cookie 标识;首次无 cookie 时响应下发新 cookie。"
),
)
async def submit_job(
request: Request,
file: UploadFile = File(..., description="要转换的 epub 文件"),
service: PdfService = Depends(_service),
owner_cookie: str = Depends(_resolve_cookie),
) -> PdfSubmitResponse:
job, _source_id = service.submit(file, owner_cookie)
# 提交成功后触发后台转换
service.schedule_convert(job.id)
resp = PdfSubmitResponse(job=job, set_cookie=False)
new_cookie = getattr(request.state, "new_pdf_cookie", None)
if new_cookie:
resp.set_cookie = True
# 用 JSONResponse 显式 set_cookie 后返回模型体
cfg = get_settings().pdf
data = resp.model_dump(mode="json")
response = JSONResponse(data)
response.set_cookie(
key=cfg.cookie_name,
value=new_cookie,
max_age=cfg.cookie_max_age_seconds,
httponly=True,
samesite="lax",
path="/",
)
return response
return resp
@router.get(
"/api/pdf/jobs",
response_model=PdfJobListResponse,
summary="当前用户的任务列表",
description="按 zk_pdf cookie 返回该用户未软删的任务,按创建时间倒序。",
)
def list_jobs(
service: PdfService = Depends(_service),
owner_cookie: str = Depends(_resolve_cookie),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> PdfJobListResponse:
return service.list_for_user(owner_cookie, limit=limit, offset=offset)
@router.get(
"/api/pdf/jobs/{job_id}",
response_model=PdfJobOut,
summary="查询单任务状态(轮询进度)",
)
def get_job(
job_id: int,
service: PdfService = Depends(_service),
owner_cookie: str = Depends(_resolve_cookie),
) -> PdfJobOut:
return service.get_job(job_id, owner_cookie)
@router.get(
"/api/pdf/jobs/{job_id}/download",
summary="下载转换后的 PDF",
description="仅任务状态为 done 且归属本人未软删时可下载。",
)
def download_job(
job_id: int,
service: PdfService = Depends(_service),
owner_cookie: str = Depends(_resolve_cookie),
) -> FileResponse:
_job, path, filename = service.get_output_path(job_id, owner_cookie)
return FileResponse(
path=str(path),
media_type="application/pdf",
filename=filename,
)
@router.delete(
"/api/pdf/jobs/{job_id}",
response_model=DeleteResult,
summary="用户软删任务(不再对用户展示)",
description="仅置 user_deleted 标记,磁盘与 DB 行保留;管理页仍可见并标注已删除。",
)
def delete_job(
job_id: int,
service: PdfService = Depends(_service),
owner_cookie: str = Depends(_resolve_cookie),
) -> DeleteResult:
ok = service.user_delete(job_id, owner_cookie)
if not ok:
raise HTTPException(404, "任务不存在")
return DeleteResult(deleted=True)
# ---------------- 管理侧Basic Auth ----------------
@router.get(
"/api/admin/pdf/jobs",
response_model=PdfJobListResponse,
summary="列出全部转换任务(需鉴权)",
description="管理页使用:含 user_deleted 标记,可看到用户是否已软删。",
)
def admin_list_jobs(
service: PdfService = Depends(_service),
_: str = Depends(require_docs_auth),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> PdfJobListResponse:
return service.list_for_admin(limit=limit, offset=offset)
@router.delete(
"/api/admin/pdf/jobs/{job_id}",
response_model=DeleteResult,
summary="管理员硬删任务(真正删除)",
description="删原始/产物磁盘文件 + UploadedFile 行 + PdfJob 行,不可恢复。",
)
def admin_delete_job(
job_id: int,
service: PdfService = Depends(_service),
_: str = Depends(require_docs_auth),
) -> DeleteResult:
ok = service.admin_delete(job_id)
if not ok:
raise HTTPException(404, "任务不存在")
return DeleteResult(deleted=True)