Files
zTools2/app/controllers/pdf_controller.py
zikai 9af28f41b4 refactor: 清理死代码/提前失败/日志/高内聚低耦合
死代码移除:
- 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 链接
2026-07-28 11:34:35 +08:00

182 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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