Files
group_fqcd_jr/app/static/portal
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
..

Portal 路由与角色映射

正式门户使用原生 JavaScript、原生 CSS 与同源 fetch,静态资源统一从 /static/portal/ 加载。

路由 角色 数据源
/portal/guest/home/ 访客 / 全部 P001(推荐位取前 3 只)
/portal/guest/products/ 访客 / 全部 P001
/portal/guest/product-detail/?code= 访客 / 全部 P001(不含历史净值曲线)
/portal/customer/login/ 未登录客户 A034
/portal/customer/dashboard/ customer / admin T001、T002
/portal/customer/holdings/ customer / admin T006
/portal/customer/profit-loss/ customer / admin T001
/portal/customer/orders/ customer / admin T003
/portal/customer/transactions/ customer / admin T007
/portal/customer/cash-ledger/ customer / admin T009
/portal/customer/risk-questionnaire/ customer ONB001、ONB002
/portal/employee-console/login/ 未登录员工 / 管理员 A034
/portal/employee-console/workspace/ admin / super_admin A002-A006、A012、A033、A035-A040、客服转人工管理接口
/portal/employee-risk/dashboard/ risk_operator / admin / super_admin /api/v1/risk/**、R001-R003
/portal/employee-advisor/dashboard/ advisor / admin / super_admin /api/v1/advisor/recommendations/published(本人 + 名下归属客户的已发布交付物)
/portal/employee-operations/dashboard/ operator / admin / super_admin /api/v1/offsite-fund/mails、/api/v1/offsite-fund/mailbox-status

投顾页的数据口径:接口按「本人 + sys_customer_assignment 里名下归属客户」过滤,且同时覆盖 investment_goal_book(方案书,发布后 review_status='published')与 advisor_recommendation_plan(推荐方案,审核后 review_status='approved')两类内容。 方案书的审核与发布都要求管理员(investment-goal:review / publish 都带 admin=True), 投顾自己发不出来 —— 这是有意设计的复核环节,不是缺陷。

客服浮窗由公开首页、基金产品页和客户工作台统一挂载。访客使用 /api/v1/visitor-tokens 获取短期令牌(角色 visitor,权限只有 agent:run + knowledge:query),因此必须走 query_knowledge 这个工具名;登录客户走 search_knowledge。两者都要出现在发布配置的 agent_tools/customer_service:<intent> 白名单里,缺哪一条,对应人群就一问即失败。

公开产品数据来自 GET /api/v1/products(编号 P001,见 docs/05 §19), 产品与净值取自 fin_product(status='上市')、行情取自 fin_market_price 的最新一行。 访客页面通过 common/visitor-token.js 取短期访客令牌后调用;该文件是访客令牌的唯一实现 (客服浮窗也用它),不要在页面里另写一份,否则存储 key 与过期判断迟早不一致。 公开产品数据不得与登录后的真实账户数据混用。

三点口径(改前端前先读):

  1. change_pct 可能是 null(行情只同步过一个交易日时算不出涨跌)。 调用方必须显示"暂无",不得当成 0 —— formatPercent 收到 null 会渲染成 +0.00%, 那等于告诉客户"今天平盘"。
  2. 历史净值曲线没有数据源:fin_nav_history 目前 0 行,详情页因此不画走势图, 并显式说明"尚未接入"。此前那条曲线是 mock 里 12 个编造点位 —— 走势图最容易被当成真数据。
  3. 产品级披露在 common/product-notes.js(如 510300 的"同指数参考产品,非本公司发行")。 fin_product 没有这个字段,所以它留在前端;新增需要披露的产品时改那一份。 凡渲染公开产品的页面都要挂 data-source-notice 说明数据来源。

客户页面均由 common/auth.js 执行入口守卫,接口路径只在 common/api-client.js 的端点表登记。