From 319e08225404ca0249c09b2d0a7fe022cafffa00 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 13 Oct 2026 13:48:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(douyin-mashida):=20=E9=A2=86=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=8F=AA=E5=8F=91=E7=BB=99=E6=9C=89=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8F=91=E8=B4=A7=E9=A2=86=E5=8F=96=E5=8D=95=E5=85=83?= =?UTF-8?q?=E7=9A=84=E5=8D=95=EF=BC=8C=E4=BA=BA=E5=B7=A5=E5=8D=95=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=81=87=E5=8F=91=E8=B4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: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) --- .../douyin-mashida/claim-link.test.ts | 85 +++++++++++++++++++ .../platforms/douyin-mashida/claim-link.ts | 60 +++++++++++++ .../douyin-mashida/notify-service.ts | 48 +++++++---- 3 files changed, 176 insertions(+), 17 deletions(-) diff --git a/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts b/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts index fa95258e..905ff851 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts @@ -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, + }), + '', + ) +}) diff --git a/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts b/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts index 68dc88e8..a95804b8 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts @@ -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}`。 * 未提供的占位符(或空值)原样保留,方便运营一眼看出漏配,而不是发出一条缺链接的消息。 diff --git a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts index 6c7c039f..2b6316bc 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts @@ -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, }) }