Compare commits
3
Commits
5a73fb74a9
...
e8fbb28cf4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8fbb28cf4 | ||
|
|
319e082254 | ||
|
|
7e197c9413 |
@@ -3,10 +3,25 @@ import test from 'node:test'
|
||||
|
||||
import {
|
||||
CLAIM_TERMINAL_STATUSES,
|
||||
MANUAL_FULFILLMENT_MESSAGE,
|
||||
resolveClaimProductImageUrl,
|
||||
resolveManualFulfillmentMessage,
|
||||
} from './kuaishou-cloud-claim-context.js'
|
||||
import { TASK_STATUS } from '../../domain/task-status.js'
|
||||
|
||||
test('人工履约提示:manual_dispatch 给等待客服话术,自动履约与已下线渠道给空串', () => {
|
||||
// 人工单(未命中自动发货映射 → 回落 manual_dispatch):领取页不许再让买家输 UID
|
||||
assert.equal(resolveManualFulfillmentMessage('manual_dispatch'), MANUAL_FULFILLMENT_MESSAGE)
|
||||
assert.equal(resolveManualFulfillmentMessage(' manual_dispatch '), MANUAL_FULFILLMENT_MESSAGE)
|
||||
assert.equal(MANUAL_FULFILLMENT_MESSAGE, '人工处理中,请等待客服')
|
||||
|
||||
// 自动发货 / 已下线渠道 / 空值都不算人工履约
|
||||
assert.equal(resolveManualFulfillmentMessage('affiliate_dash'), '')
|
||||
assert.equal(resolveManualFulfillmentMessage('kuaishou_feifei'), '')
|
||||
assert.equal(resolveManualFulfillmentMessage(''), '')
|
||||
assert.equal(resolveManualFulfillmentMessage(undefined), '')
|
||||
})
|
||||
|
||||
test('CLAIM_TERMINAL_STATUSES 包含 closed 与 expired 状态', () => {
|
||||
assert.equal(CLAIM_TERMINAL_STATUSES.has(TASK_STATUS.CLOSED), true)
|
||||
assert.equal(CLAIM_TERMINAL_STATUSES.has(TASK_STATUS.EXPIRED), true)
|
||||
|
||||
@@ -15,6 +15,7 @@ import { buildClaimUrl } from './claim-service.js'
|
||||
import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js'
|
||||
import { buildClaimIdentityPayload } from './claim-identity.js'
|
||||
import { normalizeAffiliateDashFlow } from '../fulfillment/affiliate-dash/index.js'
|
||||
import { isManualDispatchExecutor } from '../fulfillment/executors/types.js'
|
||||
import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
|
||||
@@ -25,6 +26,20 @@ const RETIRED_CHANNEL_MESSAGE: Record<string, string> = {
|
||||
kuaishou_ct_assisted: '乐玩履约渠道已下线,本链接仅可查看历史信息,无法继续领取',
|
||||
}
|
||||
|
||||
/** 领取页给人工履约单买家看的话术(人工派单 / 未命中自动发货映射)。 */
|
||||
export const MANUAL_FULFILLMENT_MESSAGE = '人工处理中,请等待客服'
|
||||
|
||||
/**
|
||||
* 人工履约提示话术:`manual_dispatch` 返回文案,其余执行器返回空串。
|
||||
*
|
||||
* 为什么要在领取详情里显式区分:人工单没有自动发货通道(没有 claim token、也不会有
|
||||
* affiliate_dash 绑定二维码),买家在领取页输 UID 既无用又误导。前端据此隐藏「输 UID」步骤、
|
||||
* 只显示等待客服的说明;话术只在此处维护一份,前端只做兜底。
|
||||
*/
|
||||
export function resolveManualFulfillmentMessage(executorKey: unknown): string {
|
||||
return isManualDispatchExecutor(executorKey) ? MANUAL_FULFILLMENT_MESSAGE : ''
|
||||
}
|
||||
|
||||
export type ClaimContext = {
|
||||
claimToken: ClaimTokenRow
|
||||
task: TaskRow
|
||||
@@ -290,6 +305,8 @@ function buildAffiliateDashClaimDetailPayload({
|
||||
flowType: 'affiliate_dash' as const,
|
||||
channelOffline: false,
|
||||
channelOfflineMessage: '',
|
||||
manualFulfillment: false,
|
||||
manualFulfillmentMessage: '',
|
||||
claimIdentity,
|
||||
task: buildClaimTaskPayload(task, claimToken),
|
||||
order: buildClaimOrderPayload(order),
|
||||
@@ -372,6 +389,8 @@ function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }:
|
||||
: ('kuaishou_cloud' as const),
|
||||
channelOffline: true,
|
||||
channelOfflineMessage: offlineMessage,
|
||||
manualFulfillment: false,
|
||||
manualFulfillmentMessage: '',
|
||||
claimIdentity,
|
||||
task: buildClaimTaskPayload(task, claimToken),
|
||||
order: buildClaimOrderPayload(order),
|
||||
@@ -413,6 +432,7 @@ function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }:
|
||||
const context = parseTaskContext(task)
|
||||
const claimIdentity = buildClaimIdentityPayload(context.claimIdentity)
|
||||
const productTitle = String(orderItem.sku_name || orderItem.sku_code || '').trim()
|
||||
const manualFulfillmentMessage = resolveManualFulfillmentMessage(task.executor_key)
|
||||
|
||||
return {
|
||||
tokenStatus: claimToken.status,
|
||||
@@ -420,6 +440,10 @@ function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }:
|
||||
flowType: String(task.executor_key || 'manual_dispatch').trim() || 'manual_dispatch',
|
||||
channelOffline: false,
|
||||
channelOfflineMessage: '',
|
||||
// 人工履约单:买家在本页**无事可做**(没有自动发货通道可用),页面只展示等待话术,
|
||||
// 不许再让买家输 UID ——否则会误导买家以为提交后就会自动发货。
|
||||
manualFulfillment: Boolean(manualFulfillmentMessage),
|
||||
manualFulfillmentMessage,
|
||||
claimIdentity,
|
||||
task: buildClaimTaskPayload(task, claimToken),
|
||||
order: buildClaimOrderPayload(order),
|
||||
|
||||
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildDouyinMashidaBuyerClaimUrl,
|
||||
buildDouyinMashidaClaimMessage,
|
||||
isDouyinMashidaH5ClaimBase,
|
||||
renderDouyinMashidaMessageTemplate,
|
||||
resolveDouyinMashidaClaimUrl,
|
||||
} from './claim-link.js'
|
||||
@@ -102,3 +104,86 @@ test('发消息正文:无领取链接则不发(返回空串),默认模
|
||||
'幸运币2个 已到账,订单 6930:https://x/claim/1',
|
||||
)
|
||||
})
|
||||
|
||||
test('领取链接基址形态:/h5 与 /h5/xxx 判为 H5 订单号形态,其它不是', () => {
|
||||
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5'), true)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5/'), true)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5/anything'), true)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase(' https://ks.khhao.com/claim '), false)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5x'), false)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase(''), false)
|
||||
assert.equal(isDouyinMashidaH5ClaimBase(undefined), false)
|
||||
})
|
||||
|
||||
test('买家领取消息链接:没有自动发货领取单元(人工单)一律返回空串 = 不发消息', () => {
|
||||
// 线上实测的缺陷场景:未命中映射的「套装-牛仔情诗」单回落人工,却收到了「已发货」领取链接
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: 'https://ks.khhao.com/h5',
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: false,
|
||||
}),
|
||||
'',
|
||||
)
|
||||
|
||||
// token 形态基址同样不发:人工单本来就没有 token,不能拿订单号去凑
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: false,
|
||||
tokenClaimUrl: '',
|
||||
}),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
test('买家领取消息链接:有自动发货领取单元时按基址形态拼接', () => {
|
||||
// /h5 形态:拼订单号(结尾斜杠、结尾多斜杠都容忍)
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: 'https://ks.khhao.com/h5',
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: true,
|
||||
}),
|
||||
'https://ks.khhao.com/h5/6918490107300556291',
|
||||
)
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: ' https://ks.khhao.com/h5/// ',
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: true,
|
||||
}),
|
||||
'https://ks.khhao.com/h5/6918490107300556291',
|
||||
)
|
||||
|
||||
// token 形态:只用 resolveOrderItemClaimLink 解析出的链接
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: true,
|
||||
tokenClaimUrl: 'https://ks.khhao.com/claim/AbC123',
|
||||
}),
|
||||
'https://ks.khhao.com/claim/AbC123',
|
||||
)
|
||||
})
|
||||
|
||||
test('买家领取消息链接:基址或订单号缺失时返回空串(不发半条消息)', () => {
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: '',
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: true,
|
||||
}),
|
||||
'',
|
||||
)
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: 'https://ks.khhao.com/h5',
|
||||
orderNo: '',
|
||||
hasAutoClaimUnit: true,
|
||||
}),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -28,6 +28,66 @@ export function resolveDouyinMashidaClaimUrl(
|
||||
return buildConfiguredClaimUrl(config.claimLinkBase, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取链接基址是否是**路径式 H5 订单号**形态(`https://<域名>/h5`、`.../h5/xxx`)。
|
||||
*
|
||||
* 这种基址拼的是**订单号**(`/h5/<订单号>`),不是 claim token;两种形态的拼接规则不同,
|
||||
* 判断只此一处,避免各调用方各写一份。
|
||||
*/
|
||||
export function isDouyinMashidaH5ClaimBase(claimLinkBase: unknown): boolean {
|
||||
const base = normalizeClaimLinkBase(claimLinkBase)
|
||||
return Boolean(base) && (base.endsWith('/h5') || base.includes('/h5/'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 买家消息里真正要发出去的领取链接(**空串 = 这条消息不许发**)。
|
||||
*
|
||||
* 硬约束:只有该订单项**确实有自动发货领取单元**(`affiliate_dash` 执行器在建单时
|
||||
* 创建的 claim token,见 `fulfillment/executors/affiliate-dash-executor.ts`)才发链接。
|
||||
* 领取 token 是「这笔单会自动发货」的唯一证据:
|
||||
* - 未命中 `AFFILIATE_DASH_SKU_MAPPING` → 显式回落人工 `manual_dispatch`,没有 token;
|
||||
* - 自动准备失败 → 任务转人工复核,同样没有 token。
|
||||
* 这两类订单发给买家「您的订单已发货,请点击链接领取」是**假发货**,买家点进去也无货可领。
|
||||
*
|
||||
* 历史缺陷:`/h5` 基址原先走了一条捷径——只拼订单号、跳过履约任务校验,
|
||||
* 于是人工单也照样收到领取消息(线上实测:未命中映射的「套装-牛仔情诗」单被发了链接)。
|
||||
*
|
||||
* 拼接规则按基址形态分流:
|
||||
* - `/h5` 形态 → `claimLinkBase` + `/` + 订单号;
|
||||
* - 其它形态 → `resolveOrderItemClaimLink` 解析出的 token 链接。
|
||||
*/
|
||||
export function buildDouyinMashidaBuyerClaimUrl(input: {
|
||||
claimLinkBase: unknown
|
||||
/** 平台订单号(`/h5` 形态用) */
|
||||
orderNo: unknown
|
||||
/** 该订单项是否有自动发货领取单元(`resolveOrderItemClaimLink().taskId` 非空即为有) */
|
||||
hasAutoClaimUnit: boolean
|
||||
/** token 形态链接(`resolveOrderItemClaimLink().claimUrl`) */
|
||||
tokenClaimUrl?: unknown
|
||||
}): string {
|
||||
if (!input.hasAutoClaimUnit) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const base = normalizeClaimLinkBase(input.claimLinkBase)
|
||||
if (!base) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (isDouyinMashidaH5ClaimBase(base)) {
|
||||
return buildConfiguredClaimUrl(base, String(input.orderNo ?? '').trim())
|
||||
}
|
||||
|
||||
return String(input.tokenClaimUrl ?? '').trim()
|
||||
}
|
||||
|
||||
/** 基址归一化:去首尾空白与结尾斜杠。 */
|
||||
function normalizeClaimLinkBase(claimLinkBase: unknown): string {
|
||||
return String(claimLinkBase ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板占位符替换:支持 `{link}` / `{sku}` / `{orderNo}` / `{productTitle}`。
|
||||
* 未提供的占位符(或空值)原样保留,方便运营一眼看出漏配,而不是发出一条缺链接的消息。
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
resolveDouyinMashidaParentOrderId,
|
||||
} from './order-mapper.js'
|
||||
import { resolveOrderItemClaimLink } from '../../claim/claim-service.js'
|
||||
import { buildDouyinMashidaClaimMessage } from './claim-link.js'
|
||||
import { buildDouyinMashidaBuyerClaimUrl, buildDouyinMashidaClaimMessage } from './claim-link.js'
|
||||
import {
|
||||
getDouyinMashidaSourceConfig,
|
||||
type DouyinMashidaSourceConfig,
|
||||
@@ -235,8 +235,13 @@ async function handlePaidMessage(input: {
|
||||
* 买家点链接 → 输 UID → 出二维码 → 扫码绑定 → 系统比对「绑定 UID == 填的 UID」→ 发货完成
|
||||
* (完全复用现成的 affiliate_dash 通道)。
|
||||
*
|
||||
* 只在「该订单项确实有履约任务 claim token + claimLinkBase 已配置 + 开关打开」时才发;
|
||||
* 拿不到链接的订单(例如未命中映射、走人工的订单)静默跳过,不打扰现有兑换流程。
|
||||
* 只在「该订单项确实有自动发货领取单元(claim token)+ claimLinkBase 已配置 + 开关打开」时才发。
|
||||
* 领取 token 由 `affiliate_dash` 执行器在建单时创建,是「这笔单会自动发货」的唯一证据:
|
||||
* 未命中映射回落人工 `manual_dispatch`、或自动准备失败的订单都拿不到 token,
|
||||
* 这类订单发给买家「已发货」属于假发货,一律**静默跳过**。
|
||||
*
|
||||
* ⚠️ 这条约束对 `/h5` 形态的基址同样成立(历史缺陷:`/h5` 曾走捷径跳过校验,
|
||||
* 结果人工单也收到了领取消息)。
|
||||
*/
|
||||
async function notifyBuyerClaimLink(input: {
|
||||
order: OrderRow
|
||||
@@ -254,22 +259,30 @@ async function notifyBuyerClaimLink(input: {
|
||||
}
|
||||
|
||||
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
|
||||
let claimUrl = ''
|
||||
let taskId: number | undefined
|
||||
|
||||
if (claimLinkBase.endsWith('/h5') || claimLinkBase.includes('/h5/')) {
|
||||
claimUrl = `${claimLinkBase}/${orderNo}`
|
||||
} else {
|
||||
const claimLink = await resolveOrderItemClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
claimLinkBase: input.config.claimLinkBase,
|
||||
})
|
||||
claimUrl = claimLink.claimUrl
|
||||
taskId = claimLink.taskId
|
||||
}
|
||||
// 先核对履约任务:`resolveOrderItemClaimLink` 只有在该订单项存在带 token 的领取单元时才给 taskId。
|
||||
const claimLink = await resolveOrderItemClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
claimLinkBase,
|
||||
})
|
||||
|
||||
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase,
|
||||
orderNo,
|
||||
hasAutoClaimUnit: Boolean(claimLink.taskId),
|
||||
tokenClaimUrl: claimLink.claimUrl,
|
||||
})
|
||||
|
||||
if (!claimUrl) {
|
||||
logIntegration('[douyin-mashida/notify]', '该订单项没有自动发货领取单元,跳过领取链接消息', {
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
orderNo,
|
||||
claimLinkBase,
|
||||
taskCount: claimLink.taskCount,
|
||||
hasClaimUnit: Boolean(claimLink.taskId),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -290,7 +303,8 @@ async function notifyBuyerClaimLink(input: {
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
orderNo,
|
||||
taskId,
|
||||
taskId: claimLink.taskId,
|
||||
claimUrl,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage'))
|
||||
const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage'))
|
||||
const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage'))
|
||||
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
|
||||
const H5ClaimEntryPage = lazy(() => import('@/pages/claim/H5ClaimEntryPage'))
|
||||
const CollectPage = lazy(() => import('@/pages/collect/CollectPage'))
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
|
||||
@@ -103,7 +104,10 @@ export default function App() {
|
||||
return <ClaimApplication />
|
||||
}
|
||||
|
||||
// 路径式 H5 订单号领取链接(`https://<域名>/h5/<订单号>`,不带 `#`)
|
||||
// H5 领取入口(`https://<域名>/h5` 及其子路径,不带 `#`)。
|
||||
// 判定是**命名空间级**:`/h5` 本身(只有前缀、没有订单号)也算命中。
|
||||
// 否则 `https://<域名>/h5` 会落回主站 HashRouter,被后台路由接管成
|
||||
// `https://<域名>/h5/#/admin/dashboard` 后台登录页(买家点领取链接看到后台登录,历史缺陷)。
|
||||
if (isH5ClaimPath(window.location.pathname)) {
|
||||
return <H5ClaimApplication />
|
||||
}
|
||||
@@ -186,10 +190,13 @@ function RedeemApplication() {
|
||||
* 复用主站 HashRouter 里的**同一个** `ClaimPage`:它只依赖 `useParams` 取 token,
|
||||
* 内部不 navigate、也不读 hash,所以换成 BrowserRouter 承载不需要复制页面。
|
||||
*
|
||||
* 只在 `isClaimPath` 命中时才会挂载,所以这里的兜底路由正常走不到;
|
||||
* 真走到也复用同一个页面(token 为空时页面自己展示「链接无效」),不会出现路由空屏。
|
||||
* `isClaimPath` 是命名空间判定(`/claim` 及子路径都命中),所以必须保留兜底路由:
|
||||
* 只有前缀(`/claim`)或多段路径(`/claim/a/b`)时 `ClaimPage` 取不到 token,
|
||||
* 由页面自己展示「链接无效」,既不会路由空屏,也不会漏回后台路由。
|
||||
*/
|
||||
function ClaimApplication() {
|
||||
useClearStrayHash()
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
@@ -202,19 +209,46 @@ function ClaimApplication() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 领取入口(`/h5` 命名空间)的独立路由树。
|
||||
*
|
||||
* - `/h5`:只有前缀、没有订单号 → 入口页(买家补订单号 / 粘贴完整链接);
|
||||
* - `/h5/<订单号>`:与分享链接完全相同的领取流程;
|
||||
* - 其余子路径(如 `/h5/a/b`):没有订单号可领,回入口页重新输入,
|
||||
* 避免"前缀下面任何一条路径"漏回后台路由。
|
||||
*
|
||||
* 全程路径式(不带 `#`):后台 HashRouter 永远拿不到 `/h5` 这几个路径。
|
||||
*/
|
||||
function H5ClaimApplication() {
|
||||
useClearStrayHash()
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/h5" element={<H5ClaimEntryPage />} />
|
||||
<Route path="/h5/:orderNo" element={<ClaimPage />} />
|
||||
<Route path="*" element={<ClaimPage />} />
|
||||
<Route path="*" element={<Navigate to="/h5" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开领取页是**路径式**的、从不使用 hash 路由,但历史链接可能带着后台残留的 hash
|
||||
* (例如 `https://<域名>/h5#/admin/dashboard`)。
|
||||
* 挂载时把 hash 抹掉,避免买家在地址栏看到、并转发一条"看着像后台地址"的领取链接。
|
||||
*/
|
||||
function useClearStrayHash() {
|
||||
useEffect(() => {
|
||||
if (!window.location.hash) return
|
||||
|
||||
const { pathname, search } = window.location
|
||||
window.history.replaceState(window.history.state, '', `${pathname}${search}`)
|
||||
}, [])
|
||||
}
|
||||
|
||||
function WorkerApplication() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -301,6 +302,25 @@ export default function ClaimPage() {
|
||||
<ExclamationCircleOutlined className="claim-large-icon claim-icon-error" />
|
||||
<p>{snapshot.channelOfflineMessage || '该履约渠道已下线,无法继续领取'}</p>
|
||||
</Card>
|
||||
) : snapshot.isManualFulfillment ? (
|
||||
// 人工履约单(未命中自动发货映射 → manual_dispatch):没有自动发货通道,
|
||||
// 买家输 UID 也没人自动发货,这里只如实告知「等客服」,不给任何操作入口。
|
||||
<Card className="claim-content-card claim-state-card">
|
||||
<ClockCircleOutlined className="claim-large-icon claim-icon-info" />
|
||||
<h3 style={{ marginTop: 16 }}>
|
||||
{snapshot.manualFulfillmentMessage || '人工处理中,请等待客服'}
|
||||
</h3>
|
||||
<p style={{ color: '#64748b' }}>
|
||||
该订单由人工客服处理,本页无需任何操作;处理完成后刷新本页即可查看结果。
|
||||
</p>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => void loadDetail()}
|
||||
>
|
||||
刷新状态
|
||||
</Button>
|
||||
</Card>
|
||||
) : snapshot.currentStep === 1 ? (
|
||||
<ClaimUidStep
|
||||
uidInput={uidInput}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Form, Input, Typography } from 'antd'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { showError } from '@/lib/feedback'
|
||||
import { resolveH5ClaimOrderNoFromInput } from '@/utils/claim-path'
|
||||
|
||||
/**
|
||||
* H5 领取入口页(`https://<域名>/h5`,不带订单号)。
|
||||
*
|
||||
* 这条前缀**只允许用来领取**:买家输入订单号后跳到 `/h5/<订单号>`,走与分享链接完全相同的领取流程。
|
||||
*
|
||||
* 为什么必须有这个页面:后台配置的 `claimLinkBase` / `certLink` 都是 `https://<域名>/h5`,
|
||||
* 平台/客服可能直接把基址给买家。历史上这条路径没有命中任何领取判定,于是落回主站 HashRouter
|
||||
* 被后台路由接管,买家点开看到的是 `https://<域名>/h5/#/admin/dashboard` 后台登录页。
|
||||
* 现在 `/h5` 整段前缀由领取应用接管,这里给出「补订单号」的入口,把链接救回来。
|
||||
*/
|
||||
export default function H5ClaimEntryPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
function handleSubmit(values: { orderNo?: string }) {
|
||||
const orderNo = resolveH5ClaimOrderNoFromInput(values.orderNo)
|
||||
if (!orderNo) {
|
||||
showError('没有识别到订单号,请粘贴完整领取链接或直接输入订单号')
|
||||
return
|
||||
}
|
||||
|
||||
void navigate(`/h5/${encodeURIComponent(orderNo)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="claim-page h5-claim-entry">
|
||||
<Card className="claim-content-card">
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
订单领取
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
|
||||
输入订单号即可进入领取流程;也可以直接粘贴完整的领取链接(形如 https://…/h5/订单号)。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, message: '请输入订单号' }]}
|
||||
>
|
||||
<Input size="large" placeholder="订单号 / 领取链接" autoComplete="off" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" size="large" block htmlType="submit" icon={<SearchOutlined />}>
|
||||
开始领取
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,9 @@ export function createClaimSnapshot(detail: ClaimDetailData | null) {
|
||||
const hasExpectedUid = Boolean(expectedUid || claimIdentity?.ready)
|
||||
const channelOffline = Boolean(detail?.channelOffline)
|
||||
const channelOfflineMessage = String(detail?.channelOfflineMessage || '').trim()
|
||||
// 人工履约单(manual_dispatch):买家无需操作,页面只显示等待客服话术
|
||||
const isManualFulfillment = Boolean(detail?.manualFulfillment)
|
||||
const manualFulfillmentMessage = String(detail?.manualFulfillmentMessage || '').trim()
|
||||
const isAffiliateDashFlow = detail?.flowType === 'affiliate_dash'
|
||||
const affiliateDashExpectedUid = String(
|
||||
expectedUid || affiliateDash?.expectedGameAccount || affiliateDash?.gameAccount || '',
|
||||
@@ -67,6 +70,8 @@ export function createClaimSnapshot(detail: ClaimDetailData | null) {
|
||||
const progressText = resolveProgressText({
|
||||
channelOffline,
|
||||
channelOfflineMessage,
|
||||
isManualFulfillment,
|
||||
manualFulfillmentMessage,
|
||||
affiliateDash,
|
||||
isAffiliateDashFlow,
|
||||
hasExpectedUid,
|
||||
@@ -102,6 +107,8 @@ export function createClaimSnapshot(detail: ClaimDetailData | null) {
|
||||
hasExpectedUid,
|
||||
channelOffline,
|
||||
channelOfflineMessage,
|
||||
isManualFulfillment,
|
||||
manualFulfillmentMessage,
|
||||
roleName: '',
|
||||
roleId: '',
|
||||
isFeifeiFlow: false,
|
||||
@@ -165,6 +172,8 @@ function resolveCurrentStep(options: {
|
||||
function resolveProgressText(options: {
|
||||
channelOffline: boolean
|
||||
channelOfflineMessage: string
|
||||
isManualFulfillment: boolean
|
||||
manualFulfillmentMessage: string
|
||||
affiliateDash: ClaimAffiliateDashFlowInfo | null
|
||||
isAffiliateDashFlow: boolean
|
||||
hasExpectedUid: boolean
|
||||
@@ -175,6 +184,10 @@ function resolveProgressText(options: {
|
||||
if (options.channelOffline) {
|
||||
return options.channelOfflineMessage || '该履约渠道已下线'
|
||||
}
|
||||
// 人工履约单:买家不需要填任何信息,直接显示等待客服
|
||||
if (options.isManualFulfillment) {
|
||||
return options.manualFulfillmentMessage || '人工处理中,请等待客服'
|
||||
}
|
||||
if (!options.hasExpectedUid) {
|
||||
return '请填写游戏编号'
|
||||
}
|
||||
|
||||
@@ -499,6 +499,13 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ===== H5 领取入口页(`/h5`,只有前缀没有订单号) ===== */
|
||||
|
||||
/* 复用领取页的卡片与背景,只把单卡在视口内垂直居中 */
|
||||
.h5-claim-entry {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===== 买家页面响应式断点 ===== */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -203,6 +203,13 @@ export interface ClaimDetailData {
|
||||
| (string & {})
|
||||
channelOffline?: boolean
|
||||
channelOfflineMessage?: string
|
||||
/**
|
||||
* 人工履约单(未命中自动发货映射 → `manual_dispatch`):买家在领取页**无需也不能操作**,
|
||||
* 页面只展示 `manualFulfillmentMessage`,不再出现「输 UID」步骤。
|
||||
*/
|
||||
manualFulfillment?: boolean
|
||||
/** 人工履约等待话术(如「人工处理中,请等待客服」);非人工单为空串 */
|
||||
manualFulfillmentMessage?: string
|
||||
claimIdentity: ClaimIdentityInfo | null
|
||||
task: ClaimTaskInfo
|
||||
order: ClaimOrderInfo
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
/**
|
||||
* 路径式领取链接(`https://<域名>/claim/<token>`,不带 `#`)的路径判定。
|
||||
* 路径式公开领取链接的路径判定与解析。
|
||||
*
|
||||
* 与 `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` 为 `/`,
|
||||
@@ -24,19 +34,21 @@
|
||||
* 返回**解码后**的 token;不命中时返回空串。
|
||||
*/
|
||||
export function resolveClaimToken(pathname: unknown): string {
|
||||
const normalized = String(pathname ?? '').trim()
|
||||
if (!normalized.startsWith('/')) return ''
|
||||
|
||||
const segments = normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
|
||||
const segments = splitPathSegments(pathname)
|
||||
|
||||
if (segments.length !== 2 || segments[0] !== 'claim') return ''
|
||||
|
||||
return decodeTokenSegment(segments[1])
|
||||
}
|
||||
|
||||
/** 是否为路径式领取链接(`/claim/<token>`) */
|
||||
/**
|
||||
* 是否为路径式领取链接(`/claim` 命名空间)。
|
||||
*
|
||||
* 命名空间级判定:`/claim`、`/claim/`、`/claim/<token>`、`/claim/a/b` 全部命中,
|
||||
* 保证 `/claim` 开头的路径永远由领取页接管,不会漏进后台路由。
|
||||
*/
|
||||
export function isClaimPath(pathname: unknown): boolean {
|
||||
return resolveClaimToken(pathname) !== ''
|
||||
return splitPathSegments(pathname)[0] === 'claim'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,23 +56,70 @@ export function isClaimPath(pathname: unknown): boolean {
|
||||
*
|
||||
* 只认 `/h5/<一段 orderNo>`:
|
||||
* - `/h5/2623001922675146` 命中,结尾多余斜杠容忍(`/h5/2623001922675146/`);
|
||||
* - `/h5`、`/h5/`、`/h5/a/b` 都不命中;
|
||||
* - `/h5`、`/h5/`、`/h5/a/b` 都不命中(此时页面用入口页让买家补订单号);
|
||||
* - 查询串 / hash 片段(如 `/h5/123?x=1`)会被丢弃后取第一段。
|
||||
*/
|
||||
export function resolveH5ClaimOrderNo(pathname: unknown): string {
|
||||
const normalized = String(pathname ?? '').trim()
|
||||
if (!normalized.startsWith('/')) return ''
|
||||
|
||||
const segments = normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
|
||||
const segments = splitPathSegments(pathname)
|
||||
|
||||
if (segments.length !== 2 || segments[0] !== 'h5') return ''
|
||||
|
||||
return decodeTokenSegment(segments[1])
|
||||
}
|
||||
|
||||
/** 是否为 H5 订单号领取链接(`/h5/<orderNo>`) */
|
||||
/**
|
||||
* 是否为 H5 订单号领取链接(`/h5` 命名空间)。
|
||||
*
|
||||
* 命名空间级判定:`/h5`、`/h5/`、`/h5/<订单号>`、`/h5/a/b` 全部命中。
|
||||
* `/h5` 本身也要命中——它是由后台配置的 `claimLinkBase` / `certLink`(`https://<域名>/h5`)
|
||||
* 直接落到买家手里的形态,必须走领取入口页,绝不允许被后台路由接管。
|
||||
*/
|
||||
export function isH5ClaimPath(pathname: unknown): boolean {
|
||||
return resolveH5ClaimOrderNo(pathname) !== ''
|
||||
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 {
|
||||
|
||||
@@ -6,20 +6,24 @@ import {
|
||||
isH5ClaimPath,
|
||||
resolveClaimToken,
|
||||
resolveH5ClaimOrderNo,
|
||||
resolveH5ClaimOrderNoFromInput,
|
||||
} from '../src/utils/claim-path.ts'
|
||||
|
||||
test('isClaimPath:只认路径式 /claim/<token>,容忍结尾斜杠', () => {
|
||||
test('isClaimPath:/claim 命名空间整段命中(含只有前缀、多段路径)', () => {
|
||||
assert.equal(isClaimPath('/claim/abc123'), true)
|
||||
assert.equal(isClaimPath('/claim/abc123/'), true)
|
||||
assert.equal(isClaimPath('/claim/abc123//'), true)
|
||||
// 只有前缀 / 没有 token 也要命中:否则会被主站 HashRouter 接管跳到后台登录
|
||||
assert.equal(isClaimPath('/claim'), true)
|
||||
assert.equal(isClaimPath('/claim/'), true)
|
||||
assert.equal(isClaimPath('/claim/a/b'), true)
|
||||
})
|
||||
|
||||
test('isClaimPath:/claim、/claim/、多段路径、其它路径都不算', () => {
|
||||
assert.equal(isClaimPath('/claim'), false)
|
||||
assert.equal(isClaimPath('/claim/'), false)
|
||||
assert.equal(isClaimPath('/claim/a/b'), false)
|
||||
test('isClaimPath:其它路径、非路径开头的串都不算', () => {
|
||||
assert.equal(isClaimPath('/redeem'), false)
|
||||
assert.equal(isClaimPath('/h5/123'), false)
|
||||
assert.equal(isClaimPath('/admin/claim/abc'), false)
|
||||
assert.equal(isClaimPath('/claimx/abc'), false)
|
||||
assert.equal(isClaimPath('/'), false)
|
||||
assert.equal(isClaimPath(''), false)
|
||||
assert.equal(isClaimPath(null), false)
|
||||
@@ -41,21 +45,74 @@ test('resolveClaimToken:取出并解码 token 片段', () => {
|
||||
assert.equal(resolveClaimToken('/claim/%E4%B8%AD%E6%96%87'), '中文')
|
||||
// 非法百分号编码退回原始片段,交给后端判定
|
||||
assert.equal(resolveClaimToken('/claim/100%'), '100%')
|
||||
// 只有前缀 / 多段路径取不到 token,由页面展示「链接无效」
|
||||
assert.equal(resolveClaimToken('/claim'), '')
|
||||
assert.equal(resolveClaimToken('/claim/a/b'), '')
|
||||
assert.equal(resolveClaimToken('/redeem'), '')
|
||||
})
|
||||
|
||||
test('isH5ClaimPath:只认路径式 /h5/<orderNo>,容忍结尾斜杠', () => {
|
||||
test('isH5ClaimPath:/h5 命名空间整段命中(含只有前缀、多段路径)', () => {
|
||||
assert.equal(isH5ClaimPath('/h5/2623001922675146'), true)
|
||||
assert.equal(isH5ClaimPath('/h5/2623001922675146/'), true)
|
||||
assert.equal(isH5ClaimPath('/h5'), false)
|
||||
assert.equal(isH5ClaimPath('/h5/'), false)
|
||||
assert.equal(isH5ClaimPath('/h5/a/b'), false)
|
||||
assert.equal(isH5ClaimPath('/claim/123'), false)
|
||||
// 关键回归:`https://<域名>/h5`(后台配置的链接基址)必须命领取入口,不能落回后台路由
|
||||
assert.equal(isH5ClaimPath('/h5'), true)
|
||||
assert.equal(isH5ClaimPath('/h5/'), true)
|
||||
assert.equal(isH5ClaimPath('/h5/a/b'), true)
|
||||
assert.equal(isH5ClaimPath('/h5#/admin/dashboard'), true)
|
||||
})
|
||||
|
||||
test('resolveH5ClaimOrderNo:正确解析并解码订单号', () => {
|
||||
test('isH5ClaimPath:相邻前缀与其它路径都不算', () => {
|
||||
assert.equal(isH5ClaimPath('/h5x'), false)
|
||||
assert.equal(isH5ClaimPath('/h5x/1'), false)
|
||||
assert.equal(isH5ClaimPath('/claim/123'), false)
|
||||
assert.equal(isH5ClaimPath('/admin/h5/1'), false)
|
||||
assert.equal(isH5ClaimPath('/'), false)
|
||||
assert.equal(isH5ClaimPath(undefined), false)
|
||||
})
|
||||
|
||||
test('resolveH5ClaimOrderNo:只认 /h5/<一段订单号>,前缀本身与多段路径返回空串', () => {
|
||||
assert.equal(resolveH5ClaimOrderNo('/h5/2623001922675146'), '2623001922675146')
|
||||
assert.equal(resolveH5ClaimOrderNo('/h5/2623001922675146?from=sms'), '2623001922675146')
|
||||
assert.equal(resolveH5ClaimOrderNo('/h5/ORDER-001/'), 'ORDER-001')
|
||||
assert.equal(resolveH5ClaimOrderNo('/h5/%E4%B8%AD%E6%96%87'), '中文')
|
||||
assert.equal(resolveH5ClaimOrderNo('/h5'), '')
|
||||
assert.equal(resolveH5ClaimOrderNo('/h5/'), '')
|
||||
assert.equal(resolveH5ClaimOrderNo('/h5/a/b'), '')
|
||||
assert.equal(resolveH5ClaimOrderNo('/claim/123'), '')
|
||||
})
|
||||
|
||||
test('resolveH5ClaimOrderNoFromInput:纯订单号原样返回', () => {
|
||||
assert.equal(resolveH5ClaimOrderNoFromInput('2628601934580683'), '2628601934580683')
|
||||
assert.equal(resolveH5ClaimOrderNoFromInput(' 2628601934580683 '), '2628601934580683')
|
||||
assert.equal(resolveH5ClaimOrderNoFromInput('ORDER-001'), 'ORDER-001')
|
||||
assert.equal(resolveH5ClaimOrderNoFromInput(''), '')
|
||||
assert.equal(resolveH5ClaimOrderNoFromInput(null), '')
|
||||
})
|
||||
|
||||
test('resolveH5ClaimOrderNoFromInput:粘贴整条链接时取路径里的订单号', () => {
|
||||
assert.equal(
|
||||
resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5/2628601934580683'),
|
||||
'2628601934580683',
|
||||
)
|
||||
// 缺协议 / 带查询串 / 带后台残留 hash 都要能取出订单号
|
||||
assert.equal(
|
||||
resolveH5ClaimOrderNoFromInput('ks.khhao.com/h5/2628601934580683'),
|
||||
'2628601934580683',
|
||||
)
|
||||
assert.equal(
|
||||
resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5/2628601934580683?from=sms'),
|
||||
'2628601934580683',
|
||||
)
|
||||
assert.equal(
|
||||
resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5/2628601934580683#/admin/dashboard'),
|
||||
'2628601934580683',
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveH5ClaimOrderNoFromInput:链接里没有订单号时返回空串(不把整条 URL 当订单号)', () => {
|
||||
assert.equal(resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5'), '')
|
||||
assert.equal(resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5/'), '')
|
||||
assert.equal(resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5#/admin/dashboard'), '')
|
||||
// 不是 /h5 领取链接的地址同样取不到订单号
|
||||
assert.equal(resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/claim/abc123'), '')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user