Compare commits

...

5 Commits

Author SHA1 Message Date
Certificate System
e6dd962dc2 修复证书下载后PDF状态不同步 2026-08-14 12:58:09 +08:00
Certificate System
81505f815e 修复证书导入模板识别与文件重选 2026-08-14 12:13:13 +08:00
Certificate System
3e9cfdfece 修复证书导入日期解析与错误报告 2026-08-14 11:51:53 +08:00
Certificate System
bff538c477 移除部署文档中的明文密钥 2026-08-14 11:37:17 +08:00
Certificate System
5e33657268 重构学员证书签发与批量导入流程 2026-08-14 11:37:09 +08:00
17 changed files with 1268 additions and 386 deletions

View File

@@ -8,12 +8,11 @@ from app.core.security import generate_public_token, hash_token
from app.db.session import get_db
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.schemas.certificate import CertificateCreate, CertificateOut, PdfPregenerationJobCreate, PdfPregenerationJobOut
from app.services.certificate_number import build_certificate_no
from app.services.certificate_issuance import CertificateIssueData, DuplicateCertificate, issue_certificate
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()
@@ -51,6 +50,7 @@ def list_certificates(
Certificate.course_name.like(like),
Certificate.stage_name.like(like),
Learner.current_name.like(like),
Learner.phone.like(like),
)
)
if status_value:
@@ -68,38 +68,34 @@ def create_certificate(
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Certificate:
learner = db.get(Learner, payload.learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
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
project = db.query(ProjectCourse).filter(ProjectCourse.code == payload.project_code, ProjectCourse.status == "active").first()
if not project:
raise ValueError("项目代码不存在或已停用")
certificate, learner, learner_created = issue_certificate(
db,
CertificateIssueData(
learner_name=payload.learner_name,
learner_phone=payload.learner_phone,
project_code=payload.project_code,
class_name=payload.class_name,
course_name=payload.course_name,
stage_name=payload.stage_name,
course_start_date=payload.course_start_date,
course_end_date=payload.course_end_date,
issue_date=payload.issue_date,
template_code=payload.template_code or project.default_template_code,
remark=payload.remark,
),
source="manual_certificate",
)
except DuplicateCertificate as exc:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except (ValueError, FileNotFoundError) as exc:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
certificate = Certificate(
learner_id=payload.learner_id,
project_code=payload.project_code,
certificate_no="PENDING",
certificate_name=project.name,
class_name=payload.class_name,
course_name=payload.course_name or project.default_course_name or project.name,
stage_name=payload.stage_name or project.default_stage_name,
course_start_date=payload.course_start_date,
course_end_date=payload.course_end_date,
issue_date=payload.issue_date,
template_code=template_code,
remark=payload.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_token(db, certificate.id, "public_link")
certificate.qr_token_id = _create_token(db, certificate.id, "qr_verify")
log_action(db, admin, "create_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code, "issue_date": certificate.issue_date})
log_action(db, admin, "create_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "learner_created": learner_created, "project_code": certificate.project_code, "issue_date": certificate.issue_date})
db.commit()
db.refresh(certificate)
return certificate
@@ -167,6 +163,7 @@ def _certificate_payload(certificate: Certificate, learner: Learner | None = Non
"id": certificate.id,
"learner_id": certificate.learner_id,
"learner_name": learner.current_name if learner else None,
"learner_phone": learner.phone if learner else None,
"import_batch_id": certificate.import_batch_id,
"project_code": certificate.project_code,
"certificate_no": certificate.certificate_no,
@@ -240,6 +237,7 @@ def preview_certificate(
return {
"certificate_no": certificate.certificate_no,
"learner_name": learner.current_name if learner else "",
"learner_phone": learner.phone if learner else "",
"certificate_name": certificate.certificate_name,
"project_code": certificate.project_code,
"course_name": certificate.course_name,

View File

@@ -3,7 +3,7 @@ import shutil
from datetime import date, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from openpyxl import Workbook, load_workbook
from openpyxl.comments import Comment
@@ -13,36 +13,40 @@ from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.core.paths import data_path
from app.core.security import generate_public_token, hash_token
from app.db.session import get_db
from app.models import (
AdminUser,
Certificate,
CertificateAccessToken,
ImportBatch,
ImportBatchRow,
Learner,
LearnerNameHistory,
ProjectCourse,
)
from app.models import AdminUser, ImportBatch, ImportBatchRow, Learner, ProjectCourse
from app.schemas.import_batch import ImportBatchOut
from app.services.certificate_number import build_certificate_no
from app.services.certificate_issuance import CertificateIssueData, DuplicateCertificate, issue_certificate
from app.services.certificate_templates import (
CertificateTemplateDefinition,
get_certificate_template,
list_certificate_templates,
)
from app.services.learner_identity import normalize_phone
from app.services.logs import log_action
from app.services.certificate_templates import get_certificate_template
router = APIRouter()
COL_NAME = "\u59d3\u540d"
COL_PHONE = "\u624b\u673a\u53f7"
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
COL_NAME = "姓名"
COL_PHONE = "手机号"
COL_PROJECT = "项目代码"
COL_COURSE_NAME = "课程名称"
COL_STAGE_NAME = "阶段名称"
COL_COURSE_START_DATE = "课程开始日期"
COL_COURSE_END_DATE = "课程结束日期"
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
COL_ISSUE_DATE = "发证日期"
TEMPLATE_HEADERS = [
COL_NAME,
COL_PHONE,
COL_PROJECT,
COMMON_HEADERS = [COL_NAME, COL_PHONE, COL_PROJECT]
FIELD_COLUMNS = {
"course_name": COL_COURSE_NAME,
"stage_name": COL_STAGE_NAME,
"course_start_date": COL_COURSE_START_DATE,
"course_end_date": COL_COURSE_END_DATE,
"issue_date": COL_ISSUE_DATE,
}
TEMPLATE_HEADERS = COMMON_HEADERS + [
COL_COURSE_NAME,
COL_STAGE_NAME,
COL_COURSE_START_DATE,
COL_COURSE_END_DATE,
COL_ISSUE_DATE,
@@ -51,25 +55,33 @@ REQUIRED_HEADERS = TEMPLATE_HEADERS
@router.get("/template")
def download_template(_: AdminUser = Depends(require_roles("system_admin", "certificate_admin"))) -> StreamingResponse:
workbook = build_import_template_workbook()
stream_path = data_path("exports") / "certificate-import-template.xlsx"
def download_template(
template_code: str = Query(default="classic"),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> StreamingResponse:
try:
template = get_certificate_template(template_code)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
workbook = build_import_template_workbook(template.code)
stream_path = data_path("exports") / f"certificate-import-{template.code}.xlsx"
workbook.save(stream_path)
file_handle = stream_path.open("rb")
return StreamingResponse(
file_handle,
stream_path.open("rb"),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="certificate-import-template.xlsx"'},
headers={"Content-Disposition": f'attachment; filename="certificate-import-{template.code}.xlsx"'},
)
def build_import_template_workbook() -> Workbook:
def build_import_template_workbook(template_code: str = "classic") -> Workbook:
template = get_certificate_template(template_code)
headers = template_headers(template)
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
sheet.append(TEMPLATE_HEADERS)
_format_template_sheet(sheet)
_add_template_instructions(workbook)
sheet.title = "证书导入模板"
sheet.append(headers)
_format_template_sheet(sheet, headers, template)
_add_template_instructions(workbook, template)
return workbook
@@ -81,21 +93,41 @@ def upload_import_file(
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")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 .xlsx 文件")
try:
template_code = get_certificate_template(template_code).code
template = get_certificate_template(template_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
upload_path = data_path("uploads") / f"{datetime.now():%Y%m%d%H%M%S}-{Path(file.filename).name}"
with upload_path.open("wb") as target:
shutil.copyfileobj(file.file, target)
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), template_code=template_code, created_by=admin.id)
detected_template = detect_template_from_workbook(upload_path)
if detected_template and detected_template.code != template.code:
upload_path.unlink(missing_ok=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"文件列属于“{detected_template.name}”,当前选择的是“{template.name}”。请切换证书模板后重新上传。",
)
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)
log_action(db, admin, "upload_import_file", "import_batch", batch.id, {"filename": file.filename, "total_rows": batch.total_rows, "valid_rows": batch.valid_rows, "failed_rows": batch.failed_rows, "status": batch.status})
log_action(
db,
admin,
"upload_import_file",
"import_batch",
batch.id,
{"filename": file.filename, "template_code": template.code, "total_rows": batch.total_rows, "valid_rows": batch.valid_rows, "failed_rows": batch.failed_rows, "status": batch.status},
)
db.commit()
db.refresh(batch)
return batch
@@ -116,13 +148,14 @@ def download_error_report(
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> FileResponse:
batch = db.get(ImportBatch, batch_id)
if not batch or not batch.error_report_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Error report not found")
return FileResponse(
batch.error_report_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=f"import-errors-{batch.id}.xlsx",
)
if not batch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导入批次不存在")
failed_count = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id, ImportBatchRow.status == "failed").count()
if not failed_count:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错误报告不存在")
batch.error_report_path = str(write_error_report(db, batch.id))
db.commit()
return FileResponse(batch.error_report_path, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename=f"import-errors-{batch.id}.xlsx")
@router.get("/{batch_id}/file")
@@ -133,12 +166,8 @@ def download_source_file(
) -> FileResponse:
batch = db.get(ImportBatch, batch_id)
if not batch or not Path(batch.file_path).exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Source file not found")
return FileResponse(
batch.file_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=batch.filename,
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="原始文件不存在")
return FileResponse(batch.file_path, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename=batch.filename)
@router.delete("/{batch_id}")
@@ -149,14 +178,10 @@ def delete_import_batch(
) -> dict[str, bool]:
batch = db.get(ImportBatch, batch_id)
if not batch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Import batch not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导入批次不存在")
for file_name in [batch.file_path, batch.error_report_path]:
if file_name:
path = Path(file_name)
if path.exists():
path.unlink()
if file_name and Path(file_name).exists():
Path(file_name).unlink()
db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id).delete()
db.delete(batch)
log_action(db, admin, "delete_import_batch", "import_batch", batch.id, {"filename": batch.filename, "status": batch.status, "total_rows": batch.total_rows})
@@ -172,84 +197,76 @@ def confirm_import_batch(
) -> ImportBatch:
batch = db.get(ImportBatch, batch_id)
if not batch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Import batch not found")
if batch.status not in {"validated", "imported"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Import batch is not ready")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导入批次不存在")
if batch.status == "imported":
return batch
if batch.status != "validated":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前批次不能确认导入")
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id, ImportBatchRow.status == "valid").all()
ok_rows = 0
failed_rows = 0
imported_rows = skipped_rows = failed_rows = created_learners = 0
for row in rows:
row_data = json.loads(row.raw_json or "{}")
learner = upsert_learner(db, row_data)
course_start_date = parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE)
course_end_date = parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE)
issue_date = parse_issue_date(row_data[COL_ISSUE_DATE])
project_code = str(row_data[COL_PROJECT]).strip().upper()
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
if not project:
row.status = "failed"
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,
batch.template_code,
)
if duplicate:
try:
_, _, learner_created = issue_certificate(
db,
CertificateIssueData(
learner_name=str(row_data[COL_NAME]),
learner_phone=str(row_data[COL_PHONE]),
project_code=str(row_data[COL_PROJECT]),
template_code=batch.template_code,
course_name=optional_text(row_data.get(COL_COURSE_NAME)),
stage_name=optional_text(row_data.get(COL_STAGE_NAME)),
course_start_date=parse_optional_date(row_data.get(COL_COURSE_START_DATE), COL_COURSE_START_DATE),
course_end_date=parse_optional_date(row_data.get(COL_COURSE_END_DATE), COL_COURSE_END_DATE),
issue_date=parse_issue_date(row_data[COL_ISSUE_DATE]),
import_batch_id=batch.id,
),
source="certificate_import",
)
row.status = "imported"
imported_rows += 1
created_learners += int(learner_created)
except DuplicateCertificate as exc:
row.status = "skipped"
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
ok_rows += 1
continue
row.error_message = str(exc)
skipped_rows += 1
except (ValueError, FileNotFoundError) as exc:
row.status = "failed"
row.error_message = str(exc)
failed_rows += 1
certificate = Certificate(
learner_id=learner.id,
import_batch_id=batch.id,
project_code=project.code,
certificate_no="PENDING",
certificate_name=project.default_certificate_name,
course_name=project.default_course_name,
stage_name=project.default_stage_name,
course_start_date=course_start_date,
course_end_date=course_end_date,
issue_date=issue_date,
template_code=batch.template_code,
remark=None,
)
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")
row.status = "imported"
ok_rows += 1
batch.status = "imported" if ok_rows else "failed"
batch.status = "imported" if imported_rows or skipped_rows else "failed"
batch.failed_rows = (batch.failed_rows or 0) + failed_rows
if failed_rows:
batch.failed_rows = (batch.failed_rows or 0) + failed_rows
log_action(db, admin, "confirm_import_batch", "import_batch", batch.id, {"filename": batch.filename, "valid_rows": batch.valid_rows, "imported_rows": ok_rows, "failed_rows": failed_rows, "status": batch.status})
db.flush()
batch.error_report_path = str(write_error_report(db, batch.id))
log_action(
db,
admin,
"confirm_import_batch",
"import_batch",
batch.id,
{"filename": batch.filename, "imported_rows": imported_rows, "skipped_rows": skipped_rows, "created_learners": created_learners, "failed_rows": failed_rows, "status": batch.status},
)
db.commit()
db.refresh(batch)
return batch
def validate_batch(db: Session, batch: ImportBatch, upload_path: Path) -> None:
template = get_certificate_template(batch.template_code)
headers = template_headers(template)
workbook = load_workbook(upload_path, read_only=True, data_only=True)
sheet = workbook.active
header = [cell.value for cell in next(sheet.iter_rows(min_row=1, max_row=1))]
first_row = next(sheet.iter_rows(min_row=1, max_row=1), None)
header = [cell.value for cell in first_row] if first_row else []
header_index = {name: idx for idx, name in enumerate(header)}
missing = [name for name in TEMPLATE_HEADERS if name not in header_index]
missing = [name for name in required_headers(template) if name not in header_index]
if missing:
batch.status = "failed"
batch.failed_rows = 1
db.add(ImportBatchRow(batch_id=batch.id, row_no=1, status="failed", error_message=f"Missing columns: {missing}"))
db.add(ImportBatchRow(batch_id=batch.id, row_no=1, status="failed", error_message=f"缺少必填列:{''.join(missing)}"))
return
active_codes = {row[0] for row in db.query(ProjectCourse.code).filter(ProjectCourse.status == "active").all()}
@@ -258,116 +275,87 @@ def validate_batch(db: Session, batch: ImportBatch, upload_path: Path) -> None:
if not any(row):
continue
total += 1
row_data = {name: row[header_index[name]] for name in TEMPLATE_HEADERS}
errors = row_errors(row_data, active_codes)
if errors:
failed += 1
db.add(
ImportBatchRow(
batch_id=batch.id,
row_no=row_no,
status="failed",
error_message="; ".join(errors),
raw_json=json.dumps(row_data, ensure_ascii=False, default=str),
)
row_data = normalize_row_data(
{name: row[header_index[name]] if name in header_index and header_index[name] < len(row) else None for name in headers}
)
errors = row_errors(row_data, active_codes, template.code, db)
row_status = "failed" if errors else "valid"
failed += int(bool(errors))
valid += int(not errors)
db.add(
ImportBatchRow(
batch_id=batch.id,
row_no=row_no,
status=row_status,
error_message="".join(errors) if errors else None,
raw_json=json.dumps(row_data, ensure_ascii=False, default=str),
)
else:
valid += 1
db.add(
ImportBatchRow(
batch_id=batch.id,
row_no=row_no,
status="valid",
raw_json=json.dumps(row_data, ensure_ascii=False, default=str),
)
)
)
batch.total_rows = total
batch.valid_rows = valid
batch.failed_rows = failed
batch.status = "validated"
batch.status = "validated" if total else "failed"
if failed:
db.flush()
batch.error_report_path = str(write_error_report(db, batch.id))
def row_errors(row_data: dict[str, object], active_codes: set[str]) -> list[str]:
errors = []
for name in REQUIRED_HEADERS:
if not row_data.get(name):
errors.append(f"{name} is required")
def row_errors(
row_data: dict[str, object],
active_codes: set[str],
template_code: str = "classic",
db: Session | None = None,
) -> list[str]:
template = get_certificate_template(template_code)
errors: list[str] = []
for name in required_headers(template):
if not optional_text(row_data.get(name)):
errors.append(f"{name}不能为空")
project_code = str(row_data.get(COL_PROJECT) or "").strip().upper()
if project_code and project_code not in active_codes:
errors.append("Project code is inactive or missing")
for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE, COL_ISSUE_DATE]:
errors.append("项目代码不存在或已停用")
for field in template.fields:
if field.field_type != "date" or field.key == "learner_name":
continue
column = FIELD_COLUMNS[field.key]
if row_data.get(column) and not date_is_valid(row_data[column]):
errors.append(f"{column}格式错误请使用YYYY-MM-DD例如2026-06-01")
errors.append(f"{column}格式错误请使用YYYY-MM-DD例如{field.example}")
if all(row_data.get(column) and date_is_valid(row_data[column]) for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE]):
start_date = parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE)
end_date = parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE)
if end_date < start_date:
if parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE) < parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE):
errors.append("课程结束日期不能早于课程开始日期")
if row_data.get(COL_PHONE):
try:
phone = normalize_phone(str(row_data[COL_PHONE]))
if db:
learner = db.query(Learner).filter(Learner.phone == phone, Learner.status != "deleted").first()
input_name = str(row_data.get(COL_NAME) or "").strip()
if learner and learner.current_name.strip() != input_name:
errors.append(f"手机号已属于学员“{learner.current_name}”,姓名不一致")
except ValueError as exc:
errors.append(str(exc))
return errors
def upsert_learner(db: Session, row_data: dict[str, object]) -> Learner:
phone = str(row_data[COL_PHONE]).strip()
name = str(row_data[COL_NAME]).strip()
learner = db.query(Learner).filter(Learner.phone == phone).first()
if learner:
if learner.current_name != name:
db.add(LearnerNameHistory(learner_id=learner.id, name=name, source="import"))
learner.current_name = name
return learner
learner = Learner(phone=phone, current_name=name)
db.add(learner)
db.flush()
db.add(LearnerNameHistory(learner_id=learner.id, name=name, source="import"))
return learner
def template_headers(template: CertificateTemplateDefinition) -> list[str]:
return COMMON_HEADERS + [FIELD_COLUMNS[field.key] for field in template.fields if field.key != "learner_name"]
def get_active_project(db: Session, project_code: str) -> ProjectCourse:
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
if not project:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Project code is inactive or missing: {project_code}")
return project
def required_headers(template: CertificateTemplateDefinition) -> list[str]:
return COMMON_HEADERS + [FIELD_COLUMNS[field.key] for field in template.fields if field.key != "learner_name" and field.required]
def find_duplicate_certificate(
db: Session,
learner_id: int,
project: ProjectCourse,
course_start_date: date,
course_end_date: date,
issue_date: date,
template_code: str,
) -> Certificate | None:
return (
db.query(Certificate)
.filter(Certificate.learner_id == learner_id)
.filter(Certificate.project_code == project.code)
.filter(Certificate.certificate_name == project.default_certificate_name)
.filter(Certificate.course_name == project.default_course_name)
.filter(Certificate.stage_name == project.default_stage_name)
.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()
)
def create_access_token(db: Session, certificate_id: int, token_type: str) -> int:
raw_token = generate_public_token()
access_token = CertificateAccessToken(
certificate_id=certificate_id,
token_hash=hash_token(raw_token),
token_value=raw_token,
token_type=token_type,
)
db.add(access_token)
db.flush()
return access_token.id
def detect_template_from_workbook(upload_path: Path) -> CertificateTemplateDefinition | None:
workbook = load_workbook(upload_path, read_only=True, data_only=True)
try:
sheet = workbook.active
first_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
header_names = {str(value).strip() for value in (first_row or ()) if value is not None and str(value).strip()}
for template in list_certificate_templates():
if header_names == set(template_headers(template)):
return template
return None
finally:
workbook.close()
def optional_text(value: object) -> str | None:
@@ -377,99 +365,148 @@ def optional_text(value: object) -> str | None:
return text or None
def normalize_row_data(row_data: dict[str, object]) -> dict[str, object]:
normalized: dict[str, object] = {}
for key, value in row_data.items():
if isinstance(value, datetime):
normalized[key] = value.date().isoformat()
elif isinstance(value, date):
normalized[key] = value.isoformat()
else:
normalized[key] = value
return normalized
def parse_issue_date(value: object) -> date:
return parse_date(value, COL_ISSUE_DATE)
def parse_optional_date(value: object, field_name: str) -> date | None:
return parse_date(value, field_name) if value not in (None, "") else None
def parse_date(value: object, field_name: str = "日期") -> date:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
try:
return date.fromisoformat(text)
except ValueError:
pass
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).date()
except ValueError:
pass
for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"]:
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name}格式错误请使用YYYY-MM-DD{text}")
raise ValueError(f"{field_name}格式错误请使用YYYY-MM-DD{text}")
def date_is_valid(value: object) -> bool:
try:
parse_issue_date(value)
parse_date(value)
return True
except HTTPException:
except ValueError:
return False
def write_error_report(db: Session, batch_id: int) -> Path:
db.flush()
batch = db.get(ImportBatch, batch_id)
template = get_certificate_template(batch.template_code) if batch else get_certificate_template("classic")
headers = template_headers(template)
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u9519\u8bef\u62a5\u544a"
sheet.append(["\u884c\u53f7", "\u9519\u8bef\u539f\u56e0", "\u539f\u59cb\u6570\u636e"])
sheet.title = "错误报告"
sheet.append(["行号", "错误原因", *headers])
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch_id, ImportBatchRow.status == "failed").all()
for row in rows:
sheet.append([row.row_no, row.error_message, row.raw_json])
raw_data = json.loads(row.raw_json or "{}")
sheet.append([row.row_no, row.error_message or "未知错误", *(report_cell_value(header, raw_data.get(header)) for header in headers)])
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = f"A1:{sheet.cell(1, len(headers) + 2).coordinate}"
sheet.column_dimensions["A"].width = 10
sheet.column_dimensions["B"].width = 48
for cell in sheet[1]:
cell.fill = PatternFill("solid", fgColor="C0392B")
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
for row in sheet.iter_rows(min_row=2):
row[1].alignment = Alignment(wrap_text=True, vertical="top")
report_path = data_path("error-reports") / f"import-errors-{batch_id}.xlsx"
workbook.save(report_path)
return report_path
def _format_template_sheet(sheet) -> None:
def report_cell_value(header: str, value: object) -> object:
if header not in {COL_COURSE_START_DATE, COL_COURSE_END_DATE, COL_ISSUE_DATE} or value in (None, ""):
return value
try:
return parse_date(value, header).isoformat()
except ValueError:
return value
def _format_template_sheet(sheet, headers: list[str], template: CertificateTemplateDefinition) -> None:
header_fill = PatternFill("solid", fgColor="208A87")
for cell in sheet[1]:
cell.fill = header_fill
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = f"A1:{sheet.cell(1, len(TEMPLATE_HEADERS)).coordinate}"
widths = [16, 18, 16, 18, 18, 18]
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[sheet.cell(1, index).column_letter].width = width
sheet.auto_filter.ref = f"A1:{sheet.cell(1, len(headers)).coordinate}"
widths = {COL_NAME: 16, COL_PHONE: 18, COL_PROJECT: 16, COL_COURSE_NAME: 28, COL_STAGE_NAME: 18, COL_COURSE_START_DATE: 18, COL_COURSE_END_DATE: 18, COL_ISSUE_DATE: 18}
sheet.column_dimensions["B"].number_format = "@"
for column_index in [4, 5, 6]:
column_letter = sheet.cell(1, column_index).column_letter
sheet.column_dimensions[column_letter].number_format = "yyyy-mm-dd"
validation = DataValidation(type="date", operator="between", formula1="DATE(2000,1,1)", formula2="DATE(2100,12,31)", allow_blank=False)
validation.promptTitle = "日期格式"
validation.prompt = "请按 YYYY-MM-DD 填写,例如 2026-06-01"
validation.errorTitle = "日期格式错误"
validation.error = "请填写 2000-01-01 至 2100-12-31 之间的有效日期"
validation.errorStyle = "stop"
validation.showInputMessage = True
validation.showErrorMessage = True
sheet.add_data_validation(validation)
validation.add(f"{column_letter}2:{column_letter}5000")
sheet.cell(1, column_index).comment = Comment("必填。请使用 YYYY-MM-DD 格式,例如 2026-06-01。", "证书管理系统")
for index, header in enumerate(headers, start=1):
column_letter = sheet.cell(1, index).column_letter
sheet.column_dimensions[column_letter].width = widths[header]
field = next((item for item in template.fields if item.label == header), None)
required = header in required_headers(template)
sheet.cell(1, index).comment = Comment(f"{'必填' if required else '选填'}{field.description if field else '用于识别和归档数据'}", "证书管理系统")
if field and field.field_type == "date":
sheet.column_dimensions[column_letter].number_format = "yyyy-mm-dd"
validation = DataValidation(type="date", operator="between", formula1="DATE(2000,1,1)", formula2="DATE(2100,12,31)", allow_blank=not field.required)
validation.promptTitle = "日期格式"
validation.prompt = f"请按 YYYY-MM-DD 填写,例如 {field.example}"
validation.errorTitle = "日期格式错误"
validation.error = "请填写有效日期"
validation.errorStyle = "stop"
validation.showInputMessage = True
validation.showErrorMessage = True
sheet.add_data_validation(validation)
validation.add(f"{column_letter}2:{column_letter}5000")
def _add_template_instructions(workbook: Workbook) -> None:
def _add_template_instructions(workbook: Workbook, template: CertificateTemplateDefinition) -> None:
sheet = workbook.create_sheet("填写说明")
sheet.append(["字段", "是否必填", "格式或示例", "填写说明"])
rows = [
(COL_NAME, "", "张三", "填写学员真实姓名"),
(COL_PHONE, "", "13800000000", "建议将单元格设为文本,避免手机号格式变化"),
(COL_PROJECT, "", "DBY", "填写系统中已启用的项目代码"),
(COL_COURSE_START_DATE, "", "2026-06-01", "课程实际开始日期,必须使用 YYYY-MM-DD"),
(COL_COURSE_END_DATE, "", "2026-06-30", "不得早于课程开始日期,必须使用 YYYY-MM-DD"),
(COL_ISSUE_DATE, "", "2026-07-05", "证书签发日期,必须使用 YYYY-MM-DD"),
instructions = [
(COL_NAME, True, "张三", "填写学员真实姓名;与手机号共同确认学员身份"),
(COL_PHONE, True, "13800000000", "不存在时自动创建学员;已存在时姓名必须一致"),
(COL_PROJECT, True, "DBY", "填写系统中已启用的项目代码"),
]
for row in rows:
sheet.append(row)
sheet.append([])
sheet.append(["重要提示", "请在“证书导入模板”工作表填写正式数据,不要修改第一行列名。日期统一填写为 YYYY-MM-DD例如 2026-06-01。"])
sheet.merge_cells(start_row=9, start_column=2, end_row=9, end_column=4)
instructions.extend((field.label, field.required, field.example, field.description) for field in template.fields if field.key != "learner_name")
for label, required, example, description in instructions:
sheet.append([label, "" if required else "", example, description])
note_row = len(instructions) + 3
sheet.cell(note_row, 1, "重要提示")
sheet.cell(note_row, 2, f"本文件仅适用于“{template.name}”。不要修改第一行列名;日期统一使用 YYYY-MM-DD。")
sheet.merge_cells(start_row=note_row, start_column=2, end_row=note_row, end_column=4)
for cell in sheet[1]:
cell.fill = PatternFill("solid", fgColor="208A87")
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center")
sheet["A9"].font = Font(color="C00000", bold=True)
sheet["B9"].font = Font(color="C00000", bold=True)
sheet["B9"].alignment = Alignment(wrap_text=True, vertical="center")
sheet.row_dimensions[9].height = 34
sheet.cell(note_row, 1).font = Font(color="C00000", bold=True)
sheet.cell(note_row, 2).font = Font(color="C00000", bold=True)
sheet.cell(note_row, 2).alignment = Alignment(wrap_text=True, vertical="center")
sheet.row_dimensions[note_row].height = 34
sheet.column_dimensions["A"].width = 20
sheet.column_dimensions["B"].width = 16
sheet.column_dimensions["C"].width = 22
sheet.column_dimensions["D"].width = 54
sheet.column_dimensions["C"].width = 24
sheet.column_dimensions["D"].width = 58
sheet.freeze_panes = "A2"

View File

@@ -1,27 +1,47 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from openpyxl.comments import Comment
from openpyxl.styles import Alignment, Font, PatternFill
from sqlalchemy import func
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.db.session import get_db
from app.models import AdminUser, Learner, LearnerNameHistory
from app.schemas.learner import LearnerCreate, LearnerOut, LearnerUpdate
from app.models import AdminUser, Certificate, Learner, LearnerNameHistory
from app.schemas.learner import LearnerCreate, LearnerImportResult, LearnerOut, LearnerUpdate
from app.services.learner_identity import LearnerIdentityConflict, normalize_name, normalize_phone, resolve_learner
from app.services.logs import diff_values, log_action, mask_phone
router = APIRouter()
LEARNER_IMPORT_HEADERS = ["姓名", "手机号", "学员编号", "备注"]
@router.get("", response_model=list[LearnerOut])
def list_learners(
keyword: str | None = Query(default=None),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[Learner]:
query = db.query(Learner).filter(Learner.status != "deleted").order_by(Learner.id.desc())
) -> list[dict[str, object]]:
count_subquery = (
db.query(Certificate.learner_id.label("learner_id"), func.count(Certificate.id).label("certificate_count"))
.group_by(Certificate.learner_id)
.subquery()
)
query = (
db.query(Learner, func.coalesce(count_subquery.c.certificate_count, 0))
.outerjoin(count_subquery, count_subquery.c.learner_id == Learner.id)
.filter(Learner.status != "deleted")
.order_by(Learner.id.desc())
)
if keyword:
like = f"%{keyword}%"
query = query.filter(or_(Learner.current_name.like(like), Learner.phone.like(like), Learner.student_no.like(like)))
return query.limit(100).all()
return [{**LearnerOut.model_validate(learner).model_dump(), "certificate_count": certificate_count} for learner, certificate_count in query.limit(100).all()]
@router.post("", response_model=LearnerOut, status_code=status.HTTP_201_CREATED)
@@ -30,32 +50,132 @@ def create_learner(
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Learner:
existing = db.query(Learner).filter(Learner.phone == payload.phone).first()
if existing:
if existing.current_name != payload.current_name:
db.add(LearnerNameHistory(learner_id=existing.id, name=payload.current_name, source="manual"))
existing.current_name = payload.current_name
existing.student_no = payload.student_no
existing.remark = payload.remark
log_action(db, admin, "update_learner", "learner", existing.id, {"name": existing.current_name, "phone": mask_phone(existing.phone)})
db.commit()
db.refresh(existing)
return existing
learner = Learner(
phone=payload.phone,
current_name=payload.current_name,
student_no=payload.student_no,
remark=payload.remark,
)
db.add(learner)
try:
learner, created = resolve_learner(db, payload.current_name, payload.phone, source="manual", create_if_missing=True)
except (ValueError, LearnerIdentityConflict) as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
assert learner is not None
learner.student_no = payload.student_no
learner.remark = payload.remark
action = "create_learner" if created else "update_learner"
log_action(db, admin, action, "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "status": learner.status})
db.commit()
db.refresh(learner)
db.add(LearnerNameHistory(learner_id=learner.id, name=learner.current_name, source="manual"))
log_action(db, admin, "create_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "status": learner.status})
db.commit()
return learner
@router.get("/import-template")
def download_learner_import_template(
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> StreamingResponse:
workbook = build_learner_import_template()
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="learner-import-template.xlsx"'},
)
@router.post("/import", response_model=LearnerImportResult)
def import_learners(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, object]:
if not file.filename or not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 .xlsx 文件")
try:
workbook = load_workbook(file.file, read_only=True, data_only=True)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件无法读取") from exc
sheet = workbook.active
first_row = next(sheet.iter_rows(min_row=1, max_row=1), None)
headers = [cell.value for cell in first_row] if first_row else []
header_index = {name: idx for idx, name in enumerate(headers)}
missing = [name for name in LEARNER_IMPORT_HEADERS[:2] if name not in header_index]
if missing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"缺少必填列:{''.join(missing)}")
result: dict[str, object] = {"total_rows": 0, "created_rows": 0, "updated_rows": 0, "unchanged_rows": 0, "failed_rows": 0, "errors": []}
seen_phones: dict[str, str] = {}
for row_no, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
if not any(row):
continue
result["total_rows"] += 1
values = {name: row[index] if index < len(row) else None for name, index in header_index.items()}
try:
name = normalize_name(str(values.get("姓名") or ""))
phone = normalize_phone(str(values.get("手机号") or ""))
if not name:
raise ValueError("姓名不能为空")
if phone in seen_phones and seen_phones[phone] != name:
raise LearnerIdentityConflict(f"文件内手机号重复且姓名不一致:{seen_phones[phone]} / {name}")
seen_phones[phone] = name
learner, created = resolve_learner(db, name, phone, source="learner_import", create_if_missing=True)
assert learner is not None
changed = False
student_no = _optional_cell(values.get("学员编号"))
remark = _optional_cell(values.get("备注"))
if student_no is not None and learner.student_no != student_no:
learner.student_no = student_no
changed = True
if remark is not None and learner.remark != remark:
learner.remark = remark
changed = True
if created:
result["created_rows"] += 1
elif changed:
result["updated_rows"] += 1
else:
result["unchanged_rows"] += 1
except (ValueError, LearnerIdentityConflict) as exc:
result["failed_rows"] += 1
result["errors"].append({"row_no": row_no, "message": str(exc)})
log_action(db, admin, "import_learners", "learner", detail={key: value for key, value in result.items() if key != "errors"})
db.commit()
return result
def build_learner_import_template() -> Workbook:
workbook = Workbook()
sheet = workbook.active
sheet.title = "学员导入模板"
sheet.append(LEARNER_IMPORT_HEADERS)
examples = {
"姓名": ("张三", "必填。填写学员真实姓名"),
"手机号": ("13800000000", "必填。11位手机号系统中已存在时姓名必须一致"),
"学员编号": ("HY20260001", "选填。内部管理编号"),
"备注": ("2026年大本营学员", "选填。内部备注"),
}
for index, header in enumerate(LEARNER_IMPORT_HEADERS, start=1):
cell = sheet.cell(1, index)
cell.fill = PatternFill("solid", fgColor="208A87")
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.comment = Comment(examples[header][1], "证书管理系统")
sheet.column_dimensions[cell.column_letter].width = 18 if header != "备注" else 32
sheet.column_dimensions["B"].number_format = "@"
instruction = workbook.create_sheet("填写说明")
instruction.append(["字段", "是否必填", "示例", "填写说明"])
for header in LEARNER_IMPORT_HEADERS:
instruction.append([header, "" if header in LEARNER_IMPORT_HEADERS[:2] else "", examples[header][0], examples[header][1]])
instruction.column_dimensions["A"].width = 18
instruction.column_dimensions["B"].width = 14
instruction.column_dimensions["C"].width = 22
instruction.column_dimensions["D"].width = 52
sheet.freeze_panes = "A2"
return workbook
def _optional_cell(value: object) -> str | None:
text = str(value).strip() if value is not None else ""
return text or None
@router.get("/{learner_id}", response_model=LearnerOut)
def get_learner(
learner_id: int,
@@ -78,7 +198,12 @@ def update_learner(
learner = db.get(Learner, learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
duplicate = db.query(Learner).filter(Learner.phone == payload.phone, Learner.id != learner_id).first()
try:
normalized_phone = normalize_phone(payload.phone)
normalized_name = normalize_name(payload.current_name)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
duplicate = db.query(Learner).filter(Learner.phone == normalized_phone, Learner.id != learner_id).first()
if duplicate:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Phone already exists")
before = {
@@ -88,10 +213,10 @@ def update_learner(
"status": learner.status,
"remark": learner.remark,
}
if learner.current_name != payload.current_name:
db.add(LearnerNameHistory(learner_id=learner.id, name=payload.current_name, source="manual"))
learner.phone = payload.phone
learner.current_name = payload.current_name
if learner.current_name != normalized_name:
db.add(LearnerNameHistory(learner_id=learner.id, name=normalized_name, source="manual"))
learner.phone = normalized_phone
learner.current_name = normalized_name
learner.student_no = payload.student_no
learner.status = payload.status
learner.remark = payload.remark

View File

@@ -4,7 +4,8 @@ from pydantic import BaseModel, Field, ValidationInfo, field_validator
class CertificateCreate(BaseModel):
learner_id: int
learner_name: str = Field(min_length=1, max_length=64)
learner_phone: str = Field(min_length=6, max_length=32)
project_code: str = Field(min_length=2, max_length=16)
certificate_name: str | None = Field(default=None, max_length=128)
class_name: str | None = Field(default=None, max_length=128)
@@ -21,6 +22,11 @@ class CertificateCreate(BaseModel):
def normalize_project_code(cls, value: str) -> str:
return value.strip().upper()
@field_validator("learner_name", "learner_phone")
@classmethod
def strip_identity(cls, value: str) -> str:
return value.strip()
@field_validator("course_end_date")
@classmethod
def validate_course_period(cls, value: date, info: ValidationInfo) -> date:
@@ -34,6 +40,7 @@ class CertificateOut(BaseModel):
id: int
learner_id: int
learner_name: str | None = None
learner_phone: str | None = None
import_batch_id: int | None = None
project_code: str
certificate_no: str

View File

@@ -1,10 +1,20 @@
from pydantic import BaseModel
class CertificateTemplateFieldOut(BaseModel):
key: str
label: str
field_type: str
required: bool
example: str
description: str
class CertificateTemplateOut(BaseModel):
code: str
name: str
description: str
dynamic_fields: list[str]
fields: list[CertificateTemplateFieldOut]
preview_url: str
status: str

View File

@@ -25,7 +25,22 @@ class LearnerOut(BaseModel):
student_no: str | None
status: str
remark: str | None
certificate_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class LearnerImportError(BaseModel):
row_no: int
message: str
class LearnerImportResult(BaseModel):
total_rows: int
created_rows: int
updated_rows: int
unchanged_rows: int
failed_rows: int
errors: list[LearnerImportError]

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

View File

@@ -0,0 +1,62 @@
# 学员与证书业务模型
## 1. 核心关系
- 学员是长期主体,证书是某一次学习成果的签发记录。
- 一个学员可以拥有多张证书,证书可以来自不同项目、不同课程、不同模板和不同时间段。
- 证书签发后保存当次课程、阶段、日期和模板代码,不依赖项目默认值继续变化。
- 项目只负责归档和提供默认值,不能代替证书模板定义字段。
## 2. 学员身份规则
- 系统使用标准化后的手机号定位学员,姓名用于一致性校验和业务确认。
- 输入手机号会去除空格、短横线及中国大陆区号,最终保存为 11 位手机号。
- 手机号不存在时,手工签发和证书批量导入都会自动创建学员。
- 手机号已存在且姓名一致时,复用原学员。
- 手机号已存在但姓名不一致时,必须报冲突,不能自动改名或把证书挂到错误学员。
- 确需更名时,应在学员管理中人工修改,并保留姓名历史。
## 3. 模板字段规则
`app/services/certificate_templates.py` 是模板字段的唯一规则源。每个字段定义:
- `key`:后端和前端使用的稳定字段键。
- `label`:页面和 Excel 使用的中文名称。
- `field_type`:文本或日期。
- `required`:该模板签发证书时是否必填。
- `example`Excel 填写示例。
- `description`:后台提示和 Excel 填写说明。
新增模板时应先在这里声明字段。手工创建表单、Excel 表头、Excel 校验和后端签发校验都会读取同一份定义。
## 4. 导入流程
### 证书导入
1. 先选择证书模板。
2. 下载该模板专用 Excel不能跨模板混用。
3. 上传后只做校验,不立即写入正式证书。
4. 管理员确认后调用统一签发服务。
5. 学员不存在时自动创建;重复证书跳过;身份冲突或字段错误进入失败记录。
证书导入的公共业务列是姓名、手机号和项目代码。其余列由所选证书模板决定。
### 学员导入
- 姓名和手机号必填,学员编号和备注选填。
- 只维护学员主数据,不创建证书。
- 已有学员姓名手机号一致时可补充学员编号和备注。
- 冲突行单独返回,不修改已有学员姓名。
## 5. 签发入口
手工创建和证书导入统一调用 `app/services/certificate_issuance.py`,由该服务负责:
- 校验项目和模板;
- 解析或创建学员;
- 校验模板字段和课程日期;
- 判断重复证书;
- 创建证书编号;
- 创建公开链接和二维码令牌。
路由层只处理权限、HTTP 参数、事务提交和操作日志,不应再次复制签发规则。

View File

@@ -0,0 +1,109 @@
from datetime import date
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.api.routes.admin_imports import COL_COURSE_NAME, COL_STAGE_NAME, build_import_template_workbook
from app.db.base import Base
from app.models import Certificate, Learner, ProjectCourse
from app.services.certificate_issuance import CertificateIssueData, DuplicateCertificate, issue_certificate
from app.services.learner_identity import LearnerIdentityConflict, resolve_learner
@pytest.fixture
def db() -> Session:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session
def add_project(db: Session) -> None:
db.add(
ProjectCourse(
code="DBY",
name="大本营",
default_certificate_name="培训结业证书",
default_course_name="智慧赋能疗愈师",
default_stage_name="初级",
default_template_code="classic",
)
)
db.flush()
def test_practice_template_has_only_its_required_certificate_columns():
sheet = build_import_template_workbook("practice-camp")["证书导入模板"]
headers = [cell.value for cell in sheet[1]]
assert headers == ["姓名", "手机号", "项目代码", "课程开始日期", "课程结束日期", "发证日期"]
assert COL_COURSE_NAME not in headers
assert COL_STAGE_NAME not in headers
def test_learner_identity_reuses_exact_name_and_phone_but_rejects_name_conflict(db: Session):
learner, created = resolve_learner(db, "张三", "138-0000-0000", source="test")
reused, reused_created = resolve_learner(db, "张三", "+86 13800000000", source="test")
assert created is True
assert reused_created is False
assert reused.id == learner.id
with pytest.raises(LearnerIdentityConflict, match="姓名.*不一致"):
resolve_learner(db, "李四", "13800000000", source="test")
def test_one_learner_can_hold_multiple_template_certificates(db: Session):
add_project(db)
common = dict(
learner_name="张三",
learner_phone="13800000000",
project_code="DBY",
course_start_date=date(2026, 3, 1),
course_end_date=date(2026, 8, 31),
)
classic, _, classic_created = issue_certificate(
db,
CertificateIssueData(
**common,
template_code="classic",
issue_date=date(2026, 9, 5),
course_name="智慧赋能疗愈师",
stage_name="初级",
),
source="test",
)
practice, _, practice_created = issue_certificate(
db,
CertificateIssueData(
**common,
template_code="practice-camp",
issue_date=date(2026, 9, 6),
),
source="test",
)
assert classic_created is True
assert practice_created is False
assert classic.learner_id == practice.learner_id
assert db.query(Learner).count() == 1
assert db.query(Certificate).count() == 2
assert practice.course_name is None
assert practice.stage_name is None
def test_duplicate_certificate_is_rejected(db: Session):
add_project(db)
data = CertificateIssueData(
learner_name="张三",
learner_phone="13800000000",
project_code="DBY",
template_code="practice-camp",
course_start_date=date(2026, 3, 1),
course_end_date=date(2026, 8, 31),
issue_date=date(2026, 9, 5),
)
issue_certificate(db, data, source="test")
with pytest.raises(DuplicateCertificate):
issue_certificate(db, data, source="test")

View File

@@ -1,27 +1,64 @@
from datetime import date
import json
from datetime import date, datetime
import pytest
from openpyxl import load_workbook
from pydantic import ValidationError
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.api.routes import admin_imports
from app.api.routes.admin_imports import (
COL_ISSUE_DATE,
COL_COURSE_END_DATE,
COL_COURSE_NAME,
COL_COURSE_START_DATE,
COL_NAME,
COL_PHONE,
COL_PROJECT,
COL_STAGE_NAME,
TEMPLATE_HEADERS,
build_import_template_workbook,
date_is_valid,
detect_template_from_workbook,
normalize_row_data,
parse_issue_date,
row_errors,
write_error_report,
)
from app.db.base import Base
from app.models import ImportBatch, ImportBatchRow
from app.schemas.certificate import CertificateCreate
def test_parse_issue_date_accepts_common_formats():
assert parse_issue_date("2026-06-01") == date(2026, 6, 1)
assert parse_issue_date("2026/06/01") == date(2026, 6, 1)
assert parse_issue_date("2026-06-01 00:00:00") == date(2026, 6, 1)
assert parse_issue_date("2026-06-01T08:30:00") == date(2026, 6, 1)
def test_normalize_row_data_removes_time_from_excel_dates():
row = normalize_row_data(
{
COL_NAME: "张三",
COL_COURSE_START_DATE: datetime(2026, 6, 1, 0, 0, 0),
COL_ISSUE_DATE: date(2026, 7, 5),
}
)
assert row[COL_COURSE_START_DATE] == "2026-06-01"
assert row[COL_ISSUE_DATE] == "2026-07-05"
def test_detect_template_from_workbook_uses_excel_headers(tmp_path):
source_path = tmp_path / "practice-camp.xlsx"
build_import_template_workbook("practice-camp").save(source_path)
detected = detect_template_from_workbook(source_path)
assert detected is not None
assert detected.code == "practice-camp"
def test_row_errors_require_project_code_to_exist():
@@ -33,7 +70,7 @@ def test_row_errors_require_project_code_to_exist():
COL_COURSE_END_DATE: "2026-05-31",
COL_ISSUE_DATE: "2026-06-01",
}
assert "Project code is inactive or missing" in row_errors(row, {"DBY"})
assert "项目代码不存在或已停用" in row_errors(row, {"DBY"})
def test_date_is_valid_rejects_bad_text():
@@ -70,16 +107,63 @@ def test_import_template_contains_date_examples_and_validation():
instruction_sheet = workbook["填写说明"]
assert [cell.value for cell in data_sheet[1]] == TEMPLATE_HEADERS
assert data_sheet.column_dimensions["D"].number_format == "yyyy-mm-dd"
assert data_sheet.column_dimensions["F"].number_format == "yyyy-mm-dd"
assert len(data_sheet.data_validations.dataValidation) == 3
assert instruction_sheet["C5"].value == "2026-06-01"
assert "YYYY-MM-DD" in instruction_sheet["D5"].value
instruction_rows = {row[0].value: row for row in instruction_sheet.iter_rows(min_row=2)}
assert instruction_rows[COL_COURSE_START_DATE][2].value == "2026-06-01"
assert "实际开始日期" in instruction_rows[COL_COURSE_START_DATE][3].value
assert COL_COURSE_NAME in TEMPLATE_HEADERS
assert COL_STAGE_NAME in TEMPLATE_HEADERS
def test_error_report_flushes_pending_rows_and_exports_reason(tmp_path, monkeypatch):
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
def temporary_data_path(name: str):
folder = tmp_path / name
folder.mkdir(parents=True, exist_ok=True)
return folder
monkeypatch.setattr(admin_imports, "data_path", temporary_data_path)
with Session(engine) as db:
batch = ImportBatch(filename="bad.xlsx", file_path="/tmp/bad.xlsx", template_code="practice-camp")
db.add(batch)
db.flush()
db.add(
ImportBatchRow(
batch_id=batch.id,
row_no=2,
status="failed",
error_message="发证日期格式错误",
raw_json=json.dumps(
{
COL_NAME: "张三",
COL_PHONE: "13800000000",
COL_PROJECT: "DBY",
COL_COURSE_START_DATE: "2026-06-01 00:00:00",
COL_ISSUE_DATE: "错误日期",
},
ensure_ascii=False,
),
)
)
report_path = write_error_report(db, batch.id)
rows = list(load_workbook(report_path, data_only=True).active.iter_rows(values_only=True))
assert rows[1][0] == 2
assert rows[1][1] == "发证日期格式错误"
assert rows[1][2] == "张三"
assert rows[1][5] == "2026-06-01"
assert rows[1][-1] == "错误日期"
def test_certificate_create_rejects_reversed_course_period():
with pytest.raises(ValidationError, match="课程结束日期不能早于课程开始日期"):
CertificateCreate(
learner_id=1,
learner_name="张三",
learner_phone="13800000000",
project_code="DBY",
course_start_date="2026-06-30",
course_end_date="2026-06-01",
@@ -89,7 +173,8 @@ def test_certificate_create_rejects_reversed_course_period():
def test_certificate_create_has_no_issuer_field():
certificate = CertificateCreate(
learner_id=1,
learner_name="张三",
learner_phone="13800000000",
project_code="DBY",
course_start_date="2026-06-01",
course_end_date="2026-06-30",

View File

@@ -16,29 +16,31 @@
### 管理员账号
- 用户名:admin
- 密码:Admin@2026
- 用户名:通过部署时的 `--admin-username` 参数设置
- 密码:通过部署时的 `--admin-password` 参数设置,不得写入 Git
### 安全密钥
- SECRET_KEYc79e1bd5d983f4109b4772a1c3078d5d131a0ee3de2227e4c2ffaacf85bbb13e
- CERTIFICATE_NO_SECRET6c82471bcf54e76ec896af91a3f33bde20d42ebe81e792c0128a99088c50445d
- `SECRET_KEY`:在服务器 `.env` 中设置独立随机值
- `CERTIFICATE_NO_SECRET`:在服务器 `.env` 中设置独立随机值
### 数据库配置
- 数据库类型MySQL 8.4Docker容器
- 数据库名certificate_system
- 数据库用户certificate
- 数据库密码:certificate
- root密码root-password
- 数据库密码:通过服务器环境变量设置
- root密码通过服务器环境变量设置
### 阿里云短信配置
- AccessKey IDLTAI5t7QosqTxVDC9WQr1XKC
- AccessKey SecretbKw8z5jd4dSHd4iw4y1Uek4eqEjTkS
- AccessKey ID仅保存在服务器 `.env`
- AccessKey Secret仅保存在服务器 `.env`
- 短信签名:深圳市慧愈文化科技
- 短信模板CodeSMS_335380348
> 安全提示:生产密码和 AccessKey 不得写入本文件或提交到 Git。若历史版本曾包含真实值必须立即轮换删除当前文件中的明文不能使历史密钥失效。
## 访问地址
- 后台管理https://cert.huiyushuyuan.cn/admin/login

View File

@@ -57,6 +57,7 @@ export interface Learner {
student_no: string | null;
status: string;
remark: string | null;
certificate_count: number;
created_at: string;
updated_at: string;
}
@@ -65,6 +66,7 @@ export interface AdminCertificate {
id: number;
learner_id: number;
learner_name: string | null;
learner_phone: string | null;
import_batch_id: number | null;
project_code: string;
certificate_no: string;
@@ -101,10 +103,29 @@ export interface CertificateTemplate {
name: string;
description: string;
dynamic_fields: string[];
fields: CertificateTemplateField[];
preview_url: string;
status: string;
}
export interface CertificateTemplateField {
key: string;
label: string;
field_type: "text" | "date";
required: boolean;
example: string;
description: string;
}
export interface LearnerImportResult {
total_rows: number;
created_rows: number;
updated_rows: number;
unchanged_rows: number;
failed_rows: number;
errors: Array<{ row_no: number; message: string }>;
}
export interface OperationLog {
id: number;
admin_user_id: number | null;

View File

@@ -30,38 +30,32 @@
</el-form-item>
<div class="create-grid">
<el-form-item label="学员" required>
<el-select v-model="form.learner_id" filterable placeholder="按姓名或手机号选择">
<el-option
v-for="item in activeLearners"
:key="item.id"
:label="`${item.current_name} ${item.phone}`"
:value="item.id"
/>
</el-select>
<el-form-item label="学员姓名" required>
<el-input v-model.trim="form.learner_name" placeholder="请输入证书上的姓名" />
</el-form-item>
<el-form-item label="手机号" required>
<el-input v-model.trim="form.learner_phone" maxlength="18" placeholder="用于匹配或自动创建学员" />
</el-form-item>
<el-form-item label="项目" required>
<el-select v-model="form.project_code" filterable placeholder="选择项目" @change="handleProjectChange">
<el-option v-for="item in activeProjects" :key="item.code" :label="`${item.code} ${item.name}`" :value="item.code" />
</el-select>
</el-form-item>
<el-form-item label="课程开始日期" required>
<el-form-item v-if="hasTemplateField('course_start_date')" label="课程开始日期" required>
<el-date-picker v-model="form.course_start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" />
</el-form-item>
<el-form-item label="课程结束日期" required>
<el-form-item v-if="hasTemplateField('course_end_date')" label="课程结束日期" required>
<el-date-picker v-model="form.course_end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" />
</el-form-item>
<el-form-item label="发证日期" required>
<el-form-item v-if="hasTemplateField('issue_date')" label="发证日期" required>
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" placeholder="选择发证日期" />
</el-form-item>
<template v-if="form.template_code === 'classic'">
<el-form-item label="课程名称">
<el-input v-model.trim="form.course_name" placeholder="留空使用项目默认值" />
</el-form-item>
<el-form-item label="阶段名称">
<el-input v-model.trim="form.stage_name" placeholder="留空使用项目默认值" />
</el-form-item>
</template>
<el-form-item v-if="hasTemplateField('course_name')" label="课程名称" required>
<el-input v-model.trim="form.course_name" placeholder="留空时使用项目默认课程名称" />
</el-form-item>
<el-form-item v-if="hasTemplateField('stage_name')" label="阶段名称" required>
<el-input v-model.trim="form.stage_name" placeholder="留空时使用项目默认阶段名称" />
</el-form-item>
<el-form-item label="内部备注">
<el-input v-model.trim="form.remark" placeholder="不会显示在证书上" />
</el-form-item>
@@ -72,6 +66,7 @@
:closable="false"
:title="`${selectedTemplate.name}将填写:${selectedTemplate.dynamic_fields.join('、')}`"
/>
<p class="identity-note">手机号已存在时会关联原学员不存在时将按本次姓名和手机号自动创建学员</p>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
@@ -108,6 +103,7 @@
<el-table-column type="selection" width="44" />
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
<el-table-column prop="learner_name" label="学员姓名" width="100" show-overflow-tooltip />
<el-table-column prop="learner_phone" label="手机号" width="128" />
<el-table-column prop="project_code" label="项目" width="72" />
<el-table-column label="模板" width="150" show-overflow-tooltip>
<template #default="{ row }">{{ templateName(row.template_code) }}</template>
@@ -196,7 +192,6 @@ import {
type AdminCertificate,
type CertificateTemplate,
type ImportBatch,
type Learner,
type PdfPregenerationJob,
type ProjectCourse,
} from "../api";
@@ -205,7 +200,6 @@ import { apiErrorMessage, downloadFile } from "../download";
const certificates = ref<AdminCertificate[]>([]);
const projects = ref<ProjectCourse[]>([]);
const templates = ref<CertificateTemplate[]>([]);
const learners = ref<Learner[]>([]);
const importBatches = ref<ImportBatch[]>([]);
const keyword = ref("");
const statusValue = ref("");
@@ -223,7 +217,8 @@ const creating = ref(false);
const templateManuallySelected = ref(false);
let pregenerationTimer: number | null = null;
const form = reactive({
learner_id: undefined as number | undefined,
learner_name: "",
learner_phone: "",
project_code: "",
course_name: "",
stage_name: "",
@@ -235,7 +230,6 @@ const form = reactive({
});
const activeProjects = computed(() => projects.value.filter((item) => item.status === "active"));
const activeLearners = computed(() => learners.value.filter((item) => item.status === "active"));
const selectedTemplate = computed(() => templates.value.find((item) => item.code === form.template_code));
function statusText(status: string) {
@@ -268,11 +262,6 @@ async function loadTemplates() {
templates.value = data;
}
async function loadLearners() {
const { data } = await http.get<Learner[]>("/admin/learners");
learners.value = data;
}
async function loadImportBatches() {
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
importBatches.value = data;
@@ -297,7 +286,8 @@ function handleSelectionChange(rows: AdminCertificate[]) {
function resetCreateForm() {
templateManuallySelected.value = false;
Object.assign(form, {
learner_id: undefined,
learner_name: "",
learner_phone: "",
project_code: "",
course_name: "",
stage_name: "",
@@ -312,6 +302,12 @@ function resetCreateForm() {
function selectTemplate(templateCode: string) {
form.template_code = templateCode;
templateManuallySelected.value = true;
if (!hasTemplateField("course_name")) form.course_name = "";
if (!hasTemplateField("stage_name")) form.stage_name = "";
}
function hasTemplateField(key: string) {
return !!selectedTemplate.value?.fields.some((field) => field.key === key);
}
function openCreateDialog() {
@@ -321,32 +317,50 @@ function openCreateDialog() {
function handleProjectChange(projectCode: string) {
const project = projects.value.find((item) => item.code === projectCode);
if (project && !templateManuallySelected.value) form.template_code = project.default_template_code;
if (!project) return;
if (!templateManuallySelected.value) form.template_code = project.default_template_code;
if (!form.course_name) form.course_name = project.default_course_name || "";
if (!form.stage_name) form.stage_name = project.default_stage_name || "";
}
async function createCertificate() {
if (!form.learner_id || !form.project_code || !form.course_start_date || !form.course_end_date || !form.issue_date) {
ElMessage.warning("请填写学员ID、项目、课程起止日期和发证日期");
if (!form.learner_name || !form.learner_phone || !form.project_code) {
ElMessage.warning("请填写学员姓名、手机号和项目");
return;
}
if (form.course_end_date < form.course_start_date) {
const fieldValues: Record<string, string> = {
course_name: form.course_name,
stage_name: form.stage_name,
course_start_date: form.course_start_date,
course_end_date: form.course_end_date,
issue_date: form.issue_date,
};
const missingField = selectedTemplate.value?.fields.find((field) => field.key !== "learner_name" && field.required && !fieldValues[field.key]);
const project = projects.value.find((item) => item.code === form.project_code);
const hasProjectDefault = missingField?.key === "course_name" ? project?.default_course_name : missingField?.key === "stage_name" ? project?.default_stage_name : null;
if (missingField && !hasProjectDefault) {
ElMessage.warning(`请填写${missingField.label}`);
return;
}
if (form.course_start_date && form.course_end_date && form.course_end_date < form.course_start_date) {
ElMessage.warning("课程结束日期不能早于课程开始日期");
return;
}
creating.value = true;
try {
await http.post("/admin/certificates", {
learner_id: form.learner_id,
learner_name: form.learner_name,
learner_phone: form.learner_phone,
project_code: form.project_code,
course_name: form.template_code === "classic" ? form.course_name || null : null,
stage_name: form.template_code === "classic" ? form.stage_name || null : null,
course_name: hasTemplateField("course_name") ? form.course_name || null : null,
stage_name: hasTemplateField("stage_name") ? form.stage_name || null : null,
course_start_date: form.course_start_date,
course_end_date: form.course_end_date,
issue_date: form.issue_date,
template_code: form.template_code,
remark: form.remark || null,
});
ElMessage.success("证书已创建");
ElMessage.success("证书已创建,学员信息已自动关联");
createVisible.value = false;
await loadCertificates();
} finally {
@@ -362,6 +376,7 @@ async function previewCertificate(row: AdminCertificate) {
async function downloadCertificate(row: AdminCertificate) {
downloadingCertificateId.value = row.id;
let downloaded = false;
const loading = ElLoading.service({
lock: true,
text: "当前查询人数较多,请稍后。。。",
@@ -369,12 +384,21 @@ async function downloadCertificate(row: AdminCertificate) {
});
try {
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`);
row.pdf_status = "generated";
downloaded = true;
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "PDF生成失败请稍后再试"));
} finally {
loading.close();
downloadingCertificateId.value = null;
}
if (downloaded) {
try {
await loadCertificates();
} catch {
ElMessage.warning("PDF已生成并下载但列表状态同步失败请稍后重试");
}
}
}
async function voidCertificate(row: AdminCertificate) {
@@ -441,7 +465,7 @@ function stopPregenerationPolling() {
}
onMounted(async () => {
await Promise.all([loadProjects(), loadTemplates(), loadLearners(), loadImportBatches(), loadCertificates()]);
await Promise.all([loadProjects(), loadTemplates(), loadImportBatches(), loadCertificates()]);
});
onUnmounted(stopPregenerationPolling);
@@ -518,6 +542,12 @@ onUnmounted(stopPregenerationPolling);
font-size: 15px;
}
.identity-note {
margin: 12px 0 0;
color: var(--app-text-secondary);
font-size: 13px;
}
.template-choice small {
color: var(--app-muted);
line-height: 1.55;

View File

@@ -5,41 +5,45 @@
<h2>Excel 导入</h2>
<p>上传后先校验确认导入后才会正式写入学员和证书数据</p>
</div>
<el-button @click="downloadTemplate">下载模板</el-button>
<el-button :disabled="!selectedTemplate" @click="downloadTemplate">下载所选模板</el-button>
</header>
<el-alert
class="import-tip"
type="info"
:closable="false"
title="课程开始日期、课程结束日期和发证日期均为必填项,请统一使用 YYYY-MM-DD 格式,例如 2026-06-01。"
title="请先选择证书模板,再下载对应 Excel。不同证书模板的列和必填项不同不要混用文件。"
/>
<div class="batch-template">
<div>
<strong>本批次证书模板</strong>
<span>确认导入后整批证书都会固化为所选模板</span>
<span>下载校验和正式签发都以本次选择为准</span>
</div>
<el-select v-model="templateCode" placeholder="选择证书模板">
<el-option v-for="item in templates" :key="item.code" :label="item.name" :value="item.code" />
</el-select>
<img v-if="selectedTemplate" :src="selectedTemplate.preview_url" :alt="selectedTemplate.name" />
<p v-if="selectedTemplate" class="template-fields">Excel 字段{{ selectedImportColumns.join("") }}</p>
</div>
<el-upload
ref="uploadRef"
class="upload"
drag
:auto-upload="false"
:show-file-list="true"
:limit="1"
:disabled="!selectedTemplate"
accept=".xlsx"
@change="pickFile"
@remove="selectedFile = null"
@remove="clearSelectedFile"
@exceed="replaceFile"
>
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div> Excel 文件拖到这里或点击选择文件</div>
<div>{{ selectedTemplate ? "把 Excel 文件拖到这里或点击选择文件" : "请先选择证书模板" }}</div>
</el-upload>
<el-button type="primary" :disabled="!selectedFile" :loading="uploading" @click="uploadFile">上传并校验</el-button>
<el-button type="primary" :disabled="!selectedFile || !selectedTemplate" :loading="uploading" @click="uploadFile">上传并校验</el-button>
<el-card class="panel" shadow="never">
<el-table :data="batches" border>
@@ -110,7 +114,14 @@
<script setup lang="ts">
import { UploadFilled } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
import {
ElMessage,
ElMessageBox,
genFileId,
type UploadFile,
type UploadInstance,
type UploadRawFile,
} from "element-plus";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { http, type CertificateTemplate, type ImportBatch, type PdfPregenerationJob } from "../api";
@@ -118,8 +129,9 @@ import { apiErrorMessage, downloadFile } from "../download";
const batches = ref<ImportBatch[]>([]);
const templates = ref<CertificateTemplate[]>([]);
const templateCode = ref("classic");
const templateCode = ref("");
const selectedFile = ref<File | null>(null);
const uploadRef = ref<UploadInstance>();
const uploading = ref(false);
const confirmingId = ref<number | null>(null);
const preGeneratingId = ref<number | null>(null);
@@ -129,6 +141,12 @@ let pregenerationTimer: number | null = null;
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
const selectedTemplate = computed(() => templates.value.find((item) => item.code === templateCode.value));
const selectedImportColumns = computed(() => [
"姓名",
"手机号",
"项目代码",
...(selectedTemplate.value?.dynamic_fields.filter((field) => field !== "姓名") || []),
]);
function templateName(code: string) {
return templates.value.find((item) => item.code === code)?.name || code;
@@ -148,8 +166,29 @@ function pickFile(file: UploadFile) {
selectedFile.value = file.raw || null;
}
function clearSelectedFile() {
selectedFile.value = null;
}
function resetUpload() {
selectedFile.value = null;
uploadRef.value?.clearFiles();
}
function replaceFile(files: File[]) {
const file = files[0] as UploadRawFile | undefined;
if (!file) return;
uploadRef.value?.clearFiles();
file.uid = genFileId();
uploadRef.value?.handleStart(file);
}
function downloadTemplate() {
downloadFile("/admin/import-batches/template", "certificate-import-template.xlsx");
if (!selectedTemplate.value) return;
downloadFile(
`/admin/import-batches/template?template_code=${encodeURIComponent(templateCode.value)}`,
`certificate-import-${templateCode.value}.xlsx`,
);
}
async function loadBatches() {
@@ -169,10 +208,16 @@ async function uploadFile() {
const body = new FormData();
body.append("file", selectedFile.value);
body.append("template_code", templateCode.value);
await http.post("/admin/import-batches", body);
ElMessage.success("文件已上传并完成校验,请检查结果后点击确认导入");
selectedFile.value = null;
const { data } = await http.post<ImportBatch>("/admin/import-batches", body);
if (data.failed_rows) {
ElMessage.warning(`校验完成:${data.valid_rows} 行可导入,${data.failed_rows} 行失败,请下载错误报告查看原因`);
} else {
ElMessage.success(`校验通过,共 ${data.valid_rows} 行可导入`);
}
resetUpload();
await loadBatches();
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "上传校验失败"));
} finally {
uploading.value = false;
}
@@ -291,6 +336,13 @@ onUnmounted(stopPregenerationPolling);
border: 1px solid #edf2f4;
}
.template-fields {
grid-column: 1 / -1;
margin: 0;
color: var(--app-muted);
font-size: 13px;
}
.import-tip {
margin-bottom: 4px;
}

View File

@@ -5,8 +5,38 @@
<h2>学员管理</h2>
<p>支持手动新增修改删除和查询学员也可以继续通过 Excel 批量导入</p>
</div>
<el-button type="primary" :icon="Upload" @click="openImportDialog">批量导入学员</el-button>
</header>
<el-dialog v-model="importVisible" title="批量导入学员" width="640px" destroy-on-close>
<div class="import-actions">
<div>
<h3>学员 Excel</h3>
<p>必填姓名和手机号学员编号备注为选填手机号已存在时姓名必须一致</p>
</div>
<el-button :icon="Download" @click="downloadImportTemplate">下载模板</el-button>
</div>
<el-upload drag :auto-upload="false" :limit="1" accept=".xlsx" @change="pickImportFile" @remove="importFile = null">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div>拖入 Excel 文件或点击选择文件</div>
</el-upload>
<el-alert
v-if="importResult"
class="import-result"
:type="importResult.failed_rows ? 'warning' : 'success'"
:closable="false"
:title="`处理 ${importResult.total_rows} 行:新建 ${importResult.created_rows},更新 ${importResult.updated_rows},未变化 ${importResult.unchanged_rows},失败 ${importResult.failed_rows}`"
/>
<el-table v-if="importResult?.errors.length" :data="importResult.errors" size="small" max-height="220">
<el-table-column prop="row_no" label="行号" width="80" />
<el-table-column prop="message" label="失败原因" />
</el-table>
<template #footer>
<el-button @click="importVisible = false">关闭</el-button>
<el-button type="primary" :disabled="!importFile" :loading="importing" @click="importLearners">开始导入</el-button>
</template>
</el-dialog>
<el-card class="panel" shadow="never">
<div class="form-section-head">
<div>
@@ -53,6 +83,7 @@
<el-table-column prop="current_name" label="姓名" width="140" />
<el-table-column prop="phone" label="手机号" width="160" />
<el-table-column prop="student_no" label="学员编号" width="150" />
<el-table-column prop="certificate_count" label="证书数量" width="100" align="center" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ statusText(row.status) }}</el-tag>
@@ -71,16 +102,21 @@
</template>
<script setup lang="ts">
import { Check, Plus, RefreshLeft } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Check, Download, Plus, RefreshLeft, Upload, UploadFilled } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { http, type Learner } from "../api";
import { http, type Learner, type LearnerImportResult } from "../api";
import { apiErrorMessage, downloadFile } from "../download";
const learners = ref<Learner[]>([]);
const keyword = ref("");
const editingId = ref<number | null>(null);
const form = reactive({ current_name: "", phone: "", student_no: "", status: "active", remark: "" });
const importVisible = ref(false);
const importFile = ref<File | null>(null);
const importing = ref(false);
const importResult = ref<LearnerImportResult | null>(null);
function statusText(status: string) {
return status === "active" ? "启用" : "停用";
@@ -130,6 +166,38 @@ async function deleteLearner(row: Learner) {
await loadLearners();
}
function openImportDialog() {
importFile.value = null;
importResult.value = null;
importVisible.value = true;
}
function downloadImportTemplate() {
downloadFile("/admin/learners/import-template", "learner-import-template.xlsx");
}
function pickImportFile(file: UploadFile) {
importFile.value = file.raw || null;
importResult.value = null;
}
async function importLearners() {
if (!importFile.value) return;
importing.value = true;
try {
const body = new FormData();
body.append("file", importFile.value);
const { data } = await http.post<LearnerImportResult>("/admin/learners/import", body);
importResult.value = data;
ElMessage.success(data.failed_rows ? "导入完成,请检查失败行" : "学员导入完成");
await loadLearners();
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "学员导入失败"));
} finally {
importing.value = false;
}
}
onMounted(loadLearners);
</script>
@@ -142,6 +210,34 @@ onMounted(loadLearners);
margin-bottom: 16px;
}
.import-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.import-actions h3,
.import-actions p {
margin: 0;
}
.import-actions p {
margin-top: 6px;
color: var(--app-muted);
font-size: 13px;
}
.upload-icon {
color: var(--app-primary);
font-size: 36px;
}
.import-result {
margin: 16px 0;
}
.panel {
margin-bottom: 16px;
}