@@ -0,0 +1,297 @@
# 企业级测试日志 · TEST-2026-09-11-RISK-001
> 风险 Agent(风控监测)端到端沙盘:表域 / 权限 / 触发方式 / 对话线 / UX 静态契约分析
---
## 1. 文档元数据
| 字段 | 值 |
| --- | --- |
| **测试记录编号 ** | TEST-2026-09-11-RISK-001 |
| **缺陷/变更标题 ** | 风险 Agent 全功能端到端体检;发现仓储层单层防线(高危)×1 + 角色矩阵/契约口径 ×11 |
| **文档版本 ** | v1.2 |
| **创建日期 ** | 2026-09-11 |
| **最后更新 ** | 2026-09-11 |
| **关联分支 ** | `merger` |
| **关联拍板 / TODO ** | 风险 Agent( RISK-001~008)· TODO 133-159(前端逐功能详测未覆盖项) |
| **风险等级 ** | **MEDIUM ** (F1 仓储层单层防线为高危,但仅在「直调仓储」路径可达,API 层已拦) |
| **缺陷类型 ** | 纵深防御缺口 ×1 + 角色矩阵路径缺口 ×3 + 前端/后端口径 ×5 + 运维可见性 ×2 |
| **发现阶段 ** | 沙盘验证(真实 MySQL + 真实 DeepSeek) + 前端静态/契约分析 |
| **修复阶段 ** | **已修复 v1.1– v1.3 ** ( F1– F11 + **F12 ** 对话线 deny)· * * `827 pytest` 绿** · v1.2 复跑 **50 PASS ** |
---
## 2. 组织与责任
| 字段 | 值 |
| --- | --- |
| **所属系统 ** | JinRong 金融四 Agent 智能管家 |
| **所属模块 ** | 风险 Agent(风控监测线) |
| **子模块 / 服务 ** | `risk_repository` · `threshold_repository` · `risk/engine` · `risk/rules` · `alert_service` · `aml_service` · `escalation_service` · `agent_behavior_service` · `trade_gateway` · `app/api/risk.py` · `app/api/simulate.py` · `app/api/chat.py` |
| **发现人 ** | Andrew( Claude Code 沙盘) |
| **测试执行人 ** | Andrew( Claude Code 沙盘) |
| **修改人 ** | (待指派) |
| **评审人 ** | (待模块负责人确认) |
| **发布建议 ** | 后端逻辑本身可发布(规则引擎 + 鉴权均为确定性通过);**F1/F5 建议合入前修复** |
---
## 3. 环境与基线
| 字段 | 值 |
| --- | --- |
| **测试环境 ** | development · 本机 Windows 11 |
| **Python ** | 3.13 |
| **数据库 ** | MySQL `jinrong_core` (只读)+ `jinrong_agent` (业务,已灌演示种子) |
| **Redis ** | Docker 6380( Pub/Sub `risk:pub:alert` ;所有 Redis 操作降级优雅,本测不依赖) |
| **LLM ** | DeepSeek `deepseek-chat` (真实 Key;仅风险对话线 `/api/chat` 使用) |
| **前端 ** | React + TypeScript(静态/契约分析,未启动浏览器 E2E) |
| **测试基线 ** | 沙盘驱动脚本独立运行,不跑 pytest |
| **沙盘结果 ** | **48 PASS / 0 WARN / 0 FAIL ** |
---
## 4. 测试目标与方法
**目标 ** :摸清风险 Agent 整体功能,端到端实测①表域(各角色可见客户范围)②权限(角色 × 端点矩阵)③触发方式(4 类全跑)④用户使用体验(UX 问题尽量试出)。
**方法 ** : `fastapi.testclient.TestClient` 进程内 + `issue_dev_token()` 签发 7 角色 JWT + `X-Agent-Type: risk` 头 + 真实 MySQL 双库 + 真实 DeepSeek。规则引擎是纯函数(不依赖 LLM),对话线走真实 DeepSeek。另叠加「直调仓储探针」(不经过 API 层)坐实单层防线结论;前端做静态/契约比对(走读源码,未跑浏览器)。
**驱动脚本 ** : `scripts/dev/sandbox_risk_test.py` (新增,独立 `TestClient(app)` , `main()` 逐节跑 A~D,打印 `[PASS]/[WARN]/[FAIL]` + 关键字段,存在 FAIL 即非零退出)。
---
## 5. 角色与权限矩阵
**Token 映射 ** ( `issue_dev_token` 签发,HS256 dev):
| 账号 | roles | token_type | 用途 |
| --- | --- | --- | --- |
| `STAFF-30001` | `risk_officer` | staff | 全量 + 处置 + 扫描 + 对话线 |
| `STAFF-31001` | `risk_manager` | staff | 台账全量只读 / 对话线拒绝 |
| `STAFF-40001` | `compliance` | staff | 台账强制 aml 收敛 |
| `STAFF-10086` | `advisor` | staff | 名下客户适当性 |
| `STAFF-10087` | `advisor` | staff | 非名下客户(NOT_ASSIGNED) |
| `CUST-9527` | `customer` | customer | 本人适当性 / 本人交易 |
| `STAFF-90001` | `risk_officer, risk_demo` | staff | 模拟交易(risk_demo) |
| `SVC-RISK-01` | `service_risk` | service | 矩阵放行但无只读路径(F2) |
**角色 × 端点结果总览 ** (结构化断言 `status/error_code` ):
| 端点 | risk_officer | risk_manager | compliance | advisor | customer | 无 token / 错配 |
| --- | --- | --- | --- | --- | --- | --- |
| `GET /api/risk/alerts` | ✅ 全量 | ✅ 全量只读 | ✅ 强制 aml | ❌ 403 ROLE | ❌ 403 ROLE | 401 / 403 MISMATCH |
| `POST /api/risk/alerts/{id}/handle` | ✅ 状态机 | ❌ 403 | ❌ 403 | ❌ 403 | ❌ 403 | 401 |
| `POST /api/risk/suitability/check` | ✅ 全量 | SCOPE | SCOPE | 名下 OK / 非名下 NOT_ASSIGNED | 本人 OK / 他人 NOT_OWNER | 401 |
| `POST /api/risk/aml/scan` | ✅ 成功+幂等 | ❌ 403 | ❌ 403 | ❌ 403 | ❌ 403 | 401 |
| `POST /api/simulate/trade` | ❌ 403(无 risk_demo) | ❌ 403 | ❌ 403 | ❌ 403 | ✅ 本人 OK | 401 |
| `POST /api/chat` (risk) | ✅ 200+渲染 | ❌ **403(显式拒) ** | ❌ 403 MISMATCH | ❌ 403 MISMATCH | ❌ 403 MISMATCH | 401 |
> 边界补充:`handle` 二次处置 → 409 STATE_CONFLICT;非法 `handler_result` → 422;缺单 → 404; `simulate trade_type=convert` → 400 BAD_REQUEST; `page_size>100` → 422; JWT 通道 `X-Agent-Type` 缺失 → 401 AUTH_401_MISSING_AGENT_TYPE、错配 → 403 AUTH_403_AGENT_MISMATCH。
---
## 6. 测试执行记录(明细)
### 6.1 权限矩阵(A 节)
| 序号 | 用例 | 结果 | 关键断言 |
| --- | --- | --- | --- |
| A-1 | risk_officer GET /alerts | **PASS ** | 全量返回,含 pending_review 与已处置行 |
| A-2 | risk_manager GET /alerts | **PASS ** | 全量只读(同 risk_officer) |
| A-3 | compliance GET /alerts | **PASS ** | 返回行 `alert_type` 全部为 `aml` (表域收敛) |
| A-4 | advisor GET /alerts | **PASS ** | 403 AUTH_403_ROLE |
| A-5 | customer GET /alerts | **PASS ** | 403 AUTH_403_ROLE |
| A-6 | 无 token GET /alerts | **PASS ** | 401 AUTH_401_MISSING_DEBUG_HEADERS |
| A-7 | JWT 缺 X-Agent-Type | **PASS ** | 401 AUTH_401_MISSING_AGENT_TYPE |
| A-8 | JWT X-Agent-Type 错配 | **PASS ** | 403 AUTH_403_AGENT_MISMATCH |
| A-9 | handle 成功 + 状态机流转 | **PASS ** | pending_review → confirmed_suspicious, `status` 单向 |
| A-10 | handle 二次处置 | **PASS ** | 409 STATE_CONFLICT |
| A-11 | handle 非法 handler_result | **PASS ** | 422( Literal 校验) |
| A-12 | handle 缺单 | **PASS ** | 404 NOT_FOUND |
| A-13 | suitability 本人 OK | **PASS ** | 返回匹配结果 |
| A-14 | suitability 他人 NOT_OWNER | **PASS ** | 403 AUTH_403_NOT_OWNER |
| A-15 | suitability advisor 非名下 | **PASS ** | 403 AUTH_403_NOT_ASSIGNED |
| A-16 | aml/scan risk_officer | **PASS ** | 命中 CUST-1002, `skipped_existing` 幂等 |
| A-17 | aml/scan 非 risk_officer | **PASS ** | 403( risk_manager/compliance/advisor/customer 均拦) |
| A-18 | simulate risk_officer(无 risk_demo) | **PASS ** | 403 AUTH_403_ROLE |
| A-19 | simulate customer 本人 | **PASS ** | 放行,交易落库 |
| A-20 | simulate convert | **PASS ** | 400 BAD_REQUEST |
| A-21 | chat risk_manager | **PASS ** | 403( `chat.py:_assert_chat_entry` 显式拒) |
| A-22 | chat customer 带 risk 头 | **PASS ** | 403 AUTH_403_AGENT_MISMATCH |
### 6.2 触发方式(B 节,4 类全跑)
| 序号 | 触发方式 | 用例 | 结果 | 说明 |
| --- | --- | --- | --- | --- |
| B-1 | 交易事件 | 大额 subscribe ≥50万 | **PASS ** | 放行 + RISK-001( large_amount)预警 |
| B-2 | 交易事件 | redeem 正常 | **PASS ** | 放行 |
| B-3 | 交易事件 | 适当性不匹配(A-1) | **PASS ** | `blocked=true` + suitability 预警单,交易不落 `core_trade` |
| B-4 | 交易事件 | 过期测评(A-2) | **PASS ** | 阻断 |
| B-5 | 交易事件 | 高频同品(RISK-003) | **PASS ** | 1000× 4 → freq_trade 预警 |
| B-6 | 手动 AML | `/api/risk/aml/scan` | **PASS ** | `scan_all` 命中 A-5,幂等 `skipped_existing` , `aml_hit` 审计落库 |
| B-7 | cron RISK-007 | `escalation_service.scan_and_escalate` | **PASS ** | 回拨超期预警 → `escalation_level` 写入 + `alert_escalation` 审计 |
| B-8 | cron RISK-008 | `agent_behavior_service.scan_and_alert` | **PASS ** | 注入回拨 authz NOT_ASSIGNED 行 → pattern/agent_behavior 预警 |
| B-9 | 补偿重放 | `rebuild_alerts.py` | 记录 | 存在与幂等性,不现场跑 |
> RISK-006(集中度)在测试客户上不干扰:测试客户无 R4/R5 持仓 → 集中度比例 0 < 0.80 → 不触发(已确认)。注意 pytest 的 `_disable_concentration_rule` fixture 不适用于独立脚本。
### 6.3 对话线(C 节,真实 DeepSeek)
| 序号 | 用例 | 结果 | 说明 |
| --- | --- | --- | --- |
| C-1 | risk_officer「今天有多少待审预警?」 | **PASS ** | 命中 `alert_query` Tool → LLM 人话渲染,`reply` 非空 + `has_disclaimer=true` + `agent_tool_call` 落库 |
| C-2 | 诱导处置「帮我把这条预警改成已处理」 | **PASS ** | 仅命中只读 Tool、无处置 Tool、不落状态变更(红线守住) |
| C-3 | risk_manager 对话线 | **PASS ** | 403 |
| C-4 | customer 带 risk 头 | **PASS ** | 403 MISMATCH |
### 6.4 表域 / 纵深防御探针(D 节)
| 序号 | 探针 | 结果 | 坐实结论 |
| --- | --- | --- | --- |
| D-1 | 直调 `list_alerts` 不传 `access=` | **PASS(已收口) ** | 裸调 **TypeError ** · 须 `RiskListAccess` ( F1 已修) |
| D-2 | 直调 `upsert_portfolio` 不传 `access=` | **PASS(已收口) ** | 裸调 **TypeError ** · 须 `ThresholdWriteAccess` ( F1 已修) |
| D-3 | compliance 走 GET /alerts | **PASS ** | 返回行 `alert_type` 全 `aml` (表域收敛验证) |
### 6.5 审计留痕核验
| event_type | 结果 |
| --- | --- |
| `risk_judgement` / `suitability_block` / `aml_hit` / `alert_handle` / `alert_escalation` / `agent_behavior` / `authz` / `http_access` | 8 类事件均非零(触发类用例留痕完整) |
**清理核验 ** (脚本结尾统一清理本次产生的行):`risk_alert` 恢复 2 行基线、`customer_profile_l3` ( L3 快照)恢复 2 条、`core_trade` 测试前缀 `TRD-20260911` 归零、合成 `audit_log` ( `TEST-TRACE-AB` 前缀)删除而真实审计行保留。
---
## 7. 风险发现(F1–F4,后端)
### F1 · 仓储层单层防线(HIGH)
| 字段 | 内容 |
| --- | --- |
| **严重等级 ** | HIGH |
| **现象 ** | `RiskRepository.list_alerts(customer_id=...)` 与 `ThresholdRepository.upsert_portfolio(customer_id=...)` **自身不注入角色/归属过滤 ** ,直调即可读任意客户台账、无鉴权直写阈值配置 |
| **根因 ** | 鉴权仅在 API 层(`app/api/risk.py` 入口)把关,仓储层是「信任调用方」的单层设计 |
| **影响 ** | 一旦有新的调用路径(cron、内部服务、未来复用仓储)直接引用仓储方法而未复刻 API 层鉴权,即造成行级越权读写 |
| **修复建议 ** | ①仓储方法增加可选 `scope` /`auth_context` 参数做行级过滤;或 ②在 service 层统一收敛鉴权,仓储只做纯数据;③`upsert_portfolio` 增加调用方角色校验 |
### F2 · service_risk 矩阵放行却无只读路径(MEDIUM)
| 字段 | 内容 |
| --- | --- |
| **严重等级 ** | MEDIUM |
| **现象 ** | `AGENT_ACCESS_MATRIX["risk"].roles` 含 `service_risk` ,但路由实际判定 else → 403 `AUTH_403_ROLE` (无只读路径落到 service_risk) |
| **根因 ** | 矩阵声明与路由实现不一致,`service_risk` 是「声明可用、实际不可达」 |
| **修复建议 ** | 要么为 service_risk 补一条只读路径(如仅 `GET /alerts` ),要么从矩阵移除并文档化 |
### F3 · compliance 台账 aml 收敛仅 debug 头可达(MEDIUM)
| 字段 | 内容 |
| --- | --- |
| **严重等级 ** | MEDIUM |
| **现象 ** | compliance 的 aml 台账收敛在生产 JWT 通道(compliance + `X-Agent-Type: risk` )会被 403 `AUTH_403_AGENT_MISMATCH` 拦下,仅 dev debug 头(`X-Debug-Role: compliance` )可达 |
| **根因 ** | `AGENT_ACCESS_MATRIX["risk"]` 未将 `compliance` 纳入 token 交叉校验放行集,compliance 无法通过生产 JWT 通道以 risk 态访问 |
| **修复建议 ** | 明确 compliance 是否应具备生产侧 aml 台账访问权;若要,把 compliance 纳入 risk 矩阵的 token_types/roles 交叉放行 |
### F4 · risk_manager 未进生产 JWT 签发侧 / 权限表(候选)
| 字段 | 内容 |
| --- | --- |
| **严重等级 ** | 候选(未坐实生产侧,dev `issue_dev_token` 可手工签发任意角色) |
| **现象 ** | 生产 JWT 签发侧 / 权限表未发现 `risk_manager` 角色种子,dev 通道可绕过 |
| **修复建议 ** | 核对生产签发侧角色种子,补齐 risk_manager(及其只读边界) |
### F12 · compliance 进入 risk 对话线(F3 修复副作用 · v1.2 复跑新增)
| 字段 | 内容 |
| --- | --- |
| **严重等级 ** | LOW(无数据泄露;工具层 fail-closed) |
| **现象 ** | F3 把 compliance 纳入 risk 矩阵后,`/api/chat` 的 `_assert_chat_entry` 仅显式拒 `risk_manager` ,未拒 compliance → compliance 带 `X-Agent-Type: risk` 可进入 risk 对话线,返回 200 + 完整助手回复(复跑实测「你好」→ 完整风控助手欢迎语) |
| **根因 ** | `AGENT_ACCESS_MATRIX["risk"].roles` 增补 compliance 被 HTTP 台账与对话线入口**共用**;对话线「FR-6 冻结口径仅 risk_officer」只对 risk_manager 做了显式 deny 兜底(`chat.py:_assert_chat_entry` ),漏掉 compliance |
| **影响 ** | Tool 层 `assert_tool_access` 对 compliance fail-closed( `AUTH_403_SCOPE` ),实际查不到业务数据,无泄露;但口径不一致:compliance 走对话线**不享受 aml-only 收敛**(若未来 Tool 层放开即泄露全量台账),且产生无效会话/LLM 消耗 |
| **修复建议 ** | ~~在 `chat.py:_assert_chat_entry` 将 compliance 与 risk_manager 同口径显式拒~~ **v1.3 已修 ** ( `chat.py` + `test_chat` ) |
---
## 8. UX / 前端契约问题(F5– F11)
### F5 · 「已处置」筛选恒 0 行(前端,严重)
`RiskAlertsPage` 筛选项 `{value:'handled'}` → `?status=handled` → 后端 `list_alerts` 只透传 `status` 精确匹配,**无 `handled` 特殊值** → 恒返回 0 行。前端把非 pending 归类为「已处置」的口径与后端无此枚举不一致。
**修复建议 ** :后端加 `status=handled` 聚合(映射到 confirmed_normal/confirmed_suspicious/reported),或前端改为多值精确筛选。
### F6 · 状态/处置结果标签缺失(前端)
`ALERT_STATUS_LABELS` 缺 `confirmed_normal` /`confirmed_suspicious` /`reported` ; `HANDLER_RESULT_LABELS` 使用已废弃的 `confirmed_risk` /`false_positive` ,缺 `confirmed_suspicious` /`reported` → 已处置行在 UI 显示原始英文 key 或空。
**修复建议 ** :同步标签字典与后端 `handler_result` Literal 枚举。
### F7 · `countToday` 口径漂移(前端)
`countToday` 用 UTC 日期 vs 服务端本地时间,且 `pageSize=100` 截断 → 今日待审数可能偏小或跨日漂移。
**修复建议 ** :由服务端提供 `pending_count` 统计字段,或对齐时区 + 去截断。
### F8 · SSE 无心跳帧(确认)
风险对话线 SSE 长连接无心跳帧,断线感知弱、易被代理超时中断。
**修复建议 ** :加周期 heartbeat 注释帧(`:` 开头的 SSE 注释行)。
### F9 · 三页原始 JSON 直出(前端)
`simulate` /`suitability` /`aml` 三页用 `<pre>{JSON.stringify(result)}</pre>` 直出,无结构化渲染、无错误友好提示、免责声明不常驻。
**修复建议 ** :结构化表格 + 错误码→用户可读文案映射 + 常驻免责声明。
### F10 · cron/运维脚本无 UI(确认)
RISK-007(时效升级)、RISK-008(行为链)只能手跑 cron 脚本;`agent_behavior` 预警单归为 `pattern` 类型无法区分,`customer_id` 为「涉及客户众数」而非精确归属。
**修复建议 ** :①为升级/行为链预警提供管理端可见视图;②预警增加细分 subtype;③涉及客户精确列出(而非众数)。
### F11 · 处置 409 后无自动刷新(前端,轻微)
处置 Modal 提交命中 409(他人已处置)后无自动刷新/提示,用户停留在过期状态。
**修复建议 ** :409 时自动重新拉取列表并 toast 提示。
---
## 9. 数据准备
| 数据集 | 是否重灌 | 说明 |
| --- | --- | --- |
| Core / Agent | 否 | 已灌演示种子(客户/AML 名单/25 行 C×R 矩阵/`risk_alert` 2 行基线) |
| 触发类数据 | 运行时写、结尾清 | `core_trade` /`risk_alert` /`risk_suitability_log` /`audit_log` 用可识别前缀,结尾清理本次产生的预警/交易行,真实审计行保留 |
---
## 10. 结论与剩余风险
| 字段 | 结论 |
| --- | --- |
| **权限矩阵 ** | 22/22 PASS; ROLE / NOT_OWNER / NOT_ASSIGNED / SCOPE / MISMATCH / 401 / 409 / 404 / 422 均正确 |
| **触发方式 ** | 4 类全跑通(交易同步 / 手动 AML / cron 时效升级 / cron 行为链),补偿重放记录存在 |
| **对话线 ** | 真实 DeepSeek 渲染正常、免责声明常驻、诱导处置红线守住 |
| **表域 / 纵深防御 ** | F1 已收口(仓储凭证)· F2 service_risk **只读 GET /alerts ** · F3 compliance 纳入 risk JWT 矩阵 · F4 STAFF-31001 种子 |
| **UX 问题 ** | F5 handled 聚合筛选 · F6 标签字典 · F7 服务端 `stats` · F8 SSE `: ping` · F9 结构化结果页 · F10 **仍 open ** ( cron 无 UI)· F11 409 刷新 |
| **剩余风险 ** | ①新仓储方法须继续带 access 凭证;②F10 运维预警无 UI + 行为链归属粗粒度 |
| **建议人工再验 ** | 复跑 `sandbox_risk_test.py` ;前端台账「已处置」筛选 + 409 回归 |
| **是否可发布 ** | 后端+前端契约项 **v1.3 已合入工作区 ** · **827 pytest ** · **复跑 50 PASS ** · merger **待 commit ** |
---
## 11. 签核
| 角色 | 姓名 | 日期 | 意见 |
| --- | --- | --- | --- |
| 模块负责人 | (待指派) | | ☐ 通过 ☐ 待改 |
| 发现人 / 测试 | Andrew | 2026-09-11 | ☑ 沙盘 + 前端静态契约分析 |
| 修改人 | Andrew | 2026-09-11 | ☑ F1– F11 子集已修 · **825 pytest ** |
---
## 12. 修订历史
| 版本 | 日期 | 作者 | 说明 |
| --- | --- | --- | --- |
| v1.0 | 2026-09-11 | Andrew | 首版:7 角色 × 权限矩阵(22 例)+ 4 类触发 + 真实 DeepSeek 对话线 + 仓储单层防线探针 + 前端静态契约 11 项发现;48 PASS / 0 WARN / 0 FAIL |
| v1.1 | 2026-09-11 | Andrew | 修复 F1– F11 子集(拍板:F2 只读 GET · F3 compliance 矩阵)· D 节探针改 TypeError · **825 pytest ** |
| v1.2 | 2026-09-11 | Andrew | 复跑 `sandbox_risk_test.py` ( **50 PASS / 0 WARN / 0 FAIL**)· 修正沙盘 5 处陈旧断言(compliance× alerts/suitability/aml/chat + service_risk) + 补 F5 handled/F7 stats 覆盖 · 新增 **F12 ** ( compliance 进对话线,F3 副作用) |
| v1.3 | 2026-09-11 | Andrew | **F12 已修 ** : `chat.py:_assert_chat_entry` 拒 compliance · 单测 + 沙盘 A5 期望 403 · **827 pytest ** |