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 @@

JinRong 项目现状

-

四角色、JWT 双通道、数据从哪来、merger 做到哪——含模块 8 答辩动线(804 pytest 基线)。

+

四角色、JWT 双通道、数据从哪来、merger 做到哪——含模块 8 答辩动线(825 pytest 基线)。

8 模块 · 入门首选
diff --git a/docs/course/jinrong-module-risk/index.html b/docs/course/jinrong-module-risk/index.html index b68180d..0e9a362 100644 --- a/docs/course/jinrong-module-risk/index.html +++ b/docs/course/jinrong-module-risk/index.html @@ -49,7 +49,7 @@ 风控专员 Demo STAFF-30001 带 risk_officer + risk_demo 角色: 能看预警台账、处置、跑 AML 扫描,还能在模拟交易页触发规则引擎。 本地登录页一键切换,改 JWT 角色后需重新登录。 - 仓库基线 merger · python -m pytest → 804 passed。 + 仓库基线 merger · python -m pytest → 825 passed。

@@ -409,7 +409,7 @@

- 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 804 passed。 + 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 825 passed。

diff --git a/docs/course/jinrong-module-risk/modules/01-capabilities.html b/docs/course/jinrong-module-risk/modules/01-capabilities.html index 291610a..8f881a4 100644 --- a/docs/course/jinrong-module-risk/modules/01-capabilities.html +++ b/docs/course/jinrong-module-risk/modules/01-capabilities.html @@ -6,7 +6,7 @@ 风控专员 Demo STAFF-30001 带 risk_officer + risk_demo 角色: 能看预警台账、处置、跑 AML 扫描,还能在模拟交易页触发规则引擎。 本地登录页一键切换,改 JWT 角色后需重新登录。 - 仓库基线 merger · python -m pytest → 804 passed。 + 仓库基线 merger · python -m pytest → 825 passed。

diff --git a/docs/course/jinrong-module-risk/modules/03-tools-rest.html b/docs/course/jinrong-module-risk/modules/03-tools-rest.html index 65cd4f1..f000a7a 100644 --- a/docs/course/jinrong-module-risk/modules/03-tools-rest.html +++ b/docs/course/jinrong-module-risk/modules/03-tools-rest.html @@ -128,7 +128,7 @@

- 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 804 passed。 + 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 825 passed。

diff --git a/docs/course/jinrong-overview/index.html b/docs/course/jinrong-overview/index.html index 7b5b230..b08893c 100644 --- a/docs/course/jinrong-overview/index.html +++ b/docs/course/jinrong-overview/index.html @@ -412,7 +412,7 @@

模块 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。

@@ -444,7 +444,7 @@
3

.\scripts\dev\start-redis.ps1 → 6380

4

uvicorn app.main:app --reload :8000

5

cd web && npm run dev → :5173 代理 API

-
6

python -m pytest 验收 804 绿

+
6

python -m pytest 验收 825 绿

改后端后: 若浏览器问数 404,多半是 :8000 的 uvicorn 没重启,OpenAPI 里还缺新路由。 @@ -674,7 +674,7 @@

5~8 分钟 Demo
+ 必背铁律

对照 docs/答辩/答辩知识点清单.md 的滚动版:先讲清四角色不互调 LLM,再按动线演示,最后主动说边界。 - 基线 804 pytest · 19 Vitest · 分支 merger。 + 基线 825 pytest · 22 Vitest · 分支 merger。

diff --git a/docs/course/jinrong-overview/modules/05-status.html b/docs/course/jinrong-overview/modules/05-status.html index 2199b85..611f6e0 100644 --- a/docs/course/jinrong-overview/modules/05-status.html +++ b/docs/course/jinrong-overview/modules/05-status.html @@ -3,7 +3,7 @@

模块 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。

@@ -35,7 +35,7 @@
3

.\scripts\dev\start-redis.ps1 → 6380

4

uvicorn app.main:app --reload :8000

5

cd web && npm run dev → :5173 代理 API

-
6

python -m pytest 验收 804 绿

+
6

python -m pytest 验收 825 绿

改后端后: 若浏览器问数 404,多半是 :8000 的 uvicorn 没重启,OpenAPI 里还缺新路由。 diff --git a/docs/course/jinrong-overview/modules/08-defense.html b/docs/course/jinrong-overview/modules/08-defense.html index a21dad8..8d5829f 100644 --- a/docs/course/jinrong-overview/modules/08-defense.html +++ b/docs/course/jinrong-overview/modules/08-defense.html @@ -4,7 +4,7 @@

5~8 分钟 Demo
+ 必背铁律

对照 docs/答辩/答辩知识点清单.md 的滚动版:先讲清四角色不互调 LLM,再按动线演示,最后主动说边界。 - 基线 804 pytest · 19 Vitest · 分支 merger。 + 基线 825 pytest · 19 Vitest · 分支 merger。

diff --git a/docs/frontend/FRONTEND-HANDOFF.md b/docs/frontend/FRONTEND-HANDOFF.md index 83d9c6a..9dabe31 100644 --- a/docs/frontend/FRONTEND-HANDOFF.md +++ b/docs/frontend/FRONTEND-HANDOFF.md @@ -25,6 +25,10 @@ uvicorn app.main:app --reload Vite 开发服务器默认使用 `http://127.0.0.1:5173`,并将 `/api` 代理到 `http://127.0.0.1:8000`。路由使用 `HashRouter`,页面 URL 形如 `http://127.0.0.1:5173/#/app/customer/home`。 +**环境自检(2026-09-11):** 后端 `GET /api/ready`(Redis + 关键路由)· 前端顶栏 `DevReadyBanner`(开发态提示,不替代生产探针)。 + +**测试基线:** 全仓 `python -m pytest` → **825 passed**, 1 skipped · `cd web && npm run test` → **22** Vitest。 + ## 3. 依赖方向 ```text @@ -162,7 +166,7 @@ Tailwind 负责: - 客户显示总市值、累计盈亏、昨日收益和持仓数;眼睛按钮只切换 `****`,不改变表格行数据;图表、分页、排序、详情和助手链接可用。 - 理财师显示客户数、总 AUM 和最大客户 fallback;客户市值降序、客户与 Chat 链接可用。 - 分析员显示产品数、上涨/下跌/平盘;按日变化绝对值排序,行情/问数/Chat 链接可用。 -- 风控显示待审数量和 API disclaimer;无预警时显示 Empty,不出现假图表或假表格;有预警时显示图表、处置和助手链接。 +- 风控显示待审数量和 API disclaimer;台账 **`GET /alerts` 的 `stats.pending`** 与「已处置」聚合筛选(`status=handled`);适当性/AML/模拟页为结构化结果(非裸 JSON);无预警时显示 Empty,不出现假图表或假表格;有预警时显示图表、处置和助手链接。 - **四角色 Chat(含客户 SSE)**:…侧栏每条可删除(调用 close API);侧栏**仅展示 active**;**清空历史会话** 调用 `POST /api/chat/sessions/close-all`(消息仍留库,仅关闭续聊)。 - API 错误态显示 code、message、trace ID 和重试;loading 态保留 shell 与 skeleton。 - 检查正收益红色、负收益绿色、零值灰色。 @@ -206,7 +210,7 @@ npm run lint | 理财师 | `#/app/advisor/home` | 名下客户 AUM 分布 | | 分析 | `#/app/analyst/home` | 市场/产品概览 | | 风控 | `#/app/risk/home` | 预警类型分布 | -| 问数 | `#/app/analytics/query` | MetricCard(`/api/analyst/dashboard`)· 无钻取(D-12 未做) | +| 问数 | `#/app/analytics/query` | MetricCard(`/api/analyst/dashboard`)· **抽样溯源 / 转人工**(N-03/N-07)· 无钻取(D-12 未做) | **为何每次进页都转圈:** @@ -216,4 +220,4 @@ npm run lint **明日优化方向(见 `TODO.md` §2026-09-11):** 批量净值 API · 客户端 stale-while-revalidate · 可选 Dashboard 短 TTL 缓存(后端)。 -**客户「产品趋势」:** 客户助手 Chat 仅 **C-05 最新净值**;走势/预测 reject。统计类趋势走 **问数工作台** + customer **`self` 域**(`数据分析Agent-合并说明.md` §9.6)。分析对话页仍为占位。 +**客户「产品趋势」:** 客户助手 Chat 仅 **C-05 最新净值**;走势/预测 reject。统计类趋势走 **问数工作台** + customer **`self` 域**(`数据分析Agent-合并说明.md` §9.6)。**分析对话 URL 已重定向问数**。 diff --git a/docs/memory/ENVIRONMENT.md b/docs/memory/ENVIRONMENT.md index 9b7b40b..d52efd3 100644 --- a/docs/memory/ENVIRONMENT.md +++ b/docs/memory/ENVIRONMENT.md @@ -25,7 +25,7 @@ - **内部分析**:SQL 或 IT 导表。 - **风控**:事后/T+1 发现异常;适当性靠交易前规则;AML 批处理或人工。 -Agent **尚未上线**;后端风控全链路 + Wave 0 共用底座 + 代销平台 API v0.1 已在 **`merger` 分支**集成(AL-09 完成,**530 pytest 绿**):事件线 B1~B9b · 对话线 C1~C6 · 知识库 T-21 · chat sessions/stream · 平台 REST v0.1;客户/代理人/分析 Agent 仍为 chat 骨架,无独立业务 Tool;无生产接入。 +Agent **尚未上线**;后端已在 **`merger` 分支**集成(AL-09 + 客服 Wave3 + 问数 S3/D-06/N-03/N-07 + 风控 TEST-RISK-001 修复 + 平台 API v0.1 + `/api/ready`):**`python -m pytest` → 825 passed 1 skipped**(集成需本机 MySQL/Redis);无生产 Core/托管接入。代理人/分析 **chat 骨架**仍为主,问数/客服/风控已有独立业务链路。 ------ diff --git a/docs/memory/FLOW.md b/docs/memory/FLOW.md index 5159853..9c41fea 100644 --- a/docs/memory/FLOW.md +++ b/docs/memory/FLOW.md @@ -43,9 +43,10 @@ ⑥ 验证 uvicorn app.main:app --reload GET http://127.0.0.1:8000/health → {"status":"ok"} + GET http://127.0.0.1:8000/api/ready → Redis + 路由自检(前端 DevReadyBanner) cd web && npm run dev → 5173 代理 8000 - python -m pytest → 816 passed 1 skipped - 问数答辩前:见 `docs/答辩/DEMO-SOP-问数.md`(灌 metric+template 种子 · battery 可选) + python -m pytest → 825 passed 1 skipped + 问数答辩前:见 `docs/答辩/DEMO-SOP-问数.md`(`scripts/dev/seed_analyst.ps1` · battery 可选) RBAC 联调账号:scripts/dev/rbac-seed-reference.md ``` @@ -60,11 +61,11 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md Client → Gateway(JWT/RBAC) → api/chat → agent_service(LangGraph) → Tools → 存储 → 响应 + audit_log ``` -当前:**风控全链路 + Wave 0 共用底座 + 代销平台 v0.1 + AL-09 + 客服 S2 Wave3 + 数据分析 D-06 + 前端 P0 Demo(2026-09-10,`merger`)**。**测试基线:`python -m pytest` → 804 passed 1 skipped** · **`web/` 19 Vitest 绿 · `npm run build` 绿**。 +当前:**风控全链路 + Wave 0 共用底座 + 代销平台 v0.1 + AL-09 + 客服 S2 Wave3 + 数据分析 D-06/N-03/N-07 + 前端 P0 Demo(2026-09-11,`merger`)**。**测试基线:`python -m pytest` → 825 passed 1 skipped** · **`web/` 22 Vitest 绿 · `npm run build` 绿**。 -**下一步(统筹 P1):** merger commit · 20 题 battery · 客服验收 · analyst D-09/N-03/N-07 · **客服** L1/L2 Redis(风控不做 L1/L2)。 +**下一步(统筹 P1):** merger commit · 20 题 battery · 客服/风控 E2E 详测 · analyst **D-09 多轮** · **客服** L2 Redis(风控不做 L1/L2)。 -**本机已就位状态(2026-09-10):** `.env` 含 `REDIS_URL=redis://127.0.0.1:6380/0`;pytest **804 绿**;问数 D-06 结果+模板缓存已接;前端四角色 Demo 可 walkthrough。 +**本机已就位状态(2026-09-11):** `.env` 含 `REDIS_URL=redis://127.0.0.1:6380/0`;pytest **825 绿**;问数 D-06 + sample/escalate;`GET /api/ready`;前端四角色 Demo 可 walkthrough。 **本机已知坑:** `mysql.exe` 不在 PATH;`reset.ps1` 自动化用 `MYSQL_PWD`;**6379 常被 Windows Redis 3.x 占用** → 项目 Docker 映射 **6380**;Milvus 中文路径需英文 `MILVUS_URI`;问数模板需灌 `seed-analyst-query-templates.sql` 才见蓝色「模板命中」标签;`battery_report.json` **不入库**(`.gitignore`)。 diff --git a/docs/memory/FRAMEWORK.md b/docs/memory/FRAMEWORK.md index 1e9a17e..14a1211 100644 --- a/docs/memory/FRAMEWORK.md +++ b/docs/memory/FRAMEWORK.md @@ -35,10 +35,10 @@ | 模块 | 职责 | 依赖 | 代码状态 | | --- | --- | --- | --- | | Agent Gateway / Auth SDK | JWT、RBAC、归属校验 | Redis、MySQL customer_advisor_rel | **已实现(T-01 + AL-09)**:模块 `service/auth_service.py` + `api/deps.py`;宿主 `gateway/` 四件套并存;`/api/auth/login` 统一走 `issue_dev_token`;S2 接缝 `auth_adapter.module_auth_from_host` | -| 客户财富 Agent | L1 画像、事实查询、阈值提醒 | Core RO、Milvus 产品库 | **S2 + Wave3(2026-09-10)**:Chat SSE · 1B/R1 · C-04/C-05/C-11 · **804 pytest** +| 客户财富 Agent | L1 画像、事实查询、阈值提醒 | Core RO、Milvus 产品库 | **S2 + Wave3(2026-09-11)**:Chat SSE · 1B/R1 · C-04/C-05/C-11 · L1 懒读 · **825 pytest** | 代理人助手 Agent | L2 画像、RAG、草稿 | L1 只读、Milvus | 空壳 service(chat 骨架已通) | -| 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | **S3+P2+D-06+D-09 子集(2026-09-11)**:interpret 拆分 · sql_guard **Q17** · 答辩模板种子 · **816 pytest** | -| 风控监测 Agent | 预警、L3、R-02 适当性 | 交易事件、AML 名单 | **已实现 B1~B9b + C1~C6(FR-1~10)**:事件线 + 对话线 + 集中度/时效升级/代理人行为链;**AL-09 已并入 `merger` 分支** | +| 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | **S3+P2+D-06+D-09 子集 + N-03/N-07(2026-09-11)**:interpret 拆分 · sql_guard **Q17** · sample/escalate · **825 pytest** | +| 风控监测 Agent | 预警、L3、R-02 适当性 | 交易事件、AML 名单 | **已实现 B1~B9b + C1~C6(FR-1~10)** + **TEST-RISK-001 修复**;**AL-09 已并入 `merger` 分支** | | Core 只读层 | L0 事实查询 | `jinrong_core` | **已实现 + 已接对话 Tool(T-04)**:core_ro 经 app/tool/core_tools.py 三只读 Tool(L0/持仓/流水)进 chat;风控扩展查询照旧 | | 共用底座 | 会话、审计、输入防护 | MySQL 11 表 + Redis | **已接入(2026-09-07)**:会话(T-06 session_repository + memory_service 窗口)、审计中间件(T-02 http_access + input_guard_log 双写)、agent_tool_call Tool 留痕(T-04)、输入防护(T-03 input_guard:注入词表纯函数检测 + oversize + Redis 固定窗口限流,chat 链路 限流→注入/超长→归属) | | 对话编排 | LangGraph StateGraph + DeepSeek | langgraph/langchain-openai | **已实现**:customer/advisor/risk/analyst 四线;**analyst 问数**独立 `analyst_agent`(非 chat StateGraph) | diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md index 74fde35..c90c1c9 100644 --- a/docs/memory/ITERATION.md +++ b/docs/memory/ITERATION.md @@ -30,5 +30,7 @@ | 2026-09-10 | **前端 E2E 问题清单收口**:`AuthProvider` 修登录崩溃/退出死循环 · Redis `socket_connect_timeout` 0.5s · 预警筛选防抖 · I18N formatter · 对话 Markdown 粗体 · A11Y `index.html` · AntD `` | `docs/整体测试/前端整体测试交接.md` 登记项 | web · `database.py` · MEMORY | | 2026-09-10 | **答辩/课程**:`docs/答辩/答辩知识点清单.md` · 总览模块 8 · 问数课 D-06 模块 5 · 导览中心更新 | 用户答辩准备 | docs/course · MEMORY | | 2026-09-10 | **客服问候修复**:「你好」关键词 → `chit_chat` · LLM 失败 `CHITCHAT_DEGRADED_TEXT`(非 FALLBACK)· 游客问候快路由 | 用户反馈 Chat 无法回答问候 | customer_prompts · customer_service · visitor · tests | +| 2026-09-11 | **TEST-RISK-001 修复**:`RiskListAccess`/`ThresholdWriteAccess` · service_risk 只读 GET · compliance 进 risk 矩阵 · STAFF-31001 · handled 筛选 · 台账 stats · 前端标签/409/结构化 · SSE ping · **825 pytest** | 沙盘 TEST-LOG v1.1 | `repo_access` · `risk.py` · `deps.py` · web risk pages | +| 2026-09-11 | **问数 N-03/N-07 + 自检**:`GET .../sample` · `POST /escalate` · `GET /api/ready` · DevReadyBanner | Superpowers 批次 | `analyst.py` · `ready.py` · web | | 2026-09-11 | **TEST-AN-001 RBAC 修复**:sql_guard A/B · `_audit_terminal` · TEST-LOG v1.2 · **820 pytest** | 沙盘测试文档 | `sql_guard` · `analyst_agent` | | 2026-09-10 | **TODO 日终清单 + 优化 TODO 补全**:硬伤盘点落账 · 2026-09-10 已完成/进行中/将要做 | 用户要求 | TODO · ITERATION | diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 88dbe9b..4e773bd 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -9,7 +9,7 @@ **项目是什么:** 金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座;**不**互调 LLM,跨 Agent 走 L1/L2/L3 画像与预警表。 -**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 Wave3 + 数据分析 D-06/D-09 解读拆分 + sql_guard RBAC 修复** · **820 pytest** · **22 Vitest** · **Redis @ 6380** · **`merger` 未 commit** +**当前进度:** 需求与表设计已定 · **风控 TEST-RISK-001(含 F12)** · **827 pytest** · **22 Vitest** · **Redis @ 6380** · **`merger` 未 commit** **工作分支:** 团队开发在 **`merger`**;历史 `risk-control-agent` 交付冻结。 @@ -21,10 +21,11 @@ | `app/api/auth.py` | **已实现(AL-09)** | Mock 登录;签发走 `auth_service.issue_dev_token`(与模块 API 同一 issuer) | | `app/api/auth_adapter.py` | **已接线(S2)** | `module_auth_from_host()`:宿主 AuthContext → 模块 AuthContext | | `app/gateway/` | **宿主 Wave 0(并存)** | jwt_service / auth_deps / rbac / ownership;模块 API 走 `deps.py`,禁止模块 import gateway | -| `app/api/risk.py` `simulate.py` `deps.py` | **已实现** | 风控 4 API + 模拟网关路由 + **JWT 鉴权工厂(T-01:Bearer 全环境优先;debug 头仅 dev+无 RS256 公钥时兜底;AGENT_ACCESS_MATRIX 准入)** | +| `app/api/risk.py` `simulate.py` `deps.py` | **已实现** | 风控 4 API + 模拟网关 · **compliance/service_risk 矩阵(F2/F3)** · 台账 **`stats` 待审计数(F7)** | | `app/api/chat.py` `audit_middleware.py` | **已实现** | POST /api/chat · **POST /api/chat/stream(customer 分流 → prepare_customer_stream)** · visitor · sessions 三端点 | | `app/api/customers.py` `products.py` `advisors.py` `staff.py` `compliance.py` | **已实现(v0.1)** | 代销平台 REST;`get_platform_auth_context`(无 X-Agent-Type);Service 层 `app/service/platform/` | -| `app/api/analyst.py` `analyst_auth_adapter.py` | **已实现(S3+D-06+D-09 子集)** | 问数 `POST /api/analyst/chat`(默认 `interpret=false` 仅表)· **`POST /api/analyst/interpret` 按需解读** · dashboard/assets/metrics · **`get_platform_auth_context`** · `meta.template_hit` / `cache_hit` | +| `app/api/analyst.py` `analyst_auth_adapter.py` | **已实现(S3+D-06+D-09 子集 + N-03/N-07)** | 问数 `POST /api/analyst/chat`(默认 `interpret=false`)· **`POST /api/analyst/interpret`** · **`GET /query/{trace_id}/sample`** · **`POST /escalate`** · dashboard/assets/metrics | +| `app/api/ready.py` | **已实现(2026-09-11)** | `GET /api/ready`:Redis + 关键路由自检;前端 `DevReadyBanner` | | `app/service/analyst_agent.py` `template_service.py` `cache_service.py` | **已实现(S3+D-06)** | NL2SQL 编排 · guardrail · **结果缓存(表世代键)+ 写侧 bump**(`analyst_cache_invalidate`)· **模板填参**(published `analytics_query_template`) | | `app/api/knowledge.py` `admin.py` | 空壳 | 待审计查询台与知识库 API(T-21 拍板一期只做脚本入库,上传/重建端点不做) | | `app/service/platform/` | **已实现(v0.1)** | 封装 core_ro + `PLATFORM_RESPONSE_DESENSITIZE` 脱敏开关 | @@ -37,7 +38,9 @@ | `app/repository/session_repository.py` | **已实现(T-06/T-04 + 前端接入 B/C)** | agent_session / agent_message / agent_tool_call 读写;**方案 B 增 `list_sessions`(分页+total)/ `list_messages_page`(seq 升序分页,勿与 LLM 窗口的 `list_messages` 混用)/ `close_session`(条件更新防并发)**;**方案 C 增 `insert_turn`(user+assistant 同事务落库 + 事务内取 seq,修评审 P0/P1)** | | `app/gateway/` | **已实现** | 模拟交易网关(仅 gateway_repository 可 INSERT core_trade,B5) | | `app/repository/core_ro.py` | **已实现** | Core 只读 SELECT(含风控扩展 sum_trades_on_date / list_trades_range / list_active_customers);**阶段一吸收 main:check_suitability(C×R 矩阵判定,CURDATE() 改 Python 端 `_is_expired`)/ list_products_for_customer / list_holdings 合并(limit=500+新列)/ list_trades / get_customer_l0 扩列版** | -| `app/repository/risk_repository.py` | **已实现** | risk_alert / risk_suitability_log / L3 / risk_aml_list / audit_log / input_guard_log 读写 | +| `app/repository/risk_repository.py` | **已实现** | risk_alert / … · **`list_alerts` 须 `RiskListAccess`(F1)** · **`status=handled` 聚合筛选** · `count_pending_stats` | +| `app/repository/repo_access.py` | **已实现(2026-09-11)** | `RiskListAccess` / `ThresholdWriteAccess` 仓储调用凭证 | +| `app/repository/threshold_repository.py` | **已实现** | 阈值配置 · **`upsert_portfolio` 须 `ThresholdWriteAccess`** | | `app/utils/` | **基本就绪** | trace(trace_id+request_id 双 contextvar)/ desensitize / db(引擎工厂)/ response(统一错误体+4xx/500 handler)/ exceptions(含 ApiError)已实现;logger 占位 | | `app/config/database.py` | **已实现** | MySQL 双引擎 + **Redis 单例**(`REDIS_URL` · **RESP2 `protocol=2`** 兼容 Docker Redis 7 / 旧 Windows Redis 3) | | `app/config/settings.py` | **已实现** | 双库 + risk_* 阈值 + JWT + **customer/visitor/profile** 字段 + `kb_root_dir` | @@ -45,9 +48,9 @@ | `app/service/threshold_service.py` | **已实现(C-04)** | L1 `threshold_pref_summary` → `customer_threshold_config`;持仓查询加权盈亏 vs 阈值 → `customer_notify_log` | | `app/utils/sanitize_postprocess.py` | **已实现(1B 共用)** | 数据查询 intent(含 nav_query)违禁 → 回退 fact_text;RAG/闲聊 → COMPLIANCE_REJECT;均不自动 transfer | | `scripts/core/*.sql` + `reset.ps1` | **已实现** | Core 模拟库 DDL + 种子 | -| `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | AML 种子 · **`prepare_all.ps1` 一键灌库** · `seed-analyst-query-templates.sql`(模板缓存)· `run_query_battery.py`(**不入库**)· `start-redis.ps1` | +| `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | **`prepare_all.ps1`** · **`push_threshold_alerts.py`** · **`seed_analyst.ps1`** · **`sandbox_risk_test.py`** · `run_query_battery.py`(**不入库**) | | `scripts/sync/*.py` | **已实现** | 归属同步 + Neo4j 全图 | -| `tests/` | **已实现** | **820 用例** 1 skipped(Wave6 sql_guard RBAC + interpret + customer) | +| `tests/` | **已实现** | **827 用例** 1 skipped | | `docs/答辩/` | **答辩提纲 + Demo SOP** | `答辩知识点清单.md` · **`DEMO-SOP-问数.md`**(套餐 ①)· spec `docs/superpowers/specs/2026-09-11-defense-stable-package.md` | | `docs/course/` | **交互课程集** | 导览中心 + 总览 **8 模块** + 问数 **5 模块**(D-06)+ 风控深潜 **7 模块** · 与答辩清单同步 | | `docs/PRD/PRD-风控监测Agent.md` | **已冻结(v1.1)** | 风控 PRD v1.0 + v1.1 追加 FR-8/9/10(§4A)+ 规则表附录 | @@ -73,7 +76,7 @@ (风控演示:`.\scripts\demo\prepare_all.ps1` 或 `prepare_risk_demo.sql` · PRD §10.2) 6. python scripts/sync/sync_advisor_rel.py && python scripts/sync/sync_neo4j.py 7. `docker compose up -d redis`(或 `.\scripts\dev\start-redis.ps1`)→ **REDIS_URL=redis://127.0.0.1:6380/0**(Docker Redis 7;避开本机 Windows Redis 占 6379) -8. uvicorn … · python -m pytest(**820 绿**);问数答辩:`docs/答辩/DEMO-SOP-问数.md` · 种子 `seed-analyst-metric-dict.sql` + **`seed-analyst-query-templates.sql`** +8. uvicorn … · python -m pytest(**827 绿**);问数答辩:`docs/答辩/DEMO-SOP-问数.md` · 种子 **`scripts/dev/seed_analyst.ps1`** ``` **AL-09 合并后架构(一句话):** 宿主 `gateway/` + 模块 `deps.py` **双栈并存**;对外登录/token **统一**;chat/risk 均走模块鉴权;接缝 S2 用 `auth_adapter`。 @@ -166,12 +169,12 @@ audit_log 等审计表(只 INSERT) | --- | --- | --- | | **1B** | 数据查询 intent(持仓/流水/风评/适当性/**净值**)经 `sanitize_reply` 命中违禁词 → **回退 `fact_text`,不转人工**;RAG/闲聊命中 → `COMPLIANCE_REJECT`,**也不自动 transfer**;**customer + visitor 共用** | `app/utils/sanitize_postprocess.py` · `finalize_sanitized_reply` | | **R1** | L1 `product_preferences` / `excluded_products` 合并时写 `items_meta`;注入 prompt **Top-K + 时间衰减 + TTL**(K=3 · TTL=90d · 半衰 30d) | `profile_service` · `settings.profile_preference_*` | -| **C-04** | 用户口述「亏 X% 提醒我」→ L1 摘要 + `customer_threshold_config`;**查持仓时**组合加权盈亏达线 → 追加提醒 + `customer_notify_log`(非 push/cron) | `threshold_service` · `core_ro_tool.query_holdings` | +| **C-04** | 持仓内联提醒 + **演示 push**:`POST /api/customers/{id}/threshold-check?push=` · `push_threshold_alerts.py` · 持仓页按钮(**无定时 cron**) | `threshold_service` · `customers.py` | | **C-05** | 「最新净值/单位净值」→ Core `get_latest_nav`(快照,非实时盘口);**「实时净值」仍 reject** | `core_ro_tool.query_product_nav` · intent `nav_query` | | **C-11** | 「我能买什么/匹配产品」→ `suitability_check` + Core 可购列表;**「推荐稳赚/买什么好」仍 reject** | `customer_prompts._ELIGIBLE_PRODUCTS_KW` | | **C-07** | 风评查询走 Core;**「重新测评/重做风评」** → 引导 App/网点(Agent 内不做问卷) | `customer_prompts._RISK_KW` | | **C-08** | 仅 L1 槽位 `investment.allocation_target`(13 槽);**无自动偏离检测/调仓** | `profile_slots.py` | -| **问数 vs 分析对话** | **问数页**:NL2SQL+表 · 按钮 **「分析该数据」**(客户 **「解读我的数据」**)→ `/api/analyst/interpret`,上下文**仅本轮问题+查数结果**;deny/clarify **不调 LLM** · **分析对话**菜单仍为 `agent_service` stub,待下线/强引导 | spec `docs/superpowers/specs/2026-09-11-query-interpret-split-design.md` | +| **问数 vs 分析对话** | 问数页 + **`/interpret`** · **N-03 抽样溯源** · **N-07 escalate/失败转人工** · 分析对话 URL **重定向问数** | spec `2026-09-11-query-interpret-split-design.md` | | **客户「趋势/走势」** | **客户 Chat**:仅 **C-05 最新净值快照** + 持仓/流水;**走势预测/实时盘口 reject**;**无 Chat 内净值历史曲线 Tool** · **统计类趋势**(近 N 日笔数、结构描述)→ **问数** `POST /api/analyst/chat` · **`self` 域**(拍板 `数据分析Agent-合并说明.md` §9.6 · 尾注「AI 分析有风险」)· **D-12 看板钻取未做** | 问数 ≠ 客户助手 · Phase B 行情 sync 未做 | | **L0 优先** | 抽槽与 L0 撞车**永远听 L0**;L1 只 enrich 措辞 | `profile_slots` D7 | @@ -185,9 +188,10 @@ Agent 库 SQL:docs/项目框架设计/表设计/01-mysql-共用底座.sql · 0 Core 模拟:scripts/core/reset.ps1 · 文档 docs/项目框架设计/Core模拟底座/ 种子:scripts/agent/seed-aml-list.sql(AML 名单)· scripts/demo/prepare_risk_demo.sql(reset 后重跑) 依赖:requirements.txt(LangGraph + langchain-core/openai + FastAPI + SQLAlchemy) -启动:uvicorn app.main:app --reload → GET /health +启动:uvicorn app.main:app --reload → GET /health · **GET /api/ready**(环境自检) Redis:`docker compose up -d redis` · `REDIS_URL=redis://127.0.0.1:6380/0` · `scripts/dev/start-redis.ps1` -测试:python -m pytest(**820 绿**;集成需本机 MySQL + AML + 风控演示数据) +测试:python -m pytest(**827 绿**;集成需本机 MySQL + AML + 风控演示数据) +风控沙盘:`python scripts/dev/sandbox_risk_test.py`(TEST-RISK-001) 前端:cd web && npm run dev · npm run build/test/lint(**22** Vitest)· 四角色 Demo 见 `docs/frontend/FRONTEND-HANDOFF.md` §8 问数模板:mysql … < scripts/agent/seed-analyst-query-templates.sql 风控一键灌库:.\scripts\demo\prepare_all.ps1 @@ -238,6 +242,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` **820 绿** · Redis **6380** · 问数 Demo 见 `docs/答辩/DEMO-SOP-问数.md`) +5. 如何验证?(`python -m pytest` **827 绿** · Redis **6380** · `/api/ready` · 问数 Demo 见 `docs/答辩/DEMO-SOP-问数.md`) 大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。 diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index 32e72f5..2e4f860 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -5,7 +5,7 @@ ## 进行中 -**2026-09-10 批次**(`merger` · 基线 **816 pytest** · `npm run build` 绿):客服 Wave3 + analyst D-06 + **D-09 解读拆分/答辩稳** + 风控前端 · **未 commit** +**2026-09-11 批次**(`merger` · 基线 **825 pytest** · `npm run build` 绿):TEST-RISK-001 修复 · N-03/N-07 · `/api/ready` · C-04 演示 push · 画像 L1 懒读 · **未 commit** ### 本批次 · 优先收尾(推荐顺序) @@ -14,7 +14,7 @@ - [ ] **数据分析 · 20 题 live battery**:`run_query_battery.py` 已加 `interpret: false` · 本机 `uvicorn` + DeepSeek 跑完写 `battery_report.json`(**不入库**) - [x] **口径字典/问数模板种子**:`scripts/dev/seed_analyst.ps1` → `seed-analyst-metric-dict.sql` · `seed-analyst-query-templates.sql` - [x] **接口契约发群稿**:`docs/项目管理/接口契约发群-2026-09-09.md`(复制到群即完成 · TODO 已勾) -- [x] **TEST-AN-001 签核(开发侧)**:`TEST-LOG-2026-09-11-AN-001.md` v1.3 · A/B/D 已修 · **824 pytest** +- [x] **TEST-AN-001 签核(开发侧)**:`TEST-LOG-2026-09-11-AN-001.md` v1.3 · A/B/D 已修 · **825 pytest**(含 RISK 修复 +1) - [ ] **前端 E2E 补齐未跑测试**(见下方「前端 E2E · 未跑测试」;2026-09-10 走查只做到「路由能渲染」,**不是逐功能详测**) ### 2026-09-10 日终 · 工作清单(给用户/答辩交接) @@ -48,12 +48,13 @@ **Tonight 已做** -- [x] **TEST-AN-001 问数 RBAC**:修复 sql_guard 缺口 A/B + deny 审计 D · v1.2 测试日志 · **820 pytest** +- [x] **TEST-AN-001 问数 RBAC**:修复 sql_guard 缺口 A/B + deny 审计 D · v1.2 测试日志 · **820 pytest**(后续 RISK/N-03 等 → **825**) - [x] `docs/答辩/答辩知识点清单.md` + 课程对齐(总览模块 8 · 问数 D-06 模块 5) - [x] 客服「你好」→ 问候 `keyword_route` + `CHITCHAT_DEGRADED_TEXT`(`customer_service` / `visitor_service` · 单测 `test_greeting_chitchat_without_llm`) - [x] **前端 E2E 清单收口**(`AuthProvider` · Redis 连接超时 · 预警防抖 · `displayLabels` · 对话粗体 · 登录/a11y · `docs/整体测试/前端整体测试交接.md`) - [x] **ChatPanel CHAT-1~4**:流式 `session_id` · `sessionStorage` 恢复 · 固定高度/侧栏分页 · 用户气泡白字 · 删除/清空会话 - [x] **会话 API**:`GET /sessions?status=` · `POST /sessions/close-all` · 前端 active 双滤 + close 兜底 · `test_chat` 增补 +- [x] **TEST-RISK-001 修复(F1–F11 子集)**:`repo_access` · service_risk 只读 · compliance 矩阵 · **825 pytest** · 日志 v1.1 **明天优先** @@ -114,7 +115,7 @@ - [ ] **分析对话菜单** — **已重定向** `/analytics/chat` → 问数;菜单项已删(2026-09-11 答辩稳) - [ ] **前端角色路由守卫**:`menus.tsx` 允许路径 ↔ URL 不一致时重定向(改 Hash 进别角色工作台仅 UX,后端仍 403) -- [ ] **SSE 断流**:半条 assistant 的提示/重试或续发策略(四角色 `ChatPanel` stream) +- [ ] **SSE 断流**:半条 assistant 的提示/重试或续发策略(四角色 `ChatPanel` stream)· **心跳注释帧已加**(`: ping`),断连重试仍 open - [ ] **会话侧栏性能**:后端稳定后改为 `status=active` 分页即可,去掉全量扫页 + 双端 close 兜底 **P2 · 展示 / 数据 / 闭环** @@ -186,7 +187,7 @@ ### 数据分析 Agent · S3 接缝(2026-09-09 已接线) -> 清单:`docs/项目框架设计/数据分析Agent-合并说明.md` · **804 passed** · 冒烟 `scripts/dev/smoke_analyst.py` +> 清单:`docs/项目框架设计/数据分析Agent-合并说明.md` · **825 passed** · 冒烟 `scripts/dev/smoke_analyst.py` - [x] merge + 接缝(`analyst_router` · `analyst_auth_adapter` · 问数走 `get_platform_auth_context`) - [x] customer `self` 域 + 「AI 分析有风险」尾注 @@ -208,7 +209,7 @@ - [x] **D-06 写侧主动失效**(2026-09-10):表世代 bump · 模拟交易 `core_trade` · 预警/L3 写侧 · `invalidate_tables` + 单测 - [x] **D-06 模板缓存**(2026-09-10):`template_service` 匹配+填参 · 问数编排跳过 LLM · 种子 `seed-analyst-query-templates.sql` - [ ] **D-06 PII 脱敏后再缓存** — 暂缓(全仓脱敏方案未定) -- [ ] **已知挂账(迭代文档)**:Q17 `create` 误杀 · Q7 无城市字段静默改职业 · 待排期 +- [ ] **已知挂账(迭代文档)**:Q7 无城市字段静默改职业 · 待排期(**Q17 已修**) - [ ] **远程分支同步**(可选):本地 `data-analysis-agent` @ `fd9464d` 落后 `xinghuo/data-analysis-agent` 2 commit ### 风控 Agent · 2026-09-10 拍板收口 @@ -231,6 +232,7 @@ **P1 · 演示一键灌库(2026-09-10 完成)** - [x] **`scripts/demo/prepare_all.ps1`**:双库 DROP → core + agent 种子 → AML → prepare_risk_demo → sync_advisor_rel +- [x] **TEST-RISK-001 修复**:见 `docs/memory/tests/2026-09-11-risk-agent-e2e/TEST-LOG-2026-09-11-RISK-001.md` v1.1 · `sandbox_risk_test.py` D 节期望 `TypeError` **P2 · 深潜课(2026-09-10 完成)** diff --git a/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md b/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md index 86914e5..7c9aab5 100644 --- a/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md +++ b/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md @@ -35,7 +35,7 @@ | **修改人** | Andrew | | **测试执行人** | Andrew | | **评审人** | (待模块负责人确认) | -| **发布建议** | 可合并 `merger`;C-04 无 push/cron 为已知缺口 | +| **发布建议** | 可合并 `merger`;C-04 **演示 push**(`threshold-check?push=` + 脚本)已做 · **无生产 cron** | --- @@ -146,7 +146,7 @@ | --- | --- | | **需求是否覆盖** | C-01~05 已实现;C-07/08/11 部分实现(见 REQUIREMENTS) | | **旧功能是否破坏** | 否 · 786 pytest 全绿 | -| **剩余风险** | ① C-04 无 push/cron;② C-05 非实时、Phase B 未做;③ C-08 无偏离检测;④ 手工 uvicorn 走查待补 | +| **剩余风险** | ① C-04 **无定时 cron**(仅有 inline + 演示 push);② C-05 非实时、Phase B 未做;③ C-08 无偏离检测;④ 手工 uvicorn 走查待补 | | **建议人工再验** | CUST-9527:持仓+阈值 · nav_query · 「我能买什么」· 「实时净值」reject · save_note 与流水优先级 | | **是否可发布** | 后端可合并 | diff --git a/docs/memory/tests/2026-09-11-analyst-domain-rbac/TEST-LOG-2026-09-11-AN-001.md b/docs/memory/tests/2026-09-11-analyst-domain-rbac/TEST-LOG-2026-09-11-AN-001.md index 5ac230a..1686ac1 100644 --- a/docs/memory/tests/2026-09-11-analyst-domain-rbac/TEST-LOG-2026-09-11-AN-001.md +++ b/docs/memory/tests/2026-09-11-analyst-domain-rbac/TEST-LOG-2026-09-11-AN-001.md @@ -47,7 +47,7 @@ | **Redis** | Docker 6380(`CacheService.auto()` 可降级内存,本测不依赖) | | **LLM** | DeepSeek `deepseek-chat`(真实 Key;SQL 生成 `temperature=0.0`) | | **修复前测试基线** | 816 passed(解读拆分 + 答辩稳 ① 后) | -| **修复后测试基线** | **820 passed**, 1 skipped | +| **修复后测试基线** | **825 passed**, 1 skipped(含 RISK-001 修复 +5 例量级) | | **前端** | 未涉及(仅后端问数线) | --- @@ -218,7 +218,7 @@ | **是否发现实际数据泄露** | **否**(当前 LLM 软注入可靠) | | **剩余风险** | ① ~~缺口 A/B/D~~ **已修复**;② 缺口 C:profile 表白名单存在但 LLM schema 未暴露(LOW,潜在);③ LLM 软注入仍为第一道防线,硬兜底为第二道 | | **建议人工再验** | 复跑 `sandbox_domain_test.py`;库内 `analytics_query_log` 出现 `exec_status='blocked'`;更换模型后回归 §6.3 | -| **是否可发布** | 后端可合并(820 pytest) | +| **是否可发布** | 后端可合并(**825 pytest** 全仓基线) | --- @@ -228,7 +228,7 @@ | --- | --- | --- | --- | | 模块负责人 | zhangyong | | ☐ 通过 ☐ 待改(开发侧 v1.3 已闭环,待负责人签) | | 发现人 / 测试 | Andrew | 2026-09-11 | ☑ 沙盘 + 自动化回归 | -| 修改人 | Andrew | 2026-09-11 | ☑ §7 A/B/D 已修 · **824 pytest** · N-03/N-07 接续 | +| 修改人 | Andrew | 2026-09-11 | ☑ §7 A/B/D 已修 · **825 pytest**(全仓)· N-03/N-07 已接续 | --- diff --git a/docs/memory/tests/2026-09-11-risk-agent-e2e/README.md b/docs/memory/tests/2026-09-11-risk-agent-e2e/README.md new file mode 100644 index 0000000..e4a6040 --- /dev/null +++ b/docs/memory/tests/2026-09-11-risk-agent-e2e/README.md @@ -0,0 +1,8 @@ +# TEST-RISK-001 沙盘包 + +| 中文职责 | 路径 | +| --- | --- | +| 企业级测试日志(v1.0 发现 · v1.1 修复) | `TEST-LOG-2026-09-11-RISK-001.md` | +| 本机可重复沙盘脚本 | `../../../scripts/dev/sandbox_risk_test.py` | + +**当前结论:** F1–F11 子集已修 · 全仓 **`825 pytest`** · 拍板 F2=service_risk 只读 GET · F3=compliance 进 risk JWT 矩阵。 diff --git a/docs/memory/tests/2026-09-11-risk-agent-e2e/TEST-LOG-2026-09-11-RISK-001.md b/docs/memory/tests/2026-09-11-risk-agent-e2e/TEST-LOG-2026-09-11-RISK-001.md new file mode 100644 index 0000000..1ab2ba1 --- /dev/null +++ b/docs/memory/tests/2026-09-11-risk-agent-e2e/TEST-LOG-2026-09-11-RISK-001.md @@ -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` 三页用 `
{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 | null + rows?: { label: string; key: string; render?: (v: unknown) => string }[] +} + +function cellValue( + data: Record, + key: string, + render?: (v: unknown) => string, +) { + const v = data[key] + if (render) return render(v) + if (v === null || v === undefined) return '—' + if (typeof v === 'object') return JSON.stringify(v) + return String(v) +} + +export function RiskJsonResult({ title, disclaimer, data, rows }: RiskJsonResultProps) { + if (!data) return null + const defaultItems: { label: string; key: string; render?: (v: unknown) => string }[] = + Object.keys(data) + .filter((k) => k !== 'disclaimer') + .slice(0, 12) + .map((k) => ({ label: k, key: k })) + const items = rows ?? defaultItems + + return ( +
+ {disclaimer ? : null} + {title} + + {items.map((item) => ( + + {cellValue(data, item.key, item.render)} + + ))} + +
+ ) +} diff --git a/web/src/hooks/useAlertsDashboard.ts b/web/src/hooks/useAlertsDashboard.ts index 5a067ec..d358f7f 100644 --- a/web/src/hooks/useAlertsDashboard.ts +++ b/web/src/hooks/useAlertsDashboard.ts @@ -5,9 +5,11 @@ import { useAsyncSequence } from './useAsyncSequence' import type { ChartDatum } from '../utils/dashboardTypes' import { labelAlertType } from '../utils/displayLabels' -function countToday(items: RiskAlertItem[]) { - const today = new Date().toISOString().slice(0, 10) - return items.filter((a) => a.created_at.startsWith(today)).length +function countTodayFromStats(stats?: { today_pending_count?: number }) { + if (stats && typeof stats.today_pending_count === 'number') { + return stats.today_pending_count + } + return 0 } export function useAlertsDashboard(token: string) { @@ -32,7 +34,7 @@ export function useAlertsDashboard(token: string) { setItems(data.items) setTotal(data.total) setDisclaimer(data.disclaimer) - setTodayCount(countToday(data.items)) + setTodayCount(countTodayFromStats(data.stats)) const customers = new Set(data.items.map((a) => a.customer_id)) setCustomerCount(customers.size) diff --git a/web/src/pages/risk/RiskAlertsPage.tsx b/web/src/pages/risk/RiskAlertsPage.tsx index 325ebca..439c0eb 100644 --- a/web/src/pages/risk/RiskAlertsPage.tsx +++ b/web/src/pages/risk/RiskAlertsPage.tsx @@ -1,4 +1,4 @@ -import { Alert, Input, Modal, Select, Table, Tag, Typography } from 'antd' +import { Alert, Input, Modal, Select, Table, Tag, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' import { useCallback, useEffect, useState } from 'react' import { ApiErrorResult } from '../../components/ApiErrorResult' @@ -17,6 +17,7 @@ import { formatDateTime, labelAlertStatus, labelAlertType, + labelHandlerResult, } from '../../utils/displayLabels' export function RiskAlertsPage() { @@ -88,7 +89,13 @@ export function RiskAlertsPage() { setHandleComment('') await load() } catch (e) { - setError(e instanceof Error ? e : new Error('handle failed')) + if (e instanceof ApiError && e.errorCode === 'STATE_CONFLICT') { + message.warning('该预警已被他人处置,列表已刷新') + setHandleTarget(null) + await load() + } else { + setError(e instanceof Error ? e : new Error('handle failed')) + } } finally { setSubmitting(false) } @@ -102,8 +109,11 @@ export function RiskAlertsPage() { { title: '状态', dataIndex: 'status', - render: (v: string) => ( - {labelAlertStatus(v)} + render: (v: string, row) => ( + + {labelAlertStatus(v)} + {row.handler_result ? ` · ${labelHandlerResult(row.handler_result)}` : ''} + ), }, { title: '创建时间', dataIndex: 'created_at', width: 180, render: (v: string) => formatDateTime(v) }, @@ -136,7 +146,14 @@ export function RiskAlertsPage() { alert={ disclaimer ? ( - ) : undefined + ) : ( + + ) } > {error && } diff --git a/web/src/pages/risk/RiskAmlScanPage.tsx b/web/src/pages/risk/RiskAmlScanPage.tsx index 88d4546..5c6069c 100644 --- a/web/src/pages/risk/RiskAmlScanPage.tsx +++ b/web/src/pages/risk/RiskAmlScanPage.tsx @@ -76,7 +76,6 @@ export function RiskAmlScanPage() { {result.alerts.join(', ')}

) : null} -
{JSON.stringify(result, null, 2)}
} /> diff --git a/web/src/pages/risk/RiskSimulatePage.tsx b/web/src/pages/risk/RiskSimulatePage.tsx index 2dad0e0..0471f26 100644 --- a/web/src/pages/risk/RiskSimulatePage.tsx +++ b/web/src/pages/risk/RiskSimulatePage.tsx @@ -5,6 +5,7 @@ import { PageShell } from '../../components/PageShell' import { Button } from '../../components/ui' import { submitSimulateTrade, type SimulateTradeResponse } from '../../api/simulate' import { ApiError } from '../../api/client' +import { RiskJsonResult } from '../../components/risk/RiskJsonResult' import { useAppAuth } from '../../layouts/AppLayout' const PRESETS = [ @@ -120,15 +121,24 @@ export function RiskSimulatePage() { ) : null} {result ? ( - {JSON.stringify(result, null, 2)} - } - /> + <> + + } + rows={[ + { label: '是否阻断', key: 'blocked' }, + { label: '阻断码', key: 'block_response_code' }, + { label: '交易 ID', key: 'trade_id' }, + { label: '预警编号', key: 'alert_id' }, + ]} + /> + ) : null} ) diff --git a/web/src/pages/risk/RiskSuitabilityPage.tsx b/web/src/pages/risk/RiskSuitabilityPage.tsx index 3932e9f..61218ac 100644 --- a/web/src/pages/risk/RiskSuitabilityPage.tsx +++ b/web/src/pages/risk/RiskSuitabilityPage.tsx @@ -5,6 +5,7 @@ import { PageShell } from '../../components/PageShell' import { Button } from '../../components/ui' import { checkSuitability, type SuitabilityCheckResponse } from '../../api/risk' import { ApiError } from '../../api/client' +import { RiskJsonResult } from '../../components/risk/RiskJsonResult' import { useAppAuth } from '../../layouts/AppLayout' const PRESETS = [ @@ -97,20 +98,31 @@ export function RiskSuitabilityPage() { ) : null} {result ? ( - - {result.block_reason ?

{result.block_reason}

: null} - {result.advice ?

{result.advice}

: null} - {result.notice ?

{result.notice}

: null} -
{JSON.stringify(result, null, 2)}
-
- } - /> + <> + + {result.block_reason ?

{result.block_reason}

: null} + {result.advice ?

{result.advice}

: null} + {result.notice ?

{result.notice}

: null} +
+ } + /> + } + rows={[ + { label: '匹配结果', key: 'match_result' }, + { label: '客户等级', key: 'customer_level' }, + { label: '产品等级', key: 'product_level' }, + { label: '是否阻断', key: 'blocked' }, + ]} + /> + ) : null} ) diff --git a/web/src/utils/displayLabels.ts b/web/src/utils/displayLabels.ts index c84abfe..e957c88 100644 --- a/web/src/utils/displayLabels.ts +++ b/web/src/utils/displayLabels.ts @@ -24,10 +24,15 @@ const ALERT_TYPE_LABELS: Record = { const ALERT_STATUS_LABELS: Record = { pending_review: '待审核', handled: '已处置', + confirmed_normal: '确认正常', + confirmed_suspicious: '确认可疑', + reported: '已上报', } const HANDLER_RESULT_LABELS: Record = { confirmed_normal: '确认正常', + confirmed_suspicious: '确认可疑', + reported: '已上报', confirmed_risk: '确认风险', false_positive: '误报', }