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)