新增多证书模板管理流程

This commit is contained in:
Certificate System
2026-08-13 18:06:05 +08:00
parent eac19ea593
commit abbefc4b48
27 changed files with 774 additions and 61 deletions

View File

@@ -3,6 +3,7 @@ from fastapi import APIRouter
from app.api.routes import (
admin_backups,
admin_certificates,
admin_certificate_templates,
admin_dashboard,
admin_exports,
admin_imports,
@@ -23,6 +24,7 @@ api_router.include_router(admin_projects.router, prefix="/admin/projects", tags=
api_router.include_router(admin_dashboard.router, prefix="/admin/dashboard", tags=["admin-dashboard"])
api_router.include_router(admin_learners.router, prefix="/admin/learners", tags=["admin-learners"])
api_router.include_router(admin_certificates.router, prefix="/admin/certificates", tags=["admin-certificates"])
api_router.include_router(admin_certificate_templates.router, prefix="/admin/certificate-templates", tags=["admin-certificate-templates"])
api_router.include_router(admin_imports.router, prefix="/admin/import-batches", tags=["admin-imports"])
api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["admin-exports"])
api_router.include_router(admin_logs.router, prefix="/admin/logs", tags=["admin-logs"])

View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from app.api.deps import require_roles
from app.models import AdminUser
from app.schemas.certificate_template import CertificateTemplateOut
from app.services.certificate_templates import get_certificate_template, list_certificate_templates
router = APIRouter()
@router.get("", response_model=list[CertificateTemplateOut])
def list_templates(
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[dict[str, object]]:
return [template.to_dict() for template in list_certificate_templates()]
@router.get("/{template_code}/preview")
def preview_template(template_code: str) -> FileResponse:
try:
template = get_certificate_template(template_code)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return FileResponse(template.asset_path, media_type="image/png")

View File

@@ -13,6 +13,7 @@ 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
from app.services.certificate_templates import get_certificate_template
router = APIRouter()
@@ -73,6 +74,11 @@ def create_certificate(
project = db.query(ProjectCourse).filter(ProjectCourse.code == payload.project_code, ProjectCourse.status == "active").first()
if not project:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project code is inactive or missing")
template_code = payload.template_code or project.default_template_code
try:
template_code = get_certificate_template(template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
certificate = Certificate(
learner_id=payload.learner_id,
@@ -86,6 +92,7 @@ def create_certificate(
course_end_date=payload.course_end_date,
issue_date=payload.issue_date,
issuer_name=project.default_issuer_name,
template_code=template_code,
remark=payload.remark,
)
db.add(certificate)
@@ -172,6 +179,7 @@ def _certificate_payload(certificate: Certificate, learner: Learner | None = Non
"course_end_date": certificate.course_end_date,
"issue_date": certificate.issue_date,
"issuer_name": certificate.issuer_name,
"template_code": certificate.template_code,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"created_at": certificate.created_at,
@@ -242,6 +250,7 @@ def preview_certificate(
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"template_code": certificate.template_code,
"status": certificate.status,
"public_url": f"/cert/{public_token.token_value}" if public_token else "",
"verify_url": f"/verify/{qr_token.token_value}" if qr_token else "",

View File

@@ -41,6 +41,7 @@ def export_certificates(
"证书编号",
"证书对外直达链接",
"项目代码",
"证书模板",
"证书名称",
"课程名称",
"阶段名称",
@@ -59,6 +60,7 @@ def export_certificates(
certificate.certificate_no,
f"{settings.public_base_url}/cert/{token.token_value}",
certificate.project_code,
certificate.template_code,
certificate.certificate_name,
certificate.course_name,
certificate.stage_name,

View File

@@ -3,7 +3,7 @@ import shutil
from datetime import date, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from openpyxl import Workbook, load_workbook
from openpyxl.comments import Comment
@@ -28,6 +28,7 @@ from app.models import (
from app.schemas.import_batch import ImportBatchOut
from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action
from app.services.certificate_templates import get_certificate_template
router = APIRouter()
@@ -74,18 +75,23 @@ def build_import_template_workbook() -> Workbook:
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
def upload_import_file(
template_code: str = Form(...),
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ImportBatch:
if not file.filename or not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only .xlsx files are supported")
try:
template_code = get_certificate_template(template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
upload_path = data_path("uploads") / file.filename
with upload_path.open("wb") as target:
shutil.copyfileobj(file.file, target)
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), created_by=admin.id)
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), template_code=template_code, created_by=admin.id)
db.add(batch)
db.flush()
validate_batch(db, batch, upload_path)
@@ -188,7 +194,15 @@ def confirm_import_batch(
row.error_message = f"Project code is inactive or missing: {project_code}"
failed_rows += 1
continue
duplicate = find_duplicate_certificate(db, learner.id, project, course_start_date, course_end_date, issue_date)
duplicate = find_duplicate_certificate(
db,
learner.id,
project,
course_start_date,
course_end_date,
issue_date,
batch.template_code,
)
if duplicate:
row.status = "skipped"
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
@@ -207,6 +221,7 @@ def confirm_import_batch(
course_end_date=course_end_date,
issue_date=issue_date,
issuer_name=project.default_issuer_name,
template_code=batch.template_code,
remark=None,
)
db.add(certificate)
@@ -326,6 +341,7 @@ def find_duplicate_certificate(
course_start_date: date,
course_end_date: date,
issue_date: date,
template_code: str,
) -> Certificate | None:
return (
db.query(Certificate)
@@ -337,6 +353,7 @@ def find_duplicate_certificate(
.filter(Certificate.course_start_date == course_start_date)
.filter(Certificate.course_end_date == course_end_date)
.filter(Certificate.issue_date == issue_date)
.filter(Certificate.template_code == template_code)
.first()
)

View File

@@ -6,6 +6,7 @@ from app.db.session import get_db
from app.models import AdminUser, ProjectCourse
from app.schemas.project import ProjectCourseCreate, ProjectCourseOut, ProjectCourseUpdate
from app.services.logs import diff_values, log_action
from app.services.certificate_templates import get_certificate_template
router = APIRouter()
@@ -24,6 +25,10 @@ def create_project(
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ProjectCourse:
try:
template_code = get_certificate_template(payload.default_template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if db.query(ProjectCourse).filter(ProjectCourse.code == payload.code).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目代码已存在")
project = ProjectCourse(
@@ -33,6 +38,7 @@ def create_project(
default_course_name=payload.default_course_name,
default_stage_name=payload.default_stage_name,
default_issuer_name=payload.default_issuer_name,
default_template_code=template_code,
)
db.add(project)
log_action(db, admin, "create_project", "project_course", detail={"code": payload.code, "name": payload.name, "status": project.status})
@@ -56,6 +62,7 @@ def update_project(
"default_course_name": project.default_course_name,
"default_stage_name": project.default_stage_name,
"default_issuer_name": project.default_issuer_name,
"default_template_code": project.default_template_code,
"status": project.status,
}
if payload.name is not None:
@@ -67,6 +74,12 @@ def update_project(
project.default_stage_name = payload.default_stage_name
if payload.default_issuer_name is not None:
project.default_issuer_name = payload.default_issuer_name
if payload.default_template_code is not None:
try:
template_code = get_certificate_template(payload.default_template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
project.default_template_code = template_code
if payload.status is not None:
project.status = payload.status
after = {
@@ -74,6 +87,7 @@ def update_project(
"default_course_name": project.default_course_name,
"default_stage_name": project.default_stage_name,
"default_issuer_name": project.default_issuer_name,
"default_template_code": project.default_template_code,
"status": project.status,
}
log_action(db, admin, "update_project", "project_course", project.id, {"code": project.code, "name": project.name, "changes": diff_values(before, after, list(after.keys()))})

View File

@@ -180,6 +180,7 @@ def public_certificate_payload(db: Session, certificate: Certificate, learner: L
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"template_code": certificate.template_code,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"public_token": token_value,

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

View File

@@ -37,6 +37,7 @@ def seed_defaults(db: Session, admin_username: str, admin_password: str) -> None
default_course_name=name,
default_stage_name=None,
default_issuer_name="本公司",
default_template_code="classic",
)
)

View File

@@ -16,6 +16,7 @@ class ProjectCourse(Base):
default_course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
default_stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
default_issuer_name: Mapped[str] = mapped_column(String(128), default="本公司")
default_template_code: Mapped[str] = mapped_column(String(32), default="classic")
status: Mapped[str] = mapped_column(String(32), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -37,6 +38,7 @@ class Certificate(Base):
course_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
issue_date: Mapped[date] = mapped_column(Date)
issuer_name: Mapped[str] = mapped_column(String(128))
template_code: Mapped[str] = mapped_column(String(32), default="classic", index=True)
status: Mapped[str] = mapped_column(String(32), default="valid")
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
pdf_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)

View File

@@ -12,6 +12,7 @@ class ImportBatch(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
filename: Mapped[str] = mapped_column(String(255))
file_path: Mapped[str] = mapped_column(String(512))
template_code: Mapped[str] = mapped_column(String(32), default="classic")
status: Mapped[str] = mapped_column(String(32), default="uploaded")
total_rows: Mapped[int] = mapped_column(Integer, default=0)
valid_rows: Mapped[int] = mapped_column(Integer, default=0)

View File

@@ -13,6 +13,7 @@ class CertificateCreate(BaseModel):
course_start_date: date
course_end_date: date
issue_date: date
template_code: str | None = Field(default=None, min_length=1, max_length=32)
remark: str | None = None
@field_validator("project_code")
@@ -44,6 +45,7 @@ class CertificateOut(BaseModel):
course_end_date: date | None
issue_date: date
issuer_name: str
template_code: str
status: str
pdf_status: str
created_at: datetime

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class CertificateTemplateOut(BaseModel):
code: str
name: str
description: str
dynamic_fields: list[str]
preview_url: str
status: str

View File

@@ -6,6 +6,7 @@ from pydantic import BaseModel
class ImportBatchOut(BaseModel):
id: int
filename: str
template_code: str
status: str
total_rows: int
valid_rows: int

View File

@@ -10,6 +10,7 @@ class ProjectCourseCreate(BaseModel):
default_course_name: str | None = Field(default=None, max_length=128)
default_stage_name: str | None = Field(default=None, max_length=128)
default_issuer_name: str = Field(default="本公司", min_length=1, max_length=128)
default_template_code: str = Field(default="classic", min_length=1, max_length=32)
@field_validator("code")
@classmethod
@@ -31,6 +32,7 @@ class ProjectCourseUpdate(BaseModel):
default_course_name: str | None = Field(default=None, max_length=128)
default_stage_name: str | None = Field(default=None, max_length=128)
default_issuer_name: str | None = Field(default=None, min_length=1, max_length=128)
default_template_code: str | None = Field(default=None, min_length=1, max_length=32)
status: str | None = Field(default=None, pattern="^(active|disabled)$")
@field_validator("default_certificate_name", "default_course_name", "default_stage_name")
@@ -50,6 +52,7 @@ class ProjectCourseOut(BaseModel):
default_course_name: str | None
default_stage_name: str | None
default_issuer_name: str
default_template_code: str
status: str
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,61 @@
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class CertificateTemplateDefinition:
code: str
name: str
description: str
asset_filename: str
dynamic_fields: tuple[str, ...]
status: str = "active"
@property
def asset_path(self) -> Path:
return Path(__file__).resolve().parents[1] / "assets" / self.asset_filename
def to_dict(self) -> dict[str, object]:
return {
"code": self.code,
"name": self.name,
"description": self.description,
"dynamic_fields": list(self.dynamic_fields),
"preview_url": f"/api/admin/certificate-templates/{self.code}/preview",
"status": self.status,
}
CLASSIC_TEMPLATE_CODE = "classic"
PRACTICE_CAMP_TEMPLATE_CODE = "practice-camp"
CERTIFICATE_TEMPLATES = {
CLASSIC_TEMPLATE_CODE: CertificateTemplateDefinition(
code=CLASSIC_TEMPLATE_CODE,
name="经典结业证书",
description="通用课程结业证书,正文包含课程名称、阶段、课程时间和发证日期。",
asset_filename="certificate-template.png",
dynamic_fields=("姓名", "课程名称", "阶段名称", "课程开始日期", "课程结束日期", "发证日期"),
),
PRACTICE_CAMP_TEMPLATE_CODE: CertificateTemplateDefinition(
code=PRACTICE_CAMP_TEMPLATE_CODE,
name="实修大本营结业证书",
description="人本智慧五个月线上实修大本营专用版,只填写姓名、课程开始日期、课程结束日期和发证日期。",
asset_filename="certificate-template-practice-camp.png",
dynamic_fields=("姓名", "课程开始日期", "课程结束日期", "发证日期"),
),
}
def list_certificate_templates() -> list[CertificateTemplateDefinition]:
return list(CERTIFICATE_TEMPLATES.values())
def get_certificate_template(code: str | None) -> CertificateTemplateDefinition:
normalized_code = (code or CLASSIC_TEMPLATE_CODE).strip().lower()
template = CERTIFICATE_TEMPLATES.get(normalized_code)
if not template or template.status != "active":
raise ValueError(f"未知或已停用的证书模板:{normalized_code}")
if not template.asset_path.exists():
raise FileNotFoundError(f"证书模板图片不存在:{template.asset_filename}")
return template

View File

@@ -8,6 +8,11 @@ from PIL import Image, ImageDraw, ImageFont
from app.core.config import settings
from app.core.paths import data_path
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.services.certificate_templates import (
CLASSIC_TEMPLATE_CODE,
PRACTICE_CAMP_TEMPLATE_CODE,
get_certificate_template,
)
DESIGN_WIDTH = 1024
DESIGN_HEIGHT = 759
@@ -70,13 +75,8 @@ def mark_pdf_generated(certificate: Certificate, output_path: Path) -> None:
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"]:
path = assets_dir / name
if path.exists():
return path
raise FileNotFoundError("Certificate template image not found")
def certificate_template_path(template_code: str = CLASSIC_TEMPLATE_CODE) -> Path:
return get_certificate_template(template_code).asset_path
def pdf_resolution(image: Image.Image) -> float:
@@ -91,7 +91,8 @@ def render_certificate_pdf(
concurrency_limit: int = 2,
) -> Path:
output_path = pdf_cache_path(certificate.certificate_no)
template_path = certificate_template_path()
template_code = getattr(certificate, "template_code", CLASSIC_TEMPLATE_CODE)
template_path = certificate_template_path(template_code)
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:
mark_pdf_generated(certificate, output_path)
@@ -112,7 +113,13 @@ def render_certificate_pdf(
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
template = certificate_template_path()
if getattr(certificate, "template_code", CLASSIC_TEMPLATE_CODE) == PRACTICE_CAMP_TEMPLATE_CODE:
return render_practice_camp_certificate_image(certificate, learner)
return render_classic_certificate_image(certificate, learner, project)
def render_classic_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
template = certificate_template_path(CLASSIC_TEMPLATE_CODE)
image = Image.open(template).convert("RGB")
base_image = image.copy()
draw = ImageDraw.Draw(image)
@@ -174,6 +181,62 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
return image
def render_practice_camp_certificate_image(certificate: Certificate, learner: Learner) -> Image.Image:
design_width = 1854
design_height = 1359
image = Image.open(certificate_template_path(PRACTICE_CAMP_TEMPLATE_CODE)).convert("RGB")
draw = ImageDraw.Draw(image)
x_scale = image.width / design_width
y_scale = image.height / design_height
text_scale = (x_scale + y_scale) / 2
start_year, start_month, _ = date_parts(certificate.course_start_date or certificate.issue_date)
end_year, end_month, _ = date_parts(certificate.course_end_date or certificate.issue_date)
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
draw.text(
(568 * x_scale, 542 * y_scale),
learner.current_name,
fill="#202020",
font=font(42, "kai", True, text_scale),
anchor="ms",
)
for value, x in [
(start_year, 474),
(start_month, 630),
(end_year, 888),
(end_month, 1058),
]:
draw.text(
(x * x_scale, 668 * y_scale),
value,
fill="#202020",
font=font(41, "song", False, text_scale),
anchor="ms",
)
issue_date_text = f"{issue_year}{issue_month}{issue_day}"
issue_date_font = font(36, "song", False, text_scale)
issue_date_position = (568 * x_scale, 1158 * y_scale)
draw.rectangle(
(
442 * x_scale,
1118 * y_scale,
696 * x_scale,
1168 * y_scale,
),
fill="#ffffff",
)
draw.text(
issue_date_position,
issue_date_text,
fill="#202020",
font=issue_date_font,
anchor="ms",
)
return image
def font(size: int, kind: str = "song", bold: bool = False, scale: float = 1.0) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
choices = {
"kai": [