Files
order_site/apps/frontend/src/utils/claim-path.ts
T
yml2213 7e197c9413 fix(frontend): H5 领取前缀整段接管,/h5 裸链接不再跳后台登录
根因:公开路径判定原先只认「带参数」形态(isH5ClaimPath 只认 /h5/<orderNo>),
裸前缀 https://<域名>/h5(后台 claimLinkBase / certLink 的基址)判定不命中,
于是落回主站 HashRouter 被后台路由接管成
https://<域名>/h5/#/admin/dashboard 后台登录页。

- utils/claim-path.ts:isClaimPath / isH5ClaimPath 改为命名空间级判定(含裸前缀与多段路径),
  参数解析仍严格;新增 resolveH5ClaimOrderNoFromInput(纯订单号 / 整条链接 / 缺协议 /
  带后台残留 hash 都能取出订单号,只有前缀时返回空串)
- pages/claim/H5ClaimEntryPage.tsx:新增 /h5 领取入口页,买家输订单号或粘贴完整链接后
  跳 /h5/<订单号>,走与分享链接同一套领取流程
- App.tsx:H5ClaimApplication 接管 /h5 整段前缀(/h5 入口页、/h5/:orderNo 领取页、
  其它子路径回入口页);useClearStrayHash 抹掉历史残留的 #/admin/dashboard
- tests/claim-path.test.ts:补齐裸前缀、相邻前缀 /h5x、粘贴链接解析等回归用例

验证:npm run check 全绿(prettier + eslint + tsc + 143 单测 + vite build)
2026-10-13 13:42:06 +08:00

133 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 路径式公开领取链接的路径判定与解析。
*
* 与 `utils/redeem.ts` 同理:把路径解析抽成纯函数,路由与页面只负责调用。
* 刻意不依赖 React / antd,也不使用 `@/` 别名,
* 方便 `node --experimental-strip-types --test` 直接跑单测。
*
* 判定分两层,两层职责不同,不能混用:
* - **命名空间判定**(`isClaimPath` / `isH5ClaimPath`):`/claim`、`/h5` 整棵前缀都算命中,
* 用在路由分发上,把这些公开前缀**整段**交给领取页。
* 必须是命名空间级:`https://<域名>/h5`(只有前缀、没有订单号)如果判定为"不命中",
* 就会落回主站 HashRouter 被后台路由接管,买家点领取链接看到的是
* `https://<域名>/h5/#/admin/dashboard` 后台登录页(历史缺陷,必须堵死)。
* - **业务参数解析**(`resolveClaimToken` / `resolveH5ClaimOrderNo`):严格要求
* `/claim/<一段 token>`、`/h5/<一段订单号>`;命名空间本身(`/claim`、`/h5`)与多段路径
* 都取不到参数,交由页面展示"链接无效"或让买家手动补订单号。
*
* 注意两条领取入口**互不影响**:
* - 路径式(买家分享链接):`https://<域名>/claim/<token>`,`pathname` 为 `/claim/<token>`;
* - hash 式(主站打手领取页):`https://<域名>/#/claim/<token>`,`pathname` 为 `/`,
* 路由由 HashRouter 解析,不会命中本文件的判定。
*
* 两个入口渲染的是**同一个** `pages/claim/ClaimPage`。
*/
/**
* 取路径式领取链接里的 token。
*
* 只认 `/claim/<一段 token>`:
* - `/claim/abc` 命中,结尾多余斜杠容忍(`/claim/abc/`);
* - `/claim`、`/claim/`、`/claim/a/b`、`/redeem`、`/` 都不命中;
* - 查询串 / hash 片段(如 `/claim/abc?x=1`)会被丢弃后取第一段。
*
* 返回**解码后**的 token;不命中时返回空串。
*/
export function resolveClaimToken(pathname: unknown): string {
const segments = splitPathSegments(pathname)
if (segments.length !== 2 || segments[0] !== 'claim') return ''
return decodeTokenSegment(segments[1])
}
/**
* 是否为路径式领取链接(`/claim` 命名空间)。
*
* 命名空间级判定:`/claim`、`/claim/`、`/claim/<token>`、`/claim/a/b` 全部命中,
* 保证 `/claim` 开头的路径永远由领取页接管,不会漏进后台路由。
*/
export function isClaimPath(pathname: unknown): boolean {
return splitPathSegments(pathname)[0] === 'claim'
}
/**
* 取 H5 订单号领取链接(`/h5/<订单号>`)里的订单号。
*
* 只认 `/h5/<一段 orderNo>`:
* - `/h5/2623001922675146` 命中,结尾多余斜杠容忍(`/h5/2623001922675146/`);
* - `/h5`、`/h5/`、`/h5/a/b` 都不命中(此时页面用入口页让买家补订单号);
* - 查询串 / hash 片段(如 `/h5/123?x=1`)会被丢弃后取第一段。
*/
export function resolveH5ClaimOrderNo(pathname: unknown): string {
const segments = splitPathSegments(pathname)
if (segments.length !== 2 || segments[0] !== 'h5') return ''
return decodeTokenSegment(segments[1])
}
/**
* 是否为 H5 订单号领取链接(`/h5` 命名空间)。
*
* 命名空间级判定:`/h5`、`/h5/`、`/h5/<订单号>`、`/h5/a/b` 全部命中。
* `/h5` 本身也要命中——它是由后台配置的 `claimLinkBase` / `certLink`(`https://<域名>/h5`)
* 直接落到买家手里的形态,必须走领取入口页,绝不允许被后台路由接管。
*/
export function isH5ClaimPath(pathname: unknown): boolean {
return splitPathSegments(pathname)[0] === 'h5'
}
/**
* 从「买家手输 / 粘贴」的内容里取 H5 领取订单号(`/h5` 入口页用)。
*
* - 纯订单号:`2628601934580683` → 原样返回;
* - 粘贴整条链接:`https://ks.khhao.com/h5/2628601934580683` → 取 `2628601934580683`;
* - 缺协议的域名:`ks.khhao.com/h5/123` → 取 `123`;
* - 只有前缀的链接:`https://ks.khhao.com/h5` → 返回空串(没有可领取的订单,
* 由页面提示买家里重新粘贴完整链接)。
*/
export function resolveH5ClaimOrderNoFromInput(value: unknown): string {
const text = String(value ?? '').trim()
if (!text) return ''
// 含 `/`、`?`、`#` 或协议前缀时按链接处理:只认路径里的订单号,
// 避免把整条 URL 当成订单号发去后端(那样必然查不到单)。
const looksLikeLink = /[/?#]/.test(text) || /^[a-z][a-z0-9+.-]*:/i.test(text)
if (!looksLikeLink) return text
return resolveH5ClaimOrderNo(extractPathname(text))
}
/** 归一化 `pathname` → 路径段数组:丢弃查询串 / hash 片段与结尾斜杠,非 `/` 开头视为不命中。 */
function splitPathSegments(pathname: unknown): string[] {
const normalized = String(pathname ?? '').trim()
if (!normalized.startsWith('/')) return []
return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
}
/** 从用户输入里取 pathname(容忍缺协议、非法 URL);解析失败时原样返回,交给上层判定。 */
function extractPathname(text: string): string {
try {
return new URL(text).pathname
} catch {
// 没写协议(`ks.khhao.com/h5/123`):补一个协议再解析
}
try {
return new URL(`http://${text}`).pathname
} catch {
return text
}
}
function decodeTokenSegment(segment: string): string {
try {
return decodeURIComponent(segment)
} catch {
// 非法百分号编码:退回原始片段,交给后端判定有效性
return segment
}
}