- 仓储:新增 list_pending_alerts_all / update_alert_escalation(统一读改写 payload, 升级标记独占写入,status/handler_* 列不碰;_parse_alert 补 created_at/handled_at 字符串→datetime 解析,兼容 sqlite 原生 DDL) - escalation_service.scan_and_escalate:扫描判级(普通 4h/24h、AML 1h/4h 短通道)、 幂等闸门(仅升不降)、先持久化再推送、按 (customer_id,level) 降噪合并一次推送、 通知链累积(L1 含 risk_manager / L2 含 compliance)、逐单+任务级审计 - scripts/cron/escalation_scan.py:15min 定时扫描壳(sys.path 引导 + new_trace + JSON 摘要) - 对话线 query_overdue_alerts Tool + 注册表 + 意图词(置于 alert_query 之前)+ summarize 分支 - C5 前置:seed STAFF-31001/31002(risk_manager) + deps 矩阵放行 + chat.py 显式 deny + risk.py 台账全量只读分支;JWT 手册 §5.3/§5.4/§6.1 增补 risk_manager - 测试:conftest 回拨 fixture + test_escalation_service(9) + risk_api/manager(5) + chat deny(1) + chat_tools overdue(2);全量 470 绿(453+17)
179 lines
7.4 KiB
Python
179 lines
7.4 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)。
|
||
挂载: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.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("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,
|
||
"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}
|