新增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

@@ -7,10 +7,11 @@ from app.api.deps import require_roles
from app.core.security import generate_public_token, hash_token
from app.db.session import get_db
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.schemas.certificate import CertificateCreate, CertificateOut
from app.schemas.certificate import CertificateCreate, CertificateOut, PdfPregenerationJobCreate, PdfPregenerationJobOut
from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action
from app.services.pdf import PdfGenerationBusy, render_certificate_pdf
from app.services.pdf_pregeneration import pdf_pregeneration_manager
from app.services.system_settings import get_pdf_generation_concurrency_limit
router = APIRouter()
@@ -33,10 +34,12 @@ def _create_token(db: Session, certificate_id: int, token_type: str) -> int:
def list_certificates(
keyword: str | None = Query(default=None),
status_value: str | None = Query(default=None, alias="status"),
pdf_status: str | None = Query(default=None),
import_batch_id: int | None = Query(default=None),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[Certificate]:
query = db.query(Certificate).order_by(Certificate.id.desc())
) -> list[dict[str, object]]:
query = db.query(Certificate, Learner).outerjoin(Learner, Learner.id == Certificate.learner_id).order_by(Certificate.id.desc())
if keyword:
like = f"%{keyword}%"
query = query.filter(
@@ -46,11 +49,16 @@ def list_certificates(
Certificate.project_code.like(like),
Certificate.course_name.like(like),
Certificate.stage_name.like(like),
Learner.current_name.like(like),
)
)
if status_value:
query = query.filter(Certificate.status == status_value)
return query.limit(100).all()
if pdf_status:
query = query.filter(Certificate.pdf_status == pdf_status)
if import_batch_id:
query = query.filter(Certificate.import_batch_id == import_batch_id)
return [_certificate_payload(certificate, learner) for certificate, learner in query.limit(200).all()]
@router.post("", response_model=CertificateOut, status_code=status.HTTP_201_CREATED)
@@ -89,6 +97,51 @@ def create_certificate(
return certificate
@router.get("/pdf-pregeneration-jobs", response_model=list[PdfPregenerationJobOut])
def list_pdf_pregeneration_jobs(
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[dict[str, object]]:
return [job.to_dict() for job in pdf_pregeneration_manager.list()]
@router.post("/pdf-pregeneration-jobs", response_model=PdfPregenerationJobOut, status_code=status.HTTP_201_CREATED)
def start_pdf_pregeneration_job(
payload: PdfPregenerationJobCreate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, object]:
certificate_ids = _resolve_pregeneration_certificate_ids(db, payload)
if not certificate_ids:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="没有可预生成的证书")
concurrency_limit = get_pdf_generation_concurrency_limit(db)
job = pdf_pregeneration_manager.start(certificate_ids, concurrency_limit)
log_action(
db,
admin,
"start_pdf_pregeneration",
"certificate",
job.id,
detail={
"total": job.total,
"concurrency_limit": concurrency_limit,
"import_batch_id": payload.import_batch_id,
},
)
db.commit()
return job.to_dict()
@router.get("/pdf-pregeneration-jobs/{job_id}", response_model=PdfPregenerationJobOut)
def get_pdf_pregeneration_job(
job_id: str,
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> dict[str, object]:
job = pdf_pregeneration_manager.get(job_id)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="预生成任务不存在或已过期")
return job.to_dict()
@router.get("/{certificate_id}", response_model=CertificateOut)
def get_certificate(
certificate_id: int,
@@ -101,6 +154,67 @@ def get_certificate(
return certificate
def _certificate_payload(certificate: Certificate, learner: Learner | None = None) -> dict[str, object]:
return {
"id": certificate.id,
"learner_id": certificate.learner_id,
"learner_name": learner.current_name if learner else None,
"import_batch_id": certificate.import_batch_id,
"project_code": certificate.project_code,
"certificate_no": certificate.certificate_no,
"certificate_name": certificate.certificate_name,
"class_name": certificate.class_name,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"issue_date": certificate.issue_date,
"issuer_name": certificate.issuer_name,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"created_at": certificate.created_at,
"updated_at": certificate.updated_at,
}
def _resolve_pregeneration_certificate_ids(db: Session, payload: PdfPregenerationJobCreate) -> list[int]:
ids: list[int] = []
if payload.import_batch_id:
ids.extend(
item[0]
for item in db.query(Certificate.id)
.filter(Certificate.import_batch_id == payload.import_batch_id)
.filter(Certificate.status == "valid")
.order_by(Certificate.id.asc())
.all()
)
selected_ids = _dedupe_ids(payload.certificate_ids)
if selected_ids:
valid_selected_ids = {
item[0]
for item in db.query(Certificate.id)
.filter(Certificate.id.in_(selected_ids))
.filter(Certificate.status == "valid")
.all()
}
ids.extend(certificate_id for certificate_id in selected_ids if certificate_id in valid_selected_ids)
seen: set[int] = set()
result: list[int] = []
for item in ids:
if item not in seen:
seen.add(item)
result.append(item)
return result
def _dedupe_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
@router.get("/{certificate_id}/preview")
def preview_certificate(
certificate_id: int,

View File

@@ -181,6 +181,7 @@ def confirm_import_batch(
certificate = Certificate(
learner_id=learner.id,
import_batch_id=batch.id,
project_code=project.code,
certificate_no="PENDING",
certificate_name=project.default_certificate_name,

View File

@@ -26,6 +26,7 @@ class Certificate(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
learner_id: Mapped[int] = mapped_column(index=True)
import_batch_id: Mapped[int | None] = mapped_column(nullable=True, index=True)
project_code: Mapped[str] = mapped_column(String(16), index=True)
certificate_no: Mapped[str] = mapped_column(String(64), unique=True, index=True)
certificate_name: Mapped[str] = mapped_column(String(128))

View File

@@ -23,6 +23,8 @@ class CertificateCreate(BaseModel):
class CertificateOut(BaseModel):
id: int
learner_id: int
learner_name: str | None = None
import_batch_id: int | None = None
project_code: str
certificate_no: str
certificate_name: str
@@ -37,3 +39,24 @@ class CertificateOut(BaseModel):
updated_at: datetime
model_config = {"from_attributes": True}
class PdfPregenerationJobCreate(BaseModel):
certificate_ids: list[int] = Field(default_factory=list, max_length=5000)
import_batch_id: int | None = None
class PdfPregenerationJobOut(BaseModel):
id: str
status: str
total: int
completed: int
generated: int
cached: int
skipped: int
failed: int
percent: int
concurrency_limit: int
errors: list[str]
created_at: datetime
finished_at: datetime | None = None

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