fix(claim): 统一所有买家领取链接为 H5 订单号格式

This commit is contained in:
yml2213
2026-11-02 22:11:22 +08:00
parent 3989b0a649
commit 4dc2f5ca90
38 changed files with 298 additions and 362 deletions
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildConfiguredClaimUrl,
buildOrderClaimUrl,
pickOrderItemClaimTask,
resolveClaimTokenExpiration,
} from './claim-service.js'
@@ -18,26 +18,40 @@ test('领取链接保留正整数小时的限时配置能力', () => {
)
})
test('配置化领取链接只拼传入基址(去尾斜杠),不读全局 claimBaseUrl', () => {
test('所有来源统一生成 /h5/<订单号> 领取链接', () => {
const orderNo = '6919156612984680391'
assert.equal(
buildConfiguredClaimUrl('https://x.ngrok-free.app/#/claim', 'AbC123'),
'https://x.ngrok-free.app/#/claim/AbC123',
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/#/claim'),
`https://x.ngrok-free.app/h5/${orderNo}`,
)
assert.equal(
buildConfiguredClaimUrl('https://x.ngrok-free.app/claim/', 'AbC123'),
'https://x.ngrok-free.app/claim/AbC123',
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/claim/'),
`https://x.ngrok-free.app/h5/${orderNo}`,
)
assert.equal(
buildConfiguredClaimUrl('https://x.ngrok-free.app', 'AbC123'),
'https://x.ngrok-free.app/AbC123',
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/custom'),
`https://x.ngrok-free.app/custom/h5/${orderNo}`,
)
assert.equal(
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/h5///'),
`https://x.ngrok-free.app/h5/${orderNo}`,
)
assert.equal(
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/h5/old-token'),
`https://x.ngrok-free.app/h5/${orderNo}`,
)
assert.equal(
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/claim/old-token'),
`https://x.ngrok-free.app/h5/${orderNo}`,
)
})
test('配置化领取链接基址/token 缺失返回空串(由调用方决定不发链接、不发消息)', () => {
assert.equal(buildConfiguredClaimUrl('', 'AbC123'), '')
assert.equal(buildConfiguredClaimUrl(undefined, 'AbC123'), '')
assert.equal(buildConfiguredClaimUrl('https://x/claim', ''), '')
assert.equal(buildConfiguredClaimUrl('https://x/claim', null), '')
test('订单号或领取基址缺失/无效时返回空串', () => {
assert.equal(buildOrderClaimUrl('', 'https://x/claim'), '')
assert.equal(buildOrderClaimUrl(null, 'https://x/claim'), '')
assert.equal(buildOrderClaimUrl('6919156612984680391', ''), '')
assert.equal(buildOrderClaimUrl('6919156612984680391', 'not-a-url'), '')
})
test('pickOrderItemClaimTask 取该订单项 task id 最小且带 token 的单元', () => {
@@ -1,6 +1,6 @@
import { runtimeConfig } from '../../config/runtime.js'
import { createClaimToken } from '../../repositories/claim-token-repo.js'
import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { getTaskById, listTasksByOrderId } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { logIntegration } from '../../utils/logger.js'
import { addHours, nowIso } from '../../utils/time.js'
@@ -11,7 +11,10 @@ type TaskClaimToken = NonNullable<Awaited<ReturnType<typeof createClaimToken>>>
claimUrl: string
}
export async function createTaskClaimToken(taskId: number | string): Promise<TaskClaimToken> {
export async function createTaskClaimToken(
taskId: number | string,
orderNo?: unknown,
): Promise<TaskClaimToken> {
const createdAt = nowIso()
const expiredAt = resolveClaimTokenExpiration(createdAt, runtimeConfig.orders.tokenTtlHours)
const token = await createClaimToken({
@@ -33,9 +36,12 @@ export async function createTaskClaimToken(taskId: number | string): Promise<Tas
})
}
const resolvedOrderNo =
orderNo === undefined ? (await getTaskById(Number(taskId)))?.platform_order_id : orderNo
return {
...token,
claimUrl: buildClaimUrl(token.token),
claimUrl: buildOrderClaimUrl(resolvedOrderNo),
}
}
@@ -49,36 +55,63 @@ export function resolveClaimTokenExpiration(
}
/**
* 全局默认领取链接:`runtimeConfig.orders.claimBaseUrl` + `/` + token。
* 所有来源统一的买家领取链接:`<领取站>/h5/<平台订单号>`。
*
* ⚠️ 默认基址是主站 HashRouter 里的买家领取页(`…/#/claim`)。**抖音码速达**的领取链接
* 走 `buildConfiguredClaimUrl` + 后台配置的 `claimLinkBase`,两条口径刻意分开、互不回退。
* `claimBaseUrl` 兼容历史配置形态(如 `https://x/#/claim`、`https://x/claim`),
* 这里会归一化到同一站点的 `/h5` 路径;订单号为空或基址无效时返回空串。
*/
export function buildClaimUrl(token: string): string {
const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim()
return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token
}
/**
* 按**调用方给定的基址**拼领取链接:`${基址去尾斜杠}/${token}`。
*
* 抖音码速达的基址只能来自后台配置的 `claimLinkBase`(买家领取页),
* 绝不回退 `buildClaimUrl` 的 `orders.claimBaseUrl`(那是主站默认领取页,口径不同)。
* 基址或 token 缺失时返回空串,由调用方决定「不生成链接 / 不发消息」,不猜、不回退。
*/
export function buildConfiguredClaimUrl(baseUrl: unknown, token: unknown): string {
const base = String(baseUrl || '')
.trim()
.replace(/\/+$/, '')
const normalizedToken = String(token || '').trim()
if (!base || !normalizedToken) {
export function buildOrderClaimUrl(
orderNo: unknown,
claimBaseUrl: unknown = runtimeConfig.orders.claimBaseUrl,
): string {
const normalizedOrderNo = String(orderNo ?? '').trim()
if (!normalizedOrderNo) {
return ''
}
return `${base}/${normalizedToken}`
const baseUrl = resolveH5ClaimBaseUrl(claimBaseUrl)
if (!baseUrl) {
return ''
}
return `${baseUrl}/${encodeURIComponent(normalizedOrderNo)}`
}
/** 把任意历史领取页基址归一化成站点下的 `/h5` 基址。 */
export function resolveH5ClaimBaseUrl(claimBaseUrl: unknown): string {
const rawValue = String(claimBaseUrl ?? '').trim()
if (!rawValue) {
return ''
}
let url: URL
try {
url = new URL(rawValue)
} catch {
return ''
}
const pathname = url.pathname.replace(/\/+$/, '')
const h5Match = pathname.match(/\/h5(?:\/|$)/i)
const claimMatch = pathname.match(/\/claim(?:\/|$)/i)
if (h5Match?.index !== undefined) {
url.pathname = `${pathname.slice(0, h5Match.index)}/h5`
} else if (claimMatch?.index !== undefined) {
url.pathname = `${pathname.slice(0, claimMatch.index)}/h5`
} else if (pathname && pathname !== '/') {
url.pathname = `${pathname}/h5`
} else {
url.pathname = '/h5'
}
url.search = ''
url.hash = ''
return url.toString().replace(/\/+$/, '')
}
export type OrderItemClaimLink = {
/** 领取链接;未配置 claimLinkBase、或该订单项没有带 token 的履约任务时为空串 */
/** 统一 H5 领取链接;无可用履约单元或订单号时为空串 */
claimUrl: string
/** 命中的履约任务 id(多个时取最小);无可用任务时不下发该字段 */
taskId?: number
@@ -97,7 +130,7 @@ export type OrderItemClaimTaskPick = {
token: string
/** 该订单项下的履约任务总数,不论有没有 token */
taskCount: number
/** 其中带 claim token 的单元数(>1 时会有多个领取链接) */
/** 其中带 claim token 的单元数;对外仍共用一个订单号链接 */
tokenTaskCount: number
}
@@ -105,7 +138,7 @@ export type OrderItemClaimTaskPick = {
* 纯函数:从订单的履约任务里挑出该订单项的领取单元。
*
* 一个订单项按数量会拆成多个履约单元(`fulfillment_tasks`),每个单元有自己的 claim token;
* 这里固定取 **task id 最小** 的那个,保证同一张券每次解析出同一条链接。
* 这里固定取 **task id 最小** 的那个,用于判断该订单项是否已经准备好自动履约。
*/
export function pickOrderItemClaimTask(
tasks: Array<Partial<TaskRow>> | null | undefined,
@@ -142,17 +175,17 @@ export function pickOrderItemClaimTask(
}
/**
* 通用领取链接解析(不再依赖任何具体执行器):
* 某订单项履约任务上的 claim token → `claimLinkBase` + `/` + token。
* 通用领取单元解析(不再依赖任何具体执行器):
* 先按订单项找到带 claim token 的履约任务,再生成统一的 `/h5/<订单号>` 链接。
*
* 抖音码速达的皮肤/礼包订单走 `affiliate_dash` 通道,履约任务上的 token 就是买家领取 token,
* 因此这里只按 `order_item_id` 找任务、**不看 executor_key**:
* 换执行器不会再让领取链接解析失效。
* 抖音码速达的皮肤/礼包订单走 `affiliate_dash` 通道。这里只按 `order_item_id` 找任务、
* **不看 executor_key**,换执行器不会影响链接和自动发货判断。
*/
export async function resolveOrderItemClaimLink(input: {
orderId: number | string
orderNo: unknown
orderItemId: number | string
claimLinkBase: unknown
claimLinkBase?: unknown
}): Promise<OrderItemClaimLink> {
const orderId = Number(input.orderId)
if (!Number.isFinite(orderId)) {
@@ -180,7 +213,7 @@ export async function resolveOrderItemClaimLink(input: {
}
return {
claimUrl: buildConfiguredClaimUrl(input.claimLinkBase, picked.token),
claimUrl: buildOrderClaimUrl(input.orderNo, input.claimLinkBase || undefined),
taskId: picked.taskId,
taskCount: picked.taskCount,
}
@@ -11,7 +11,7 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
import { nowIso } from '../../utils/time.js'
import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js'
import { buildClaimUrl } from './claim-service.js'
import { buildOrderClaimUrl } 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'
@@ -301,7 +301,7 @@ function buildAffiliateDashClaimDetailPayload({
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
claimUrl: buildOrderClaimUrl(order.platform_order_id),
flowType: 'affiliate_dash' as const,
channelOffline: false,
channelOfflineMessage: '',
@@ -382,7 +382,7 @@ function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }:
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
claimUrl: buildOrderClaimUrl(order.platform_order_id),
flowType:
executorKey === 'kuaishou_feifei'
? ('kuaishou_feifei' as const)
@@ -436,7 +436,7 @@ function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }:
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
claimUrl: buildOrderClaimUrl(order.platform_order_id),
flowType: String(task.executor_key || 'manual_dispatch').trim() || 'manual_dispatch',
channelOffline: false,
channelOfflineMessage: '',