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:
56
app/models/pdf_job.py
Normal file
56
app/models/pdf_job.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""PDF 转换任务实体。
|
||||
|
||||
一个任务记录一次「上传文件 -> 转为 PDF」的转换。原始上传文件与转换产物 PDF
|
||||
均复用 UploadedFile 存储(落盘 + 元数据入库),本表只记录两者关系与转换状态,
|
||||
不重复实现存储逻辑。
|
||||
|
||||
删除语义(两级):
|
||||
用户软删(user_deleted=true)-- 用户页不再展示,但磁盘与 DB 行保留;
|
||||
管理页仍可见且标注「已删除」。
|
||||
管理员硬删 -- 删原始/产物磁盘文件 + UploadedFile 行 + 本表行,真正删除。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class PdfJob(Base):
|
||||
__tablename__ = "pdf_job"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
# 用户标识:httpOnly cookie 值(uuid4.hex),用户凭此查看自己的任务
|
||||
owner_cookie: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
# 原始上传文件(复用 UploadedFile 存储)
|
||||
source_file_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
source_filename: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
source_size: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
# 转换产物 PDF(复用 UploadedFile 存储);转换完成前为 NULL
|
||||
output_file_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, default=None)
|
||||
# pending(排队) / converting(转换中) / done(完成) / failed(失败)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
# 转换进度 0-100,供前端轮询显示
|
||||
progress: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 失败原因(status=failed 时填写)
|
||||
error_message: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
# 用户软删标记:true=用户已从其页面删除,不再对用户展示
|
||||
user_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
# 用户软删时间(管理页展示「是否已删除」时用)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<PdfJob id={self.id} status={self.status} "
|
||||
f"src={self.source_filename!r} progress={self.progress}>"
|
||||
)
|
||||
Reference in New Issue
Block a user