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:
yml2213
2026-10-13 13:48:58 +08:00
parent 7e197c9413
commit 319e082254
3 changed files with 176 additions and 17 deletions
@@ -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,
})
}