fix(douyin-mashida): 领取消息只发给有自动发货领取单元的单,人工单不再假发货
根因:notifyBuyerClaimLink 对 `/h5` 形态的 claimLinkBase 走了一条捷径——只拼订单号
(`${base}/${orderNo}`)就发消息,跳过了「该订单项是否有自动发货领取单元」的校验。
领取 token 只由 affiliate_dash 执行器在建单时创建,未命中 AFFILIATE_DASH_SKU_MAPPING
回落 manual_dispatch 的人工单、以及自动准备失败的订单都没有 token,却照样收到
「您的订单已发货,请点击链接领取」(线上实测:非映射商品「套装-牛仔情诗」)。
- claim-link.ts:新增 buildDouyinMashidaBuyerClaimUrl(无自动领取单元一律返回空串 = 不发消息)
与 isDouyinMashidaH5ClaimBase(/h5 形态判定收敛到一处)
- notify-service.ts:两种基址形态统一先 resolveOrderItemClaimLink 拿 taskId 作为「会自动发货」
的证据,缺失则跳过并留痕「该订单项没有自动发货领取单元,跳过领取链接消息」
- claim-link.test.ts:补齐人工单不发消息、/h5 与 token 形态拼接、基址/订单号缺失等用例
验证:npm run check 全绿(prettier + SQL guard + eslint + tsc + 703 单测 + build)
This commit is contained in:
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildDouyinMashidaBuyerClaimUrl,
|
||||||
buildDouyinMashidaClaimMessage,
|
buildDouyinMashidaClaimMessage,
|
||||||
|
isDouyinMashidaH5ClaimBase,
|
||||||
renderDouyinMashidaMessageTemplate,
|
renderDouyinMashidaMessageTemplate,
|
||||||
resolveDouyinMashidaClaimUrl,
|
resolveDouyinMashidaClaimUrl,
|
||||||
} from './claim-link.js'
|
} from './claim-link.js'
|
||||||
@@ -102,3 +104,86 @@ test('发消息正文:无领取链接则不发(返回空串),默认模
|
|||||||
'幸运币2个 已到账,订单 6930:https://x/claim/1',
|
'幸运币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)
|
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}`。
|
* 模板占位符替换:支持 `{link}` / `{sku}` / `{orderNo}` / `{productTitle}`。
|
||||||
* 未提供的占位符(或空值)原样保留,方便运营一眼看出漏配,而不是发出一条缺链接的消息。
|
* 未提供的占位符(或空值)原样保留,方便运营一眼看出漏配,而不是发出一条缺链接的消息。
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
resolveDouyinMashidaParentOrderId,
|
resolveDouyinMashidaParentOrderId,
|
||||||
} from './order-mapper.js'
|
} from './order-mapper.js'
|
||||||
import { resolveOrderItemClaimLink } from '../../claim/claim-service.js'
|
import { resolveOrderItemClaimLink } from '../../claim/claim-service.js'
|
||||||
import { buildDouyinMashidaClaimMessage } from './claim-link.js'
|
import { buildDouyinMashidaBuyerClaimUrl, buildDouyinMashidaClaimMessage } from './claim-link.js'
|
||||||
import {
|
import {
|
||||||
getDouyinMashidaSourceConfig,
|
getDouyinMashidaSourceConfig,
|
||||||
type DouyinMashidaSourceConfig,
|
type DouyinMashidaSourceConfig,
|
||||||
@@ -235,8 +235,13 @@ async function handlePaidMessage(input: {
|
|||||||
* 买家点链接 → 输 UID → 出二维码 → 扫码绑定 → 系统比对「绑定 UID == 填的 UID」→ 发货完成
|
* 买家点链接 → 输 UID → 出二维码 → 扫码绑定 → 系统比对「绑定 UID == 填的 UID」→ 发货完成
|
||||||
* (完全复用现成的 affiliate_dash 通道)。
|
* (完全复用现成的 affiliate_dash 通道)。
|
||||||
*
|
*
|
||||||
* 只在「该订单项确实有履约任务 claim token + claimLinkBase 已配置 + 开关打开」时才发;
|
* 只在「该订单项确实有自动发货领取单元(claim token)+ claimLinkBase 已配置 + 开关打开」时才发。
|
||||||
* 拿不到链接的订单(例如未命中映射、走人工的订单)静默跳过,不打扰现有兑换流程。
|
* 领取 token 由 `affiliate_dash` 执行器在建单时创建,是「这笔单会自动发货」的唯一证据:
|
||||||
|
* 未命中映射回落人工 `manual_dispatch`、或自动准备失败的订单都拿不到 token,
|
||||||
|
* 这类订单发给买家「已发货」属于假发货,一律**静默跳过**。
|
||||||
|
*
|
||||||
|
* ⚠️ 这条约束对 `/h5` 形态的基址同样成立(历史缺陷:`/h5` 曾走捷径跳过校验,
|
||||||
|
* 结果人工单也收到了领取消息)。
|
||||||
*/
|
*/
|
||||||
async function notifyBuyerClaimLink(input: {
|
async function notifyBuyerClaimLink(input: {
|
||||||
order: OrderRow
|
order: OrderRow
|
||||||
@@ -254,22 +259,30 @@ async function notifyBuyerClaimLink(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
|
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
|
||||||
let claimUrl = ''
|
|
||||||
let taskId: number | undefined
|
|
||||||
|
|
||||||
if (claimLinkBase.endsWith('/h5') || claimLinkBase.includes('/h5/')) {
|
// 先核对履约任务:`resolveOrderItemClaimLink` 只有在该订单项存在带 token 的领取单元时才给 taskId。
|
||||||
claimUrl = `${claimLinkBase}/${orderNo}`
|
const claimLink = await resolveOrderItemClaimLink({
|
||||||
} else {
|
orderId: input.order.id,
|
||||||
const claimLink = await resolveOrderItemClaimLink({
|
orderItemId: input.orderItem.id,
|
||||||
orderId: input.order.id,
|
claimLinkBase,
|
||||||
orderItemId: input.orderItem.id,
|
})
|
||||||
claimLinkBase: input.config.claimLinkBase,
|
|
||||||
})
|
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
|
||||||
claimUrl = claimLink.claimUrl
|
claimLinkBase,
|
||||||
taskId = claimLink.taskId
|
orderNo,
|
||||||
}
|
hasAutoClaimUnit: Boolean(claimLink.taskId),
|
||||||
|
tokenClaimUrl: claimLink.claimUrl,
|
||||||
|
})
|
||||||
|
|
||||||
if (!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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +303,8 @@ async function notifyBuyerClaimLink(input: {
|
|||||||
orderId: input.order.id,
|
orderId: input.order.id,
|
||||||
orderItemId: input.orderItem.id,
|
orderItemId: input.orderItem.id,
|
||||||
orderNo,
|
orderNo,
|
||||||
taskId,
|
taskId: claimLink.taskId,
|
||||||
|
claimUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user