Initial certificate system
This commit is contained in:
1
backend/app/api/routes/__init__.py
Normal file
1
backend/app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
195
backend/app/api/routes/admin_certificates.py
Normal file
195
backend/app/api/routes/admin_certificates.py
Normal 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
|
||||
22
backend/app/api/routes/admin_dashboard.py
Normal file
22
backend/app/api/routes/admin_dashboard.py
Normal 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(),
|
||||
}
|
||||
84
backend/app/api/routes/admin_exports.py
Normal file
84
backend/app/api/routes/admin_exports.py
Normal 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:]}"
|
||||
362
backend/app/api/routes/admin_imports.py
Normal file
362
backend/app/api/routes/admin_imports.py
Normal 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
|
||||
126
backend/app/api/routes/admin_learners.py
Normal file
126
backend/app/api/routes/admin_learners.py
Normal 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}
|
||||
115
backend/app/api/routes/admin_logs.py
Normal file
115
backend/app/api/routes/admin_logs.py
Normal 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]
|
||||
82
backend/app/api/routes/admin_projects.py
Normal file
82
backend/app/api/routes/admin_projects.py
Normal 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
|
||||
21
backend/app/api/routes/auth.py
Normal file
21
backend/app/api/routes/auth.py
Normal 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,
|
||||
)
|
||||
8
backend/app/api/routes/health.py
Normal file
8
backend/app/api/routes/health.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health_check() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
138
backend/app/api/routes/public.py
Normal file
138
backend/app/api/routes/public.py
Normal 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
|
||||
Reference in New Issue
Block a user