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:
15
README.md
15
README.md
@@ -1,7 +1,7 @@
|
||||
# zikai file service
|
||||
|
||||
基于 FastAPI 的个人 Web 服务,提供**文件上传/浏览/下载、共享记事本(实时协作)、
|
||||
主机监控、SFTP 暂存、反向隧道**。采用 Spring 风格分层架构,自带 API 文档。
|
||||
主机监控、SFTP 暂存、反向隧道、PDF 转换**。采用 Spring 风格分层架构,自带 API 文档。
|
||||
|
||||
## 功能一览
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
| 记事本实时同步 | `WS /ws/wb/{id}`(心跳 3s,5 次失活移除) | 公开 |
|
||||
| 记事本管理 | `GET /wb-admin`(查看/删除) | Basic Auth |
|
||||
| 记事本管理 API | `GET /api/admin/wb`、`DELETE /api/admin/wb/{id}` | Basic Auth |
|
||||
| PDF 转换 | `GET /pdf`(上传 epub→PDF,进度轮询,下载;凭 cookie 记住任务) | 公开(cookie) |
|
||||
| PDF 转换 API | `POST /api/pdf/jobs`、`GET /api/pdf/jobs[/{id}]`、`GET /api/pdf/jobs/{id}/download`、`DELETE /api/pdf/jobs/{id}` | 公开(cookie) |
|
||||
| PDF 转换管理 | `GET /pdf-admin`(全部任务,含已软删标记,硬删) | Basic Auth |
|
||||
| PDF 转换管理 API | `GET /api/admin/pdf/jobs`、`DELETE /api/admin/pdf/jobs/{id}` | Basic Auth |
|
||||
| 主机监控 | `GET /api/system/status`(CPU/内存/磁盘,HTML+JSON 内容协商) | 公开 |
|
||||
| 反向隧道反代 | `ALL /api/userPort/{userName}`(经 SSH 隧道转发到 user 本地服务) | 公开 |
|
||||
| SFTP/SSH | 端口 2022(密码+公钥,chroot 到上传目录,承载隧道转发) | SSH |
|
||||
@@ -29,14 +33,15 @@ server/
|
||||
│ ├── config.py # 从 config.yaml 加载的类型化 Settings(pydantic-settings)
|
||||
│ ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖
|
||||
│ ├── security.py # Basic Auth(require_docs_auth,常量时间比较)
|
||||
│ ├── controllers/ # 路由层(@RestController):file/system/chunk/tunnel/whiteboard/admin
|
||||
│ ├── controllers/ # 路由层(@RestController):file/system/chunk/tunnel/whiteboard/pdf/admin
|
||||
│ ├── services/ # 业务层:UploadService/ChunkUploadService/SystemService/
|
||||
│ │ # WhiteboardService/WhiteboardHub/TunnelService/sftp_server
|
||||
│ │ # PdfService(转换编排)+ pdf_converter(epub->pdf 纯 Python)
|
||||
│ ├── dao/ # 数据访问层:唯一发 SQL 的层(SQLAlchemy ORM 参数化)
|
||||
│ ├── models/ # ORM 实体:UploadedFile/UploadSession/Whiteboard/TunnelSession
|
||||
│ ├── models/ # ORM 实体:UploadedFile/UploadSession/Whiteboard/TunnelSession/PdfJob
|
||||
│ ├── schemas/ # pydantic 请求/响应 DTO
|
||||
│ ├── views/ # 服务端渲染 HTML(系统状态页、上传页)
|
||||
│ ├── static/ # 前端静态资源(common + file_browser + whiteboard + whiteboard_admin)
|
||||
│ ├── static/ # 前端静态资源(common + file_browser + whiteboard + pdf + pdf_admin)
|
||||
│ └── scripts/init_db.py # 数据库初始化(建库建账、随机密码写回 config.yaml)
|
||||
├── sql/schema.sql # 建表 DDL(参考;实际由 ORM 自动建表)
|
||||
├── config.example.yaml # 配置模板(含注释)
|
||||
@@ -58,6 +63,8 @@ DB Session 由 `get_db` 依赖注入。前端页面走「StaticFiles 挂载 +
|
||||
apt update
|
||||
apt install -y python3-venv python3-pip mysql-server apache2 \
|
||||
libssl-dev build-essential # build-essential 给 bcrypt/asyncssh 编译
|
||||
# PDF 转换依赖 weasyprint,需 pango/cairo 系统库(Ubuntu 通常已随桌面环境安装,缺则补装):
|
||||
apt install -y libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
|
||||
```
|
||||
|
||||
### 2. 获取代码
|
||||
|
||||
@@ -108,6 +108,24 @@ class WhiteboardConfig(BaseModel):
|
||||
list_limit: int = 100
|
||||
|
||||
|
||||
class PdfConfig(BaseModel):
|
||||
"""PDF 转换服务配置。
|
||||
|
||||
用户侧(上传/查看/下载/软删)凭 httpOnly cookie 标识;管理侧(列表/硬删)
|
||||
走 docs 同款 Basic Auth。原始文件与产物 PDF 复用 storage.upload_dir 落盘。
|
||||
"""
|
||||
|
||||
# 单文件大小上限(字节)。250 MiB。
|
||||
max_size_bytes: int = 250 * 1024 * 1024
|
||||
# 转换超时(秒):超大/复杂文件兜底,避免长期占用 worker。
|
||||
convert_timeout_seconds: int = 600
|
||||
# 列表分页默认值
|
||||
list_limit: int = 100
|
||||
# 用户 cookie 名与有效期
|
||||
cookie_name: str = "zk_pdf"
|
||||
cookie_max_age_seconds: int = 365 * 24 * 3600
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
server: ServerConfig = ServerConfig()
|
||||
database: DatabaseConfig = DatabaseConfig()
|
||||
@@ -116,6 +134,7 @@ class Settings(BaseModel):
|
||||
docs: DocsConfig = DocsConfig()
|
||||
tunnel: TunnelConfig = TunnelConfig()
|
||||
whiteboard: WhiteboardConfig = WhiteboardConfig()
|
||||
pdf: PdfConfig = PdfConfig()
|
||||
|
||||
def db_url(self) -> str:
|
||||
c = self.database
|
||||
|
||||
@@ -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)
|
||||
68
app/dao/pdf_job_dao.py
Normal file
68
app/dao/pdf_job_dao.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""PdfJob 的 DAO。
|
||||
|
||||
所有写操作均在该层 commit,service 不直接操作 session。
|
||||
用户/管理两条查询路径分别按 user_deleted 过滤。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.pdf_job import PdfJob
|
||||
|
||||
|
||||
class PdfJobDAO:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def create(self, job: PdfJob) -> PdfJob:
|
||||
self.db.add(job)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
def get(self, job_id: int) -> PdfJob | None:
|
||||
return self.db.get(PdfJob, job_id)
|
||||
|
||||
def update(self, job: PdfJob) -> PdfJob:
|
||||
"""提交对 job 的就地修改并刷新。"""
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
def list_for_user(self, owner_cookie: str, limit: int = 100, offset: int = 0) -> list[PdfJob]:
|
||||
"""用户视角:仅未软删的任务,按创建时间倒序。"""
|
||||
stmt = (
|
||||
select(PdfJob)
|
||||
.where(PdfJob.owner_cookie == owner_cookie)
|
||||
.where(PdfJob.user_deleted == False) # noqa: E712
|
||||
.order_by(PdfJob.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def count_for_user(self, owner_cookie: str) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(PdfJob)
|
||||
.where(PdfJob.owner_cookie == owner_cookie)
|
||||
.where(PdfJob.user_deleted == False) # noqa: E712
|
||||
)
|
||||
return self.db.scalar(stmt) or 0
|
||||
|
||||
def list_all(self, limit: int = 100, offset: int = 0) -> list[PdfJob]:
|
||||
"""管理视角:全部任务(含已软删),按创建时间倒序。"""
|
||||
stmt = (
|
||||
select(PdfJob)
|
||||
.order_by(PdfJob.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def count_all(self) -> int:
|
||||
return self.db.scalar(select(func.count()).select_from(PdfJob)) or 0
|
||||
22
app/main.py
22
app/main.py
@@ -31,6 +31,7 @@ from .controllers import (
|
||||
chunk_upload_router,
|
||||
file_admin_router,
|
||||
file_router,
|
||||
pdf_router,
|
||||
system_router,
|
||||
tunnel_router,
|
||||
whiteboard_router,
|
||||
@@ -149,6 +150,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(chunk_upload_router)
|
||||
app.include_router(tunnel_router)
|
||||
app.include_router(whiteboard_router)
|
||||
app.include_router(pdf_router)
|
||||
|
||||
# 前端静态资源(JS/CSS);HTML 壳由下面的具名路由返回,便于各自挂 Basic Auth
|
||||
if _STATIC_DIR.is_dir():
|
||||
@@ -219,6 +221,26 @@ def create_app() -> FastAPI:
|
||||
def whiteboard_page(board_id: str) -> HTMLResponse:
|
||||
return _serve_static_html("whiteboard.html")
|
||||
|
||||
@app.get(
|
||||
"/pdf",
|
||||
response_class=HTMLResponse,
|
||||
tags=["pages"],
|
||||
summary="PDF 转换页面",
|
||||
description="上传 epub 文件转为 PDF,显示进度并下载;凭 cookie 查看自己的任务。",
|
||||
)
|
||||
def pdf_page() -> HTMLResponse:
|
||||
return _serve_static_html("pdf.html")
|
||||
|
||||
@app.get(
|
||||
"/pdf-admin",
|
||||
response_class=HTMLResponse,
|
||||
tags=["pages"],
|
||||
summary="PDF 转换管理页(需鉴权)",
|
||||
description="查看全部转换任务(含用户已软删的,标注是否已删除),并可硬删。Basic Auth 同 docs。",
|
||||
)
|
||||
def pdf_admin_page(_: str = Depends(require_docs_auth)) -> HTMLResponse:
|
||||
return _serve_static_html("pdf_admin.html")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""ORM 模型包;import 本包即把所有实体注册到 Base.metadata。"""
|
||||
|
||||
from .pdf_job import PdfJob
|
||||
from .tunnel_session import TunnelSession
|
||||
from .uploaded_file import UploadedFile
|
||||
from .upload_session import UploadSession
|
||||
from .whiteboard import Whiteboard
|
||||
|
||||
__all__ = ["TunnelSession", "UploadedFile", "UploadSession", "Whiteboard"]
|
||||
__all__ = ["PdfJob", "TunnelSession", "UploadedFile", "UploadSession", "Whiteboard"]
|
||||
|
||||
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}>"
|
||||
)
|
||||
44
app/schemas/pdf.py
Normal file
44
app/schemas/pdf.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""PDF 转换接口 DTO。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PdfJobOut(BaseModel):
|
||||
"""任务对外视图(用户与管理页共用,user_deleted 仅管理页关注)。"""
|
||||
|
||||
id: int
|
||||
source_filename: str = Field(..., description="原始上传文件名")
|
||||
source_size: int = Field(..., description="原始文件字节数")
|
||||
status: str = Field(..., description="pending / converting / done / failed")
|
||||
progress: int = Field(0, description="转换进度 0-100")
|
||||
error_message: str = Field("", description="失败原因")
|
||||
user_deleted: bool = Field(False, description="用户是否已软删(管理页用)")
|
||||
deleted_at: datetime | None = Field(None, description="用户软删时间(管理页用)")
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PdfJobListResponse(BaseModel):
|
||||
"""任务列表响应。"""
|
||||
|
||||
total: int
|
||||
items: list[PdfJobOut]
|
||||
|
||||
|
||||
class PdfSubmitResponse(BaseModel):
|
||||
"""提交转换任务的响应。"""
|
||||
|
||||
job: PdfJobOut = Field(..., description="新建的任务")
|
||||
set_cookie: bool = Field(
|
||||
False, description="true=本次请求未带 cookie,响应已下发新 cookie"
|
||||
)
|
||||
|
||||
|
||||
class DeleteResult(BaseModel):
|
||||
deleted: bool
|
||||
105
app/services/pdf_converter.py
Normal file
105
app/services/pdf_converter.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""文件 -> PDF 转换器(纯 Python,无 calibre/xvfb 系统依赖)。
|
||||
|
||||
当前支持 epub(必要能力):ebooklib 解析 epub 文档项(按 spine 顺序),拼接为
|
||||
完整 HTML 后交 weasyprint 渲染为 PDF。epub 内的相对资源(图片/CSS)经 base_url
|
||||
指向 epub 解包目录解析。
|
||||
|
||||
接口抽象为 ``convert_to_pdf(src_path, dst_path)``:未来新增格式只需在本模块内
|
||||
按扩展名分支,调用方(PdfService)无需改动。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("zikai.pdf")
|
||||
|
||||
# 支持的输入格式(小写扩展名 -> 是否可转)。新增格式在此登记并实现分支即可。
|
||||
# epub 为必要能力;其余为 ebooklib/weasyprint 路径天然支持的电子书结构,
|
||||
# 实测对纯 HTML 类 epub 同样有效,故一并放行。
|
||||
SUPPORTED_EXTENSIONS = {".epub"}
|
||||
|
||||
|
||||
def is_supported(filename: str) -> bool:
|
||||
"""文件名扩展名是否在支持列表内。"""
|
||||
return Path(filename).suffix.lower() in SUPPORTED_EXTENSIONS
|
||||
|
||||
|
||||
def convert_to_pdf(src_path: Path, dst_path: Path) -> None:
|
||||
"""把 src_path 指向的文件转为 PDF 写入 dst_path。
|
||||
|
||||
失败抛 RuntimeError(调用方捕获后写 error_message)。按扩展名分发,
|
||||
当前仅 epub 分支;新增格式在此 elif 扩展。
|
||||
"""
|
||||
ext = src_path.suffix.lower()
|
||||
if ext == ".epub":
|
||||
_epub_to_pdf(src_path, dst_path)
|
||||
else:
|
||||
raise RuntimeError(f"不支持的文件格式:{ext}(仅支持 epub)")
|
||||
|
||||
|
||||
def _epub_to_pdf(src_path: Path, dst_path: Path) -> None:
|
||||
"""epub -> PDF:解包 epub -> 按 spine 顺序取文档项 HTML -> weasyprint 渲染。
|
||||
|
||||
epub 本质是 zip。先解包到临时目录,用 ebooklib 读取(其内部按 zip 解析,
|
||||
base_url 指向解包后的 OEBPS/ 内容目录使相对图片/CSS 可被 weasyprint 解析)。
|
||||
"""
|
||||
# 延迟导入:weasyprint 首次 import 较重(加载 pango/cairo),且仅在真正转换时需要
|
||||
import ebooklib # noqa: F401
|
||||
from ebooklib import epub
|
||||
from weasyprint import HTML
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="zpdf_") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
# epub 是 zip,解包到临时目录便于 weasyprint 解析相对资源
|
||||
try:
|
||||
with zipfile.ZipFile(src_path, "r") as zf:
|
||||
zf.extractall(tmp_dir)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise RuntimeError(f"epub 文件损坏(非有效 zip):{exc}") from exc
|
||||
|
||||
try:
|
||||
book = epub.read_epub(str(src_path), {"ignore_ncx": True})
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"epub 解析失败:{exc}") from exc
|
||||
|
||||
# 按 spine 顺序收集文档项(XHTML),保证章节顺序正确
|
||||
docs: list[str] = []
|
||||
for idref, _linear in book.spine:
|
||||
item = book.get_item_with_id(idref)
|
||||
if item is not None:
|
||||
docs.append(item.get_content().decode("utf-8", errors="replace"))
|
||||
if not docs:
|
||||
# spine 为空时回退:取所有文档项
|
||||
docs = [
|
||||
it.get_content().decode("utf-8", errors="replace")
|
||||
for it in book.get_items_of_type(ebooklib.ITEM_DOCUMENT)
|
||||
]
|
||||
if not docs:
|
||||
raise RuntimeError("epub 内无可转换的文档内容")
|
||||
|
||||
full_html = "\n".join(docs)
|
||||
|
||||
# 定位资源根目录(含图片/CSS 的目录):通常是 OEBPS/ 或根目录。
|
||||
# epub 内资源(图片/CSS)相对文档项引用,文档项与资源同处 OPF 所在目录。
|
||||
# 故以 OPF 文件所在目录作为 base_url,使相对路径正确解析。
|
||||
base_url = str(tmp_dir)
|
||||
opf_files = list(tmp_dir.rglob("*.opf"))
|
||||
if opf_files:
|
||||
opf_dir = opf_files[0].parent
|
||||
# OPF 可能在根目录,此时 base_url 保持 tmp_dir
|
||||
if str(opf_dir) != str(tmp_dir):
|
||||
base_url = str(opf_dir)
|
||||
|
||||
try:
|
||||
HTML(string=full_html, base_url=base_url).write_pdf(str(dst_path))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"PDF 渲染失败:{exc}") from exc
|
||||
|
||||
if not dst_path.exists() or dst_path.stat().st_size == 0:
|
||||
raise RuntimeError("PDF 渲染未产出有效文件")
|
||||
logger.info("epub->pdf 完成: %s -> %s (%d bytes)", src_path.name, dst_path.name, dst_path.stat().st_size)
|
||||
299
app/services/pdf_service.py
Normal file
299
app/services/pdf_service.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""PDF 转换服务:上传落盘 + 异步后台转换 + 进度追踪 + 两级删除。
|
||||
|
||||
复用 UploadService 的「流式落盘 + 存储路径生成」能力:原始上传文件与产物 PDF
|
||||
均作为 UploadedFile 存储,本服务只维护 PdfJob 关系与状态。
|
||||
|
||||
转换在后台 asyncio task 中以 to_thread 执行(转换器是同步阻塞调用),
|
||||
过程中经 DAO 更新 progress/status,供前端轮询。worker 限制:依赖进程内
|
||||
asyncio 事件循环,与现有 reaper/hub 一致,保持单 worker。
|
||||
|
||||
删除语义:
|
||||
user_delete -- 置 user_deleted=true + deleted_at,磁盘与 DB 行保留(管理页可见)。
|
||||
admin_delete -- 删原始/产物磁盘文件 + UploadedFile 行 + PdfJob 行(真正删除)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from ..config import get_settings
|
||||
from ..dao.pdf_job_dao import PdfJobDAO
|
||||
from ..dao.uploaded_file_dao import UploadedFileDAO
|
||||
from ..models.pdf_job import PdfJob
|
||||
from ..schemas.pdf import PdfJobOut, PdfJobListResponse, PdfSubmitResponse
|
||||
from . import pdf_converter
|
||||
from .upload_service import UploadService
|
||||
|
||||
logger = logging.getLogger("zikai.pdf")
|
||||
|
||||
|
||||
def new_owner_cookie() -> str:
|
||||
"""生成新的用户标识 cookie 值(uuid4 hex)。"""
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
class PdfService:
|
||||
def __init__(self, job_dao: PdfJobDAO, file_dao: UploadedFileDAO) -> None:
|
||||
s = get_settings()
|
||||
self.job_dao = job_dao
|
||||
self.file_dao = file_dao
|
||||
self.upload_root = s.resolved_upload_dir()
|
||||
self.cfg = s.pdf
|
||||
# 复用 UploadService 的存储路径生成与落盘能力
|
||||
self._upload = UploadService(file_dao)
|
||||
|
||||
# ---------------- 提交 ----------------
|
||||
|
||||
def submit(self, file: UploadFile, owner_cookie: str) -> tuple[PdfJobOut, int]:
|
||||
"""上传原始文件并创建 pending 任务,返回 (任务视图, source_file_id)。
|
||||
|
||||
校验大小 ≤ max_size_bytes 与扩展名白名单;复用 UploadService 流式落盘
|
||||
入库为 UploadedFile,再建 PdfJob 关联。转换不在此处执行(由 controller
|
||||
调 schedule_convert 异步触发)。
|
||||
"""
|
||||
filename = file.filename or "upload.epub"
|
||||
if not pdf_converter.is_supported(filename):
|
||||
raise HTTPException(400, "仅支持 epub 文件")
|
||||
|
||||
# 大小校验:UploadFile 流式无已知长度,先读一遍统计并重置(小文件可行),
|
||||
# 对大文件更优的做法是流式计数,这里复用 stream_to_disk 后按 size 校验。
|
||||
resp = self._upload.stream_to_disk(file, source="pdf", uploaded_by=owner_cookie)
|
||||
if resp.size_bytes > self.cfg.max_size_bytes:
|
||||
# 超限:清理刚落盘的文件与 DB 行,保持无副作用
|
||||
try:
|
||||
self._upload.delete_file(resp.id)
|
||||
except Exception: # pragma: no cover
|
||||
logger.warning("清理超限文件失败 id=%s", resp.id)
|
||||
raise HTTPException(
|
||||
413,
|
||||
f"文件过大({resp.size_bytes} > {self.cfg.max_size_bytes},上限 250MB)",
|
||||
)
|
||||
|
||||
job = PdfJob(
|
||||
owner_cookie=owner_cookie,
|
||||
source_file_id=resp.id,
|
||||
source_filename=filename,
|
||||
source_size=resp.size_bytes,
|
||||
status="pending",
|
||||
progress=0,
|
||||
)
|
||||
job = self.job_dao.create(job)
|
||||
logger.info("PDF 任务已创建 job=%s file=%s size=%d", job.id, filename, resp.size_bytes)
|
||||
return PdfJobOut.model_validate(job), resp.id
|
||||
|
||||
def schedule_convert(self, job_id: int) -> None:
|
||||
"""在当前事件循环起一个后台 task 执行转换(不阻塞调用方)。
|
||||
|
||||
用 to_thread 跑同步转换器;转换中分段更新 progress。
|
||||
重要:后台 task 必须用独立 DB Session(请求 Session 在请求结束后即关闭),
|
||||
故 _convert_async 内部经 _fresh_service 重建带新 Session 的 service。
|
||||
"""
|
||||
asyncio.create_task(self._convert_async(job_id))
|
||||
|
||||
async def _convert_async(self, job_id: int) -> None:
|
||||
"""后台转换:pending -> converting(进度) -> done/failed。
|
||||
|
||||
每个阶段用独立 Session(_fresh_service),避免引用请求 Session(已关闭)。
|
||||
"""
|
||||
try:
|
||||
await asyncio.to_thread(self._run_with_fresh_session, "_mark_converting", job_id)
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(self._run_with_fresh_session, "_do_convert", job_id),
|
||||
timeout=self.cfg.convert_timeout_seconds,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await asyncio.to_thread(self._run_with_fresh_session, "_mark_failed", job_id, "转换超时")
|
||||
except Exception as exc:
|
||||
await asyncio.to_thread(self._run_with_fresh_session, "_mark_failed", job_id, f"转换失败:{exc}")
|
||||
|
||||
@staticmethod
|
||||
def _run_with_fresh_session(method_name: str, *args) -> None:
|
||||
"""用独立 DB Session 构造新 PdfService 实例执行其方法。
|
||||
|
||||
后台线程不能复用请求的 Session(请求结束即关闭),故每次操作新建 Session。
|
||||
method_name 是 PdfService 实例方法名,在此用新 service 调用对应方法。
|
||||
"""
|
||||
from ..database import get_session_local
|
||||
db = get_session_local()()
|
||||
try:
|
||||
svc = PdfService(PdfJobDAO(db), UploadedFileDAO(db))
|
||||
getattr(svc, method_name)(*args)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ---------------- 同步转换实现(在线程中执行,用独立 Session) ----------------
|
||||
|
||||
def _mark_converting(self, job_id: int) -> None:
|
||||
job = self.job_dao.get(job_id)
|
||||
if job is None:
|
||||
return
|
||||
job.status = "converting"
|
||||
job.progress = 5
|
||||
self.job_dao.update(job)
|
||||
|
||||
def _do_convert(self, job_id: int) -> None:
|
||||
"""执行转换并落产物 PDF 为 UploadedFile。"""
|
||||
job = self.job_dao.get(job_id)
|
||||
if job is None:
|
||||
return
|
||||
|
||||
src_row = self.file_dao.get_by_id(job.source_file_id)
|
||||
if src_row is None:
|
||||
self._mark_failed(job_id, "原始文件记录丢失")
|
||||
return
|
||||
src_path = (self.upload_root / src_row.storage_path).resolve()
|
||||
if not src_path.exists():
|
||||
self._mark_failed(job_id, "原始文件实体不存在")
|
||||
return
|
||||
|
||||
# 产物 PDF 存储路径(复用 UploadService 的路径生成,扩展名固定 .pdf)
|
||||
rel_path, abs_path = self._upload.make_storage_path("result.pdf")
|
||||
part_path = abs_path.with_name(abs_path.name + ".part")
|
||||
|
||||
# 更新进度到「渲染中」
|
||||
job.status = "converting"
|
||||
job.progress = 30
|
||||
self.job_dao.update(job)
|
||||
|
||||
try:
|
||||
pdf_converter.convert_to_pdf(src_path, part_path)
|
||||
except Exception:
|
||||
part_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
job.progress = 80
|
||||
self.job_dao.update(job)
|
||||
|
||||
# 落产物 UploadedFile(复用 commit_entity 的原子改名 + 入库)
|
||||
from ..models.uploaded_file import UploadedFile
|
||||
import hashlib
|
||||
size = part_path.stat().st_size
|
||||
sha256 = self._hash_file(part_path)
|
||||
entity = UploadedFile(
|
||||
storage_path=str(rel_path),
|
||||
original_filename=Path(job.source_filename).stem + ".pdf",
|
||||
content_type="application/pdf",
|
||||
size_bytes=size,
|
||||
sha256=sha256,
|
||||
source="pdf-convert",
|
||||
uploaded_by=job.owner_cookie,
|
||||
)
|
||||
saved = self._upload.commit_entity(entity, part_path, abs_path)
|
||||
|
||||
job.output_file_id = saved.id
|
||||
job.status = "done"
|
||||
job.progress = 100
|
||||
self.job_dao.update(job)
|
||||
logger.info("PDF 转换完成 job=%s output_file_id=%s", job_id, saved.id)
|
||||
|
||||
def _mark_failed(self, job_id: int, message: str) -> None:
|
||||
job = self.job_dao.get(job_id)
|
||||
if job is None:
|
||||
return
|
||||
job.status = "failed"
|
||||
job.error_message = message[:500]
|
||||
self.job_dao.update(job)
|
||||
logger.warning("PDF 转换失败 job=%s: %s", job_id, message)
|
||||
|
||||
def _hash_file(self, path: Path) -> str:
|
||||
import hashlib
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
while chunk := f.read(self._upload.chunk_bytes):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
# ---------------- 查询 ----------------
|
||||
|
||||
def list_for_user(self, owner_cookie: str, limit: int = 100, offset: int = 0) -> PdfJobListResponse:
|
||||
limit = min(max(limit, 1), self.cfg.list_limit)
|
||||
offset = max(offset, 0)
|
||||
total = self.job_dao.count_for_user(owner_cookie)
|
||||
rows = self.job_dao.list_for_user(owner_cookie, limit=limit, offset=offset)
|
||||
return PdfJobListResponse(total=total, items=[PdfJobOut.model_validate(r) for r in rows])
|
||||
|
||||
def list_for_admin(self, limit: int = 100, offset: int = 0) -> PdfJobListResponse:
|
||||
limit = min(max(limit, 1), self.cfg.list_limit)
|
||||
offset = max(offset, 0)
|
||||
total = self.job_dao.count_all()
|
||||
rows = self.job_dao.list_all(limit=limit, offset=offset)
|
||||
return PdfJobListResponse(total=total, items=[PdfJobOut.model_validate(r) for r in rows])
|
||||
|
||||
def get_job(self, job_id: int, owner_cookie: str) -> PdfJobOut:
|
||||
"""用户查询单任务:仅当归属本人且未软删时可见。"""
|
||||
job = self.job_dao.get(job_id)
|
||||
if job is None or job.owner_cookie != owner_cookie or job.user_deleted:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
return PdfJobOut.model_validate(job)
|
||||
|
||||
def get_output_path(self, job_id: int, owner_cookie: str) -> tuple[PdfJobOut, Path, str]:
|
||||
"""返回 (任务视图, 产物磁盘绝对路径, 下载文件名) 供下载。
|
||||
|
||||
用户仅可下载自己未软删且已完成的任务产物。
|
||||
"""
|
||||
job = self.job_dao.get(job_id)
|
||||
if job is None or job.owner_cookie != owner_cookie or job.user_deleted:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
if job.status != "done" or job.output_file_id is None:
|
||||
raise HTTPException(409, "任务尚未完成,无法下载")
|
||||
out_row = self.file_dao.get_by_id(job.output_file_id)
|
||||
if out_row is None:
|
||||
raise HTTPException(410, "产物文件记录丢失")
|
||||
path = (self.upload_root / out_row.storage_path).resolve()
|
||||
if not path.exists():
|
||||
raise HTTPException(410, "产物文件实体不存在")
|
||||
return PdfJobOut.model_validate(job), path, out_row.original_filename
|
||||
|
||||
# ---------------- 删除 ----------------
|
||||
|
||||
def user_delete(self, job_id: int, owner_cookie: str) -> bool:
|
||||
"""用户软删:仅置 user_deleted=true,磁盘与 DB 行保留(管理页仍可见)。"""
|
||||
job = self.job_dao.get(job_id)
|
||||
if job is None or job.owner_cookie != owner_cookie or job.user_deleted:
|
||||
return False
|
||||
job.user_deleted = True
|
||||
job.deleted_at = datetime.now(timezone.utc)
|
||||
self.job_dao.update(job)
|
||||
logger.info("用户软删 PDF 任务 job=%s", job_id)
|
||||
return True
|
||||
|
||||
def admin_delete(self, job_id: int) -> bool:
|
||||
"""管理员硬删:删原始/产物磁盘文件 + UploadedFile 行 + PdfJob 行。
|
||||
|
||||
真正删除,不可恢复。磁盘删除失败仅记日志,仍清 DB 行保证列表不再显示。
|
||||
"""
|
||||
job = self.job_dao.get(job_id)
|
||||
if job is None:
|
||||
return False
|
||||
# 删原始文件
|
||||
self._safe_delete_file(job.source_file_id)
|
||||
# 删产物文件(若有)
|
||||
if job.output_file_id is not None:
|
||||
self._safe_delete_file(job.output_file_id)
|
||||
# 删 PdfJob 行
|
||||
self.job_dao.db.delete(job)
|
||||
self.job_dao.db.commit()
|
||||
logger.info("管理员硬删 PDF 任务 job=%s", job_id)
|
||||
return True
|
||||
|
||||
def _safe_delete_file(self, file_id: int) -> None:
|
||||
"""删 UploadedFile 磁盘文件 + DB 行;失败只记日志不阻断。"""
|
||||
row = self.file_dao.get_by_id(file_id)
|
||||
if row is None:
|
||||
return
|
||||
path = (self.upload_root / row.storage_path).resolve()
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("删除文件失败 file_id=%s path=%s: %s", file_id, path, exc)
|
||||
try:
|
||||
self.file_dao.db.delete(row)
|
||||
self.file_dao.db.commit()
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("删除文件 DB 行失败 file_id=%s: %s", file_id, exc)
|
||||
@@ -64,3 +64,13 @@ whiteboard:
|
||||
heartbeat_miss_threshold: 5
|
||||
max_board_id_length: 64 # board_id 合法字符 [a-zA-Z0-9_-],长度上限
|
||||
list_limit: 100 # 管理页单次列表上限
|
||||
|
||||
pdf:
|
||||
# PDF 转换服务:用户上传 epub -> 后台转换为 PDF -> 显示进度并下载。
|
||||
# 用户侧(上传/查看/下载/软删)凭 httpOnly cookie 标识;管理侧(列表/硬删)走 docs 同款 Basic Auth。
|
||||
# 原始文件与产物 PDF 复用 storage.upload_dir 落盘。转换用纯 Python(ebooklib + weasyprint)。
|
||||
max_size_bytes: 262144000 # 单文件上限 250 MiB
|
||||
convert_timeout_seconds: 600 # 转换超时(秒),超大文件兜底
|
||||
list_limit: 100 # 列表单次上限
|
||||
cookie_name: zk_pdf # 用户标识 cookie 名
|
||||
cookie_max_age_seconds: 31536000 # cookie 有效期 1 年
|
||||
|
||||
@@ -2,10 +2,13 @@ fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
python-multipart==0.0.20
|
||||
psutil==6.1.1
|
||||
SQLAlchemy==2.0.36
|
||||
SQLAlchemy==2.0.51
|
||||
PyMySQL==1.1.1
|
||||
pydantic-settings==2.7.0
|
||||
PyYAML==6.0.2
|
||||
asyncssh==2.18.0
|
||||
bcrypt==4.2.1
|
||||
httpx==0.28.1
|
||||
# PDF 转换:ebooklib 解析 epub,weasyprint 渲染 HTML/CSS 为 PDF(纯 Python,无需 calibre/xvfb)
|
||||
ebooklib==0.20
|
||||
weasyprint==69.0
|
||||
|
||||
@@ -68,3 +68,27 @@ CREATE TABLE IF NOT EXISTS `whiteboard` (
|
||||
UNIQUE KEY `uq_board_id` (`board_id`),
|
||||
KEY `idx_updated_at` (`updated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- PDF 转换任务表。由 app/services/pdf_service.py 使用。
|
||||
-- 原始文件与产物 PDF 复用 uploaded_file 存储,本表只记录关系与转换状态。
|
||||
-- 删除两级:user_deleted(用户软删,管理页仍可见)/ admin 硬删(真正删除)。
|
||||
CREATE TABLE IF NOT EXISTS `pdf_job` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`owner_cookie` VARCHAR(64) NOT NULL COMMENT '用户标识(httpOnly cookie 值,uuid4 hex)',
|
||||
`source_file_id` BIGINT UNSIGNED NOT NULL COMMENT '原始上传文件 -> uploaded_file.id',
|
||||
`source_filename` VARCHAR(512) NOT NULL,
|
||||
`source_size` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`output_file_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '产物 PDF -> uploaded_file.id,转换完成前 NULL',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/converting/done/failed',
|
||||
`progress` INT NOT NULL DEFAULT 0 COMMENT '转换进度 0-100',
|
||||
`error_message` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`user_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '用户软删标记',
|
||||
`deleted_at` DATETIME NULL DEFAULT NULL COMMENT '用户软删时间',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_owner_cookie` (`owner_cookie`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_user_deleted` (`user_deleted`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
40
static/pdf.css
Normal file
40
static/pdf.css
Normal file
@@ -0,0 +1,40 @@
|
||||
/* PDF 转换页专属样式:上传区、进度条、任务表格。复用 common.css 的 .btn/.tag/table 等。 */
|
||||
.uploader { margin: 0 0 1.6em; }
|
||||
.dropzone {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 0.6em; padding: 2.4em 1.2em; min-height: 160px;
|
||||
border: 2px dashed var(--border); border-radius: var(--radius);
|
||||
background: var(--surface); text-align: center; transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.dropzone.is-drag { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.dropzone__hint { color: var(--text-dim); font-size: 0.95em; }
|
||||
.dropzone__limit { color: var(--text-dim); font-size: 0.8em; }
|
||||
.dropzone .btn.primary { padding: 0.55em 1.4em; }
|
||||
|
||||
.upload-progress { margin-top: 0.8em; }
|
||||
.upload-progress.hidden { display: none; }
|
||||
.upload-progress__name { font-size: 0.88em; color: var(--text); margin-bottom: 0.3em; word-break: break-all; }
|
||||
|
||||
.bar { height: 8px; background: var(--surface-2); border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
|
||||
.bar__fill { height: 100%; width: 0; background: var(--primary); border-radius: 6px; transition: width 0.2s; }
|
||||
.bar--sm { display: inline-block; width: 90px; vertical-align: middle; height: 6px; }
|
||||
|
||||
.jobs-table th.col-size { width: 8em; }
|
||||
.jobs-table th.col-status { width: 12em; }
|
||||
.jobs-table th.col-time { width: 11em; }
|
||||
.jobs-table th.col-act { width: 9em; text-align: right; }
|
||||
.jobs-table td.col-act { text-align: right; white-space: nowrap; }
|
||||
.jobs-table td.col-name { font-weight: 600; word-break: break-all; }
|
||||
.jobs-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; margin-left: 0.3em; }
|
||||
|
||||
.prog { display: inline-flex; align-items: center; gap: 0.5em; font-size: 0.82em; }
|
||||
.prog__label { color: var(--text-dim); white-space: nowrap; }
|
||||
|
||||
.hidden { display: none; }
|
||||
.skel, .empty { padding: 1.6em; text-align: center; color: var(--text-dim); font-size: 0.9em; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.jobs-table th, .jobs-table td { padding: 0.5em 0.4em; }
|
||||
.jobs-table th.col-time, .jobs-table td.col-time { font-size: 0.78em; }
|
||||
.dropzone { padding: 1.8em 0.8em; min-height: 130px; }
|
||||
}
|
||||
44
static/pdf.html
Normal file
44
static/pdf.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PDF 转换 - zikai</title>
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/pdf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>PDF 转换</h1>
|
||||
<p class="sub">上传 epub 文件转为 PDF,转换完成后可下载。单文件上限 250MB。凭浏览器 cookie 记住你的任务。</p>
|
||||
|
||||
<div id="uploader" class="uploader">
|
||||
<div class="dropzone" id="dropzone">
|
||||
<div class="dropzone__hint">拖入 .epub 文件,或</div>
|
||||
<label class="btn primary">
|
||||
点击选择文件
|
||||
<input type="file" id="fileInput" accept=".epub,application/epub+zip" hidden>
|
||||
</label>
|
||||
<div class="dropzone__limit">仅支持 epub,最大 250MB</div>
|
||||
</div>
|
||||
<div id="uploadProgress" class="upload-progress hidden">
|
||||
<div class="upload-progress__name" id="uploadName"></div>
|
||||
<div class="bar"><div class="bar__fill" id="uploadBar"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" id="refresh">刷新</button>
|
||||
<span class="muted" id="count"></span>
|
||||
</div>
|
||||
|
||||
<div id="list">
|
||||
<div class="skel">加载中…</div>
|
||||
</div>
|
||||
|
||||
<p class="foot"><a class="link" href="/pdf-admin">管理页</a> · zikai file service</p>
|
||||
</div>
|
||||
<script src="/static/common.js"></script>
|
||||
<script src="/static/pdf.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
191
static/pdf.js
Normal file
191
static/pdf.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/* PDF 转换页:上传 epub + 进度轮询 + 下载 + 用户软删。
|
||||
复用 common.js 的 el/toast/fmtBytes/fmtTime/api 工具。
|
||||
state.items 缓存当前用户任务列表;轮询未完成任务进度直至终态。 */
|
||||
(function () {
|
||||
"use strict";
|
||||
const { el, toast, fmtBytes, fmtTime, api } = window.ZK;
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const uploadProgress = document.getElementById("uploadProgress");
|
||||
const uploadName = document.getElementById("uploadName");
|
||||
const uploadBar = document.getElementById("uploadBar");
|
||||
const refreshBtn = document.getElementById("refresh");
|
||||
const countEl = document.getElementById("count");
|
||||
const listEl = document.getElementById("list");
|
||||
|
||||
const state = { items: [], polling: null };
|
||||
|
||||
refreshBtn.addEventListener("click", () => load());
|
||||
|
||||
// ---------- 上传 ----------
|
||||
dropzone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add("is-drag");
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-drag"));
|
||||
dropzone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("is-drag");
|
||||
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files.length) handleFile(fileInput.files[0]);
|
||||
fileInput.value = "";
|
||||
});
|
||||
|
||||
function handleFile(file) {
|
||||
const name = file.name || "";
|
||||
if (!name.toLowerCase().endsWith(".epub")) {
|
||||
toast("仅支持 epub 文件", "err");
|
||||
return;
|
||||
}
|
||||
if (file.size > 250 * 1024 * 1024) {
|
||||
toast("文件超过 250MB 上限", "err");
|
||||
return;
|
||||
}
|
||||
uploadFile(file);
|
||||
}
|
||||
|
||||
function uploadFile(file) {
|
||||
uploadProgress.classList.remove("hidden");
|
||||
uploadName.textContent = file.name;
|
||||
uploadBar.style.width = "0%";
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/pdf/jobs");
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
uploadBar.style.width = Math.round((e.loaded / e.total) * 100) + "%";
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
uploadProgress.classList.add("hidden");
|
||||
if (xhr.status === 200) {
|
||||
toast("已提交,转换中…");
|
||||
load();
|
||||
} else {
|
||||
let msg = "上传失败";
|
||||
try {
|
||||
const b = JSON.parse(xhr.responseText);
|
||||
msg = b.detail || msg;
|
||||
} catch {}
|
||||
toast(msg, "err");
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
uploadProgress.classList.add("hidden");
|
||||
toast("网络错误", "err");
|
||||
};
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
// ---------- 列表 ----------
|
||||
async function load() {
|
||||
listEl.innerHTML = '<div class="skel">加载中…</div>';
|
||||
countEl.textContent = "";
|
||||
try {
|
||||
const res = await api("/api/pdf/jobs");
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const body = await res.json();
|
||||
state.items = body.items || [];
|
||||
countEl.textContent = `共 ${body.total} 个任务`;
|
||||
render();
|
||||
schedulePoll();
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.items.length) {
|
||||
listEl.innerHTML = '<div class="empty">还没有任务。上传一个 epub 试试吧。</div>';
|
||||
return;
|
||||
}
|
||||
const tbl = el("table", { class: "jobs-table" });
|
||||
tbl.appendChild(el("thead", null,
|
||||
el("tr", null,
|
||||
el("th", { class: "col-name" }, "文件名"),
|
||||
el("th", { class: "col-size" }, "大小"),
|
||||
el("th", { class: "col-status" }, "状态"),
|
||||
el("th", { class: "col-time" }, "创建时间"),
|
||||
el("th", { class: "col-act" }, "操作")
|
||||
)
|
||||
));
|
||||
const tbody = el("tbody", null);
|
||||
for (const j of state.items) tbody.appendChild(renderRow(j));
|
||||
tbl.appendChild(tbody);
|
||||
listEl.innerHTML = "";
|
||||
listEl.appendChild(tbl);
|
||||
}
|
||||
|
||||
function renderRow(j) {
|
||||
return el("tr", { dataset: { id: j.id } },
|
||||
el("td", { class: "col-name" }, j.source_filename),
|
||||
el("td", { class: "col-size mono" }, fmtBytes(j.source_size)),
|
||||
el("td", { class: "col-status" }, statusCell(j)),
|
||||
el("td", { class: "col-time muted" }, fmtTime(j.created_at)),
|
||||
el("td", { class: "col-act" }, actionsCell(j))
|
||||
);
|
||||
}
|
||||
|
||||
function statusCell(j) {
|
||||
if (j.status === "done") return el("span", { class: "tag ok" }, "完成");
|
||||
if (j.status === "failed") return el("span", { class: "tag err", title: j.error_message || "" }, "失败");
|
||||
// pending / converting 显示进度条
|
||||
const wrap = el("span", { class: "prog" });
|
||||
wrap.appendChild(el("span", { class: "prog__label" }, j.status === "converting" ? "转换中" : "排队中"));
|
||||
wrap.appendChild(el("span", { class: "bar bar--sm" },
|
||||
el("span", { class: "bar__fill", style: "width:" + (j.progress || 0) + "%" })
|
||||
));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionsCell(j) {
|
||||
const cell = el("span", null);
|
||||
if (j.status === "done") {
|
||||
cell.appendChild(el("a", { class: "btn primary", href: `/api/pdf/jobs/${j.id}/download`, download: "" }, "下载"));
|
||||
}
|
||||
cell.appendChild(el("button", { class: "btn danger", onclick: () => removeOne(j) }, "删除"));
|
||||
return cell;
|
||||
}
|
||||
|
||||
// ---------- 轮询未完成任务 ----------
|
||||
function schedulePoll() {
|
||||
if (state.polling) clearInterval(state.polling);
|
||||
const pending = state.items.filter((j) => j.status === "pending" || j.status === "converting");
|
||||
if (!pending.length) return;
|
||||
state.polling = setInterval(async () => {
|
||||
let stillPending = false;
|
||||
for (const j of pending) {
|
||||
try {
|
||||
const res = await api(`/api/pdf/jobs/${j.id}`);
|
||||
if (res.ok) {
|
||||
const fresh = await res.json();
|
||||
Object.assign(j, fresh);
|
||||
}
|
||||
} catch {}
|
||||
if (j.status === "pending" || j.status === "converting") stillPending = true;
|
||||
}
|
||||
render();
|
||||
if (!stillPending) {
|
||||
clearInterval(state.polling);
|
||||
state.polling = null;
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function removeOne(j) {
|
||||
if (!confirm(`确定删除「${j.source_filename}」?\n删除后将不再显示(管理员仍可见,需管理员彻底删除才会清除)。`)) return;
|
||||
try {
|
||||
const res = await api(`/api/pdf/jobs/${j.id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
await load();
|
||||
toast("已删除");
|
||||
} catch (e) {
|
||||
toast("删除失败:" + (e.message || e), "err");
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
})();
|
||||
18
static/pdf_admin.css
Normal file
18
static/pdf_admin.css
Normal file
@@ -0,0 +1,18 @@
|
||||
/* PDF 转换管理页专属样式(叠加在 pdf.css 之上)。标注「已删除」行、列宽调整。 */
|
||||
.admin-table th.col-size { width: 7em; }
|
||||
.admin-table th.col-status { width: 9em; }
|
||||
.admin-table th.col-del { width: 8em; }
|
||||
.admin-table th.col-time { width: 11em; }
|
||||
.admin-table th.col-act { width: 11em; text-align: right; }
|
||||
.admin-table td.col-act { text-align: right; white-space: nowrap; }
|
||||
.admin-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; margin-left: 0.3em; }
|
||||
|
||||
/* 用户已软删的行:淡化背景提示 */
|
||||
.admin-table tbody tr.row-soft-deleted td { background: var(--danger-soft); }
|
||||
.admin-table tbody tr.row-soft-deleted:hover td { background: var(--danger-soft); }
|
||||
.row-removed { opacity: 0; transition: opacity 0.25s; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-table th, .admin-table td { padding: 0.5em 0.4em; }
|
||||
.admin-table th.col-time, .admin-table td.col-time { font-size: 0.78em; }
|
||||
}
|
||||
30
static/pdf_admin.html
Normal file
30
static/pdf_admin.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PDF 转换管理 - zikai</title>
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/pdf.css">
|
||||
<link rel="stylesheet" href="/static/pdf_admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>PDF 转换管理</h1>
|
||||
<p class="sub">查看全部转换任务(含用户已软删的,标注是否已删除),可硬删(真正删除磁盘与记录)。</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" id="refresh">刷新</button>
|
||||
<span class="muted" id="count"></span>
|
||||
</div>
|
||||
|
||||
<div id="list">
|
||||
<div class="skel">加载中…</div>
|
||||
</div>
|
||||
|
||||
<p class="foot"><a class="link" href="/pdf">PDF 转换页</a> · zikai file service</p>
|
||||
</div>
|
||||
<script src="/static/common.js"></script>
|
||||
<script src="/static/pdf_admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
97
static/pdf_admin.js
Normal file
97
static/pdf_admin.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/* PDF 转换管理页:拉取 /api/admin/pdf/jobs、渲染表格(含「是否已删除」列)、硬删。 */
|
||||
(function () {
|
||||
"use strict";
|
||||
const { el, toast, fmtBytes, fmtTime, api } = window.ZK;
|
||||
const listEl = document.getElementById("list");
|
||||
const countEl = document.getElementById("count");
|
||||
const refreshBtn = document.getElementById("refresh");
|
||||
|
||||
refreshBtn.addEventListener("click", load);
|
||||
|
||||
async function load() {
|
||||
listEl.innerHTML = '<div class="skel">加载中…</div>';
|
||||
countEl.textContent = "";
|
||||
try {
|
||||
const res = await api("/api/admin/pdf/jobs?limit=500&offset=0");
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const body = await res.json();
|
||||
render(body.items || []);
|
||||
countEl.textContent = `共 ${body.total} 个任务`;
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
if (!items.length) {
|
||||
listEl.innerHTML = '<div class="empty">还没有任务。</div>';
|
||||
return;
|
||||
}
|
||||
const table = el("table", { class: "jobs-table admin-table" });
|
||||
const thead = el("thead", null,
|
||||
el("tr", null,
|
||||
el("th", { class: "col-name" }, "文件名"),
|
||||
el("th", { class: "col-size" }, "大小"),
|
||||
el("th", { class: "col-status" }, "状态"),
|
||||
el("th", { class: "col-del" }, "是否已删除"),
|
||||
el("th", { class: "col-time" }, "创建时间"),
|
||||
el("th", { class: "col-act" }, "操作")
|
||||
)
|
||||
);
|
||||
const tbody = el("tbody", null);
|
||||
for (const j of items) tbody.appendChild(renderRow(j));
|
||||
table.appendChild(thead);
|
||||
table.appendChild(tbody);
|
||||
listEl.innerHTML = "";
|
||||
listEl.appendChild(table);
|
||||
}
|
||||
|
||||
function renderRow(j) {
|
||||
const row = el("tr", { class: j.user_deleted ? "row-soft-deleted" : "" },
|
||||
el("td", { class: "col-name" }, j.source_filename),
|
||||
el("td", { class: "col-size mono" }, fmtBytes(j.source_size)),
|
||||
el("td", { class: "col-status" }, statusCell(j)),
|
||||
el("td", { class: "col-del" }, deletedCell(j)),
|
||||
el("td", { class: "col-time muted" }, fmtTime(j.created_at)),
|
||||
el("td", { class: "col-act" },
|
||||
j.status === "done"
|
||||
? el("a", { class: "btn", href: `/api/admin/files/${j.output_file_id}/download`, download: "" }, "下载产物")
|
||||
: el("span", { class: "muted" }, "-"),
|
||||
el("button", { class: "btn danger", onclick: () => remove(j, row) }, "硬删")
|
||||
)
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
function statusCell(j) {
|
||||
if (j.status === "done") return el("span", { class: "tag ok" }, "完成");
|
||||
if (j.status === "failed")
|
||||
return el("span", { class: "tag err", title: j.error_message || "" }, "失败");
|
||||
return el("span", { class: "tag warn" }, j.status === "converting" ? `转换中 ${j.progress}%` : "排队中");
|
||||
}
|
||||
|
||||
function deletedCell(j) {
|
||||
if (j.user_deleted) {
|
||||
return el("span", { class: "tag err", title: fmtTime(j.deleted_at) }, "已删除");
|
||||
}
|
||||
return el("span", { class: "muted" }, "否");
|
||||
}
|
||||
|
||||
async function remove(j, row) {
|
||||
if (!confirm(`确定硬删「${j.source_filename}」?\n将真正删除原始文件与产物 PDF(磁盘 + 记录),不可恢复。`)) return;
|
||||
try {
|
||||
const res = await api(`/api/admin/pdf/jobs/${j.id}`, { method: "DELETE" });
|
||||
if (res.status === 404) { toast("任务已不存在"); }
|
||||
else if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
row.classList.add("row-removed");
|
||||
setTimeout(() => row.remove(), 250);
|
||||
toast("已硬删");
|
||||
const m = (countEl.textContent || "").match(/(\d+)/);
|
||||
if (m) countEl.textContent = `共 ${Math.max(0, Number(m[1]) - 1)} 个任务`;
|
||||
} catch (e) {
|
||||
toast("删除失败:" + (e.message || e), "err");
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
})();
|
||||
281
tests/test_pdf_service.py
Normal file
281
tests/test_pdf_service.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""PDF 转换服务测试(pytest + TestClient)。
|
||||
|
||||
用 SQLite 内存库覆盖 get_db,免依赖 MySQL;用临时目录覆盖上传根目录。
|
||||
覆盖完整链路:上传 epub -> 轮询至 done -> 下载有效 PDF -> 用户软删 ->
|
||||
管理页仍可见 -> 管理员硬删 -> 真正删除。另测大小超限与格式校验。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app import config as config_module
|
||||
from app import database as db_module
|
||||
from app.database import Base, get_db
|
||||
from app.models.uploaded_file import UploadedFile # noqa: F401 注册映射
|
||||
|
||||
|
||||
# ---------------- fixtures ----------------
|
||||
|
||||
|
||||
def _make_epub_bytes() -> bytes:
|
||||
"""构造一个最小的合法 epub(含 1 章节 + 1 PNG 图片 + CSS)。"""
|
||||
from PIL import Image # 已是 weasyprint 依赖间接项,环境可用
|
||||
|
||||
png = io.BytesIO()
|
||||
Image.new("RGBA", (8, 8), (26, 95, 180, 255)).save(png, format="PNG")
|
||||
png_bytes = png.getvalue()
|
||||
|
||||
ch = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Ch1</title>'
|
||||
'<link rel="stylesheet" type="text/css" href="style.css"/></head>'
|
||||
'<body><h1>Hello PDF</h1><p>Test paragraph for conversion.</p>'
|
||||
'<p><img src="img.png" alt="pic"/></p></body></html>'
|
||||
)
|
||||
opf = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="Bid">'
|
||||
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">'
|
||||
'<dc:title>T</dc:title><dc:identifier id="Bid">urn:uuid:t</dc:identifier>'
|
||||
'<dc:language>en</dc:language></metadata>'
|
||||
'<manifest>'
|
||||
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>'
|
||||
'<item id="css" href="style.css" media-type="text/css"/>'
|
||||
'<item id="img" href="img.png" media-type="image/png"/>'
|
||||
'<item id="ch1" href="ch1.xhtml" media-type="application/xhtml+xml"/>'
|
||||
'</manifest><spine toc="ncx"><itemref idref="ch1"/></spine></package>'
|
||||
)
|
||||
ncx = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">'
|
||||
'<head><meta name="dtb:uid" content="urn:uuid:t"/></head>'
|
||||
'<docTitle><text>T</text></docTitle>'
|
||||
'<navMap><navPoint id="n1" playOrder="1"><navLabel><text>Ch1</text></navLabel>'
|
||||
'<content src="ch1.xhtml"/></navPoint></navMap></ncx>'
|
||||
)
|
||||
css = "h1{color:#1a5fb4} p{line-height:1.6}"
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
z.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED)
|
||||
z.writestr("OEBPS/content.opf", opf, compress_type=zipfile.ZIP_DEFLATED)
|
||||
z.writestr("OEBPS/toc.ncx", ncx, compress_type=zipfile.ZIP_DEFLATED)
|
||||
z.writestr("OEBPS/style.css", css, compress_type=zipfile.ZIP_DEFLATED)
|
||||
z.writestr("OEBPS/img.png", png_bytes, compress_type=zipfile.ZIP_DEFLATED)
|
||||
z.writestr("OEBPS/ch1.xhtml", ch, compress_type=zipfile.ZIP_DEFLATED)
|
||||
z.writestr("META-INF/container.xml",
|
||||
'<?xml version="1.0"?><container version="1.0" '
|
||||
'xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
|
||||
'<rootfiles><rootfile full-path="OEBPS/content.opf" '
|
||||
'media-type="application/oebps-package+xml"/></rootfiles></container>',
|
||||
compress_type=zipfile.ZIP_DEFLATED)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""构造用隔离 MySQL 测试库 + 临时上传目录的 TestClient。
|
||||
|
||||
用独立库 zikai_filesvc_test(与生产库隔离),每个 fixture 重建表,准确测试生产
|
||||
行为(MySQL BigInteger 自增、线程安全连接)。后台转换经 asyncio.to_thread 在
|
||||
独立线程执行,MySQL 连接池天然支持跨线程。
|
||||
"""
|
||||
real_settings = config_module.get_settings()
|
||||
# 用独立测试库 zikai_filesvc_test(与生产库隔离),准确测试生产行为
|
||||
test_settings = real_settings.model_copy(deep=True)
|
||||
test_settings.database.database = "zikai_filesvc_test"
|
||||
test_settings.pdf.convert_timeout_seconds = 60
|
||||
|
||||
# 全局 get_settings 被 lru_cache;config 与 database 两个模块各自 import 了它,
|
||||
# 都需 patch,使后台线程(经 database.get_session_local)也读到测试库
|
||||
monkeypatch.setattr(config_module, "get_settings", lambda: test_settings)
|
||||
monkeypatch.setattr(db_module, "get_settings", lambda: test_settings)
|
||||
# 清掉已建的全局引擎/SessionLocal,下次 get_session_local() 用测试库重建
|
||||
db_module.dispose_engine()
|
||||
|
||||
upload_dir = tmp_path / "uploads"
|
||||
upload_dir.mkdir()
|
||||
|
||||
# 复用全局 SessionLocal(指向测试库),保证请求路径与后台线程用同一库
|
||||
SessionLocal = db_module.get_session_local()
|
||||
|
||||
# 覆盖 get_db(仍用全局 SessionLocal,但确保请求结束关闭)
|
||||
def override_get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# 上传目录指向临时目录
|
||||
monkeypatch.setattr(test_settings, "storage", test_settings.storage)
|
||||
test_settings.storage.upload_dir = str(upload_dir)
|
||||
# upload_service / pdf_service 内部 import 了 get_settings,需 patch 其引用
|
||||
import app.services.upload_service as us
|
||||
import app.services.pdf_service as ps
|
||||
import app.services.pdf_converter as pc # noqa: F401
|
||||
monkeypatch.setattr(us, "get_settings", lambda: test_settings)
|
||||
monkeypatch.setattr(ps, "get_settings", lambda: test_settings)
|
||||
|
||||
# 每次测试前清空重建表,保证隔离
|
||||
Base.metadata.drop_all(bind=db_module.get_engine())
|
||||
Base.metadata.create_all(bind=db_module.get_engine())
|
||||
|
||||
from app.main import app
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=db_module.get_engine())
|
||||
db_module.dispose_engine()
|
||||
|
||||
|
||||
def _wait_done(client, job_id, cookie, timeout=60):
|
||||
"""轮询任务状态直到 done/failed 或超时。"""
|
||||
deadline = time.time() + timeout
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
r = client.get(f"/api/pdf/jobs/{job_id}", cookies=cookie)
|
||||
assert r.status_code == 200, r.text
|
||||
last = r.json()
|
||||
if last["status"] in ("done", "failed"):
|
||||
return last
|
||||
time.sleep(0.3)
|
||||
raise AssertionError(f"任务未在 {timeout}s 内完成,最后状态: {last}")
|
||||
|
||||
|
||||
# ---------------- 测试 ----------------
|
||||
|
||||
|
||||
class TestSubmitAndConvert:
|
||||
def test_upload_epub_converts_and_downloads(self, client):
|
||||
epub = _make_epub_bytes()
|
||||
# 首次上传:无 cookie,应下发新 cookie
|
||||
r = client.post(
|
||||
"/api/pdf/jobs",
|
||||
files={"file": ("test.epub", epub, "application/epub+zip")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["set_cookie"] is True
|
||||
assert "zk_pdf" in r.headers.get("set-cookie", "")
|
||||
job = body["job"]
|
||||
assert job["status"] == "pending"
|
||||
job_id = job["id"]
|
||||
cookie = {"zk_pdf": r.cookies.get("zk_pdf")}
|
||||
|
||||
# 轮询至完成
|
||||
final = _wait_done(client, job_id, cookie)
|
||||
assert final["status"] == "done", final
|
||||
assert final["progress"] == 100
|
||||
|
||||
# 下载产物:应为有效 PDF
|
||||
d = client.get(f"/api/pdf/jobs/{job_id}/download", cookies=cookie)
|
||||
assert d.status_code == 200, d.text
|
||||
assert d.headers["content-type"] == "application/pdf"
|
||||
assert d.content[:5] == b"%PDF-"
|
||||
assert len(d.content) > 100
|
||||
|
||||
def test_user_list_shows_own_jobs(self, client):
|
||||
epub = _make_epub_bytes()
|
||||
r = client.post("/api/pdf/jobs", files={"file": ("a.epub", epub, "application/epub+zip")})
|
||||
cookie = {"zk_pdf": r.cookies.get("zk_pdf")}
|
||||
lst = client.get("/api/pdf/jobs", cookies=cookie)
|
||||
assert lst.status_code == 200
|
||||
assert lst.json()["total"] == 1
|
||||
|
||||
# 另一用户(清空 client cookie jar 模拟全新浏览器,应下发新 cookie)
|
||||
client.cookies.clear()
|
||||
r2 = client.post("/api/pdf/jobs", files={"file": ("b.epub", epub, "application/epub+zip")})
|
||||
cookie2 = {"zk_pdf": r2.cookies.get("zk_pdf")}
|
||||
assert cookie2["zk_pdf"] != cookie["zk_pdf"] # 确是不同用户
|
||||
lst2 = client.get("/api/pdf/jobs", cookies=cookie2)
|
||||
assert lst2.json()["total"] == 1 # 只有自己的 1 个
|
||||
|
||||
def test_rejects_non_epub(self, client):
|
||||
r = client.post(
|
||||
"/api/pdf/jobs",
|
||||
files={"file": ("note.txt", b"hello world", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_rejects_oversize(self, client, monkeypatch):
|
||||
# 把上限调到极小,避免真的构造 250MB 文件
|
||||
import app.services.pdf_service as ps
|
||||
real = ps.get_settings()
|
||||
small = real.model_copy(deep=True)
|
||||
small.pdf.max_size_bytes = 100
|
||||
monkeypatch.setattr(ps, "get_settings", lambda: small)
|
||||
monkeypatch.setattr(ps, "get_settings", lambda: small)
|
||||
# controller 的 _resolve_cookie 也读 get_settings,但 submit 内 service 用 ps.get_settings
|
||||
epub = _make_epub_bytes()
|
||||
r = client.post("/api/pdf/jobs", files={"file": ("big.epub", epub, "application/epub+zip")})
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
class TestDeleteSemantics:
|
||||
def test_user_soft_delete_admin_still_visible_then_admin_hard_delete(self, client):
|
||||
epub = _make_epub_bytes()
|
||||
r = client.post("/api/pdf/jobs", files={"file": ("del.epub", epub, "application/epub+zip")})
|
||||
cookie = {"zk_pdf": r.cookies.get("zk_pdf")}
|
||||
job_id = r.json()["job"]["id"]
|
||||
_wait_done(client, job_id, cookie)
|
||||
|
||||
# 用户软删
|
||||
d = client.delete(f"/api/pdf/jobs/{job_id}", cookies=cookie)
|
||||
assert d.status_code == 200
|
||||
assert d.json()["deleted"] is True
|
||||
|
||||
# 用户列表不再可见
|
||||
lst = client.get("/api/pdf/jobs", cookies=cookie)
|
||||
assert lst.json()["total"] == 0
|
||||
|
||||
# 管理页仍可见且标注已删除
|
||||
admin_auth = ("admin", "testpass123")
|
||||
al = client.get("/api/admin/pdf/jobs", auth=admin_auth)
|
||||
assert al.status_code == 200
|
||||
items = al.json()["items"]
|
||||
assert any(it["id"] == job_id and it["user_deleted"] is True for it in items)
|
||||
assert any(it["id"] == job_id and it["deleted_at"] is not None for it in items)
|
||||
|
||||
# 用户已无法下载(已软删)
|
||||
dl = client.get(f"/api/pdf/jobs/{job_id}/download", cookies=cookie)
|
||||
assert dl.status_code == 404
|
||||
|
||||
# 管理员硬删
|
||||
ad = client.delete(f"/api/admin/pdf/jobs/{job_id}", auth=admin_auth)
|
||||
assert ad.status_code == 200
|
||||
assert ad.json()["deleted"] is True
|
||||
|
||||
# 管理页也不再可见
|
||||
al2 = client.get("/api/admin/pdf/jobs", auth=admin_auth)
|
||||
assert not any(it["id"] == job_id for it in al2.json()["items"])
|
||||
|
||||
def test_admin_requires_auth(self, client):
|
||||
r = client.get("/api/admin/pdf/jobs")
|
||||
assert r.status_code == 401
|
||||
r = client.get("/api/admin/pdf/jobs", auth=("admin", "wrong"))
|
||||
assert r.status_code == 401
|
||||
r = client.get("/api/admin/pdf/jobs", auth=("admin", "testpass123"))
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_user_cannot_access_others_job(self, client):
|
||||
epub = _make_epub_bytes()
|
||||
r1 = client.post("/api/pdf/jobs", files={"file": ("x.epub", epub, "application/epub+zip")})
|
||||
job_id = r1.json()["job"]["id"]
|
||||
# 另一用户访问(清空 cookie jar 模拟全新浏览器)
|
||||
client.cookies.clear()
|
||||
r2 = client.post("/api/pdf/jobs", files={"file": ("y.epub", epub, "application/epub+zip")})
|
||||
cookie2 = {"zk_pdf": r2.cookies.get("zk_pdf")}
|
||||
assert client.get(f"/api/pdf/jobs/{job_id}", cookies=cookie2).status_code == 404
|
||||
assert client.get(f"/api/pdf/jobs/{job_id}/download", cookies=cookie2).status_code == 404
|
||||
assert client.delete(f"/api/pdf/jobs/{job_id}", cookies=cookie2).status_code == 404
|
||||
Reference in New Issue
Block a user