"""风控 API(B6 · PRD FR-4 预警台账与人工处置 / FR-2 校验接口 / FR-5 手动扫描)。 鉴权:`Depends(get_auth_context)`(dev debug 头,T-01 后换 JWT,签名不变)。 归属校验:`assert_customer_access`(G-01,越权 403 + audit,A-9);全部 403/401 经 deps.deny/依赖层审计(手册 P-05,B6 评审 P1-1)。 权限矩阵:GET alerts = risk_officer 全量 / compliance 强制 aml(A-7); handle 仅 risk_officer;suitability/check 走 G-01;aml/scan 仅 risk_officer。 handler_result 枚举由请求模型 Literal 校验(repo 不校验,开发计划 B6 备注)。 直调 suitability/check 每次落 audit(request_ref='api:suitability_check', 评审 P2-3/P3-9);aml/scan 幂等防护挂账 B9b 前(评审 P3-6)。 挂载:main.py include(B7);错误体统一 ApiError → 手册 §10 结构 (utils/response.register_error_handlers,挂账④)。 预警类 API 响应体固定 disclaimer(PRD §6/规则表 §5 · B9b 核查单①)。 """ from __future__ import annotations from dataclasses import asdict from datetime import datetime from typing import Literal from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field from app.api.deps import AuthContext, assert_customer_access, deny, get_auth_context from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository from app.repository.repo_access import RiskListAccess from app.service.risk.alert_service import handle_alert from app.service.risk.aml_service import scan_all from app.service.suitability import suitability_check from app.utils.exceptions import ApiError, StateConflict from app.utils.trace import current_trace, new_trace router = APIRouter(prefix="/api/risk", tags=["risk"]) # 预警系统级声明(规则表 §5,区别于 G-08 客户免责声明;PRD §6 固定出现在预警类响应体) ALERT_DISCLAIMER = "本预警由系统自动生成,最终判定需经风控专员人工审核。" def _repo() -> RiskRepository: """仓储入口(测试 monkeypatch 点)。""" return RiskRepository() class HandleRequest(BaseModel): handler_result: Literal["confirmed_normal", "confirmed_suspicious", "reported"] handler_comment: str | None = Field(None, max_length=512) class SuitabilityCheckRequest(BaseModel): customer_id: str = Field(..., min_length=1) product_id: str = Field(..., min_length=1) @router.get("/alerts") def list_alerts_api( auth: AuthContext = Depends(get_auth_context), status: str | None = None, alert_type: str | None = None, customer_id: str | None = None, start_date: datetime | None = None, end_date: datetime | None = None, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ) -> dict: """预警台账分页(FR-4)。compliance 强制 aml 过滤(A-7:仅返回 aml 单)。""" # C5 前置(PRD 4A.1):risk_manager 经 AGENT_ACCESS_MATRIX 放行 HTTP 通道, # 此处同 risk_officer 全量只读路径(无处置入口);handle/aml/scan 端点角色 # 校验保持 risk_officer only,manager 触碰即 403,零代码改动。 if auth.has_role("risk_officer"): pass elif auth.has_role("risk_manager"): pass elif auth.has_role("service_risk"): pass elif auth.has_role("compliance"): alert_type = "aml" else: deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer/compliance/service_risk only") repo = _repo() rows, total = repo.list_alerts( access=RiskListAccess.api(), status=status, alert_type=alert_type, customer_id=customer_id, start=start_date, end=end_date, page=page, page_size=page_size, ) stats = repo.count_pending_stats(access=RiskListAccess.api(), alert_type=alert_type) return { "items": rows, "total": total, "page": page, "page_size": page_size, "stats": stats, "disclaimer": ALERT_DISCLAIMER, } @router.post("/alerts/{alert_id}/handle") def handle_alert_api(alert_id: str, req: HandleRequest, auth: AuthContext = Depends(get_auth_context)) -> dict: """人工处置(FR-4):仅 risk_officer(A-7 compliance 403);状态机 + 审计。""" if not auth.has_role("risk_officer"): deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer only") try: handled = handle_alert( alert_id, req.handler_result, auth.actor_id, req.handler_comment, risk_repo=_repo() ) except LookupError as exc: # NotFoundError 子类(B6 评审 P3-5 收敛) raise ApiError(404, "NOT_FOUND", str(exc)) from exc except StateConflict as exc: raise ApiError(409, "STATE_CONFLICT", str(exc)) from exc return {**handled, "disclaimer": ALERT_DISCLAIMER} @router.post("/suitability/check") def suitability_check_api(req: SuitabilityCheckRequest, auth: AuthContext = Depends(get_auth_context)) -> dict: """适当性校验(FR-2/G-01):customer 仅本人、advisor 名下、risk_officer 全量。 直调路径每次校验补 audit(PRD §7.3 全量留痕;网关路径由 trade_request 审计兜底,评审 P2-3)。 """ repo = _repo() try: core = CoreReadOnlyRepository() assert_customer_access(auth, req.customer_id, core_ro=core, risk_repo=repo) result = suitability_check( req.customer_id, req.product_id, core_ro=core, risk_repo=repo, check_source="manual", actor_id=auth.actor_id, request_ref="api:suitability_check", ) except LookupError as exc: # 归属校验阶段客户缺失(判定阶段 not_found 已改结构化返回,main 契约) raise ApiError(404, "NOT_FOUND", str(exc)) from exc repo.insert_audit_log( { "trace_id": current_trace() or new_trace(), "event_type": "suitability_check", "agent_type": "risk", "actor_id": auth.actor_id, "customer_id": req.customer_id, "rule_id": result.rule_id, "input_summary": { "product_id": req.product_id, "request_ref": "api:suitability_check", "reasons": list(result.reasons), }, "decision": "suitability_blocked" if result.blocked else "suitability_passed", "risk_score": None, "handler_id": None, "handler_result": None, "handler_comment": None, } ) body = asdict(result) if result.blocked: # G-08 三要素补齐(PRD §6 阻断类响应;直调阻断面向客户本人,评审 B9b P3-4) from app.gateway.trade_gateway import ADVICE, RECORDED_NOTICE body["advice"] = ADVICE body["notice"] = RECORDED_NOTICE return body @router.post("/aml/scan") def aml_scan_api(auth: AuthContext = Depends(get_auth_context)) -> dict: """手动全量 AML 扫描(FR-5 触发时机 2):仅 risk_officer。""" if not auth.has_role("risk_officer"): deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer only") repo = _repo() summary = scan_all(core_ro=CoreReadOnlyRepository(), risk_repo=repo) repo.insert_audit_log( { "trace_id": current_trace() or new_trace(), "event_type": "aml_scan", "agent_type": "risk", "actor_id": auth.actor_id, "customer_id": None, "rule_id": None, "input_summary": dict(summary), "decision": "scan_completed", "risk_score": None, "handler_id": None, "handler_result": None, "handler_comment": None, } ) return {**summary, "disclaimer": ALERT_DISCLAIMER}