Files
Genarrative/src/routing/activeAppPageRoutes.ts
T
suzmii ad0430fcd2 补上收银台深链 /pay/<checkoutToken> 的前缀路由(Nginx 三模板 + Pingora + 门禁)
- 现状:只把 `/pay`、`/profile/payment` 加进 allowlist 只能让 check:nginx-spa-routes 变绿;
  payment.rs 生成的 checkoutUrl 是 `/pay/<checkoutToken>`,深链仍落默认 location 的
  try_files → 404。同一批漂移里 check:pingora-route-parity 也是红的(Pingora MAIN_SPA_PATHS
  缺 /pay、/profile/payment),只是被 lint 链里先失败的门禁掩盖,修一条要跑到链尾
- 真相源:src/routing/activeAppPageRoutes.ts 新增 APP_PREFIX_ROUTE_ENTRIES
  ('/pay' → payment-checkout),resolveSelectionStageFromPath 改用它
- 门禁:scripts/check-nginx-spa-routes.mjs 要求三份模板都有锚定前缀 location
  `location ~* "^/pay/[^/]+/?$"`(裸前缀仍由精确 location 负责;前缀 location 必须镜像精确
  location 的维护闸与 try_files 回退);新增 scripts/check-nginx-spa-routes.test.mjs 正/反用例
  (把前缀写成精确匹配或过宽裸前缀都会红),由 npm run check:nginx-spa-routes 一起执行;
  check-pingora-route-parity 新增 MAIN_SPA_PREFIX_PATHS 与前端前缀路由的逐条比对
- 模板:deploy/nginx/genarrative.conf、deploy/nginx/genarrative-dev-http.conf、
  deploy/container/nginx.conf 各加一条锚定前缀 location
- Pingora:MAIN_SPA_PATHS 补 /pay、/profile/payment;新增 MAIN_SPA_PREFIX_PATHS 与
  is_main_spa_prefix_path(大小写不敏感,只认「前缀 + 恰好一段」),矩阵新增
  pay_checkout_spa_fallback 用例,并给网关补一条前缀正/反单测
- 文档:Pingora 试点文档的路由表与门禁说明、deploy/nginx/README 与本地开发/生产运维文档
  同步前缀路由口径与线上 curl 复验方式
- 本地实跑:node --test scripts/check-nginx-spa-routes.test.mjs(4 passed)、
  node scripts/check-nginx-spa-routes.mjs(OK,14 SPA routes / 1 prefix routes / 3 templates)、
  npm run check:pingora-route-parity(OK,25 routes)、
  cargo test -p pingora-gateway -- pay_checkout_deep_link matches_nginx_route_parity_matrix(2 passed)
2026-10-04 03:25:54 +08:00

179 lines
5.9 KiB
TypeScript

import { HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS } from '../../packages/shared/src/contracts/hostBridge';
import type { SelectionStage } from '../components/platform-entry/platformEntryActiveTypes';
export const EDITOR_CANVAS_PROJECT_ID_QUERY_PARAM = 'projectid';
const STAGE_ROUTE_ENTRIES = [
['platform', '/'],
['creation-home', '/creation'],
['project', '/project'],
['profile', '/profile'],
['profile-payment', '/profile/payment'],
['payment-checkout', '/pay'],
['image-editor', '/editor/canvas'],
['games', '/games'],
['game-detail', '/games/detail'],
['game-play', '/games/play'],
['game-mine', '/games/mine'],
['game-publish', '/games/publish'],
] as const satisfies readonly (readonly [SelectionStage, string])[];
/**
* 带动态段、需要整体回退 index.html 的对外路由前缀 → 归属 stage。
* 例如收银台深链 `/pay/<checkoutToken>`(后端 `payment.rs` 用 `format!("/pay/{}", token)` 生成,
* 前端从最后一个路径段读 token)。Nginx 必须按「前缀 + 恰好一个路径段」的锚定形状放行:
* 只放行裸前缀会让真实收银台链接落到默认 location 变成 404,放行过宽又会把未知路径吞掉。
*/
export const APP_PREFIX_ROUTE_ENTRIES = [
['/pay', 'payment-checkout'],
] as const satisfies readonly (readonly [string, SelectionStage])[];
export const APP_STAGE_ROUTES: Record<SelectionStage, string> =
Object.fromEntries(STAGE_ROUTE_ENTRIES) as Record<SelectionStage, string>;
const ROUTE_STAGE_BY_PATH = new Map(
STAGE_ROUTE_ENTRIES.map(([stage, path]) => [path, stage] as const),
) as Map<string, SelectionStage>;
const APP_RUNTIME_CONTEXT_QUERY_KEYS =
HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS;
export const APP_HISTORY_STATE_KEY = '__genarrativeAppHistoryEntry';
const APP_HISTORY_STATE_DEPTH_KEY = '__genarrativeAppHistoryDepth';
export function normalizeAppPath(pathname: string) {
const trimmedPathname = pathname.trim().toLowerCase();
if (!trimmedPathname || trimmedPathname === '/') {
return '/';
}
return trimmedPathname.replace(/\/+$/u, '');
}
export function resolveSelectionStageFromPath(
pathname: string,
): SelectionStage {
const normalizedPath = normalizeAppPath(pathname);
for (const [prefix, stage] of APP_PREFIX_ROUTE_ENTRIES) {
if (normalizedPath.startsWith(`${prefix}/`)) {
return stage;
}
}
return ROUTE_STAGE_BY_PATH.get(normalizedPath) ?? 'platform';
}
export function resolveInitialSelectionStageFromPath(
pathname: string,
_isDesktopLayout: boolean,
): SelectionStage {
return resolveSelectionStageFromPath(pathname);
}
export function resolvePathForSelectionStage(stage: SelectionStage) {
return APP_STAGE_ROUTES[stage];
}
export function readEditorCanvasProjectIdFromLocationSearch(search: string) {
const params = new URLSearchParams(search);
return params.get(EDITOR_CANVAS_PROJECT_ID_QUERY_PARAM)?.trim() || null;
}
export function shouldRedirectEditorCanvasWithoutProject(
pathname: string,
search: string,
) {
return (
normalizeAppPath(pathname) === APP_STAGE_ROUTES['image-editor'] &&
!readEditorCanvasProjectIdFromLocationSearch(search)
);
}
export function isKnownMainAppPagePath(pathname: string) {
const normalized = normalizeAppPath(pathname);
return ROUTE_STAGE_BY_PATH.has(normalized) || normalized.startsWith('/pay/');
}
export function pushAppHistoryPath(path: string) {
writeAppHistoryPath(path, 'push');
}
export function replaceAppHistoryPath(path: string) {
writeAppHistoryPath(path, 'replace');
}
function writeAppHistoryPath(path: string, mode: 'push' | 'replace') {
const nextUrl = new URL(path, window.location.origin);
const normalizedPath = normalizeAppPath(nextUrl.pathname);
const nextSearch = buildPreservedAppSearch(
nextUrl.search,
window.location.search,
);
const nextRelativeUrl = `${normalizedPath}${nextSearch}`;
const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`;
if (currentRelativeUrl === nextRelativeUrl) {
return;
}
const state = buildAppHistoryState(mode);
if (mode === 'replace') {
window.history.replaceState(state, '', nextRelativeUrl);
return;
}
window.history.pushState(state, '', nextRelativeUrl);
}
export function isAppHistoryState(state: unknown) {
return Boolean(
state &&
typeof state === 'object' &&
(state as Record<string, unknown>)[APP_HISTORY_STATE_KEY] === true,
);
}
/**
* 当前历史条目是不是应用内导航写入的、且存在可回退的上一页。
* 直接打开深链时浏览器条目不是应用写入的,此时返回需要走兜底路径,
* 否则 `history.back()` 会直接退出应用。
*/
export function hasAppHistoryBackEntry() {
const state = window.history.state;
if (!isAppHistoryState(state)) {
return false;
}
return readAppHistoryDepth(state as Record<string, unknown>) > 0;
}
function readAppHistoryDepth(state: Record<string, unknown>) {
const depth = state[APP_HISTORY_STATE_DEPTH_KEY];
return typeof depth === 'number' && Number.isFinite(depth) ? depth : 0;
}
function buildAppHistoryState(mode: 'push' | 'replace') {
const currentState =
window.history.state && typeof window.history.state === 'object'
? window.history.state
: {};
const currentDepth = readAppHistoryDepth(
currentState as Record<string, unknown>,
);
return {
...currentState,
[APP_HISTORY_STATE_KEY]: true,
[APP_HISTORY_STATE_DEPTH_KEY]:
mode === 'push' ? currentDepth + 1 : currentDepth,
};
}
function buildPreservedAppSearch(
explicitSearch: string,
currentSearch: string,
) {
const params = new URLSearchParams(explicitSearch);
const currentParams = new URLSearchParams(currentSearch);
APP_RUNTIME_CONTEXT_QUERY_KEYS.forEach((key) => {
if (!params.has(key) && currentParams.has(key)) {
params.set(key, currentParams.get(key) ?? '');
}
});
const serialized = params.toString();
return serialized ? `?${serialized}` : '';
}