根因:公开路径判定原先只认「带参数」形态(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)
133 lines
5.5 KiB
TypeScript
133 lines
5.5 KiB
TypeScript
/**
|
||
* 路径式公开领取链接的路径判定与解析。
|
||
*
|
||
* 与 `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
|
||
}
|
||
}
|