From bfd3964ef85088b2d2cec42ddc92fac4dd44d318 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: Sun, 13 Sep 2026 21:56:27 +0800
Subject: [PATCH] =?UTF-8?q?feat(demo):=20=E6=BC=94=E7=A4=BA=E6=95=B0?=
=?UTF-8?q?=E6=8D=AE=E4=B8=80=E9=94=AE=E5=87=86=E5=A4=87=E3=80=81=E5=90=AF?=
=?UTF-8?q?=E5=8A=A8=E8=84=9A=E6=9C=AC=E4=B8=8E=E6=BC=94=E7=A4=BA=E6=B5=81?=
=?UTF-8?q?=E7=A8=8B=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
交付"别人能自己把系统跑起来做演示"所需的四件东西:
- tools/seed_demo_data.py 演示数据一键准备:10 步按依赖排序
(此前散在 10 个脚本里,没人知道该跑哪些、按什么顺序跑,且知识库那步
根本没有脚本、靠手工调接口)
- tools/seed_knowledge_demo.py 知识库演示素材灌入,幂等(先删同 source_file 再灌)
- start.ps1 启动 API + Worker,含依赖检查与**自动刷新行情**
- docs/44-演示流程.md 8 个主线场景的照读流程 + 排障表 + 账号/命令速查
两条硬约束同时写进了脚本和文档:
1. **Worker 必须常驻**:没有它客服对话一直停在 queued(前端只显示"超时")、
新知识不进 Milvus 且**没有任何报错**。
2. **行情有效期仅 15 分钟**(`trade_service.MAX_QUOTE_AGE`):超时后所有委托
直接 503「行情已过期」,而系统**没有自动刷新机制**。故 start.ps1 启动时刷一次,
文档另给"演示中途 503 时补刷、无需重启服务"的处置方法(已实测)。
实测证据:
- start.ps1 在 8099 完整启动,`/docs` 与 `/portal/` 均 HTTP 200(测试进程已清理)
- `tools/e2e_smoke_test.py` → 40/40 通过
- `tools/seed_demo_data.py --dry-run` → 10 步全部正常列出
- 文档中的下单命令实跑:510300 成交价 4.579、金额 457.90、手续费 0.05
---
docs/44-演示流程.md | 290 +++++++++++++++++++++++++++++++++++
start.ps1 | 167 ++++++++++++++++++++
tools/seed_demo_data.py | 138 +++++++++++++++++
tools/seed_knowledge_demo.py | 157 +++++++++++++++++++
4 files changed, 752 insertions(+)
create mode 100644 docs/44-演示流程.md
create mode 100644 start.ps1
create mode 100644 tools/seed_demo_data.py
create mode 100644 tools/seed_knowledge_demo.py
diff --git a/docs/44-演示流程.md b/docs/44-演示流程.md
new file mode 100644
index 0000000..dd34afc
--- /dev/null
+++ b/docs/44-演示流程.md
@@ -0,0 +1,290 @@
+# 演示流程(照着走)
+
+> **读者**:负责演示的人。本文按"操作 → 看到什么 → 这体现什么"三段式写,
+> 可以直接照着念。
+> **主线时长**:约 8 分钟;含讲解约 15 分钟。
+> **全部内容均已实测**(2026-09-13);标 ⚠️ 的地方是容易翻车的点。
+
+---
+
+## 0. 演示前准备
+
+### 0.1 准备演示数据(首次、或换了机器才需要)
+
+```powershell
+python tools/seed_demo_data.py
+```
+
+10 步:账号权限 → 演示口令 → 客户账户 → **场内行情** → 风控预警样本 → 投顾数据 →
+三类发布配置 → 知识库素材。脚本会逐步打印完成/失败。
+
+> ⚠️ 第 2 步**不是幂等的**:重跑等于重设密码(bcrypt 每次加盐不同)。
+
+### 0.2 一条必须知道的硬规则:**行情有效期只有 15 分钟**
+
+下单要求行情快照落在 **15 分钟内**(`app/service/trade_service.py` 的 `MAX_QUOTE_AGE`),
+**系统没有任何自动刷新机制** —— 超时后**所有委托直接返回 `503 行情已过期`**。
+
+演示时踩过这个坑:明明上午刷过行情,下午演示第一单就 503。
+
+**怎么办**:`start.ps1` **已经会替你刷新一次**(见 0.3),所以正常不用管。
+万一演示中途下单报 503 —— 在新窗口跑一次就行,**不用重启任何服务**(已实测):
+
+```powershell
+python tools/sync_market_prices.py
+```
+
+它按产品 upsert(幂等),几秒内把 20 只产品行情全部刷新,15 分钟窗口重新计时。
+
+> 不想让启动脚本联网刷行情:`start.ps1 -SkipPriceSync`
+
+### 0.3 启动平台
+
+```powershell
+powershell -ExecutionPolicy Bypass -File start.ps1
+```
+
+它按顺序做四件事:**找解释器 → 检查 MySQL/Redis/Milvus → 刷新行情 → 起两个窗口**,然后打印访问入口与账号。
+它会**开两个窗口**:
+
+| 窗口 | 作用 | 少了它会怎样 |
+|---|---|---|
+| API | 所有接口与页面 | 什么都没有 |
+| **Worker** | Agent 对话、知识向量同步、记忆抽取、风控扫描 | 客服对话一直"超时";新知识不进 Milvus 且**无任何报错** |
+
+### 0.4 开场前自检(务必做)
+
+```powershell
+python tools/e2e_smoke_test.py --read-only
+```
+
+看最后一行:**40/40 通过**才开始演示。有 FAIL 就按 §4 排查。
+
+### 0.5 打开第一个页面
+
+
+
+---
+
+## 1. 主线场景
+
+### 场景 1 · 访客咨询与知识边界(1.5 min)
+
+| | |
+|---|---|
+| **打开** | |
+| **操作** | 点右下角客服浮窗 → 输入「场内基金的管理费率是多少」→ 发送 |
+| **预期** | **约 3–4 秒**返回:本平台 20 只场内基金的管理费率 0.15%~1.20%/年,并列出 ETF 与 LOF 的差异 |
+
+**这体现什么**:客服不是"模型随便聊",而是**检索已发布知识后作答**,答案可追溯;
+延迟稳定在 3–4 秒(一次意图分类 + 一次向量检索)。
+
+**接着再问一句(体现安全边界)**:「我的账户里有多少钱?」
+→ 预期:回复"该服务需要登录后才能查询您的个人信息",**不编造账户数字**。
+
+> 浮窗上方还有一条来源声明:产品页数据来自 `mock-data.js`(公开产品接口尚未实现),
+> 页面显著标注了"非真实行情"。**这是我们主动标注的**,不是缺陷。
+
+### 场景 2 · 客户资产(1 min)
+
+| | |
+|---|---|
+| **打开** | |
+| **账号** | `cust_t` / `123456` |
+| **预期** | 自动进资产总览:总资产约 10 万、可用资金、持仓市值(7 只持仓) |
+
+**这体现什么**:持仓按**真实行情**计价(不是写死的假数);页面底部有数据来源说明。
+
+点左侧「我的持仓」「资金流水」「成交明细」各看一眼即可。
+
+### 场景 3 · 客户下单(1.5 min)⭐ 重点
+
+| | |
+|---|---|
+| **打开** | 客户页 → 交易记录 → 或直接用接口 |
+| **说明** | 前端下单表单待完善,本场用接口演示更直观(见下) |
+
+```powershell
+# 演示用:买入 100 份沪深300ETF
+$body = '{"product_code":"510300","order_side":"buy","quantity":100}'
+$r = Invoke-RestMethod -Uri http://127.0.0.1:8000/api/v1/auth/tokens -Method Post `
+ -Body '{"username":"cust_t","password":"123456"}' -ContentType application/json
+Invoke-RestMethod -Uri http://127.0.0.1:8000/api/v1/users/me/orders -Method Post `
+ -Headers @{ Authorization = "Bearer $($r.data.access_token)"; "Idempotency-Key" = [guid]::NewGuid().ToString("N") } `
+ -Body $body -ContentType application/json | ConvertTo-Json -Depth 4
+```
+
+**预期**:`status=已成交`、`executed_price=4.579`(**真实行情价**)、
+成交金额 457.90、手续费 0.05、实际扣款 457.95(`net_amount`)。
+
+**这体现什么**:
+- **成交价来自外部行情**,不是模拟随机数;
+- 下单前会做**适当性校验**(风险等级匹配),不匹配直接拒绝;
+- 成交后持仓、资金流水、成交明细**三处同时落账**(可当场刷新场景 2 的页面看变化)。
+
+> ⚠️ 如果返回 `503 FUND_QUOTE_UNAVAILABLE` → 跑 §0.2 的行情同步。
+
+### 场景 4 · 客服对话与转人工(1 min)
+
+| | |
+|---|---|
+| **操作** | 客户页右下角浮窗 → 问「基金定投是什么」→ 点「转人工客服」 |
+| **预期** | 先得到知识库答案;转人工返回 `202`,工单进入管理员队列 |
+
+**这体现什么**:客户**主动**请求人工与"答不上来"是两条不同路径 —— 前者建工单,
+后者按既定口径引导拨打客服热线(**不建工单**,符合我们"答不了就转人工、不硬答"的原则)。
+
+### 场景 5 · 风控预警处置(2 min)⭐ 重点
+
+| | |
+|---|---|
+| **打开** | → `risk_t` / `666666` |
+| **预期** | 概览:未闭环预警数、高风险数、待处理、已超时 |
+
+**操作顺序(这是核心闭环)**:
+
+1. 点预警队列里的某条 → 打开详情;
+2. 详情里有**八类证据**入口:客户、产品、交易、资金流水、持仓、登录记录、预警、通知;
+3. 点「确认接收」→ 状态变为已确认;
+4. 点「进入调查」→ 状态变为调查中;
+5. 点「完成结案」→ 状态变为**已结案**,并且**按风险等级扣减客户行为分**。
+
+**这体现什么**:预警处置是**有状态机、有留痕、有连带影响**的 ——
+不是改个字段。每一步都记审计(谁、何时、做了什么)。
+
+> 若队列里没有「待处理」的预警(都已被处置过),跑
+> `python tools/e2e_smoke_test.py` —— 它会自动造一条待处理预警并跑完整闭环。
+
+### 场景 6 · 风控日报(1 min)
+
+| | |
+|---|---|
+| **操作** | 风控工作台 → 日报 → 生成 |
+| **预期** | 文本**流式**逐段出现(SSE),完成后可填多个邮箱发送 |
+
+**这体现什么**:长任务走 SSE 流式,而不是让页面转圈等 30 秒。
+
+### 场景 7 · 投顾工作台(1 min)
+
+| | |
+|---|---|
+| **打开** | |
+| **账号** | `advisor_t` / `abc12345` |
+| **预期** | 一张「投资目标方案书 · 客户 9001」卡片,含发布时间与方案内容 |
+
+**这体现什么**:投顾看到的是**自己服务的客户**的已发布交付物。
+数据范围按 `sys_customer_assignment` 归属关系判定(不是按 `data_scope` ——
+那会把 all 级权限放大成"看全部客户")。
+
+> 方案书的**审核与发布都要求管理员**(`admin=True`),投顾自己发不出来。
+> 这是有意的复核环节,不是缺陷。
+
+### 场景 8 · 管理员治理(1.5 min)
+
+| | |
+|---|---|
+| **打开** | |
+| **账号** | `admin_t` / `88888888` |
+| **预期** | 四个指标卡 + 六个标签页 |
+
+**挨个点一遍,每处一句话**:
+
+| 标签页 | 讲解要点 |
+|---|---|
+| 角色权限 | 五个角色,权限数分别是 admin 59 / advisor 28 / customer 26 / risk_operator 10 / operator 2 |
+| 配置发布 | 工具白名单、提示词**走发布状态机**(校验→审核→激活),不是改配置文件 |
+| 审计 | 每一次权限判定、工具调用都有记录 |
+| 转人工工单 | 场景 4 建的工单在这里;**不返回客户标识与原始对话**(脱敏) |
+| 模型端点 | 当前用 `deepseek-flash`(意图分类)+ `qwen` 向量(embedding) |
+
+**这体现什么**:**权限变更必须过审核流程并留痕** —— 这是金融场景的硬要求,
+所以平台刻意**不提供**"直接改权限"的接口。
+
+---
+
+## 2. 备选场景(时间充足时)
+
+| 场景 | 怎么演 | 体现什么 |
+|---|---|---|
+| 配置发布四态 | 管理员工作台建一个草稿版本 → 提交校验 → 审核 → 激活 | 改配置是走流程的,可回滚 |
+| 五角色权限对比 | 用五个账号分别登录,看入口守卫各跳哪里 | 越权在**前端就被挡**,服务端还有一层 |
+| 知识入库 | `python tools/seed_knowledge_demo.py` 后复测场景 1 | 知识是**可运营**的,不是写死的 |
+| 平台体检 | `python tools/e2e_smoke_test.py` | 40 项全绿,交付质量可自证 |
+
+---
+
+## 3. 可能被问到的问题(建议话术)
+
+**Q:这是真实行情吗?**
+A:行情取自公开数据源(腾讯行情),成交价用的就是它。但**交易本身是模拟的** ——
+平台是"场内基金模拟交易",不下真实单。
+
+**Q:Agent 会不会乱答?**
+A:不会。客服只回答**检索到的已发布知识**,且有置信度门槛:
+命中分数 ≥0.75 直接答;0.55~0.75 之间还要求领先次优 ≥0.07;
+不满足就引导人工。**宁可转人工也不答错** —— 这是刻意的取舍。
+
+**Q:为什么有些问题答不上来?**
+A:知识库是人工审核入库的,目前覆盖交易规则、20 只产品、费率与常见问答。
+问不到的地方会转人工,**这是设计而不是故障**。
+
+**Q:数据安全怎么保证?**
+A:三层 —— 前端按权限隐藏入口;服务端每次请求**重新解析权限与数据范围**(令牌里不带权限);
+工单等管理面接口**不返回客户标识与原始对话**。所有判定都有审计。
+
+**Q:为什么不让管理员直接改权限?**
+A:金融场景要求权限变更留痕可追溯,所以统一走配置发布流程(草稿→校验→审核→激活)。
+
+---
+
+## 4. 出问题怎么办
+
+| 现象 | 原因 | 怎么办 |
+|---|---|---|
+| 客户下单返回 `503 行情已过期` | 行情快照过期(**有效期仅 15 分钟**) | `python tools/sync_market_prices.py` —— **立即生效,无需重启服务** |
+| 客服一直"繁忙/超时" | **Worker 没在跑** | 看 `start.ps1` 起的第二个窗口;或 `python -m app.worker` |
+| 改了页面看不到效果 | 浏览器缓存 | `Ctrl+F5` 硬刷新 |
+| 知识库问答答不上 | 向量没同步 | 确认 Worker 在跑,然后跑 `tools/seed_knowledge_demo.py` |
+| 平台起不来 | MySQL 没起 | 看 `start.ps1` 的依赖检查输出 |
+| 风控队列没有"待处理"预警 | 演示样本都被处置过了 | `python tools/e2e_smoke_test.py`(自动造一条) |
+
+**一条命令定位**:
+
+```powershell
+python tools/e2e_smoke_test.py
+```
+
+它逐条打印 6 条线 40 项的结果,FAIL 的那条就是问题所在。
+
+---
+
+## 5. 速查
+
+**账号**
+
+| 角色 | 用户名 | 密码 | 登录入口 |
+|---|---|---|---|
+| 客户 | `cust_t` | `123456` | `/portal/customer/login/` |
+| 风控专员 | `risk_t` | `666666` | `/portal/employee-console/login/` |
+| 管理员 | `admin_t` | `88888888` | 同上 |
+| 投顾 | `advisor_t` | `abc12345` | 同上(→ 投顾工作台) |
+| 运营 | `offsite_t` | `offsite123` | 同上(→ 运营工作台) |
+
+**常用命令**
+
+```powershell
+python tools/seed_demo_data.py # 准备演示数据(10 步,首次/换机器)
+python tools/sync_market_prices.py # 刷行情(15 分钟有效期,下单 503 时补跑)
+powershell -ExecutionPolicy Bypass -File start.ps1 # 起 API + Worker(自动刷行情)
+python tools/e2e_smoke_test.py # 全量体检(40 项)
+python tools/e2e_smoke_test.py --read-only # 只读体检,不动数据
+```
+
+**已知的空数据**(演示时别点进去尴尬)
+
+| 位置 | 现状 |
+|---|---|
+| 运营工作台 | 场外邮件 0 条、邮箱未初始化(需要真实邮件源) |
+| 管理员 · 画像候选 | 0 条(需要客户对话触发画像抽取) |
+
+**端口**:API `8000` / MySQL `3306` / Redis `6379` / Milvus `19530`
diff --git a/start.ps1 b/start.ps1
new file mode 100644
index 0000000..0f6d981
--- /dev/null
+++ b/start.ps1
@@ -0,0 +1,167 @@
+<#
+.SYNOPSIS
+ 启动金融 Agent 平台:依赖检查 → API → Agent Worker。
+
+.NOTES
+ ⚠️ 本文件必须保存为 **UTF-8 with BOM**。
+ Windows PowerShell 5.1 在缺少 BOM 时按系统 ANSI 解析脚本(简体中文下是 GBK),
+ 中文注释与输出会变成乱码并直接抛语法错误(实测报 `Unexpected token '[璀﹀憡]'`)。
+ 用编辑器改完本文件后,务必另存为「UTF-8 带 BOM」。
+
+.DESCRIPTION
+ 平台需要**两个进程**才能完整工作,本脚本把它们一起起在独立窗口里:
+
+ · API —— 所有 HTTP 接口与前端页面(/portal/)
+ · Worker —— 消费队列:Agent 对话、知识向量同步、记忆抽取、风控扫描
+
+ **没有 Worker 的后果**(这也是最容易踩的坑):
+ · 客服对话会一直停在 queued,前端显示"客服繁忙/超时";
+ · 新灌的知识写不进 Milvus,客服照旧答不上,且**没有任何报错**。
+
+.PARAMETER Port
+ API 监听端口,默认 8000。
+
+.PARAMETER SkipChecks
+ 跳过依赖端口检查(MySQL / Redis / Milvus)。
+
+.PARAMETER ApiOnly
+ 只起 API,不起 Worker(仅在明确不需要 Agent 能力时使用)。
+
+.PARAMETER SkipPriceSync
+ 跳过启动时的行情刷新。仅在外部数据源不可用、或不想联网时使用。
+
+.EXAMPLE
+ powershell -ExecutionPolicy Bypass -File start.ps1
+ powershell -ExecutionPolicy Bypass -File start.ps1 -Port 8100
+#>
+param(
+ [int]$Port = 8000,
+ [switch]$SkipChecks,
+ [switch]$ApiOnly,
+ [switch]$SkipPriceSync
+)
+
+$ErrorActionPreference = "Stop"
+$Root = $PSScriptRoot
+Set-Location $Root
+
+Write-Host "=== 金融 Agent 平台 · 启动 ===" -ForegroundColor Cyan
+Write-Host "工作目录:$Root"
+
+# ---------------------------------------------------------------- 解释器
+# 逐个尝试:项目虚拟环境 → 常见 conda 环境 → PATH 上的 python。
+# 不写死任何一个,因为各人机器的环境不同(.venv 被 gitignore,不进仓库)。
+$candidates = @(
+ (Join-Path $Root ".venv\Scripts\python.exe"),
+ "D:\conda\envs\jr_py313\python.exe",
+ "python"
+)
+$Python = $null
+foreach ($candidate in $candidates) {
+ try {
+ if ($candidate -eq "python") {
+ $version = & python --version 2>&1
+ } else {
+ if (-not (Test-Path $candidate)) { continue }
+ $version = & $candidate --version 2>&1
+ }
+ if ($LASTEXITCODE -eq 0) {
+ $Python = $candidate
+ Write-Host "解释器:$candidate($version)" -ForegroundColor Green
+ break
+ }
+ } catch { continue }
+}
+if (-not $Python) {
+ Write-Host "[失败] 找不到可用的 Python。请先建虚拟环境,或把解释器路径加进本脚本的候选列表。" -ForegroundColor Red
+ exit 1
+}
+
+# ---------------------------------------------------------------- 依赖检查
+if (-not $SkipChecks) {
+ Write-Host "`n--- 依赖服务检查 ---" -ForegroundColor Cyan
+ $services = @(
+ @{ Name = "MySQL"; HostName = "127.0.0.1"; Port = 3306; Required = $true },
+ @{ Name = "Redis"; HostName = "127.0.0.1"; Port = 6379; Required = $false },
+ @{ Name = "Milvus"; HostName = "127.0.0.1"; Port = 19530; Required = $false }
+ )
+ foreach ($service in $services) {
+ $reachable = $false
+ try {
+ $reachable = (Test-NetConnection -ComputerName $service.HostName -Port $service.Port `
+ -InformationLevel Quiet -WarningAction SilentlyContinue)
+ } catch { $reachable = $false }
+
+ if ($reachable) {
+ Write-Host (" [OK] {0,-7} {1}:{2}" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Green
+ } elseif ($service.Required) {
+ Write-Host (" [缺失] {0,-7} {1}:{2} —— 平台起不来,请先启动它" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Red
+ exit 1
+ } else {
+ Write-Host (" [警告] {0,-7} {1}:{2} 不可达:相关知识检索会降级、Docker 未运行时常见" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Yellow
+ }
+ }
+}
+
+# ---------------------------------------------------------------- 刷新行情
+# ⚠️ 为什么放在启动流程里:下单要求行情快照落在 **15 分钟**有效期内
+# (`app/service/trade_service.py` 的 `MAX_QUOTE_AGE`),超时后**所有委托直接 503
+# 「行情已过期」**,而系统**没有任何自动刷新机制** —— 演示时讲到一半下单就会失败。
+# 所以默认在启动时刷一次:15 分钟窗口从此刻重新计时。
+# 同步是按产品 upsert 的幂等操作;失败只警告不阻断(仍可手动重跑)。
+if (-not $SkipPriceSync) {
+ Write-Host "`n--- 刷新行情(下单前置,有效期 15 分钟)---" -ForegroundColor Cyan
+ $syncOutput = & $Python "tools\sync_market_prices.py" 2>&1
+ if ($LASTEXITCODE -eq 0) {
+ Write-Host " [OK] 行情已刷新,15 分钟内可正常下单" -ForegroundColor Green
+ } else {
+ Write-Host " [警告] 行情刷新失败(退出码 $LASTEXITCODE):下单可能返回 503" -ForegroundColor Yellow
+ Write-Host " 可手动重跑:$Python tools\sync_market_prices.py" -ForegroundColor Yellow
+ $syncOutput | Select-Object -Last 8 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
+ }
+}
+
+# ---------------------------------------------------------------- 启动进程
+# 用独立窗口起,方便分别看两边的日志(Worker 的日志是排查问题的第一现场)。
+function Start-PlatformWindow {
+ param([string]$Title, [string[]]$Arguments)
+ $shell = if (Get-Command pwsh -ErrorAction SilentlyContinue) { "pwsh" } else { "powershell" }
+ $command = "`$host.UI.RawUI.WindowTitle = '$Title'; & '$Python' " + ($Arguments -join " ")
+ Start-Process -FilePath $shell -ArgumentList "-NoExit", "-Command", $command | Out-Null
+}
+
+Write-Host "`n--- 启动进程 ---" -ForegroundColor Cyan
+Start-PlatformWindow -Title "平台 API :$Port" -Arguments @(
+ "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "$Port"
+)
+Write-Host " [已启动] API 窗口(uvicorn,端口 $Port)" -ForegroundColor Green
+
+if (-not $ApiOnly) {
+ Start-PlatformWindow -Title "平台 Agent Worker" -Arguments @("-m", "app.worker")
+ Write-Host " [已启动] Worker 窗口(Agent 对话 / 知识向量 / 记忆抽取)" -ForegroundColor Green
+} else {
+ Write-Host " [跳过] Worker(-ApiOnly):客服对话会一直 queued、新知识不会进 Milvus" -ForegroundColor Yellow
+}
+
+# ---------------------------------------------------------------- 访问入口
+Start-Sleep -Seconds 3
+Write-Host "`n=== 访问入口 ===" -ForegroundColor Cyan
+Write-Host " 门户首页 http://127.0.0.1:$Port/portal/"
+Write-Host " 访客页 http://127.0.0.1:$Port/portal/guest/home/"
+Write-Host " 客户登录 http://127.0.0.1:$Port/portal/customer/login/ (cust_t / 123456)"
+Write-Host " 员工登录 http://127.0.0.1:$Port/portal/employee-console/login/(见下)"
+Write-Host " 接口文档 http://127.0.0.1:$Port/docs"
+
+Write-Host "`n=== 演示账号 ===" -ForegroundColor Cyan
+Write-Host " 客户 cust_t / 123456"
+Write-Host " 风控专员 risk_t / 666666"
+Write-Host " 管理员 admin_t / 88888888"
+Write-Host " 投顾 advisor_t / abc12345"
+Write-Host " 运营 offsite_t / offsite123"
+
+Write-Host "`n提示:" -ForegroundColor Yellow
+Write-Host " · 首次使用先准备数据:python tools/seed_demo_data.py"
+Write-Host " · 行情有效期只有 15 分钟:演示中途下单若报 503,在新窗口重跑"
+Write-Host " python tools/sync_market_prices.py (立即生效,无需重启服务)"
+Write-Host " · 体检:python tools/e2e_smoke_test.py"
+Write-Host " · 演示流程见 docs/44-演示流程.md"
diff --git a/tools/seed_demo_data.py b/tools/seed_demo_data.py
new file mode 100644
index 0000000..d8116d0
--- /dev/null
+++ b/tools/seed_demo_data.py
@@ -0,0 +1,138 @@
+"""一键准备演示数据:按依赖顺序跑齐所有 seed 脚本。
+
+## 为什么需要它
+
+演示数据此前散在 10 个脚本里,**顺序有讲究**(账号 → 口令 → 账户 → 行情 → 配置 → 知识),
+而且有一个环节(知识库素材)根本没有脚本、靠手工调接口。换台机器接手时没人知道该跑哪些、
+按什么顺序跑。本脚本是唯一入口。
+
+## 顺序与依赖
+
+| # | 步骤 | 为什么在这个位置 |
+|---|---|---|
+| 1 | 账号与权限 | 后面所有步骤都要用到这些账号 |
+| 2 | 演示口令 | 依赖 1 建出的账号 |
+| 3 | 客户账户与持仓 | 客户页面与下单的前提 |
+| 4 | 场内行情 | **下单的硬前置**;必须在演示前跑,行情会过期 |
+| 5 | 风控预警样本 | 风控页面要有东西可看 |
+| 6 | 投顾演示数据 | 投顾工作台要有方案可看 |
+| 7-9 | 各类发布配置 | Agent 工具白名单与提示词,缺了客服/风控会"失败关闭" |
+| 10 | 知识库素材 | 客服答得出问题的前提 |
+
+## ⚠️ 两个必须知道的点
+
+1. **最后一步与行情都需要 Agent Worker 才会真正生效**:知识入库只写 MySQL + 投 outbox 事件,
+ 向量由 Worker 消费事件后写 Milvus;没有 Worker 时现象是"客服照旧答不上",且**没有报错**。
+ 所以跑完本脚本**必须**再起 Worker(`start.ps1` 会一起起)。
+2. **第 2 步不是幂等的**:`set_user_password.py` 重跑等于**重设密码**(bcrypt 每次加盐不同)。
+ 这是有意的(改密就该覆盖),但要知道它不是"已存在就跳过"。
+
+## 用法
+
+ python tools/seed_demo_data.py # 全跑
+ python tools/seed_demo_data.py --from 4 # 从第 4 步开始(重跑行情等)
+ python tools/seed_demo_data.py --only 4,10 # 只跑指定步骤
+ python tools/seed_demo_data.py --list # 只列步骤
+"""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
+
+#: (标题, 脚本相对路径, 备注)
+STEPS: tuple[tuple[str, str, str], ...] = (
+ ("账号与权限", "tools/seed_test_rbac.py", "五个演示角色 + 权限号段 9001-9046"),
+ ("演示口令", "tools/set_user_password.py", "⚠️ 非幂等:重跑等于重设密码"),
+ ("客户账户与持仓", "tools/seed_sim_account_demo.py", "客户 9001 开 10 万虚拟资金 + 持仓"),
+ ("场内行情", "tools/sync_market_prices.py", "⚠️ 下单硬前置;行情会过期,演示前必跑"),
+ ("风控预警样本", "tools/seed_risk_alert_demo_data.py", "三条不同状态的演示预警"),
+ ("投顾演示数据", "tools/seed_advisor_demo.py", "投顾工作台要展示的方案与归属"),
+ ("风控 Agent 白名单", "tools/publish_risk_agent_config.py", "缺了风控助手工具会失败关闭"),
+ ("客服配置白名单", "tools/publish_customer_service_config.py", "缺了客服工具会失败关闭"),
+ ("客服闲聊提示词", "tools/publish_chitchat_prompt.py", "话术走发布配置,不硬编码"),
+ ("知识库素材", "tools/seed_knowledge_demo.py", "⚠️ 需要 Worker 才会写进 Milvus"),
+)
+
+
+def run_step(index: int, title: str, script: str, note: str, *, dry_run: bool) -> bool:
+ path = PROJECT_ROOT / script
+ print()
+ print("=" * 88)
+ print(f"[{index}/{len(STEPS)}] {title} —— {note}")
+ print(f" {script}")
+ print("=" * 88)
+ if not path.exists():
+ print(f"[跳过] 脚本不存在:{path}")
+ return False
+ if dry_run:
+ print("[dry-run] 未执行")
+ return True
+ started = time.perf_counter()
+ # 刻意**不捕获输出**(继承 stdio):既不吞掉子脚本的报错,
+ # 也避免在受限环境里因 piped stdio 失败。
+ result = subprocess.run([sys.executable, str(path)], check=False, cwd=str(PROJECT_ROOT))
+ elapsed = time.perf_counter() - started
+ ok = result.returncode == 0
+ print(f"[{'完成' if ok else '失败'}] {title} 用时 {elapsed:.1f}s 退出码 {result.returncode}")
+ return ok
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="一键准备演示数据")
+ parser.add_argument("--list", action="store_true", help="只列步骤")
+ parser.add_argument("--from", dest="start", type=int, default=1, help="从第几步开始")
+ parser.add_argument("--only", default="", help="只跑这些步骤(逗号分隔,如 4,10)")
+ parser.add_argument("--dry-run", action="store_true", help="只打印将执行什么")
+ args = parser.parse_args()
+
+ if args.list:
+ print(f"共 {len(STEPS)} 步:")
+ for index, (title, script, note) in enumerate(STEPS, 1):
+ print(f" {index:>2}. {title:<20} {script:<48} {note}")
+ return 0
+
+ only = {int(part) for part in args.only.split(",") if part.strip()} if args.only else None
+
+ def wanted(index: int) -> bool:
+ return index in only if only is not None else index >= args.start
+
+ selected = [(index, *step) for index, step in enumerate(STEPS, 1) if wanted(index)]
+ if not selected:
+ print("没有匹配的步骤。")
+ return 1
+
+ print(f"准备演示数据:{len(selected)} 步" + ("(dry-run)" if args.dry_run else ""))
+ failed: list[str] = []
+ for index, title, script, note in selected:
+ if not run_step(index, title, script, note, dry_run=args.dry_run):
+ failed.append(title)
+
+ print()
+ print("=" * 88)
+ if failed:
+ print(f"有 {len(failed)} 步失败:{'、'.join(failed)}")
+ print("先看上面的原始报错;多数失败是外部依赖没起来(MySQL / Redis / Docker)。")
+ return 1
+ print("全部完成。")
+ if not args.dry_run:
+ print(
+ "\n下一步:\n"
+ " 1. 起服务:powershell -ExecutionPolicy Bypass -File start.ps1\n"
+ " (它会把 API 与 **Agent Worker** 一起起 —— 没有 Worker,知识检索不到、\n"
+ " 客服对话会一直显示'超时')\n"
+ " 2. 验证:python tools/e2e_smoke_test.py"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/seed_knowledge_demo.py b/tools/seed_knowledge_demo.py
new file mode 100644
index 0000000..dda9890
--- /dev/null
+++ b/tools/seed_knowledge_demo.py
@@ -0,0 +1,157 @@
+"""把演示用的场内基金知识灌进知识库(幂等:先清同源旧数据再上传)。
+
+## 为什么需要它
+
+知识库是客服能答对问题的前提,但它是**演示数据里唯一没有脚本化的一环** ——
+之前是手工调 `POST /api/v1/knowledge/upload` 灌的,换台机器没人知道该灌什么、
+灌到哪个集合。本脚本把 `docs/43-场内基金产品手册(知识库入库版).md` 定为唯一素材源。
+
+## 三个必须讲清的点
+
+1. **上传后不会立刻可检索**:入库只写 MySQL 元数据 + 投 outbox 事件,向量由
+ **Agent Worker** 消费事件后写 Milvus。所以**必须先起 Worker**
+ (`python -m app.worker`),否则知识永远检索不到 —— 而且现象是"客服照旧答不上",
+ 没有任何报错。
+2. **素材必须是"客户可见版"**:用 `docs/43`,**不要**用 `docs/42` —— 后者含内部决策
+ 备注("待你确认""库里 vs 真实"),灌进去可能被客户问题检索出来。
+3. **走进程内调用**(ASGITransport),与 `tools/publish_*.py` 一致,因此**不需要先起 API**。
+
+## 用法
+
+ python tools/seed_knowledge_demo.py # 幂等重灌
+ python tools/seed_knowledge_demo.py --dry-run # 只看会做什么,不调写接口
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import base64
+import sys
+import uuid
+from pathlib import Path
+from typing import Any
+
+import httpx
+import jwt
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+from app.core.config import get_settings # noqa: E402
+from app.main import create_app # noqa: E402
+
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
+
+ADMIN = "9003"
+SOURCE = PROJECT_ROOT / "docs" / "43-场内基金产品手册(知识库入库版).md"
+#: 上传后的文件名,也是**幂等清理的判据**:同名的旧行会被先删掉。
+UPLOAD_FILENAME = "场内基金产品手册.md"
+KNOWLEDGE_TYPE = "product"
+
+
+def token(subject: str) -> str:
+ settings = get_settings()
+ private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8")
+ import datetime as dt
+
+ now = dt.datetime.now(dt.UTC)
+ return jwt.encode(
+ {
+ "sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience,
+ "exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5),
+ "jti": str(uuid.uuid4()),
+ },
+ private_key,
+ algorithm="RS256",
+ )
+
+
+async def list_existing(client: httpx.AsyncClient, auth: dict[str, str]) -> list[dict[str, Any]]:
+ """列出当前知识。
+
+ ⚠️ 这个端点**不套 `data` 信封**(直接返回 `{"items": [...], "count": N}`),
+ 按 `data.items` 解包会得到空列表、看着像"库里没数据" —— 实测踩过。
+ """
+ response = await client.get("/api/v1/knowledge/list?limit=100", headers=auth)
+ if response.status_code != 200:
+ print(f"[警告] 列表接口返回 {response.status_code},按『无现存知识』继续")
+ return []
+ body = response.json()
+ items = body.get("items")
+ if items is None and isinstance(body.get("data"), dict):
+ items = body["data"].get("items")
+ return items or []
+
+
+async def main() -> int:
+ parser = argparse.ArgumentParser(description="灌入演示用的场内基金知识")
+ parser.add_argument("--dry-run", action="store_true", help="只看会做什么,不调写接口")
+ args = parser.parse_args()
+
+ if not SOURCE.exists():
+ print(f"[失败] 素材不存在:{SOURCE}")
+ return 1
+ text = SOURCE.read_text(encoding="utf-8")
+ print(f"素材:{SOURCE.name}({len(text)} 字符)")
+ print(f"目标:knowledge_type={KNOWLEDGE_TYPE} → fin_product_collection")
+ print(f"文件名:{UPLOAD_FILENAME}(同名旧行会被先删除,以保证幂等)\n")
+
+ app = create_app()
+ auth = {"Authorization": f"Bearer {token(ADMIN)}"}
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=120
+ ) as client:
+ existing = await list_existing(client, auth)
+ stale = [row for row in existing if row.get("source_file") == UPLOAD_FILENAME]
+ print(f"现存知识 {len(existing)} 块,其中本素材的旧版 {len(stale)} 块")
+
+ if args.dry_run:
+ print("\n[dry-run] 将会:")
+ print(f" 1. 删除 {len(stale)} 块旧版(id: {[r.get('knowledge_id') for r in stale]})")
+ print(f" 2. 上传 {SOURCE.name} 并切块入库")
+ print(" 未调用任何写接口。")
+ return 0
+
+ deleted = 0
+ for row in stale:
+ knowledge_id = row.get("knowledge_id")
+ # 写接口必须带幂等键:平台对缺失键的写请求按失败关闭处理。
+ response = await client.delete(
+ f"/api/v1/knowledge/{knowledge_id}",
+ headers={**auth, "Idempotency-Key": uuid.uuid4().hex},
+ )
+ if response.status_code in (200, 204):
+ deleted += 1
+ else:
+ print(f" [警告] 删除 {knowledge_id} 返回 {response.status_code}")
+ if stale:
+ print(f"已清理旧版 {deleted}/{len(stale)} 块")
+
+ response = await client.post(
+ "/api/v1/knowledge/upload",
+ headers={**auth, "Idempotency-Key": uuid.uuid4().hex},
+ json={
+ "filename": UPLOAD_FILENAME,
+ "knowledge_type": KNOWLEDGE_TYPE,
+ "content_base64": base64.b64encode(text.encode("utf-8")).decode("ascii"),
+ },
+ )
+ if response.status_code not in (200, 201):
+ print(f"[失败] 上传返回 {response.status_code}:{response.text[:300]}")
+ return 1
+ print(f"上传成功(HTTP {response.status_code})")
+
+ print(
+ "\n完成。⚠️ 接下来必须:\n"
+ " 1. 确认 Agent Worker 在跑(python -m app.worker)—— 向量由它写进 Milvus;\n"
+ " 2. 等几秒让向量同步完成;\n"
+ " 3. 用 python tools/e2e_smoke_test.py 验证 A 线(访客问答)不再转人工。"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(asyncio.run(main()))