Files
XingHuo/app/api/risk.py
T
GaoYiYuan_0626 3e40a2bb49 fix: B6 评审修复——鉴权审计统一 deny/401 留痕、start_date/end_date 对齐 PRD、simulate 回挂鉴权、异常与审计注入收敛
- P1-1: 403/401 全部经 deps.deny/_authz_audit 留痕(event_type='authz')
- P2-1: GET /alerts 参数名 start_date/end_date 对齐 PRD;page/page_size 用 fastapi.Query
- P2-2: /api/simulate/trade 回挂 get_auth_context(risk_demo 或本人),越权 403+审计
- P2-3: suitability/aml 直调补审计+request_ref 透传
- P3-1: 多角色 fail-closed 口径固化进测试;P3-3/P3-4 core_ro/risk_repo 必传;
  P3-5 LookupError→NotFoundError 统一;P3-9 alert_service import 上提
- 测试: test_risk_api 权限矩阵/审计断言/多角色组合/非 dev 拒绝;
  test_trade_gateway 客户本人 vs 越权 403(P3-4 模拟 _repo 注入 sqlite)
- 挂账: P3-2/P3-7(响应外壳+disclaimer)→B7;P3-6(scan 幂等)→B9b 前
2026-09-06 18:58:48 +08:00

162 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""风控 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"])
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_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