Initial certificate system

This commit is contained in:
nelso
2026-06-06 19:32:24 +08:00
commit ce4ed6213b
105 changed files with 9133 additions and 0 deletions

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,195 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.api.deps import require_roles
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
from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action
from app.services.pdf import render_certificate_pdf
router = APIRouter()
def _create_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
@router.get("", response_model=list[CertificateOut])
def list_certificates(
keyword: str | None = Query(default=None),
status_value: str | None = Query(default=None, alias="status"),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[Certificate]:
query = db.query(Certificate).order_by(Certificate.id.desc())
if keyword:
like = f"%{keyword}%"
query = query.filter(
or_(
Certificate.certificate_no.like(like),
Certificate.certificate_name.like(like),
Certificate.project_code.like(like),
Certificate.course_name.like(like),
Certificate.stage_name.like(like),
)
)
if status_value:
query = query.filter(Certificate.status == status_value)
return query.limit(100).all()
@router.post("", response_model=CertificateOut, status_code=status.HTTP_201_CREATED)
def create_certificate(
payload: CertificateCreate,
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")
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,
issue_date=payload.issue_date,
issuer_name=payload.issuer_name or project.default_issuer_name,
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})
db.commit()
db.refresh(certificate)
return certificate
@router.get("/{certificate_id}", response_model=CertificateOut)
def get_certificate(
certificate_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> Certificate:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
return certificate
@router.get("/{certificate_id}/preview")
def preview_certificate(
certificate_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> dict[str, object]:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
learner = db.get(Learner, certificate.learner_id)
public_token = db.get(CertificateAccessToken, certificate.public_token_id) if certificate.public_token_id else None
qr_token = db.get(CertificateAccessToken, certificate.qr_token_id) if certificate.qr_token_id else None
return {
"certificate_no": certificate.certificate_no,
"learner_name": learner.current_name if learner else "",
"certificate_name": certificate.certificate_name,
"project_code": certificate.project_code,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"status": certificate.status,
"public_url": f"/cert/{public_token.token_value}" if public_token else "",
"verify_url": f"/verify/{qr_token.token_value}" if qr_token else "",
}
@router.get("/{certificate_id}/download")
def download_certificate(
certificate_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> FileResponse:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
if certificate.status != "valid":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Voided certificate cannot download normal PDF")
learner = db.get(Learner, certificate.learner_id)
token = db.get(CertificateAccessToken, certificate.qr_token_id or certificate.public_token_id)
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
if not learner or not token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Certificate data is incomplete")
pdf_path = render_certificate_pdf(certificate, learner, project, token)
db.commit()
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
@router.post("/{certificate_id}/void", response_model=CertificateOut)
def void_certificate(
certificate_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Certificate:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
certificate.status = "voided"
certificate.pdf_status = "not_generated"
certificate.pdf_file_path = None
learner = db.get(Learner, certificate.learner_id)
log_action(db, admin, "void_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name if learner else "", "previous_status": "valid", "status": "voided"})
db.commit()
db.refresh(certificate)
return certificate
@router.post("/{certificate_id}/token/regenerate", response_model=CertificateOut)
def regenerate_public_token(
certificate_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> Certificate:
certificate = db.get(Certificate, certificate_id)
if not certificate:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
if certificate.public_token_id:
old_token = db.get(CertificateAccessToken, certificate.public_token_id)
if old_token:
old_token.status = "revoked"
certificate.public_token_id = _create_token(db, certificate.id, "public_link")
learner = db.get(Learner, certificate.learner_id)
log_action(
db,
admin,
"regenerate_public_token",
"certificate",
certificate.id,
{"certificate_no": certificate.certificate_no, "learner_name": learner.current_name if learner else ""},
)
db.commit()
db.refresh(certificate)
return certificate

View File

@@ -0,0 +1,22 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.db.session import get_db
from app.models import AdminUser, Certificate, ImportBatch, Learner
router = APIRouter()
@router.get("/summary")
def get_summary(
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> dict[str, int]:
return {
"learner_count": db.query(Learner).count(),
"certificate_count": db.query(Certificate).count(),
"valid_certificate_count": db.query(Certificate).filter(Certificate.status == "valid").count(),
"voided_certificate_count": db.query(Certificate).filter(Certificate.status == "voided").count(),
"recent_import_count": db.query(ImportBatch).count(),
}

View File

@@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.core.config import settings
from app.core.paths import data_path
from app.db.session import get_db
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner
from app.services.logs import log_action
router = APIRouter()
@router.get("/certificates")
def export_certificates(
project_code: str | None = Query(default=None),
status_value: str | None = Query(default=None, alias="status"),
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> StreamingResponse:
query = (
db.query(Certificate, Learner, CertificateAccessToken)
.join(Learner, Learner.id == Certificate.learner_id)
.join(CertificateAccessToken, CertificateAccessToken.id == Certificate.public_token_id)
.order_by(Certificate.id.desc())
)
if project_code:
query = query.filter(Certificate.project_code == project_code.strip().upper())
if status_value:
query = query.filter(Certificate.status == status_value)
workbook = Workbook()
sheet = workbook.active
sheet.title = "证书导出"
sheet.append(
[
"姓名",
"手机号",
"证书编号",
"证书对外直达链接",
"项目代码",
"证书名称",
"课程名称",
"阶段名称",
"发证日期",
"证书状态",
"PDF状态",
]
)
for certificate, learner, token in query.limit(50_000).all():
sheet.append(
[
learner.current_name,
_mask_phone(learner.phone),
certificate.certificate_no,
f"{settings.public_base_url}/cert/{token.token_value}",
certificate.project_code,
certificate.certificate_name,
certificate.course_name,
certificate.stage_name,
certificate.issue_date.isoformat(),
certificate.status,
certificate.pdf_status,
]
)
export_path = data_path("exports") / "certificates-export.xlsx"
workbook.save(export_path)
log_action(db, admin, "export_certificates", "certificate", detail={"project_code": project_code, "status": status_value})
db.commit()
file_handle = export_path.open("rb")
return StreamingResponse(
file_handle,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="certificates-export.xlsx"'},
)
def _mask_phone(phone: str) -> str:
if len(phone) < 7:
return phone
return f"{phone[:3]}****{phone[-4:]}"

View File

@@ -0,0 +1,362 @@
import json
import shutil
from datetime import date, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from openpyxl import Workbook, load_workbook
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.schemas.import_batch import ImportBatchOut
from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action
router = APIRouter()
COL_NAME = "\u59d3\u540d"
COL_PHONE = "\u624b\u673a\u53f7"
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
TEMPLATE_HEADERS = [
COL_NAME,
COL_PHONE,
COL_PROJECT,
COL_ISSUE_DATE,
]
REQUIRED_HEADERS = [COL_NAME, COL_PHONE, COL_PROJECT, COL_ISSUE_DATE]
@router.get("/template")
def download_template(_: AdminUser = Depends(require_roles("system_admin", "certificate_admin"))) -> StreamingResponse:
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
sheet.append(TEMPLATE_HEADERS)
stream_path = data_path("exports") / "certificate-import-template.xlsx"
workbook.save(stream_path)
file_handle = stream_path.open("rb")
return StreamingResponse(
file_handle,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="certificate-import-template.xlsx"'},
)
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
def upload_import_file(
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ImportBatch:
if not file.filename or not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only .xlsx files are supported")
upload_path = data_path("uploads") / file.filename
with upload_path.open("wb") as target:
shutil.copyfileobj(file.file, target)
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), created_by=admin.id)
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})
db.commit()
db.refresh(batch)
return batch
@router.get("", response_model=list[ImportBatchOut])
def list_import_batches(
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[ImportBatch]:
return db.query(ImportBatch).order_by(ImportBatch.id.desc()).limit(50).all()
@router.get("/{batch_id}/error-report")
def download_error_report(
batch_id: int,
db: Session = Depends(get_db),
_: 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",
)
@router.get("/{batch_id}/file")
def download_source_file(
batch_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> 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,
)
@router.delete("/{batch_id}")
def delete_import_batch(
batch_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> 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")
for file_name in [batch.file_path, batch.error_report_path]:
if file_name:
path = Path(file_name)
if path.exists():
path.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})
db.commit()
return {"ok": True}
@router.post("/{batch_id}/confirm", response_model=ImportBatchOut)
def confirm_import_batch(
batch_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> 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")
if batch.status == "imported":
return batch
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id, ImportBatchRow.status == "valid").all()
ok_rows = 0
failed_rows = 0
for row in rows:
row_data = json.loads(row.raw_json or "{}")
learner = upsert_learner(db, row_data)
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, issue_date)
if duplicate:
row.status = "skipped"
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
ok_rows += 1
continue
certificate = Certificate(
learner_id=learner.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,
issue_date=issue_date,
issuer_name=project.default_issuer_name,
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"
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.commit()
db.refresh(batch)
return batch
def validate_batch(db: Session, batch: ImportBatch, upload_path: Path) -> None:
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))]
header_index = {name: idx for idx, name in enumerate(header)}
missing = [name for name in TEMPLATE_HEADERS 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}"))
return
active_codes = {row[0] for row in db.query(ProjectCourse.code).filter(ProjectCourse.status == "active").all()}
total = valid = failed = 0
for row_no, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
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),
)
)
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"
if failed:
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")
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")
if row_data.get(COL_ISSUE_DATE) and not date_is_valid(row_data[COL_ISSUE_DATE]):
errors.append("Issue date format is invalid")
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 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 find_duplicate_certificate(db: Session, learner_id: int, project: ProjectCourse, issue_date: date) -> 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.issue_date == issue_date)
.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 optional_text(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def parse_issue_date(value: object) -> date:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
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"Invalid issue date: {text}")
def date_is_valid(value: object) -> bool:
try:
parse_issue_date(value)
return True
except HTTPException:
return False
def write_error_report(db: Session, batch_id: int) -> Path:
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u9519\u8bef\u62a5\u544a"
sheet.append(["\u884c\u53f7", "\u9519\u8bef\u539f\u56e0", "\u539f\u59cb\u6570\u636e"])
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])
report_path = data_path("error-reports") / f"import-errors-{batch_id}.xlsx"
workbook.save(report_path)
return report_path

View File

@@ -0,0 +1,126 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
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.services.logs import diff_values, log_action, mask_phone
router = APIRouter()
@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())
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()
@router.post("", response_model=LearnerOut, status_code=status.HTTP_201_CREATED)
def create_learner(
payload: LearnerCreate,
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)
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("/{learner_id}", response_model=LearnerOut)
def get_learner(
learner_id: int,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> Learner:
learner = db.get(Learner, learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
return learner
@router.put("/{learner_id}", response_model=LearnerOut)
def update_learner(
learner_id: int,
payload: LearnerUpdate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> 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()
if duplicate:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Phone already exists")
before = {
"phone": learner.phone,
"current_name": learner.current_name,
"student_no": learner.student_no,
"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
learner.student_no = payload.student_no
learner.status = payload.status
learner.remark = payload.remark
after = {
"phone": learner.phone,
"current_name": learner.current_name,
"student_no": learner.student_no,
"status": learner.status,
"remark": learner.remark,
}
changes = diff_values(before, after, list(after.keys()))
if "phone" in changes:
changes["phone"] = {"before": mask_phone(changes["phone"]["before"]), "after": mask_phone(changes["phone"]["after"])}
log_action(db, admin, "update_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "changes": changes})
db.commit()
db.refresh(learner)
return learner
@router.delete("/{learner_id}")
def delete_learner(
learner_id: int,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, bool]:
learner = db.get(Learner, learner_id)
if not learner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
learner.status = "deleted"
log_action(db, admin, "delete_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone)})
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,115 @@
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.core.paths import data_path
from app.db.session import get_db
from app.models import AdminUser, OperationLog
from app.schemas.log import OperationLogOut
from app.services.logs import HIGH_RISK_LOG_RETENTION_DAYS, LOG_RETENTION_DAYS, cleanup_expired_logs, format_log
router = APIRouter()
@router.get("", response_model=list[OperationLogOut])
def list_logs(
action: str | None = Query(default=None),
object_type: str | None = Query(default=None),
risk: str | None = Query(default=None),
keyword: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> dict[str, object]:
items = _query_logs(db, action, object_type, risk, keyword)
total = len(items)
total_pages = max(1, (total + page_size - 1) // page_size)
page = min(page, total_pages)
start = (page - 1) * page_size
return {
"items": items[start:start + page_size],
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
@router.get("/export")
def export_logs(
action: str | None = Query(default=None),
object_type: str | None = Query(default=None),
risk: str | None = Query(default=None),
keyword: str | None = Query(default=None),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> StreamingResponse:
items = _query_logs(db, action, object_type, risk, keyword)
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u64cd\u4f5c\u65e5\u5fd7"
sheet.append(["\u65f6\u95f4", "\u64cd\u4f5c\u4ebaID", "\u64cd\u4f5c", "\u5bf9\u8c61", "\u5bf9\u8c61ID", "\u98ce\u9669\u7b49\u7ea7", "\u6458\u8981", "\u8be6\u60c5"])
for item in items:
sheet.append([
item.get("created_at"),
item.get("admin_user_id"),
item.get("action_label"),
item.get("object_label"),
item.get("object_id"),
item.get("risk_label"),
item.get("summary"),
item.get("detail_json"),
])
for column in "ABCDEFGH":
sheet.column_dimensions[column].width = 22 if column != "H" else 60
export_path = data_path("exports") / "operation-logs.xlsx"
workbook.save(export_path)
file_handle = export_path.open("rb")
return StreamingResponse(
file_handle,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="operation-logs.xlsx"'},
)
@router.post("/cleanup")
def cleanup_logs(
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, int]:
removed = cleanup_expired_logs(db)
db.commit()
return {
"removed": removed,
"normal_retention_days": LOG_RETENTION_DAYS,
"high_risk_retention_days": HIGH_RISK_LOG_RETENTION_DAYS,
}
def _query_logs(
db: Session,
action: str | None,
object_type: str | None,
risk: str | None,
keyword: str | None,
) -> list[dict[str, object]]:
query = db.query(OperationLog).order_by(OperationLog.id.desc())
cleanup_expired_logs(db)
db.commit()
if action:
query = query.filter(OperationLog.action == action)
if object_type:
query = query.filter(OperationLog.object_type == object_type)
items = [format_log(item) for item in query.limit(500).all()]
if risk:
items = [item for item in items if item["risk"] == risk]
if keyword:
word = keyword.strip().lower()
items = [
item for item in items
if word in " ".join(str(item.get(key, "")) for key in ["object_id", "summary", "detail_json", "action_label", "object_label"]).lower()
]
return items[:200]

View File

@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import require_roles
from app.db.session import get_db
from app.models import AdminUser, ProjectCourse
from app.schemas.project import ProjectCourseCreate, ProjectCourseOut, ProjectCourseUpdate
from app.services.logs import diff_values, log_action
router = APIRouter()
@router.get("", response_model=list[ProjectCourseOut])
def list_projects(
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[ProjectCourse]:
return db.query(ProjectCourse).order_by(ProjectCourse.code.asc()).all()
@router.post("", response_model=ProjectCourseOut, status_code=status.HTTP_201_CREATED)
def create_project(
payload: ProjectCourseCreate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ProjectCourse:
if db.query(ProjectCourse).filter(ProjectCourse.code == payload.code).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目代码已存在")
project = ProjectCourse(
code=payload.code,
name=payload.name,
default_certificate_name=payload.name,
default_course_name=payload.default_course_name,
default_stage_name=payload.default_stage_name,
default_issuer_name=payload.default_issuer_name,
)
db.add(project)
log_action(db, admin, "create_project", "project_course", detail={"code": payload.code, "name": payload.name, "status": project.status})
db.commit()
db.refresh(project)
return project
@router.put("/{project_id}", response_model=ProjectCourseOut)
def update_project(
project_id: int,
payload: ProjectCourseUpdate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ProjectCourse:
project = db.get(ProjectCourse, project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
before = {
"name": project.name,
"default_course_name": project.default_course_name,
"default_stage_name": project.default_stage_name,
"default_issuer_name": project.default_issuer_name,
"status": project.status,
}
if payload.name is not None:
project.name = payload.name
project.default_certificate_name = payload.name
if payload.default_course_name is not None:
project.default_course_name = payload.default_course_name
if payload.default_stage_name is not None:
project.default_stage_name = payload.default_stage_name
if payload.default_issuer_name is not None:
project.default_issuer_name = payload.default_issuer_name
if payload.status is not None:
project.status = payload.status
after = {
"name": project.name,
"default_course_name": project.default_course_name,
"default_stage_name": project.default_stage_name,
"default_issuer_name": project.default_issuer_name,
"status": project.status,
}
log_action(db, admin, "update_project", "project_course", project.id, {"code": project.code, "name": project.name, "changes": diff_values(before, after, list(after.keys()))})
db.commit()
db.refresh(project)
return project

View File

@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password
from app.db.session import get_db
from app.models import AdminUser
from app.schemas.auth import LoginRequest, LoginResponse
router = APIRouter()
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse:
admin = db.query(AdminUser).filter(AdminUser.username == payload.username).first()
if not admin or admin.status != "active" or not verify_password(payload.password, admin.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号或密码错误,请重试")
return LoginResponse(
access_token=create_access_token(str(admin.id), admin.role_code),
role_code=admin.role_code,
username=admin.username,
)

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}

View File

@@ -0,0 +1,138 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Session
from app.core.security import hash_token
from app.db.session import get_db
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.services.captcha import make_captcha, verify_captcha
from app.services.logs import log_action, mask_phone
from app.services.pdf import render_certificate_pdf
from app.services.rate_limit import hit_too_often
router = APIRouter()
DISCLAIMER = "本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。"
class CertificateSearchRequest(BaseModel):
certificate_no: str | None = Field(default=None, max_length=64)
phone: str | None = Field(default=None, max_length=32)
name: str = Field(min_length=1, max_length=64)
captcha_id: str = Field(min_length=1, max_length=128)
captcha_code: str = Field(min_length=1, max_length=16)
@model_validator(mode="after")
def certificate_no_or_phone_required(self) -> "CertificateSearchRequest":
if not (self.certificate_no or "").strip() and not (self.phone or "").strip():
raise ValueError("请填写证书编号或手机号其中一项")
return self
@router.get("/captcha")
def get_captcha() -> dict[str, str]:
return make_captcha()
@router.post("/certificates/search")
def search_certificate(
payload: CertificateSearchRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, object]:
client_ip = request.client.host if request.client else "unknown"
if hit_too_often(f"search:{client_ip}", limit=30, window_seconds=60):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="访问过于频繁,请稍后再试")
if not verify_captcha(payload.captcha_id, payload.captcha_code):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期,请重新输入")
name = payload.name.strip()
certificate_no = (payload.certificate_no or "").strip().upper()
phone = (payload.phone or "").strip()
query = db.query(Certificate, Learner).join(Learner, Learner.id == Certificate.learner_id)
if certificate_no:
result = query.filter(Certificate.certificate_no == certificate_no).filter(Learner.current_name == name).all()
else:
result = (
query.filter(Learner.phone == phone)
.filter(Learner.current_name == name)
.order_by(Certificate.issue_date.desc(), Certificate.id.desc())
.all()
)
if not result:
log_action(db, None, "public_search_certificate", "public_access", detail={"mode": "certificate_no" if certificate_no else "phone", "name": name, "certificate_no": certificate_no or None, "phone": mask_phone(phone), "matched_count": 0, "result": "not_found"})
db.commit()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未查询到匹配的证书信息,请核对后重试")
log_action(db, None, "public_search_certificate", "public_access", result[0][0].certificate_no, {"mode": "certificate_no" if certificate_no else "phone", "name": name, "certificate_no": certificate_no or None, "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"})
db.commit()
return {"items": [public_certificate_payload(db, certificate, learner) for certificate, learner in result]}
@router.get("/certificates/token/{token}")
def get_certificate_by_token(token: str, db: Session = Depends(get_db)) -> dict[str, object]:
access_token = get_active_token(db, token)
certificate, learner = get_certificate_and_learner(db, access_token.certificate_id)
return public_certificate_payload(db, certificate, learner)
@router.get("/certificates/token/{token}/download")
def download_certificate_pdf(token: str, db: Session = Depends(get_db)) -> FileResponse:
access_token = get_active_token(db, token)
certificate, learner = get_certificate_and_learner(db, access_token.certificate_id)
if certificate.status != "valid":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="证书已作废,不支持下载正常 PDF")
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
pdf_path = render_certificate_pdf(certificate, learner, project, access_token)
log_action(db, None, "public_download_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code})
db.commit()
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
def public_certificate_payload(db: Session, certificate: Certificate, learner: Learner) -> dict[str, object]:
token = db.get(CertificateAccessToken, certificate.public_token_id) if certificate.public_token_id else None
token_value = token.token_value if token and token.status == "active" else None
return {
"certificate_no": certificate.certificate_no,
"learner_name": learner.current_name,
"certificate_name": certificate.certificate_name,
"project_code": certificate.project_code,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"public_token": token_value,
"public_url": f"/cert/{token_value}" if token_value else None,
"download_url": f"/api/public/certificates/token/{token_value}/download" if token_value and certificate.status == "valid" else None,
"can_download_pdf": certificate.status == "valid" and bool(token_value),
"disclaimer": DISCLAIMER,
}
def get_active_token(db: Session, token: str) -> CertificateAccessToken:
access_token = (
db.query(CertificateAccessToken)
.filter(CertificateAccessToken.token_hash == hash_token(token))
.filter(CertificateAccessToken.status == "active")
.first()
)
if not access_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="链接无效或已失效")
return access_token
def get_certificate_and_learner(db: Session, certificate_id: int) -> tuple[Certificate, Learner]:
result = (
db.query(Certificate, Learner)
.join(Learner, Learner.id == Certificate.learner_id)
.filter(Certificate.id == certificate_id)
.first()
)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="证书不存在")
return result