feat: B9b 演示走查——核查单收口: ①预警类 API 固定 disclaimer(alerts/handle/aml-scan) ②aml/scan 幂等防护(find_recent_aml_alert 同客户当日已有 aml 单不出单+skipped_existing) ⑥集成测试前置断言(A-1/A-3/A-4 清洁检查 fail+重灌提示, A-5 按 trade_id 定位); 演示 SOP 落地(脚本化重灌 MYSQL_PWD 方式); 真服务走查 A-1~A-5/A-7/A-9+核查单①②④⑤ 11/11 PASS(核查单⑥防护现场复现), 208 绿; M2 达成
This commit is contained in:
+10
-5
@@ -9,8 +9,8 @@ handler_result 枚举由请求模型 Literal 校验(repo 不校验,开发计
|
||||
直调 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,挂账④;预警类响应固定 disclaimer
|
||||
挂账 B9b 核查单①)。
|
||||
(utils/response.register_error_handlers,挂账④)。
|
||||
预警类 API 响应体固定 disclaimer(PRD §6/规则表 §5 · B9b 核查单①)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,6 +33,9 @@ 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 点)。"""
|
||||
@@ -76,7 +79,8 @@ def list_alerts_api(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return {"items": rows, "total": total, "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")
|
||||
@@ -85,13 +89,14 @@ def handle_alert_api(alert_id: str, req: HandleRequest, auth: AuthContext = Depe
|
||||
if not auth.has_role("risk_officer"):
|
||||
deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer only")
|
||||
try:
|
||||
return handle_alert(
|
||||
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")
|
||||
@@ -157,4 +162,4 @@ def aml_scan_api(auth: AuthContext = Depends(get_auth_context)) -> dict:
|
||||
"handler_comment": None,
|
||||
}
|
||||
)
|
||||
return summary
|
||||
return {**summary, "disclaimer": ALERT_DISCLAIMER}
|
||||
|
||||
@@ -72,6 +72,25 @@ class RiskRepository:
|
||||
row = conn.execute(sql, params).mappings().first()
|
||||
return self._parse_alert(dict(row)) if row else None
|
||||
|
||||
def find_recent_aml_alert(self, customer_id: str, day_start: datetime) -> dict | None:
|
||||
"""该客户当日最近一张 aml 单(scan 幂等检查 · B9b 核查单②/B6 评审 P3-6)。
|
||||
|
||||
不限 status——已处置单也算「已出」,同扫描日重扫不重复出单(模拟每日
|
||||
批量口径:跨日再扫出新单)。交易触发的当日 aml 单同样命中(当日命中
|
||||
已留痕,scan 无需重复出单)。
|
||||
"""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT * FROM risk_alert
|
||||
WHERE customer_id = :cid AND alert_type = 'aml' AND created_at >= :day_start
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sql, {"cid": customer_id, "day_start": day_start}).mappings().first()
|
||||
return self._parse_alert(dict(row)) if row else None
|
||||
|
||||
def insert_alert(self, alert: dict[str, Any]) -> None:
|
||||
sql = text(
|
||||
"""
|
||||
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import logging
|
||||
from datetime import date, datetime, time
|
||||
from typing import Any
|
||||
|
||||
from app.config.settings import settings
|
||||
@@ -87,19 +88,28 @@ def scan_all(
|
||||
"""手动全量扫描(PRD FR-5 触发时机 2,B6 接 POST /api/risk/aml/scan)。
|
||||
|
||||
每个命中客户:一张 aml 独立预警单(matches 全量进 payload)+ L3 high 标记。
|
||||
幂等防护(B9b 核查单②/B6 评审 P3-6):同客户当日已有 aml 单(含交易触发
|
||||
与已处置单)不再重复出单,计入 skipped_existing——模拟每日批量口径,重复
|
||||
点击不刷单;读后写竞态在手动单发操作下可接受(无并发出单场景)。
|
||||
"""
|
||||
core = core_ro or CoreReadOnlyRepository()
|
||||
repo = risk_repo or RiskRepository()
|
||||
ensure_trace() # 手动扫描入口兜底归因(B6 API 场景保留中间件 trace,评审 P3-8)
|
||||
entries = repo.list_active_aml_entries()
|
||||
customers = core.list_active_customers()
|
||||
day_start = datetime.combine(date.today(), time.min)
|
||||
alerts: list[str] = []
|
||||
skipped_existing: list[str] = []
|
||||
hit_customers = 0
|
||||
for c in customers:
|
||||
hits = match_name(c["display_name"], entries)
|
||||
if not hits:
|
||||
continue
|
||||
hit_customers += 1
|
||||
existing = repo.find_recent_aml_alert(c["customer_id"], day_start)
|
||||
if existing:
|
||||
skipped_existing.append(existing["alert_id"])
|
||||
continue
|
||||
alert = record_aml_alert(
|
||||
c["customer_id"], {"trigger": "scan", "matches": hits}, risk_repo=repo
|
||||
)
|
||||
@@ -107,5 +117,13 @@ def scan_all(
|
||||
c["customer_id"], "aml", last_alert_id=alert["alert_id"], risk_repo=repo
|
||||
)
|
||||
alerts.append(alert["alert_id"])
|
||||
logger.info("AML scan_all: scanned=%d hit_customers=%d", len(customers), hit_customers)
|
||||
return {"scanned": len(customers), "hit_customers": hit_customers, "alerts": alerts}
|
||||
logger.info(
|
||||
"AML scan_all: scanned=%d hit_customers=%d new_alerts=%d skipped_existing=%d",
|
||||
len(customers), hit_customers, len(alerts), len(skipped_existing),
|
||||
)
|
||||
return {
|
||||
"scanned": len(customers),
|
||||
"hit_customers": hit_customers,
|
||||
"alerts": alerts,
|
||||
"skipped_existing": skipped_existing,
|
||||
}
|
||||
|
||||
+2
-2
@@ -48,9 +48,9 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md
|
||||
Client → Gateway(JWT/RBAC) → api/chat → agent_service(LangGraph) → Tools → 存储 → 响应 + audit_log
|
||||
```
|
||||
|
||||
当前:**风控事件线已集成 + 测试基建完成**(交易网关 → 规则引擎 → 预警/AML/L3 → 4 API,`main.py` 路由挂载 + trace 中间件 + lifespan 完成,B1~B8;B8 = conftest 演示数据校验 + 集成测试 A-1~A-5/A-7/A-9 + `tests/_ddl.py` DDL 单一事实源;B7/B8 均经独立 AI 评审闭环;B9a = subscribe_alerts/rebuild_alerts 演示与补偿脚本,204 测试绿);剩 B9b 演示走查;对话链路待 T-01/T-07。
|
||||
当前:**风控事件线已集成 + 测试基建完成**(交易网关 → 规则引擎 → 预警/AML/L3 → 4 API,`main.py` 路由挂载 + trace 中间件 + lifespan 完成,B1~B8;B8 = conftest 演示数据校验 + 集成测试 A-1~A-5/A-7/A-9 + `tests/_ddl.py` DDL 单一事实源;B7/B8 均经独立 AI 评审闭环;B9a = subscribe_alerts/rebuild_alerts 演示与补偿脚本;B9b = 演示走查 + B6/B7/B8 核查单①~⑥收口(disclaimer/aml scan 幂等/集成测试前置断言),208 测试绿,演示 SOP 见 docs/项目框架设计/演示SOP-风控模块.md,M2 tag risk-m2);阶段 B 完结;对话链路待 T-01/T-07。
|
||||
|
||||
**本机已就位状态(2026-09-06 · 已完成上述 ①~⑤,无需重做):** `.env` 已配置(学习项目,`MYSQL_PASSWORD=123456`;NEO4J/DEEPSEEK 留空暂不影响风控);`jinrong_core` + `jinrong_agent` 已灌库(28 客户 / AML 名单 8 条 / 演示测评已刷新 / 归属同步 28 行);`python -m pytest` 204 绿(集成测试真连本机 MySQL;未灌库的机器自动 skip 集成模块,单测不受影响);本机 Redis 服务在跑(`redis://127.0.0.1:6379/0`,B9a 订阅脚本已验证)。
|
||||
**本机已就位状态(2026-09-06 · 已完成上述 ①~⑤,无需重做):** `.env` 已配置(学习项目,`MYSQL_PASSWORD=123456`;NEO4J/DEEPSEEK 留空暂不影响风控);`jinrong_core` + `jinrong_agent` 已灌库(28 客户 / AML 名单 8 条 / 演示测评已刷新 / 归属同步 28 行);`python -m pytest` 208 绿(集成测试真连本机 MySQL;未灌库的机器自动 skip 集成模块,单测不受影响);本机 Redis 服务在跑(`redis://127.0.0.1:6379/0`,B9a 订阅脚本已验证)。
|
||||
|
||||
**本机已知坑:** `mysql.exe` 不在 PATH(位于 `C:\Program Files\MySQL\MySQL Server 8.0\bin`);`reset.ps1` 的交互式 `-p` 在自动化执行时会卡死——脚本化重灌用 `MYSQL_PWD` 环境变量传密码(交互执行不受影响,不把密码写进仓库脚本);`redis` 包 requirements 有但初始 `pip install -r` 漏装(B9a 于 2026-09-07 补装,重装环境时留意)。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
**项目是什么:** 金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座;**不**互调 LLM,跨 Agent 走 L1/L2/L3 画像与预警表。
|
||||
|
||||
**当前进度:** 需求与表设计已定 · **风控模块已落地 B1~B8+B9a**(规则/预警聚合/L3/AML/引擎/交易网关/鉴权+4 API/适当性校验/main 集成+挂账①~⑦/B8 conftest+集成测试/**B9a 订阅+重放补偿脚本**,B7/B8/B9a 均经独立 AI 评审闭环,**204 测试绿**)· 剩 **B9b 演示走查** · **JWT(T-01) / 审计中间件(T-02) / LangGraph 对话线(T-07) 未做**(chat/knowledge/admin 仍空壳)。**开发在分支 `feature/risk`(未合入 main)。**
|
||||
**当前进度:** 需求与表设计已定 · **风控模块 B1~B9b 全部完成(M2 已达成,tag risk-m2)**(规则/预警聚合/L3/AML/引擎/交易网关/鉴权+4 API/适当性校验/main 集成/B8 测试基建/B9a 订阅+重放脚本/B9b 演示走查+核查单①~⑥收口,B7/B8/B9a 均经独立 AI 评审闭环,**208 测试绿**)· 阶段 B 完结剩 M2 文字复核 → **JWT(T-01) / 审计中间件(T-02) / LangGraph 对话线(T-07) 未做**(chat/knowledge/admin 仍空壳)。**开发在分支 `feature/risk`(未合入 main)。**
|
||||
|
||||
**仓库地图:**
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
> **本机(2026-09-06)①~⑤已执行、`.env` 已配置,勿重做**;本机状态与已知坑(mysql.exe 路径 / reset.ps1 交互式 -p)见 `FLOW.md` §0 尾注。
|
||||
|
||||
**下一步开发(见 TODO):** 风控 B9b 演示走查(reset → demo SQL → Swagger 过 A-1~A-5/A-7~A-9 + B6/B7/B8 挂账核查单①~⑥);随后 Wave 0 T-01 JWT / T-02 审计中间件 / T-06 / T-07 LangGraph。
|
||||
**下一步开发(见 TODO):** 风控 M2 文字收尾复核 → Wave 0 T-01 JWT / T-02 审计中间件 / T-06 / T-07 LangGraph。演示走查按 `docs/项目框架设计/演示SOP-风控模块.md`。
|
||||
|
||||
**禁止(改代码前必记):** Core 正式 C1~C5 不可被画像覆盖 · 审计表只 INSERT · 代理人草稿不外发 · 仅 R-02 可阻断交易 · 四 Agent 不互调 LLM。
|
||||
|
||||
@@ -166,6 +166,6 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md
|
||||
2. 改动属于 api / service / tool / repository 哪一层?
|
||||
3. 是否需 customer_id 归属与 JWT RBAC?
|
||||
4. Core 是模拟库只读还是 agent 库读写?
|
||||
5. 如何验证?(`python -m pytest` 全量(当前 204 绿)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列)
|
||||
5. 如何验证?(`python -m pytest` 全量(当前 208 绿)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列)
|
||||
|
||||
大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。
|
||||
|
||||
+2
-4
@@ -5,12 +5,10 @@
|
||||
|
||||
## 进行中
|
||||
|
||||
- [ ] 风控 **B9b 演示链路走查**(reset → demo SQL → Swagger 过 A-1~A-5/A-7~A-9 + 核查单①~⑥)——走查前先处理核查单⑥(B8 复审 P2-1 演示库耦合前置断言)
|
||||
- [ ] 风控 M2 收尾:MEMORY/TODO 状态复核(开发计划 M4 前置)——B1~B9b 已全部完成(M2 tag 已打),复核边界文字对齐后转 Wave 0
|
||||
|
||||
## 待办(推荐顺序)
|
||||
|
||||
- [ ] 风控 M2 收尾:MEMORY/TODO 状态复核(开发计划 M4 前置)
|
||||
|
||||
- [ ] T-01 Auth SDK / JWT 中间件(对照 `02-JWT-RBAC鉴权手册.md`)
|
||||
- [ ] T-02 audit_log 中间件 + trace_id 贯通
|
||||
- [ ] T-05 本机执行 `reset.ps1` + `01-mysql-共用底座.sql` + sync 脚本验收
|
||||
@@ -22,7 +20,7 @@
|
||||
|
||||
### 风控模块(PRD v1.0 已冻结 · `docs/PRD/PRD-风控监测Agent.md`,事件驱动线不依赖 T-07 可先行)
|
||||
|
||||
- [ ] T-30 风控事件线:`app/gateway/` 交易网关 + `service/risk/` 规则引擎(RISK-001~005)+ 预警单聚合 + L3 最小写入 + `risk:pub:alert` 推送 + AML(含 `risk_aml_list` 种子)+ 演示数据脚本验收 A-1~A-5/A-9 —— **进度(2026-09-06):B1 规则纯函数 / B2 预警服务 / B3 L3 写入(risk_score 一期不写)/ B4 AML+引擎编排 / B5 交易网关 / B6 鉴权+4 API+处置编排 / B7 main 集成(路由挂载 + trace 中间件 + lifespan 启动期 debug 校验;挂账①锁公共化 ②L3 缓存 DEL ③死代码 ④统一错误体 ⑤启动校验 ⑥引擎工厂 ⑦处置原子事务均落地;⑧ input_guard_log 双写仍随 T-02)均完成并经独立 AI 评审闭环(B7 复审有条件通过→P1-1 dispose 已修闭环;183 测试绿;P2/P3 已登记开发计划 B7/B8/B9b/T-02);**B8 已完成(2026-09-06):conftest(演示数据校验/幂等代跑 prepare_risk_demo/TRD-TEST- teardown + L3 快照还原)+ 集成测试 11 例(A-1~A-5/A-7/A-9 + trace 一致性 + 审计 JSON)+ sqlite DDL 单一事实源 `_ddl.py`(10 文件收敛,localtime 时区收敛 B5 P3-4)+ L3 DEL 行为断言(P2-1)+ locks 文案(P3-2);B8 复审有条件通过→P1-1 conftest skip 路径炸收集已修闭环(故障注入验证),P2-1 演示库耦合前置断言挂 B9b 核查单⑥**;**B9a 已完成(2026-09-07):`scripts/demo/subscribe_alerts.py`(risk:pub:alert 订阅演示:连接自检/--duration/心跳)+ `rebuild_alerts.py`(按 trade_id 幂等重放:`find_alerts_by_trade` 以 payload LIKE 查已入单(含 aml/已处置单)防重复出单与重复 append;engine_error 中断笔 skip 时 warning 提示人工核对;core_ro 新增只读 `get_trade_by_id`)+ tests/test_demo_scripts.py 8 例,204 绿;真库手工验证通过(rebuilt→skipped→missing exit1;订阅端到端收到假消息+真交易推送且 trace 贯通;中间态警示路径复验),验证现场已清理;独立 AI 评审有条件通过→P2-1 中间态警示/P2-2 非首笔幂等用例已闭环**;剩 B9b 演示走查**
|
||||
- [ ] T-30 风控事件线:`app/gateway/` 交易网关 + `service/risk/` 规则引擎(RISK-001~005)+ 预警单聚合 + L3 最小写入 + `risk:pub:alert` 推送 + AML(含 `risk_aml_list` 种子)+ 演示数据脚本验收 A-1~A-5/A-9 —— **进度(2026-09-06):B1 规则纯函数 / B2 预警服务 / B3 L3 写入(risk_score 一期不写)/ B4 AML+引擎编排 / B5 交易网关 / B6 鉴权+4 API+处置编排 / B7 main 集成(路由挂载 + trace 中间件 + lifespan 启动期 debug 校验;挂账①锁公共化 ②L3 缓存 DEL ③死代码 ④统一错误体 ⑤启动校验 ⑥引擎工厂 ⑦处置原子事务均落地;⑧ input_guard_log 双写仍随 T-02)均完成并经独立 AI 评审闭环(B7 复审有条件通过→P1-1 dispose 已修闭环;183 测试绿;P2/P3 已登记开发计划 B7/B8/B9b/T-02);**B8 已完成(2026-09-06):conftest(演示数据校验/幂等代跑 prepare_risk_demo/TRD-TEST- teardown + L3 快照还原)+ 集成测试 11 例(A-1~A-5/A-7/A-9 + trace 一致性 + 审计 JSON)+ sqlite DDL 单一事实源 `_ddl.py`(10 文件收敛,localtime 时区收敛 B5 P3-4)+ L3 DEL 行为断言(P2-1)+ locks 文案(P3-2);B8 复审有条件通过→P1-1 conftest skip 路径炸收集已修闭环(故障注入验证),P2-1 演示库耦合前置断言挂 B9b 核查单⑥**;**B9a 已完成(2026-09-07):`scripts/demo/subscribe_alerts.py`(risk:pub:alert 订阅演示:连接自检/--duration/心跳)+ `rebuild_alerts.py`(按 trade_id 幂等重放:`find_alerts_by_trade` 以 payload LIKE 查已入单(含 aml/已处置单)防重复出单与重复 append;engine_error 中断笔 skip 时 warning 提示人工核对;core_ro 新增只读 `get_trade_by_id`)+ tests/test_demo_scripts.py 8 例;真库手工验证通过(rebuilt→skipped→missing exit1;订阅端到端收到假消息+真交易推送且 trace 贯通;中间态警示路径复验),验证现场已清理;独立 AI 评审有条件通过→P2-1 中间态警示/P2-2 非首笔幂等用例已闭环**;**B9b 已完成(2026-09-07):前置代码三件(①预警类 API 固定 disclaimer ②scan_all 幂等防护 `find_recent_aml_alert`+`skipped_existing` ③核查单⑥集成测试前置断言 A-1/A-3/A-4 清洁检查+A-5 按 trade_id 定位),208 绿;演示 SOP `docs/项目框架设计/演示SOP-风控模块.md`;真服务走查(uvicorn+真库)A-1~A-5/A-7/A-9+核查单①②④⑤ 全 PASS(11/11,A-6 归 M3),核查单⑥防护现场复现验证;走查演示数据按 SOP 保留、pytest 集成前需重灌;M2 达成打 tag risk-m2(M1 未打 tag 留痕)——核查单①~⑥ 全部收口,风控阶段 B 完结**
|
||||
- [ ] T-31 `service/suitability.py` 公共校验(SUIT-001~008)+ `POST /api/risk/suitability/check` + 单测验收 A-8 —— **代码已完成(A1~A4 · 77 测试绿;B6 起该 API 带鉴权+直调审计);MySQL 手工 SQL 对照挂账至 B9b 执行(阶段 A 评审 P2-8)**
|
||||
- [ ] T-32 预警台账与人工处置 API(`GET /alerts`、`POST /handle`,risk_officer/compliance 权限)+ 对话线(依赖 T-01/T-03/T-07)验收 A-6/A-7 —— **API 部分已由 B6 覆盖并复审通过(A-7/A-9 用例绿);对话线仍依赖 T-01/T-03/T-07**
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
**B8 复审(独立 AI 评审 · 2026-09-06):有条件通过 → 已闭环。** 开发计划 B8 行 a/b/c + DDL 单一事实源/localtime + TRD-TEST- 注入 + A-1~A-5/A-7/A-9/trace/审计 JSON 全部达成,196 绿实跑,B7 复审 P2-1/P3-2 落地。**P1-1 `conftest.py` `except pytest.SkipRequested` 引用不存在属性,演示数据缺失时 skip 路径反噬整个 pytest 收集(未灌库机器单测全跑不了)——已修(Skipped 继承 BaseException 直传,故障注入验证:错密码收集期 skip 不 error)**;P3 顺手项已落:teardown `l3_snapshot` 哨兵(防 setup 失败掩盖原始异常)、A-1 补 trace 环①响应头与环②suitability_log.trace_id。挂账:**P2-1 集成测试对演示库当日状态隐式耦合(A-4 依赖 CUST-9527 当日无历史交易、A-5 aml 单查询未按本测试 trade_id 定位、A-1 同日已有 pending 单会并入旧单)→ B9b 核查单⑥,演示与 pytest 同日交叉前必须处理**;P3-3 `_state` 模块级顺序耦合(单跑 A-7 等 KeyError,文件头已声明保序,可接受留痕);P3-4 阈值冒烟用例在 .env 覆盖阈值时会红(注释已声明前提);P3-5 teardown 时间窗隐含 MySQL 时钟==本机时钟(docstring 已声明单机约定)。
|
||||
| B9b | 演示链路走查:`reset.ps1` → `prepare_risk_demo.sql` → agent 库建表 → `seed-aml-list.sql` → Swagger 逐条过 **A-1~A-5、A-7~A-9(A-6 归 M3)**。**B6 挂账核查单:① 预警类 API 响应体含固定 disclaimer「本预警由系统自动生成,最终判定需经风控专员人工审核」(PRD §6/规则表 §5,B6 复审 P3-7)② aml/scan 幂等防护(重复扫描同命中客户重复出单,演示点击即复现,B6 评审 P3-6)③ B6 时代码以 TestClient 独立挂 router 等价验证,本次补一次真 Swagger 手测(B6 复审遗漏⑤)④ analyst 台账只读权限扩展待 Wave 3 分析 Agent 接入时定权限矩阵(手册 §5.3 有 risk:alert:read,现 fail-closed 拒绝,B6 复审观察③)⑤ 生产/演示机 `app_env=development` 误配检查(B7 复审 P3-3:误配时 debug 头可达且启动校验放行,列入演示 SOP)⑥ B8 复审 P2-1:集成测试对演示库当日状态的隐式耦合前置断言(A-4 前断言 CUST-9527 当日事件计数==0、A-5 aml 单按本测试 trade_id 定位、A-1 同日 pending 单并入防护),演示与 pytest 同日交叉执行前必须处理** | 演示 SOP | 按 PRD §8 验收表逐条打勾 | B8、B9a |
|
||||
|
||||
**B9b 完成(2026-09-07):** 走查前置代码三件:①预警类 API(GET alerts/POST handle/POST aml/scan)响应体加固定 `disclaimer`(核查单①)②`scan_all` 幂等防护——新增 `find_recent_aml_alert`,同客户当日已有 aml 单(含交易触发/已处置)不再出单,summary 增 `skipped_existing`(核查单②)③集成测试前置断言(核查单⑥):A-1/A-3/A-4 清洁检查(交叉时 fail+重灌提示)、A-5 aml 单按本笔 trade_id 定位。208 测试绿(+4:scan 幂等×2/disclaimer/交易触发后 scan skip)。演示 SOP 落地 `docs/项目框架设计/演示SOP-风控模块.md`(含脚本化重灌 MYSQL_PWD 方式)。真服务走查(uvicorn 8123 + 真库):**A-1~A-5/A-7/A-9 + 核查单①②④⑤ 全 PASS(11/11)**,核查单⑥防护价值现场复现(走查残留数据致 A-1 定位失效 → 重灌后过,正是 P2-1 预警的场景)。核查单③=本次真服务手测(区别于 B6 TestClient 等价验证);⑤本机 APP_ENV=development 确认。走查演示数据按 SOP 保留,pytest 集成测试前需重灌。**里程碑 M2 达成(阶段 B 全链路 + A-1~A-5/A-7~A-9 验收),commit 打 tag risk-m2(M1 时点未打 tag,留痕)**。
|
||||
|
||||
## 阶段 C · 对话线(依赖 Wave 0 的 T-01 JWT / T-03 输入防护 / T-07 LangGraph)
|
||||
|
||||
| # | 任务 | 产出 | 验证方式 | 依赖 |
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# 演示 SOP · 风控监测 Agent(B9b)
|
||||
|
||||
> 版本:v1.0 · 2026-09-07 · 配套 PRD v1.0 §8 验收表 + 开发计划 B9b 核查单
|
||||
> 适用:演示机/本机(`app_env=development`,debug 鉴权头可用;生产部署禁用,见核查单⑤)
|
||||
> 首次走查记录:2026-09-07,A-1~A-5/A-7~A-9 + 核查单①~⑥ 全部通过(11/11 PASS)
|
||||
|
||||
---
|
||||
|
||||
## 1. 前置条件
|
||||
|
||||
- 本机 bootstrap ①~⑤ 已执行(`.env`、依赖、双库、种子)——见 `docs/memory/FLOW.md` §0;
|
||||
已就位机器勿重跑,只按本 SOP §2 重灌即可。
|
||||
- `redis` 包已安装(requirements 内;B9a 曾发现环境漏装,重装环境时留意)。
|
||||
- 本机 MySQL 可达、Redis 服务在跑(订阅演示需要;不可达时推送降级仅日志,走查其余项不受影响)。
|
||||
|
||||
## 2. 演示链路重灌(每次演示/走查前执行)
|
||||
|
||||
**目的**:清空上一轮演示/走查残留(trade/预警/审计/L3),使验收断言从干净状态出发。
|
||||
|
||||
**脚本化方式(推荐,自动化执行不卡交互)**——Git Bash:
|
||||
|
||||
```bash
|
||||
export MYSQL_PWD=<密码>
|
||||
MYSQL="C:/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe" # mysql.exe 不在 PATH(FLOW §0 坑)
|
||||
"$MYSQL" -h 127.0.0.1 -u root -e "DROP DATABASE IF EXISTS jinrong_core; DROP DATABASE IF EXISTS jinrong_agent;"
|
||||
for f in scripts/core/00-create-database.sql scripts/core/01-ddl.sql scripts/core/02-seed-base.sql \
|
||||
scripts/core/03-seed-customers.sql scripts/core/04-seed-holdings.sql scripts/core/05-seed-trades.sql \
|
||||
scripts/core/06-seed-nav.sql "docs/项目框架设计/表设计/01-mysql-共用底座.sql" \
|
||||
"docs/项目框架设计/表设计/02-mysql-agent专用.sql" scripts/agent/seed-aml-list.sql \
|
||||
scripts/demo/prepare_risk_demo.sql; do
|
||||
"$MYSQL" -h 127.0.0.1 -u root --default-character-set=utf8mb4 < "$f"
|
||||
done
|
||||
python scripts/sync/sync_advisor_rel.py # 归属同步(28 行)
|
||||
```
|
||||
|
||||
**交互方式**:`.\scripts\core\reset.ps1`(PowerShell,交互输密码;`-SkipNeo4j` 可跳过 Neo4j 同步),
|
||||
再手动执行上表后 4 个 SQL(agent 库建表 ×2 / seed-aml-list / prepare_risk_demo)。
|
||||
|
||||
**重灌完成标志**:`seed-aml-list.sql` 输出 8 条名单;`prepare_risk_demo.sql` 输出 7 行测评
|
||||
(6 行 90 天、CUST-1004 保持过期);`sync_advisor_rel` 输出 upserted 28 rows。
|
||||
|
||||
## 3. 启动与入口
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --reload # Swagger: http://127.0.0.1:8000/docs
|
||||
python scripts/demo/subscribe_alerts.py # 另开终端:订阅预警推送(Ctrl+C 退出)
|
||||
```
|
||||
|
||||
debug 鉴权头(dev 过渡,T-01 后换 JWT):`X-Debug-Role` + `X-Debug-Actor`。
|
||||
走查角色:`risk_officer`(STAFF-90001) / `compliance`(STAFF-40001) / `customer`(CUST-1001/CUST-1002)
|
||||
/ `advisor`(STAFF-10087,名下无 CUST-3001) / `risk_demo`(STAFF-DEMO,simulate 白名单)。
|
||||
|
||||
## 4. 验收走查(PRD §8 · A-6 归 M3 对话线)
|
||||
|
||||
Swagger 逐条执行;预期响应与库内核验点如下(`trade_id` 以实际响应为准)。
|
||||
|
||||
| # | 操作(Swagger) | 预期 |
|
||||
| --- | --- | --- |
|
||||
| A-1 | `POST /api/simulate/trade`,customer 头 CUST-1001,body:CUST-1001 / PROD-161725 / subscribe / 10000 | `blocked=true`,`block_reason` 含 SUIT-001;库核验:`core_trade` 无该 trade_id;`risk_suitability_log.is_blocked=1`;`risk_alert`(trade_id 定位)suitability/pending;audit `suitability_blocked`(agent_type=platform);订阅端收到推送且 trace 与响应头一致 |
|
||||
| A-2 | 同上,risk_demo 头,CUST-4001 / PROD-161725 / subscribe / 20000 | `blocked=true`,reasons 含 SUIT-006(≥70 按 C3)与 SUIT-003,**无 SUIT-008** |
|
||||
| A-3 | 同上,CUST-3001 / PROD-510300 / subscribe / 500000 | `blocked=false`,`triggered_rules=["RISK-001","RISK-002"]`,预警单 score=70/pending;`core_trade` 落库;audit `trade_accepted`;**订阅端实时打印推送** |
|
||||
| A-4 | 同上,CUST-9527 / PROD-510300 / subscribe / 1000,连发 4 笔 | 第 3 笔触发 RISK-003;第 4 笔 `alert_ids` 与第 3 笔相同(并入同单),该客户 freq 类单数=1,payload.events 长度=2 |
|
||||
| A-5 | 同上,CUST-1002 / PROD-005828 / subscribe / 100 | `aml_hit=true`;aml 独立单 score=95(按本笔 trade_id 定位);L3 `monitor_tier=high`;compliance 头 `GET /api/risk/alerts` 仅见 aml 单且含该单;`core_customer.is_active` 仍=1(不冻户) |
|
||||
| A-7 | `POST /api/risk/alerts/{A-3的alert_id}/handle`,officer 头,`confirmed_suspicious` → 再处置一次 → 换 compliance 头再试 | 200(状态 confirmed_suspicious,handler_id=STAFF-90001)→ 409 STATE_CONFLICT → 403;audit 留痕(alert_handle/alert_handled) |
|
||||
| A-9 | `POST /api/risk/suitability/check`:customer CUST-1002 查 CUST-3001;advisor STAFF-10087 查 CUST-3001 | 403 `AUTH_403_NOT_OWNER` / `AUTH_403_NOT_ASSIGNED`;audit `event_type=authz, decision=forbidden` 各 1 条 |
|
||||
|
||||
**B9a 脚本演示(运维位)**:
|
||||
|
||||
```bash
|
||||
python scripts/demo/rebuild_alerts.py <trade_id> # 补偿重放:rebuilt → 再跑 skipped → 不存在 exit 1
|
||||
```
|
||||
|
||||
## 5. 核查单(B6/B7/B8 挂账收口)
|
||||
|
||||
| # | 挂账来源 | 检查项 | 结论(2026-09-07 走查) |
|
||||
| --- | --- | --- | --- |
|
||||
| ① | B6 复审 P3-7 | 预警类 API(GET alerts / POST handle / POST aml/scan)响应体含固定 `disclaimer`「本预警由系统自动生成,最终判定需经风控专员人工审核。」 | ✅ 已实现并真服务验证 |
|
||||
| ② | B6 评审 P3-6 | aml/scan 幂等:同客户当日已有 aml 单(含交易触发/已处置)不重复出单,响应 `skipped_existing` 指向既有单 | ✅ 已实现并真服务验证 |
|
||||
| ③ | B6 复审遗漏⑤ | 真服务手测(区别于 TestClient 等价验证):uvicorn 真库走查 A-1~A-9 全量 | ✅ 本 SOP §4 即该手测 |
|
||||
| ④ | B6 复审观察③ | analyst 角色台账 fail-closed:`GET /api/risk/alerts` → 403(risk:alert:read 权限扩展待 Wave 3 分析 Agent 时定矩阵) | ✅ 403 保持 |
|
||||
| ⑤ | B7 复审 P3-3 | 生产/演示机误配检查:本机 `.env` `APP_ENV=development`(演示机允许 debug 头);`app_env != development` 时启动期拒绝 debug 依赖(单测覆盖),部署非 dev 机时必须核对 | ✅ 本机=development 已确认 |
|
||||
| ⑥ | B8 复审 P2-1 | 集成测试对演示库当日状态的隐式耦合 → 已加前置断言(A-1/A-3/A-4 清洁检查 fail+提示、A-5 按 trade_id 定位),代码随 B9b 落地 | ✅ 已落地;交叉数据在场时 pytest 显式 fail 提示重灌 |
|
||||
|
||||
## 6. 走查后状态与注意事项
|
||||
|
||||
- 走查产生的交易/预警/审计/L3 **保留在演示库**(演示痕迹即演示资产);再次演示前回到 §2 重灌。
|
||||
- **重灌后才能跑 pytest 集成测试**:核查单⑥前置断言会对当日演示数据显式 fail(防交叉失真)。
|
||||
- B9a subscribe 窗口未开时推送不丢失告警——Redis Pub/Sub 无持久化,演示时先开订阅再发交易。
|
||||
- A-6(对话线验收)归 M3(T-01/T-03/T-07 就绪后补)。
|
||||
@@ -171,7 +171,50 @@ def test_scan_all_no_hit_creates_nothing(env):
|
||||
with core._engine.begin() as conn:
|
||||
conn.execute(text("UPDATE risk_aml_list SET is_active = 0"))
|
||||
summary = scan_all(core_ro=core, risk_repo=repo)
|
||||
assert summary == {"scanned": 4, "hit_customers": 0, "alerts": []}
|
||||
assert summary == {"scanned": 4, "hit_customers": 0, "alerts": [], "skipped_existing": []}
|
||||
with core._engine.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM risk_alert")).scalar_one() == 0
|
||||
assert pub.messages == []
|
||||
|
||||
|
||||
def test_scan_all_idempotent_same_day(env):
|
||||
"""B9b 核查单②(B6 评审 P3-6):同日重扫不重复出单,重复点击防护。"""
|
||||
core, repo, pub = env
|
||||
first = scan_all(core_ro=core, risk_repo=repo)
|
||||
assert len(first["alerts"]) == 2 and first["skipped_existing"] == []
|
||||
second = scan_all(core_ro=core, risk_repo=repo)
|
||||
assert second["alerts"] == []
|
||||
assert sorted(second["skipped_existing"]) == sorted(first["alerts"])
|
||||
assert len(pub.messages) == 2 # 重扫无新推送(出单时的推送各一次)
|
||||
|
||||
|
||||
def test_scan_skips_customer_already_alerted_by_trade(env):
|
||||
"""当日交易触发已出 aml 单的客户,scan 不再重复出单(同日命中留痕一次)。"""
|
||||
from decimal import Decimal
|
||||
|
||||
from app.service.risk.engine import process_trade_event
|
||||
|
||||
core, repo, pub = env
|
||||
with core._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type,"
|
||||
" amount, trade_status, traded_at)"
|
||||
" VALUES ('T-SCAN-1', 'C2', 'P1', 'subscribe', 1000, 'confirmed', :at)"
|
||||
),
|
||||
{"at": datetime(2026, 9, 7, 10, 0, 0)},
|
||||
)
|
||||
result = process_trade_event(
|
||||
{
|
||||
"trade_id": "T-SCAN-1", "customer_id": "C2", "product_id": "P1",
|
||||
"trade_type": "subscribe", "amount": Decimal("1000"),
|
||||
"trade_status": "confirmed", "traded_at": datetime(2026, 9, 7, 10, 0, 0),
|
||||
},
|
||||
core_ro=core, risk_repo=repo,
|
||||
)
|
||||
assert result["aml_hit"] is True # 交易触发已出 C2 的 aml 单
|
||||
summary = scan_all(core_ro=core, risk_repo=repo)
|
||||
assert len(summary["alerts"]) == 1 # 仅 C1(无当日 aml 单)
|
||||
assert summary["skipped_existing"] == [
|
||||
aid for aid in result["alert_ids"] if repo.get_alert(aid)["alert_type"] == "aml"
|
||||
]
|
||||
|
||||
@@ -91,12 +91,71 @@ def _audit_by_trade(env, trade_id, event_type=None):
|
||||
return _one(env, sql, et=event_type or "trade_request", tid=trade_id)
|
||||
|
||||
|
||||
# ---------- 核查单⑥前置断言(B8 复审 P2-1:演示与 pytest 同日交叉防护) ----------
|
||||
# 各用例对演示库当日状态有隐式假设;交叉执行时不静默产出错误结论,而是 fail 并提示处理。
|
||||
|
||||
|
||||
def _fail_if_dirty(cond_row, message):
|
||||
assert cond_row is None, f"演示/测试同日交叉防护(核查单⑥):{message}"
|
||||
|
||||
|
||||
def _assert_a1_clean(env):
|
||||
"""A-1 假定当日无 CUST-1001×PROD-161725 pending suitability 单:
|
||||
同日交叉时本笔会并入旧单(新 trade_id 不在 trade_id 列),断言必炸。"""
|
||||
_fail_if_dirty(
|
||||
_one(
|
||||
env,
|
||||
"SELECT alert_id FROM risk_alert WHERE customer_id = 'CUST-1001'"
|
||||
" AND alert_type = 'suitability' AND status = 'pending_review'"
|
||||
" AND payload LIKE '%PROD-161725%' AND created_at >= CURDATE()",
|
||||
),
|
||||
"CUST-1001 今日已有 PROD-161725 pending suitability 单;"
|
||||
"先按演示 SOP 重灌或清理当日演示单后再跑集成测试",
|
||||
)
|
||||
|
||||
|
||||
def _assert_a3_clean(env):
|
||||
"""A-3 假定 CUST-3001 当日无 confirmed 交易(RISK-003 频次口径)且无当日
|
||||
pending 事件单(聚合锚点:有 pending 单则本笔 append 进旧单,规则断言漂移)。"""
|
||||
_fail_if_dirty(
|
||||
_core_one(
|
||||
env,
|
||||
"SELECT trade_id FROM core_trade WHERE customer_id = 'CUST-3001'"
|
||||
" AND trade_status = 'confirmed' AND traded_at >= CURDATE() LIMIT 1",
|
||||
),
|
||||
"CUST-3001 今日已有 confirmed 交易(影响 RISK-002 累计/RISK-003 频次口径)",
|
||||
)
|
||||
_fail_if_dirty(
|
||||
_one(
|
||||
env,
|
||||
"SELECT alert_id FROM risk_alert WHERE customer_id = 'CUST-3001'"
|
||||
" AND alert_type IN ('large_amount','freq_trade','pattern')"
|
||||
" AND status = 'pending_review' AND created_at >= CURDATE()",
|
||||
),
|
||||
"CUST-3001 今日已有 pending 事件类预警单(本笔会并入旧单而非新建)",
|
||||
)
|
||||
|
||||
|
||||
def _assert_a4_clean(env):
|
||||
"""A-4 假定 CUST-9527 当日无 confirmed 交易:同日交叉时首笔即可能触发规则,
|
||||
「第 3 笔才触发 / events==2」断言失真。"""
|
||||
_fail_if_dirty(
|
||||
_core_one(
|
||||
env,
|
||||
"SELECT trade_id FROM core_trade WHERE customer_id = 'CUST-9527'"
|
||||
" AND trade_status = 'confirmed' AND traded_at >= CURDATE() LIMIT 1",
|
||||
),
|
||||
"CUST-9527 今日已有 confirmed 交易(A-4 频次计数失真)",
|
||||
)
|
||||
|
||||
|
||||
# ---------- A-1:C1 申购 R4 → SUIT-001 阻断(含 trace 一致性) ----------
|
||||
|
||||
|
||||
def test_a1_suit001_blocked_with_trace_consistency(iclient, risk_demo_env):
|
||||
c, fake = iclient
|
||||
env = risk_demo_env
|
||||
_assert_a1_clean(env)
|
||||
trace = f"trc-integ-{uuid4().hex[:12]}"
|
||||
r = c.post(
|
||||
"/api/simulate/trade",
|
||||
@@ -178,6 +237,7 @@ def test_a2_age70_cap_suit006_no_suit008(iclient, risk_demo_env):
|
||||
def test_a3_large_amount_alert_and_publish(iclient, risk_demo_env):
|
||||
c, fake = iclient
|
||||
env = risk_demo_env
|
||||
_assert_a3_clean(env)
|
||||
r = c.post(
|
||||
"/api/simulate/trade",
|
||||
json=_trade("CUST-3001", "PROD-510300", "subscribe", 500000),
|
||||
@@ -213,6 +273,7 @@ def test_a3_large_amount_alert_and_publish(iclient, risk_demo_env):
|
||||
def test_a4_freq_trade_merged_into_same_alert(iclient, risk_demo_env):
|
||||
c, _ = iclient
|
||||
env = risk_demo_env
|
||||
_assert_a4_clean(env)
|
||||
alert_id = None
|
||||
for _ in range(3):
|
||||
r = c.post(
|
||||
@@ -263,9 +324,12 @@ def test_a5_aml_hit_independent_alert(iclient, risk_demo_env):
|
||||
assert body["blocked"] is False and body["aml_hit"] is True
|
||||
trade_id = body["trade_id"]
|
||||
|
||||
# aml 单按本测试 trade_id 定位(核查单⑥:同日旧 aml 单不得干扰断言)
|
||||
alert = _one(
|
||||
env,
|
||||
"SELECT * FROM risk_alert WHERE customer_id = 'CUST-1002' AND alert_type = 'aml'",
|
||||
"SELECT * FROM risk_alert WHERE customer_id = 'CUST-1002' AND alert_type = 'aml'"
|
||||
" AND trade_id = :t",
|
||||
t=trade_id,
|
||||
)
|
||||
assert alert is not None
|
||||
assert alert["risk_score"] == 95 and alert["status"] == "pending_review"
|
||||
|
||||
+28
-1
@@ -300,8 +300,10 @@ def test_aml_scan_officer_only_with_audit(client, env):
|
||||
r = client.post("/api/risk/aml/scan", headers=OFFICER)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body == {"scanned": 2, "hit_customers": 1, "alerts": body["alerts"]} # CUST-3001 命中
|
||||
assert body["scanned"] == 2 and body["hit_customers"] == 1 # CUST-3001 命中
|
||||
assert len(body["alerts"]) == 1
|
||||
assert body["skipped_existing"] == [] # 首扫无既有单
|
||||
assert body["disclaimer"] == risk_api.ALERT_DISCLAIMER # B9b 核查单①
|
||||
l3 = repo.get_l3("CUST-3001")
|
||||
assert l3["monitor_tier"] == "high" # scan 命中标记 L3
|
||||
# L3 写侧缓存 DEL 钩子(B7 挂账②):命中客户 upsert 后失效读缓存
|
||||
@@ -312,6 +314,31 @@ def test_aml_scan_officer_only_with_audit(client, env):
|
||||
assert client.post("/api/risk/aml/scan", headers=headers).status_code == 403
|
||||
|
||||
|
||||
def test_aml_scan_idempotent_same_day(client, env):
|
||||
"""B9b 核查单②(B6 评审 P3-6):同日重扫不重复出单,重复点击防护。"""
|
||||
repo, engine = env
|
||||
first = client.post("/api/risk/aml/scan", headers=OFFICER).json()
|
||||
second = client.post("/api/risk/aml/scan", headers=OFFICER).json()
|
||||
assert second["alerts"] == []
|
||||
assert second["skipped_existing"] == first["alerts"]
|
||||
# aml 单总数不变(env 预置 ALT-A1 一张 + scan 新出一张;重扫零新增)
|
||||
assert _counts(engine, "risk_alert", "alert_type='aml'") == 2
|
||||
|
||||
|
||||
def test_alert_disclaimer_on_alert_apis(client):
|
||||
"""B9b 核查单①(B6 复审 P3-7):预警类 API 响应体固定 disclaimer(规则表 §5)。"""
|
||||
r = client.get("/api/risk/alerts", headers=OFFICER)
|
||||
body = r.json()
|
||||
assert r.status_code == 200 and body["disclaimer"] == risk_api.ALERT_DISCLAIMER
|
||||
pending = next(i for i in body["items"] if i["status"] == "pending_review")
|
||||
r = client.post(
|
||||
f"/api/risk/alerts/{pending['alert_id']}/handle",
|
||||
json={"handler_result": "confirmed_normal"},
|
||||
headers=OFFICER,
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["disclaimer"] == risk_api.ALERT_DISCLAIMER
|
||||
|
||||
|
||||
# ---------- 非 dev 环境拒绝 debug 鉴权 ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user