docs(40): 补齐前端验收清单(投顾/运营页、访客浮窗、Worker 前置)

- 关键修正:第 0 节原先写「停常驻 Worker」,方向是反的 —— 客服对话**必须**有
  Worker,否则 agent_run 停在 queued,前端只显示超时。补上排查第一步(查最新
  那行的 status)与本机实测延迟(端到端 4.1-4.8 秒,受理只占 0.05 秒)。
- 新增第 6 节投顾工作台:数据口径(本人 + 名下归属客户,不用 data_scope)、
  方案书审核与发布必须由管理员做(admin=True)、归属数据不随环境走。
- 新增第 7 节运营工作台,含客户/风控访问被 403 拦的实测。
- 新增 2.5 节访客客服浮窗:访客令牌的角色与权限、访客与客户走不同检索工具名。
- 新增第 9 节「看起来像 bug、其实是设计」:置信度阈值 0.75/0.55/0.07 与三条实测
  分数逐条对应(0.8203 直接答;0.6818 但间隙 0.0034 转人工;0.5221 转人工),
  说明「总转人工」的根因在知识库内容而不是代码;另两条是投顾接口非越权、
  方案书复核环节。
- 第 1 节更新为 18 个页面路径 + 12 个静态资源,全部 200(实测值)。
- 第 10 节补入本轮修的 4 个问题(角色落点、访客白名单、mock 声明、发布脚本丢提示词)。
- 新增第 11 节已知未修:知识库无场内 ETF 内容、fin_knowledge_meta 与 Milvus 不一致
  (列表接口实测 count=0)、品牌名三处不一致、ADVISOR_GOAL/ANALYSIS 声明未用等。
- 附录补演示账号,并注明 9006/9020 的口令不在 set_user_password.py 的演示规则里。
This commit is contained in:
2026-09-13 19:10:21 +08:00
parent 680c2a0749
commit 52ae38efd7
+246 -74
View File
@@ -1,51 +1,75 @@
# 前端验收清单(正式门户)
> **被测对象**:**正式前端** `app/static/portal/`,由 `app/main.py` 挂载在 **`/portal/`**
> **启动**:`python -m uvicorn app.main:app --host 127.0.0.1 --port 8000`
> (模块级变量是 **`app`**,不是 `application`)
> **入口**:<http://127.0.0.1:8000/portal/>(`/` 会 307 跳到 `/portal/guest/home/`)
> **时点**:2026-09-13
> **入口**:<http://127.0.0.1:8000/portal/>(`/` 与 `/portal/` 都会 307 跳到 `/portal/guest/home/`)
> **时点**:2026-09-13(第三次更新:补入投顾/运营两套页面、访客客服浮窗、Worker 前置)
>
> **证据口径**
> **✅实测** = 我用真实 HTTP 请求打过,数字是那次响应的真实值;
> **⚠️待点验** = 只能从代码与接口推断的**页面交互**,需要你在浏览器里点一遍。
>
> **证据口径**:标 ✅实测 的项目**由我用真实请求打过**;标 ⚠️待点验 的是**页面交互层面**,
> 我只能从代码与接口推断,需要你在浏览器里点一遍。
> 交付方自述的路由与数据源见 `app/static/portal/README.md`。
---
## 0. 启动与前置
## 0. 启动与前置 ⚠️ 这一节最容易踩
| # | 项 | 命令 / 检查 | 预期 |
|---|---|---|---|
| 0-1 | 依赖服务 | MySQL、Redis 可用 | 平台能起,无 500 |
| 0-1 | 依赖服务 | MySQL、Redis 可用;Milvus 需要 Docker Desktop 在运行 | 平台能起,无 500 |
| 0-2 | RBAC 种子 | `python tools/seed_test_rbac.py` | 五个演示账号可登录 ✅实测 |
| 0-3 | 演示口令 | `python tools/set_user_password.py` | 口令生效(**非幂等**,重跑等于改密)✅实测 |
| 0-4 | **虚拟资金账户** | `python -m tools.seed_sim_account_demo` | 客户 9001 开 10 万初始资金 + 2 只持仓。<br>**不跑这一步,`/customer/dashboard/` 与 `/customer/cash-ledger/` 必然打不开**(404「客户未开户」)✅实测 |
| 0-5 | 风控 Agent 配置 | `python tools/publish_risk_agent_config.py` | `risk_overview` / `risk_search` / `risk_evidence` 三个工具生效 ✅实测 |
| 0-6 | 停常驻 Worker | 确认没有 `python -m app.worker` | 否则客服对话 / Agent Run 会被抢队列 |
| 0-4 | **虚拟资金账户** | `python -m tools.seed_sim_account_demo` | 客户 9001 开 10 万初始资金 + 2 只持仓。<br>**不跑这步,`/customer/dashboard/` 与 `/customer/cash-ledger/` 必然打不开**(404「客户未开户」)✅实测 |
| 0-5 | 风控 Agent 配置 | `python tools/publish_risk_agent_config.py` | `risk_overview`/`risk_search`/`risk_evidence` 三个工具生效 ✅实测 |
| 0-6 | **Agent Worker(必开!)** | `python -m app.worker` | **不启动它,所有客服对话都会"超时"** —— 见下方说明 |
### ⚠️ 0-6 为什么是"必开"而不是"必停"
Agent 请求是**三段式**:API 受理(返回 202)→ **Worker 领单执行** → 落结果。
**没有 Worker 时**,`agent_run` 会一直停在 `status='queued'`、`worker_id` 为空,
前端轮询到底只会显示"客服繁忙/超时" —— **看起来像链路慢,实际是没人处理**。
(2026-09-13 的访客浮窗"回答超时"就是这么来的。)
> `docs/20` 里写的是"跑验收**脚本**前先停 Worker"(免得抢队列)—— 两件事不矛盾:
> **要跟 Agent 对话就必须开着;要跑验收脚本就关掉。**
排查第一步永远是:查 `agent_run` 最新那行的 `status` 是不是 `queued`。
```powershell
# 一次性把前置跑齐
# 一次性把前置跑齐(两个终端)
python tools/seed_test_rbac.py
python tools/set_user_password.py
python -m tools.seed_sim_account_demo
python tools/publish_risk_agent_config.py
# 终端 1:API
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
# 终端 2:Worker(客服对话依赖它)
python -m app.worker
```
> 模块级变量是 **`app`**,不是 `application`。
---
## 1. 路由与静态资源 ✅实测
## 1. 页面路由与静态资源 ✅实测
| # | 路径 | 预期 | 实测 |
|---|---|---|---|
| 1-1 | `/` | 307 → `/portal/guest/home/` | ✅ |
| 1-2 | `/portal/` | 307 → 同上 | ✅ |
| 1-3 | `/portal/guest/home/` | 200 | ✅ 5740 B |
| 1-4 | `/portal/guest/products/` | 200 | ✅ |
| 1-5 | `/portal/guest/product-detail/` | 200 | ✅ |
| 1-6 | `/portal/customer/login/` | 200 | ✅ |
| 1-7 | `/portal/employee-console/login/` | 200 | ✅ |
| 1-8 | `common/api-client.js`、`auth.js`、`permission-guard.js`、`app-shell.js`、`base.css`、`tokens.css`、`mock-data.js` | 全部 200 且 content-type 正确 | ✅ 9/9 |
**18 个路径全部 200**(`/` 与 `/portal/` 记的是跟随重定向后的最终 200):
| 分组 | 路径 | 实测 |
|---|---|---|
| 跳转 | `/` → `/portal/guest/home/`<br>`/portal/` → 同上 | ✅ |
| 访客 | `/portal/guest/home/`(5740 B)、`products/`(2153 B)、`product-detail/`(2317 B) | ✅ |
| 客户 | `customer/login/`、`dashboard/`、`holdings/`、`orders/`、`transactions/`、`profit-loss/`、`cash-ledger/`、`risk-questionnaire/` | ✅ 8/8 |
| 员工 | `employee-console/login/`、`employee-console/workspace/` | ✅ |
| 风控 | `employee-risk/dashboard/`(11872 B) | ✅ |
| **投顾** | `employee-advisor/dashboard/`(2449 B) | ✅ 本轮新增 |
| **运营** | `employee-operations/dashboard/`(1968 B) | ✅ 本轮新增 |
**12 个关键静态资源全部 200**:`api-client.js` / `auth.js` / `login-controller.js` / `permission-guard.js` /
`layout/app-shell.js` / `mock-data.js` / `customer-service-widget/widget.js` / `widget.css` /
`base.css` / `tokens.css` / `operations.css` / `auth-layout.css` ✅
---
@@ -54,14 +78,31 @@ python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
| # | 操作 | 预期 | 证据 |
|---|---|---|---|
| 2-1 | 打开 `/portal/guest/home/` | 首屏含品牌与产品摘要 | ⚠️待点验 |
| 2-2 | **看页面的数据来源提示** | 显示"公开产品接口尚未提供,本页使用与 `fin_product`、`fin_nav_history` 模型同字段的演示数据" | ⚠️待点验<br>`MOCK_SOURCE_NOTICE` 常量已确认存在 |
| 2-3 | 进产品列表 / 详情 | 能筛选、能按排名排序、详情有净值走势 | ⚠️待点验(数据来自 `mock-data.js`,**非真实接口**) |
| 2-2 | **看页面的数据来源提示条** | 产品列表页与详情页顶部都有浅色提示条,写明"公开产品接口尚未提供…演示数据,**非真实行情**" | ✅ 页面 HTML 与三个 JS 资源实测都带 `data-source-notice` / `MOCK_SOURCE_NOTICE`<br>⚠️ 渲染位置待点验 |
| 2-3 | 进产品列表 / 详情 | 能筛选、能按排名排序(`?view=ranking`)、详情有净值走势 | ⚠️待点验(数据来自 `common/mock-data.js`,**非真实接口**) |
| 2-4 | 点登录入口 | 进 `/portal/customer/login/` | ⚠️待点验 |
> **关于 mock**:这三页用的是 `common/mock-data.js`,因为**公开产品 HTTP 接口尚未实现**。
> 页面显著标注了来源,README 也写明"不得与登录后的真实账户数据混用"。
> **这不是 17- 文档禁止的"为业务演示造数据"**,而是接口缺位时的显式降级;
> 真正的解法是补公开产品接口。
> **关于 mock**:访客三页用 `common/mock-data.js`,因为**公开产品 HTTP 接口尚未实现**。
> 页面显著标注了来源 —— 这个标注**不能删**:删掉不会让数据变真,只会让客户以为看到的是真实净值。
### 2.5 访客智能客服浮窗 ⭐ 本轮重点
公开首页、产品列表页、产品详情页右下角都有客服浮窗。
| # | 操作 | 预期 | 证据 |
|---|---|---|---|
| 2-5 | 点浮窗,问一个**知识库里有**的问题 | 约 4 秒内给出答案 | ✅实测端到端 **4.11s**<br>问「基金定投是什么」→ score 0.8203 → 直接答 |
| 2-6 | 问一个**知识库里没有**的问题 | 引导拨打客服热线(**这是正确行为**,见第 9 节) | ✅实测 **4.09s / 4.82s** |
| 2-7 | 问个人账户相关问题(如"我的风险等级是多少") | 提示"该服务需要登录后才能查询您的个人信息" | ⚠️待点验(代码路径已确认:`_guide_to_login`) |
| 2-8 | 浮窗里点"登录后查询账户" | 跳客户登录页 | ⚠️待点验 |
**访客令牌机制**:`POST /api/v1/visitor-tokens`(无鉴权)
→ 返回短期令牌,身份是 `roles=("visitor",)`、`permissions=("agent:run","knowledge:query")` ✅实测
> ⚠️ **访客与客户走的是不同的检索工具名**:访客走 `query_knowledge`(要 `knowledge:query`),
> 登录客户走 `search_knowledge`(要 `knowledge:reference:read`)。两者**必须同时**在发布配置
> `agent_tools/customer_service:<intent>` 的白名单里 —— 缺哪一条,对应人群就一问即失败。
> 2026-09-13 修过一次:当时白名单只有 `search_knowledge`,访客一问就抛 `ForbiddenAgentError`。
---
@@ -71,23 +112,26 @@ python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
| # | 操作 | 预期 | 证据 |
|---|---|---|---|
| 3-1 | 用 `cust_t` / `123456` 登录(A034) | 200,拿到令牌,跳 dashboard | ✅ A034 200,`user_id=9001`,`expires_in=1800` |
| 3-2 | dashboard(T001 `GET /users/me/account/dashboard`) | 总资产 / 可用资金 / 持仓市值等 | ✅ 200(**修前置前是 404「客户未开户」**) |
| 3-3 | 持仓(T006 `GET /users/me/holdings`) | 持仓表 | ✅ 200 |
| 3-1 | 登录(A034) | 200,拿到令牌,跳 dashboard | ✅ 200,`user_id=9001`,`expires_in=1800` |
| 3-2 | dashboard(T001) | 总资产 / 可用资金 / 持仓市值 | ✅ 200(**修前置前是 404「客户未开户」**) |
| 3-3 | 持仓(T006) | 持仓表 | ✅ 200 |
| 3-4 | 交易流水(T007) | 流水表 | ✅ 200 |
| 3-5 | 资金流水(T009 `GET /users/me/cash-ledger`) | 资金明细 | ✅ 200(同 3-2,修前置前 404) |
| 3-6 | 订单列表(T003 `GET /users/me/orders`) | 空列表不报错 | ✅ 200(0 条) |
| 3-7 | 下单(T002 `POST /users/me/orders`) | 报文 `{product_code, order_side, quantity}`,`order_side ∈ {buy, sell}` | ⚠️待点验<br>需用真实 product_code(`7002` 是 product_id 不是 code) |
| 3-5 | 资金流水(T009) | 资金明细 | ✅ 200 |
| 3-6 | 订单列表(T003) | 空列表不报错 | ✅ 200 |
| 3-7 | 下单(T002) | 报文 `{product_code, order_side, quantity}`,`order_side ∈ {buy, sell}` | ⚠️待点验<br>需真实 `product_code`(`7002` 是 product_id 不是 code) |
| 3-8 | 盈亏(T001) | 收益曲线 | ⚠️待点验 |
| 3-9 | 风险测评(ONB001) | 返回问卷定义 | ✅ 200 |
| 3-10 | 提交问卷(ONB002) | 提交后状态变更;**未提交测评时部分接口会 403/409** | ⚠️待点验(写操作) |
| 3-10 | 提交问卷(ONB002) | 提交后状态变更 | ⚠️待点验 |
### 3.2 客服
### 3.2 客户客服浮窗
| # | 操作 | 预期 | 证据 |
|---|---|---|---|
| 3-11 | 与客服 Agent 对话(R001 + R002/R003) | 受理 202 → SSE 出结果 | ⚠️待点验(需停常驻 Worker) |
| 3-12 | 转人工 | 工单进管理员队列 | ✅ 我之前实测过 `POST /conversations/{sid}/handover-requests` → 202,前提是**先调 C001 真实创建会话** |
| 3-11 | 在客户工作台用浮窗提问 | 与访客同一浮窗,但身份是登录客户 | ⚠️待点验(需 Worker 在跑) |
| 3-12 | 点"转人工客服" | `POST /conversations/{sid}/handover-requests` → 202,工单进管理员队列 | ✅ 实测 202(**前提:先由 C001 真实创建会话**) |
> **转人工的实现口径**:按你的要求,**只回话术引导拨客服热线,不创建工单**用于"答不上来"的场景;
> 上面 3-12 是客户**主动**点"转人工"按钮才建工单,两者不是一回事。
---
@@ -95,68 +139,196 @@ python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
| # | 操作 | 预期 | 证据 |
|---|---|---|---|
| 4-1 | `/portal/employee-console/login/` 登录 | 进 workspace | ✅ A034 200 |
| 4-1 | 员工入口登录 | 进 workspace | ✅ 200 |
| 4-2 | 配置发布列表(A002) | 版本列表 | ✅ 200 |
| 4-3 | 模型端点(A012) | 端点列表 | ✅ 200 |
| 4-3 | 模型端点(A012) | 端点列表 | ✅ 200(`deepseek-flash`、`qwen-embedding`) |
| 4-4 | 审计查询(A033) | 时间/动作/操作者/结果 | ✅ 200 |
| 4-5 | 角色列表 / 权限 / 用户角色(A035–A038) | 角色与权限明细 | ✅ 200(`customer` 角色 26 项权限) |
| 4-5 | 角色 / 权限 / 用户身份(A035–A038) | 角色与权限明细 | ✅ 200(`customer` 26 项、`advisor` 28 项、`admin` 59 项、`operator` 2 项、`risk_operator` 10 项) |
| 4-6 | 画像候选审核(A039) | 候选列表 | ✅ 200 |
| 4-7 | 客服转人工工单(ADMIN_HANDOVERS) | 工单队列,**不返回客户标识与原始正文** | ✅ 200 |
| 4-8 | 配置发布四态(A004/A005/A006) | 校验→审核→激活,**必须带 `Idempotency-Key`** | ⚠️待点验(写操作) |
> **平台只提供 RBAC 只读查询**:没有改权限的写接口,权限变更走 `config_release` 发布。
> 这是设计,不是功能缺失。
> **平台只提供 RBAC 只读查询**:没有改权限的写接口,权限变更走 `config_release` 发布。这是设计。
---
## 5. 风控(`risk_t` / `666666`)—— 按 `docs/风控业务演示文档/17-*.md` 对齐
## 5. 风控(`risk_t` / `666666`)—— 对齐 `docs/风控业务演示文档/17-*.md`
| # | 操作 | 预期 | 证据 |
|---|---|---|---|
| 5-1 | 风险概览(RK001) | 总量 / 等级 / 待处理 / 超时 / 重点预警 | ✅ 200(total=2 pending=1 overdue=2 levels{高风险:2}) |
| 5-2 | 预警队列(RK002) | **每页 5 条**、风险等级优先、筛选、分页 | ✅ 200(2 条:`ALDEMO0002` 高、`ALDEMO0001` 高)<br>⚠️ `limit` **上限就是 5**,传 20 会 422 且表格渲染不出来 |
| 5-3 | 队列筛选 | 关键词 / 客户号 / 风险等级 / 规则码 / 产品 / 时间 | ✅ `rule_code=RW-015 → ALDEMO0002`、`customer_no=T-CUST → 2 条`<br>⚠️ **风险等级要填「高/中/低」**:预警对象用「高」,概览 `levels` 用「高风险」 |
| 5-4 | 预警详情(RK003) | 编号 / 状态 / 规则 / 证据 / 回执 / 客户 | ✅ 200(alert 23 字段 + customer 13 字段) |
| 5-5 | 八类证据(RK004) | `customers`/`products`/`transactions`/`capital_flows`/`holdings`/`login_records`/`alerts`/`notifications` | ✅ 8/8 全 200<br>⚠️ 路径是 **`holdings`**,写 `positions` 会被 422 |
| 5-6 | 通知记录(RK005) | 预警编号 / 类型 / 发送状态 | ✅ 200 |
| 5-1 | 风险概览(RK001) | 总量/等级/待处理/超时/重点预警 | ✅ 200(total=2 pending=1 overdue=2 levels{高风险:2}) |
| 5-2 | 预警队列(RK002) | **每页 5 条**、筛选、分页 | ✅ 200(2 条:`ALDEMO0002`、`ALDEMO0001`)<br>⚠️ `limit` **上限就是 5**,传 20 会 422 且表格渲染不出来 |
| 5-3 | 队列筛选 | 关键词/客户号/风险等级/规则码/产品/时间 | ✅ `rule_code=RW-015 → ALDEMO0002`、`customer_no=T-CUST → 2 条`<br>⚠️ **风险等级填「高/中/低」**:预警对象用「高」,概览 `levels` 用「高风险」 |
| 5-4 | 预警详情(RK003) | 编号/状态/规则/证据/回执/客户 | ✅ 200(alert 23 字段 + customer 13 字段) |
| 5-5 | 八类证据(RK004) | `customers`/`products`/`transactions`/`capital_flows`/**`holdings`**/`login_records`/`alerts`/`notifications` | ✅ 8/8 全 200<br>⚠️ 是 **`holdings`**,写 `positions` 会被 422 |
| 5-6 | 通知记录(RK005) | 预警编号/类型/发送状态 | ✅ 200 |
| 5-7 | 手动扫描(RK006) | 200 `code=0`,**要带 `Idempotency-Key`** | ✅ 200 |
| 5-8 | 确认接收(RK007) | **二次确认**;状态不符返回 409 | ✅ 409「只有待处理的预警才能确认解决」 |
| 5-8 | 确认接收(RK007) | 二次确认;状态不符返回 409 | ✅ 409「只有待处理的预警才能确认解决」 |
| 5-9 | 进入调查(RK008)/ 关闭误报(RK009) | 误报**必须填理由** | ⚠️待点验(写操作) |
| 5-10 | 结案(RK010)/ 升级(RK011) | 结案填 `resolution`、升级填 `reason`;**需先确认接收** | ⚠️待点验(顺序:确认 → 调查/误报/升级/结案) |
| 5-11 | 证据上传(RK012) | **`multipart/form-data`** | ⚠️待点验 |
| 5-12 | 日报(RK013 / RK014 / RK015) | 生成 / **SSE 流式** / 多邮箱发送 | ✅ RK013 200;SSE 事件类型实测为 `start`/`progress`/`replace` |
| 5-12 | 日报(RK013/RK014/RK015) | 生成 / **SSE 流式** / 多邮箱发送 | ✅ RK013 200;SSE 事件类型实测 `start`/`progress`/`replace` |
---
## 6. 通用预期(跨页面)
## 6. 投顾(`advisor_t` / `abc12345`)⭐ 本轮新增页面
登录入口同样是**员工登录页** `/portal/employee-console/login/`,登录后自动落到投顾工作台。
| # | 操作 | 预期 | 证据 |
|---|---|---|---|
| 6-1 | 用 `advisor_t` 登录 | 跳 `/portal/employee-advisor/dashboard/` | ✅ 实测守卫生效(此前会**静默弹回登录页**,已修) |
| 6-2 | 工作台列表 | 显示"**投资目标方案书 · 客户 9001**"卡片 + 发布时间 + 方案内容 | ✅ 接口实测返回 1 条(`content_id=1`、`customer_id=9001`、`type=investment_goal_book`、`published_at=2026-09-13T10:50:17`) |
| 6-3 | 点"刷新" | 重新拉取 `ADVISOR_PUBLISHED` | ⚠️待点验 |
| 6-4 | 用别的角色访问该页 | `risk_operator` 会被弹回风控页;`operator` 弹回运营页 | ✅ 实测守卫矩阵 |
**数据口径**(`ProductRecommendationService.published`):
按「**本人 + `sys_customer_assignment` 里名下归属客户**」过滤,且覆盖两类内容 ——
`investment_goal_book`(方案书,发布后 `review_status='published'`)与
`advisor_recommendation_plan`(推荐方案,审核后 `'approved'`)。
> **不用 `data_scope`** 的原因:投顾持有 `promotion:*` 这类 all 级权限,
> `IdentityService` 会把整个身份的 scope 抬到 `all`,按它判定会放开到**全部客户**。
> 归属关系来自 `sys_customer_assignment`(逐条授权 + 时间窗校验),比 scope 更窄。
>
> **本机归属数据**:`sys_customer_assignment` 只有一行 `(customer_id=9001, employee_id=9020, advisor)`。
> 所以投顾现在能看到客户 9001 的交付物;**换环境这条数据不跟着走,页面会是空态**。
### 6.5 方案书的审核与发布必须由管理员做
```
客户确认目标 → 管理员审核方案书 → 管理员发布方案书
```
`review_book` 与 `publish_book` 都带 `admin=True`(`investment_goal_service.py:174/216`)——
**投顾虽然有 `investment-goal:review` 权限码也做不了**。这是有意的复核环节,不是缺陷。
实测:投顾调用审核 → 403;管理员调用 → 200 ✅
---
## 7. 运营(`offsite_t` / `offsite123`)⭐ 本轮新增页面
| # | 操作 | 预期 | 证据 |
|---|---|---|---|
| 7-1 | 用 `offsite_t` 登录 | 跳 `/portal/employee-operations/dashboard/` | ✅ 实测守卫生效(此前同样静默弹回) |
| 7-2 | 场外邮件列表(`OFFSITE_MAILS`) | 分页列表:主题/发件人/时间/状态 | ✅ 200 `data=dict{items,page,page_size,total}` |
| 7-3 | 收件箱状态(`OFFSITE_MAILBOX`) | 邮箱 / 游标状态 / 阻塞标记 | ✅ 200 `data=dict{mailbox,status,blocked,last_uid,…}` |
| 7-4 | 用客户/风控访问这两个接口 | 被拦 | ✅ 客户 `code=403 当前角色不能操作场外基金流程`;风控 `code=403 缺少场外基金操作权限` |
> 场外线按**角色**收口(`operator`/`risk_operator`/`admin`/`super_admin`),不是按权限码。
---
## 8. 通用预期(跨页面)
| # | 情形 | 预期 |
|---|---|---|
| 6-1 | **业务失败也返回 HTTP 200** | 本平台把业务错误放在 `body.code`(如生成失败 `HTTP 200 + code=422`),前端按 `error-codes.js` 解析 ✅实测 |
| 6-2 | 403 | "没有当前操作权限";按钮可见性应与服务端权限一致 ✅实测(客户访问 `/admin/roles` → 403) |
| 6-3 | 404 | 注意 `SESSION_NOT_FOUND` 被**三个异常类共用**,可能是"会话不存在"、"客户不可访问"或"知识文档不存在",只能看 message ✅实测 |
| 6-4 | 幂等 | 写请求必须带唯一 `Idempotency-Key`(`api-client.js` 已用 `idempotent: true` 标记)✅实测 |
| 6-5 | 多标签 | 各页面独立,会话互不干扰 ⚠️待点验 |
| 6-6 | 排版 | 文字不重叠、内容不溢出、表格行高稳定 ⚠️待点验 |
| 8-1 | **业务失败也返回 HTTP 200** | 平台把业务错误放在 `body.code`(如生成失败 `HTTP 200 + code=422`),前端按 `error-codes.js` 解析 ✅实测 |
| 8-2 | 403 | "没有当前操作权限";按钮可见性应与服务端权限一致 ✅实测 |
| 8-3 | 404 | `SESSION_NOT_FOUND` 被**三个异常类共用**(会话不存在 / 客户不可访问 / 知识文档不存在),只能看 message ✅实测 |
| 8-4 | 幂等 | 写请求必须带唯一 `Idempotency-Key`(`api-client.js` 用 `idempotent: true` 标记)✅实测 |
| 8-5 | 多标签 | 各页面独立。**注意**:`auth.js` 现在以 cookie 为权威(跨标签同步),所以**同一浏览器两个账号不能并存** ✅实测代码逻辑 |
| 8-6 | 排版 | 文字不重叠、内容不溢出、表格行高稳定 ⚠️待点验 |
| 8-7 | 缓存 | 产品列表/详情页的 CSS/JS 已带 `?v=20260913-4`;若改了资源仍看不到效果,请硬刷新 ⚠️待点验 |
---
## 7. 我做了什么验证(可复现)
## 9. ⚠️ 看起来像 bug、其实是设计(测试时最容易被误判的三件事)
### 9-1 "客服答不上来,总是引导转人工" —— 这是置信度防线在工作
客服命中知识后要过两道阈值(`customer_service.py:148-150`):
```
HIGH_SCORE = 0.75 # ≥ 0.75 直接答
MID_SCORE = 0.55 # 0.55~0.75 之间,必须同时满足 MIN_GAP
MIN_GAP = 0.07 # top1 领先次优的最小间隙
```
**实测三条,逐条对上**:
| 提问 | top1 分数 | 次优 | 间隙 | 判定 |
|---|---|---|---|---|
| 基金定投是什么 | **0.8203** | 0.5886 | 0.2317 | ≥0.75 → **直接答** ✅ |
| 南方沪深300ETF的起投金额是多少 | 0.6818 | 0.6784 | **0.0034** | 间隙 < 0.07 → **转人工** |
| 购买基金需要什么条件 | 0.5221 | 0.5162 | 0.0059 | 分数 < 0.55 → **转人工** |
第二问如果硬答,会答成"**南方结构性存款(挂钩型):起投金额 20 万元**" —— 那是另一个产品,
而且客户问的是场内 ETF。**转人工在这里是正确行为**,符合你定的"不会的就转人工,首先要保证稳定性"。
> 真正要改的是**知识库内容**,不是代码:检索命中的是一套"南方科技有限公司 个人理财产品手册"
> 的素材(`PROD-*`/`FAQ-*`/`POL-*`),**里面没有本项目的场内 ETF 条目**,所以问 ETF 注定分数上不去。
### 9-2 "客户能读投顾的接口" —— 不是越权
`GET /api/v1/advisor/recommendations/published` 客户调用返回 200,但 SQL 里按
`customer_id IN (本人 + 归属客户)` 过滤,**读的是自己的**。风控返回 403 是因为没有
`product-recommendation:read:self` 权限码。用"能访问"判断越权会误判。
### 9-3 "投顾发布不了方案书" —— 是有意的复核环节
见 6.5。审核与发布都要求 `admin=True`。
---
## 10. 我做了什么验证(可复现)
| 项 | 方式 | 结果 |
|---|---|---|
| 16 个页面路由 + 9 个静态资源 | 逐个 GET | 全部 200 / 307 符合预期 |
| 49 个端点里的 26 个(客户域 / 风控域 / 管理面 / Agent) | 用五个角色真实登录后逐个请求 | 除 3 个(见下)全通 |
| 登录链路 | `POST /api/v1/auth/tokens` × 5 角色 | 全部 200 |
| 平台门禁 | ruff / mypy / pytest / 表审计 | ruff 干净 / **mypy 249 文件 0 错** / 单元+契约 **1376 passed** / 集成 **104 passed** / **89 张业务表** |
| 18 个页面路径 + 12 个静态资源 | 逐个 GET | 全部 200 |
| 五个角色登录 | `POST /api/v1/auth/tokens` × 5 | 全部 200 |
| 各角色登录后落点与越权矩阵 | **真实执行 `auth.js` 的守卫函数**(node + DOM 桩) | advisor→投顾页、operator→运营页、risk→风控页、admin→工作台;交叉误入能弹回自己家 |
| 访客客服问答端到端 | 与浮窗同链路(V001 → R001 → 轮询 R002) | **4.11s / 4.09s / 4.82s**,三条全部 succeeded |
| 投顾页数据 | 走完整业务流(客户确认→管理员审核→管理员发布)后复查 | 投顾读到归属客户 9001 的方案书;客户只读到自己的;风控 403 |
| 平台门禁 | ruff / mypy / pytest / 表审计 / 文档检查 | ruff 干净 · **mypy 249 文件 0 错** · 单元+契约 **1376 passed** 2 skipped · 集成 **104 passed** · **89 张业务表** · 文档 55 份无编号冲突 |
| 前端 JS 语法 | 7 个文件按 ESM 解析检查 | 全部通过 |
**联调时发现并已修的问题**:
**联调中发现并已修的问题**:
1. **`T001` / `T009` 恒 404「客户未开户」** —— 根因是 `tools/seed_test_rbac.py` 把
`fund_account_status` **硬编码成 `'closed'`**,与 `tools/create_test_user.py` 的
`{customer: 已开户, employee: closed}` 口径不一致。已改为按 `user_type` 取状态;
再配合 `python -m tools.seed_sim_account_demo` 开虚拟资金账户后,T001/T006/T007/T009 全部 200。
2. **`rule_code` 筛选恒为空** —— `risk_repository.py` 用 `.contains([code])`,SQLAlchemy 会把它
编译成 `LIKE`,而 `trigger_rule_codes` 是 JSON 数组,等于匹配字符串 `'["RW-015"]'`。
已改为 `func.json_contains(...)`;扫描去重处的同一写法也一并修了。
3. **误提交的 `.agents/skills`(15 个文件)** —— AI 助手配置,与本项目无关,已从仓库移除并加入 `.gitignore`。
1. **`T001`/`T009` 恒 404「客户未开户」** —— `tools/seed_test_rbac.py` 把 `fund_account_status`
硬编码成 `'closed'`,与 `create_test_user.py` 的 `{customer: 已开户, employee: closed}` 不一致。
已改为按 `user_type` 取;配合 `seed_sim_account_demo` 后 T001/T006/T007/T009 全部 200。
2. **`rule_code` 筛选恒为空** —— `risk_repository.py` 用 `.contains([code])`,SQLAlchemy 编译成 `LIKE`,
而 `trigger_rule_codes` 是 JSON 数组。已改为 `func.json_contains(...)`,扫描去重处同一写法一并修。
3. **误提交的 `.agents/skills`(15 个文件)** —— AI 助手配置,与本项目无关,已移除并加入 `.gitignore`。
4. **投顾/运营登录后静默弹回登录页** —— `staffHomeForRoles` 缺这两个角色的落点,
进 workspace 后被 `requireAdmin` 弹回且**不带任何提示**,看起来像"登录没反应"。
已补两个落点与 `requireAdvisor`/`requireOperator` 守卫。
5. **访客客服浮窗一问即失败** —— 客服代码让访客走 `query_knowledge`,但发布配置的三个知识意图
只发了 `search_knowledge`,工具白名单直接抛 `ForbiddenAgentError`。已补发 `query_knowledge`
(保留 `search_knowledge` 给登录客户)。
6. **访客页的"演示数据"声明被删掉、但假数据还在渲染** —— 已恢复 `MOCK_SOURCE_NOTICE`、
两个页面的提示条与样式,并给 `link`/`script` 加版本参数(此前会被浏览器缓存)。
7. **发布脚本会静默丢提示词** —— `publish_customer_service_config.py` 只继承
`platform_config_item`,把 `customer_service_chitchat` 提示词漏在了旧版本里。
已改用 `effective_snapshot()` 读全三张受管表、补提示词搬运与条数硬校验;丢失的提示词已按原文恢复。
---
## 11. 已知问题(未修)
| 项 | 说明 |
|---|---|
| **知识库没有场内 ETF 内容** | 检索命中的是"南方科技理财产品手册"素材,问 ETF 类问题必然分数不足而转人工。**属知识内容工作,需业务提供素材后灌库** |
| `fin_knowledge_meta` 表为空 | **Milvus 里有知识**(`FAQ-*`/`PROD-*`/`POL-*` 都能检索到),但 MySQL 元数据表 0 行 ⇒ 管理端列表(`GET /api/v1/knowledge/list`)看不到内容、也无从删除。元数据与向量不一致 |
| 品牌名三处不一致 | 后端 Agent 自称"**奶龙基金**"(`customer_service_rules.py`、`risk_agent.py`),前端全站是"**南方财富**",闲聊提示词与知识素材写的是"**南方科技**"。**需要你定对外用哪个**;注意 `fund_manager` 字段值是"南方基金"且被同步服务用作过滤条件,**不能改** |
| `ADVISOR_GOAL` / `ADVISOR_ANALYSIS` 声明未用 | 投顾页只调了 `ADVISOR_PUBLISHED`;且 `portfolio-analysis` 的 body 必须是 `{}`(空模型 + `extra="forbid"`) |
| `widget.js` 里的 `portal:auth-changed` 监听 | 是死代码 —— `auth.js` 从不派发该事件(它用 BroadcastChannel) |
| 同一浏览器不能并存两个账号 | 见 8-5,是跨标签同步的代价,若不符合预期需要改回 |
---
## 附:演示账号
| 角色 | 用户名 | 密码 | user_id | 入口 |
|---|---|---|---|---|
| 客户 | `cust_t` | `123456` | 9001 | `/portal/customer/login/` |
| 风控专员 | `risk_t` | `666666` | 9002 | `/portal/employee-console/login/` |
| 管理员 | `admin_t` | `88888888` | 9003 | 同上 |
| **运营** | `offsite_t` | `offsite123` | 9006 | 同上(→ 运营工作台) |
| **投顾** | `advisor_t` | `abc12345` | 9020 | 同上(→ 投顾工作台) |
> ⚠️ `tools/set_user_password.py` 的内置演示规则**只写了 9001/9002/9003**;
> 9006 与 9020 的口令是建号时单独设的,**换环境重建时会没有密码**,需要用
> `python tools/set_user_password.py --user 9006 --password <口令>` 补设。
> 另:9004 `review_t`、9005 `offsite_worker` 是占位符,**登不了**。