新增PDF批量预生成

This commit is contained in:
2026-06-23 13:59:56 +08:00
parent b6be8b4593
commit 4742c175c3
18 changed files with 725 additions and 24 deletions

View File

@@ -15,6 +15,7 @@ ACTION_LABELS = {
"update_learner": "\u4fee\u6539\u5b66\u5458",
"delete_learner": "\u5220\u9664\u5b66\u5458",
"create_certificate": "\u65b0\u589e\u8bc1\u4e66",
"start_pdf_pregeneration": "\u542f\u52a8PDF\u6279\u91cf\u9884\u751f\u6210",
"void_certificate": "\u4f5c\u5e9f\u8bc1\u4e66",
"regenerate_public_token": "\u91cd\u7f6e\u8bc1\u4e66\u94fe\u63a5",
"upload_import_file": "\u4e0a\u4f20\u5bfc\u5165\u6587\u4ef6",
@@ -49,6 +50,7 @@ MEDIUM_RISK_ACTIONS = {
"update_learner",
"confirm_import_batch",
"create_certificate",
"start_pdf_pregeneration",
"create_database_backup",
"update_pdf_settings",
}
@@ -110,6 +112,8 @@ def log_summary(action: str, object_type: str, object_id: object, detail: dict[s
if action in {"create_certificate", "void_certificate", "regenerate_public_token"}:
learner_name = detail.get("learner_name")
return f"{label}\uff1a{detail.get('certificate_no') or object_id}" + (f"\uff08{learner_name}\uff09" if learner_name else "")
if action == "start_pdf_pregeneration":
return f"{label}\uff1a{detail.get('total', 0)} \u5f20\uff0c\u5e76\u53d1 {detail.get('concurrency_limit')}"
if action in {"upload_import_file", "delete_import_batch"}:
return f"{label}\uff1a{detail.get('filename') or object_id}"
if action == "confirm_import_batch":

View File

@@ -64,6 +64,12 @@ def update_pdf_access_time(certificate: Certificate) -> None:
certificate.pdf_last_accessed_at = datetime.utcnow()
def mark_pdf_generated(certificate: Certificate, output_path: Path) -> None:
certificate.pdf_status = "generated"
certificate.pdf_file_path = str(output_path)
update_pdf_access_time(certificate)
def certificate_template_path() -> Path:
assets_dir = Path(__file__).resolve().parents[1] / "assets"
for name in ["certificate-template.png", "certificate-template.jpg"]:
@@ -88,24 +94,20 @@ def render_certificate_pdf(
template_path = certificate_template_path()
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).stat().st_mtime)
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
# 更新访问时间
update_pdf_access_time(certificate)
mark_pdf_generated(certificate, output_path)
return output_path
pdf_generation_limiter.acquire(max(1, concurrency_limit))
try:
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
update_pdf_access_time(certificate)
mark_pdf_generated(certificate, output_path)
return output_path
image = render_certificate_image(certificate, learner, project)
image.save(output_path, "PDF", resolution=pdf_resolution(image))
finally:
pdf_generation_limiter.release()
certificate.pdf_status = "generated"
certificate.pdf_file_path = str(output_path)
# 更新访问时间
update_pdf_access_time(certificate)
mark_pdf_generated(certificate, output_path)
return output_path

View File

@@ -0,0 +1,182 @@
import queue
import threading
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from app.db.session import SessionLocal
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.services.pdf import cached_pdf_is_fresh, certificate_template_path, pdf_cache_path, render_certificate_pdf
MAX_PDF_PREGENERATION_JOBS = 30
def _utc_now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
@dataclass
class PdfPregenerationJob:
id: str
certificate_ids: list[int]
concurrency_limit: int
status: str = "running"
total: int = 0
completed: int = 0
generated: int = 0
cached: int = 0
skipped: int = 0
failed: int = 0
errors: list[str] = field(default_factory=list)
created_at: datetime = field(default_factory=_utc_now)
finished_at: datetime | None = None
def to_dict(self) -> dict[str, object]:
percent = round((self.completed / self.total) * 100) if self.total else 0
return {
"id": self.id,
"status": self.status,
"total": self.total,
"completed": self.completed,
"generated": self.generated,
"cached": self.cached,
"skipped": self.skipped,
"failed": self.failed,
"percent": percent,
"concurrency_limit": self.concurrency_limit,
"errors": self.errors[-10:],
"created_at": self.created_at,
"finished_at": self.finished_at,
}
class PdfPregenerationManager:
def __init__(self) -> None:
self._jobs: dict[str, PdfPregenerationJob] = {}
self._lock = threading.Lock()
def start(self, certificate_ids: list[int], concurrency_limit: int) -> PdfPregenerationJob:
unique_ids = _unique_ids(certificate_ids)
if not unique_ids:
raise ValueError("请选择需要预生成PDF的证书")
job = PdfPregenerationJob(
id=uuid.uuid4().hex,
certificate_ids=unique_ids,
concurrency_limit=max(1, concurrency_limit),
total=len(unique_ids),
)
with self._lock:
self._jobs[job.id] = job
self._trim_jobs()
thread = threading.Thread(target=self._run_job, args=(job.id,), daemon=True)
thread.start()
return job
def get(self, job_id: str) -> PdfPregenerationJob | None:
with self._lock:
return self._jobs.get(job_id)
def list(self) -> list[PdfPregenerationJob]:
with self._lock:
return sorted(self._jobs.values(), key=lambda item: item.created_at, reverse=True)
def _run_job(self, job_id: str) -> None:
job = self.get(job_id)
if not job:
return
work_queue: queue.Queue[int] = queue.Queue()
for certificate_id in job.certificate_ids:
work_queue.put(certificate_id)
workers = [
threading.Thread(target=self._worker, args=(job_id, work_queue), daemon=True)
for _ in range(min(job.concurrency_limit, job.total))
]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
with self._lock:
job.status = "completed_with_errors" if job.failed else "completed"
job.finished_at = _utc_now()
def _worker(self, job_id: str, work_queue: queue.Queue[int]) -> None:
while True:
try:
certificate_id = work_queue.get_nowait()
except queue.Empty:
return
try:
job = self.get(job_id)
if not job:
return
result = pre_generate_certificate_pdf(certificate_id, job.concurrency_limit)
self._record(job_id, result)
except Exception as exc:
self._record(job_id, "failed", f"证书ID {certificate_id}{exc}")
finally:
work_queue.task_done()
def _record(self, job_id: str, result: str, error: str | None = None) -> None:
job = self.get(job_id)
if not job:
return
with self._lock:
job.completed += 1
if result == "generated":
job.generated += 1
elif result == "cached":
job.cached += 1
elif result == "skipped":
job.skipped += 1
else:
job.failed += 1
if error:
job.errors.append(error)
def _trim_jobs(self) -> None:
if len(self._jobs) <= MAX_PDF_PREGENERATION_JOBS:
return
old_jobs = sorted(self._jobs.values(), key=lambda item: item.created_at)
for job in old_jobs[: len(self._jobs) - MAX_PDF_PREGENERATION_JOBS]:
self._jobs.pop(job.id, None)
def pre_generate_certificate_pdf(certificate_id: int, concurrency_limit: int) -> str:
with SessionLocal() as db:
certificate = db.get(Certificate, certificate_id)
if not certificate or certificate.status != "valid":
return "skipped"
learner = db.get(Learner, certificate.learner_id)
token = db.get(CertificateAccessToken, certificate.qr_token_id or certificate.public_token_id)
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
if not learner or not token:
raise ValueError("证书数据不完整")
was_cached = certificate_pdf_is_ready(certificate)
render_certificate_pdf(certificate, learner, project, token, concurrency_limit=concurrency_limit)
db.commit()
return "cached" if was_cached else "generated"
def certificate_pdf_is_ready(certificate: Certificate) -> bool:
output_path = pdf_cache_path(certificate.certificate_no)
template_path = certificate_template_path()
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).with_name("pdf.py").stat().st_mtime)
return cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime
def _unique_ids(values: list[int]) -> list[int]:
seen: set[int] = set()
result: list[int] = []
for value in values:
if value not in seen:
seen.add(value)
result.append(value)
return result
pdf_pregeneration_manager = PdfPregenerationManager()