From 776b504ee6b83e401654b89f8a58bd705445d14e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Tue, 15 Sep 2026 00:10:23 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8A=E5=85=A8=E5=B9=B3?=
=?UTF-8?q?=E5=8F=B0=E5=8A=9F=E8=83=BD=E6=B5=81=E7=A8=8B=20=C2=B7=20?=
=?UTF-8?q?=E5=A4=A7=E7=99=BD=E8=AF=9D=E7=89=88=E3=80=8B=EF=BC=9A=E4=BB=8E?=
=?UTF-8?q?=E7=99=BB=E5=85=A5=E8=AE=B2=E5=88=B0=E5=85=AD=E9=97=A8=E6=88=B7?=
=?UTF-8?q?=E5=85=A8=E9=83=A8=E6=B5=81=E7=A8=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 读者是第一次接触平台的人:全文不用"链路/编排/收敛"这类词,讲"点一下之后发生了什么"
- 结构:三个角色(浏览器/API/Worker)→ 访客 → 登入与角色落地 → 客户 8 页 → 风控 →
投顾 → 运营三条线 → 管理员 8 个页签 → 底座 9 件事 → 一条主线时间线 → 常见疑问 → 速查
- 每节都标了文件/接口依据,并写清"已知空数据"与"已知小偏差"(通知类型下拉未接线、
NL2SQL 错误信息列恒为 --、推广页无 form 导致 required 不生效、场外默认 dry-run 等)
- 顺带纠正三处易误传的说法:幂等键只防"同一次请求重发"(连点仍会下两笔)、
客户侧前端权限门是装饰性的(后端 403 才是拦截)、FM-03 熔断目前只在前端拦
- 风控演示数据那 6 行 RISKDEMO 场外申购/赎回:代码注释与今日盈亏说明改口径为
"风控异常交易演示的触发材料,刻意保留",不再当脏数据;今日盈亏按 transaction_type 语义排除它们
---
app/service/trade_service.py | 11 +-
docs/演示用/今日盈亏实现说明-2026-09-14.md | 13 +-
docs/演示用/全功能流程-大白话版.md | 709 +++++++++++++++++++++
3 files changed, 723 insertions(+), 10 deletions(-)
create mode 100644 docs/演示用/全功能流程-大白话版.md
diff --git a/app/service/trade_service.py b/app/service/trade_service.py
index bb4299a..5912ddf 100644
--- a/app/service/trade_service.py
+++ b/app/service/trade_service.py
@@ -274,11 +274,12 @@ class TradeService:
) -> _TodayTradeFlow:
"""基准日当天该客户在该产品上的成交汇总(用于反推昨日持仓数量)。
- **只认本平台的场内成交**(`transaction_type` ∈ {买入, 卖出})。演示库里
- `fin_transaction` 混进了风控演示用的场外申购/赎回(`RISKDEMO-*`,`transaction_type`
- 是「申购」「赎回」,且不改持仓表):把它们算成当日买卖会让「昨日持仓数量」凭空多出
- 几十万份、今日盈亏变成几万元(实测 12001 从 −21.22 变成 −1612.72)。
- `AGENTS.md` 规则 8 也要求场外流程不写场内表 —— 这里按类型甄别,不依赖编号前缀。
+ **只认本平台的场内成交**(`transaction_type` ∈ {买入, 卖出})。演示库里 6 行
+ `RISKDEMO-*` 记录(`transaction_type` 是「申购」「赎回」)是**风控「异常交易」演示的
+ 触发材料,刻意保留**,不是真实成交:它们不改持仓表,份额也与持仓对不上
+ (客户 12001 实际持有 10000 份,那条"赎回"写的是 750000 份)。
+ 把它们当当日买卖会把「昨日持仓数量」算成 76 万份、今日盈亏从 −21.22 变成 −1612.72。
+ 这里按 `transaction_type` **语义**甄别,不依赖编号前缀 —— 换一批演示数据也不用改代码。
"""
rows = (
(
diff --git a/docs/演示用/今日盈亏实现说明-2026-09-14.md b/docs/演示用/今日盈亏实现说明-2026-09-14.md
index 8ef91af..122c47b 100644
--- a/docs/演示用/今日盈亏实现说明-2026-09-14.md
+++ b/docs/演示用/今日盈亏实现说明-2026-09-14.md
@@ -52,14 +52,17 @@
会拿到**同一天的两行**,把"昨收"算成"今收"(盈亏恒等 0)。
→ 实现里取 8 行**按日期去重**后再取两日(`_latest_two_trading_days` / `_latest_two_nav_days`)。
-### 3.2 演示库里混进了场外申购/赎回
+### 3.2 演示库里混进了场外申购/赎回(**刻意保留的演示材料**)
`fin_transaction` 里有 6 行 `transaction_no` 形如 `RISKDEMO-*`、`transaction_type` 是
-「申购」「赎回」的记录(风控演示用,**不改持仓表**,违反 `AGENTS.md` 规则 8 但已在库里)。
-它们 `order_side` 是 `buy`/`sell`,若按 `order_side` 汇总,客户 12001 的"昨日持仓数量"
-会被抬到 **760000 份**,今日盈亏从 `-21.22` 变成 **`-1612.72`**(多出两个数量级)。
+「申购」「赎回」的记录。**它们是风控「异常交易」演示的触发材料,是有意保留的**,
+不是脏数据、也不是漏删:风控专员那边的预警队列要靠它们才能造出"大额赎回""频繁申购"这类场景。
+它们**不代表真实场内成交**(不改持仓表:客户 12001 实际持有 10000 份,那条"赎回"写的是 750000 份)。
+
+问题在于:若按 `order_side` 汇总当日成交,客户 12001 的"昨日持仓数量"会被抬到 **760000 份**,
+今日盈亏从 `-21.22` 变成 **`-1612.72`**(多出两个数量级)。
→ 实现里**只认 `transaction_type` ∈ {买入, 卖出}**(本平台场内下单的唯一写入值),
-按**语义**甄别而不是按编号前缀。
+按**语义**甄别而不是按编号前缀 —— 这样演示材料可以继续保留,换一批演示数据也不用改代码。
### 3.3 货币 ETF 的净值与价格不同量纲
diff --git a/docs/演示用/全功能流程-大白话版.md b/docs/演示用/全功能流程-大白话版.md
new file mode 100644
index 0000000..80cb9bf
--- /dev/null
+++ b/docs/演示用/全功能流程-大白话版.md
@@ -0,0 +1,709 @@
+# 全平台功能流程 · 大白话版(2026-09-14)
+
+> **读者**:第一次接触这个平台的人(演示听众 / 接手的人 / 业务方)。
+> **写法**:不用"链路""编排""收敛""双写"这类词,讲的是"**点一下之后到底发生了什么**"。
+> 每一句说法都能在代码或数据库里查到,关键处标了文件路径,方便你反查。
+> **配套**:想照着演一遍看 `docs/44-演示流程.md`;只想看「今日盈亏」看 `今日盈亏实现说明-2026-09-14.md`。
+
+---
+
+## 0. 三十秒版:这平台到底是什么
+
+1. 它是一个**基金业务的"通用 Agent 平台"**:把"会话、登录鉴权、记忆、模型调用、工具调用、
+ 合规校验、审计留痕、配置发布"这些**每个业务都要用**的东西做成底座,
+ 上面挂着 **7 个业务 Agent** 和**一条不做 AI 的模拟交易服务**。
+2. **不做真实交易**:场内基金是**模拟盘**(钱是假钱),但**行情是真行情**(取自公开数据源)。
+ 场外基金那条线只做"运营流程"(收邮件、认单据、发通知),同样不下真实单。
+3. 对外一共**六个门**(门户),每个角色一个:
+
+| 门户 | 地址 | 谁能进 | 一句话作用 |
+|---|---|---|---|
+| 访客 | `/portal/guest/home/` | 任何人(不用登录) | 看产品、问客服 |
+| 客户 | `/portal/customer/login/` | 客户 | 看资产、下单、看流水、做风险测评 |
+| 风控 | `/portal/employee-risk/dashboard/` | 风控专员 | 处置预警、出日报 |
+| 投顾 | `/portal/employee-advisor/dashboard/` | 投顾 | 给自己服务的客户出方案 |
+| 运营 | `/portal/employee-operations/offsite/` | 运营 | 场外收件、推广材料、问数(NL2SQL) |
+| 管理员 | `/portal/employee-console/workspace/` | 管理员 | 权限、配置发布、审计、工单 |
+
+> 员工侧**只有一个登录页**:`/portal/employee-console/login/`。
+> 登录后系统按你的角色把你送到上面四个工作台里的某一个(见 §3)。
+> 客户侧有**自己的**登录页 `/portal/customer/login/`,员工账号在客户登录页会被拒绝,反之亦然。
+
+---
+
+## 1. 跑起来一共三个"人":浏览器、API、Worker
+
+看懂这三者的分工,后面所有的"为什么没反应"都能自己判断。
+
+| 角色 | 是什么 | 少了它会怎样 |
+|---|---|---|
+| **浏览器** | 六个门户的页面(原生 JS,没有前端框架)。它只做两件事:调接口、把结果画出来 | 没有界面(但接口还能用 curl 调) |
+| **API** | `python -m uvicorn app.main:app --port 8000`。所有接口 + 所有页面都从它出 | 什么都没有 |
+| **Worker** | `python -m app.worker`。**后台慢慢干的活**都归它 | 客服对话永远"繁忙/超时";新知识搜不到;记忆不更新;**而且它不跑通常一声不响** |
+
+**Worker 具体干哪些活**(都在 `app/worker/runtime.py` 的 `run_once()` 和
+`app/worker/__main__.py` 里,一轮一轮轮询):
+
+1. **Agent 运行**:把"排队中"的 Agent 请求领出来真正执行(**这一条最关键**,见下);
+2. 领域事件投递(`domain_event_outbox`):事件先落库、再由它一条条消费,失败就重试、重试到头进死信;
+3. 知识向量同步:把知识库内容写进 Milvus(不写进去,客服就检索不到);
+4. 记忆抽取与画像重建:从对话里提炼长期记忆 → 重建画像 → 投到 Neo4j(关系)与 Milvus(向量);
+5. 会话片段聚合(把若干轮对话合成一个"事件片段",供长期记忆用);
+6. 清理派生数据:记忆失效/销户时删掉图库与向量库里的副本,删不掉就如实记"跳过",**不假装删了**;
+7. **场外收件**:`OffsiteMailWorker` 在**同一个进程、同一个入口**里跑(邮箱收信、认单据)。
+
+> ⚠️ **"客服一直转圈/超时" 十有八九是 Worker 没在跑**。
+> 因为 Agent 请求是**三段式**:API 只负责受理并返回 `202`("收到了")→ 请求进队列
+> (`agent_run.status='queued'`)→ **Worker 领单才真正执行**,结果再落库。
+> 没有 Worker,队列里那行就一直停着,而页面只会显示"客服繁忙/响应超时"——
+> 看起来像慢,其实是**没人处理**。一句话排查:查 `agent_run` 最新那行是不是 `queued`。
+
+**数据库和中间件**(都在本机):
+
+| 组件 | 端口 | 干什么 |
+|---|---|---|
+| MySQL | 3306 | **唯一权威数据源**,90 张表 |
+| Redis | 6379 | 配置缓存、客服会话短期上下文 |
+| Milvus | 19530 | 向量检索(知识库片段、长期记忆向量) |
+| Neo4j | 7687 | 客户画像的**关系**(标签、产品、多跳),是派生视图,**一律以 MySQL 为准** |
+
+**一键启动**:双击桌面的 `启动金融Agent平台.bat`(或 `powershell -File start.ps1`)——
+它按顺序做六件事:找解释器 → 检查 MySQL/Redis/Milvus → 刷行情 → 起 **API 与 Worker 两个窗口**
+→ 等 API 真正应答 → 自动打开浏览器。**重复双击是安全的**,不会起第二份。
+⚠️ 那个 bat 必须同时满足"GBK 编码 + CRLF 换行 + 无 BOM",所以**别手写**,
+改完 `start.ps1` 后跑 `python tools/make_launcher_bat.py` 重新生成。
+
+---
+
+## 2. 第一眼:访客门户(不用登录就能看)
+
+打开 ,它会跳到 `/portal/guest/home/`。三个页面:
+
+| 页面 | 看到什么 | 数据从哪来 |
+|---|---|---|
+| 首页 | 平台的宣传页 + 推荐位(前 3 只基金) | 推荐位来自 `GET /api/v1/products`,其余静态 |
+| 产品列表 | 场内基金清单、最新价、"最近涨跌";还能切"基金排行"视图 | `GET /api/v1/products`(真实接口,不是写死的假数据) |
+| 产品详情 | 产品要素 + **净值走势图** | 净值来自 `fin_nav_history`(当前库里 26 只产品、3253 个净值日) |
+
+**"最近涨跌"有个刻意的设计**:这个数**只显示行情源直接给的涨跌幅**。
+源没给、或这只产品走的是净值降级路径时,页面显示"**暂无**"而**不是 0** ——
+因为 0 会被读成"今天平盘",那是编数据。
+
+**右下角客服浮窗**(本平台最值得看的 AI 能力,因为它"会拒答"):
+
+1. 你发一句话 → 平台先判断**意图**(是问费率?问规则?还是问账户?);
+2. 命知识类意图 → 去**已发布的知识**里做向量检索;
+3. 有把握才答:命中分 ≥ 0.75 直接答;0.55~0.75 之间还要求"领先第二名 ≥ 0.07";否则不答;
+4. 答的时候**带引用来源**,你可以点开看出自哪份材料;
+5. 答不了 → 引导你**转人工**(点"转人工客服"才建工单);
+ 问"我的账户里有多少钱"这类**要登录才能查**的问题 → 它会明确说"需要登录后查询",**不编数字**。
+
+**它是"异步 + 轮询",不是"一问一答的同步调用"**:前端把问题交上去,接口立刻返回 `202`(收到了),
+然后每 0.5 秒问一次"好了吗",最多问 40 次(约 20 秒)。所以**Worker 没在跑时**,
+你看到的就是那句"客服繁忙,暂时没能给出答复"——不是模型慢,是没人处理。
+(平台也提供了更省事的 SSE 推送端点,但浮窗目前用的是轮询。)
+
+> 访客没有账号也能用,是因为平台给了它一张**范围极小的临时通行证**
+> (`POST /api/v1/visitor-tokens`,只带 `knowledge:query` 等最小权限)。
+> 客户/员工登录后走的是另一套令牌,权限大得多。
+>
+> 两个"知道就好"的前端细节:产品详情页如果 `?code=` 写错了,页面**不会报错**,
+> 而是静默展示第一只基金;「资金流水」页因为导航配置里 `active` 是空串,
+> **顶部导航不会高亮**它(功能正常,只是不好看)。
+
+---
+
+## 3. 登录:我该去哪个门、进去之后能看到什么
+
+### 3.1 一次登录到底做了什么
+
+1. 你在登录页填用户名 + 密码,前端调 `POST /api/v1/auth/tokens`(端点代号 **A034**);
+2. 接口返回你的**角色列表**(roles);
+3. 前端做一次**门禁检查**:客户登录页只接受 `customer`;员工登录页只接受
+ `risk_operator / operator / advisor / admin / super_admin`。角色的门走错了会被拦下,
+ 并明确提示"该账号不是客户账号,请使用后台登录入口"(`app/static/portal/common/login-controller.js`);
+4. 登录成功 → 令牌写进 **cookie(`auth_token`)+ sessionStorage 缓存**,
+ 跨标签页以 cookie 为准;
+5. 按角色**送到不同的工作台**(下表);客户如果**还没做风险测评**,会被强制先跳到问卷页
+ (`GET /api/v1/onboarding/risk-questionnaire` 返回 `required=true` 时);
+6. 令牌失效/换账号 → 自动跳回登录页并带 `?reason=session-expired` 之类的提示。
+
+### 3.2 角色 → 落地页(`app/static/portal/common/auth.js` 的 `staffHomeForRoles`)
+
+| 角色 | 登录后落到 |
+|---|---|
+| `customer`(客户) | `/portal/customer/dashboard/`(未测评则先到 `/portal/customer/risk-questionnaire/`) |
+| `risk_operator`(风控专员) | `/portal/employee-risk/dashboard/` |
+| `advisor`(投顾) | `/portal/employee-advisor/dashboard/` |
+| `operator`(运营) | `/portal/employee-operations/offsite/` |
+| `admin` / `super_admin`(管理员) | `/portal/employee-console/workspace/` |
+
+### 3.3 为什么"令牌里不带权限"
+
+**令牌里只有"你是谁",没有"你能干什么"。** 每次请求到达服务端时,
+平台**重新查一遍**你的角色、权限码和**数据范围**。这么做换来两件事:
+
+- 管理员改了权限,**下一次请求立刻生效**,不用等你重新登录;
+- 令牌被别人拿到,也不能靠它越权——服务端照样按当前权限判。
+
+### 3.4 越权有三道防线,但**只有后两道是真的在拦**
+
+| 防线 | 在哪 | 实际强度 |
+|---|---|---|
+| 第一道:前端隐藏/禁用入口 | 各门户页面(员工工作台会调 `A038` 拿服务端角色与权限后再刷按钮) | **体验层,不是安全层**。而且客户门户这一道目前是**装饰性的**:登录响应里不含权限清单,前端对客户角色直接按"全量权限"渲染,所以 `data-permission-required` 恒为"已授权"。**别把它当防线。** |
+| 第二道:权限码 | 服务端每个接口都要一个权限码,比如下单要 `trade:order:create` | **真拦**。没有就 403(`AGENT_PERMISSION_DENIED` 一类) |
+| 第三道:数据范围 | `data_scope` + **客户归属关系**(`sys_customer_assignment`) | **真拦**。有"看客户"的权限,也只能看**分配给自己**的客户 |
+
+> 换句话说:**能不能点,看前端;能不能成,看后端。** 判断"某人到底能不能做某件事",
+> 一律以服务端为准;前端只是提前把不该看到的藏起来。
+>
+> 第三道是最容易被忽略、也最关键的一道:**员工号和客户号是同号段的**
+> (演示数据里客户 9001-9020、员工 9002/9020 并存)。
+> 如果只按"号"去查,风控/投顾就会读到**陌生客户**的长期记忆,而且不会报错。
+> 所以平台把"能读哪些客户"**收敛到一个文件**(`app/core/memory_scope.py`):
+> 客户只读自己;员工只读分配给自己的客户,且单次最多 10 个;归属没维护就**失败关闭**。
+
+---
+
+## 4. 客户门户:八个页面,一页一句
+
+| 页面 | 地址 | 干什么 | 主要接口 |
+|---|---|---|---|
+| 登录 | `/portal/customer/login/` | 登录 | A034 |
+| **资产总览** | `/portal/customer/dashboard/` | 总资产、持仓市值、累计盈亏、**今日盈亏** | T001 |
+| **我的持仓** | `/portal/customer/holdings/` | 每只基金的份额、成本、最新价、市值、持有盈亏、今日盈亏 | T006 |
+| 委托 | `/portal/customer/orders/` | 我下过的委托及状态(**页面上没有撤单按钮**:市价单下单即成交,能撤的极少) | T003 / T004(撤单接口 T005 已就绪但页面未接) |
+| 成交 | `/portal/customer/transactions/` | 已经成交的每一笔(价格、数量、费用) | T007 / T008 |
+| 资金流水 | `/portal/customer/cash-ledger/` | 每一笔钱的进出与余额变化 | T009 |
+| 盈亏分析 | `/portal/customer/profit-loss/` | 累计盈亏 / 今日盈亏 + 按持仓拆分 | T001(复用看板数据) |
+| **风险测评** | `/portal/customer/risk-questionnaire/` | 填问卷,决定你的风险承受等级(C1–C5) | ONB001 / ONB002 |
+
+### 4.1 下单一笔,中间发生了六件事(这是全平台最该讲清楚的一条)
+
+1. **你点了买入** → 前端调 `POST /api/v1/users/me/orders`(**T002**),
+ 并且必须带一个**幂等键**(`Idempotency-Key`,16–128 个可打印 ASCII 字符)。
+ 它的作用是:**同一次请求被重发(超时重试、网络抖动)时只成交一笔**。
+ ⚠️ 注意边界:**它防的是"同一次请求的重发",不防"你又点了一次"**——
+ 前端目前每提交一次就生成一个新键,所以手动连点两下**确实会产生两笔委托**。
+ 要防这个得让前端为"一次下单意图"复用同一个键,属于**已知的前端缺口**。
+2. **服务端先看行情**:取这只基金的最新行情快照,要求它在 **15 分钟内**。
+ 超过 15 分钟直接拒绝(`503 行情已过期`)——**这是演示最容易翻的一环**,
+ 因为系统**没有任何自动刷新**,补刷命令是 `python tools/sync_market_prices.py`(立即生效,不用重启)。
+3. **适当性校验**:你的风险等级(C1–C5)和这只基金的风险等级(R1–R5)必须匹配,
+ 不匹配直接拒单。这就是"**风险测评不是形式**"的落地处。
+4. **算钱**:按 `fin_fee_rule` 里优先级最高且生效的费率规则算手续费,
+ 成交时把费率**固化**进这笔成交记录(以后改费率不影响历史)。
+5. **落账(同一个数据库事务里同时写四处)**:委托、成交、资金流水、持仓。
+ 任何一处失败,整笔回滚。所以这三页(资产/持仓/流水)看到的数字**永远对得上**。
+6. **成交价来自外部行情**,不是随机数;订单状态直接是"已成交"——
+ 因为首版是**市价委托立即全额成交**,没有撮合排队。
+
+> 顺带解释一个常被当 bug 的现象:**撤单几乎总是失败**。
+> 因为市价单下单即成交,只有停在"待风控"的委托才能撤,所以页面点撤单大概率提示"该委托已成交"。
+
+### 4.2 「今日盈亏」是怎么算的(2026-09-14 起是真实数据)
+
+一句话:**今天收盘时值多少钱 − 昨天收盘时值多少钱 − 今天买入花掉的钱 + 今天卖出收回的钱**
+(不含手续费,和旁边的"持有盈亏"同口径)。
+
+两个值得在演示时说清的细节:
+
+- 当天买的份额**只算"买入价 → 今收"**,不享受昨天到今天那段涨幅(昨天它还不是你的);
+- 行情只有一天的产品会**回退用净值**算,所以不会出现"某只永远是 0"。
+
+完整口径、三个数据坑、逐只复算命令见 `今日盈亏实现说明-2026-09-14.md`。
+
+---
+
+## 5. 风控专员工作台(预警的"从哪来 → 怎么处置 → 怎么闭环")
+
+**进门**:从员工登录页登录后自动落到 `/portal/employee-risk/dashboard/`;
+页面加载时先调 `A038` 把**服务端真正解析出来的**角色、权限、数据范围拿回来,
+再据此决定哪些按钮可点(这就是 §3.4 说的"第一道只是体验层")。
+
+### 5.1 上半屏:概览四张卡
+
+| 卡片 | 含义 |
+|---|---|
+| 未闭环预警 | 还没走完处置流程的预警总数 |
+| 高风险 | 其中等级为"高风险"的数量 |
+| 待处理 | 状态还是"待处理"的数量 |
+| 已超时 | 超过处置时限还没动的(**大于 0 会标红**) |
+
+### 5.2 预警是从哪来的
+
+两条路:**页面上点"手动扫描"**(`RK006`,同步执行、最长 60 秒),
+或**定时调度**(`app/worker/risk_scan_scheduler.py`,是个独立的可选进程)。
+扫描会按规则(`RW-xxx`)核对交易、资金、持仓、登录等数据,命中就落一条预警。
+
+> **演示数据里那些"异常交易"记录是刻意的**:客户 12001–12005 名下有 6 行
+> `RISKDEMO-*` 记录(大额申购/赎回),就是**为了造出"大额赎回""频繁申购"这类预警场景**
+> 才写进去的**演示触发材料,有意保留**,不是脏数据、也不是漏删。
+> 它们**不代表真实场内成交**(不改持仓表,份额也和持仓对不上)。
+> 副作用只有一个:算「今日盈亏」时必须**只认本平台的场内成交**(买入/卖出),
+> 否则这些虚拟份额会把"昨日持仓"算到几十万份 —— 详见 `今日盈亏实现说明-2026-09-14.md`。
+
+### 5.3 预警队列与筛选
+
+游标分页(不是页码),**每页固定 10 条**。筛选项:关键词、客户编号、产品代码、
+风险等级(高/中/低)、规则编号(必须形如 `RW-001`)。
+后端还支持"产品名 + 时间范围"两个筛选,**页面暂未提供输入框**(属可用性缺口,不是故障)。
+
+### 5.4 预警详情:为什么说它"有据可查"
+
+一条预警点开,能看到四层东西:
+
+1. **基本信息**:预警编号、风险等级、客户、产品、处置状态、确认状态、命中规则、是否已升级;
+2. **证据摘要**:触发这条预警的那几条关键事实;
+3. **七个关联业务区段**:把这个客户在这条线索上的交易、资金、持仓等上下文摆在一起;
+4. **证据快照 / 证据归档**:当时的字段原样留存,事后可复查。
+
+另外"证据查询"标签页提供 **8 类只读实体**的直查:客户、产品、交易、资金流水、
+持仓、登录记录、预警、通知。**全部是只读的**,改不了任何数据。
+
+### 5.5 处置是"五步状态机",不是改个字段
+
+| 动作 | 效果 |
+|---|---|
+| **确认接收** | 只记录"谁在什么时候看到了",**状态不变** |
+| **进入调查** | 状态:待处理 → 调查中 |
+| **关闭误报** | 状态 → 已排除,**必须填理由** |
+| **完成结案** | 只有"调查中"的才能结案;**必须填结论**,并且**按风险等级扣客户行为分:低 3 / 中 5 / 高 20**(行为分 0–20 封顶) |
+| **升级处理** | 只打"已升级"标记,**不改状态**,可以和上面几步并行 |
+
+每一步都写审计(谁、何时、做了什么)。所以"这条预警谁处理的、依据是什么"事后能查清。
+
+### 5.6 日报与助手
+
+- **日报**:点生成后**逐段出现**(SSE 推流),一共 9 段(当日数量、等级分布、重点事件、
+ 未闭环、误报情况、类型分布、处置结果、规则效果、优化建议)。
+ 末尾会标来源:模型可用时是"大模型生成",不可用时**自动降级为规则模板**并如实标注——
+ **降级不是报错**。生成完可以填多个邮箱发送(逗号/分号/空格分隔)。
+- **风控助手**:预设问法 6 个(通用的 3 个 + 绑定当前预警的 3 个)。
+ 它只会调三个**只读**工具:`get_risk_overview`、`search_risk_alerts`、`get_alert_evidence`,
+ 输出的是"研判与话术草案",**并明确声明不构成任何处置结论,处置一律由人工做**。
+- **通知记录**:可以看到每条通知的渠道、发送状态、失败原因。顶部铃铛只统计"站内提醒"。
+ ⚠️ 邮件通道依赖 SMTP 配置,授权码失效时会显示"发送失败"(详见 §12)。
+
+---
+
+## 6. 投顾工作台(给自己服务的客户出方案)
+
+**进门**:员工登录页登录 → 落到 `/portal/employee-advisor/dashboard/`。
+投顾能操作的客户范围**由服务端按归属关系限定**(`sys_customer_assignment`:本人 + 分配给我的客户),
+拿不到全量客户——这是数据范围,不是页面缺陷。(页面上那份"我的客户"名单**目前是内置的演示数据**,
+真正的范围以服务端为准,见 §6.1。)
+
+### 6.1 页面上有什么
+
+| 区域 | 作用 |
+|---|---|
+| 概览四张卡 | 已发布交付物数量、名下客户数、风评熔断数、会话来源 |
+| 客户区 | 客户列表 + 参数 + 7 个动作按钮 + 一句自然语言输入框。⚠️ **这份客户名单目前是页面内置的演示数据**(张总 9101 / 李阿姨 9102 / 王工 9103 / 陈医生 9104);**真正的客户范围在服务端**按归属关系(`sys_customer_assignment`)判定,并且有一个**灰度开关**:没开放的账号直接 403「当前投顾功能尚未对该账号开放」 |
+| 操作区 | 7 个动作:**组合分析 / 资产配置 / 生成推荐方案 / 录入客户目标 / 目标确认与方案书 / 画像评分 / 调仓建议** |
+| 结果区 | 动作结果 + **推荐流水线六步**进度(① 鉴权 ② 画像 ③ 筛选 ④ 方案 ⑤ 合规 ⑥ 审核留痕) |
+| 已发布交付物 | 已发布给客户的方案书/推荐方案(只列"我自己 + 归属我的客户"的,且已发布、倒序 20 条) |
+| 助手对话框 | 用自然语言下指令。⚠️ 它走的是**前端正则意图路由**(识别"组合分析/配置/推荐"等词后复用同一套按钮动作),**刻意没有走 Agent 异步链路**——因为异步 run 需要常驻 Worker 与发布配置,投顾工作台要保证离线也能演示 |
+
+### 6.2 三种动作,真实的只有三种(这点要讲清楚)
+
+| 动作 | 走哪里 |
+|---|---|
+| 组合分析、资产配置、生成推荐方案 | **走真实后端**(`/api/v1/advisor/portfolio-analysis`、`/asset-allocation`、`/recommendations`),并且会带上所选客户的 `customer_id` |
+| 画像评分、调仓建议 | 页面**本地演示引擎**算(后端暂无对应接口),数据取自页面内置的演示产品/客户表 |
+| 录入客户目标、目标确认与方案书 | 走真实后端(投资目标与方案书接口) |
+
+> 这是**刻意标注**的边界:页面内置了一张演示产品与客户表,只服务"离线演示"和那两个后端没有的动作。
+> 演示时如果被问"这个评分是怎么来的",照实说"这一步是前端演示引擎,后端暂未提供该接口"即可。
+
+### 6.3 生成推荐方案:为什么可能"一个产品都没有"
+
+这是最容易被误判成故障的一步。系统不是"随机挑几只",而是**逐道闸门筛**,全过了才进候选:
+
+1. **可交易**:产品状态是"上市"、在沪深交易所、当前在开放窗口内;
+2. **适当性证据**:该产品在治理表里有 `verified`(已核验)的适当性记录,机构、来源链接、文档摘要都要齐;
+3. **合同证据**:同样要有已核验的合同快照;
+4. **不在治理待办里**(有未决事项的产品先排除);
+5. **规模**(如果设了门槛);
+6. **流动性**:日均成交额要达到门槛(日频 1000 万、7 日内 100 万、30 日内 10 万);
+7. **硬性适当性过滤**:按客户风险等级(C1–C5)挡掉不匹配的产品;
+8. 剩下的按规则**排序**取前列。
+
+所以"候选池为空"时,页面会**把原因讲清楚**(证据缺失/流动性不够/适当性不匹配/没有客户目标),
+并给出两条路(补证据材料,或走管理员审核的既有交付物)——**不是一句"没有通过校验的产品"**。
+实测:客户 9101(C4)出的方案里有 3 只 R4 产品,9102(C1)只有 1 只 R1。
+
+### 6.4 审核与发布:哪一步投顾自己能做
+
+| 交付物 | 投顾能做什么 | 谁来做审核/发布 |
+|---|---|---|
+| **产品推荐方案** | 生成、**自助审核 / 驳回 / 发布给客户** | 投顾自己(2026-09-14 起开放),管理员复核队列同样能做 |
+| **投资目标方案书** | 录入目标 → 确认目标 → 查看方案书 | **仍归管理员**(接口上是 `admin=True` 的复核环节,故意的) |
+
+方案书与推荐方案都有状态:目标 `待确认 → 已确认`;方案书 `待审核 → 审核通过 → 已发布`(或已退回)。
+
+### 6.5 合规熔断(FM-03)
+
+客户的风险测评**超过 12 个月**即视为失效:页面会**冻结购买类动作**
+(生成推荐方案、资产配置、画像评分、调仓建议),只留赎回方向,并明确显示熔断原因。
+⚠️ 这个拦截目前发生在**前端闸门**——后端只判断"测评有没有有效期字段",
+不比较是否已过期。所以**不要绕过页面直接调接口来"演示"这件事**,那里拦不住。
+
+### 6.6 一个专门的"离线演示模式":网址后面加 `?demo=1`
+
+投顾工作台有一条**唯一能绕过登录守卫**的路径:`/portal/employee-advisor/dashboard/?demo=1`。
+进去之后:
+
+- 不持有令牌(所有真实接口请求都不发),页面顶部明确标注「**演示模式 · 本地引擎**」;
+- 数据范围文案改成"本地演示引擎",指标卡的数据源也标成"本地引擎";
+- 生成的方案带「**规则模拟,不构成真实推荐**」字样;
+- 没有退出按钮(本来就是离线)。
+
+**它是给"没有后端 / 没有 Worker 也要讲一遍投顾流程"准备的**,演示时务必**主动说明这是演示模式**,
+别让人以为真实链路就是这么跑的。
+
+---
+
+## 7. 运营门户(三条线:场外收件 / 推广材料 / 问数)
+
+**进门**:员工登录页登录后落到 `/portal/employee-operations/offsite/`。
+顶部导航四个入口:概览、场外基金、推广材料、NL2SQL。
+
+### 7.1 线一 · 场外基金受理:把"收到的邮件"变成"核对过的单据 + 发出的通知"
+
+这是一条**确定性流程**(不靠模型自由发挥),完整走一遍是这样:
+
+| 步 | 操作 | 背后发生了什么 |
+|---|---|---|
+| 1 | 看收件状态 | ⚠️ **页面上没有"上传邮件/投递"入口**:邮件是 Worker 通过 IMAP 从**真实邮箱**收进来的(**Worker 不在跑 = 永远不收信**),而且**发件人必须在白名单里**,否则回 403「发件人不在场外业务白名单」。状态显示"运行中/未启用";游标卡住时可以点"恢复收件游标"(⚠️ 它只把状态从"阻塞"重置为"空闲",**不会跳过那封失败的邮件**) |
+| 2 | 打开一封邮件 | 左侧列表 → 右边显示正文、**附件原件**(可预览/下载)、**OCR 识别字段**、关联单据 |
+| 3 | **人工修正 OCR 字段并保存** | 识别不可能 100% 准,所以运营可以逐字段改。**保存后系统会自动重新核对一遍**(触发 NL2SQL → 拉回字段 → 重判规则),不用再手动点一次 |
+| 4 | (必要时)识别重试 | 附件识别失败可以提交重试 |
+| 5 | 看单据要素 | 申请日期、申购金额 / 赎回份额、机构、基金代码 |
+| 6 | 看**规则结果**表 | 每条规则一行:规则名 / 结果(满足·不满足·无法判断)/ 单据值 / 规则判断。可点"重新核对并判定规则"(这一步**只重跑计算与判定**,不会重新识别、也不会重新生成自然语言) |
+| 7 | **人工下结论** | "确认正常" 或 "确认异常" —— 这是**人的决策**,系统只给依据。⚠️ 识别或核对没走完就点确认,会回 422「识别或数据核对尚未完成,不能进行业务确认」 |
+| 8 | 创建并发送通知 | 按结论选通知类型(邮件回执 / 正常回执 / 异常回执 / 风险通知 / 清算通知),**正文可编辑**,然后发送 |
+| 9 | 收尾 | 结清统计可重算(只统计"已确认正常且已成功回执"的单据);也可以彻底删除一封邮件(附件、OCR、规则、通知一并删除,**删除审计保留**) |
+
+三个"知道就好、别被问倒"的点:
+
+- 后端对**通知类型与确认结论的一致性有硬校验**:风险通知 / 异常回执只允许"已确认异常",
+ 清算通知 / 正常回执只允许"已确认正常",邮件回执必须先完成人工确认——**对不上直接 422**。
+- ⚠️ **发送通知默认是"演练"(dry-run)**:本机配置里 SMTP 发送是关着的、dry-run 是开着的,
+ 所以点"发送通知"会返回发送结果与状态,但**不会真的把邮件发出去**。要真发得先配 SMTP。
+- ⚠️「收件状态」显示的是**配置**,不是 Worker 心跳:Worker 假死时它**仍然显示"运行中"**。
+ 判断 Worker 死活要看进程,别只看这一格。
+
+> ⚠️ 这一步是演示最容易尴尬的地方:**邮件列表可能是 0 条**,因为收件依赖真实可用的邮箱配置;
+> 发通知还额外依赖 SMTP 授权码。没有真实邮件源时,这页就是空的——**是"已知空数据",不是坏了**。
+
+### 7.2 线二 · 推广材料:把产品事实编排成"可审核、可交付"的材料
+
+| 步 | 操作 | 要点 |
+|---|---|---|
+| 1 | 创建任务 | 任务编号、产品名称、产品代码、材料标题、视觉风格(均衡配置 / 稳健专业 / 成长研究)。**进页面不会发任何请求**,任务号由后端按 `PM-日期-序号` 生成 |
+| 2 | 填结构化资料 | 产品信息、管理人信息、团队与策略、费用/业绩/风险四组。⚠️ 页面上带 `*` 的必填**其实不会触发浏览器校验**(页面没有 `