From 4742c175c371f89492542865ad41c7a6bfca01db Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Tue, 23 Jun 2026 13:59:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EPDF=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E9=A2=84=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- ...260623_0005_certificate_import_batch_id.py | 26 +++ backend/app/api/routes/admin_certificates.py | 122 +++++++++++- backend/app/api/routes/admin_imports.py | 1 + backend/app/models/certificate.py | 1 + backend/app/schemas/certificate.py | 23 +++ backend/app/services/logs.py | 4 + backend/app/services/pdf.py | 16 +- backend/app/services/pdf_pregeneration.py | 182 ++++++++++++++++++ backend/tests/test_pdf_pregeneration.py | 31 +++ docs/decision-log.md | 7 + docs/deployment-checklist.md | 3 + docs/usage-deployment-manual.md | 11 +- docs/正式部署操作手册.md | 2 + docs/阿里云正式部署详细操作手册.md | 2 + frontend/src/api.ts | 18 ++ frontend/src/views/AdminCertificates.vue | 176 ++++++++++++++++- frontend/src/views/AdminImports.vue | 122 +++++++++++- 18 files changed, 725 insertions(+), 24 deletions(-) create mode 100644 backend/alembic/versions/20260623_0005_certificate_import_batch_id.py create mode 100644 backend/app/services/pdf_pregeneration.py create mode 100644 backend/tests/test_pdf_pregeneration.py diff --git a/README.md b/README.md index 6c87e86..3de8a14 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ reset-local-data.bat - 学员手动新增、查询、修改、删除。 - 项目新增、搜索、修改、启用、停用。 -- 证书手动新增、后台预览、后台下载、作废、重置链接。 +- 证书手动新增、后台预览、后台下载、作废、重置链接、批量预生成 PDF。 - Excel 模板下载、导入、确认导入、导出证书。 - 操作日志中文显示。 - 公开查询(支持证书编号+姓名+图形验证码、手机号+短信验证码两种方式)、证书直达页、PDF 下载。 diff --git a/backend/alembic/versions/20260623_0005_certificate_import_batch_id.py b/backend/alembic/versions/20260623_0005_certificate_import_batch_id.py new file mode 100644 index 0000000..c544d14 --- /dev/null +++ b/backend/alembic/versions/20260623_0005_certificate_import_batch_id.py @@ -0,0 +1,26 @@ +"""add certificate import batch id + +Revision ID: 20260623_0005 +Revises: 20260623_0004 +Create Date: 2026-06-23 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "20260623_0005" +down_revision: Union[str, None] = "20260623_0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("certificates", sa.Column("import_batch_id", sa.Integer(), nullable=True)) + op.create_index("ix_certificates_import_batch_id", "certificates", ["import_batch_id"]) + + +def downgrade() -> None: + op.drop_index("ix_certificates_import_batch_id", table_name="certificates") + op.drop_column("certificates", "import_batch_id") diff --git a/backend/app/api/routes/admin_certificates.py b/backend/app/api/routes/admin_certificates.py index 93e8b6b..af2a2fd 100644 --- a/backend/app/api/routes/admin_certificates.py +++ b/backend/app/api/routes/admin_certificates.py @@ -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, diff --git a/backend/app/api/routes/admin_imports.py b/backend/app/api/routes/admin_imports.py index d142f6f..3c22fb8 100644 --- a/backend/app/api/routes/admin_imports.py +++ b/backend/app/api/routes/admin_imports.py @@ -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, diff --git a/backend/app/models/certificate.py b/backend/app/models/certificate.py index a432a9b..33371e0 100644 --- a/backend/app/models/certificate.py +++ b/backend/app/models/certificate.py @@ -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)) diff --git a/backend/app/schemas/certificate.py b/backend/app/schemas/certificate.py index 35a66ab..4175d5e 100644 --- a/backend/app/schemas/certificate.py +++ b/backend/app/schemas/certificate.py @@ -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 diff --git a/backend/app/services/logs.py b/backend/app/services/logs.py index 1ab8906..79cabf0 100644 --- a/backend/app/services/logs.py +++ b/backend/app/services/logs.py @@ -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": diff --git a/backend/app/services/pdf.py b/backend/app/services/pdf.py index fe431f8..086e33a 100644 --- a/backend/app/services/pdf.py +++ b/backend/app/services/pdf.py @@ -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 diff --git a/backend/app/services/pdf_pregeneration.py b/backend/app/services/pdf_pregeneration.py new file mode 100644 index 0000000..d14ecbd --- /dev/null +++ b/backend/app/services/pdf_pregeneration.py @@ -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() diff --git a/backend/tests/test_pdf_pregeneration.py b/backend/tests/test_pdf_pregeneration.py new file mode 100644 index 0000000..fe3cb02 --- /dev/null +++ b/backend/tests/test_pdf_pregeneration.py @@ -0,0 +1,31 @@ +from time import monotonic, sleep + +from app.services import pdf_pregeneration + + +def test_pdf_pregeneration_job_deduplicates_and_tracks_progress(monkeypatch): + calls: list[tuple[int, int]] = [] + + def fake_pre_generate_certificate_pdf(certificate_id: int, concurrency_limit: int) -> str: + calls.append((certificate_id, concurrency_limit)) + return {101: "generated", 102: "cached", 103: "skipped"}[certificate_id] + + monkeypatch.setattr(pdf_pregeneration, "pre_generate_certificate_pdf", fake_pre_generate_certificate_pdf) + + manager = pdf_pregeneration.PdfPregenerationManager() + job = manager.start([101, 102, 102, 103], concurrency_limit=2) + + deadline = monotonic() + 2 + while job.status == "running" and monotonic() < deadline: + sleep(0.01) + + assert job.status == "completed" + assert job.total == 3 + assert job.completed == 3 + assert job.generated == 1 + assert job.cached == 1 + assert job.skipped == 1 + assert job.failed == 0 + assert sorted(certificate_id for certificate_id, _ in calls) == [101, 102, 103] + assert {limit for _, limit in calls} == {2} + assert job.to_dict()["percent"] == 100 diff --git a/docs/decision-log.md b/docs/decision-log.md index 98d4f30..c59ed28 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -100,3 +100,10 @@ - Decision: production PDF rendering now uses the approved certificate image as the base layer, then renders learner/course/date/certificate number dynamically with Pillow. - Current limitation: the data model only has `issue_date`; until separate training start/end dates are added, the rendered course date range uses `issue_date` for both start and end. - Current limitation: mentor, issuer names, and QR area remain from the approved base image for visual consistency. + +### C012 PDF 批量预生成 + +- 决定:后台提供批量预生成 PDF 能力,可按导入批次生成,也可在证书管理中筛选和勾选后生成。 +- 策略:预生成继续使用系统设置里的 `PDF 生成并发限制`,不单独开无限制后台任务。 +- 原因:大批量学员毕业前先生成缓存,可以降低公开查询高峰的等待时间和 CPU 压力。 +- 当前限制:任务进度保存在后端进程内,服务重启后只保留已生成的 PDF 缓存,不恢复历史进度。 diff --git a/docs/deployment-checklist.md b/docs/deployment-checklist.md index 2b1c9f7..99b2fb3 100644 --- a/docs/deployment-checklist.md +++ b/docs/deployment-checklist.md @@ -39,6 +39,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a - 系统设置里的 `PDF 生成并发限制` 已确认,2 核 4G 建议保持默认 `2`。 - Excel 导入模板已下载并试填。 - 占位证书模板已确认可临时使用。 +- 大批量毕业前的操作约定已确认:确认导入后先在“Excel 导入”或“证书管理”里批量预生成 PDF。 ## 验收动作 @@ -46,6 +47,8 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a - 可新增学员。 - 可新增证书并生成编号。 - 可上传 Excel 并确认导入。 +- 已导入批次可以启动“预生成PDF”,进度弹窗能显示总数、已完成、失败数和并发数。 +- 证书管理页可按导入批次和 PDF 状态筛选,并能勾选多张证书批量预生成。 - 可导出证书和直达链接。 - 公开查询支持两种方式: - 证书编号查询:必须输入姓名和图形验证码。 diff --git a/docs/usage-deployment-manual.md b/docs/usage-deployment-manual.md index dc911db..331a3fc 100644 --- a/docs/usage-deployment-manual.md +++ b/docs/usage-deployment-manual.md @@ -41,9 +41,10 @@ reset-local-data.bat 1. 登录 `/admin/login`。 2. 进入“项目管理”,维护 `DBY`、`QSX` 等项目代码。项目支持搜索、修改、启用和停用。 3. 进入“学员管理”,手动新增、查询、修改或删除学员。 -4. 进入“证书管理”,手动新增证书,支持单张预览、PDF 下载、作废、重置直达链接和导出。 +4. 进入“证书管理”,手动新增证书,支持单张预览、PDF 下载、作废、重置直达链接、按条件筛选、勾选后批量预生成 PDF 和导出。 5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果,确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期;证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。 “确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。 + 确认导入后,可以在该批次行点击“预生成PDF”,系统会提前为这一批有效证书生成 PDF,并在弹窗里显示总数、已完成、新生成、已缓存、跳过和失败数量。 6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2`。 7. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。 @@ -65,6 +66,13 @@ PDF 由服务端按需生成,并使用 30 天缓存。这样可以保证模板 首次生成 PDF 会占用 CPU。后台“系统设置”里的 `PDF 生成并发限制` 默认是 `2`,用于防止多人同时首次下载 PDF 时把 2 核 4G 服务器打满。前端下载和预览时会显示“当前查询人数较多,请稍后。。。”,避免用户误以为页面卡死。 +如果一次性导入大量毕业学员,建议管理员先做批量预生成: + +1. 进入“Excel 导入”,找到已确认导入的批次,点击“预生成PDF”。 +2. 或进入“证书管理”,按导入批次、PDF 状态、项目或关键词筛选,勾选需要提前生成的人,再点击“预生成所选PDF”。 +3. 批量预生成会继续遵守 `PDF 生成并发限制`,不会绕过系统并发保护。 +4. 预生成任务只在当前后端进程内记录进度;如果服务重启,已生成的 PDF 缓存仍然保留,但任务进度弹窗不会恢复。 + ## 正式部署准备 - Linux 服务器,建议 2 核 4G 起步。 @@ -134,6 +142,7 @@ docker compose exec backend pytest - 项目新增、搜索、修改、启用、停用。 - 学员手动新增、查询、修改、删除。 - 证书新增、查询、后台预览、后台下载、作废、重置直达链接。 +- 证书按导入批次筛选、按 PDF 状态筛选、批量预生成 PDF。 - Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、发证日期。 - 导入批次原文件下载和导入批次删除。 - 导入后自动生成证书编号、直达链接 token、二维码 token。 diff --git a/docs/正式部署操作手册.md b/docs/正式部署操作手册.md index cd89759..5389778 100644 --- a/docs/正式部署操作手册.md +++ b/docs/正式部署操作手册.md @@ -516,6 +516,7 @@ https://你的域名/admin/login - 证书编号自动生成。 - 能预览证书。 - 能下载 PDF。 +- 能按导入批次或 PDF 状态筛选证书,并勾选多张证书批量预生成 PDF。 - PDF 底图、姓名、日期、课程、导师、二维码清晰。 - 作废证书后不能下载正常 PDF。 - 重置链接后旧链接失效。 @@ -538,6 +539,7 @@ https://你的域名/admin/login - 错误数据能生成错误报告。 - 校验通过后能确认导入。 - 确认导入后能生成学员和证书。 +- 已导入批次能点击“预生成PDF”,进度弹窗显示总数、已完成和失败数。 - 能下载导入原文件。 ### 9.6 公开查询 diff --git a/docs/阿里云正式部署详细操作手册.md b/docs/阿里云正式部署详细操作手册.md index 20d0ae9..61318bf 100644 --- a/docs/阿里云正式部署详细操作手册.md +++ b/docs/阿里云正式部署详细操作手册.md @@ -513,6 +513,7 @@ https://你的域名/admin/login - 证书名称使用项目名称。 - 能预览证书。 - 能下载 PDF。 +- 能按导入批次或 PDF 状态筛选证书,并勾选多张证书批量预生成 PDF。 - 证书日期、课程标题、导师、二维码显示正常。 - 能作废证书。 - 作废证书不能下载正常 PDF。 @@ -536,6 +537,7 @@ https://你的域名/admin/login - 能确认导入。 - 确认导入后状态变成已导入。 - 不能重复确认导入。 +- 已导入批次能点击“预生成PDF”,进度弹窗显示总数、已完成和失败数。 ### 13.6 公开查询 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index cab7d09..ff06395 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -62,6 +62,8 @@ export interface Learner { export interface AdminCertificate { id: number; learner_id: number; + learner_name: string | null; + import_batch_id: number | null; project_code: string; certificate_no: string; certificate_name: string; @@ -124,3 +126,19 @@ export interface PdfSettings { pdf_generation_concurrency_limit: number; updated_at: string | null; } + +export interface PdfPregenerationJob { + id: string; + status: string; + total: number; + completed: number; + generated: number; + cached: number; + skipped: number; + failed: number; + percent: number; + concurrency_limit: number; + errors: string[]; + created_at: string; + finished_at: string | null; +} diff --git a/frontend/src/views/AdminCertificates.vue b/frontend/src/views/AdminCertificates.vue index fcb2fa8..9956320 100644 --- a/frontend/src/views/AdminCertificates.vue +++ b/frontend/src/views/AdminCertificates.vue @@ -3,7 +3,7 @@ 证书管理 - 支持单张新增、预览、下载、作废和链接重置。 + 支持单张新增、预览、下载、作废、链接重置和批量预生成PDF。 新增证书 @@ -44,16 +44,32 @@ + + + + + + + + 搜索 + 预生成所选PDF + 预生成当前批次全部 导出 - + + - + + + + {{ pdfStatusText(row.pdf_status) }} + + {{ statusText(row.status) }} @@ -90,23 +106,55 @@ + + + + + + 总数{{ pregenerationJob.total }} + 已完成{{ pregenerationJob.completed }} + 新生成{{ pregenerationJob.generated }} + 已缓存{{ pregenerationJob.cached }} + 跳过{{ pregenerationJob.skipped }} + 失败{{ pregenerationJob.failed }} + + 当前任务按系统设置的最大并发数执行:{{ pregenerationJob.concurrency_limit }} + + + {{ item }} + + + diff --git a/frontend/src/views/AdminImports.vue b/frontend/src/views/AdminImports.vue index d72e1e0..37204c0 100644 --- a/frontend/src/views/AdminImports.vue +++ b/frontend/src/views/AdminImports.vue @@ -34,7 +34,7 @@ - + 下载原文件 确认导入 + + 预生成PDF + 错误报告 删除 + + + + + + 总数{{ pregenerationJob.total }} + 已完成{{ pregenerationJob.completed }} + 新生成{{ pregenerationJob.generated }} + 已缓存{{ pregenerationJob.cached }} + 跳过{{ pregenerationJob.skipped }} + 失败{{ pregenerationJob.failed }} + + 当前任务按系统设置的最大并发数执行:{{ pregenerationJob.concurrency_limit }} + + + {{ item }} + + +
支持单张新增、预览、下载、作废和链接重置。
支持单张新增、预览、下载、作废、链接重置和批量预生成PDF。
当前任务按系统设置的最大并发数执行:{{ pregenerationJob.concurrency_limit }}