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:
@@ -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",
|
||||
|
||||
182
app/controllers/pdf_controller.py
Normal file
182
app/controllers/pdf_controller.py
Normal 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)
|
||||
Reference in New Issue
Block a user