- P2: test_risk_api env 注入 FakePublisher,aml 推送不触真 Redis - P3: deny/_authz_audit 增 agent_type 参数,simulate 越权审计传 platform (与网关放行审计同口径);删 HANDLE_RESULTS 死常量(Literal 单处定义) - P3: 补 start_date/end_date 过滤+分页 422 边界+suitability api 层审计断言; 补 app/service/risk/__init__.py - 挂账: B7 行新增④项(启动期拒绝/引擎工厂覆盖三实例化点/handle 原子性/ input_guard_log);B9b 行核查单(disclaimer/scan 幂等/Swagger 手测/analyst); TODO.md T-30/T-31/T-32 进度同步
160 lines
6.2 KiB
Python
160 lines
6.2 KiB
Python
"""风控 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)。
|
||
挂载:B7 集成 main.py;统一响应外壳(含错误体结构对齐手册 §10、预警类响应
|
||
附 disclaimer)挂账 B7(评审 P3-2/P3-7)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import asdict
|
||
from datetime import datetime
|
||
from typing import Literal
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, 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.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 StateConflict
|
||
from app.utils.trace import current_trace, new_trace
|
||
|
||
router = APIRouter(prefix="/api/risk", tags=["risk"])
|
||
|
||
|
||
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 单)。"""
|
||
if auth.has_role("risk_officer"):
|
||
pass
|
||
elif auth.has_role("compliance"):
|
||
alert_type = "aml"
|
||
else:
|
||
deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer/compliance only")
|
||
rows, total = _repo().list_alerts(
|
||
status=status,
|
||
alert_type=alert_type,
|
||
customer_id=customer_id,
|
||
start=start_date,
|
||
end=end_date,
|
||
page=page,
|
||
page_size=page_size,
|
||
)
|
||
return {"items": rows, "total": total, "page": page, "page_size": page_size}
|
||
|
||
|
||
@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:
|
||
return handle_alert(
|
||
alert_id, req.handler_result, auth.actor_id, req.handler_comment, risk_repo=_repo()
|
||
)
|
||
except LookupError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except StateConflict as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
|
||
|
||
@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,
|
||
request_ref="api:suitability_check",
|
||
)
|
||
except LookupError as exc:
|
||
raise HTTPException(status_code=404, detail=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,
|
||
}
|
||
)
|
||
return asdict(result)
|
||
|
||
|
||
@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
|