死代码移除: - whiteboard_hub.py: 移除未引用的 reset_hub 单例重置函数 - tunnel_service.py: 移除未引用的 is_port_allowed (逻辑已在 sftp_server 内联) - tunnel_session_dao.py: 移除未引用的 get_active_by_port - pdf_job_dao.py: 移除未用 datetime 导入 - pdf_converter.py: 移除未用 shutil 导入 - pdf_service.py: 移除未用 PdfSubmitResponse 导入 + _do_convert 内未用 hashlib 导入 - upload_html.py: 移除未用 escape 导入 (JS 侧自有 escapeHtml) - pdf_controller.py: 移除 _resolve_cookie 内未用 cfg 局部变量 提前失败/分层修复: - database.py init_db_schema: 建表后用 inspector 校验既有表列与模型一致, 缺列即抛 RuntimeError (fail-fast on schema drift), 避免运行期才暴露 - whiteboard_dao.get_or_create: 仅 IntegrityError 才回滚重读, 其他异常向上抛 (原 except Exception 会掩盖 schema/连接等真实故障) - pdf_service.admin_delete/_safe_delete_file: 改用 PdfJobDAO.delete / UploadedFileDAO.delete, 不再直接操作 job_dao.db / file_dao.db (修复分层契约: DAO 头注释声明 service 不直接操作 session) - PdfJobDAO 新增 delete(job) 方法 日志补全 (8 处 silent catch): - whiteboard_hub.py disconnect/close_board 关闭 ws: logger.debug - whiteboard_controller _safe_send/_safe_close: logger.debug - sftp_server _close_tunnel_dao/读用户名: logger.debug - sftp_server validate_public_key: logger.warning (auth 路径, 避免静默失败) 文档: - 新增 docs/routes.md, docs/configuration.md, docs/error-handling.md - README.md 精简为简介/结构/外部依赖/apache2 配置/Ubuntu 安装/docs 链接
182 lines
6.2 KiB
Python
182 lines
6.2 KiB
Python
"""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)
|