fix(claim): 统一所有买家领取链接为 H5 订单号格式
This commit is contained in:
@@ -62,7 +62,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
},
|
||||
|
||||
orders: {
|
||||
claimBaseUrl: 'http://127.0.0.1:5173/#/claim',
|
||||
claimBaseUrl: 'http://127.0.0.1:5173/h5',
|
||||
// 0 表示长期有效;后台手动关闭任务后领取链接仍会立即失效。
|
||||
tokenTtlHours: 0,
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ test('applyEnvOverrides derives claim base url from app base url when explicit c
|
||||
APP_BASE_URL: 'https://example.test///',
|
||||
})
|
||||
|
||||
assert.equal(config.orders.claimBaseUrl, 'https://example.test/#/claim')
|
||||
assert.equal(config.orders.claimBaseUrl, 'https://example.test/h5')
|
||||
})
|
||||
|
||||
test('CLAIM_TOKEN_TTL_HOURS=0 表示领取链接长期有效', () => {
|
||||
|
||||
@@ -158,7 +158,7 @@ function applyClaimBaseUrlOverride(config: RuntimeConfig, env: RuntimeEnv): void
|
||||
|
||||
const appBaseUrl = parseString(env.APP_BASE_URL)
|
||||
if (appBaseUrl !== null) {
|
||||
config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, '')}/#/claim`
|
||||
config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, '')}/h5`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 历史短链只读跳转入口(/s/:code)。
|
||||
* 新 claim 交付请使用本站 /#/claim/{token},勿再为 feifei 生成 shortLink。
|
||||
* 新 claim 交付请使用本站 /h5/{订单号},勿再为 feifei 生成 shortLink。
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildClaimUrl, buildConfiguredClaimUrl } from '../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl } from '../claim/claim-service.js'
|
||||
import { buildClaimIdentityAdminSummary } from '../claim/claim-identity.js'
|
||||
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
|
||||
import {
|
||||
@@ -385,9 +385,7 @@ export async function getAdminTaskDetail(
|
||||
const taskContext = parseTaskContext(task)
|
||||
const taskState = parseTaskState(task)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
// 后台回显的领取链接口径按**订单来源**决定(不再按 executor_key):
|
||||
// 抖音码速达订单用后台配置的 `claimLinkBase`(买家领取页,走 affiliate_dash 领取流程);
|
||||
// 其它来源沿用全局 buildClaimUrl(orders.claimBaseUrl)。
|
||||
// 后台回显的领取链接统一为 `/h5/<平台订单号>`;抖音码速达仍可使用自己的领取站基址。
|
||||
const claimUrl = resolveAdminTaskClaimUrl(claimToken, order)
|
||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
||||
const cloudSourceLabelMap = buildCloudSourceLabelMap()
|
||||
@@ -514,32 +512,24 @@ export async function getAdminTaskDetail(
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台展示的领取链接。
|
||||
* 后台展示的买家领取链接,所有来源统一为 `/h5/<平台订单号>`。
|
||||
*
|
||||
* - 抖音码速达订单(`provider='douyin_mashida'`):`claimLinkBase` + token(买家领取页,
|
||||
* 走 affiliate_dash 领取流程);未配 `claimLinkBase` 时退回全局 claim 链接,至少链接不会为空。
|
||||
* - 其它来源:沿用全局 `buildClaimUrl`(orders.claimBaseUrl),历史行为不变。
|
||||
*
|
||||
* ⚠️ 这里按 **provider** 判断,不按 executor_key:抖音皮肤单已改走 `affiliate_dash` 通道,
|
||||
* 按执行器判断会失效。拿不到 provider(order 为空)时按「其它来源」处理。
|
||||
* 抖音码速达可继续使用自己配置的领取站基址,其它来源使用全局 claimBaseUrl;
|
||||
* 订单号缺失时不生成链接。链接不再依赖 claim token。
|
||||
*/
|
||||
function resolveAdminTaskClaimUrl(
|
||||
claimToken: { token: string } | null | undefined,
|
||||
order: Pick<OrderRow, 'provider'> | null | undefined,
|
||||
order: Pick<OrderRow, 'provider' | 'platform_order_id'> | null | undefined,
|
||||
): string {
|
||||
const token = String(claimToken?.token || '').trim()
|
||||
if (!token) {
|
||||
if (!String(claimToken?.token || '').trim() || !order) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (isDouyinMashidaProvider(order?.provider)) {
|
||||
const claimUrl = buildConfiguredClaimUrl(getDouyinMashidaSourceConfig().claimLinkBase, token)
|
||||
if (claimUrl) {
|
||||
return claimUrl
|
||||
}
|
||||
}
|
||||
const douyinClaimBase = isDouyinMashidaProvider(order.provider)
|
||||
? getDouyinMashidaSourceConfig().claimLinkBase
|
||||
: ''
|
||||
|
||||
return buildClaimUrl(token)
|
||||
return buildOrderClaimUrl(order.platform_order_id, douyinClaimBase || undefined)
|
||||
}
|
||||
|
||||
function mapKuaishouIndustryVoucherContext(value: unknown): JsonRecord | null {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
cancelCertificateVerify,
|
||||
ensureCertificateForOrderItem,
|
||||
formatGrantTime,
|
||||
resolveDouyinMashidaCertLink,
|
||||
verifyCertificate,
|
||||
} from '../redeem/certificate-service.js'
|
||||
import type {
|
||||
@@ -154,7 +155,18 @@ export async function syncAdminDouyinMashidaCertificate(
|
||||
})
|
||||
}
|
||||
|
||||
const certLink = String(certificate.cert_link || config.certLink || '').trim()
|
||||
const context = await resolveCertificateOrderContext({
|
||||
...(certificate.local_order_id !== null ? { localOrderId: certificate.local_order_id } : {}),
|
||||
...(certificate.order_item_id !== null ? { orderItemId: certificate.order_item_id } : {}),
|
||||
})
|
||||
const regeneratedCertLink = await resolveDouyinMashidaCertLink({
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
config,
|
||||
})
|
||||
const certLink = String(
|
||||
regeneratedCertLink || certificate.cert_link || config.certLink || '',
|
||||
).trim()
|
||||
if (!certLink) {
|
||||
throw createHttpError('未配置兑换链接(certLink),无法同步券码', {
|
||||
statusCode: 400,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../utils/masking.js'
|
||||
|
||||
import type { TaskRow } from '../../types/repository/rows.js'
|
||||
@@ -52,11 +52,11 @@ export async function ensureTaskClaimLink(task: TaskRow): Promise<TaskClaimLink>
|
||||
return {
|
||||
token,
|
||||
expiredAt,
|
||||
claimUrl: buildClaimUrl(token),
|
||||
claimUrl: buildOrderClaimUrl(task.platform_order_id),
|
||||
}
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const claimToken = await createTaskClaimToken(task.id, task.platform_order_id)
|
||||
return {
|
||||
token: claimToken.token,
|
||||
expiredAt: claimToken.expired_at,
|
||||
|
||||
@@ -5,11 +5,7 @@ import {
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { getOrderById } from '../../../repositories/order-repo.js'
|
||||
import {
|
||||
createTaskClaimToken,
|
||||
buildClaimUrl,
|
||||
buildConfiguredClaimUrl,
|
||||
} from '../../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js'
|
||||
import { getDouyinMashidaSourceConfig } from '../../platforms/douyin-mashida/source-config-service.js'
|
||||
import { isDouyinMashidaProvider } from '../../platforms/douyin-mashida/order-mapper.js'
|
||||
import { notifyTaskAutoManualReview } from '../../notification/domain-notifications.js'
|
||||
@@ -240,33 +236,25 @@ export async function retryAdminTask(
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台回显的领取链接口径(按**订单来源**判断,不按 executor_key):
|
||||
* - 抖音码速达订单:后台配置的 `claimLinkBase` + token(买家领取页,走 affiliate_dash 领取流程);
|
||||
* 未配 `claimLinkBase` 时退回通用领取页,至少链接不会为空;
|
||||
* - 其它渠道:沿用 runtimeConfig 的通用领取页(行为完全不变)。
|
||||
*/
|
||||
/** 后台回显的买家领取链接统一为 `/h5/<平台订单号>`,不再生成 token 链接。 */
|
||||
async function resolveAdminTaskClaimUrl(
|
||||
task: Pick<TaskRow, 'order_id'>,
|
||||
token: string,
|
||||
): Promise<string> {
|
||||
const normalizedToken = String(token || '').trim()
|
||||
if (!normalizedToken) {
|
||||
if (!String(token || '').trim()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const order = await getOrderById(task.order_id)
|
||||
if (isDouyinMashidaProvider(order?.provider)) {
|
||||
const claimUrl = buildConfiguredClaimUrl(
|
||||
getDouyinMashidaSourceConfig().claimLinkBase,
|
||||
normalizedToken,
|
||||
)
|
||||
if (claimUrl) {
|
||||
return claimUrl
|
||||
}
|
||||
if (!order) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return buildClaimUrl(normalizedToken)
|
||||
const douyinClaimBase = isDouyinMashidaProvider(order.provider)
|
||||
? getDouyinMashidaSourceConfig().claimLinkBase
|
||||
: ''
|
||||
|
||||
return buildOrderClaimUrl(order.platform_order_id, douyinClaimBase || undefined)
|
||||
}
|
||||
|
||||
export async function regenerateAdminTaskClaimLink(
|
||||
@@ -306,7 +294,7 @@ export async function regenerateAdminTaskClaimLink(
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const claimToken = await createTaskClaimToken(task.id, task.platform_order_id)
|
||||
const nextStatus =
|
||||
task.task_status === TASK_STATUS.MANUAL_REVIEW || task.task_status === TASK_STATUS.RETRY_PENDING
|
||||
? TASK_STATUS.LINK_GENERATED
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getFulfillmentProfileByKey,
|
||||
upsertFulfillmentProfile,
|
||||
} from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.js'
|
||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, assertOpen91Config } from '../open-91/config.js'
|
||||
import {
|
||||
@@ -258,7 +258,7 @@ export async function createAffiliateDashMockClaim(
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const claimToken = await createTaskClaimToken(task.id, orderNo)
|
||||
await updateTask(task.id, {
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
@@ -274,7 +274,7 @@ export async function createAffiliateDashMockClaim(
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
claimToken: claimToken.token,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
claimUrl: buildOrderClaimUrl(orderNo),
|
||||
frontendBaseUrl: input.frontendBaseUrl,
|
||||
expectedUid: fallbackUid,
|
||||
step,
|
||||
@@ -633,7 +633,7 @@ function buildCreateResult(input: {
|
||||
taskNo: input.taskNo,
|
||||
claimToken: input.claimToken,
|
||||
claimUrl: input.claimUrl,
|
||||
frontendClaimUrl: buildFrontendClaimUrl(input.claimToken, input.frontendBaseUrl),
|
||||
frontendClaimUrl: buildOrderClaimUrl(input.orderNo, input.frontendBaseUrl || undefined),
|
||||
expectedUid: input.expectedUid,
|
||||
step: input.step,
|
||||
productName: input.productName,
|
||||
@@ -642,14 +642,6 @@ function buildCreateResult(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function buildFrontendClaimUrl(token: string, frontendBaseUrl?: unknown) {
|
||||
const base = String(frontendBaseUrl || '').trim()
|
||||
if (base) {
|
||||
return `${base.replace(/\/+$/, '')}/#/claim/${token}`
|
||||
}
|
||||
return `http://127.0.0.1/#/claim/${token}`
|
||||
}
|
||||
|
||||
function buildAffiliateDashTips(step: AffiliateDashMockStep, uid: string) {
|
||||
if (step === 'uid') {
|
||||
return [`打开领取链接,Step1 填 UID:${uid}(或自定义)`, '提交后进入绑定步,mock 会给出二维码']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildClaimUrl } from '../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl } from '../claim/claim-service.js'
|
||||
import { resolveTaskDeliveryLink } from './delivery-link-service.js'
|
||||
import type { TaskRow } from '../../types/repository/rows.js'
|
||||
|
||||
@@ -15,7 +15,7 @@ test('resolveTaskDeliveryLink 为 affiliate_dash 任务返回本站领取链接'
|
||||
)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
claimUrl: buildClaimUrl('affiliate-token'),
|
||||
claimUrl: buildOrderClaimUrl('2614900602069169'),
|
||||
expireTime: '2026-07-09T00:00:00.000Z',
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,7 @@ test('resolveTaskDeliveryLink 兼容 claim_token 回落字段', async () => {
|
||||
)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
claimUrl: buildClaimUrl('legacy-token'),
|
||||
claimUrl: buildOrderClaimUrl('2614900602069169'),
|
||||
expireTime: '2026-07-09T08:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildClaimUrl } from '../../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl } from '../../claim/claim-service.js'
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import { prepareAffiliateDashTask } from '../affiliate-dash/index.js'
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ async function preparePaidTask(
|
||||
try {
|
||||
let workingTask = task
|
||||
if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) {
|
||||
const claimToken = await deps.createTaskClaimToken(task.id)
|
||||
const claimToken = await deps.createTaskClaimToken(task.id, task.platform_order_id)
|
||||
workingTask =
|
||||
(await deps.updateTask(task.id, {
|
||||
claim_token: claimToken.token,
|
||||
@@ -64,7 +64,7 @@ async function resolveDeliveryLink(task: TaskRow): Promise<FulfillmentDeliveryLi
|
||||
}
|
||||
|
||||
return {
|
||||
claimUrl: buildClaimUrl(primaryToken),
|
||||
claimUrl: buildOrderClaimUrl(task.platform_order_id),
|
||||
expireTime,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ export type FulfillmentDeliveryLink = {
|
||||
|
||||
export type FulfillmentPrepareDeps = {
|
||||
updateTask: (taskId: number | string, patch: TaskUpdatePatch) => Promise<TaskRow | null>
|
||||
createTaskClaimToken: (taskId: number | string) => Promise<{
|
||||
createTaskClaimToken: (
|
||||
taskId: number | string,
|
||||
orderNo?: unknown,
|
||||
) => Promise<{
|
||||
token: string
|
||||
expired_at: string | null
|
||||
[key: string]: unknown
|
||||
|
||||
@@ -117,14 +117,14 @@ test('resolveOpen91QueryState returns success only when every task is ready', ()
|
||||
test('buildOpen91CardItem mirrors claim link into jumpLink and leaves password empty', () => {
|
||||
assert.deepEqual(
|
||||
buildOpen91CardItem({
|
||||
claimUrl: 'https://example.com/#/claim/abc123xyz',
|
||||
claimUrl: 'https://example.com/h5/6919156612984680391',
|
||||
expireTime: '2026-05-05 12:00:00',
|
||||
}),
|
||||
{
|
||||
cardNo: 'https://example.com/#/claim/abc123xyz',
|
||||
cardNo: 'https://example.com/h5/6919156612984680391',
|
||||
cardPwd: '',
|
||||
expireTime: '2026-05-05 12:00:00',
|
||||
jumpLink: 'https://example.com/#/claim/abc123xyz',
|
||||
jumpLink: 'https://example.com/h5/6919156612984680391',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildOrderClaimUrl } from '../../claim/claim-service.js'
|
||||
import {
|
||||
buildDouyinMashidaBuyerClaimUrl,
|
||||
buildDouyinMashidaClaimMessage,
|
||||
isDouyinMashidaH5ClaimBase,
|
||||
renderDouyinMashidaMessageTemplate,
|
||||
resolveDouyinMashidaClaimUrl,
|
||||
} from './claim-link.js'
|
||||
import {
|
||||
createDefaultDouyinMashidaSourceConfig,
|
||||
@@ -45,22 +44,6 @@ test('抖音配置归一化:claimLinkBase 去尾斜杠,开关可关,模板
|
||||
assert.equal(config.claimMessageEnabled, false)
|
||||
})
|
||||
|
||||
test('买家领取链接 = claimLinkBase(去尾斜杠)+ / + token,绝不回退全局领取页', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaClaimUrl(
|
||||
'AbC123',
|
||||
buildConfig({ claimLinkBase: 'https://x.ngrok-free.app/claim/' }),
|
||||
),
|
||||
'https://x.ngrok-free.app/claim/AbC123',
|
||||
)
|
||||
|
||||
assert.equal(resolveDouyinMashidaClaimUrl('AbC123', buildConfig()), '')
|
||||
assert.equal(
|
||||
resolveDouyinMashidaClaimUrl('', buildConfig({ claimLinkBase: 'https://x/claim' })),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
test('模板占位符替换:支持 link/sku/orderNo/productTitle,空值保留占位符', () => {
|
||||
assert.equal(
|
||||
renderDouyinMashidaMessageTemplate(
|
||||
@@ -105,16 +88,6 @@ test('发消息正文:无领取链接则不发(返回空串),默认模
|
||||
)
|
||||
})
|
||||
|
||||
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(
|
||||
@@ -126,19 +99,18 @@ test('买家领取消息链接:没有自动发货领取单元(人工单)
|
||||
'',
|
||||
)
|
||||
|
||||
// token 形态基址同样不发:人工单本来就没有 token,不能拿订单号去凑
|
||||
// 历史 /claim 基址同样不发:人工单本来就没有自动发货单元
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: false,
|
||||
tokenClaimUrl: '',
|
||||
}),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
test('买家领取消息链接:有自动发货领取单元时按基址形态拼接', () => {
|
||||
test('买家领取消息链接:有自动发货领取单元时统一生成 /h5/订单号', () => {
|
||||
// /h5 形态:拼订单号(结尾斜杠、结尾多斜杠都容忍)
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
@@ -157,15 +129,14 @@ test('买家领取消息链接:有自动发货领取单元时按基址形态
|
||||
'https://ks.khhao.com/h5/6918490107300556291',
|
||||
)
|
||||
|
||||
// token 形态:只用 resolveOrderItemClaimLink 解析出的链接
|
||||
// 历史 /claim 基址也归一化到 /h5,不再回退 token 链接
|
||||
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',
|
||||
'https://ks.khhao.com/h5/6918490107300556291',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -176,7 +147,7 @@ test('买家领取消息链接:基址或订单号缺失时返回空串(不
|
||||
orderNo: '6918490107300556291',
|
||||
hasAutoClaimUnit: true,
|
||||
}),
|
||||
'',
|
||||
buildOrderClaimUrl('6918490107300556291'),
|
||||
)
|
||||
assert.equal(
|
||||
buildDouyinMashidaBuyerClaimUrl({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildConfiguredClaimUrl } from '../../claim/claim-service.js'
|
||||
import { buildOrderClaimUrl } from '../../claim/claim-service.js'
|
||||
import {
|
||||
DEFAULT_CLAIM_MESSAGE_TEMPLATE,
|
||||
getDouyinMashidaSourceConfig,
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
/**
|
||||
* 抖音码速达的买家领取链接与发货消息拼装(纯函数,便于单测)。
|
||||
*
|
||||
* 领取链接 = `config.claimLinkBase`(去尾斜杠) + `/` + token。
|
||||
* ⚠️ 这里**只认** `claimLinkBase`,绝不回退 `runtimeConfig.orders.claimBaseUrl`
|
||||
* (那条口径的默认基址属于主站默认领取页,两者不能互相回退)。
|
||||
* 买家链接统一为 `/h5/<平台订单号>`;配置了更具体的领取站基址时沿用其站点。
|
||||
*/
|
||||
|
||||
export type DouyinMashidaClaimMessageVars = {
|
||||
@@ -20,25 +18,6 @@ export type DouyinMashidaClaimMessageVars = {
|
||||
productTitle?: string | undefined
|
||||
}
|
||||
|
||||
/** 买家领取链接;`claimLinkBase` 或 token 缺失时返回空串(调用方据此跳过发链接/发消息)。 */
|
||||
export function resolveDouyinMashidaClaimUrl(
|
||||
token: unknown,
|
||||
config: DouyinMashidaSourceConfig = getDouyinMashidaSourceConfig(),
|
||||
): string {
|
||||
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/'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 买家消息里真正要发出去的领取链接(**空串 = 这条消息不许发**)。
|
||||
*
|
||||
@@ -52,40 +31,21 @@ export function isDouyinMashidaH5ClaimBase(claimLinkBase: unknown): boolean {
|
||||
* 历史缺陷:`/h5` 基址原先走了一条捷径——只拼订单号、跳过履约任务校验,
|
||||
* 于是人工单也照样收到领取消息(线上实测:未命中映射的「套装-牛仔情诗」单被发了链接)。
|
||||
*
|
||||
* 拼接规则按基址形态分流:
|
||||
* - `/h5` 形态 → `claimLinkBase` + `/` + 订单号;
|
||||
* - 其它形态 → `resolveOrderItemClaimLink` 解析出的 token 链接。
|
||||
* 拼接规则:有自动发货领取单元时,所有来源统一发 `/h5/<平台订单号>`。
|
||||
* 历史配置的 `/#/claim`、`/claim` 等基址也会由公共 helper 归一化到 `/h5`。
|
||||
*/
|
||||
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(/\/+$/, '')
|
||||
return buildOrderClaimUrl(input.orderNo, input.claimLinkBase || undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -254,31 +254,28 @@ async function notifyBuyerClaimLink(input: {
|
||||
const claimLinkBase = String(input.config.claimLinkBase || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
if (!claimLinkBase) {
|
||||
return
|
||||
}
|
||||
|
||||
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
|
||||
const receiptOrderNo = resolveReceiptOrderId(input.order, input.orderItem)
|
||||
const claimOrderNo = String(input.order.platform_order_id || receiptOrderNo).trim()
|
||||
|
||||
// 先核对履约任务:`resolveOrderItemClaimLink` 只有在该订单项存在带 token 的领取单元时才给 taskId。
|
||||
const claimLink = await resolveOrderItemClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderNo: claimOrderNo,
|
||||
orderItemId: input.orderItem.id,
|
||||
claimLinkBase,
|
||||
})
|
||||
|
||||
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase,
|
||||
orderNo,
|
||||
orderNo: claimOrderNo,
|
||||
hasAutoClaimUnit: Boolean(claimLink.taskId),
|
||||
tokenClaimUrl: claimLink.claimUrl,
|
||||
})
|
||||
|
||||
if (!claimUrl) {
|
||||
logIntegration('[douyin-mashida/notify]', '该订单项没有自动发货领取单元,跳过领取链接消息', {
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
orderNo,
|
||||
orderNo: claimOrderNo,
|
||||
claimLinkBase,
|
||||
taskCount: claimLink.taskCount,
|
||||
hasClaimUnit: Boolean(claimLink.taskId),
|
||||
@@ -290,19 +287,24 @@ async function notifyBuyerClaimLink(input: {
|
||||
config: input.config,
|
||||
claimUrl,
|
||||
sku: input.orderItem.sku_code,
|
||||
orderNo,
|
||||
orderNo: claimOrderNo,
|
||||
productTitle: input.orderItem.sku_name,
|
||||
})
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
|
||||
await sendDouyinMashidaOrderMessage({ orderId: orderNo, content, config: input.config })
|
||||
await sendDouyinMashidaOrderMessage({
|
||||
orderId: receiptOrderNo,
|
||||
content,
|
||||
config: input.config,
|
||||
})
|
||||
|
||||
logIntegration('[douyin-mashida/notify]', '已把领取链接发给买家(order.message)', {
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
orderNo,
|
||||
orderNo: claimOrderNo,
|
||||
receiptOrderNo,
|
||||
taskId: claimLink.taskId,
|
||||
claimUrl,
|
||||
})
|
||||
|
||||
@@ -18,13 +18,11 @@ import {
|
||||
* - `uid` 是抖店商户号;`secret` 是用户秘钥(两者都由平台方分配)。
|
||||
* - `certLink` 是我们发给平台的固定兑换链接(`coupons.syncV2` 的 `cert_link`),
|
||||
* **必须做成配置项**:链接一旦同步给平台就收不回来,换域名时只影响新券。
|
||||
* 命中 affiliate-dash 映射的订单会优先用「买家领取链接」(`claimLinkBase` + token),
|
||||
* 此时 `certLink` 退化为兜底。
|
||||
* - `claimLinkBase` 是**买家领取页**基址,必须渲染 affiliate-dash 领取流程
|
||||
* (输 UID → 二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」完成发货)。
|
||||
* 本仓库前端该页面是 `pages/claim/ClaimPage`,路径式 `https://<域名>/claim` 与
|
||||
* hash 式 `https://<域名>/#/claim` 都会渲染它;它**不是** `runtimeConfig.orders.claimBaseUrl`
|
||||
* (两条口径不能互相回退)。
|
||||
* 买家领取链接统一为 `/h5/<订单号>`;未配 `claimLinkBase` 时使用全局领取站,
|
||||
* `certLink` 仅在订单号或领取站不可用时兜底。
|
||||
* - `claimLinkBase` 是**买家领取页**基址;出站链接会在其站点下统一生成
|
||||
* `/h5/<平台订单号>`,历史 `/#/claim`、`/claim` 基址也会被归一化。
|
||||
* 本仓库前端该页面是 `pages/claim/ClaimPage`,走 affiliate-dash 领取流程。
|
||||
* - `handledTopics` 是本地处理白名单:白名单外的 topic 只落库留痕、不产生副作用。
|
||||
*/
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { isPlainObject, type JsonObject } from '../../types/json.js'
|
||||
|
||||
import { resolveOrderItemClaimLink } from '../claim/claim-service.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
@@ -30,10 +29,7 @@ import {
|
||||
syncDouyinMashidaCoupons,
|
||||
verifyDouyinMashidaCoupon,
|
||||
} from '../platforms/douyin-mashida/gateway-client.js'
|
||||
import {
|
||||
buildDouyinMashidaBuyerClaimUrl,
|
||||
isDouyinMashidaH5ClaimBase,
|
||||
} from '../platforms/douyin-mashida/claim-link.js'
|
||||
import { buildDouyinMashidaBuyerClaimUrl } from '../platforms/douyin-mashida/claim-link.js'
|
||||
import {
|
||||
DOUYIN_MASHIDA_PROVIDER,
|
||||
extractDouyinMashidaSubOrderId,
|
||||
@@ -295,18 +291,9 @@ async function attachDouyinMashidaCertificateToWorkOrder(input: {
|
||||
/**
|
||||
* 解析本次同步要交给平台的 `cert_link`。
|
||||
*
|
||||
* 判据:券对**所有订单**都要发(业务口径),链接按基址形态决定:
|
||||
* - `/h5` 形态基址 → `claimLinkBase/<订单号>`(买家落到自己订单的状态页,自动发货/人工处理中都合理);
|
||||
* - 其它形态基址 → 只有该订单项确实有自动发货领取单元(claim token)时才给 token 领取链接;
|
||||
* - 拿不到可用链接 → 回退固定 `config.certLink`。
|
||||
*
|
||||
* 历史缺陷:`claimLinkBase` 以 `/h5` 结尾时走了一条捷径——**无条件**下拼订单号,
|
||||
* 但那只是「拼法」问题;真正的缺陷在私信侧(无 token 也发「已发货」,见 notify-service,
|
||||
* 已用同一个纯函数修好)。这里保留 `/h5` 按订单号拼的行为,但**不再让链接依赖 token**。
|
||||
*
|
||||
* 链接解析走通用 helper(只看订单项的履约任务,不看执行器 key):
|
||||
* 抖音皮肤/礼包单已改走 affiliate_dash 通道,换执行器不会再让 cert_link 失效。
|
||||
* 同一个订单项按数量拆成多个履约单元时会有多个 token,helper 取最小 task id 并打 warn。
|
||||
* 券对**所有订单**都要发,买家链接统一使用 `/h5/<平台订单号>`;
|
||||
* 抖音配置了 `claimLinkBase` 时使用其站点,否则使用全局领取站;
|
||||
* 订单号缺失或领取站不可用时回退固定 `config.certLink`。
|
||||
*
|
||||
* ⚠️ 只对新单生效:已同步成功的券不会被改写。
|
||||
*/
|
||||
@@ -315,70 +302,24 @@ export async function resolveDouyinMashidaCertLink(input: {
|
||||
orderItem: OrderItemRow
|
||||
config: DouyinMashidaSourceConfig
|
||||
}): Promise<string> {
|
||||
const fallback = String(input.config.certLink || '').trim()
|
||||
const claimLinkBase = String(input.config.claimLinkBase || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
if (!claimLinkBase) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const claimLink = await resolveOrderItemClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
claimLinkBase: input.config.claimLinkBase,
|
||||
})
|
||||
|
||||
// 有履约任务却拿不到 token = 任务没被正确准备(配置/执行器问题),值得留痕;
|
||||
// 连任务都还没有属于建单顺序上的正常瞬时状态,静默回退即可。
|
||||
if (!claimLink.taskId && claimLink.taskCount > 0) {
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
'该订单项有履约任务但尚无 claim token,cert_link 回退固定兑换链接',
|
||||
{
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
taskCount: claimLink.taskCount,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
|
||||
return resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: fallback,
|
||||
claimLinkBase,
|
||||
orderNo: resolveReceiptOrderId(input.order, input.orderItem),
|
||||
tokenClaimUrl: claimLink.claimUrl,
|
||||
certLink: input.config.certLink,
|
||||
claimLinkBase: input.config.claimLinkBase,
|
||||
orderNo: input.order.platform_order_id,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `cert_link` 的**纯函数**判定(便于单测)。
|
||||
*
|
||||
* 业务口径:**券对所有订单都要发**(与是否命中自动发货无关),因此链接不能退化成裸基址:
|
||||
* - `/h5` 形态基址 → 一律按**订单号**拼(买家直接落到自己订单的状态页);
|
||||
* - 其它形态基址 → 有 token 才给领取链接,否则回退固定 `certLink`。
|
||||
*/
|
||||
/** `cert_link` 的纯函数判定,便于单测。 */
|
||||
export function resolveDouyinMashidaCertLinkFromClaim(input: {
|
||||
certLink: unknown
|
||||
claimLinkBase: unknown
|
||||
orderNo: unknown
|
||||
tokenClaimUrl?: unknown
|
||||
}): string {
|
||||
const fallback = String(input.certLink ?? '').trim()
|
||||
const base = String(input.claimLinkBase ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
if (!base) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const orderNoLink = isDouyinMashidaH5ClaimBase(base)
|
||||
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: base,
|
||||
claimLinkBase: input.claimLinkBase,
|
||||
orderNo: input.orderNo,
|
||||
hasAutoClaimUnit: orderNoLink || Boolean(String(input.tokenClaimUrl ?? '').trim()),
|
||||
tokenClaimUrl: input.tokenClaimUrl,
|
||||
hasAutoClaimUnit: true,
|
||||
})
|
||||
|
||||
return claimUrl || fallback
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
generateRedeemCertificateCode,
|
||||
resolveRedeemVerifyCertNo,
|
||||
} from '../../repositories/redeem-certificate-repo.js'
|
||||
import { buildOrderClaimUrl } from '../claim/claim-service.js'
|
||||
import { createDefaultDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js'
|
||||
import {
|
||||
buildAfterVerifyMessage,
|
||||
@@ -129,65 +130,60 @@ test('平台侧是否已核销:status=3 或 verify_num>0', () => {
|
||||
assert.equal(isPlatformCouponVerified(null), false)
|
||||
})
|
||||
|
||||
// ---- 抖音 cert_link:券对所有订单都发,链接按基址形态决定 ----
|
||||
// ---- 抖音 cert_link:券对所有订单都发,链接统一使用 /h5/订单号 ----
|
||||
|
||||
test('抖音 cert_link:/h5 形态基址 → 一律按订单号拼(与是否命中自动发货无关)', () => {
|
||||
test('抖音 cert_link:/h5 基址 → 一律按订单号拼(与是否命中自动发货无关)', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/h5',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: '',
|
||||
}),
|
||||
'https://ks.khhao.com/h5/6930318258716179555',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:/h5 基址 + 有 token → 仍按订单号拼(H5 页认订单号)', () => {
|
||||
test('抖音 cert_link:/h5 基址带尾斜杠也只按订单号拼', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/h5/',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: 'https://ks.khhao.com/h5/token-abc',
|
||||
}),
|
||||
'https://ks.khhao.com/h5/6930318258716179555',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:token 形态基址 + 有 token → 用 token 领取链接', () => {
|
||||
test('抖音 cert_link:历史 /claim 基址也归一化为 /h5/订单号', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: 'https://ks.khhao.com/claim/token-abc',
|
||||
}),
|
||||
'https://ks.khhao.com/claim/token-abc',
|
||||
'https://ks.khhao.com/h5/6930318258716179555',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:token 形态基址 + 无 token(人工单 / 工单单)→ 回退固定兑换链接', () => {
|
||||
test('抖音 cert_link:订单号缺失时回退固定兑换链接', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: '',
|
||||
claimLinkBase: 'https://ks.khhao.com/h5',
|
||||
orderNo: '',
|
||||
}),
|
||||
'https://ks.khhao.com/redeem',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:未配 claimLinkBase 时回退固定链接(不报错)', () => {
|
||||
test('抖音 cert_link:未配 claimLinkBase 时回退全局 /h5 链接', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: '',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: 'https://ks.khhao.com/claim/token-abc',
|
||||
}),
|
||||
'https://ks.khhao.com/redeem',
|
||||
buildOrderClaimUrl('6930318258716179555'),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 内部短链服务。
|
||||
*
|
||||
* 注意(2026-07 起):
|
||||
* - 领取主路径已统一为本站 `/#/claim/{token}`,feifei 不再为 claim 生成 shortLink。
|
||||
* - 领取链接已统一为本站 `/h5/{订单号}`,feifei 不再为 claim 生成 shortLink。
|
||||
* - 本模块仅保留:历史 `/s/{code}` 解析、其它潜在内部用途。
|
||||
* - 新业务不要再依赖 shortLink 作为对外交付链接。
|
||||
*/
|
||||
@@ -46,7 +46,7 @@ export async function ensureShortLinkForTarget(
|
||||
const source = String(options.source || '').trim()
|
||||
if (source === SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM) {
|
||||
console.warn(
|
||||
'[short-link] feifei claim 已不再使用 shortLink 作为对外交付;请使用本站 /#/claim/{token} 并在 H5 拼 uid',
|
||||
'[short-link] feifei claim 已不再使用 shortLink 作为对外交付;请使用本站 /h5/{订单号} 领取页',
|
||||
)
|
||||
}
|
||||
const targetUrlHash = hashTargetUrl(normalizedTargetUrl)
|
||||
|
||||
Reference in New Issue
Block a user