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:
@@ -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
|
||||
Reference in New Issue
Block a user