新增PDF批量预生成
This commit is contained in:
@@ -45,7 +45,7 @@ reset-local-data.bat
|
|||||||
|
|
||||||
- 学员手动新增、查询、修改、删除。
|
- 学员手动新增、查询、修改、删除。
|
||||||
- 项目新增、搜索、修改、启用、停用。
|
- 项目新增、搜索、修改、启用、停用。
|
||||||
- 证书手动新增、后台预览、后台下载、作废、重置链接。
|
- 证书手动新增、后台预览、后台下载、作废、重置链接、批量预生成 PDF。
|
||||||
- Excel 模板下载、导入、确认导入、导出证书。
|
- Excel 模板下载、导入、确认导入、导出证书。
|
||||||
- 操作日志中文显示。
|
- 操作日志中文显示。
|
||||||
- 公开查询(支持证书编号+姓名+图形验证码、手机号+短信验证码两种方式)、证书直达页、PDF 下载。
|
- 公开查询(支持证书编号+姓名+图形验证码、手机号+短信验证码两种方式)、证书直达页、PDF 下载。
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -7,10 +7,11 @@ from app.api.deps import require_roles
|
|||||||
from app.core.security import generate_public_token, hash_token
|
from app.core.security import generate_public_token, hash_token
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner, ProjectCourse
|
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.certificate_number import build_certificate_no
|
||||||
from app.services.logs import log_action
|
from app.services.logs import log_action
|
||||||
from app.services.pdf import PdfGenerationBusy, render_certificate_pdf
|
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
|
from app.services.system_settings import get_pdf_generation_concurrency_limit
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -33,10 +34,12 @@ def _create_token(db: Session, certificate_id: int, token_type: str) -> int:
|
|||||||
def list_certificates(
|
def list_certificates(
|
||||||
keyword: str | None = Query(default=None),
|
keyword: str | None = Query(default=None),
|
||||||
status_value: str | None = Query(default=None, alias="status"),
|
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),
|
db: Session = Depends(get_db),
|
||||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||||
) -> list[Certificate]:
|
) -> list[dict[str, object]]:
|
||||||
query = db.query(Certificate).order_by(Certificate.id.desc())
|
query = db.query(Certificate, Learner).outerjoin(Learner, Learner.id == Certificate.learner_id).order_by(Certificate.id.desc())
|
||||||
if keyword:
|
if keyword:
|
||||||
like = f"%{keyword}%"
|
like = f"%{keyword}%"
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
@@ -46,11 +49,16 @@ def list_certificates(
|
|||||||
Certificate.project_code.like(like),
|
Certificate.project_code.like(like),
|
||||||
Certificate.course_name.like(like),
|
Certificate.course_name.like(like),
|
||||||
Certificate.stage_name.like(like),
|
Certificate.stage_name.like(like),
|
||||||
|
Learner.current_name.like(like),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if status_value:
|
if status_value:
|
||||||
query = query.filter(Certificate.status == 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)
|
@router.post("", response_model=CertificateOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -89,6 +97,51 @@ def create_certificate(
|
|||||||
return 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)
|
@router.get("/{certificate_id}", response_model=CertificateOut)
|
||||||
def get_certificate(
|
def get_certificate(
|
||||||
certificate_id: int,
|
certificate_id: int,
|
||||||
@@ -101,6 +154,67 @@ def get_certificate(
|
|||||||
return 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")
|
@router.get("/{certificate_id}/preview")
|
||||||
def preview_certificate(
|
def preview_certificate(
|
||||||
certificate_id: int,
|
certificate_id: int,
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ def confirm_import_batch(
|
|||||||
|
|
||||||
certificate = Certificate(
|
certificate = Certificate(
|
||||||
learner_id=learner.id,
|
learner_id=learner.id,
|
||||||
|
import_batch_id=batch.id,
|
||||||
project_code=project.code,
|
project_code=project.code,
|
||||||
certificate_no="PENDING",
|
certificate_no="PENDING",
|
||||||
certificate_name=project.default_certificate_name,
|
certificate_name=project.default_certificate_name,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class Certificate(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
learner_id: Mapped[int] = mapped_column(index=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)
|
project_code: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
certificate_no: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
certificate_no: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
certificate_name: Mapped[str] = mapped_column(String(128))
|
certificate_name: Mapped[str] = mapped_column(String(128))
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class CertificateCreate(BaseModel):
|
|||||||
class CertificateOut(BaseModel):
|
class CertificateOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
learner_id: int
|
learner_id: int
|
||||||
|
learner_name: str | None = None
|
||||||
|
import_batch_id: int | None = None
|
||||||
project_code: str
|
project_code: str
|
||||||
certificate_no: str
|
certificate_no: str
|
||||||
certificate_name: str
|
certificate_name: str
|
||||||
@@ -37,3 +39,24 @@ class CertificateOut(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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",
|
"update_learner": "\u4fee\u6539\u5b66\u5458",
|
||||||
"delete_learner": "\u5220\u9664\u5b66\u5458",
|
"delete_learner": "\u5220\u9664\u5b66\u5458",
|
||||||
"create_certificate": "\u65b0\u589e\u8bc1\u4e66",
|
"create_certificate": "\u65b0\u589e\u8bc1\u4e66",
|
||||||
|
"start_pdf_pregeneration": "\u542f\u52a8PDF\u6279\u91cf\u9884\u751f\u6210",
|
||||||
"void_certificate": "\u4f5c\u5e9f\u8bc1\u4e66",
|
"void_certificate": "\u4f5c\u5e9f\u8bc1\u4e66",
|
||||||
"regenerate_public_token": "\u91cd\u7f6e\u8bc1\u4e66\u94fe\u63a5",
|
"regenerate_public_token": "\u91cd\u7f6e\u8bc1\u4e66\u94fe\u63a5",
|
||||||
"upload_import_file": "\u4e0a\u4f20\u5bfc\u5165\u6587\u4ef6",
|
"upload_import_file": "\u4e0a\u4f20\u5bfc\u5165\u6587\u4ef6",
|
||||||
@@ -49,6 +50,7 @@ MEDIUM_RISK_ACTIONS = {
|
|||||||
"update_learner",
|
"update_learner",
|
||||||
"confirm_import_batch",
|
"confirm_import_batch",
|
||||||
"create_certificate",
|
"create_certificate",
|
||||||
|
"start_pdf_pregeneration",
|
||||||
"create_database_backup",
|
"create_database_backup",
|
||||||
"update_pdf_settings",
|
"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"}:
|
if action in {"create_certificate", "void_certificate", "regenerate_public_token"}:
|
||||||
learner_name = detail.get("learner_name")
|
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 "")
|
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"}:
|
if action in {"upload_import_file", "delete_import_batch"}:
|
||||||
return f"{label}\uff1a{detail.get('filename') or object_id}"
|
return f"{label}\uff1a{detail.get('filename') or object_id}"
|
||||||
if action == "confirm_import_batch":
|
if action == "confirm_import_batch":
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ def update_pdf_access_time(certificate: Certificate) -> None:
|
|||||||
certificate.pdf_last_accessed_at = datetime.utcnow()
|
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:
|
def certificate_template_path() -> Path:
|
||||||
assets_dir = Path(__file__).resolve().parents[1] / "assets"
|
assets_dir = Path(__file__).resolve().parents[1] / "assets"
|
||||||
for name in ["certificate-template.png", "certificate-template.jpg"]:
|
for name in ["certificate-template.png", "certificate-template.jpg"]:
|
||||||
@@ -88,24 +94,20 @@ def render_certificate_pdf(
|
|||||||
template_path = certificate_template_path()
|
template_path = certificate_template_path()
|
||||||
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).stat().st_mtime)
|
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:
|
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
|
||||||
# 更新访问时间
|
mark_pdf_generated(certificate, output_path)
|
||||||
update_pdf_access_time(certificate)
|
|
||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
pdf_generation_limiter.acquire(max(1, concurrency_limit))
|
pdf_generation_limiter.acquire(max(1, concurrency_limit))
|
||||||
try:
|
try:
|
||||||
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_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
|
return output_path
|
||||||
image = render_certificate_image(certificate, learner, project)
|
image = render_certificate_image(certificate, learner, project)
|
||||||
image.save(output_path, "PDF", resolution=pdf_resolution(image))
|
image.save(output_path, "PDF", resolution=pdf_resolution(image))
|
||||||
finally:
|
finally:
|
||||||
pdf_generation_limiter.release()
|
pdf_generation_limiter.release()
|
||||||
|
|
||||||
certificate.pdf_status = "generated"
|
mark_pdf_generated(certificate, output_path)
|
||||||
certificate.pdf_file_path = str(output_path)
|
|
||||||
# 更新访问时间
|
|
||||||
update_pdf_access_time(certificate)
|
|
||||||
return 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()
|
||||||
31
backend/tests/test_pdf_pregeneration.py
Normal file
31
backend/tests/test_pdf_pregeneration.py
Normal file
@@ -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
|
||||||
@@ -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.
|
- 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: 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.
|
- Current limitation: mentor, issuer names, and QR area remain from the approved base image for visual consistency.
|
||||||
|
|
||||||
|
### C012 PDF 批量预生成
|
||||||
|
|
||||||
|
- 决定:后台提供批量预生成 PDF 能力,可按导入批次生成,也可在证书管理中筛选和勾选后生成。
|
||||||
|
- 策略:预生成继续使用系统设置里的 `PDF 生成并发限制`,不单独开无限制后台任务。
|
||||||
|
- 原因:大批量学员毕业前先生成缓存,可以降低公开查询高峰的等待时间和 CPU 压力。
|
||||||
|
- 当前限制:任务进度保存在后端进程内,服务重启后只保留已生成的 PDF 缓存,不恢复历史进度。
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
|
|||||||
- 系统设置里的 `PDF 生成并发限制` 已确认,2 核 4G 建议保持默认 `2`。
|
- 系统设置里的 `PDF 生成并发限制` 已确认,2 核 4G 建议保持默认 `2`。
|
||||||
- Excel 导入模板已下载并试填。
|
- Excel 导入模板已下载并试填。
|
||||||
- 占位证书模板已确认可临时使用。
|
- 占位证书模板已确认可临时使用。
|
||||||
|
- 大批量毕业前的操作约定已确认:确认导入后先在“Excel 导入”或“证书管理”里批量预生成 PDF。
|
||||||
|
|
||||||
## 验收动作
|
## 验收动作
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
|
|||||||
- 可新增学员。
|
- 可新增学员。
|
||||||
- 可新增证书并生成编号。
|
- 可新增证书并生成编号。
|
||||||
- 可上传 Excel 并确认导入。
|
- 可上传 Excel 并确认导入。
|
||||||
|
- 已导入批次可以启动“预生成PDF”,进度弹窗能显示总数、已完成、失败数和并发数。
|
||||||
|
- 证书管理页可按导入批次和 PDF 状态筛选,并能勾选多张证书批量预生成。
|
||||||
- 可导出证书和直达链接。
|
- 可导出证书和直达链接。
|
||||||
- 公开查询支持两种方式:
|
- 公开查询支持两种方式:
|
||||||
- 证书编号查询:必须输入姓名和图形验证码。
|
- 证书编号查询:必须输入姓名和图形验证码。
|
||||||
|
|||||||
@@ -41,9 +41,10 @@ reset-local-data.bat
|
|||||||
1. 登录 `/admin/login`。
|
1. 登录 `/admin/login`。
|
||||||
2. 进入“项目管理”,维护 `DBY`、`QSX` 等项目代码。项目支持搜索、修改、启用和停用。
|
2. 进入“项目管理”,维护 `DBY`、`QSX` 等项目代码。项目支持搜索、修改、启用和停用。
|
||||||
3. 进入“学员管理”,手动新增、查询、修改或删除学员。
|
3. 进入“学员管理”,手动新增、查询、修改或删除学员。
|
||||||
4. 进入“证书管理”,手动新增证书,支持单张预览、PDF 下载、作废、重置直达链接和导出。
|
4. 进入“证书管理”,手动新增证书,支持单张预览、PDF 下载、作废、重置直达链接、按条件筛选、勾选后批量预生成 PDF 和导出。
|
||||||
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果,确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期;证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
|
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果,确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期;证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
|
||||||
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
|
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
|
||||||
|
确认导入后,可以在该批次行点击“预生成PDF”,系统会提前为这一批有效证书生成 PDF,并在弹窗里显示总数、已完成、新生成、已缓存、跳过和失败数量。
|
||||||
6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2`。
|
6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2`。
|
||||||
7. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。
|
7. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。
|
||||||
|
|
||||||
@@ -65,6 +66,13 @@ PDF 由服务端按需生成,并使用 30 天缓存。这样可以保证模板
|
|||||||
|
|
||||||
首次生成 PDF 会占用 CPU。后台“系统设置”里的 `PDF 生成并发限制` 默认是 `2`,用于防止多人同时首次下载 PDF 时把 2 核 4G 服务器打满。前端下载和预览时会显示“当前查询人数较多,请稍后。。。”,避免用户误以为页面卡死。
|
首次生成 PDF 会占用 CPU。后台“系统设置”里的 `PDF 生成并发限制` 默认是 `2`,用于防止多人同时首次下载 PDF 时把 2 核 4G 服务器打满。前端下载和预览时会显示“当前查询人数较多,请稍后。。。”,避免用户误以为页面卡死。
|
||||||
|
|
||||||
|
如果一次性导入大量毕业学员,建议管理员先做批量预生成:
|
||||||
|
|
||||||
|
1. 进入“Excel 导入”,找到已确认导入的批次,点击“预生成PDF”。
|
||||||
|
2. 或进入“证书管理”,按导入批次、PDF 状态、项目或关键词筛选,勾选需要提前生成的人,再点击“预生成所选PDF”。
|
||||||
|
3. 批量预生成会继续遵守 `PDF 生成并发限制`,不会绕过系统并发保护。
|
||||||
|
4. 预生成任务只在当前后端进程内记录进度;如果服务重启,已生成的 PDF 缓存仍然保留,但任务进度弹窗不会恢复。
|
||||||
|
|
||||||
## 正式部署准备
|
## 正式部署准备
|
||||||
|
|
||||||
- Linux 服务器,建议 2 核 4G 起步。
|
- Linux 服务器,建议 2 核 4G 起步。
|
||||||
@@ -134,6 +142,7 @@ docker compose exec backend pytest
|
|||||||
- 项目新增、搜索、修改、启用、停用。
|
- 项目新增、搜索、修改、启用、停用。
|
||||||
- 学员手动新增、查询、修改、删除。
|
- 学员手动新增、查询、修改、删除。
|
||||||
- 证书新增、查询、后台预览、后台下载、作废、重置直达链接。
|
- 证书新增、查询、后台预览、后台下载、作废、重置直达链接。
|
||||||
|
- 证书按导入批次筛选、按 PDF 状态筛选、批量预生成 PDF。
|
||||||
- Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、发证日期。
|
- Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、发证日期。
|
||||||
- 导入批次原文件下载和导入批次删除。
|
- 导入批次原文件下载和导入批次删除。
|
||||||
- 导入后自动生成证书编号、直达链接 token、二维码 token。
|
- 导入后自动生成证书编号、直达链接 token、二维码 token。
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ https://你的域名/admin/login
|
|||||||
- 证书编号自动生成。
|
- 证书编号自动生成。
|
||||||
- 能预览证书。
|
- 能预览证书。
|
||||||
- 能下载 PDF。
|
- 能下载 PDF。
|
||||||
|
- 能按导入批次或 PDF 状态筛选证书,并勾选多张证书批量预生成 PDF。
|
||||||
- PDF 底图、姓名、日期、课程、导师、二维码清晰。
|
- PDF 底图、姓名、日期、课程、导师、二维码清晰。
|
||||||
- 作废证书后不能下载正常 PDF。
|
- 作废证书后不能下载正常 PDF。
|
||||||
- 重置链接后旧链接失效。
|
- 重置链接后旧链接失效。
|
||||||
@@ -538,6 +539,7 @@ https://你的域名/admin/login
|
|||||||
- 错误数据能生成错误报告。
|
- 错误数据能生成错误报告。
|
||||||
- 校验通过后能确认导入。
|
- 校验通过后能确认导入。
|
||||||
- 确认导入后能生成学员和证书。
|
- 确认导入后能生成学员和证书。
|
||||||
|
- 已导入批次能点击“预生成PDF”,进度弹窗显示总数、已完成和失败数。
|
||||||
- 能下载导入原文件。
|
- 能下载导入原文件。
|
||||||
|
|
||||||
### 9.6 公开查询
|
### 9.6 公开查询
|
||||||
|
|||||||
@@ -513,6 +513,7 @@ https://你的域名/admin/login
|
|||||||
- 证书名称使用项目名称。
|
- 证书名称使用项目名称。
|
||||||
- 能预览证书。
|
- 能预览证书。
|
||||||
- 能下载 PDF。
|
- 能下载 PDF。
|
||||||
|
- 能按导入批次或 PDF 状态筛选证书,并勾选多张证书批量预生成 PDF。
|
||||||
- 证书日期、课程标题、导师、二维码显示正常。
|
- 证书日期、课程标题、导师、二维码显示正常。
|
||||||
- 能作废证书。
|
- 能作废证书。
|
||||||
- 作废证书不能下载正常 PDF。
|
- 作废证书不能下载正常 PDF。
|
||||||
@@ -536,6 +537,7 @@ https://你的域名/admin/login
|
|||||||
- 能确认导入。
|
- 能确认导入。
|
||||||
- 确认导入后状态变成已导入。
|
- 确认导入后状态变成已导入。
|
||||||
- 不能重复确认导入。
|
- 不能重复确认导入。
|
||||||
|
- 已导入批次能点击“预生成PDF”,进度弹窗显示总数、已完成和失败数。
|
||||||
|
|
||||||
### 13.6 公开查询
|
### 13.6 公开查询
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface Learner {
|
|||||||
export interface AdminCertificate {
|
export interface AdminCertificate {
|
||||||
id: number;
|
id: number;
|
||||||
learner_id: number;
|
learner_id: number;
|
||||||
|
learner_name: string | null;
|
||||||
|
import_batch_id: number | null;
|
||||||
project_code: string;
|
project_code: string;
|
||||||
certificate_no: string;
|
certificate_no: string;
|
||||||
certificate_name: string;
|
certificate_name: string;
|
||||||
@@ -124,3 +126,19 @@ export interface PdfSettings {
|
|||||||
pdf_generation_concurrency_limit: number;
|
pdf_generation_concurrency_limit: number;
|
||||||
updated_at: string | null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<header class="page-head">
|
<header class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>证书管理</h2>
|
<h2>证书管理</h2>
|
||||||
<p>支持单张新增、预览、下载、作废和链接重置。</p>
|
<p>支持单张新增、预览、下载、作废、链接重置和批量预生成PDF。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="createCertificate">新增证书</el-button>
|
<el-button type="primary" @click="createCertificate">新增证书</el-button>
|
||||||
</header>
|
</header>
|
||||||
@@ -44,16 +44,32 @@
|
|||||||
<el-option label="有效" value="valid" />
|
<el-option label="有效" value="valid" />
|
||||||
<el-option label="已作废" value="voided" />
|
<el-option label="已作废" value="voided" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-select v-model="pdfStatusValue" clearable placeholder="PDF状态">
|
||||||
|
<el-option label="未生成" value="not_generated" />
|
||||||
|
<el-option label="已生成" value="generated" />
|
||||||
|
<el-option label="已清理" value="cleaned" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="importBatchId" clearable filterable placeholder="导入批次">
|
||||||
|
<el-option v-for="item in importBatches" :key="item.id" :label="`#${item.id} ${item.filename}`" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
<el-button @click="loadCertificates">搜索</el-button>
|
<el-button @click="loadCertificates">搜索</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedCertificates.length" :loading="pregenerationStarting" @click="startPregenerationSelected">预生成所选PDF</el-button>
|
||||||
|
<el-button :disabled="!importBatchId" :loading="pregenerationStarting" @click="startPregenerationBatch">预生成当前批次全部</el-button>
|
||||||
<el-button @click="exportCertificates">导出</el-button>
|
<el-button @click="exportCertificates">导出</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="certificates" border>
|
<el-table :data="certificates" border @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column type="selection" width="44" />
|
||||||
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
|
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
|
||||||
<el-table-column prop="learner_id" label="学员ID" width="76" />
|
<el-table-column prop="learner_name" label="学员姓名" width="100" show-overflow-tooltip />
|
||||||
<el-table-column prop="project_code" label="项目" width="72" />
|
<el-table-column prop="project_code" label="项目" width="72" />
|
||||||
<el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
|
<el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column prop="issue_date" label="发证日期" width="108" />
|
<el-table-column prop="issue_date" label="发证日期" width="108" />
|
||||||
|
<el-table-column label="PDF" width="92">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.pdf_status === 'generated' ? 'success' : 'info'">{{ pdfStatusText(row.pdf_status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="80">
|
<el-table-column label="状态" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag>
|
<el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag>
|
||||||
@@ -90,23 +106,55 @@
|
|||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="pregenerationVisible" title="批量预生成PDF" width="560px" @closed="stopPregenerationPolling">
|
||||||
|
<div v-if="pregenerationJob" class="pregeneration-panel">
|
||||||
|
<el-progress :percentage="pregenerationJob.percent" />
|
||||||
|
<dl class="pregeneration-stats">
|
||||||
|
<div><dt>总数</dt><dd>{{ pregenerationJob.total }}</dd></div>
|
||||||
|
<div><dt>已完成</dt><dd>{{ pregenerationJob.completed }}</dd></div>
|
||||||
|
<div><dt>新生成</dt><dd>{{ pregenerationJob.generated }}</dd></div>
|
||||||
|
<div><dt>已缓存</dt><dd>{{ pregenerationJob.cached }}</dd></div>
|
||||||
|
<div><dt>跳过</dt><dd>{{ pregenerationJob.skipped }}</dd></div>
|
||||||
|
<div><dt>失败</dt><dd>{{ pregenerationJob.failed }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="progress-note">当前任务按系统设置的最大并发数执行:{{ pregenerationJob.concurrency_limit }}</p>
|
||||||
|
<el-alert
|
||||||
|
v-if="pregenerationDone"
|
||||||
|
:type="pregenerationJob.failed ? 'warning' : 'success'"
|
||||||
|
:closable="false"
|
||||||
|
:title="pregenerationJob.failed ? '预生成已完成,但有部分失败。' : '预生成已完成。'"
|
||||||
|
/>
|
||||||
|
<ul v-if="pregenerationJob.errors.length" class="error-list">
|
||||||
|
<li v-for="item in pregenerationJob.errors" :key="item">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElLoading, ElMessage, ElMessageBox } from "element-plus";
|
import { ElLoading, ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
import { http, type AdminCertificate, type ProjectCourse } from "../api";
|
import { http, type AdminCertificate, type ImportBatch, type PdfPregenerationJob, type ProjectCourse } from "../api";
|
||||||
import { apiErrorMessage, downloadFile } from "../download";
|
import { apiErrorMessage, downloadFile } from "../download";
|
||||||
|
|
||||||
const certificates = ref<AdminCertificate[]>([]);
|
const certificates = ref<AdminCertificate[]>([]);
|
||||||
const projects = ref<ProjectCourse[]>([]);
|
const projects = ref<ProjectCourse[]>([]);
|
||||||
|
const importBatches = ref<ImportBatch[]>([]);
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const statusValue = ref("");
|
const statusValue = ref("");
|
||||||
|
const pdfStatusValue = ref("");
|
||||||
|
const importBatchId = ref<number | undefined>();
|
||||||
const previewVisible = ref(false);
|
const previewVisible = ref(false);
|
||||||
const preview = ref<any>(null);
|
const preview = ref<any>(null);
|
||||||
const downloadingCertificateId = ref<number | null>(null);
|
const downloadingCertificateId = ref<number | null>(null);
|
||||||
|
const selectedCertificates = ref<AdminCertificate[]>([]);
|
||||||
|
const pregenerationStarting = ref(false);
|
||||||
|
const pregenerationVisible = ref(false);
|
||||||
|
const pregenerationJob = ref<PdfPregenerationJob | null>(null);
|
||||||
|
let pregenerationTimer: number | null = null;
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
learner_id: undefined as number | undefined,
|
learner_id: undefined as number | undefined,
|
||||||
project_code: "",
|
project_code: "",
|
||||||
@@ -123,18 +171,38 @@ function statusText(status: string) {
|
|||||||
return status === "valid" ? "有效" : "已作废";
|
return status === "valid" ? "有效" : "已作废";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pdfStatusText(status: string) {
|
||||||
|
return { generated: "已生成", cleaned: "已清理", not_generated: "未生成" }[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
|
||||||
|
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
const { data } = await http.get<ProjectCourse[]>("/admin/projects");
|
const { data } = await http.get<ProjectCourse[]>("/admin/projects");
|
||||||
projects.value = data;
|
projects.value = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadImportBatches() {
|
||||||
|
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
|
||||||
|
importBatches.value = data;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCertificates() {
|
async function loadCertificates() {
|
||||||
const { data } = await http.get<AdminCertificate[]>("/admin/certificates", {
|
const { data } = await http.get<AdminCertificate[]>("/admin/certificates", {
|
||||||
params: { keyword: keyword.value || undefined, status: statusValue.value || undefined },
|
params: {
|
||||||
|
keyword: keyword.value || undefined,
|
||||||
|
status: statusValue.value || undefined,
|
||||||
|
pdf_status: pdfStatusValue.value || undefined,
|
||||||
|
import_batch_id: importBatchId.value || undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
certificates.value = data;
|
certificates.value = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(rows: AdminCertificate[]) {
|
||||||
|
selectedCertificates.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
async function createCertificate() {
|
async function createCertificate() {
|
||||||
if (!form.learner_id || !form.project_code || !form.issue_date || !form.issuer_name) {
|
if (!form.learner_id || !form.project_code || !form.issue_date || !form.issuer_name) {
|
||||||
ElMessage.warning("请填写学员ID、项目、发证日期和发证单位");
|
ElMessage.warning("请填写学员ID、项目、发证日期和发证单位");
|
||||||
@@ -192,9 +260,58 @@ function exportCertificates() {
|
|||||||
downloadFile("/admin/exports/certificates", "certificates-export.xlsx");
|
downloadFile("/admin/exports/certificates", "certificates-export.xlsx");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startPregenerationSelected() {
|
||||||
|
const ids = selectedCertificates.value.map((item) => item.id);
|
||||||
|
if (!ids.length) {
|
||||||
|
ElMessage.warning("请先勾选证书");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startPregeneration({ certificate_ids: ids });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPregenerationBatch() {
|
||||||
|
if (!importBatchId.value) return;
|
||||||
|
await startPregeneration({ import_batch_id: importBatchId.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPregeneration(payload: { certificate_ids?: number[]; import_batch_id?: number }) {
|
||||||
|
pregenerationStarting.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.post<PdfPregenerationJob>("/admin/certificates/pdf-pregeneration-jobs", payload);
|
||||||
|
pregenerationJob.value = data;
|
||||||
|
pregenerationVisible.value = true;
|
||||||
|
startPregenerationPolling(data.id);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(await apiErrorMessage(error, "启动PDF预生成失败"));
|
||||||
|
} finally {
|
||||||
|
pregenerationStarting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPregenerationPolling(jobId: string) {
|
||||||
|
stopPregenerationPolling();
|
||||||
|
pregenerationTimer = window.setInterval(async () => {
|
||||||
|
const { data } = await http.get<PdfPregenerationJob>(`/admin/certificates/pdf-pregeneration-jobs/${jobId}`);
|
||||||
|
pregenerationJob.value = data;
|
||||||
|
if (["completed", "completed_with_errors"].includes(data.status)) {
|
||||||
|
stopPregenerationPolling();
|
||||||
|
await loadCertificates();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPregenerationPolling() {
|
||||||
|
if (pregenerationTimer) {
|
||||||
|
clearInterval(pregenerationTimer);
|
||||||
|
pregenerationTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadProjects(), loadCertificates()]);
|
await Promise.all([loadProjects(), loadImportBatches(), loadCertificates()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(stopPregenerationPolling);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -231,6 +348,10 @@ onMounted(async () => {
|
|||||||
max-width: 340px;
|
max-width: 340px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar .el-select {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-tip {
|
.form-tip {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
@@ -247,4 +368,45 @@ onMounted(async () => {
|
|||||||
.table-actions :deep(.el-button + .el-button) {
|
.table-actions :deep(.el-button + .el-button) {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pregeneration-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregeneration-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregeneration-stats div {
|
||||||
|
border: 1px solid #e2edf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregeneration-stats dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregeneration-stats dd {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-note {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<el-table-column prop="valid_rows" label="可导入" width="100" />
|
<el-table-column prop="valid_rows" label="可导入" width="100" />
|
||||||
<el-table-column prop="failed_rows" label="失败" width="100" />
|
<el-table-column prop="failed_rows" label="失败" width="100" />
|
||||||
<el-table-column prop="created_at" label="上传时间" width="190" />
|
<el-table-column prop="created_at" label="上传时间" width="190" />
|
||||||
<el-table-column label="操作" width="380">
|
<el-table-column label="操作" width="470">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="downloadSource(row)">下载原文件</el-button>
|
<el-button size="small" @click="downloadSource(row)">下载原文件</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -46,27 +46,65 @@
|
|||||||
>
|
>
|
||||||
确认导入
|
确认导入
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'imported'"
|
||||||
|
size="small"
|
||||||
|
:loading="preGeneratingId === row.id"
|
||||||
|
@click="startPregeneration(row)"
|
||||||
|
>
|
||||||
|
预生成PDF
|
||||||
|
</el-button>
|
||||||
<el-button v-if="row.error_report_path" size="small" @click="downloadErrorReport(row)">错误报告</el-button>
|
<el-button v-if="row.error_report_path" size="small" @click="downloadErrorReport(row)">错误报告</el-button>
|
||||||
<el-button size="small" type="danger" @click="deleteBatch(row)">删除</el-button>
|
<el-button size="small" type="danger" @click="deleteBatch(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="pregenerationVisible" title="批量预生成PDF" width="560px" @closed="stopPregenerationPolling">
|
||||||
|
<div v-if="pregenerationJob" class="pregeneration-panel">
|
||||||
|
<el-progress :percentage="pregenerationJob.percent" />
|
||||||
|
<dl class="pregeneration-stats">
|
||||||
|
<div><dt>总数</dt><dd>{{ pregenerationJob.total }}</dd></div>
|
||||||
|
<div><dt>已完成</dt><dd>{{ pregenerationJob.completed }}</dd></div>
|
||||||
|
<div><dt>新生成</dt><dd>{{ pregenerationJob.generated }}</dd></div>
|
||||||
|
<div><dt>已缓存</dt><dd>{{ pregenerationJob.cached }}</dd></div>
|
||||||
|
<div><dt>跳过</dt><dd>{{ pregenerationJob.skipped }}</dd></div>
|
||||||
|
<div><dt>失败</dt><dd>{{ pregenerationJob.failed }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="progress-note">当前任务按系统设置的最大并发数执行:{{ pregenerationJob.concurrency_limit }}</p>
|
||||||
|
<el-alert
|
||||||
|
v-if="pregenerationDone"
|
||||||
|
:type="pregenerationJob.failed ? 'warning' : 'success'"
|
||||||
|
:closable="false"
|
||||||
|
:title="pregenerationJob.failed ? '预生成已完成,但有部分失败。' : '预生成已完成。'"
|
||||||
|
/>
|
||||||
|
<ul v-if="pregenerationJob.errors.length" class="error-list">
|
||||||
|
<li v-for="item in pregenerationJob.errors" :key="item">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { UploadFilled } from "@element-plus/icons-vue";
|
import { UploadFilled } from "@element-plus/icons-vue";
|
||||||
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
|
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||||
|
|
||||||
import { http, type ImportBatch } from "../api";
|
import { http, type ImportBatch, type PdfPregenerationJob } from "../api";
|
||||||
import { downloadFile } from "../download";
|
import { apiErrorMessage, downloadFile } from "../download";
|
||||||
|
|
||||||
const batches = ref<ImportBatch[]>([]);
|
const batches = ref<ImportBatch[]>([]);
|
||||||
const selectedFile = ref<File | null>(null);
|
const selectedFile = ref<File | null>(null);
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const confirmingId = ref<number | null>(null);
|
const confirmingId = ref<number | null>(null);
|
||||||
|
const preGeneratingId = ref<number | null>(null);
|
||||||
|
const pregenerationVisible = ref(false);
|
||||||
|
const pregenerationJob = ref<PdfPregenerationJob | null>(null);
|
||||||
|
let pregenerationTimer: number | null = null;
|
||||||
|
|
||||||
|
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
|
||||||
|
|
||||||
function statusText(status: string) {
|
function statusText(status: string) {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
@@ -134,7 +172,42 @@ async function deleteBatch(row: ImportBatch) {
|
|||||||
await loadBatches();
|
await loadBatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startPregeneration(row: ImportBatch) {
|
||||||
|
preGeneratingId.value = row.id;
|
||||||
|
try {
|
||||||
|
const { data } = await http.post<PdfPregenerationJob>("/admin/certificates/pdf-pregeneration-jobs", {
|
||||||
|
import_batch_id: row.id,
|
||||||
|
});
|
||||||
|
pregenerationJob.value = data;
|
||||||
|
pregenerationVisible.value = true;
|
||||||
|
startPregenerationPolling(data.id);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(await apiErrorMessage(error, "启动PDF预生成失败"));
|
||||||
|
} finally {
|
||||||
|
preGeneratingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPregenerationPolling(jobId: string) {
|
||||||
|
stopPregenerationPolling();
|
||||||
|
pregenerationTimer = window.setInterval(async () => {
|
||||||
|
const { data } = await http.get<PdfPregenerationJob>(`/admin/certificates/pdf-pregeneration-jobs/${jobId}`);
|
||||||
|
pregenerationJob.value = data;
|
||||||
|
if (["completed", "completed_with_errors"].includes(data.status)) {
|
||||||
|
stopPregenerationPolling();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPregenerationPolling() {
|
||||||
|
if (pregenerationTimer) {
|
||||||
|
clearInterval(pregenerationTimer);
|
||||||
|
pregenerationTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadBatches);
|
onMounted(loadBatches);
|
||||||
|
onUnmounted(stopPregenerationPolling);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -163,4 +236,45 @@ onMounted(loadBatches);
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pregeneration-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregeneration-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregeneration-stats div {
|
||||||
|
border: 1px solid #e2edf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregeneration-stats dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregeneration-stats dd {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-note {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-list {
|
||||||
|
margin: 0;
|
||||||
|
color: #b91c1c;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user