Files
server-core/app/api/routers/rbac_certifications.py
T

562 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)),
):
types = await db.cert_types.list(category=category)
# 按大类分组
grouped: dict[str, list] = {}
for t in types:
grouped.setdefault(t["category"], []).append(t)
return {"items": types, "grouped": grouped}
@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}