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,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)
|
||||
Reference in New Issue
Block a user