/** * 访客令牌:公开页面(首页 / 产品列表 / 产品详情)用它访问 * 「需要令牌但不需要登录」的接口。 * * ## 为什么单独抽一个模块 * * 这段逻辑原先只写在客服浮窗里。浮窗在客服模块重构中被删过一次(2026-09-16), * 2026-09-19 随客服 Agent 重建恢复,仍然复用本模块 —— 产品页与浮窗合计有九处需要 * 同一份逻辑,复制九份的话,存储 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}` } : {}; }