Files
group_fqcd_jr/tools/seed_demo_data.py
lzf_0626 7208713c17 feat(portal): 接入历史净值,产品详情页恢复真实走势图
`fin_nav_history` 一直是**空表**,所以产品详情页画不出走势图 —— 此前那条曲线是
前端 `mock-data.js` 里编的 12 个点位,接入公开产品接口时把它去掉了
(走势图最容易被当成真实业绩),页面改为显示"尚未接入"。本次补上完整链路。

## 1. 取数(app/infrastructure/fund_market_adapter.py)

新增 `fetch_nav_history()`:东方财富历史净值接口(`api.fund.eastmoney.com/f10/lsjz`)
分页取序列。

- **为什么不复用 `fetch_kline`**:它走 `push2his.eastmoney.com`,
  该域名在本环境实测连接被拒(`RemoteProtocolError`);
- **为什么不复用 `hq.get_southern_fund_nav_history`**:那个函数校验**南方基金白名单**,
  而 `fin_product` 里有非南方基金的产品(510300 是华泰柏瑞的),调它会直接 `ValueError`。

⚠️ 实现里踩到一个坑:接口**会忽略请求中的 `pageSize`**(实测固定每次返回 20 条)。
最初按硬编码的 30 判断"是否最后一页",于是 `len(items) < 30` 永远成立、只取到第一页 ——
走势图看上去"有数据",其实只有最近 20 天,而且毫无报错。
改为按首屏**实际条数** + `TotalCount` 推算页数后,同样区间取到 124 条。

## 2. 落库(tools/sync_nav_history.py,新增)

写入 `fin_nav_history`,按 `(product_id, nav_date)` 幂等 upsert。
实测:20 只产品 / **2513 行** / 2026-03-17 ~ 09-13;重跑**新写入 0 行**。

## 3. 接口(P002)

`GET /api/v1/products/{product_code}/nav-history`,编号 **P002**,已登记 `docs/05` §19。
鉴权口径与 P001 相同(要求有效令牌、不校验权限码,访客令牌可用);`days` 有界 1–365。

**表为空时返回 `count=0` 与空数组,而不是报错** —— 调用方据此显示"尚未接入",
**不得回退到编造曲线**。产品不存在或未上市 → `404`(否则前端分不清"没有数据"
和"没有这只产品")。

## 4. 前端

详情页按序列画 SVG 折线,期数标题改为动态("近 N 个交易日")。
表为空时仍显示"尚未接入"占位,并补上此前缺失的 `.detail-chart__empty` 样式。
`product-detail.js` / `.css` / `index.html` 的缓存版本参数一并 bump 到 `-7`。

## 5. 演示数据

`tools/seed_demo_data.py` 增加第 5 步「历史净值」(现 **11 步**),
否则换台机器演示时走势图又会是空的。

验证:P002 实测 515450 / 510300 各 120 个净值点;ruff 通过;mypy 251 文件 0 错;
unit+contract 1391 passed;integration 108 passed;e2e 冒烟 40/40。
2026-09-13 23:38:13 +08:00

141 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""一键准备演示数据:按依赖顺序跑齐所有 seed 脚本。
## 为什么需要它
演示数据此前散在 10 个脚本里,**顺序有讲究**(账号 → 口令 → 账户 → 行情 → 配置 → 知识),
而且有一个环节(知识库素材)根本没有脚本、靠手工调接口。换台机器接手时没人知道该跑哪些、
按什么顺序跑。本脚本是唯一入口。
## 顺序与依赖
| # | 步骤 | 为什么在这个位置 |
|---|---|---|
| 1 | 账号与权限 | 后面所有步骤都要用到这些账号 |
| 2 | 演示口令 | 依赖 1 建出的账号 |
| 3 | 客户账户与持仓 | 客户页面与下单的前提 |
| 4 | 场内行情 | **下单的硬前置**;必须在演示前跑,行情会过期 |
| 5 | 历史净值 | 产品详情页净值走势图的数据源(`fin_nav_history`) |
| 6 | 风控预警样本 | 风控页面要有东西可看 |
| 7 | 投顾演示数据 | 投顾工作台要有方案可看 |
| 8-10 | 各类发布配置 | Agent 工具白名单与提示词,缺了客服/风控会"失败关闭" |
| 11 | 知识库素材 | 客服答得出问题的前提 |
## ⚠️ 两个必须知道的点
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/sync_nav_history.py", "产品详情页走势图的数据源(fin_nav_history)"),
("风控预警样本", "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())