2026-09-06 18:37:53 +08:00
|
|
|
|
"""风控 API(B6 · PRD FR-4 预警台账与人工处置 / FR-2 校验接口 / FR-5 手动扫描)。
|
|
|
|
|
|
|
|
|
|
|
|
鉴权:`Depends(get_auth_context)`(dev debug 头,T-01 后换 JWT,签名不变)。
|
2026-09-06 18:58:48 +08:00
|
|
|
|
归属校验:`assert_customer_access`(G-01,越权 403 + audit,A-9);全部 403/401
|
|
|
|
|
|
经 deps.deny/依赖层审计(手册 P-05,B6 评审 P1-1)。
|
2026-09-06 18:37:53 +08:00
|
|
|
|
权限矩阵:GET alerts = risk_officer 全量 / compliance 强制 aml(A-7);
|
|
|
|
|
|
handle 仅 risk_officer;suitability/check 走 G-01;aml/scan 仅 risk_officer。
|
|
|
|
|
|
handler_result 枚举由请求模型 Literal 校验(repo 不校验,开发计划 B6 备注)。
|
2026-09-06 18:58:48 +08:00
|
|
|
|
直调 suitability/check 每次落 audit(request_ref='api:suitability_check',
|
|
|
|
|
|
评审 P2-3/P3-9);aml/scan 幂等防护挂账 B9b 前(评审 P3-6)。
|
2026-09-06 20:48:50 +08:00
|
|
|
|
挂载:main.py include(B7);错误体统一 ApiError → 手册 §10 结构
|
2026-09-07 01:36:44 +08:00
|
|
|
|
(utils/response.register_error_handlers,挂账④)。
|
|
|
|
|
|
预警类 API 响应体固定 disclaimer(PRD §6/规则表 §5 · B9b 核查单①)。
|
2026-09-06 18:37:53 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import asdict
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from typing import Literal
|
|
|
|
|
|
|
2026-09-06 20:48:50 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
2026-09-06 18:37:53 +08:00
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
2026-09-06 18:58:48 +08:00
|
|
|
|
from app.api.deps import AuthContext, assert_customer_access, deny, get_auth_context
|
2026-09-06 18:37:53 +08:00
|
|
|
|
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
|
2026-09-06 20:48:50 +08:00
|
|
|
|
from app.utils.exceptions import ApiError, StateConflict
|
2026-09-06 18:37:53 +08:00
|
|
|
|
from app.utils.trace import current_trace, new_trace
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/risk", tags=["risk"])
|
|
|
|
|
|
|
2026-09-07 01:36:44 +08:00
|
|
|
|
# 预警系统级声明(规则表 §5,区别于 G-08 客户免责声明;PRD §6 固定出现在预警类响应体)
|
|
|
|
|
|
ALERT_DISCLAIMER = "本预警由系统自动生成,最终判定需经风控专员人工审核。"
|
|
|
|
|
|
|
2026-09-06 18:37:53 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-06 18:58:48 +08:00
|
|
|
|
start_date: datetime | None = None,
|
|
|
|
|
|
end_date: datetime | None = None,
|
2026-09-06 18:37:53 +08:00
|
|
|
|
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:
|
2026-09-06 18:58:48 +08:00
|
|
|
|
deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer/compliance only")
|
2026-09-06 18:37:53 +08:00
|
|
|
|
rows, total = _repo().list_alerts(
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
alert_type=alert_type,
|
|
|
|
|
|
customer_id=customer_id,
|
2026-09-06 18:58:48 +08:00
|
|
|
|
start=start_date,
|
|
|
|
|
|
end=end_date,
|
2026-09-06 18:37:53 +08:00
|
|
|
|
page=page,
|
|
|
|
|
|
page_size=page_size,
|
|
|
|
|
|
)
|
2026-09-07 01:36:44 +08:00
|
|
|
|
return {"items": rows, "total": total, "page": page, "page_size": page_size,
|
|
|
|
|
|
"disclaimer": ALERT_DISCLAIMER}
|
2026-09-06 18:37:53 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"):
|
2026-09-06 18:58:48 +08:00
|
|
|
|
deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer only")
|
2026-09-06 18:37:53 +08:00
|
|
|
|
try:
|
2026-09-07 01:36:44 +08:00
|
|
|
|
handled = handle_alert(
|
2026-09-06 18:37:53 +08:00
|
|
|
|
alert_id, req.handler_result, auth.actor_id, req.handler_comment, risk_repo=_repo()
|
|
|
|
|
|
)
|
2026-09-06 20:48:50 +08:00
|
|
|
|
except LookupError as exc: # NotFoundError 子类(B6 评审 P3-5 收敛)
|
|
|
|
|
|
raise ApiError(404, "NOT_FOUND", str(exc)) from exc
|
2026-09-06 18:37:53 +08:00
|
|
|
|
except StateConflict as exc:
|
2026-09-06 20:48:50 +08:00
|
|
|
|
raise ApiError(409, "STATE_CONFLICT", str(exc)) from exc
|
2026-09-07 01:36:44 +08:00
|
|
|
|
return {**handled, "disclaimer": ALERT_DISCLAIMER}
|
2026-09-06 18:37:53 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/suitability/check")
|
|
|
|
|
|
def suitability_check_api(req: SuitabilityCheckRequest, auth: AuthContext = Depends(get_auth_context)) -> dict:
|
2026-09-06 18:58:48 +08:00
|
|
|
|
"""适当性校验(FR-2/G-01):customer 仅本人、advisor 名下、risk_officer 全量。
|
|
|
|
|
|
|
|
|
|
|
|
直调路径每次校验补 audit(PRD §7.3 全量留痕;网关路径由 trade_request
|
|
|
|
|
|
审计兜底,评审 P2-3)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
repo = _repo()
|
2026-09-06 18:37:53 +08:00
|
|
|
|
try:
|
|
|
|
|
|
core = CoreReadOnlyRepository()
|
2026-09-06 18:58:48 +08:00
|
|
|
|
assert_customer_access(auth, req.customer_id, core_ro=core, risk_repo=repo)
|
2026-09-06 18:37:53 +08:00
|
|
|
|
result = suitability_check(
|
2026-09-06 18:58:48 +08:00
|
|
|
|
req.customer_id, req.product_id, core_ro=core, risk_repo=repo,
|
|
|
|
|
|
request_ref="api:suitability_check",
|
2026-09-06 18:37:53 +08:00
|
|
|
|
)
|
2026-09-06 20:48:50 +08:00
|
|
|
|
except LookupError as exc: # customer/product not found
|
|
|
|
|
|
raise ApiError(404, "NOT_FOUND", str(exc)) from exc
|
2026-09-06 18:58:48 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-09-07 01:59:15 +08:00
|
|
|
|
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
|
2026-09-06 18:37:53 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"):
|
2026-09-06 18:58:48 +08:00
|
|
|
|
deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer only")
|
2026-09-06 18:37:53 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-09-07 01:36:44 +08:00
|
|
|
|
return {**summary, "disclaimer": ALERT_DISCLAIMER}
|