# -*- coding: utf-8 -*- """信用认证体系端点:认证类型/我的认证/提交申请/技能测试/运营审核。 覆盖四大认证线:个人认证 / 企业认证 / OPC人才认证(付费) / 技能认证(按需付费)。 """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from ..dependencies import get_db from ...rbac import require_roles, write_audit from ...infrastructure.repositories import Database, new_id, utcnow_iso from ...infrastructure.models import Certification, CertificationType, SkillTest import json router = APIRouter(tags=["certifications"]) _ANY = ("opc_member", "carrier", "operator") _OPERATOR = ("operator",) # ── Pydantic 请求模型 ────────────────────────────────────────────────────── class CertApplyRequest(BaseModel): cert_type: str materials: dict = {} level: str = "" class CertResubmitRequest(BaseModel): materials: dict = {} class CertReviewRequest(BaseModel): status: str # approved / rejected / revoked review_note: str = "" credit_points: int | None = None class SkillTestStartRequest(BaseModel): skill_domain: str class SkillTestSubmitRequest(BaseModel): answers: dict = {} # ── 认证类型配置 ──────────────────────────────────────────────────────────── @router.get("/cert/types", summary="认证类型列表(按大类分组)") async def list_cert_types( category: str = "", db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): from ...infrastructure.cache import cache as redis_cache cache_key = f"cert:types:{category or 'all'}" cached = await redis_cache.get_json(cache_key) if cached is not None: return cached types = await db.cert_types.list(category=category) # 按大类分组 grouped: dict[str, list] = {} for t in types: grouped.setdefault(t["category"], []).append(t) result = {"items": types, "grouped": grouped} import asyncio asyncio.create_task(redis_cache.set_json(cache_key, result, ttl=300)) return result @router.get("/cert/types/{code}", summary="认证类型详情") async def get_cert_type( code: str, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): t = await db.cert_types.get_by_code(code) if t is None: raise HTTPException(status_code=404, detail="认证类型不存在") return t # ── 我的认证 ──────────────────────────────────────────────────────────────── @router.get("/me/certs", summary="我的认证列表") async def my_certs( cert_type: str = "", status: str = "", category: str = "", db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): items = await db.certifications.list( user_id=user["id"], cert_type=cert_type, status=status, category=category ) # 统计已获得的信用加分 total_credit = sum(i.get("credit_points", 0) for i in items if i.get("status") in ("active", "approved")) return {"items": items, "total_credit_points": total_credit} @router.get("/me/certs/{cert_id}", summary="我的认证详情") async def get_my_cert( cert_id: str, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): c = await db.certifications.get(cert_id) if c is None or c.get("user_id") != user["id"]: raise HTTPException(status_code=404, detail="认证记录不存在") return c @router.post("/cert/apply", summary="提交认证申请") async def apply_cert( req: CertApplyRequest, request: Request, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): # 校验认证类型 cert_type_info = await db.cert_types.get_by_code(req.cert_type) if cert_type_info is None: raise HTTPException(status_code=400, detail="认证类型不存在") # 检查是否已有生效中的同类型认证 existing = await db.certifications.get_active(user["id"], req.cert_type) if existing: raise HTTPException(status_code=400, detail="该类型认证已生效,无需重复申请") # 检查是否有待审核的同类型认证 pending = await db.certifications.list(user_id=user["id"], cert_type=req.cert_type, status="pending") if pending: raise HTTPException(status_code=400, detail="该类型认证审核中,请耐心等待") # 自动审核模式:直接通过 now = utcnow_iso() status = "approved" if cert_type_info.get("review_mode") == "auto" else "pending" credit_points = cert_type_info.get("credit_points", 0) if status == "approved" else 0 expires_at = "" if cert_type_info.get("validity_days", 0) > 0 and status == "approved": from datetime import datetime, timedelta exp = datetime.now() + timedelta(days=cert_type_info["validity_days"]) expires_at = exp.strftime("%Y-%m-%dT%H:%M:%S") cert = await db.certifications.create( user_id=user["id"], cert_type=req.cert_type, cert_category=cert_type_info.get("category", "personal"), level=req.level, status=status, materials=req.materials, credit_points=credit_points, fee_amount=cert_type_info.get("fee", 0), submitted_at=now, approved_at=now if status == "approved" else "", expires_at=expires_at, ) await write_audit(db, action="cert.apply", resource="certification", resource_id=cert["id"], detail=f"type={req.cert_type}, status={status}", user=user, request=request) # 实时通知:待人工审核时通知运营;自动通过时回执申请人 from ...services.notification_service import notify as _n if status == "pending": await _n(db, user["id"], "cert", "认证申请已提交", f"「{cert_type_info.get('name') or req.cert_type}」认证已提交,请等待审核", event_code="cert.submitted", link="/opc/credit", ref_type="certification", ref_id=cert["id"]) try: from ...services.notification_service import notify_roles await notify_roles(db, ["operator"], "ops", "新认证待审核", f"新认证申请「{cert_type_info.get('name') or req.cert_type}」待审核", event_code="ops.cert_review", link="/cert-admin", ref_type="certification", ref_id=cert["id"]) except Exception: # noqa: BLE001 pass elif status == "approved": await _n(db, user["id"], "cert", "认证审核通过", f"「{cert_type_info.get('name') or req.cert_type}」认证已自动通过" + (f",信用分 +{credit_points}" if credit_points else ""), event_code="cert.approved", level="success", link="/opc/credit", ref_type="certification", ref_id=cert["id"]) return {"ok": True, "item": cert, "auto_approved": status == "approved"} @router.post("/cert/{cert_id}/resubmit", summary="重新提交认证(驳回后)") async def resubmit_cert( cert_id: str, req: CertResubmitRequest, request: Request, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): c = await db.certifications.get(cert_id) if c is None or c.get("user_id") != user["id"]: raise HTTPException(status_code=404, detail="认证记录不存在") if c.get("status") not in ("rejected",): raise HTTPException(status_code=400, detail="仅驳回状态可重新提交") updated = await db.certifications.update(cert_id, { "materials": req.materials, "status": "pending", "submitted_at": utcnow_iso(), "review_note": "", }) await write_audit(db, action="cert.resubmit", resource="certification", resource_id=cert_id, detail="重新提交", user=user, request=request) return {"ok": True, "item": updated} @router.delete("/cert/{cert_id}", summary="撤销认证申请(审核前)") async def cancel_cert( cert_id: str, request: Request, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): c = await db.certifications.get(cert_id) if c is None or c.get("user_id") != user["id"]: raise HTTPException(status_code=404, detail="认证记录不存在") if c.get("status") not in ("pending", "not_started"): raise HTTPException(status_code=400, detail="仅审核中状态可撤销") updated = await db.certifications.update(cert_id, {"status": "revoked"}) await write_audit(db, action="cert.cancel", resource="certification", resource_id=cert_id, detail="用户撤销申请", user=user, request=request) return {"ok": True, "item": updated} # ── 技能测试 ──────────────────────────────────────────────────────────────── @router.get("/skill/domains", summary="技能测试领域列表") async def list_skill_domains( db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): types = await db.cert_types.list(category="skill") # 附带用户已完成的最高成绩 result = [] for t in types: domain = t["code"].replace("skill_", "") best = await db.skill_tests.get_latest_completed(user["id"], domain) t["my_best_score"] = best.get("score", 0) if best else 0 t["my_best_level"] = best.get("level", "") if best else "" result.append(t) return {"items": result} @router.post("/skill/test/start", summary="开始技能测试(创建测试记录)") async def start_skill_test( req: SkillTestStartRequest, request: Request, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): # 校验领域 cert_type = await db.cert_types.get_by_code(f"skill_{req.skill_domain}") if cert_type is None: raise HTTPException(status_code=400, detail="技能领域不存在") # 检查是否有未完成的测试 existing = await db.skill_tests.list(user_id=user["id"], skill_domain=req.skill_domain, status="in_progress") if existing: return {"ok": True, "item": existing[0], "resumed": True} test = await db.skill_tests.create( user_id=user["id"], skill_domain=req.skill_domain, status="in_progress", started_at=utcnow_iso(), ) await write_audit(db, action="skill.test.start", resource="skill_test", resource_id=test["id"], detail=f"domain={req.skill_domain}", user=user, request=request) return {"ok": True, "item": test, "resumed": False} @router.post("/skill/test/{test_id}/submit", summary="提交技能测试") async def submit_skill_test( test_id: str, req: SkillTestSubmitRequest, request: Request, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): test = await db.skill_tests.get(test_id) if test is None or test.get("user_id") != user["id"]: raise HTTPException(status_code=404, detail="测试记录不存在") if test.get("status") != "in_progress": raise HTTPException(status_code=400, detail="测试已提交或已过期") # 简单评分:从 answers 中计算客观题得分(实际应从题库判分) objective_score = 0 total_objective = 0 for qid, ans in req.answers.items(): if isinstance(ans, dict) and ans.get("type") == "objective": total_objective += 1 if ans.get("correct"): objective_score += 1 score = int(objective_score / max(total_objective, 1) * 100) if total_objective > 0 else 0 # 等级判定 if score >= 95: level = "expert" elif score >= 85: level = "advanced" elif score >= 75: level = "intermediate" elif score >= 60: level = "beginner" else: level = "" now = utcnow_iso() updated = await db.skill_tests.update(test_id, { "status": "completed" if level else "failed", "score": score, "level": level, "objective_score": objective_score, "answers": req.answers, "submitted_at": now, "scored_at": now, }) # 通过则创建认证记录 cert_id = "" if level: cert_type = await db.cert_types.get_by_code(f"skill_{test['skill_domain']}") credit_map = {"beginner": 40, "intermediate": 80, "advanced": 120, "expert": 200} credit = credit_map.get(level, 0) from datetime import datetime, timedelta exp = datetime.now() + timedelta(days=cert_type.get("validity_days", 730) if cert_type else 730) cert = await db.certifications.create( user_id=user["id"], cert_type=f"skill_{test['skill_domain']}", cert_category="skill", level=level, status="active", materials={"test_id": test_id, "score": score}, credit_points=credit, submitted_at=now, approved_at=now, expires_at=exp.strftime("%Y-%m-%dT%H:%M:%S"), ) cert_id = cert["id"] # 回写 certification_id await db.skill_tests.update(test_id, {"certification_id": cert_id}) # 信用事件:技能测试通过(按等级 40/80/120/200,单用户按 ref_id 去重) from ...services.credit_engine import on_credit_event await on_credit_event( db, user_id=user["id"], event_code="skill.test.passed", payload={"level": level}, ref_type="skill_test", ref_id=test_id, reason=f"技能测试通过({level})", ) await write_audit(db, action="skill.test.submit", resource="skill_test", resource_id=test_id, detail=f"score={score}, level={level}", user=user, request=request) return {"ok": True, "item": updated, "certification_id": cert_id} @router.get("/skill/test/{test_id}/result", summary="技能测试结果") async def get_skill_test_result( test_id: str, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): test = await db.skill_tests.get(test_id) if test is None or test.get("user_id") != user["id"]: raise HTTPException(status_code=404, detail="测试记录不存在") return test @router.get("/skill/my-certs", summary="我的技能认证列表") async def my_skill_certs( db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): items = await db.certifications.list(user_id=user["id"], category="skill", status="active") return {"items": items} # ── 用户公开认证信息(脱敏) ────────────────────────────────────────────────── @router.get("/users/{user_id}/certs", summary="获取用户公开认证信息(脱敏)") async def get_user_public_certs( user_id: str, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_ANY)), ): items = await db.certifications.list(user_id=user_id, status="active") # 脱敏:移除 materials 中的敏感信息 public = [] for c in items: public.append({ "id": c["id"], "cert_type": c["cert_type"], "cert_category": c["cert_category"], "level": c["level"], "status": c["status"], "credit_points": c["credit_points"], "approved_at": c["approved_at"], "expires_at": c["expires_at"], }) return {"items": public} # ── 运营端:认证审核 ───────────────────────────────────────────────────────── @router.get("/admin/cert/list", summary="认证申请列表(运营端)") async def admin_list_certs( status: str = "", cert_type: str = "", category: str = "", db: Database = Depends(get_db), user: dict = Depends(require_roles(*_OPERATOR)), ): items = await db.certifications.list( status=status, cert_type=cert_type, category=category ) return {"items": items} @router.get("/admin/cert/{cert_id}", summary="认证申请详情(运营端)") async def admin_get_cert( cert_id: str, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_OPERATOR)), ): c = await db.certifications.get(cert_id) if c is None: raise HTTPException(status_code=404, detail="认证记录不存在") return c @router.post("/admin/cert/{cert_id}/review", summary="审核认证申请(通过/驳回/撤销)") async def admin_review_cert( cert_id: str, req: CertReviewRequest, request: Request, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_OPERATOR)), ): c = await db.certifications.get(cert_id) if c is None: raise HTTPException(status_code=404, detail="认证记录不存在") if req.status not in ("approved", "rejected", "revoked", "active"): raise HTTPException(status_code=400, detail="状态仅支持 approved/rejected/rejected/active") # 通过时计算信用加分和有效期 credit_points = req.credit_points expires_at = c.get("expires_at", "") if req.status in ("approved", "active"): if credit_points is None: cert_type_info = await db.cert_types.get_by_code(c["cert_type"]) credit_points = cert_type_info.get("credit_points", 0) if cert_type_info else 0 if not expires_at: cert_type_info = await db.cert_types.get_by_code(c["cert_type"]) validity = cert_type_info.get("validity_days", 0) if cert_type_info else 0 if validity > 0: from datetime import datetime, timedelta exp = datetime.now() + timedelta(days=validity) expires_at = exp.strftime("%Y-%m-%dT%H:%M:%S") updated = await db.certifications.set_status( cert_id, req.status, reviewer_id=user["id"], review_note=req.review_note, credit_points=credit_points, expires_at=expires_at, ) # 信用事件:认证通过按类别加分;撤销/驳回已生效认证则扣回 from ...services.credit_engine import on_credit_event, recalc_user cert_category = (c.get("cert_category") or "").strip() event_map = { "identity": "cert.identity", "merchant": "cert.merchant", "enterprise": "cert.enterprise", "education": "cert.education", "qualification": "cert.qualification", "opc": "cert.opc", "skill": "cert.skill", } if req.status in ("approved", "active"): event_code = event_map.get(cert_category, "") if event_code: await on_credit_event( db, user_id=c["user_id"], event_code=event_code, ref_type="certification", ref_id=cert_id, reason=f"{c.get('cert_type', '')} 认证审核通过", ) elif req.status == "revoked" and c.get("status") in ("active", "approved"): # 扣回:认证撤销按当时加分反向冲正 back = credit_points if credit_points is not None else 0 if back and back > 0: await db.credit_ledger.add( user_id=c["user_id"], event_code="manual.adjust", delta=-back, dimension="compliance", reason=f"认证撤销扣回({c.get('cert_type', '')})", ref_type="certification", ref_id=cert_id, source="admin", operator_id=user["id"], ) await recalc_user(db, c["user_id"]) await write_audit(db, action="admin.cert.review", resource="certification", resource_id=cert_id, detail=f"status={req.status}, credit={credit_points}", user=user, request=request) # 实时通知:审核结果回传申请人(通过/驳回),认证通过含信用变化提示 from ...services.notification_service import notify as _n _title = {"approved": "认证审核通过", "active": "认证已生效", "rejected": "认证审核未通过", "revoked": "认证已撤销"}.get(req.status, "认证状态更新") _lvl = "success" if req.status in ("approved", "active") else "warning" _credit_txt = f",信用分 +{credit_points}" if req.status in ("approved", "active") and credit_points else "" await _n(db, c["user_id"], "cert", _title, f"你的「{c.get('cert_type', '')}」认证{_title}{_credit_txt}" f"{(':' + req.review_note) if req.review_note else ''}", event_code=f"cert.{'approved' if req.status in ('approved','active') else ('rejected' if req.status=='rejected' else 'revoked')}", level=_lvl, link="/opc/credit", ref_type="certification", ref_id=cert_id) # 认证通过时同步发徽章提示 if req.status in ("approved", "active") and cert_category == "opc": await _n(db, c["user_id"], "cert", "OPC 人才认证已生效", "你已获得 OPC 人才认证,可参与高等级任务与投标", event_code="cert.badge_granted", level="success", link="/opc/credit", ref_type="certification", ref_id=cert_id) return {"ok": True, "item": updated} @router.get("/admin/cert/types", summary="认证类型配置列表(运营端)") async def admin_list_cert_types( db: Database = Depends(get_db), user: dict = Depends(require_roles(*_OPERATOR)), ): items = await db.cert_types.list(active_only=False) return {"items": items} @router.post("/admin/cert/types", summary="新增认证类型(运营端)") async def admin_create_cert_type( body: dict, request: Request, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_OPERATOR)), ): code = body.get("code", "") if not code: raise HTTPException(status_code=400, detail="缺少认证类型编码") existing = await db.cert_types.get_by_code(code) if existing: raise HTTPException(status_code=400, detail="认证类型编码已存在") t = await db.cert_types.create(**body) await write_audit(db, action="admin.cert.type.create", resource="certification_type", resource_id=t["id"], detail=code, user=user, request=request) return {"ok": True, "item": t} @router.put("/admin/cert/types/{code}", summary="编辑认证类型(运营端)") async def admin_update_cert_type( code: str, body: dict, request: Request, db: Database = Depends(get_db), user: dict = Depends(require_roles(*_OPERATOR)), ): t = await db.cert_types.update(code, body) if t is None: raise HTTPException(status_code=404, detail="认证类型不存在") await write_audit(db, action="admin.cert.type.update", resource="certification_type", resource_id=code, detail=json.dumps(body, ensure_ascii=False), user=user, request=request) return {"ok": True, "item": t}