重构学员证书签发与批量导入流程

This commit is contained in:
Certificate System
2026-08-14 11:37:09 +08:00
parent 522f529c6a
commit 5e33657268
16 changed files with 1047 additions and 370 deletions

View File

@@ -0,0 +1,135 @@
from dataclasses import dataclass
from datetime import date
from sqlalchemy.orm import Session
from app.core.security import generate_public_token, hash_token
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.services.certificate_number import build_certificate_no
from app.services.certificate_templates import certificate_field_keys, get_certificate_template
from app.services.learner_identity import resolve_learner
class DuplicateCertificate(ValueError):
def __init__(self, certificate: Certificate):
self.certificate = certificate
super().__init__(f"已存在相同证书:{certificate.certificate_no}")
@dataclass(frozen=True)
class CertificateIssueData:
learner_name: str
learner_phone: str
project_code: str
template_code: str
issue_date: date
course_start_date: date | None = None
course_end_date: date | None = None
course_name: str | None = None
stage_name: str | None = None
class_name: str | None = None
remark: str | None = None
import_batch_id: int | None = None
def issue_certificate(db: Session, data: CertificateIssueData, *, source: str) -> tuple[Certificate, Learner, bool]:
template = get_certificate_template(data.template_code)
project_code = data.project_code.strip().upper()
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
if not project:
raise ValueError(f"项目代码不存在或已停用:{project_code}")
values = _template_values(data, project)
_validate_template_values(template, values)
learner, learner_created = resolve_learner(
db,
data.learner_name,
data.learner_phone,
source=source,
create_if_missing=True,
)
assert learner is not None
duplicate = find_duplicate_certificate(db, learner.id, project_code, template.code, values)
if duplicate:
raise DuplicateCertificate(duplicate)
certificate = Certificate(
learner_id=learner.id,
import_batch_id=data.import_batch_id,
project_code=project_code,
certificate_no="PENDING",
certificate_name=project.default_certificate_name or project.name,
class_name=data.class_name,
course_name=values["course_name"],
stage_name=values["stage_name"],
course_start_date=values["course_start_date"],
course_end_date=values["course_end_date"],
issue_date=data.issue_date,
template_code=template.code,
remark=data.remark,
)
db.add(certificate)
db.flush()
certificate.certificate_no = build_certificate_no(certificate.id, certificate.project_code, certificate.issue_date)
certificate.public_token_id = _create_access_token(db, certificate.id, "public_link")
certificate.qr_token_id = _create_access_token(db, certificate.id, "qr_verify")
return certificate, learner, learner_created
def find_duplicate_certificate(
db: Session,
learner_id: int,
project_code: str,
template_code: str,
values: dict[str, object],
) -> Certificate | None:
return (
db.query(Certificate)
.filter(Certificate.learner_id == learner_id)
.filter(Certificate.project_code == project_code)
.filter(Certificate.template_code == template_code)
.filter(Certificate.course_name == values["course_name"])
.filter(Certificate.stage_name == values["stage_name"])
.filter(Certificate.course_start_date == values["course_start_date"])
.filter(Certificate.course_end_date == values["course_end_date"])
.filter(Certificate.issue_date == values["issue_date"])
.filter(Certificate.status == "valid")
.first()
)
def _template_values(data: CertificateIssueData, project: ProjectCourse) -> dict[str, object]:
template = get_certificate_template(data.template_code)
keys = certificate_field_keys(template)
return {
"course_name": (data.course_name or project.default_course_name) if "course_name" in keys else None,
"stage_name": (data.stage_name or project.default_stage_name) if "stage_name" in keys else None,
"course_start_date": data.course_start_date if "course_start_date" in keys else None,
"course_end_date": data.course_end_date if "course_end_date" in keys else None,
"issue_date": data.issue_date,
}
def _validate_template_values(template, values: dict[str, object]) -> None:
for field in template.fields:
if field.key == "learner_name":
continue
if field.required and not values.get(field.key):
raise ValueError(f"{template.name}需要填写{field.label}")
start_date = values.get("course_start_date")
end_date = values.get("course_end_date")
if start_date and end_date and end_date < start_date:
raise ValueError("课程结束日期不能早于课程开始日期")
def _create_access_token(db: Session, certificate_id: int, token_type: str) -> int:
raw_token = generate_public_token()
token = CertificateAccessToken(
certificate_id=certificate_id,
token_hash=hash_token(raw_token),
token_value=raw_token,
token_type=token_type,
)
db.add(token)
db.flush()
return token.id

View File

@@ -2,15 +2,39 @@ from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class CertificateFieldDefinition:
key: str
label: str
field_type: str
required: bool
example: str
description: str
def to_dict(self) -> dict[str, object]:
return {
"key": self.key,
"label": self.label,
"field_type": self.field_type,
"required": self.required,
"example": self.example,
"description": self.description,
}
@dataclass(frozen=True)
class CertificateTemplateDefinition:
code: str
name: str
description: str
asset_filename: str
dynamic_fields: tuple[str, ...]
fields: tuple[CertificateFieldDefinition, ...]
status: str = "active"
@property
def dynamic_fields(self) -> tuple[str, ...]:
return tuple(field.label for field in self.fields)
@property
def asset_path(self) -> Path:
return Path(__file__).resolve().parents[1] / "assets" / self.asset_filename
@@ -20,7 +44,8 @@ class CertificateTemplateDefinition:
"code": self.code,
"name": self.name,
"description": self.description,
"dynamic_fields": list(self.dynamic_fields),
"dynamic_fields": [field.label for field in self.fields],
"fields": [field.to_dict() for field in self.fields],
"preview_url": f"/api/admin/certificate-templates/{self.code}/preview",
"status": self.status,
}
@@ -35,14 +60,26 @@ CERTIFICATE_TEMPLATES = {
name="经典结业证书",
description="通用课程结业证书,正文包含课程名称、阶段、课程时间和发证日期。",
asset_filename="certificate-template.png",
dynamic_fields=("姓名", "课程名称", "阶段名称", "课程开始日期", "课程结束日期", "发证日期"),
fields=(
CertificateFieldDefinition("learner_name", "姓名", "text", True, "张三", "证书持有人真实姓名"),
CertificateFieldDefinition("course_name", "课程名称", "text", True, "智慧赋能疗愈师", "证书正文中的课程名称"),
CertificateFieldDefinition("stage_name", "阶段名称", "text", True, "初级", "证书正文中的课程阶段"),
CertificateFieldDefinition("course_start_date", "课程开始日期", "date", True, "2026-06-01", "课程实际开始日期"),
CertificateFieldDefinition("course_end_date", "课程结束日期", "date", True, "2026-06-30", "课程实际结束日期"),
CertificateFieldDefinition("issue_date", "发证日期", "date", True, "2026-07-05", "证书签发日期"),
),
),
PRACTICE_CAMP_TEMPLATE_CODE: CertificateTemplateDefinition(
code=PRACTICE_CAMP_TEMPLATE_CODE,
name="实修大本营结业证书",
description="人本智慧五个月线上实修大本营专用版,只填写姓名、课程开始日期、课程结束日期和发证日期。",
asset_filename="certificate-template-practice-camp.png",
dynamic_fields=("姓名", "课程开始日期", "课程结束日期", "发证日期"),
fields=(
CertificateFieldDefinition("learner_name", "姓名", "text", True, "张三", "证书持有人真实姓名"),
CertificateFieldDefinition("course_start_date", "课程开始日期", "date", True, "2026-03-01", "课程实际开始日期"),
CertificateFieldDefinition("course_end_date", "课程结束日期", "date", True, "2026-08-31", "课程实际结束日期"),
CertificateFieldDefinition("issue_date", "发证日期", "date", True, "2026-09-05", "证书签发日期"),
),
),
}
@@ -59,3 +96,7 @@ def get_certificate_template(code: str | None) -> CertificateTemplateDefinition:
if not template.asset_path.exists():
raise FileNotFoundError(f"证书模板图片不存在:{template.asset_filename}")
return template
def certificate_field_keys(template: CertificateTemplateDefinition) -> set[str]:
return {field.key for field in template.fields}

View File

@@ -0,0 +1,57 @@
import re
from sqlalchemy.orm import Session
from app.models import Learner, LearnerNameHistory
class LearnerIdentityConflict(ValueError):
pass
def normalize_name(value: str) -> str:
return " ".join(value.strip().split())
def normalize_phone(value: str) -> str:
phone = re.sub(r"[\s-]", "", value.strip())
if phone.startswith("+86"):
phone = phone[3:]
elif phone.startswith("0086"):
phone = phone[4:]
if not re.fullmatch(r"1\d{10}", phone):
raise ValueError("手机号格式错误请填写11位中国大陆手机号")
return phone
def resolve_learner(
db: Session,
name: str,
phone: str,
*,
source: str,
create_if_missing: bool = True,
) -> tuple[Learner | None, bool]:
normalized_name = normalize_name(name)
normalized_phone = normalize_phone(phone)
if not normalized_name:
raise ValueError("姓名不能为空")
learner = db.query(Learner).filter(Learner.phone == normalized_phone, Learner.status != "deleted").first()
if learner:
if normalize_name(learner.current_name) != normalized_name:
raise LearnerIdentityConflict(
f"手机号 {normalized_phone} 已属于学员“{learner.current_name}”,与本次姓名“{normalized_name}”不一致"
)
if learner.status != "active":
raise LearnerIdentityConflict(f"学员“{normalized_name}”当前已停用,请先在学员管理中启用")
return learner, False
if not create_if_missing:
return None, False
learner = Learner(phone=normalized_phone, current_name=normalized_name)
db.add(learner)
db.flush()
db.add(LearnerNameHistory(learner_id=learner.id, name=normalized_name, source=source))
return learner, True