132 lines
5.0 KiB
Python
132 lines
5.0 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)。
|
||
权限矩阵:GET alerts = risk_officer 全量 / compliance 强制 aml(A-7);
|
||
handle 仅 risk_officer;suitability/check 走 G-01;aml/scan 仅 risk_officer。
|
||
handler_result 枚举由请求模型 Literal 校验(repo 不校验,开发计划 B6 备注)。
|
||
挂载:B7 集成 main.py;统一响应外壳挂账 B7。
|
||
"""
|
||
|
||
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, 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"])
|
||
|
||
HANDLE_RESULTS = ("confirmed_normal", "confirmed_suspicious", "reported")
|
||
|
||
|
||
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: datetime | None = None,
|
||
end: 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:
|
||
raise HTTPException(status_code=403, detail="risk_officer/compliance only")
|
||
rows, total = _repo().list_alerts(
|
||
status=status,
|
||
alert_type=alert_type,
|
||
customer_id=customer_id,
|
||
start=start,
|
||
end=end,
|
||
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"):
|
||
raise HTTPException(status_code=403, detail="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 全量。"""
|
||
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()
|
||
)
|
||
except LookupError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
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"):
|
||
raise HTTPException(status_code=403, detail="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
|