feat(demo): 演示数据一键准备、启动脚本与演示流程文档

交付"别人能自己把系统跑起来做演示"所需的四件东西:

- 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
This commit is contained in:
2026-09-13 21:56:27 +08:00
parent f37fd922a1
commit bfd3964ef8
4 changed files with 752 additions and 0 deletions
+138
View File
@@ -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())
+157
View File
@@ -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()))