"""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。 """ 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)