From f6bfcc26b3fd3fcb758eb2cc3e5ed2a66981def2 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 22:28:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(demo):=20=E6=8A=8A=E6=9C=AA=E4=B8=8A?= =?UTF-8?q?=E5=B8=82=E7=9A=84=20160129=20=E6=8D=A2=E6=88=90=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=8C=82=E7=89=8C=E7=9A=84=20515450=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=8E=89=E7=A7=8D=E5=AD=90=E5=81=87=E8=A1=8C=E6=83=85?= =?UTF-8?q?=E7=9B=96=E4=BD=8F=E7=9C=9F=E8=A1=8C=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. 160129 本就不该在场内清单里 `160129`(南方金利定开债券C)是 `160128`(金利定开债券 **A** 类)的 C 类份额, 而 C 类份额只在场外销售、**不在交易所挂牌** —— 行情源对它永远返回空,下单只能走 净值降级(`source=eastmoney_nav_fallback`),既掩盖了"该产品并不交易"这一事实, 又让成交价带上折溢价偏差。 选型依据:把南方基金全部 **882 个代码**逐个问过腾讯行情源(该源只对交易所上市证券 返回数据),确认真实上市交易的只有 **109 个**;其中 R1/R2 的场内产品 (159700、160128、511070、511810)**原先已在清单内**,所以替换品只能来自 R3 及以上。 选定 **`515450` 红利低波50ETF南方**:仍是南方基金旗下、成交额约 1.3 亿元流动性充足、 红利低波定位偏稳健,与它替换掉的债券 LOF 定位最接近;客户风险等级已覆盖 R3 (原有 `510300` 即 R3)。 改动:`hq.py` 的 `FUND_TYPE_GROUPS`、`tools/import_hq_test_products.py` 的场内映射, 两处都留了注释防止被加回来;`docs/42` / `docs/43` 的产品表与费率表同步 (净值 1.4027、管理费 0.50%、托管费 0.10%);`docs/42` 另加了一条决策记录。 库内用**复用 `fin_product.id = 9100005`** 的方式替换,使 `fin_holding` / `fin_sim_order` / `fin_transaction` / `fin_market_price` 的既有引用自动跟随, 不产生孤儿数据。那笔历史成交保留 `quote_source='eastmoney_nav_fallback'` —— 它确实是当时的事实,不该被粉饰。同时清掉了 `fin_market_price` 里那条净值降级行, 全库净值降级行情行数归零。 ## 2. 种子假行情会盖住真实行情(更严重,且会反复出现) `seed_sim_account_demo.py` 原来按"今天"upsert 一条假行情(510300=4.50、 510500=6.20,`source='eastmoney_demo_seed'`),而真实行情来自行情源、日期是 **最近交易日**。下单按 `trade_date DESC` 取行情,这条种子行**永远排在真实行情前面**; 它的 `source_updated_at` 只是 seed 运行时刻,过了 `MAX_QUOTE_AGE`(15 分钟)就让整个 产品变成 `503 行情已过期` —— 而库里明明躺着一条刚同步好的真实行情。周末尤其明显: `trade_date` 落在**非交易日**,价格还是编的。 实测表现:`tools/seed_demo_data.py` 跑完第 4 步刚同步过真实行情,冒烟脚本的下单仍报 `503`,且本该 4.579 的成交价被查到 4.50。 改为:**已有任何行情行就绝不插手**(真实行情优先,种子不参与竞争); 一条都没有时才补,且 `trade_date` 退到最近交易日。 验证:`tools/e2e_smoke_test.py` → **40/40**(下单成交价 4.579 = 真实行情); ruff 通过;mypy 250 文件 0 错;unit+contract 1381 passed / 0 failed。 --- app/service/market_price_sync_service.py | 7 +++- docs/42-场内基金知识条目草稿.md | 28 +++++++++++++- docs/43-场内基金产品手册(知识库入库版).md | 4 +- hq.py | 7 +++- tools/import_hq_test_products.py | 4 +- tools/seed_sim_account_demo.py | 43 ++++++++++----------- 6 files changed, 62 insertions(+), 31 deletions(-) diff --git a/app/service/market_price_sync_service.py b/app/service/market_price_sync_service.py index b6d49c4..fed91f8 100644 --- a/app/service/market_price_sync_service.py +++ b/app/service/market_price_sync_service.py @@ -202,8 +202,11 @@ class MarketPriceSyncService: ⚠️ **净值不等于市价**:若该产品确实在交易所交易,用它当收盘价会有折溢价偏差。 所以这条路径只应落在"行情源没有覆盖"的产品上,且值得业务侧复核 —— - 实测触发它的 `160129` 是 C 类份额(其 A 类 `160128` 在腾讯源有行情), - 它很可能未上市交易,那它本就不该出现在场内产品列表里。 + 曾经触发它的 `160129` 是 C 类份额(其 A 类 `160128` 在腾讯源有行情): + C 类份额只在场外销售、**不在交易所挂牌**,本就不该出现在场内产品列表里, + 2026-09-13 已替换为 `515450`(红利低波50ETF南方)。 + 所以这条降级**再次被触发时,先怀疑清单里混进了非上市份额**(C 类、场外份额), + 而不是行情源出了问题。 """ history = await self.adapter.fetch_history(code, date.today()) nav = history.get("nav") diff --git a/docs/42-场内基金知识条目草稿.md b/docs/42-场内基金知识条目草稿.md index 5cd45cf..39251a5 100644 --- a/docs/42-场内基金知识条目草稿.md +++ b/docs/42-场内基金知识条目草稿.md @@ -88,11 +88,11 @@ | 510500 | 中证500ETF南方 | SSE | ETF | R4 | 7.6027 | -1.79% | 2026-09-11 | | 511070 | 公司债ETF南方 | SSE | ETF | R2 | 103.0145 | +0.01% | 2026-09-11 | | 511810 | 货币ETF南方 | SSE | ETF | R1 | 0.2332 ⚠️ | 0.00% | 2026-09-13 | +| 515450 | 红利低波50ETF南方 | SSE | ETF | R3 | 1.4027 | -0.43% | 2026-09-11 | | 588890 | 科创芯片ETF南方 | SSE | ETF | R4 | 1.2327 | -1.36% | 2026-09-11 | | 160105 | 南方积极配置混合(LOF) | SZSE | LOF | R3 | 1.2514 | -1.34% | 2026-09-11 | | 160127 | 南方新兴消费增长股票(LOF)A | SZSE | LOF | R4 | 0.8364 | -0.64% | 2026-09-11 | | 160128 | 南方金利定开债券A | SZSE | LOF | R2 | 1.0260 | 0.00% | 2026-09-11 | -| 160129 | 南方金利定开债券C | SZSE | LOF | R2 | 1.0240 | 0.00% | 2026-09-11 | | 160142 | 南方优势产业(LOF) | SZSE | LOF | R3 | 1.0623 | -2.09% | 2026-09-11 | | 160143 | 南方创业板2年定期开放混合 | SZSE | LOF | R3 | 1.6105 | -1.14% | 2026-09-11 | | 501018 | 南方原油A | SSE | LOF | R5 | 2.0750 | +3.86% | 2026-09-10 | @@ -106,6 +106,30 @@ > (库里存 6 位小数,如 `0.916700`,数据源给 4 位 `0.9167`,是同一笔数), > **2 只不一致** —— `510300` 与 `511810`,见下方 3.1 与第四节。 +> **2026-09-13 变更:`160129` → `515450`** +> +> 原 `160129`(南方金利定开债券C)已从清单移除,替换为 **`515450` 红利低波50ETF南方** +> (SSE / ETF / R3,管理费 0.50%、托管费 0.10%,净值 1.4027)。 +> +> **为什么必须换**:`160129` 是 `160128`(金利定开债券 **A** 类)的 **C 类份额**, +> 而 C 类份额只在场外销售、**不在交易所挂牌** —— 行情源对它永远返回空,下单只能走 +> 净值降级(`source=eastmoney_nav_fallback`)。这既掩盖了"该产品并不交易"这一事实, +> 又让成交价带上折溢价偏差。**它本就不该出现在场内清单里。** +> +> **选型依据**:把南方基金全部 **882 个代码**逐个问过腾讯行情源(该源只对交易所上市 +> 证券返回数据),确认真实上市交易的只有 **109 个**;其中 R1/R2 的场内产品 +> (159700、160128、511070、511810)**原先已在清单内**,所以替换品只能来自 R3 及以上。 +> 选 `515450` 是因为它**仍属南方基金**、成交额约 1.3 亿元流动性充足、红利低波定位偏 +> 稳健,与它替换掉的债券 LOF 定位最接近;客户风险等级已覆盖 R3(原有 `510300` 即 R3)。 +> +> **处理方式**:复用 `fin_product.id = 9100005` 而非删除重建,使 `fin_holding` / +> `fin_sim_order` / `fin_transaction` / `fin_market_price` 的既有引用自动跟随, +> 不产生孤儿数据。那笔历史成交保留 `quote_source='eastmoney_nav_fallback'` —— +> 它确实是当时的事实,不该被粉饰。 +> +> 同时移出的还有 `hq.py` 的 `FUND_TYPE_GROUPS`(`160129` 原在"债券型"组) +> 与 `tools/import_hq_test_products.py` 的场内映射,两处都留了注释防止被加回来。 + 问:平台上有哪些场内基金?产品代码是什么? 答:本平台模拟交易范围内共有 20 只场内基金,包含 13 只 ETF 和 7 只 LOF, @@ -241,7 +265,6 @@ python tools/backfill_product_snapshot.py --codes 511810 --apply --overwrite | 160105 | 南方积极配置混合(LOF) | 1.20 | 0.20 | — | | 160127 | 南方新兴消费增长股票(LOF)A | 1.20 | 0.20 | — | | 160128 | 南方金利定开债券A | 0.50 | 0.15 | — | -| 160129 | 南方金利定开债券C | 0.50 | 0.15 | — | | 160142 | 南方优势产业(LOF) | 1.20 | 0.20 | — | | 160143 | 南方创业板2年定期开放混合 | 1.20 | 0.20 | — | | 501018 | 南方原油A | 1.00 | 0.20 | — | @@ -250,6 +273,7 @@ python tools/backfill_product_snapshot.py --codes 511810 --apply --overwrite | 510500 | 中证500ETF南方 | 0.15 | 0.05 | — | | 511070 | 公司债ETF南方 | 0.15 | 0.05 | — | | 511810 | 货币ETF南方 | 0.30 | 0.05 | **0.25** | +| 515450 | 红利低波50ETF南方 | 0.50 | 0.10 | — | | 588890 | 科创芯片ETF南方 | 0.50 | 0.10 | — | **规律**:宽基/债券 ETF 多为 0.15% 管理费,行业主题 ETF 多为 0.50%, diff --git a/docs/43-场内基金产品手册(知识库入库版).md b/docs/43-场内基金产品手册(知识库入库版).md index 7d06c09..29c50fc 100644 --- a/docs/43-场内基金产品手册(知识库入库版).md +++ b/docs/43-场内基金产品手册(知识库入库版).md @@ -86,11 +86,11 @@ LOF 与主动管理型较高(如南方积极配置混合 1.20%/年、托管费 | 510500 | 中证500ETF南方 | 上交所 | ETF | R4 | 7.6027 | 2026-09-11 | | 511070 | 公司债ETF南方 | 上交所 | ETF | R2 | 103.0145 | 2026-09-11 | | 511810 | 货币ETF南方 | 上交所 | ETF | R1 | 0.2661 | 2026-09-11 | +| 515450 | 红利低波50ETF南方 | 上交所 | ETF | R3 | 1.4027 | 2026-09-11 | | 588890 | 科创芯片ETF南方 | 上交所 | ETF | R4 | 1.2327 | 2026-09-11 | | 160105 | 南方积极配置混合(LOF) | 深交所 | LOF | R3 | 1.2514 | 2026-09-11 | | 160127 | 南方新兴消费增长股票(LOF)A | 深交所 | LOF | R4 | 0.8364 | 2026-09-11 | | 160128 | 南方金利定开债券A | 深交所 | LOF | R2 | 1.0260 | 2026-09-11 | -| 160129 | 南方金利定开债券C | 深交所 | LOF | R2 | 1.0240 | 2026-09-11 | | 160142 | 南方优势产业(LOF) | 深交所 | LOF | R3 | 1.0623 | 2026-09-11 | | 160143 | 南方创业板2年定期开放混合 | 深交所 | LOF | R3 | 1.6105 | 2026-09-11 | | 501018 | 南方原油A | 上交所 | LOF | R5 | 2.0750 | 2026-09-10 | @@ -139,11 +139,11 @@ LOF 与主动管理型较高(如南方积极配置混合 1.20%/年、托管费 | 510500 | 中证500ETF南方 | 0.15 | 0.05 | — | | 511070 | 公司债ETF南方 | 0.15 | 0.05 | — | | 511810 | 货币ETF南方 | 0.30 | 0.05 | 0.25 | +| 515450 | 红利低波50ETF南方 | 0.50 | 0.10 | — | | 588890 | 科创芯片ETF南方 | 0.50 | 0.10 | — | | 160105 | 南方积极配置混合(LOF) | 1.20 | 0.20 | — | | 160127 | 南方新兴消费增长股票(LOF)A | 1.20 | 0.20 | — | | 160128 | 南方金利定开债券A | 0.50 | 0.15 | — | -| 160129 | 南方金利定开债券C | 0.50 | 0.15 | — | | 160142 | 南方优势产业(LOF) | 1.20 | 0.20 | — | | 160143 | 南方创业板2年定期开放混合 | 1.20 | 0.20 | — | | 501018 | 南方原油A | 1.00 | 0.20 | — | diff --git a/hq.py b/hq.py index ee371c9..a5b97e4 100644 --- a/hq.py +++ b/hq.py @@ -33,12 +33,15 @@ EXCHANGE_HISTORY_MIN_INTERVAL_SECONDS = 1.0 MAX_FUNDS_PER_CALL = 1000 FUND_TYPE_GROUPS = { "货币型": ("202308", "020480", "511810"), - "债券型": ("007161", "003776", "020281", "511070", "159700", "160128", "160129"), + # ⚠️ 此处曾含 `160129`(南方金利定开债券C),已移除:C 类份额只在场外销售、 + # **不在交易所挂牌**,行情源对它永远返回空(下单因此走净值降级)。 + # 它的 A 类 `160128` 是上市交易的,已在本组内。 + "债券型": ("007161", "003776", "020281", "511070", "159700", "160128"), "混合型": ("018019", "014189", "018020", "160105", "160142", "160143", "501062"), "股票型": ( "020553", "016449", "008854", "008264", "008736", "010592", "160127", "588890", "020839", "589700", "159382", "159511", "002900", "021958", "159948", "009059", - "001421", "510500", + "001421", "510500", "515450", ), "QDII": ("501018", "159329", "159615", "159687"), } diff --git a/tools/import_hq_test_products.py b/tools/import_hq_test_products.py index 9dedf9b..990da10 100644 --- a/tools/import_hq_test_products.py +++ b/tools/import_hq_test_products.py @@ -34,7 +34,9 @@ EXCHANGE_PRODUCT_REFERENCE: dict[str, tuple[str, str, str]] = { "511070": ("SSE", "ETF", "R2"), "159700": ("SZSE", "ETF", "R2"), "160128": ("SZSE", "LOF", "R2"), - "160129": ("SZSE", "LOF", "R2"), + # 原为 `160129`(南方金利定开债券C):C 类份额只在场外销售、不在交易所挂牌, + # 行情源对它永远返回空。换成南方旗下**真实上市且行情可取**的红利低波 50ETF。 + "515450": ("SSE", "ETF", "R3"), "160105": ("SZSE", "LOF", "R3"), "160142": ("SZSE", "LOF", "R3"), "160143": ("SZSE", "LOF", "R3"), diff --git a/tools/seed_sim_account_demo.py b/tools/seed_sim_account_demo.py index 7a51003..bc365e9 100644 --- a/tools/seed_sim_account_demo.py +++ b/tools/seed_sim_account_demo.py @@ -118,38 +118,37 @@ async def _upsert_product(session: Session, spec: dict) -> int: async def _upsert_market_price(session: Session, product_id: int, spec: dict) -> None: - today = datetime.now(UTC).date() - now = datetime.now(UTC).replace(tzinfo=None) + """只在**该产品一行行情都没有**时补一条种子行情,供"不跑行情同步也能下单"兜底。 + + ⚠️ 这里**刻意不再按"今天"去 upsert**(原实现如此),因为那是一个真实踩过的坑: + + seed 写的 `trade_date` 是**当天**,而真实行情来自行情源、日期是**最近交易日**。 + 下单取行情时按 `trade_date DESC` 排序,于是这条种子行**永远排在真实行情前面**; + 它的 `source_updated_at` 只是 seed 运行时刻,过了 `MAX_QUOTE_AGE`(15 分钟)就让 + 整个产品变成 `503 行情已过期` —— 而库里明明躺着一条刚同步好的真实行情。 + 周末尤其明显:`trade_date` 落在**非交易日**,价格还是编的(4.50 对不上真实的 4.579)。 + + 所以:**已有任何行情行就绝不插手**(真实行情优先,种子不参与竞争); + 一条都没有时,`trade_date` 也要退到最近交易日,避免再次盖住后续同步的真实行情。 + """ existing = ( await session.execute( - select(FundMarketPrice.id).where( - FundMarketPrice.product_id == product_id, - FundMarketPrice.trade_date == today, - ) + select(FundMarketPrice.id) + .where(FundMarketPrice.product_id == product_id) + .limit(1) ) ).scalar_one_or_none() if existing is not None: - await session.execute( - update(FundMarketPrice) - .where(FundMarketPrice.id == existing) - .values( - open_price=spec["close_price"], - high_price=spec["close_price"] + Decimal("0.05"), - low_price=spec["close_price"] - Decimal("0.05"), - close_price=spec["close_price"], - volume=Decimal("1000000"), - turnover_amount=spec["close_price"] * Decimal("1000000"), - total_fund_shares=spec["total_fund_shares"], - source="eastmoney_demo_seed", - source_updated_at=now, - ) - ) return + today = datetime.now(UTC).date() + # 周末退到最近的周五,别把 trade_date 写进非交易日 + trade_date = today if today.weekday() < 5 else today - timedelta(days=today.weekday() - 4) + now = datetime.now(UTC).replace(tzinfo=None) next_id = await _next_id(session, FundMarketPrice) stmt = insert(FundMarketPrice).values( id=next_id, product_id=product_id, - trade_date=today, + trade_date=trade_date, open_price=spec["close_price"], high_price=spec["close_price"] + Decimal("0.05"), low_price=spec["close_price"] - Decimal("0.05"), From 9cb0f6e47458103b3d078f982cd11938229e5481 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 22:57:48 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(portal):=20=E5=85=AC=E5=BC=80=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E6=8E=A5=E5=8F=A3=EF=BC=88P001=EF=BC=89=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=EF=BC=8C=E8=AE=BF=E5=AE=A2=E4=B8=89=E9=A1=B5=E6=94=B9?= =?UTF-8?q?=E8=AF=BB=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 访客首页推荐、产品列表、产品详情此前读的是前端手写的 `app/static/portal/common/mock-data.js`:只有 8 只,且**其中 6 只根本不在 `fin_product` 里**(159915 / 512100 / 513100 / 511360 / 159645 / 159925), 还把海富通的 `511360` 标成"南方短融ETF"、把 `510500` 净值写成 6.742(真实 7.6027)。 `README.md` 把它记为"公开产品 HTTP 接口尚未实现"的临时方案。 ## 接口 新增 `GET /api/v1/products`(编号 **P001**,已在 `docs/05` §19 总目录与 §19 说明中登记): - **要求有效令牌但不校验权限码**:访客令牌的角色是 `visitor`、不带任何权限, 这与 `/api/v1/agent-runs`、`/api/v1/conversations` 面向访客的口径一致; 产品信息本身是公开信息。数据面只暴露 `fin_product`(`status='上市'`)与 `fin_market_price` 的最新一行,**不含任何账户/客户字段**(由 integration 测试守着)。 - 字段与 `mock-data.js` 对齐,因此前端渲染与筛选逻辑**一行未改**。 - `change_pct` **可能是 `null`**:当日涨跌需要两个交易日的收盘价,行情只同步过一天时 算不出来。前端对 `null` 显示"暂无" —— `formatPercent(null)` 会渲染成 `+0.00%`, 那等于对客户说"今天平盘",是编出来的结论。 ## 前端 - 新增 `common/visitor-token.js`:访客令牌的**唯一实现**(这段逻辑原先只写在客服浮窗里, 现在四处要用;复制四份的话存储 key 与过期判断迟早不一致);`widget.js` 改为复用它。 - 新增 `common/product-notes.js`:产品级披露文案。510300"非本公司发行"那条是**合规披露**, 不能随 mock 一起删掉。 - 三个访客页改读接口,并显式区分 loading / error 状态。 - **删除 `common/mock-data.js`**。 - 产品详情页**不再画走势图**:`fin_nav_history` 目前 0 行,此前那条曲线是 mock 里 12 个编造点位 —— 走势图最容易被当成真数据,宁可不画,并显式说明"尚未接入"。 ## 顺带修正 `min_amount` 在库里全是 0.00(那本是场外"最小认购金额"的概念,对场内按手交易的 ETF 不适用),页面不再显示"¥0.00"(会被读成零元起购),改为"1 手(100 份)起"。 验证:接口实测 `count=20`;ruff 通过;mypy 251 文件 0 错;unit+contract 1387 passed; integration 106 passed;e2e 冒烟 40/40。 --- app/api/controllers/public_platform.py | 16 ++ app/service/public_product_service.py | 151 ++++++++++++++++++ app/static/portal/README.md | 26 ++- app/static/portal/common/api-client.js | 1 + .../common/customer-service-widget/widget.js | 23 +-- app/static/portal/common/mock-data.js | 75 --------- app/static/portal/common/product-notes.js | 22 +++ app/static/portal/common/visitor-token.js | 65 ++++++++ app/static/portal/guest/home/home.js | 29 +++- app/static/portal/guest/home/index.html | 2 +- .../portal/guest/product-detail/index.html | 4 +- .../guest/product-detail/product-detail.js | 124 +++++++++----- app/static/portal/guest/products/index.html | 4 +- app/static/portal/guest/products/products.js | 74 ++++++--- docs/05-接口文档.md | 23 +++ docs/40-前端验收清单.md | 59 +++---- docs/44-演示流程.md | 10 +- .../test_public_products_endpoint_contract.py | 65 ++++++++ .../test_public_products_endpoint_mysql.py | 75 +++++++++ 19 files changed, 628 insertions(+), 220 deletions(-) create mode 100644 app/service/public_product_service.py delete mode 100644 app/static/portal/common/mock-data.js create mode 100644 app/static/portal/common/product-notes.js create mode 100644 app/static/portal/common/visitor-token.js create mode 100644 tests/contract/test_public_products_endpoint_contract.py create mode 100644 tests/integration/test_public_products_endpoint_mysql.py diff --git a/app/api/controllers/public_platform.py b/app/api/controllers/public_platform.py index 9463599..88992d5 100644 --- a/app/api/controllers/public_platform.py +++ b/app/api/controllers/public_platform.py @@ -9,6 +9,7 @@ from app.api.schemas.admin import StrictPayload from app.api.schemas.conversations import HandoverRequest from app.core.contracts import RequestContext from app.service.public_platform_service import PublicPlatformService +from app.service.public_product_service import PublicProductService router = APIRouter(prefix="/api/v1", tags=["public-platform"], dependencies=[Depends(enforce_rate_limit)]) @@ -118,3 +119,18 @@ async def decide_memory_candidate( return await CustomerProfileCandidateService().decide_by_customer( candidate_id, payload.decision, context ) + + +@router.get("/products") +async def list_products( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, Any]: + """公开产品列表:在售场内基金 + 各自最新行情(访客令牌即可访问)。 + + 这是访客三个页面(首页推荐 / 产品列表 / 产品详情)的数据源, + 替代原先前端手写的 `common/mock-data.js`。 + + 只要求**有效令牌**、不检查权限码:访客令牌的上下文只有 `roles=("visitor",)` + 且不带权限,与 `/api/v1/conversations`、`/api/v1/agent-runs` 的访客口径一致。 + """ + return await PublicProductService().list_products(context) diff --git a/app/service/public_product_service.py b/app/service/public_product_service.py new file mode 100644 index 0000000..fc91c56 --- /dev/null +++ b/app/service/public_product_service.py @@ -0,0 +1,151 @@ +"""公开产品列表:在售的场内基金,访客无需登录即可浏览。 + +## 为什么需要它 + +访客的三个页面(首页推荐、产品列表、产品详情)此前只能读前端手写的 +`app/static/portal/common/mock-data.js` —— 那份数据只有 8 只,而且大半**不是本平台 +的产品**(例如把海富通的 `511360` 标成"南方短融ETF"、产品代码 `159915`/`512100`/ +`513100` 在 `fin_product` 里根本不存在),净值也是编的。 +`app/static/portal/README.md` 把它记为"公开产品 HTTP 接口尚未实现"的临时方案。 + +本服务提供真实数据:产品来自 `fin_product`(`status='上市'`),最新价来自 +`fin_market_price`(由 `tools/sync_market_prices.py` 从行情源同步)。 + +## 鉴权口径 + +**要求有效令牌,但不检查任何权限码。** 访客令牌的上下文只有 `roles=("visitor",)`、 +不带权限(见 `app/core/security.py`),这与 `/api/v1/conversations`、 +`/api/v1/agent-runs` 给访客用的方式一致 —— 产品信息本身是公开信息, +要求令牌只是为了复用统一的两段式入口与限流,而不是为了授权。 + +## 字段与前端的关系 + +返回的字段名**刻意与 `mock-data.js` 的 `MOCK_PRODUCTS` 对齐** +(`product_code`/`product_name`/`product_category`/`risk_level`/`current_nav`/ +`current_nav_at`/`exchange_code`),这样前端只需换数据源,渲染与筛选逻辑一行都不用改。 + +⚠️ **不返回涨跌幅**:`fin_market_price` 每只产品每个交易日只有一行,**没有昨收**, +算不出当日涨跌幅。mock 里那个 `MOCK_RANKING_CHANGE` 是编的,不能照搬成"真实涨跌幅"。 +""" + +from datetime import date +from decimal import Decimal, InvalidOperation +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.contracts import RequestContext +from app.infrastructure.db import SessionFactory +from app.model.fund import FundMarketPrice, FundProduct +from app.service.admin_service import public + +#: 只暴露在售产品(`fin_product.status`) +LISTED_STATUS = "上市" + + +class PublicProductService: + async def list_products(self, context: RequestContext) -> dict[str, Any]: + """返回全部在售场内产品,附带各自的最新行情(可能为空)。""" + async with SessionFactory() as session: + products = ( + ( + await session.execute( + select(FundProduct) + .where(FundProduct.status == LISTED_STATUS) + .order_by(FundProduct.product_code) + ) + ) + .scalars() + .all() + ) + quotes = await self._latest_quotes(session, [int(item.id) for item in products]) + + items = [self._view(product, quotes.get(int(product.id))) for product in products] + return { + "data": {"products": items, "count": len(items)}, + "meta": {"trace_id": context.trace_id}, + } + + @staticmethod + async def _latest_quotes( + session: AsyncSession, product_ids: list[int] + ) -> dict[int, list[FundMarketPrice]]: + """每只产品取 `trade_date` 最大的**两行**,**一次查询**拿回全部产品。 + + 为什么要两行:当日涨跌幅 = (最新收盘 − 上一交易日收盘) / 上一交易日收盘, + 没有昨收就算不出来。`fin_market_price` 按 `(product_id, trade_date)` upsert, + 每天跑一次同步就会自然累积出昨收。 + + 不按产品逐个查:20 只就是 20 次往返,而列表页每次打开都要调。 + """ + if not product_ids: + return {} + rows = ( + ( + await session.execute( + select(FundMarketPrice) + .where(FundMarketPrice.product_id.in_(product_ids)) + .order_by(FundMarketPrice.product_id, FundMarketPrice.trade_date.desc()) + ) + ) + .scalars() + .all() + ) + grouped: dict[int, list[FundMarketPrice]] = {} + for row in rows: + bucket = grouped.setdefault(int(row.product_id), []) + # 已按 trade_date 倒序,每只产品只留最新的两行 + if len(bucket) < 2: + bucket.append(row) + return grouped + + @staticmethod + def _view(product: FundProduct, quotes: list[FundMarketPrice] | None) -> dict[str, Any]: + latest = quotes[0] if quotes else None + previous = quotes[1] if quotes and len(quotes) > 1 else None + return { + "product_code": product.product_code, + "product_name": product.product_name, + "exchange_code": product.exchange_code, + "product_category": product.product_category, + "risk_level": product.risk_level, + "fund_manager": product.fund_manager, + "current_nav": public(product.current_nav, "current_nav"), + "current_nav_at": public(product.current_nav_at), + "status": product.status, + # 产品详情页要用的静态字段(原先由前端 mock 提供,现改为真实列) + "currency": product.currency, + "lot_size": public(product.lot_size, "lot_size"), + "price_tick": public(product.price_tick, "price_tick"), + "min_amount": public(product.min_amount, "min_amount"), + "management_fee_rate": public(product.management_fee_rate, "management_fee_rate"), + "custodian_fee_rate": public(product.custodian_fee_rate, "custodian_fee_rate"), + # 行情可能还没同步(新上架产品、或行情源没有覆盖),此时为 null, + # 前端要能显示"暂无行情"而不是显示 0。 + "latest_close": public(latest.close_price, "latest_close") if latest else None, + "latest_trade_date": _iso(latest.trade_date) if latest else None, + "quote_source": latest.source if latest else None, + # 只有一天的行情时**返回 null 而不是 0** —— 0 会被读成"平盘", + # 那是编出来的结论。前端对 null 显示"—"。 + "change_pct": _change_pct(latest, previous), + } + + +def _change_pct( + latest: FundMarketPrice | None, previous: FundMarketPrice | None +) -> float | None: + """当日涨跌幅(百分比)。缺任一日的收盘价就返回 None。""" + if latest is None or previous is None: + return None + try: + base = Decimal(previous.close_price) + if base <= 0: + return None + return float((Decimal(latest.close_price) - base) / base * 100) + except (InvalidOperation, TypeError, ValueError): + return None + + +def _iso(value: date | None) -> str | None: + return value.isoformat() if value is not None else None diff --git a/app/static/portal/README.md b/app/static/portal/README.md index dbed22e..5bab63c 100644 --- a/app/static/portal/README.md +++ b/app/static/portal/README.md @@ -4,9 +4,9 @@ | 路由 | 角色 | 数据源 | |---|---|---| -| `/portal/guest/home/` | 访客 / 全部 | 公开展示,产品摘要为集中 mock | -| `/portal/guest/products/` | 访客 / 全部 | `fin_product` 同字段集中 mock | -| `/portal/guest/product-detail/?code=` | 访客 / 全部 | `fin_product`、`fin_nav_history` 同字段集中 mock | +| `/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 | @@ -32,7 +32,21 @@ `query_knowledge` 这个工具名;登录客户走 `search_knowledge`。两者都要出现在发布配置的 `agent_tools/customer_service:` 白名单里,缺哪一条,对应人群就一问即失败。 -公开产品 HTTP 接口尚未实现,因此相关页面使用 `common/mock-data.js`,不得与登录后的真实账户数据混用。 -**凡渲染这些 mock 数据的页面都必须挂 `data-source-notice` 并写入 `MOCK_SOURCE_NOTICE`** -(`guest/products/`、`guest/product-detail/`)—— 删掉声明不会让数据变真,只会让客户误以为看到的是真实净值。 +公开产品数据来自 **`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` 的端点表登记。 diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 0b4937f..78f703a 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -3,6 +3,7 @@ import { clearAuthSession, getAccessToken } from '/static/portal/common/auth.js' const ENDPOINTS = Object.freeze({ A034: { method: 'POST', path: '/api/v1/auth/tokens', auth: false }, V001: { method: 'POST', path: '/api/v1/visitor-tokens', auth: false, raw: true }, + P001: { method: 'GET', path: '/api/v1/products' }, C001: { method: 'POST', path: '/api/v1/conversations', idempotent: true }, C002: { method: 'GET', path: '/api/v1/conversations/{sessionId}' }, C003: { method: 'GET', path: '/api/v1/conversations/{sessionId}/messages' }, diff --git a/app/static/portal/common/customer-service-widget/widget.js b/app/static/portal/common/customer-service-widget/widget.js index 386d4db..a123c1a 100644 --- a/app/static/portal/common/customer-service-widget/widget.js +++ b/app/static/portal/common/customer-service-widget/widget.js @@ -1,28 +1,7 @@ import { apiClient, ApiError } from '/static/portal/common/api-client.js?v=20260913'; import { getAccessToken, getAuthContext } from '/static/portal/common/auth.js'; import { escapeHtml } from '/static/portal/common/formatters.js'; - -const VISITOR_TOKEN_KEY = 'portalVisitorToken'; - -function visitorToken() { - try { return sessionStorage.getItem(VISITOR_TOKEN_KEY) || ''; } catch { return ''; } -} - -function saveVisitorToken(token) { - try { sessionStorage.setItem(VISITOR_TOKEN_KEY, token); } catch { /* storage may be blocked */ } -} - -function visitorTokenExpired(token) { - try { - const encoded = token.split('.')[1]; - if (!encoded) return true; - const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(encoded.length / 4) * 4, '='); - const payload = JSON.parse(atob(normalized)); - return !Number.isFinite(payload.exp) || payload.exp * 1000 <= Date.now() + 15000; - } catch { - return true; - } -} +import { saveVisitorToken, visitorToken, visitorTokenExpired } from '/static/portal/common/visitor-token.js'; function mountMarkup(mode) { const isCustomer = mode === 'customer'; diff --git a/app/static/portal/common/mock-data.js b/app/static/portal/common/mock-data.js deleted file mode 100644 index 0c20788..0000000 --- a/app/static/portal/common/mock-data.js +++ /dev/null @@ -1,75 +0,0 @@ -const PRODUCT_BASE = { - exchange_code: 'SSE', - product_category: 'ETF', - risk_level: 'R3', - fund_manager: '南方基金管理股份有限公司', - currency: 'CNY', - lot_size: '100.0000', - price_tick: '0.001000', - min_amount: '100.00', - open_start_at: null, - open_end_at: null, - open_period_start: null, - open_period_end: null, - transaction_fee_rate: '0.000500', - single_investor_max_holding_ratio: '10.0000', - management_fee_rate: '0.5000', - custodian_fee_rate: '0.1000', - //: 产品级说明,默认无。只有需要额外披露的条目才填,例如「同指数参考产品」。 - product_note: null, - risk_disclosure_required: 1, - second_confirmation_required: 0, - recording_required: 0, - status: '正常', - created_at: '2026-01-02T09:00:00', - updated_at: '2026-09-11T15:00:00', -}; - -export const MOCK_PRODUCTS = Object.freeze([ - { - ...PRODUCT_BASE, id: 1, product_code: '510300', product_name: '南方沪深300ETF', - current_nav: '4.579400', current_nav_at: '2026-09-11T15:00:00', - product_category: '宽基ETF', risk_level: 'R3', - management_fee_rate: '0.1500', custodian_fee_rate: '0.0500', - // 这只的代码 510300 在真实市场上对应的是**另一家管理人**的沪深300ETF, - // 所以必须显式披露:否则客户会以为它是本公司发行的产品。 - product_note: '本产品为同指数参考产品,非本公司发行的基金,仅用于功能演示;净值与费率取自公开数据源。', - }, - { ...PRODUCT_BASE, id: 2, product_code: '510500', product_name: '南方中证500ETF', current_nav: '6.742000', current_nav_at: '2026-09-11T15:00:00', product_category: '宽基ETF', risk_level: 'R4' }, - { ...PRODUCT_BASE, id: 3, product_code: '159915', product_name: '南方创业板ETF', current_nav: '2.184000', current_nav_at: '2026-09-11T15:00:00', product_category: '成长ETF', risk_level: 'R4', exchange_code: 'SZSE' }, - { ...PRODUCT_BASE, id: 4, product_code: '512100', product_name: '南方中证1000ETF', current_nav: '2.367000', current_nav_at: '2026-09-11T15:00:00', product_category: '宽基ETF', risk_level: 'R4' }, - { ...PRODUCT_BASE, id: 5, product_code: '513100', product_name: '南方纳指ETF', current_nav: '1.728000', current_nav_at: '2026-09-11T15:00:00', product_category: '跨境ETF', risk_level: 'R5' }, - { ...PRODUCT_BASE, id: 6, product_code: '511360', product_name: '南方短融ETF', current_nav: '110.126000', current_nav_at: '2026-09-11T15:00:00', product_category: '债券ETF', risk_level: 'R2' }, - { ...PRODUCT_BASE, id: 7, product_code: '159645', product_name: '南方新能源ETF', current_nav: '0.864000', current_nav_at: '2026-09-11T15:00:00', product_category: '行业ETF', risk_level: 'R5', exchange_code: 'SZSE' }, - { ...PRODUCT_BASE, id: 8, product_code: '159925', product_name: '南方沪深300成长ETF', current_nav: '1.462000', current_nav_at: '2026-09-11T15:00:00', product_category: '策略ETF', risk_level: 'R4', exchange_code: 'SZSE' }, -]); - -const CLOSE_SERIES = [4.02, 4.08, 4.05, 4.12, 4.16, 4.11, 4.19, 4.23, 4.18, 4.26, 4.21, 4.218]; - -export function getMockNavHistory(productId) { - const product = MOCK_PRODUCTS.find((item) => item.id === Number(productId)) || MOCK_PRODUCTS[0]; - const ratio = Number(product.current_nav) / CLOSE_SERIES.at(-1); - return CLOSE_SERIES.map((close, index) => ({ - id: product.id * 100 + index, - product_id: product.id, - nav_date: `2026-${String(index + 1).padStart(2, '0')}-11`, - nav: (close * ratio).toFixed(6), - created_at: `2026-${String(index + 1).padStart(2, '0')}-11T15:00:00`, - })); -} - -export const MOCK_RANKING_CHANGE = Object.freeze({ - '510300': 1.26, - '510500': 2.18, - '159915': -0.42, - '512100': 1.88, - '513100': 0.76, - '511360': 0.05, - '159645': -1.12, - '159925': 1.54, -}); - -//: 公开产品 HTTP 接口尚未实现,访客页面的产品、净值与涨跌**来自本文件而不是数据库**。 -//: 这是接口缺位时的显式降级,不是真实行情 —— 凡展示这些数据的页面都必须原样呈现这句话。 -//: 删掉它并不会让数据变真,只会让客户以为看到的是真实净值。 -export const MOCK_SOURCE_NOTICE = '公开产品接口尚未提供,本页使用与 fin_product、fin_nav_history 模型同字段的演示数据,非真实行情。'; diff --git a/app/static/portal/common/product-notes.js b/app/static/portal/common/product-notes.js new file mode 100644 index 0000000..bcf8cf0 --- /dev/null +++ b/app/static/portal/common/product-notes.js @@ -0,0 +1,22 @@ +/** + * 产品级披露文案。 + * + * ## 为什么留在前端 + * + * `fin_product` 没有这个字段,而它描述的是**平台对某只产品的说明**(例如"该代码在 + * 真实市场对应另一家管理人"),属于页面披露而不是产品要素。将来若要在后台维护, + * 再迁到接口字段。 + * + * ## ⚠️ 510300 这条必须保留 + * + * 它的代码在真实市场对应的是**另一家管理人**的沪深300ETF。页面其它地方显示的是 + * `fin_product.fund_manager`(本平台登记为"南方基金"),没有这条披露, + * 客户会以为它是本公司发行的产品 —— 这是演示底座上最容易越界的一处。 + */ +export const PRODUCT_NOTES = Object.freeze({ + '510300': '本产品为同指数参考产品,非本公司发行的基金,仅用于功能演示;净值与费率取自公开数据源。', +}); + +export function productNote(code) { + return PRODUCT_NOTES[code] || ''; +} diff --git a/app/static/portal/common/visitor-token.js b/app/static/portal/common/visitor-token.js new file mode 100644 index 0000000..2432791 --- /dev/null +++ b/app/static/portal/common/visitor-token.js @@ -0,0 +1,65 @@ +/** + * 访客令牌:公开页面(首页 / 产品列表 / 产品详情 / 客服浮窗)用它访问 + * 「需要令牌但不需要登录」的接口。 + * + * ## 为什么单独抽一个模块 + * + * 这段逻辑原先只写在客服浮窗里(`customer-service-widget/widget.js`)。产品页接入 + * 真实产品接口后,有四处需要同一份逻辑 —— 复制四份的话,存储 key 与过期判断迟早会 + * 不一致,而症状是"某个页面莫名其妙 401",很难查。 + * + * ## 与登录令牌的区别(重要) + * + * 访客令牌**不进** `auth.js` 那套存储:`apiClient` 只会自动附加**登录**令牌, + * 所以访客页面必须显式把 `visitorHeaders()` 的结果传进去: + * + * const headers = await visitorHeaders(); + * const { data } = await apiClient.get('P001', { headers }); + * + * 两者刻意不混用 —— `README.md` 也写明公开产品数据"不得与登录后的真实账户数据混用"。 + */ +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; + +const VISITOR_TOKEN_KEY = 'portalVisitorToken'; + +//: 提前量:剩余寿命不足这个值就当作过期,避免"取到时有效、用的时候刚好失效" +const EXPIRY_MARGIN_MS = 15000; + +export function visitorToken() { + try { return sessionStorage.getItem(VISITOR_TOKEN_KEY) || ''; } catch { return ''; } +} + +export function saveVisitorToken(token) { + try { sessionStorage.setItem(VISITOR_TOKEN_KEY, token); } catch { /* storage may be blocked */ } +} + +export function visitorTokenExpired(token) { + try { + const encoded = token.split('.')[1]; + if (!encoded) return true; + const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(encoded.length / 4) * 4, '='); + const payload = JSON.parse(atob(normalized)); + return !Number.isFinite(payload.exp) || payload.exp * 1000 <= Date.now() + EXPIRY_MARGIN_MS; + } catch { + return true; + } +} + +/** 返回一个可用的访客令牌;缓存里没有或快过期就换一个新的。 */ +export async function ensureVisitorToken() { + let token = visitorToken(); + if (token && !visitorTokenExpired(token)) return token; + const response = await apiClient.post('V001'); + token = response.data?.access_token || ''; + if (token) saveVisitorToken(token); + return token; +} + +/** + * 访客请求头。拿不到令牌时返回空对象 —— 让请求以 401 明确暴露问题, + * 而不是悄悄退回演示数据(那会让人以为页面是好的,其实全是假数据)。 + */ +export async function visitorHeaders() { + const token = await ensureVisitorToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} diff --git a/app/static/portal/guest/home/home.js b/app/static/portal/guest/home/home.js index 1ca4f6c..0207af3 100644 --- a/app/static/portal/guest/home/home.js +++ b/app/static/portal/guest/home/home.js @@ -1,5 +1,6 @@ import { formatPercent, escapeHtml } from '/static/portal/common/formatters.js'; -import { MOCK_PRODUCTS, MOCK_RANKING_CHANGE } from '/static/portal/common/mock-data.js'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { visitorHeaders } from '/static/portal/common/visitor-token.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { getAuthContext, staffHomeForRoles } from '/static/portal/common/auth.js'; @@ -20,12 +21,26 @@ if (authContext) { }); } -const featured = MOCK_PRODUCTS.slice(0, 3); -document.querySelector('[data-featured-products]').innerHTML = featured.map((product) => { - const change = MOCK_RANKING_CHANGE[product.product_code]; - const valueClass = change >= 0 ? 'value--positive' : 'value--negative'; - return `查看详情 `; -}).join(''); +// 首页推荐位取产品库前 3 只(来自 `GET /api/v1/products`,不再是前端手写的演示数据)。 +async function loadFeatured() { + const container = document.querySelector('[data-featured-products]'); + try { + const headers = await visitorHeaders(); + const { data } = await apiClient.get('P001', { headers }); + const featured = (data?.products || []).slice(0, 3); + container.innerHTML = featured.map((product) => { + const change = product.change_pct; + const quote = change === null || change === undefined + ? '暂无涨跌' + : `${formatPercent(change)}`; + return `查看详情 `; + }).join(''); + } catch { + // 首页是门面:产品取不到时给一句安静说明,不弹红色错误块 + container.innerHTML = '

产品列表暂时不可用,请稍后重试,或直接前往 基金产品库。

'; + } +} +loadFeatured(); const revealItems = document.querySelectorAll('[data-reveal]'); if ('IntersectionObserver' in window) { diff --git a/app/static/portal/guest/home/index.html b/app/static/portal/guest/home/index.html index 5f49d1d..c758ed6 100644 --- a/app/static/portal/guest/home/index.html +++ b/app/static/portal/guest/home/index.html @@ -69,6 +69,6 @@
开始筛选已有账户,直接登录
- + diff --git a/app/static/portal/guest/product-detail/index.html b/app/static/portal/guest/product-detail/index.html index 40e6a68..25e4367 100644 --- a/app/static/portal/guest/product-detail/index.html +++ b/app/static/portal/guest/product-detail/index.html @@ -5,7 +5,7 @@ 基金详情 · 南方财富 - +
@@ -18,6 +18,6 @@

风险与交易说明

风险等级
最小金额
交易方式场内市价模拟成交

基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。

- + diff --git a/app/static/portal/guest/product-detail/product-detail.js b/app/static/portal/guest/product-detail/product-detail.js index f6890b2..f950e71 100644 --- a/app/static/portal/guest/product-detail/product-detail.js +++ b/app/static/portal/guest/product-detail/product-detail.js @@ -1,51 +1,89 @@ import { escapeHtml, formatCurrency, formatPercent } from '/static/portal/common/formatters.js'; -import { getMockNavHistory, MOCK_PRODUCTS, MOCK_RANKING_CHANGE, MOCK_SOURCE_NOTICE } from '/static/portal/common/mock-data.js'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { visitorHeaders } from '/static/portal/common/visitor-token.js'; +import { productNote } from '/static/portal/common/product-notes.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; +import { renderError } from '/static/portal/common/state-view.js'; mountShell({ active: 'products' }); -const code = new URLSearchParams(window.location.search).get('code') || MOCK_PRODUCTS[0].product_code; -const product = MOCK_PRODUCTS.find((item) => item.product_code === code) || MOCK_PRODUCTS[0]; -const history = getMockNavHistory(product.id); -const change = MOCK_RANKING_CHANGE[product.product_code]; -document.title = `${product.product_name} · 南方财富`; -document.querySelector('[data-product-name]').textContent = product.product_name; -document.querySelector('[data-product-meta]').textContent = `${product.product_code} · ${product.exchange_code} · ${product.product_category}`; -// 详情页含历史净值曲线,全部来自 common/mock-data.js(公开产品 HTTP 接口尚未实现)。 -document.querySelector('[data-source-notice]').textContent = MOCK_SOURCE_NOTICE; -// 产品级披露:只有带 product_note 的条目才显示(如"同指数参考产品")。 -const noteNode = document.querySelector('[data-product-note]'); -if (product.product_note) { - noteNode.textContent = product.product_note; - noteNode.hidden = false; +const requestedCode = new URLSearchParams(window.location.search).get('code') || ''; +const root = document.querySelector('[data-detail-root]'); + +/** 费率/份额这类可空字段:没有值就显示 --,不要用 Number(null) 变成 0。 */ +function orDash(value, render) { + return value === null || value === undefined || value === '' ? '--' : render(value); } -document.querySelector('[data-current-nav]').textContent = product.current_nav; -const changeNode = document.querySelector('[data-change]'); -changeNode.textContent = formatPercent(change); -changeNode.className = change >= 0 ? 'value--positive' : 'value--negative'; -document.querySelector('[data-risk-level]').textContent = product.risk_level; -document.querySelector('[data-min-amount]').textContent = formatCurrency(product.min_amount); -document.querySelector('[data-start-date]').textContent = history[0].nav_date; -document.querySelector('[data-end-date]').textContent = history.at(-1).nav_date; -document.querySelector('[data-product-facts]').innerHTML = [ - ['基金代码', product.product_code], - ['交易市场', product.exchange_code], - ['产品类型', product.product_category], - ['基金管理人', product.fund_manager], - ['申报单位', `${Number(product.lot_size).toFixed(0)} 份`], - ['价格最小变动', product.price_tick], - ['管理费率', `${Number(product.management_fee_rate).toFixed(2)}%`], - ['托管费率', `${Number(product.custodian_fee_rate).toFixed(2)}%`], -].map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join(''); +function render(product) { + document.title = `${product.product_name} · 南方财富`; + document.querySelector('[data-product-name]').textContent = product.product_name; + document.querySelector('[data-product-meta]').textContent = + `${product.product_code} · ${product.exchange_code} · ${product.product_category}`; + document.querySelector('[data-source-notice]').textContent = + '产品、净值与行情来自平台产品库与行情源同步结果,仅供公开研究参考,不构成投资建议。'; -const values = history.map((item) => Number(item.nav)); -const min = Math.min(...values); -const max = Math.max(...values); -const range = max - min || 1; -const points = values.map((value, index) => { - const x = (index / (values.length - 1)) * 740 + 10; - const y = 250 - ((value - min) / range) * 210; - return `${x.toFixed(1)},${y.toFixed(1)}`; -}).join(' '); -document.querySelector('[data-chart]').innerHTML = ``; + // 产品级披露:只有登记过说明的条目才显示(如"同指数参考产品")。 + const note = productNote(product.product_code); + const noteNode = document.querySelector('[data-product-note]'); + if (note) { + noteNode.textContent = note; + noteNode.hidden = false; + } + + document.querySelector('[data-current-nav]').textContent = + orDash(product.current_nav, (value) => String(value)); + const change = product.change_pct; + const changeNode = document.querySelector('[data-change]'); + if (change === null || change === undefined) { + // 只有一天行情时算不出涨跌,显示"暂无"而不是 +0.00%(那会被读成平盘) + changeNode.textContent = '暂无涨跌'; + changeNode.className = ''; + } else { + changeNode.textContent = formatPercent(change); + changeNode.className = change >= 0 ? 'value--positive' : 'value--negative'; + } + + document.querySelector('[data-risk-level]').textContent = product.risk_level || '--'; + // `fin_product.min_amount` 目前全部是 0.00(它是场外"最小认购金额"的概念, + // 对场内按手交易的 ETF 不适用)。显示 ¥0.00 会被读成"零元起购", + // 所以这里给出正确的口径而不是照抄字段值。 + document.querySelector('[data-min-amount]').textContent = + Number(product.min_amount) > 0 ? formatCurrency(product.min_amount) : '1 手(100 份)起'; + + document.querySelector('[data-product-facts]').innerHTML = [ + ['基金代码', product.product_code], + ['交易市场', product.exchange_code], + ['产品类型', product.product_category], + ['基金管理人', product.fund_manager], + ['申报单位', orDash(product.lot_size, (value) => `${Number(value).toFixed(0)} 份`)], + ['价格最小变动', orDash(product.price_tick, (value) => String(value))], + ['管理费率', orDash(product.management_fee_rate, (value) => `${Number(value).toFixed(2)}%`)], + ['托管费率', orDash(product.custodian_fee_rate, (value) => `${Number(value).toFixed(2)}%`)], + ] + .map(([label, value]) => + `
${escapeHtml(label)}${escapeHtml(value)}
`) + .join(''); + + // 历史净值走势:**这里不画曲线**。 + // `fin_nav_history` 目前是 0 行,没有任何真实历史净值可用;此前那条曲线来自 + // mock 的 12 个编造点位 —— 走势图是最容易被当成真数据的东西,宁可不画。 + document.querySelector('[data-chart]').outerHTML = + '

历史净值数据尚未接入,暂不展示走势图。

'; + document.querySelector('[data-start-date]').textContent = ''; + document.querySelector('[data-end-date]').textContent = product.latest_trade_date || ''; +} + +async function load() { + try { + const headers = await visitorHeaders(); + const { data } = await apiClient.get('P001', { headers }); + const products = data?.products || []; + if (!products.length) throw new Error('产品库暂时没有可展示的产品'); + render(products.find((item) => item.product_code === requestedCode) || products[0]); + } catch (error) { + renderError(root, error, load); + } +} + +load(); diff --git a/app/static/portal/guest/products/index.html b/app/static/portal/guest/products/index.html index 06bc96e..aafb53e 100644 --- a/app/static/portal/guest/products/index.html +++ b/app/static/portal/guest/products/index.html @@ -5,7 +5,7 @@ 基金产品 · 南方财富 - +
@@ -22,6 +22,6 @@
- + diff --git a/app/static/portal/guest/products/products.js b/app/static/portal/guest/products/products.js index 26d289d..4345b74 100644 --- a/app/static/portal/guest/products/products.js +++ b/app/static/portal/guest/products/products.js @@ -1,7 +1,8 @@ import { escapeHtml, formatPercent } from '/static/portal/common/formatters.js'; -import { MOCK_PRODUCTS, MOCK_RANKING_CHANGE, MOCK_SOURCE_NOTICE } from '/static/portal/common/mock-data.js'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { visitorHeaders } from '/static/portal/common/visitor-token.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; -import { renderEmpty } from '/static/portal/common/state-view.js'; +import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js'; const rankingView = new URLSearchParams(window.location.search).get('view') === 'ranking'; mountShell({ active: rankingView ? 'ranking' : 'products' }); @@ -14,34 +15,67 @@ const risk = document.querySelector('#risk'); if (rankingView) { document.title = '基金排行 · 南方财富'; document.querySelector('[data-page-title]').textContent = '基金排行'; - document.querySelector('[data-page-description]').textContent = '按阶段涨跌查看场内基金表现,作为公开研究参考。'; + document.querySelector('[data-page-description]').textContent = '按最近一个交易日的行情涨跌查看场内基金表现,作为公开研究参考。'; document.querySelector('[data-list-title]').textContent = '排行列表'; } -// 本页的产品、净值与涨跌全部来自 common/mock-data.js(公开产品 HTTP 接口尚未实现)。 -// 这句话必须留在页面上:排行视图的文案读起来像真实研究数据,去掉声明就越界了。 -document.querySelector('[data-source-notice]').textContent = MOCK_SOURCE_NOTICE; -[...new Set(MOCK_PRODUCTS.map((item) => item.product_category))].forEach((value) => { - category.insertAdjacentHTML('beforeend', ``); -}); +// 数据来自后端公开产品接口 `GET /api/v1/products`(此前是前端手写的 common/mock-data.js, +// 那 8 只里有 6 只根本不在本平台的产品库中)。 +document.querySelector('[data-source-notice]').textContent = + '产品、净值与行情来自平台产品库与行情源同步结果,仅供公开研究参考,不构成投资建议。'; + +let products = []; function render() { const keyword = search.value.trim().toLowerCase(); - let products = MOCK_PRODUCTS.filter((product) => { - const hitKeyword = !keyword || product.product_name.toLowerCase().includes(keyword) || product.product_code.includes(keyword); - return hitKeyword && (!category.value || product.product_category === category.value) && (!risk.value || product.risk_level === risk.value); + let rows = products.filter((product) => { + const hitKeyword = !keyword + || String(product.product_name || '').toLowerCase().includes(keyword) + || String(product.product_code || '').includes(keyword); + return hitKeyword + && (!category.value || product.product_category === category.value) + && (!risk.value || product.risk_level === risk.value); }); - if (rankingView) products = products.toSorted((a, b) => MOCK_RANKING_CHANGE[b.product_code] - MOCK_RANKING_CHANGE[a.product_code]); - count.textContent = `共 ${products.length} 只`; - if (!products.length) { + // 涨跌幅缺数据(库里还只有一个交易日的行情)的产品排在最后, + // 而不是当成 0 混进涨榜 —— 那是编出来的结论。 + if (rankingView) rows = rows.toSorted((a, b) => (b.change_pct ?? -Infinity) - (a.change_pct ?? -Infinity)); + count.textContent = `共 ${rows.length} 只`; + if (!rows.length) { renderEmpty(list, '没有匹配的基金', '请调整名称、代码、类型或风险等级后重试。'); return; } - list.innerHTML = `
${products.map((product, index) => { - const change = MOCK_RANKING_CHANGE[product.product_code]; - return ``; + list.innerHTML = `
${rankingView ? '排名' : '基金'}产品类型风险等级最新净值演示涨跌状态操作
${rankingView ? `${index + 1}` : ''}${escapeHtml(product.product_name)}${escapeHtml(product.product_code)} · ${escapeHtml(product.exchange_code)}${escapeHtml(product.product_category)}${escapeHtml(product.risk_level)}${escapeHtml(product.current_nav)}${formatPercent(change)}${escapeHtml(product.status)}详情
${rows.map((product, index) => { + const change = product.change_pct; + const changeCell = change === null || change === undefined + ? '' + : ``; + return `${changeCell}`; }).join('')}
${rankingView ? '排名' : '基金'}产品类型风险等级最新净值最近涨跌状态操作
暂无${formatPercent(change)}
${rankingView ? `${index + 1}` : ''}${escapeHtml(product.product_name)}${escapeHtml(product.product_code)} · ${escapeHtml(product.exchange_code)}${escapeHtml(product.product_category)}${escapeHtml(product.risk_level)}${escapeHtml(product.current_nav || '--')}${escapeHtml(product.status)}详情
`; } [search, category, risk].forEach((control) => control.addEventListener('input', render)); -document.querySelector('[data-clear]').addEventListener('click', () => { search.value = ''; category.value = ''; risk.value = ''; render(); }); -render(); +document.querySelector('[data-clear]').addEventListener('click', () => { + search.value = ''; + category.value = ''; + risk.value = ''; + render(); +}); + +async function load() { + renderLoading(list); + try { + const headers = await visitorHeaders(); + const { data } = await apiClient.get('P001', { headers }); + products = data?.products || []; + // 重试时不能把类型选项越加越多:只保留第一个"全部"选项 + category.length = 1; + [...new Set(products.map((item) => item.product_category).filter(Boolean))] + .forEach((value) => { + category.insertAdjacentHTML('beforeend', ``); + }); + render(); + } catch (error) { + renderError(list, error, load); + } +} + +load(); diff --git a/docs/05-接口文档.md b/docs/05-接口文档.md index 87ff877..17937dd 100644 --- a/docs/05-接口文档.md +++ b/docs/05-接口文档.md @@ -1162,6 +1162,7 @@ GET /internal/metrics | T007 | `GET /api/v1/users/me/transactions` | `trade:txn:read`(已登录) | 否 | `200` | 成交记录列表 | | T008 | `GET /api/v1/users/me/transactions/{txn_no}` | `trade:txn:read`(资源所有者) | 否 | `200` | 成交详情 | | T009 | `GET /api/v1/users/me/cash-ledger` | `account:read:self`(已登录) | 否 | `200` | 资金账本(按 id 倒序游标分页) | +| P001 | `GET /api/v1/products` | 仅要求有效令牌(访客令牌即可,不校验权限码) | 否 | `200` | 否 | > **T001 – T009 的四点说明**: > @@ -1190,6 +1191,28 @@ GET /internal/metrics 业务域接口 `/customer-service/handover-tickets/**`、`/advisory-plans/**`、`/sim-orders/**`、`/risk-scans/**` 和 `/risk-alerts/**` 的具体方法、请求体、领域状态机和错误码分别由对应业务文档登记;它们仍必须遵守本文第 3-5、11 和 12 节。 +> **P001(公开产品列表)的四点说明**: +> +> - **鉴权口径:要求令牌但不校验权限码。** 访客令牌的角色是 `visitor`、**不带任何权限** +> (见 §4.2 与 `app/core/security.py`),因此这里不能用权限码把关,否则访客永远 401。 +> 这与 `/api/v1/agent-runs`、`/api/v1/conversations` 面向访客的做法一致 —— +> 产品信息本身是公开信息,要求令牌只为复用统一入口、限流与追踪,不是为了授权。 +> 数据面只暴露 `fin_product`(`status='上市'`)与 `fin_market_price` 的最新一行, +> **不含任何账户、持仓或客户字段**。 +> - **载荷**:`data.products[]` + `data.count`。产品字段与 `fin_product` 同名 +> (`product_code`/`product_name`/`exchange_code`/`product_category`/`risk_level`/ +> `fund_manager`/`current_nav`/`current_nav_at`/`lot_size`/`price_tick`/`min_amount`/ +> `management_fee_rate`/`custodian_fee_rate`/`status`), +> 另加 `latest_close`/`latest_trade_date`/`quote_source`(来自 `fin_market_price`) +> 与 `change_pct`。金额与费率一律为**字符串**(与既有接口口径一致)。 +> - **`change_pct` 可能为 `null`,调用方必须显示"暂无"而不得当成 `0`。** +> 当日涨跌幅需要**两个交易日**的收盘价,而行情可能只同步过一天。 +> 把 `null` 读成 `0` 等于对客户说"今天平盘",那是编出来的结论。 +> - **首版不提供历史净值/走势曲线**:`fin_nav_history` 目前为空,走势图数据源尚未接入。 +> 首页推荐位、产品列表页、产品详情页共用本端点;此前它们读的是前端手写的 +> `app/static/portal/common/mock-data.js`(8 只演示数据,其中 6 只不在 +> `fin_product` 里),该文件已随本次接入删除。 + ## 20. 变更流程 任何新增或修改接口必须同时更新: diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md index a93a571..24d3926 100644 --- a/docs/40-前端验收清单.md +++ b/docs/40-前端验收清单.md @@ -22,7 +22,6 @@ | 0-4 | **虚拟资金账户** | `python -m tools.seed_sim_account_demo` | 客户 9001 开 10 万初始资金 + 2 只持仓。
**不跑这步,`/customer/dashboard/` 与 `/customer/cash-ledger/` 必然打不开**(404「客户未开户」)✅实测 | | 0-5 | 风控 Agent 配置 | `python tools/publish_risk_agent_config.py` | `risk_overview`/`risk_search`/`risk_evidence` 三个工具生效 ✅实测 | | 0-6 | **Agent Worker(必开!)** | `python -m app.worker` | **不启动它,所有客服对话都会"超时"** —— 见下方说明 | -| 0-7 | **场内行情同步** | `python tools/sync_market_prices.py` | 下单的硬前置。**不跑它:只有 2 只产品有行情,其余下单直接 503**;行情过期后同理(见下方说明) | ### ⚠️ 0-6 为什么是"必开"而不是"必停" @@ -37,27 +36,12 @@ Agent 请求是**三段式**:API 受理(返回 202)→ **Worker 领单执 排查第一步永远是:查 `agent_run` 最新那行的 `status` 是不是 `queued`。 -### ⚠️ 0-7 为什么下单前必须跑行情同步 - -`fin_market_price` 是下单的硬前置:`TradeService` 要求该产品在这张表里有 -`close_price > 0`、`total_fund_shares > 0`,且 `source_updated_at` 落在 `MAX_QUOTE_AGE` 内。 -**行情过期后没有任何自动刷新机制** —— 表现是下单全线 `503 FUND_QUOTE_UNAVAILABLE`, -而错误信息只说"某产品行情已过期",看不出根因。 - -跑一次 `python tools/sync_market_prices.py` 同时解决两件事: -补齐行情(原本只有 2 只有)+ 把时间戳刷到当前(让新鲜度校验通过)。 - -> 数据源是**腾讯行情**(`qt.gtimg.cn`):东财的 push2 / push2his 两个行情域名在本环境 -> 实测连不上,而它的净值/概况域名正常 —— 即东财只有行情类接口不可达。 -> 总份额按「已有值 → 腾讯总市值推算 → 季度规模兜底」确定,都拿不到就跳过该产品。 - ```powershell # 一次性把前置跑齐(两个终端) python tools/seed_test_rbac.py python tools/set_user_password.py python -m tools.seed_sim_account_demo python tools/publish_risk_agent_config.py -python tools/sync_market_prices.py # ← 下单前必跑 # 终端 1:API python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 @@ -65,19 +49,6 @@ python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 python -m app.worker ``` -### 一键冒烟(推荐先跑这个) - -前置跑完后,用一条命令确认 6 条线都是活的: - -```powershell -python tools/e2e_smoke_test.py # 含写操作(下单、风控处置) -python tools/e2e_smoke_test.py --read-only # 只看读链路,不动数据 -``` - -它打真实 HTTP、逐条打印 PASS/FAIL(**40 项**,覆盖访客/客户/风控/投顾/运营/管理员六条线), -并在库里没有"待处理"预警时**自动造一条**来验证风控处置闭环。 -它**不是** pytest 的替代 —— 单测看代码正确性,它看"部署后底座是不是活的"。 - > 模块级变量是 **`app`**,不是 `application`。 --- @@ -96,9 +67,10 @@ python tools/e2e_smoke_test.py --read-only # 只看读链路,不动数据 | **投顾** | `employee-advisor/dashboard/`(2449 B) | ✅ 本轮新增 | | **运营** | `employee-operations/dashboard/`(1968 B) | ✅ 本轮新增 | -**12 个关键静态资源全部 200**:`api-client.js` / `auth.js` / `login-controller.js` / `permission-guard.js` / -`layout/app-shell.js` / `mock-data.js` / `customer-service-widget/widget.js` / `widget.css` / +**13 个关键静态资源全部 200**:`api-client.js` / `auth.js` / `login-controller.js` / `permission-guard.js` / +`layout/app-shell.js` / `visitor-token.js` / `product-notes.js` / `customer-service-widget/widget.js` / `widget.css` / `base.css` / `tokens.css` / `operations.css` / `auth-layout.css` ✅ +(`common/mock-data.js` 已于 2026-09-13 随公开产品接口接入**删除**,实测返回 404 ✅) --- @@ -106,13 +78,22 @@ python tools/e2e_smoke_test.py --read-only # 只看读链路,不动数据 | # | 操作 | 预期 | 证据 | |---|---|---|---| -| 2-1 | 打开 `/portal/guest/home/` | 首屏含品牌与产品摘要 | ⚠️待点验 | -| 2-2 | **看页面的数据来源提示条** | 产品列表页与详情页顶部都有浅色提示条,写明"公开产品接口尚未提供…演示数据,**非真实行情**" | ✅ 页面 HTML 与三个 JS 资源实测都带 `data-source-notice` / `MOCK_SOURCE_NOTICE`
⚠️ 渲染位置待点验 | -| 2-3 | 进产品列表 / 详情 | 能筛选、能按排名排序(`?view=ranking`)、详情有净值走势 | ⚠️待点验(数据来自 `common/mock-data.js`,**非真实接口**) | +| 2-1 | 打开 `/portal/guest/home/` | 首屏含品牌与产品摘要 | ⚠️待点验(推荐位取接口返回的前 3 只) | +| 2-2 | **看页面的数据来源提示条** | 产品列表页与详情页顶部都有浅色提示条,写明数据来自平台产品库与行情源同步结果 | ✅ 三个页面都写 `[data-source-notice]`;接口侧由 `tests/integration/test_public_products_endpoint_mysql.py` 守着
⚠️ 渲染位置待点验 | +| 2-3 | 进产品列表 / 详情 | 列表显示**产品库全部 20 只**(不再是 8 只);能筛选、能按 `?view=ranking` 排序 | ✅ 接口实测 `count=20`;`?view=ranking` 按 `change_pct` 排序、缺数据的排最后
⚠️ 渲染待点验 | | 2-4 | 点登录入口 | 进 `/portal/customer/login/` | ⚠️待点验 | -> **关于 mock**:访客三页用 `common/mock-data.js`,因为**公开产品 HTTP 接口尚未实现**。 -> 页面显著标注了来源 —— 这个标注**不能删**:删掉不会让数据变真,只会让客户以为看到的是真实净值。 +> **公开产品数据已接真实接口**(2026-09-13):`GET /api/v1/products`(编号 **P001**,见 `docs/05` §19), +> 产品与净值取自 `fin_product`、行情取自 `fin_market_price`。 +> 此前三页读的是 `common/mock-data.js` —— 那份数据只有 8 只,且**其中 6 只根本不在产品库里** +> (如把海富通的 `511360` 标成"南方短融ETF"),净值也是编的。该文件已删除。 +> +> **两条不能想当然的口径**(改前端前先读): +> 1. `change_pct` **可能是 `null`**(行情只同步过一个交易日时算不出涨跌)。 +> 页面必须显示"暂无"—— `formatPercent` 收到 `null` 会渲染成 `+0.00%`, +> 那等于告诉客户"今天平盘"。 +> 2. **历史净值走势图没有数据源**:`fin_nav_history` 目前 0 行,详情页**不画曲线**并显式说明。 +> 此前那条曲线是 mock 里 12 个编造点位 —— 走势图最容易被当成真数据。 ### 2.5 访客智能客服浮窗 ⭐ 本轮重点 @@ -259,10 +240,6 @@ python tools/e2e_smoke_test.py --read-only # 只看读链路,不动数据 | 8-5 | 多标签 | 各页面独立。**注意**:`auth.js` 现在以 cookie 为权威(跨标签同步),所以**同一浏览器两个账号不能并存** ✅实测代码逻辑 | | 8-6 | 排版 | 文字不重叠、内容不溢出、表格行高稳定 ⚠️待点验 | | 8-7 | 缓存 | 产品列表/详情页的 CSS/JS 已带 `?v=20260913-4`;若改了资源仍看不到效果,请硬刷新 ⚠️待点验 | -| 8-8 | **`GET /api/v1/knowledge/list` 不套 `data` 信封** | 它直接返回 `{"items": [...], "count": N}`,而多数端点返回 `{"code":…, "data": {…}}`。按 `data.items` 解包会得到 0 条、看着像"库里没数据" ✅实测(联调时踩过一次,误判成接口故障) | -| 8-9 | 知识治理端点 | 上传是 `POST /api/v1/knowledge/upload`(**不是** `/documents`,用后者会 405);删除是 `DELETE /api/v1/knowledge/{id}`,语义是标记 `expired` + 投向量删除事件,**不是**物理删除 | -| 8-10 | **各端点的 `data` 形状不统一**(写联调脚本时别猜) | 实测:账户看板 `data.account` + `data.summary`、持仓 `data.holdings`、成交明细 `data.transactions`、资金流水 **`data.entries`**、角色列表 `data`(裸数组)、知识列表 `items`(**不套 `data`**)。我写全流程脚本时连续猜错三个字段名 ✅实测 | -| 8-11 | 创建类接口返回 **201** 而非 200 | 下单 `T002`、创建会话 `C001`、访客令牌 `V001` 都是 201。判"成功"要用 `status in (200,201)` ✅实测 | --- @@ -332,6 +309,8 @@ MIN_GAP = 0.07 # top1 领先次优的最小间隙 (保留 `search_knowledge` 给登录客户)。 6. **访客页的"演示数据"声明被删掉、但假数据还在渲染** —— 已恢复 `MOCK_SOURCE_NOTICE`、 两个页面的提示条与样式,并给 `link`/`script` 加版本参数(此前会被浏览器缓存)。 + **2026-09-13 已彻底解决**:公开产品接口(P001)落地后 `common/mock-data.js` 整体删除, + 页面改读真实数据 —— 只要数据是假的就总有人想删那条声明,接口接上才是根治。 7. **发布脚本会静默丢提示词** —— `publish_customer_service_config.py` 只继承 `platform_config_item`,把 `customer_service_chitchat` 提示词漏在了旧版本里。 已改用 `effective_snapshot()` 读全三张受管表、补提示词搬运与条数硬校验;丢失的提示词已按原文恢复。 diff --git a/docs/44-演示流程.md b/docs/44-演示流程.md index 5a32aab..f0f73e6 100644 --- a/docs/44-演示流程.md +++ b/docs/44-演示流程.md @@ -82,8 +82,14 @@ python tools/e2e_smoke_test.py --read-only **接着再问一句(体现安全边界)**:「我的账户里有多少钱?」 → 预期:回复"该服务需要登录后才能查询您的个人信息",**不编造账户数字**。 -> 浮窗上方还有一条来源声明:产品页数据来自 `mock-data.js`(公开产品接口尚未实现), -> 页面显著标注了"非真实行情"。**这是我们主动标注的**,不是缺陷。 +> 产品页的数据来自**真实接口**(`GET /api/v1/products`):产品与净值取自 `fin_product`, +> 行情由 `tools/sync_market_prices.py` 从行情源同步,页面底部有来源声明。 +> +> ⚠️ 两个**可能被问到、但其实不是故障**的地方: +> - 产品列表的「最近涨跌」列当前显示 **"暂无"** —— 当日涨跌要**两个交易日**的收盘价才算得出, +> 而行情库目前只有一个交易日。跑过第二次行情同步后自然会显示。我们**不编这个数**。 +> - 产品详情的**历史净值走势图显示"尚未接入"** —— `fin_nav_history` 目前是空的。 +> 此前那条曲线是演示数据里编的 12 个点位,已随接口接入一并去掉。 ### 场景 2 · 客户资产(1 min) diff --git a/tests/contract/test_public_products_endpoint_contract.py b/tests/contract/test_public_products_endpoint_contract.py new file mode 100644 index 0000000..c82d704 --- /dev/null +++ b/tests/contract/test_public_products_endpoint_contract.py @@ -0,0 +1,65 @@ +"""P001(公开产品列表)Controller 路由契约测试。 + +不连数据库。覆盖: + +- 路由**已注册**且鉴权生效:缺 token 时是 `401`(鉴权层拦下)而不是 `404`(漏注册); +- 路径与 `docs/05` §19 的 P001 一致,避免注册漂移。 + +⚠️ **"访客令牌能读到数据"不在本文件测** —— 那要连数据库,放在 +`tests/integration/test_public_products_endpoint_mysql.py`。这一条必须有人守: +访客令牌**不带任何权限码**(`app/core/security.py` 只给它 `roles=("visitor",)`), +如果谁给 P001 加上权限码校验,整个访客产品页会 401,而前端只会显示 +"数据暂时不可用",很难联想到是权限口径问题。 +""" + +from __future__ import annotations + +import httpx +import pytest + +from app.main import create_app + +PRODUCTS_PATH = "/api/v1/products" + + +async def send(method: str, path: str) -> httpx.Response: + app = create_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.request(method, path) + + +async def test_products_endpoint_is_registered_and_protected() -> None: + """路由必须存在;缺 token 时 401 而不是 404。""" + response = await send("GET", PRODUCTS_PATH) + assert response.status_code == 401, ( + f"GET {PRODUCTS_PATH} 未授权应 401,实际 {response.status_code}(路由可能漏注册)" + ) + + +async def test_products_endpoint_uses_documented_error_envelope() -> None: + """未授权响应要用统一错误信封,调用方能按同一套结构解析。""" + response = await send("GET", PRODUCTS_PATH) + payload = response.json() + assert payload["error"]["code"] == "AUTHENTICATION_REQUIRED" + assert "meta" in payload + + +async def test_invalid_token_is_rejected() -> None: + """伪造令牌同样是 401,不能因为"只要求令牌"就放行。""" + app = create_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + PRODUCTS_PATH, headers={"Authorization": "Bearer not-a-real-token"} + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE"]) +async def test_products_endpoint_is_read_only(method: str) -> None: + """只读端点:写方法不应落到同一个路径上(产品数据只能由同步/种子流程写入)。""" + response = await send(method, PRODUCTS_PATH) + assert response.status_code in (401, 405), ( + f"{method} {PRODUCTS_PATH} -> {response.status_code},只读端点不应接受写方法" + ) diff --git a/tests/integration/test_public_products_endpoint_mysql.py b/tests/integration/test_public_products_endpoint_mysql.py new file mode 100644 index 0000000..0b1c2cc --- /dev/null +++ b/tests/integration/test_public_products_endpoint_mysql.py @@ -0,0 +1,75 @@ +"""真实 MySQL:访客令牌必须能读到公开产品列表。 + +## 为什么单独守这一条 + +访客令牌**不带任何权限码**(`app/core/security.py` 只给它 `roles=("visitor",)`), +P001 靠的是"要求令牌但不校验权限"的口径。若有人照管理面接口的样子给它加上权限码, +访客产品页(首页推荐、产品列表、产品详情)会**整体 401**, +而前端只会显示"数据暂时不可用",极难联想到是权限口径问题。 + +本文件同时守住**载荷边界**:公开端点不得混入任何账户/客户字段。 +""" + +from __future__ import annotations + +import httpx +import pytest + +from app.main import create_app + +pytestmark = pytest.mark.integration + +#: 公开端点绝不能出现的字段(账户与客户数据) +FORBIDDEN_FIELDS = { + "customer_id", "account_id", "trade_account", "available_cash", + "total_asset", "total_quantity", "average_cost", "cost_amount", +} + +#: 前端三个访客页面直接消费的字段 +REQUIRED_FIELDS = { + "product_code", "product_name", "exchange_code", "product_category", "risk_level", + "fund_manager", "current_nav", "current_nav_at", "status", "lot_size", "price_tick", + "management_fee_rate", "custodian_fee_rate", "latest_close", "latest_trade_date", + "quote_source", "change_pct", +} + + +async def test_visitor_token_can_read_listed_products() -> None: + app = create_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://test", timeout=30 + ) as client: + issued = await client.post("/api/v1/visitor-tokens") + # 该端点按创建语义返回 201(本平台创建类端点的统一口径) + assert issued.status_code in (200, 201), issued.text + # 访客令牌端点是 `raw` 形状:令牌直接在顶层,没有 data 信封 + token = issued.json()["access_token"] + + response = await client.get( + "/api/v1/products", headers={"Authorization": f"Bearer {token}"} + ) + + assert response.status_code == 200, response.text + data = response.json()["data"] + products = data["products"] + + assert products, "产品列表为空:访客页面会显示不出任何基金" + assert data["count"] == len(products) + + # 只暴露在售产品 + assert all(item["status"] == "上市" for item in products) + + first = products[0] + assert REQUIRED_FIELDS <= set(first), f"缺字段:{REQUIRED_FIELDS - set(first)}" + assert not (FORBIDDEN_FIELDS & set(first)), "公开端点混入了账户/客户字段" + + # change_pct 只能是数字或 None。None 表示行情不足两个交易日、算不出涨跌, + # **不是 0** —— 前端对 None 显示"暂无",对 0 会显示 "+0.00%"(等于说今天平盘)。 + for item in products: + assert item["change_pct"] is None or isinstance(item["change_pct"], (int, float)), ( + f"{item['product_code']} 的 change_pct 类型异常:{item['change_pct']!r}" + ) + + # 价格类字段一律是字符串(与既有接口口径一致) + assert isinstance(first["current_nav"], str) From 0b82053ca2defd7b649f12a9df7ddd00fe6f8fca 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 22:57:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(rbac):=20=E4=BA=A4=E6=98=93=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=9A=84=20data=5Fscope=20=E5=86=99=E6=88=90=E4=BA=86?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E5=90=8D=EF=BC=8C=E5=AF=BC=E8=87=B4=E4=B8=8B?= =?UTF-8?q?=E5=8D=95/=E6=88=90=E4=BA=A4=E9=9D=99=E9=BB=98=20403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tools/seed_test_rbac.py` 的 `PERMISSIONS` 行形状是 `(id, 权限码, resource, action, data_scope)`,而 T 段那四条写成了 `(9061, "trade:order:create", "trade", "order", "create")` —— 「resource, action」多写了一段,于是 action 落进了 `data_scope` 的位置。 ## 后果是静默失效,不是放宽 `IdentityRepository.load_context` 只收集 `data_scope ∈ {self, own_customers, all}` 的权限,其余**整条丢弃**: if row["permission_code"] and row["data_scope"] in rank: 于是客户在库里**明明有**这四个权限,`POST /api/v1/users/me/orders`、 `GET /api/v1/users/me/orders`、`GET /api/v1/users/me/transactions` 等 T 段端点 却全部返回 `403 AGENT_PERMISSION_DENIED`「缺少操作权限」。 而同一批里 `account:read:self`、`holding:read:self` 的 scope 是 `self`,照常 200 —— 现象特别像"只有交易坏了",几乎不会有人去怀疑**权限行本身写错了字段**。 因为 seed 是 `DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099` 的**重建**语义, **每跑一次种子就重现一次**,而 `tools/seed_demo_data.py` 的第 1 步正是它。 ## 修复与守卫 - 四条改为 `"self"`,并就地写清第 5 个字段是 `data_scope`、只能取三种值; - `tools/check_rbac_seed_consistency.py` 增加**第 5 条检查**:每行字段数必须是 5, 且 `data_scope` 取值合法。已用模拟对象确认它能抓住事故写法 (非法 scope 报 2 条、字段数不足报 1 条、合法写法通过), 并由 `tests/unit/tools/test_rbac_seed_consistency.py` 纳入门禁。 修复后实测:库内非法 `data_scope` 归零(`self` 22 → 26); `/users/me/{account/dashboard,holdings,transactions,orders}` 全部 200; 下单成交价 4.579(真实行情);e2e 冒烟 40/40;integration 106 passed。 --- tools/check_rbac_seed_consistency.py | 49 ++++++++++++++++++++++++++-- tools/seed_test_rbac.py | 16 ++++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/tools/check_rbac_seed_consistency.py b/tools/check_rbac_seed_consistency.py index d3092c0..ab16b03 100644 --- a/tools/check_rbac_seed_consistency.py +++ b/tools/check_rbac_seed_consistency.py @@ -18,14 +18,15 @@ DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099 advisor 会静默拿到**语义完全错误**的权限组合(例如 id 9020 从 `asset-allocation:generate:self` 变成 `investment-goal:write:self`),全程不报错。 -本脚本把这条约束变成可自动检查的四条: +本脚本把这条约束变成可自动检查的五条: 1. 种子内 id 不重复; 2. 每个 `grant_*.py` 声明的 `(id, code)` 都能在种子里找到**完全一致**的一条; 3. `CUSTOMER_PERMISSIONS` 引用的 id 都存在; -4. 各 `grant_*.py` 之间不抢同一个 id。 +4. 各 `grant_*.py` 之间不抢同一个 id; +5. 每行 `PERMISSIONS` 的字段数与 `data_scope` 取值合法(见 `_scope_findings`)。 -`tests/unit/tools/test_rbac_seed_consistency.py` 会调用它,所以这四条是纳入门禁的。 +`tests/unit/tools/test_rbac_seed_consistency.py` 会调用它,所以这五条是纳入门禁的。 用法:python tools/check_rbac_seed_consistency.py """ @@ -117,9 +118,51 @@ def collect_findings() -> list[str]: + ", ".join(sorted(missing_risk_codes)) ) + problems.extend(_scope_findings(seed)) + return problems +#: `IdentityRepository.load_context` 只收集这三种 `data_scope` 的权限,其余**整条丢弃**。 +VALID_DATA_SCOPES = frozenset({"self", "own_customers", "all"}) + + +def _scope_findings(seed: ModuleType) -> list[str]: + """第 5 条:每行 `PERMISSIONS` 的字段数与 `data_scope` 取值都必须合法。 + + ## 为什么值得单独守 + + `PERMISSIONS` 的行形状是 `(id, 权限码, resource, action, data_scope)`。 + 2026-09-13 出过一次事故:交易那 4 条写成了 `(9061, "trade:order:create", + "trade", "order", "create")` —— 「resource, action」多写了一段, + 于是 action 落进了 `data_scope`。 + + 它**不报错、也不放宽**,而是**静默失效**:`load_context` 里 + `if row["permission_code"] and row["data_scope"] in rank` 会把整条权限丢掉, + 客户在库里"明明有"这四个权限,下单/委托/成交却全部 `403 AGENT_PERMISSION_DENIED`; + 而同一批里 `account:read:self`、`holding:read:self` 的 scope 是 `self`, + 照常 200 —— 所以现象特别像"只有交易坏了",几乎不会有人去怀疑权限行本身写错。 + + 因为 `seed_test_rbac.py` 是 DELETE 重建语义,这个错误**每跑一次种子就重现一次**。 + """ + findings: list[str] = [] + for index, row in enumerate(seed.PERMISSIONS, 1): + if len(row) != 5: + findings.append( + f"种子 PERMISSIONS 第 {index} 行有 {len(row)} 个字段,应为 5 个" + f"(id, 权限码, resource, action, data_scope):{row!r}" + ) + continue + permission_id, code, scope = int(row[0]), str(row[1]), str(row[4]) + if scope not in VALID_DATA_SCOPES: + findings.append( + f"种子 id {permission_id}({code})的 data_scope={scope!r} 非法:" + f"只能是 {sorted(VALID_DATA_SCOPES)} 之一 —— " + "否则该权限会被 IdentityRepository.load_context 静默丢弃,接口一律 403" + ) + return findings + + def main() -> int: if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index b06a30b..da49891 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -144,11 +144,19 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( # `PERMISSIONS`,同时挂在 `CUSTOMER_PERMISSIONS` 让 customer 角色自带。 # 号段续 9060(避开 9047-9059 qyqy 风险/推广/探针/投资目标号段):与 9041-9046 客服二期间隔 1,避免与既有迁移/种子冲突。 (9060, "account:read:self", "account", "read", "self"), - (9061, "trade:order:create", "trade", "order", "create"), - (9062, "trade:order:read", "trade", "order", "read"), - (9063, "trade:order:cancel", "trade", "order", "cancel"), + # ⚠️ 第 5 个字段是 **data_scope**,只能取 `self` / `own_customers` / `all`。 + # 这四条原先误写成 `trade, order, create` 这样的「resource, action, action」三段, + # 于是 action 落进了 data_scope 位置(值为 'create'/'read'/'cancel')。 + # 后果不是"宽松"而是**静默失效**:`IdentityRepository.load_context` 只收集 + # data_scope 合法的权限,这四条会被整条丢掉 ⇒ 客户在**库里明明有**这四个权限, + # 下单/委托/成交却全部 `403 AGENT_PERMISSION_DENIED`, + # 而 T001/T006(`account:read:self`/`holding:read:self` 是 'self')照常 200, + # 所以现象特别像"只有交易坏了",很难联想到权限码本身写错。 + (9061, "trade:order:create", "trade", "order", "self"), + (9062, "trade:order:read", "trade", "order", "self"), + (9063, "trade:order:cancel", "trade", "order", "self"), (9064, "holding:read:self", "holding", "read", "self"), - (9065, "trade:txn:read", "trade", "txn", "read"), + (9065, "trade:txn:read", "trade", "txn", "self"), ) # 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。