重构学员证书签发与批量导入流程
This commit is contained in:
135
backend/app/services/certificate_issuance.py
Normal file
135
backend/app/services/certificate_issuance.py
Normal 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
|
||||
@@ -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}
|
||||
|
||||
57
backend/app/services/learner_identity.py
Normal file
57
backend/app/services/learner_identity.py
Normal 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
|
||||
Reference in New Issue
Block a user