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