访客首页推荐、产品列表、产品详情此前读的是前端手写的 `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。
66 lines
2.6 KiB
JavaScript
66 lines
2.6 KiB
JavaScript
/**
|
|
* 访客令牌:公开页面(首页 / 产品列表 / 产品详情 / 客服浮窗)用它访问
|
|
* 「需要令牌但不需要登录」的接口。
|
|
*
|
|
* ## 为什么单独抽一个模块
|
|
*
|
|
* 这段逻辑原先只写在客服浮窗里(`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}` } : {};
|
|
}
|