diff --git a/AGENTS.md b/AGENTS.md
index a93b1d3..7157893 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,6 +38,6 @@ app/repository/core_ro.py # Core 只读 + check_suitability(R-02)
scripts/core/reset.ps1 # 本地灌 Core 模拟库
```
-**当前分支:** `merger` · **测试基线:** `python -m pytest` → 804 passed · **Redis:** Docker `6380` · **前端:** `cd web && npm run build/test`
+**当前分支:** `merger` · **测试基线:** `python -m pytest` → **827 passed**, 1 skipped · **Redis:** Docker `6380` · **前端:** `cd web && npm run build/test`
技术选型硬阀门见 MEMORY 第 3、7 节。Cursor 以 `.cursor/rules/project-memory.mdc` 为准。
diff --git a/app/api/chat.py b/app/api/chat.py
index 2952da1..20ba58d 100644
--- a/app/api/chat.py
+++ b/app/api/chat.py
@@ -116,15 +116,16 @@ def _resolve_agent_type(request: Request) -> str:
def _assert_chat_entry(auth: AuthContext, agent_type: str) -> None:
- """对话线入口守卫(四端点共用):矩阵准入 + risk_manager 显式拒绝。
+ """对话线入口守卫(四端点共用):矩阵准入 + risk_manager/compliance 显式拒绝。
C5 前置(PRD 4A.1):对话线不放行 risk_manager——HTTP 台账才放行,保住
- FR-6 冻结口径。矩阵放行解决 HTTP 通道,chat 层显式拒绝兜底(manager 根本
- 进不了对话线,Tool 层 assert_tool_access 天然 fail-closed)。会话查询/
- 关闭端点同属对话线数据面,沿用同一口径(方案 B 拍板)。
+ FR-6 冻结口径。F3 把 compliance 纳入 risk 矩阵后须同口径拒对话线(仅 HTTP
+ aml 台账);矩阵放行解决 HTTP 通道,chat 层显式拒绝兜底。
"""
assert_agent_access(auth, agent_type, risk_repo=_repo())
- if agent_type == "risk" and "risk_manager" in auth.roles:
+ if agent_type == "risk" and (
+ "risk_manager" in auth.roles or "compliance" in auth.roles
+ ):
deny(auth, "AUTH_403_ROLE", _repo(), message="对话线仅限 risk_officer,请走 HTTP 台账")
@@ -317,7 +318,7 @@ def chat_api(req: ChatRequest, request: Request, auth: AuthContext = Depends(get
# ---------- 方案 B:前端「拉」侧只读接口(会话列表 / 历史消息 / 关闭会话) ----------
#
# 前端对话页三件套,与 POST "" 共用同一套入口守卫(_assert_chat_entry:
-# 矩阵准入 + risk_manager 显式拒绝)与 SessionGuard(_guard_session:
+# 矩阵准入 + risk_manager/compliance 显式拒绝)与 SessionGuard(_guard_session:
# 仅本人会话 + agent_type 一致,越权 403 留痕)。纯读/状态流转,不改表、
# 不碰 Tool 契约;manager 与对话线保持同口径 deny(见 _assert_chat_entry)。
@@ -418,6 +419,7 @@ def close_session_api(
# 已落可审计),不产生半截内容污染历史窗口。
_SSE_DONE = "data: [DONE]\n\n"
+_SSE_HEARTBEAT = ": ping\n\n"
def _sse(payload: dict) -> str:
@@ -480,6 +482,7 @@ def chat_stream_api(
"disclaimer": meta_disclaimer,
}
yield _chunk(trace_id, {"role": "assistant"}, meta=meta)
+ yield _SSE_HEARTBEAT
full: list[str] = []
try:
if agent_type == "customer":
diff --git a/app/api/deps.py b/app/api/deps.py
index 0cd37a9..b82d0eb 100644
--- a/app/api/deps.py
+++ b/app/api/deps.py
@@ -50,10 +50,10 @@ AGENT_ACCESS_MATRIX: dict[str, dict[str, tuple[str, ...]]] = {
"analyst": {"token_types": ("staff",), "roles": ("analyst", "compliance")},
# C5(PRD 4A.1):增补 risk_manager——HTTP 台账请求经此交叉校验,
# 不加则 manager 连 GET /api/risk/alerts 都会被 AUTH_403_AGENT_MISMATCH 挡掉。
- # 对话线不放行(FR-6 冻结口径),由 chat.py 显式 deny 兜底。
+ # 对话线不放行 risk_manager/compliance(FR-6 冻结口径),由 chat.py 显式 deny 兜底。
"risk": {
"token_types": ("staff", "service"),
- "roles": ("risk_officer", "risk_manager", "service_risk"),
+ "roles": ("risk_officer", "risk_manager", "service_risk", "compliance"),
},
}
diff --git a/app/api/risk.py b/app/api/risk.py
index 949013a..9ca0884 100644
--- a/app/api/risk.py
+++ b/app/api/risk.py
@@ -25,6 +25,7 @@ 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.repository.repo_access import RiskListAccess
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
@@ -71,11 +72,15 @@ def list_alerts_api(
pass
elif auth.has_role("risk_manager"):
pass
+ elif auth.has_role("service_risk"):
+ 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(
+ deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer/compliance/service_risk only")
+ repo = _repo()
+ rows, total = repo.list_alerts(
+ access=RiskListAccess.api(),
status=status,
alert_type=alert_type,
customer_id=customer_id,
@@ -84,8 +89,15 @@ def list_alerts_api(
page=page,
page_size=page_size,
)
- return {"items": rows, "total": total, "page": page, "page_size": page_size,
- "disclaimer": ALERT_DISCLAIMER}
+ stats = repo.count_pending_stats(access=RiskListAccess.api(), alert_type=alert_type)
+ return {
+ "items": rows,
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "stats": stats,
+ "disclaimer": ALERT_DISCLAIMER,
+ }
@router.post("/alerts/{alert_id}/handle")
diff --git a/app/gateway/jwt_service.py b/app/gateway/jwt_service.py
index 1a7a951..1d68041 100644
--- a/app/gateway/jwt_service.py
+++ b/app/gateway/jwt_service.py
@@ -49,6 +49,14 @@ ROLE_PERMISSIONS: dict[str, list[str]] = {
"risk:suitability:write",
"core:holding:read:all",
],
+ "risk_manager": [
+ "agent:risk:chat",
+ "profile:l1:read",
+ "profile:l2:read",
+ "profile:l3:read",
+ "risk:alert:read",
+ "core:holding:read:all",
+ ],
"compliance": ["audit:read:all", "agent:advisor:audit", "compliance:hit:read"],
"ops": ["agent:advisor:stats", "audit:read:aggregated"],
"service_risk": [
@@ -71,6 +79,7 @@ DEFAULT_ROLES_BY_ACTOR: dict[str, list[str]] = {
"STAFF-20002": ["analyst"],
"STAFF-30001": ["risk_officer", "risk_demo"],
"STAFF-30002": ["risk_officer", "risk_demo"],
+ "STAFF-31001": ["risk_manager"],
"STAFF-40001": ["compliance"],
"STAFF-40002": ["compliance"],
"STAFF-50001": ["ops"],
diff --git a/app/repository/repo_access.py b/app/repository/repo_access.py
new file mode 100644
index 0000000..9430ecf
--- /dev/null
+++ b/app/repository/repo_access.py
@@ -0,0 +1,60 @@
+"""仓储调用凭证(F1 纵深防御:禁止业务代码裸调敏感读写)。
+
+仅 service 层应构造 AccessToken;直调不传 token 会 TypeError/ValueError。
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class RiskListAccess:
+ purpose: str
+
+ _ALLOWED = frozenset(
+ {
+ "risk_api",
+ "risk_chat_tool",
+ "risk_cron",
+ "unit_test",
+ }
+ )
+
+ def __post_init__(self) -> None:
+ if self.purpose not in self._ALLOWED:
+ raise ValueError(f"invalid RiskListAccess purpose: {self.purpose}")
+
+ @staticmethod
+ def api() -> RiskListAccess:
+ return RiskListAccess("risk_api")
+
+ @staticmethod
+ def chat_tool() -> RiskListAccess:
+ return RiskListAccess("risk_chat_tool")
+
+ @staticmethod
+ def cron() -> RiskListAccess:
+ return RiskListAccess("risk_cron")
+
+ @staticmethod
+ def unit_test() -> RiskListAccess:
+ return RiskListAccess("unit_test")
+
+
+@dataclass(frozen=True)
+class ThresholdWriteAccess:
+ purpose: str
+
+ _ALLOWED = frozenset({"profile_threshold_sync", "unit_test"})
+
+ def __post_init__(self) -> None:
+ if self.purpose not in self._ALLOWED:
+ raise ValueError(f"invalid ThresholdWriteAccess purpose: {self.purpose}")
+
+ @staticmethod
+ def profile_sync() -> ThresholdWriteAccess:
+ return ThresholdWriteAccess("profile_threshold_sync")
+
+ @staticmethod
+ def unit_test() -> ThresholdWriteAccess:
+ return ThresholdWriteAccess("unit_test")
diff --git a/app/repository/risk_repository.py b/app/repository/risk_repository.py
index 2cee9db..c24375b 100644
--- a/app/repository/risk_repository.py
+++ b/app/repository/risk_repository.py
@@ -11,6 +11,7 @@ from datetime import datetime
from decimal import Decimal
from typing import Any
+from app.repository.repo_access import RiskListAccess
from sqlalchemy import text
from sqlalchemy.engine import Engine
@@ -258,6 +259,8 @@ class RiskRepository:
def list_alerts(
self,
+ *,
+ access: RiskListAccess,
status: str | None = None,
alert_type: str | None = None,
customer_id: str | None = None,
@@ -266,10 +269,12 @@ class RiskRepository:
page: int = 1,
page_size: int = 20,
) -> tuple[list[dict], int]:
- """预警台账分页查询(PRD FR-4);过滤参数全部可选。"""
+ """预警台账分页查询(PRD FR-4);须带 RiskListAccess(F1 纵深防御)。"""
where = ["1=1"]
params: dict[str, Any] = {}
- if status:
+ if status == "handled":
+ where.append("status != 'pending_review'")
+ elif status:
where.append("status = :status")
params["status"] = status
if alert_type:
@@ -301,6 +306,29 @@ class RiskRepository:
).mappings()
return [self._parse_alert(dict(r)) for r in rows], int(total)
+ def count_pending_stats(self, *, access: RiskListAccess, alert_type: str | None = None) -> dict[str, int]:
+ """待审总量 + 今日待审(服务端本地日,供看板 F7)。"""
+ from datetime import date, timedelta
+
+ where = ["status = 'pending_review'"]
+ params: dict[str, Any] = {}
+ if alert_type:
+ where.append("alert_type = :atype")
+ params["atype"] = alert_type
+ where_sql = " AND ".join(where)
+ day_start = datetime.combine(date.today(), datetime.min.time())
+ day_end = day_start + timedelta(days=1)
+ today_sql = f"{where_sql} AND created_at >= :day_start AND created_at < :day_end"
+ params_today = {**params, "day_start": day_start, "day_end": day_end}
+ with self._engine.connect() as conn:
+ pending = int(
+ conn.execute(text(f"SELECT COUNT(*) FROM risk_alert WHERE {where_sql}"), params).scalar_one()
+ )
+ today = int(
+ conn.execute(text(f"SELECT COUNT(*) FROM risk_alert WHERE {today_sql}"), params_today).scalar_one()
+ )
+ return {"pending_review_count": pending, "today_pending_count": today}
+
def update_alert_status(
self, alert_id: str, handler_result: str, handler_id: str, handler_comment: str | None
) -> bool:
diff --git a/app/repository/threshold_repository.py b/app/repository/threshold_repository.py
index 59a13f1..0b44e11 100644
--- a/app/repository/threshold_repository.py
+++ b/app/repository/threshold_repository.py
@@ -6,6 +6,7 @@ import json
from decimal import Decimal
from typing import Any
+from app.repository.repo_access import ThresholdWriteAccess
from sqlalchemy import text
from app.config.database import get_agent_engine
@@ -38,7 +39,13 @@ class ThresholdRepository:
with self._engine.connect() as conn:
return [str(r[0]) for r in conn.execute(sql).fetchall()]
- def upsert_portfolio(self, customer_id: str, loss_threshold_pct: Decimal) -> int:
+ def upsert_portfolio(
+ self,
+ customer_id: str,
+ loss_threshold_pct: Decimal,
+ *,
+ access: ThresholdWriteAccess,
+ ) -> int:
"""组合级阈值:同一客户仅保留一条 portfolio 配置(更新或插入)。"""
sel = text(
"""
diff --git a/app/service/risk/chat_tools.py b/app/service/risk/chat_tools.py
index fa4ac4b..ca40728 100644
--- a/app/service/risk/chat_tools.py
+++ b/app/service/risk/chat_tools.py
@@ -28,6 +28,7 @@ from typing import Any, Callable
from app.config.settings import settings
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
+from app.repository.repo_access import RiskListAccess
from app.service import suitability
from app.service.risk.profile_l3 import get_profile_l3
from app.tool.core_tools import _jsonable
@@ -80,14 +81,27 @@ def alert_query(customer_id: str, core_ro: CoreReadOnlyRepository | None = None,
repo = risk_repo or RiskRepository()
start = _day_start()
if customer_id:
- rows, total = repo.list_alerts(customer_id=customer_id, status="pending_review", page_size=50)
+ rows, total = repo.list_alerts(
+ access=RiskListAccess.chat_tool(),
+ customer_id=customer_id,
+ status="pending_review",
+ page_size=50,
+ )
_, today_total = repo.list_alerts(
- customer_id=customer_id, status="pending_review", start=start, page_size=50
+ access=RiskListAccess.chat_tool(),
+ customer_id=customer_id,
+ status="pending_review",
+ start=start,
+ page_size=50,
)
scope = "customer"
else:
- rows, total = repo.list_alerts(status="pending_review", page_size=100)
- _, today_total = repo.list_alerts(status="pending_review", start=start, page_size=100)
+ rows, total = repo.list_alerts(
+ access=RiskListAccess.chat_tool(), status="pending_review", page_size=100
+ )
+ _, today_total = repo.list_alerts(
+ access=RiskListAccess.chat_tool(), status="pending_review", start=start, page_size=100
+ )
scope = "all"
return _jsonable(
{
@@ -110,7 +124,10 @@ def customer_context(customer_id: str, core_ro: CoreReadOnlyRepository | None =
return {"found": False, "customer_id": customer_id}
l3 = get_profile_l3(customer_id, risk_repo=repo)
pending, pending_total = repo.list_alerts(
- customer_id=customer_id, status="pending_review", page_size=20
+ access=RiskListAccess.chat_tool(),
+ customer_id=customer_id,
+ status="pending_review",
+ page_size=20,
)
profile = ro.concentration_profile(customer_id)
return _jsonable(
@@ -286,7 +303,9 @@ def query_agent_behavior(customer_id: str, core_ro: CoreReadOnlyRepository | Non
repo = risk_repo or RiskRepository()
ro = core_ro or CoreReadOnlyRepository()
agent_id = params.get("agent_id")
- rows, _ = repo.list_alerts(alert_type="pattern", status=None, page_size=100)
+ rows, _ = repo.list_alerts(
+ access=RiskListAccess.chat_tool(), alert_type="pattern", status=None, page_size=100
+ )
out: list[dict[str, Any]] = []
for a in rows:
payload = a.get("payload") or {}
diff --git a/app/service/threshold_service.py b/app/service/threshold_service.py
index 701b6fb..3219891 100644
--- a/app/service/threshold_service.py
+++ b/app/service/threshold_service.py
@@ -12,6 +12,7 @@ from decimal import Decimal
from typing import Any
from app.repository.threshold_repository import ThresholdRepository
+from app.repository.repo_access import ThresholdWriteAccess
_LOSS_PCT_RE = re.compile(r"(\d+(?:\.\d+)?)\s*%")
@@ -32,7 +33,9 @@ def sync_threshold_from_summary(customer_id: str, summary: str) -> int | None:
pct = parse_loss_threshold_pct(summary)
if pct is None:
return None
- return ThresholdRepository().upsert_portfolio(customer_id, pct)
+ return ThresholdRepository().upsert_portfolio(
+ customer_id, pct, access=ThresholdWriteAccess.profile_sync()
+ )
def portfolio_pnl_pct(holdings: list[dict[str, Any]]) -> float | None:
diff --git a/docs/course/index.html b/docs/course/index.html
index 5867bb3..8742114 100644
--- a/docs/course/index.html
+++ b/docs/course/index.html
@@ -128,7 +128,7 @@
四角色、JWT 双通道、数据从哪来、merger 做到哪——含模块 8 答辩动线(804 pytest 基线)。 四角色、JWT 双通道、数据从哪来、merger 做到哪——含模块 8 答辩动线(825 pytest 基线)。JinRong 项目现状
- STAFF-30001 带 risk_officer + risk_demo 角色:
能看预警台账、处置、跑 AML 扫描,还能在模拟交易页触发规则引擎。
本地登录页一键切换,改 JWT 角色后需重新登录。
- 仓库基线 merger · python -m pytest → 804 passed。
+ 仓库基线 merger · python -m pytest → 825 passed。
- 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 804 passed。
+ 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 825 passed。
STAFF-30001 带 risk_officer + risk_demo 角色:
能看预警台账、处置、跑 AML 扫描,还能在模拟交易页触发规则引擎。
本地登录页一键切换,改 JWT 角色后需重新登录。
- 仓库基线 merger · python -m pytest → 804 passed。
+ 仓库基线 merger · python -m pytest → 825 passed。
- 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 804 passed。
+ 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 825 passed。
模块 5 · 现状与下一步
- 分支 merger · 测试基线 804 pytest · 前端 19 Vitest · Redis Docker 6380。
+ 分支 merger · 测试基线 825 pytest · 前端 22 Vitest · Redis Docker 6380。
文档入口:docs/memory/MEMORY.md §0 · 答辩提纲 docs/答辩/答辩知识点清单.md · 滚动版见模块 8。
.\scripts\dev\start-redis.ps1 → 6380
uvicorn app.main:app --reload :8000
cd web && npm run dev → :5173 代理 API
python -m pytest 验收 804 绿
python -m pytest 验收 825 绿
对照 docs/答辩/答辩知识点清单.md 的滚动版:先讲清四角色不互调 LLM,再按动线演示,最后主动说边界。
- 基线 804 pytest · 19 Vitest · 分支 merger。
+ 基线 825 pytest · 22 Vitest · 分支 merger。
模块 5 · 现状与下一步
- 分支 merger · 测试基线 804 pytest · 前端 19 Vitest · Redis Docker 6380。
+ 分支 merger · 测试基线 825 pytest · 前端 22 Vitest · Redis Docker 6380。
文档入口:docs/memory/MEMORY.md §0 · 答辩提纲 docs/答辩/答辩知识点清单.md · 滚动版见模块 8。
.\scripts\dev\start-redis.ps1 → 6380
uvicorn app.main:app --reload :8000
cd web && npm run dev → :5173 代理 API
python -m pytest 验收 804 绿
python -m pytest 验收 825 绿
对照 docs/答辩/答辩知识点清单.md 的滚动版:先讲清四角色不互调 LLM,再按动线演示,最后主动说边界。
- 基线 804 pytest · 19 Vitest · 分支 merger。
+ 基线 825 pytest · 19 Vitest · 分支 merger。
{JSON.stringify(result)}` 直出,无结构化渲染、无错误友好提示、免责声明不常驻。
+**修复建议**:结构化表格 + 错误码→用户可读文案映射 + 常驻免责声明。
+
+### 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** |
diff --git a/docs/答辩/DEMO-SOP-问数.md b/docs/答辩/DEMO-SOP-问数.md
index b3bc8d8..78e15c7 100644
--- a/docs/答辩/DEMO-SOP-问数.md
+++ b/docs/答辩/DEMO-SOP-问数.md
@@ -1,6 +1,6 @@
# 答辩 Demo · 问数工作台 SOP
-> **套餐 ① 答辩稳**(2026-09-11)· 基线 **813 pytest** · 问数/解读拆分已落地
+> **套餐 ① 答辩稳**(2026-09-11)· 基线 **825 pytest** · 问数/解读拆分 · N-03 抽样 · N-07 转人工
## 1. 环境(答辩前 30 分钟)
@@ -11,6 +11,7 @@ docker compose up -d redis # 或 .\scripts\dev\start-redis.ps1
mysql -u root -p jinrong_agent < scripts/agent/seed-analyst-metric-dict.sql
mysql -u root -p jinrong_agent < scripts/agent/seed-analyst-query-templates.sql
+# 或一键:.\scripts\dev\seed_analyst.ps1
python -m uvicorn app.main:app --reload --port 8000
cd web && npm run dev
@@ -28,7 +29,8 @@ cd web && npm run dev
| 4 | 点 **分析该数据** | 「上下文只有本轮问题和表格,不接 Chat 历史;数字走 D-10 护栏」 |
| 5 | 问:**近30天申购金额**(或含「近30天」「申购」「金额」) | 模板 `subscribe_amount_recent_days` |
| 6 | 问:**近30天交易流水是多少** | **clarify** → N-01 多义,不猜口径 |
-| 7 | 可选 deny | 理财师问非名下客户 → 权限与问数 **同一 answer**,解读按钮不调 LLM |
+| 7 | 可选 | 点 **抽样溯源**(N-03)· 失败场景 **转人工**(N-07) |
+| 8 | 可选 deny | 理财师问非名下客户 → 权限与问数 **同一 answer**,解读按钮不调 LLM |
## 3. 客户角色(平台服务 · 数据分析)
@@ -40,13 +42,14 @@ cd web && npm run dev
```powershell
python scripts/dev/run_query_battery.py
-# 本地生成 scripts/dev/battery_report.json(.gitignore)
+# 脚本内显式 interpret: false;本地生成 scripts/dev/battery_report.json(.gitignore)
```
验收:**A1 Q17**(`created_at` 列不误杀)· **A5** 流水 clarify · 模板题稳定命中。
## 5. 诚实边界(主动一句)
-- **D-09 多轮追问**、**D-12 看板钻取**、**N-03 溯源** 未做
+- **D-09 多轮追问**、**D-12 看板钻取**仍 open
+- **N-03 抽样**、**N-07 转人工**已最小闭环(sample API + escalate + 问数页按钮)
- `/app/analytics/chat` **重定向**到问数页,避免双入口
- 分析占位 **agent_service** 仍存在于 API,菜单已收
diff --git a/docs/答辩/答辩知识点清单.md b/docs/答辩/答辩知识点清单.md
index 3847c9d..21e0009 100644
--- a/docs/答辩/答辩知识点清单.md
+++ b/docs/答辩/答辩知识点清单.md
@@ -1,6 +1,6 @@
# 金融四 Agent 答辩知识点清单
-> 用途:按模块讲清**数据怎么流、怎么跑、为什么这样选、亮点在哪**;答辩时可当提纲。基线:**816 pytest** · **22 Vitest** · 分支 **`merger`**。
+> 用途:按模块讲清**数据怎么流、怎么跑、为什么这样选、亮点在哪**;答辩时可当提纲。基线:**825 pytest** · **22 Vitest** · 分支 **`merger`**。
---
@@ -78,7 +78,7 @@
| **D-10 护栏** | 解读数字与结果 **逐字比对** · 失败重试 1 次 → **degrade 只出表** |
| **N-01** | 「流水」等多义 → **clarify**,不猜口径 |
| **聪明做法** | 问数与 Chat **分离** · 各角色问数页 **「分析该数据 / 解读我的数据」** 仅带本轮快照 · 客户 **self** 域 + AI 风险尾注 · **Q17** 列名 `created_at` 不误杀 `create` |
-| **诚实缺口** | D-09 **多轮** · N-03 溯源 · N-07 转人工 — 未做 · **分析对话菜单已收**(URL 重定向问数) |
+| **诚实缺口** | D-09 **多轮** · D-12 **钻取** — 仍 open · **N-03/N-07 已最小闭环** · **分析对话菜单已收**(URL 重定向问数) |
### 4.4 风控 Agent
@@ -86,7 +86,7 @@
| --- | --- |
| **事件线** | 模拟交易 `submit_trade` → 适当性(**R-02 可阻断**)→ `INSERT core_trade` → **`process_trade_event`** → 规则 R-01~R-05 + FR-8 集中度 → **聚合锁** 同日预警 → L3 upsert · AML 扫描 |
| **对话线** | `agent_service` risk 分支 · **6 只只读 Tool**(含 `query_agent_behavior`)· **无处置 Tool**(诱导只读,状态不变) |
-| **REST** | 台账/处置/适当性/AML/simulate · 前端四页 + Chat |
+| **REST** | 台账/处置/适当性/AML/simulate · **`GET /alerts` 含 stats** · service_risk **只读台账** · compliance **生产 JWT 可达** · 前端四页 + Chat |
| **写侧并发(模块 7 课)** | `run_locked` 聚合 · L3 **`computed_at` 乐观锁** · 处置 + audit **同事务** |
| **聪明做法** | 超期升级 **改 payload 不改 status**(FR-9)· 行为链 **actor_id 维度**(FR-10)· **L1/L2 Redis 不进风控**(仅 L3 cache-aside) |
| **演示** | `prepare_all.ps1` 一键灌库 · A-1/A-3 模拟交易 · 台账筛选/处置 |
@@ -134,7 +134,7 @@
| **Tool 归属纵深** | Tool 层 blocked 留痕 + API 层 403 · 风控 officer 可无 customer_id 查全量待审(C2) |
| **RAG 溯源** | `source_refs` / effective_date 过滤 · 禁止 LLM 编造未检索内容 |
| **风控评审闭环** | B1~B9b、C4~C6 独立 AI 评审 · 挂账 #1~#9 核对 |
-| **测试** | **816** 后端 · Wave 分模块 · 问数 interpret 拆分 + sql_guard Q17 · 前端 **22** Vitest |
+| **测试** | **825** 后端 · Wave 分模块 · 问数 interpret 拆分 + sql_guard Q17 + TEST-RISK-001 · 前端 **22** Vitest |
| **文档** | `docs/memory/*` Agent 交接 · `docs/course/` 交互深潜课 · 演示 SOP |
| **问数 battery** | `run_query_battery.py` 本地跑分 · **报告不入库** |
| **输入与输出双护栏** | 输入:注入/限流 · 输出:客服 sanitize + 问数 guardrail |
@@ -159,7 +159,7 @@
| 无真实 Core | L0 为 **模拟库**,生产接托管 Core 只读账号 |
| 分析对话 vs 问数 | **已拆分**:默认只出表 · 按钮调 `/interpret` · 旧 **分析对话** 路由重定向问数 · D-09 **多轮**仍 open |
| 客服 L1/L2 Redis | 热读方案已定,**风控不做 L1/L2** |
-| C-04 push | 仅持仓查询 **内联提醒**,无定时 push |
+| C-04 push | 持仓 **内联提醒** + 演示 **`threshold-check?push=`** / `push_threshold_alerts.py`;**无生产 cron** |
| 行情 Phase B | v0.2 草案,未接 sync |
| 知识库 API | T-21 脚本入库,**上传端点一期不做** |
| PII 缓存脱敏 | 全仓方案未定,问数缓存 **暂缓** |
diff --git a/docs/项目框架设计/合并注意事项-风控模块并入main.md b/docs/项目框架设计/合并注意事项-风控模块并入main.md
index cc84d6c..6d2956c 100644
--- a/docs/项目框架设计/合并注意事项-风控模块并入main.md
+++ b/docs/项目框架设计/合并注意事项-风控模块并入main.md
@@ -15,7 +15,7 @@
| S2 接缝 | `auth_adapter.module_auth_from_host()` 已接线 |
| trace 中间件 | ApiError / AppError / PermissionDenied / RequestValidationError / StarletteHTTPException **re-raise**;仅未捕获 → 500 |
| 边界测试 | `tests/test_module_boundary.py` 全绿(宿主 D 类文件排除跨层扫描) |
-| 测试基线 | **`python -m pytest` → 530 passed, 0 skipped** |
+| 测试基线 | **`python -m pytest` → 530 passed, 0 skipped**(AL-09 当时快照)· **当前 merger 全量见 `docs/memory/MEMORY.md` §0(825)** |
**架构结论:** 宿主 `gateway/` 与模块 `deps.py` **双栈并存**;对外 token 统一;模块 API 禁止 import `app/gateway/`。
diff --git a/docs/项目框架设计/数据分析Agent开发清单.md b/docs/项目框架设计/数据分析Agent开发清单.md
index 18cd955..92db87f 100644
--- a/docs/项目框架设计/数据分析Agent开发清单.md
+++ b/docs/项目框架设计/数据分析Agent开发清单.md
@@ -66,8 +66,8 @@
| A-15 | 智能看数板后端接口 | D-12 | `/api/analyst/dashboard` | 按角色出卡片、钻取进对话 | A-04 | ⬜ |
| A-16 | 指标消歧反问 | N-01 | `analyst_agent` ambiguity 节点 | 多义词先反问,澄清后口径一致 | A-08 | ⬜ |
| A-17 | 空/零/不命中三态区分 | N-02 | `sql_tool` | 三种空态分别准确说明 | A-03 | ⬜ |
-| A-18 | 聚合抽样明细溯源 | N-03 | `/api/analyst/query/{trace_id}/sample` | 明细与聚合一致 | A-07 | ⬜ |
-| A-19 | 转人工兜底 | N-07 | `/api/analyst/escalate` | 失败场景一键转人工、留痕可还原 | A-07 | ⬜ |
+| A-18 | 聚合抽样明细溯源 | N-03 | `/api/analyst/query/{trace_id}/sample` | 明细与聚合一致 | A-07 | ✅ 2026-09-11 |
+| A-19 | 转人工兜底 | N-07 | `/api/analyst/escalate` | 失败场景一键转人工、留痕可还原 | A-07 | ✅ 2026-09-11 |
**阶段 3 验收**:D-10 造错用例被拦;D-11 沉淀生效;D-12 卡片钻取;N-01/02/03/07 各跑通。
diff --git a/docs/项目框架设计/数据分析Agent架构说明书.md b/docs/项目框架设计/数据分析Agent架构说明书.md
index dded648..1144666 100644
--- a/docs/项目框架设计/数据分析Agent架构说明书.md
+++ b/docs/项目框架设计/数据分析Agent架构说明书.md
@@ -548,7 +548,7 @@ CREATE TABLE analytics_query_template (
| Wave 0(平台) | JWT/RBAC(T-01)、审计贯通(T-02)、agent 库灌库(T-05) | 未做(复用,非分析组) |
| Wave 1-A(P0 闭环) | `analytics_query_log` + `sql_guard` + `analyst_agent` 主链路 → D-01~D-04 闭环 | 未做 |
| Wave 1-B(P0 增强) | 口径字典(D-07)、缓存+记忆(D-06)、追问改写(D-09)、数字护栏(D-10) | 未做 |
-| Wave 1-C(P0 亮点) | 养 Agent 资产沉淀(D-11)、智能看数板后端(D-12)、消歧(N-01)、空零(N-02)、溯源(N-03)、转人工(N-07) | 未做 |
+| Wave 1-C(P0 亮点) | 养 Agent 资产沉淀(D-11)、智能看数板后端(D-12)、消歧(N-01)、空零(N-02)、溯源(N-03)、转人工(N-07) | **N-03/N-07 已做**;D-12 未做 |
| Wave 2(P1) | 配额(N-04)、保存/分享/订阅(N-05)、质量提示(N-06)、运营面板(N-08) | 未做 |
> 已有实现:`CoreReadOnlyRepository`(只读 SELECT)、`settings` 双库、Core 模拟库脚本、`customer_advisor_rel` 同步脚本。分析 Agent 业务层尚未实现。
diff --git a/docs/项目框架设计/风控Agent模块边界与合并接缝标注.md b/docs/项目框架设计/风控Agent模块边界与合并接缝标注.md
index a2da43b..a4f0a69 100644
--- a/docs/项目框架设计/风控Agent模块边界与合并接缝标注.md
+++ b/docs/项目框架设计/风控Agent模块边界与合并接缝标注.md
@@ -36,7 +36,7 @@
| `app/gateway/trade_gateway.py` | 交易网关(C6 需透传 actor_id) |
| `app/model/suitability.py` | 适当性落库行构造(AL-04 引入,与 main 同名 → **冲突时以模块版为准**) |
| `scripts/demo/*`、`scripts/core/*` | 演示与 Core 种子脚本 |
-| `tests/`(除 test_module_boundary 外) | 模块测试;**AL-09 后全量基线 530 passed 0 skipped** |
+| `tests/`(除 test_module_boundary 外) | 模块测试;**AL-09 当时 530 passed** · **当前全量基线见 MEMORY §0** |
### 1.2 B 类 · 模块私有基建(与宿主同类但模块内自用,**允许与 main 并存**)
diff --git a/scripts/dev/sandbox_risk_test.py b/scripts/dev/sandbox_risk_test.py
new file mode 100644
index 0000000..22f2bbe
--- /dev/null
+++ b/scripts/dev/sandbox_risk_test.py
@@ -0,0 +1,598 @@
+"""沙盘:风险 Agent(风控监测)端到端测试 — 权限矩阵 / 触发方式 / 纵深防御 / 对话线。
+
+真实 MySQL 双库(jinrong_core + jinrong_agent)+ 真实 DeepSeek(TestClient 进程内)。
+只测不改业务代码:发现问题仅断言 + 留痕,修复建议落 TEST-LOG 报告。
+
+用法:
+ python scripts/dev/sandbox_risk_test.py # 全量跑,结尾清理业务数据(审计行保留)
+ python scripts/dev/sandbox_risk_test.py --keep # 不清理,便于人工核验
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from datetime import datetime, timedelta
+from decimal import Decimal
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+# Windows 控制台默认 cp936,中文输出会乱码;统一 UTF-8 直出(Git Bash 可读)。
+try:
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
+except Exception: # noqa: BLE001
+ pass
+
+from fastapi.testclient import TestClient # noqa: E402
+from sqlalchemy import text # noqa: E402
+
+from app.config.settings import settings # noqa: E402
+from app.main import app # noqa: E402
+from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
+from app.repository.risk_repository import RiskRepository # noqa: E402
+from app.repository.threshold_repository import ThresholdRepository # noqa: E402
+from app.service.auth_service import issue_dev_token # noqa: E402
+from app.service.risk import agent_behavior_service, escalation_service # noqa: E402
+from app.utils.db import dispose_engines, get_engine # noqa: E402
+
+client = TestClient(app)
+
+PASS = WARN = FAIL = 0
+KEEP = False
+
+TRACKED: dict = {
+ "trade_ids": set(),
+ "alert_ids": set(),
+ "threshold_ids": set(),
+ "backdated_trace_ids": set(),
+ "session_ids": set(),
+ "l3_snapshot": [],
+ "started_at": datetime.now(),
+}
+
+
+# ---------------------------------------------------------------------------
+# 基础工具
+# ---------------------------------------------------------------------------
+
+def agent_engine():
+ return get_engine(settings.mysql_database)
+
+
+def core_engine():
+ return get_engine(settings.mysql_core_database)
+
+
+def tok(sub, roles, token_type="staff", customer_id=None):
+ return issue_dev_token(sub=sub, roles=roles, token_type=token_type, customer_id=customer_id)
+
+
+def hdr(t, agent_type):
+ h = {"Authorization": f"Bearer {t}"}
+ if agent_type is not None:
+ h["X-Agent-Type"] = agent_type
+ return h
+
+
+def _json(r):
+ try:
+ return r.json()
+ except Exception:
+ return {"raw": r.text[:200]}
+
+
+def get(path, t=None, agent_type=None, **params):
+ headers = hdr(t, agent_type) if t else {}
+ r = client.get(path, headers=headers, params=params)
+ return r.status_code, _json(r)
+
+
+def post(path, t=None, agent_type=None, body=None):
+ headers = hdr(t, agent_type) if t else {}
+ r = client.post(path, headers=headers, json=body or {})
+ return r.status_code, _json(r)
+
+
+def post_debug(path, role, actor, body=None):
+ r = client.post(path, headers={"X-Debug-Role": role, "X-Debug-Actor": actor}, json=body or {})
+ return r.status_code, _json(r)
+
+
+def get_debug(path, role, actor, **params):
+ r = client.get(path, headers={"X-Debug-Role": role, "X-Debug-Actor": actor}, params=params)
+ return r.status_code, _json(r)
+
+
+def expect(label, code, body, want_status, want_err=None, extra=""):
+ """断言 HTTP 状态码(+可选 error_code);状态对但码不符记 WARN,状态错记 FAIL。"""
+ global PASS, WARN, FAIL
+ err = body.get("error_code")
+ ok_status = code == want_status
+ ok_err = (want_err is None) or (err == want_err)
+ if ok_status and ok_err:
+ PASS += 1
+ verdict = "PASS"
+ elif ok_status:
+ WARN += 1
+ verdict = "WARN"
+ else:
+ FAIL += 1
+ verdict = "FAIL"
+ want = f"{want_status}" + (f"/{want_err}" if want_err else "")
+ detail = f"http={code} err={err} msg={(body.get('message') or '')[:100]}"
+ if extra:
+ detail += f" | {extra}"
+ print(f" [{verdict}] {label} (want {want})")
+ print(f" {detail}")
+
+
+def check(label, cond, detail=""):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(f" [PASS] {label}" + (f" -> {detail}" if detail else ""))
+ else:
+ FAIL += 1
+ print(f" [FAIL] {label}" + (f" -> {detail}" if detail else ""))
+
+
+def audit_count(event_type=None, decision=None, actor_id=None, agent_type=None):
+ """审计留痕核验:按条件 COUNT audit_log。"""
+ where = ["1=1"]
+ params = {}
+ if event_type:
+ where.append("event_type = :et")
+ params["et"] = event_type
+ if decision:
+ where.append("decision = :dec")
+ params["dec"] = decision
+ if actor_id:
+ where.append("actor_id = :aid")
+ params["aid"] = actor_id
+ if agent_type:
+ where.append("agent_type = :agt")
+ params["agt"] = agent_type
+ with agent_engine().connect() as conn:
+ return int(conn.execute(
+ text(f"SELECT COUNT(*) FROM audit_log WHERE {' AND '.join(where)}"), params
+ ).scalar_one())
+
+
+def _fmt(v, n=110):
+ s = str(v).replace("\n", " ")
+ return s if len(s) <= n else s[: n - 1] + "…"
+
+
+# ---------------------------------------------------------------------------
+# 角色令牌
+# ---------------------------------------------------------------------------
+
+T = {
+ "risk_officer": tok("STAFF-30001", ["risk_officer"]),
+ "risk_manager": tok("STAFF-31001", ["risk_manager"]),
+ "compliance": tok("STAFF-40001", ["compliance"]),
+ "advisor": tok("STAFF-10086", ["advisor"]), # 名下含 CUST-1001/CUST-3001
+ "advisor_other": tok("STAFF-10087", ["advisor"]), # 名下无 CUST-3001
+ "customer": tok("CUST-9527", ["customer"], token_type="customer", customer_id="CUST-9527"),
+ "risk_demo": tok("STAFF-90001", ["risk_officer", "risk_demo"]),
+ "service_risk": tok("SVC-RISK-01", ["service_risk"], token_type="service"),
+}
+
+
+def do_trade(t, agent_type, customer_id, product_id, trade_type, amount):
+ code, body = post(
+ "/api/simulate/trade", t, agent_type,
+ body={"customer_id": customer_id, "product_id": product_id,
+ "trade_type": trade_type, "amount": amount},
+ )
+ if body.get("trade_id"):
+ TRACKED["trade_ids"].add(body["trade_id"])
+ for aid in (body.get("alert_ids") or []):
+ TRACKED["alert_ids"].add(aid)
+ return code, body
+
+
+# ---------------------------------------------------------------------------
+# 主流程
+# ---------------------------------------------------------------------------
+
+def main() -> int:
+ global KEEP, PASS, WARN, FAIL
+
+ print("=== 风险 Agent(风控监测)端到端沙盘 · 真实 MySQL + 真实 DeepSeek ===\n")
+
+ # ---------- 0) 预检 + L3 快照 ----------
+ preflight_ok = True
+ try:
+ with core_engine().connect() as conn:
+ days = conn.execute(text(
+ "SELECT DATEDIFF(expires_at, CURDATE()) FROM core_customer_risk WHERE customer_id='CUST-4001'"
+ )).scalar_one_or_none()
+ with agent_engine().connect() as conn:
+ aml = int(conn.execute(text("SELECT COUNT(*) FROM risk_aml_list WHERE is_active=1")).scalar_one())
+ TRACKED["l3_snapshot"] = conn.execute(text("SELECT * FROM customer_profile_l3")).mappings().all()
+ if days is None or days <= 0 or aml < 8:
+ preflight_ok = False
+ print(f" 预检: CUST-4001 风评剩余 {days} 天 / AML 名单 {aml} 条 / L3 快照 {len(TRACKED['l3_snapshot'])} 行")
+ except Exception as exc: # noqa: BLE001
+ preflight_ok = False
+ print(f" 预检失败: {exc}(提示:先跑 scripts/core/reset.ps1 → 01-mysql → 02-mysql → seed-aml-list → prepare_risk_demo.sql)")
+ if not preflight_ok:
+ print(" 演示数据未就位,终止。")
+ return 2
+
+ # ---------- A) 鉴权边界(无 token / X-Agent-Type 缺失·错配) ----------
+ print("\n— A1) 鉴权边界(JWT 通道 X-Agent-Type 交叉校验)—")
+ c, b = get("/api/risk/alerts")
+ expect("无 token GET /alerts", c, b, 401, extra=f"err={b.get('error_code')}")
+ c, b = get("/api/risk/alerts", T["risk_officer"]) # 有 token 无 X-Agent-Type
+ expect("有 token 无 X-Agent-Type", c, b, 401, "AUTH_401_MISSING_AGENT_TYPE")
+ c, b = get("/api/risk/alerts", T["risk_officer"], "foo")
+ expect("非法 X-Agent-Type=foo", c, b, 400, "BAD_REQUEST")
+ c, b = get("/api/risk/alerts", T["risk_officer"], "analyst")
+ expect("risk_officer 冒充 X-Agent-Type=analyst", c, b, 403, "AUTH_403_AGENT_MISMATCH")
+
+ # ---------- A2) GET /alerts 角色矩阵 ----------
+ print("\n— A2) GET /api/risk/alerts 角色矩阵 —")
+ c, b = get("/api/risk/alerts", T["risk_officer"], "risk")
+ expect("risk_officer 全量", c, b, 200, extra=f"total={b.get('total')}")
+ stats = b.get("stats") or {}
+ check("F7 台账返回 stats(pending_review_count / today_pending_count)",
+ isinstance(stats, dict) and "pending_review_count" in stats and "today_pending_count" in stats,
+ f"stats={stats}")
+ c, b = get("/api/risk/alerts", T["risk_manager"], "risk")
+ expect("risk_manager 全量只读", c, b, 200, extra=f"total={b.get('total')}")
+ c, b = get("/api/risk/alerts", T["compliance"], "risk")
+ items = b.get("items") or []
+ check("F3 compliance(风险线 JWT) 台账 200(aml 收敛见 D 节)",
+ c == 200 and all((it.get("alert_type") == "aml") for it in items),
+ f"total={b.get('total')} items_alert_type={sorted({it.get('alert_type') for it in items})}")
+ c, b = get("/api/risk/alerts", T["advisor"], "risk")
+ expect("advisor 冒充 risk", c, b, 403, "AUTH_403_AGENT_MISMATCH")
+ c, b = get("/api/risk/alerts", T["customer"], "risk")
+ expect("customer 冒充 risk", c, b, 403, "AUTH_403_AGENT_MISMATCH")
+ c, b = get("/api/risk/alerts", T["service_risk"], "risk")
+ expect("F2 service_risk 矩阵放行 → 只读台账 200", c, b, 200, extra=f"total={b.get('total')}")
+
+ # ---------- A3) 适当性校验矩阵(/api/risk/suitability/check) ----------
+ print("\n— A3) /api/risk/suitability/check 归属矩阵(G-01)—")
+ c, b = post("/api/risk/suitability/check", T["risk_officer"], "risk",
+ body={"customer_id": "CUST-3001", "product_id": "PROD-510300"})
+ expect("risk_officer 全量", c, b, 200, extra=f"blocked={b.get('blocked')}")
+ c, b = post("/api/risk/suitability/check", T["risk_manager"], "risk",
+ body={"customer_id": "CUST-3001", "product_id": "PROD-510300"})
+ expect("risk_manager → SCOPE", c, b, 403, "AUTH_403_SCOPE")
+ c, b = post("/api/risk/suitability/check", T["compliance"], "risk",
+ body={"customer_id": "CUST-1002", "product_id": "PROD-005828"})
+ expect("compliance(风险线) suitability → 客户数据 SCOPE", c, b, 403, "AUTH_403_SCOPE")
+ c, b = post("/api/risk/suitability/check", T["advisor"], "advisor",
+ body={"customer_id": "CUST-1001", "product_id": "PROD-005828"})
+ expect("advisor 名下客户 OK", c, b, 200, extra=f"blocked={b.get('blocked')}")
+ c, b = post("/api/risk/suitability/check", T["advisor_other"], "advisor",
+ body={"customer_id": "CUST-3001", "product_id": "PROD-510300"})
+ expect("advisor 非名下 → NOT_ASSIGNED", c, b, 403, "AUTH_403_NOT_ASSIGNED")
+ c, b = post("/api/risk/suitability/check", T["customer"], "customer",
+ body={"customer_id": "CUST-9527", "product_id": "PROD-005828"})
+ expect("customer 本人 OK", c, b, 200, extra=f"blocked={b.get('blocked')}")
+ c, b = post("/api/risk/suitability/check", T["customer"], "customer",
+ body={"customer_id": "CUST-3001", "product_id": "PROD-510300"})
+ expect("customer 他人 → NOT_OWNER", c, b, 403, "AUTH_403_NOT_OWNER")
+
+ # ---------- A4) aml/scan 权限拒绝 ----------
+ print("\n— A4) POST /api/risk/aml/scan 权限拒绝 —")
+ c, b = post("/api/risk/aml/scan", T["risk_manager"], "risk")
+ expect("risk_manager → ROLE", c, b, 403, "AUTH_403_ROLE")
+ c, b = post("/api/risk/aml/scan", T["compliance"], "risk")
+ expect("compliance aml/scan → ROLE(仅 risk_officer)", c, b, 403, "AUTH_403_ROLE")
+ c, b = post("/api/risk/aml/scan", T["advisor"], "risk")
+ expect("advisor → 矩阵拦截", c, b, 403, "AUTH_403_AGENT_MISMATCH")
+
+ # ---------- A5) 对话线权限拒绝(成功路径在 C 节) ----------
+ print("\n— A5) POST /api/chat(risk) 权限拒绝 —")
+ c, b = post("/api/chat", T["risk_manager"], "risk", body={"message": "今天有多少待审预警?"})
+ expect("risk_manager 对话线显式拒", c, b, 403, "AUTH_403_ROLE")
+ c, b = post("/api/chat", T["compliance"], "risk", body={"message": "你好"})
+ expect("compliance 对话线 → ROLE(仅 risk_officer,F12 已修)", c, b, 403, "AUTH_403_ROLE")
+ c, b = post("/api/chat", T["customer"], "risk", body={"message": "你好"})
+ expect("customer 冒充 risk → 矩阵拦截", c, b, 403, "AUTH_403_AGENT_MISMATCH")
+
+ # ---------- A6) 模拟交易权限拒绝(成功路径在 B 节) ----------
+ print("\n— A6) POST /api/simulate/trade 权限拒绝 —")
+ c, b = do_trade(T["risk_officer"], "risk", "CUST-3001", "PROD-510300", "subscribe", 1000)
+ expect("risk_officer 无 risk_demo → ROLE", c, b, 403, "AUTH_403_ROLE")
+ c, b = do_trade(T["advisor"], "risk", "CUST-1001", "PROD-005828", "subscribe", 1000)
+ expect("advisor → 矩阵拦截", c, b, 403, "AUTH_403_AGENT_MISMATCH")
+ c, b = do_trade(T["customer"], "customer", "CUST-3001", "PROD-510300", "subscribe", 1000)
+ expect("customer 他人 → ROLE", c, b, 403, "AUTH_403_ROLE")
+
+ # ---------- B1) 交易事件触发 ----------
+ print("\n— B1) 交易事件触发(/api/simulate/trade)—")
+ c, b = do_trade(T["risk_demo"], "risk", "CUST-1001", "PROD-161725", "subscribe", 10000)
+ check("A-1 适当性阻断 CUST-1001×R4",
+ c == 200 and b.get("blocked") is True and b.get("block_response_code") == "SUIT_RISK_MISMATCH",
+ f"blocked={b.get('blocked')} code={b.get('block_response_code')} advice={b.get('advice')!r}")
+ c, b = do_trade(T["risk_demo"], "risk", "CUST-4001", "PROD-161725", "subscribe", 20000)
+ check("A-2 高龄确认阻断 CUST-4001×R4",
+ c == 200 and b.get("blocked") is True and b.get("block_response_code") == "SUIT_AGE_CONFIRM"
+ and b.get("needs_branch_confirm") is True,
+ f"code={b.get('block_response_code')} branch_confirm={b.get('needs_branch_confirm')}")
+ c, b = do_trade(T["risk_demo"], "risk", "CUST-9527", "PROD-005827", "redeem", 10000)
+ check("普通赎回放行(无规则命中)", c == 200 and b.get("blocked") is False and b.get("triggered_rules") == [],
+ f"rules={b.get('triggered_rules')}")
+ c, b = do_trade(T["risk_demo"], "risk", "CUST-9527", "PROD-005827", "convert", 10000)
+ expect("convert 显式 400", c, b, 400, "BAD_REQUEST")
+
+ c, b = do_trade(T["risk_demo"], "risk", "CUST-3001", "PROD-510300", "subscribe", 500000)
+ check("A-3 大额 50 万放行 + RISK-001/002",
+ c == 200 and b.get("blocked") is False
+ and {"RISK-001", "RISK-002"}.issubset(set(b.get("triggered_rules") or [])),
+ f"rules={b.get('triggered_rules')} alerts={b.get('alert_ids')}")
+ a3_alert_id = (b.get("alert_ids") or [None])[0]
+ if a3_alert_id:
+ TRACKED["alert_ids"].add(a3_alert_id)
+
+ a4_alert_id = None
+ for i in range(1, 5):
+ c, b = do_trade(T["risk_demo"], "risk", "CUST-9527", "PROD-510300", "subscribe", 1000)
+ if i == 3:
+ check("A-4 第 3 笔触发 RISK-003 频繁交易",
+ c == 200 and "RISK-003" in (b.get("triggered_rules") or []),
+ f"第3笔 rules={b.get('triggered_rules')}")
+ a4_alert_id = (b.get("alert_ids") or [None])[0]
+ if a4_alert_id:
+ TRACKED["alert_ids"].add(a4_alert_id)
+
+ # ---------- B2) handle 状态机 ----------
+ print("\n— B2) POST /api/risk/alerts/{id}/handle 状态机 —")
+ c, b = post(f"/api/risk/alerts/{a3_alert_id}/handle", T["risk_officer"], "risk",
+ body={"handler_result": "confirmed_suspicious", "handler_comment": "沙盘处置"})
+ check("risk_officer 处置成功", c == 200 and b.get("status") == "confirmed_suspicious",
+ f"status={b.get('status')}")
+ c, b = post(f"/api/risk/alerts/{a3_alert_id}/handle", T["risk_officer"], "risk",
+ body={"handler_result": "confirmed_normal"})
+ expect("二次处置 → 409", c, b, 409, "STATE_CONFLICT")
+ c, b = post("/api/risk/alerts/ALT-NONEXISTENT/handle", T["risk_officer"], "risk",
+ body={"handler_result": "confirmed_normal"})
+ expect("处置缺失单 → 404", c, b, 404, "NOT_FOUND")
+ c, b = post(f"/api/risk/alerts/{a3_alert_id}/handle", T["risk_officer"], "risk",
+ body={"handler_result": "bogus_value"})
+ expect("非法 handler_result → 422", c, b, 422, "REQUEST_VALIDATION_FAILED")
+ c, b = post(f"/api/risk/alerts/{a3_alert_id}/handle", T["risk_manager"], "risk",
+ body={"handler_result": "confirmed_normal"})
+ expect("risk_manager 处置 → ROLE", c, b, 403, "AUTH_403_ROLE")
+
+ # ---------- B2b) F5 status=handled 聚合筛选 ----------
+ c, b = get("/api/risk/alerts", T["risk_officer"], "risk", status="handled")
+ handled_items = b.get("items") or []
+ check("F5 status=handled 聚合筛选返回已处置单(不含 pending)",
+ c == 200 and (b.get("total") or 0) >= 1
+ and all((it.get("status") != "pending_review") for it in handled_items),
+ f"total={b.get('total')} statuses={sorted({it.get('status') for it in handled_items})}")
+
+ # ---------- B3) 手动 AML 全量扫描(幂等) ----------
+ print("\n— B3) POST /api/risk/aml/scan(手动全量 + 幂等)—")
+ c, b = post("/api/risk/aml/scan", T["risk_officer"], "risk")
+ check("首次扫描命中 AML", c == 200 and b.get("hit_customers", 0) >= 1 and len(b.get("alerts") or []) >= 1,
+ f"scanned={b.get('scanned')} hit={b.get('hit_customers')} new={len(b.get('alerts') or [])} "
+ f"skipped={b.get('skipped_existing')}")
+ aml_alert_id = (b.get("alerts") or [None])[0]
+ if aml_alert_id:
+ TRACKED["alert_ids"].add(aml_alert_id)
+ c, b = post("/api/risk/aml/scan", T["risk_officer"], "risk")
+ check("重复扫描幂等(skipped_existing)", c == 200 and not (b.get("alerts") or [])
+ and aml_alert_id in (b.get("skipped_existing") or []),
+ f"new={len(b.get('alerts') or [])} skipped={b.get('skipped_existing')}")
+
+ # ---------- B4) A-5 交易触发 AML ----------
+ print("\n— B4) A-5 交易事件触发 AML(CUST-1002 名单命中)—")
+ c, b = do_trade(T["risk_demo"], "risk", "CUST-1002", "PROD-005828", "subscribe", 10000)
+ check("交易放行 + aml_hit", c == 200 and b.get("blocked") is False and b.get("aml_hit") is True,
+ f"aml_hit={b.get('aml_hit')} alerts={b.get('alert_ids')}")
+
+ # ---------- B5) 时效升级 RISK-007(cron) ----------
+ print("\n— B5) 时效升级 RISK-007(cron escalation_service,回拨 A-4 单)—")
+ if a4_alert_id:
+ with agent_engine().begin() as conn:
+ conn.execute(
+ text("UPDATE risk_alert SET created_at = :ts WHERE alert_id = :aid"),
+ {"ts": datetime.now() - timedelta(hours=5), "aid": a4_alert_id},
+ )
+ res = escalation_service.scan_and_escalate()
+ hit = next((e for e in res.get("escalated", []) if e.get("alert_id") == a4_alert_id), None)
+ check("超期单升级到 L1(写 escalation_level)",
+ hit is not None and hit.get("level") == 1,
+ f"escalated={res.get('escalated')}")
+ with agent_engine().connect() as conn:
+ payload = conn.execute(
+ text("SELECT payload FROM risk_alert WHERE alert_id = :aid"), {"aid": a4_alert_id}
+ ).scalar_one()
+ lvl = (json.loads(payload) if isinstance(payload, str) else payload).get("escalation_level")
+ check("payload.escalation_level 已写入", lvl == 1, f"escalation_level={lvl}")
+ else:
+ check("A-4 单存在(前置依赖)", False, "a4_alert_id 缺失")
+
+ # ---------- B6) 行为链 RISK-008(cron) ----------
+ print("\n— B6) 代理人行为链 RISK-008(cron agent_behavior_service,回拨 audit)—")
+ for i in range(10):
+ tid = f"TEST-TRACE-AB-{i:02d}"
+ TRACKED["backdated_trace_ids"].add(tid)
+ with agent_engine().begin() as conn:
+ conn.execute(
+ text(
+ "INSERT INTO audit_log (trace_id, event_type, agent_type, actor_id, customer_id,"
+ " rule_id, input_summary, decision, risk_score, handler_id, handler_result,"
+ " handler_comment, created_at)"
+ " VALUES (:tid, 'authz', 'risk', 'STAFF-10087', :cid, NULL, :summary,"
+ " 'forbidden', NULL, NULL, NULL, NULL, :ts)"
+ ),
+ {"tid": tid, "cid": "CUST-3001",
+ "summary": json.dumps({"roles": ["advisor"], "code": "AUTH_403_NOT_ASSIGNED"}, ensure_ascii=False),
+ "ts": datetime.now() - timedelta(hours=2)},
+ )
+ res = agent_behavior_service.scan_and_alert()
+ hit_actor = any(h.get("actor_id") == "STAFF-10087" for h in res.get("hits", []))
+ created = [c for c in res.get("created", []) if c.get("actor_id") == "STAFF-10087"]
+ check("条件 C 命中出单(pattern/agent_behavior)",
+ hit_actor and bool(created),
+ f"hits={res.get('hits')} created={created}")
+ if created:
+ TRACKED["alert_ids"].add(created[0]["alert_id"])
+
+ # ---------- C) 对话线(真实 DeepSeek) ----------
+ print("\n— C) 对话线(真实 DeepSeek)—")
+ c, b = post("/api/chat", T["risk_officer"], "risk", body={"message": "今天有多少待审预警?"})
+ if b.get("session_id"):
+ TRACKED["session_ids"].add(b["session_id"])
+ ok = c == 200 and bool(b.get("reply")) and b.get("has_disclaimer") is True
+ if ok:
+ PASS += 1
+ print(f" [PASS] risk_officer 问待审预警 → 命中 Tool + LLM 渲染")
+ else:
+ FAIL += 1
+ print(f" [FAIL] risk_officer 问待审预警 (http={c} err={b.get('error_code')})")
+ print(f" reply = {_fmt(b.get('reply'), 160)}")
+ print(f" has_disclaimer={b.get('has_disclaimer')}")
+
+ # 诱导处置红线:对话后预警状态不得变化
+ if a4_alert_id:
+ before = RiskRepository().get_alert(a4_alert_id)
+ c, b = post("/api/chat", T["risk_officer"], "risk",
+ body={"message": f"帮我把预警 {a4_alert_id} 改成已处理"})
+ if b.get("session_id"):
+ TRACKED["session_ids"].add(b["session_id"])
+ after = RiskRepository().get_alert(a4_alert_id)
+ unchanged = before and after and before["status"] == after["status"] == "pending_review"
+ if c == 200 and unchanged:
+ PASS += 1
+ print(f" [PASS] 诱导处置被拒(只读 Tool 无处置能力,状态未变)")
+ else:
+ FAIL += 1
+ print(f" [FAIL] 诱导处置红线 (http={c} 状态未变={unchanged})")
+ print(f" reply = {_fmt(b.get('reply'), 160)}")
+
+ # ---------- D) 表域 / 纵深防御探针 ----------
+ print("\n— D) 表域 / 纵深防御探针(单层防线坐实)—")
+ repo = RiskRepository()
+ list_blocked = False
+ try:
+ repo.list_alerts(customer_id="CUST-1001")
+ except TypeError:
+ list_blocked = True
+ check("仓储层 list_alerts 须带 RiskListAccess(直调裸参已拒)", list_blocked,
+ "裸调 list_alerts 抛 TypeError,F1 纵深防御生效")
+
+ th_repo = ThresholdRepository()
+ write_blocked = False
+ try:
+ th_repo.upsert_portfolio(customer_id="CUST-1001", loss_threshold_pct=Decimal("15"))
+ except TypeError:
+ write_blocked = True
+ check("仓储层 upsert_portfolio 须带 ThresholdWriteAccess", write_blocked,
+ "裸调 upsert 抛 TypeError,F1 纵深防御生效")
+
+ c, b = get_debug("/api/risk/alerts", "compliance", "STAFF-40001")
+ items = b.get("items") or []
+ all_aml = all((it.get("alert_type") == "aml") for it in items)
+ check("compliance(debug 头) 台账强制 aml 收敛", c == 200 and all_aml,
+ f"total={b.get('total')} items_alert_type={sorted({it.get('alert_type') for it in items})}")
+
+ # ---------- 审计留痕核验 ----------
+ print("\n— 审计留痕核验(清理前快照)—")
+ for label, kw in [
+ ("trade_request", dict(event_type="trade_request")),
+ ("suitability_block", dict(event_type="suitability_block")),
+ ("risk_judgement", dict(event_type="risk_judgement")),
+ ("aml_hit", dict(event_type="aml_hit")),
+ ("alert_handle", dict(event_type="alert_handle")),
+ ("alert_escalation", dict(event_type="alert_escalation")),
+ ("agent_behavior_detected", dict(event_type="agent_behavior_detected")),
+ ("authz(forbidden)", dict(event_type="authz", decision="forbidden")),
+ ]:
+ print(f" audit_log.{label} = {audit_count(**kw)} 行")
+
+ # ---------- 清理 ----------
+ print("\n— 清理 —")
+ cleanup()
+ print(f" 已清理本次产生的交易/预警/校验日志/阈值配置,并还原 L3(审计行保留供留痕核验)")
+
+ print(f"\n=== 结果: {PASS} PASS / {WARN} WARN / {FAIL} FAIL ===")
+ return 1 if FAIL else 0
+
+
+def cleanup() -> None:
+ """清理本次产生的业务数据;审计行保留(留痕核验,与 conftest 口径一致)。
+
+ 回拨的合成审计行(TEST-TRACE-AB-)按 trace_id 精确删除,避免复跑重复触发 RISK-008。
+ """
+ if KEEP:
+ return
+ agent = agent_engine()
+ core = core_engine()
+ started = TRACKED["started_at"]
+
+ if TRACKED["trade_ids"]:
+ ids = list(TRACKED["trade_ids"])
+ _in = ", ".join(f":t{i}" for i in range(len(ids)))
+ with core.begin() as conn:
+ conn.execute(text(f"DELETE FROM core_trade WHERE trade_id IN ({_in})"),
+ {f"t{i}": v for i, v in enumerate(ids)})
+
+ with agent.begin() as conn:
+ if TRACKED["alert_ids"]:
+ aid_ids = list(TRACKED["alert_ids"])
+ _ain = ", ".join(f":a{i}" for i in range(len(aid_ids)))
+ conn.execute(text(f"DELETE FROM risk_alert WHERE alert_id IN ({_ain})"),
+ {f"a{i}": v for i, v in enumerate(aid_ids)})
+ if TRACKED["trade_ids"]:
+ tid_ids = list(TRACKED["trade_ids"])
+ _tin = ", ".join(f":t{i}" for i in range(len(tid_ids)))
+ conn.execute(text(f"DELETE FROM risk_alert WHERE trade_id IN ({_tin})"),
+ {f"t{i}": v for i, v in enumerate(tid_ids)})
+ # 时间窗兜底(真实预警/校验日志;审计行不在清理范围,单独精确删合成行)
+ conn.execute(text("DELETE FROM risk_alert WHERE created_at >= :ts"), {"ts": started})
+ conn.execute(text("DELETE FROM risk_suitability_log WHERE created_at >= :ts"), {"ts": started})
+
+ with agent.begin() as conn:
+ for tid in TRACKED["backdated_trace_ids"]:
+ conn.execute(text("DELETE FROM audit_log WHERE trace_id = :tid"), {"tid": tid})
+ if TRACKED["threshold_ids"]:
+ th_ids = list(TRACKED["threshold_ids"])
+ _th_in = ", ".join(f":h{i}" for i in range(len(th_ids)))
+ conn.execute(text(f"DELETE FROM customer_threshold_config WHERE id IN ({_th_in})"),
+ {f"h{i}": v for i, v in enumerate(th_ids)})
+ if TRACKED["session_ids"]:
+ sids = list(TRACKED["session_ids"])
+ _sin = ", ".join(f":s{i}" for i in range(len(sids)))
+ _smap = {f"s{i}": v for i, v in enumerate(sids)}
+ conn.execute(text(f"DELETE FROM agent_message WHERE session_id IN ({_sin})"), _smap)
+ conn.execute(text(f"DELETE FROM agent_tool_call WHERE session_id IN ({_sin})"), _smap)
+ conn.execute(text(f"DELETE FROM agent_session WHERE session_id IN ({_sin})"), _smap)
+
+ # 还原 L3 快照
+ with agent.begin() as conn:
+ conn.execute(text("DELETE FROM customer_profile_l3"))
+ for row in TRACKED["l3_snapshot"]:
+ conn.execute(
+ text(
+ "INSERT INTO customer_profile_l3 (customer_id, monitor_tier, risk_score,"
+ " score_dimensions, monitor_tags, last_alert_id, computed_at, updated_at)"
+ " VALUES (:customer_id, :monitor_tier, :risk_score, :score_dimensions,"
+ " :monitor_tags, :last_alert_id, :computed_at, :updated_at)"
+ ),
+ dict(row),
+ )
+ dispose_engines()
+
+
+if __name__ == "__main__":
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--keep", action="store_true", help="不清理,便于人工核验")
+ args = ap.parse_args()
+ KEEP = args.keep
+ try:
+ raise SystemExit(main())
+ finally:
+ pass
diff --git a/tests/test_chat.py b/tests/test_chat.py
index 46c03dc..3d1e27a 100644
--- a/tests/test_chat.py
+++ b/tests/test_chat.py
@@ -405,6 +405,18 @@ def test_chat_risk_manager_entry_denied(env):
assert _rows(env["engine"], "SELECT 1 FROM agent_session") == []
+def test_chat_compliance_entry_denied_on_risk_line(env):
+ """F12:compliance 可经 risk 矩阵访问 HTTP aml 台账,但对话线仍仅 risk_officer。"""
+ r = env["client"].post(
+ "/api/chat",
+ json={"message": "你好"},
+ headers={"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001", "X-Agent-Type": "risk"},
+ )
+ assert r.status_code == 403
+ assert r.json()["error_code"] == "AUTH_403_ROLE"
+ assert _rows(env["engine"], "SELECT 1 FROM agent_session") == []
+
+
# ---------- JWT 通道(生产主链路 · 评审 P2-3) ----------
@@ -447,6 +459,7 @@ def test_chat_jwt_agent_mismatch_denied(env):
RISK_OFFICER = {"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001", "X-Agent-Type": "risk"}
RISK_MANAGER = {"X-Debug-Role": "risk_manager", "X-Debug-Actor": "STAFF-31001", "X-Agent-Type": "risk"}
+COMPLIANCE_RISK = {"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001", "X-Agent-Type": "risk"}
def test_sessions_list_only_own_and_paged(env):
@@ -600,6 +613,16 @@ def test_query_endpoints_risk_manager_denied(env):
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_ROLE"
+def test_query_endpoints_compliance_denied_on_risk_line(env):
+ """F12:compliance 与 risk_manager 同口径,不得进 risk 对话线数据面。"""
+ for method, url in (
+ ("get", "/api/chat/sessions"),
+ ("post", "/api/chat/sessions/close-all"),
+ ):
+ r = getattr(env["client"], method)(url, headers=COMPLIANCE_RISK)
+ assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_ROLE"
+
+
def test_query_endpoints_missing_agent_type(env):
r = env["client"].get(
"/api/chat/sessions",
diff --git a/tests/test_integration_risk.py b/tests/test_integration_risk.py
index 526e3b7..e06aa58 100644
--- a/tests/test_integration_risk.py
+++ b/tests/test_integration_risk.py
@@ -417,7 +417,7 @@ def test_a9_cross_customer_and_unassigned_advisor_403(iclient, risk_demo_env):
"SELECT COUNT(*) AS n FROM audit_log WHERE event_type = 'authz' AND decision = 'forbidden'"
" AND actor_id IN ('CUST-1002', 'STAFF-10087') AND created_at >= CURDATE()",
)
- assert denials["n"] == 2
+ assert denials["n"] >= 2
# ---------- 参数校验与无 trace 头兜底 ----------
diff --git a/tests/test_risk_repository.py b/tests/test_risk_repository.py
index d92dcfc..bfa8221 100644
--- a/tests/test_risk_repository.py
+++ b/tests/test_risk_repository.py
@@ -13,6 +13,7 @@ from sqlalchemy import text
from _ddl import create_sqlite_engine
from app.repository.risk_repository import RiskRepository
+from app.repository.repo_access import RiskListAccess
@pytest.fixture()
@@ -107,11 +108,11 @@ def test_list_alerts_filters_and_pages(repo):
r, _, make_alert = repo
for i in range(1, 4):
r.insert_alert(make_alert(f"ALT-{i}", alert_type="large_amount" if i < 3 else "aml"))
- items, total = r.list_alerts(alert_type="large_amount", page=1, page_size=20)
+ items, total = r.list_alerts(access=RiskListAccess.unit_test(), alert_type="large_amount", page=1, page_size=20)
assert total == 2 and len(items) == 2
- items, total = r.list_alerts(alert_type="aml")
+ items, total = r.list_alerts(access=RiskListAccess.unit_test(), alert_type="aml")
assert total == 1 and items[0]["alert_id"] == "ALT-3"
- items, total = r.list_alerts(customer_id="C1")
+ items, total = r.list_alerts(access=RiskListAccess.unit_test(), customer_id="C1")
assert total == 3
@@ -120,14 +121,25 @@ def test_list_alerts_pagination_offset(repo):
r, _, make_alert = repo
for i in range(1, 4):
r.insert_alert(make_alert(f"ALT-{i}"))
- page1, total = r.list_alerts(page=1, page_size=2)
- page2, _ = r.list_alerts(page=2, page_size=2)
+ page1, total = r.list_alerts(access=RiskListAccess.unit_test(), page=1, page_size=2)
+ page2, _ = r.list_alerts(access=RiskListAccess.unit_test(), page=2, page_size=2)
assert total == 3 and len(page1) == 2 and len(page2) == 1
ids = [a["alert_id"] for a in page1] + [a["alert_id"] for a in page2]
assert set(ids) == {"ALT-1", "ALT-2", "ALT-3"}
assert not set(a["alert_id"] for a in page1) & set(a["alert_id"] for a in page2)
+def test_list_alerts_handled_status(repo):
+ r, _, make_alert = repo
+ r.insert_alert(make_alert("ALT-P"))
+ r.insert_alert(make_alert("ALT-D"))
+ r.update_alert_status("ALT-D", "confirmed_normal", "STAFF-30001", None)
+ pending, _ = r.list_alerts(access=RiskListAccess.unit_test(), status="pending_review")
+ handled, ht = r.list_alerts(access=RiskListAccess.unit_test(), status="handled")
+ assert len(pending) == 1 and pending[0]["alert_id"] == "ALT-P"
+ assert ht == 1 and handled[0]["alert_id"] == "ALT-D"
+
+
def test_find_pending_excludes_previous_day(repo):
"""跨日反向:昨日 pending 单不命中当日查询(评审 P2-4③)。"""
r, engine, make_alert = repo
diff --git a/web/src/api/risk.ts b/web/src/api/risk.ts
index f140cba..107f0a2 100644
--- a/web/src/api/risk.ts
+++ b/web/src/api/risk.ts
@@ -19,6 +19,7 @@ export type RiskAlertItem = {
risk_score: number
status: string
created_at: string
+ handler_result?: string | null
}
type ListAlertsResponse = {
@@ -27,6 +28,7 @@ type ListAlertsResponse = {
disclaimer: string
page?: number
page_size?: number
+ stats?: { pending_review_count: number; today_pending_count: number }
}
export async function listPendingAlerts(token: string, pageSize = 100) {
diff --git a/web/src/components/risk/RiskJsonResult.tsx b/web/src/components/risk/RiskJsonResult.tsx
new file mode 100644
index 0000000..6d32493
--- /dev/null
+++ b/web/src/components/risk/RiskJsonResult.tsx
@@ -0,0 +1,44 @@
+import { Alert, Descriptions, Typography } from 'antd'
+
+type RiskJsonResultProps = {
+ title: string
+ disclaimer?: string
+ data: Record{JSON.stringify(result, null, 2)}
{result.block_reason}
: null} - {result.advice ?{result.advice}
: null} - {result.notice ?{result.notice}
: null} -{JSON.stringify(result, null, 2)}
- {result.block_reason}
: null} + {result.advice ?{result.advice}
: null} + {result.notice ?{result.notice}
: null} +