新增PDF批量预生成
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
182
backend/app/services/pdf_pregeneration.py
Normal file
182
backend/app/services/pdf_pregeneration.py
Normal 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()
|
||||
Reference in New Issue
Block a user