fix(claim): 统一所有买家领取链接为 H5 订单号格式
This commit is contained in:
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user