feat(portal): 公开产品接口(P001)落地,访客三页改读真实数据

访客首页推荐、产品列表、产品详情此前读的是前端手写的
`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。
This commit is contained in:
2026-09-13 22:57:48 +08:00
parent f6bfcc26b3
commit 9cb0f6e474
19 changed files with 628 additions and 220 deletions
+151
View File
@@ -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