feat(douyin-mashida): 抖音按 SKU 三层分类接入(对齐 91/快手)

- sku_code 恒为内部 SKU(不再被英文 sku 覆写);英文 sku / 引流标题分别落 snapshot
- 命中映射 → affiliate_dash;未命中不再兜底 manual_dispatch(无执行载体 = 待人工,不建任务)
- 抖音映射支持「常量表 + 后台 skuMapping 增量」,新增商品不必发版
- 载体唯一守卫:命中自动发货的订单项不再建工单
- 券 cert_link 与命中解耦(/h5 一律按订单号拼);券写入工单 material_json.douyinMashidaVoucher
- 新增/修正 16 例单测;后端全量 717 例 / 0 失败
This commit is contained in:
yml2213
2026-10-14 13:00:49 +08:00
parent e8fbb28cf4
commit 4b081d88c5
6 changed files with 599 additions and 81 deletions
@@ -142,31 +142,46 @@ test('抖音订单:内部 SKU 命中映射表 → 复用 affiliate_dash 通道
assert.equal(item.isConfigured, true)
assert.equal(selectedExecutorKeyOf(item), 'affiliate_dash')
// 走 affiliate_dash 通道后与 91 同口径:skuCode 是英文 sku、skuName 是中文商品名
assert.equal(item.skuCode, 'lucky_coin_x2')
// 抖音的 sku_code 是**分类键(内部 SKU)**:命中映射后也不再被英文 sku 覆写
assert.equal(item.skuCode, '幸运币2个')
assert.equal(item.skuName, '幸运币2个')
// 快照里的 affiliateDash 就是 affiliate_dash 执行器/规划器要用的映射结果
// 英文 sku 只落在快照里(affiliate_dash 执行器/规划器要用它)
assert.deepEqual(item.snapshot.affiliateDash, {
matchMode: 'affiliate_dash_sku_mapping',
productNo: '幸运币2个',
sku: 'lucky_coin_x2',
skuName: '幸运币2个',
})
// 引流长标题另存 productTitle(不再占 skuName)
assert.equal(item.snapshot.productTitle, DOUYIN_SKIN_TITLE)
// 分类结果(发单层据此维护「一个单子只有一个执行载体」)
assert.deepEqual(item.snapshot.classification, {
channel: 'affiliate_dash',
canonicalSku: '幸运币2个',
matchMode: 'affiliate_dash_sku_mapping',
})
assert.equal(resolveProductImageUrl('幸运币2个'), '/images/products/lucky_coin_x2.webp')
})
test('抖音订单:内部 SKU 未命中映射表 → 回退 manual_dispatch', async () => {
test('抖音订单:内部 SKU 未命中映射表 → 无执行载体(既不建任务也不写死人工)', async () => {
const item = await resolveOrderItemForFulfillment({
provider: 'douyin_mashida',
platform: 'doudian',
item: buildDouyinItem('不存在的皮肤'),
})
assert.equal(selectedExecutorKeyOf(item), 'manual_dispatch')
// 错误流程(已废弃):未命中写死 manual_dispatch → 强行建人工任务、抢占工单判定。
// 正确流程:未命中不选执行器 → 由发单层先判接单模板(命中 → 工单),都不中即「待人工」。
assert.equal(selectedExecutorKeyOf(item), '')
assert.equal(item.isConfigured, false)
assert.equal(item.snapshot.affiliateDash, null)
// 引流长标题作为 skuName,内部 SKU 作为 skuCode
assert.equal(item.skuCode, '不存在的皮肤')
assert.equal(item.skuName, DOUYIN_SKIN_TITLE)
assert.equal(item.skuName, '不存在的皮肤')
assert.deepEqual(item.snapshot.classification, {
channel: 'unmatched',
canonicalSku: '不存在的皮肤',
matchMode: '',
})
})
test('抖音订单:平台闸门只认 douyin_mashida + doudian,其它组合仍不配置', async () => {
@@ -194,7 +209,27 @@ test('抖音订单:不触发 affiliate-dash 平台的 sku 透传(严格按
item: buildDouyinItem('lucky_coin_x2'),
})
// 英文 sku 不是内部 SKU,抖音侧不做透传匹配,走人工
assert.equal(selectedExecutorKeyOf(item), 'manual_dispatch')
// 英文 sku 不是内部 SKU,抖音侧不做透传匹配 → 无执行载体
assert.equal(selectedExecutorKeyOf(item), '')
assert.equal(item.isConfigured, false)
assert.equal(item.snapshot.affiliateDash, null)
})
test('91 订单:不写抖音分类字段(历史快照形状与路由行为不变)', async () => {
const item = await resolveOrderItemForFulfillment({
provider: '91kaquan',
platform: 'kuaishou',
item: {
itemId: '幸运币2个',
externalItemId: '幸运币2个',
externalSkuCode: '幸运币2个',
externalSkuName: '幸运币2个',
skuCode: '幸运币2个',
skuName: '幸运币2个',
quantity: 1,
},
})
assert.equal('classification' in item.snapshot, false)
assert.equal('productTitle' in item.snapshot, false)
})
@@ -1,14 +1,12 @@
import {
getFulfillmentRoutingConfig,
normalizeInternalSkuMatchKey,
resolveFulfillmentRoute,
type FulfillmentRouteResult,
} from './routing-config-service.js'
import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js'
import { listAllAffiliateDashProducts } from '../platforms/affiliate-dash/product-service.js'
import {
AFFILIATE_DASH_SKU_MAPPING,
mergeAffiliateDashSkuMapping,
} from '../platforms/affiliate-dash/sku-mapping.js'
import { mergeAffiliateDashSkuMapping } from '../platforms/affiliate-dash/sku-mapping.js'
import type { AffiliateDashSkuMapping } from '../../types/runtime-config.js'
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
import { normalizeProductMatchName } from '../order/product-match-utils.js'
@@ -44,6 +42,20 @@ export type AffiliateDashSkuMatch = {
matchMode: 'affiliate_dash_sku_mapping' | 'affiliate_dash_sku_passthrough'
}
/**
* 抖音按 SKU 分类的结果(写进 `order_items.item_snapshot_json.classification`)。
*
* 只表达**履约侧**的判定:`affiliate_dash` = 这个单子的执行载体已定为自动发货;
* `unmatched` = 没命中自动发货(后续由发单层决定是否派打手,都不中就是人工)。
* 发单层据此维护「一个单子只有一个执行载体」的不变式。
*/
export type FulfillmentItemClassification = {
channel: 'affiliate_dash' | 'unmatched'
/** 分类键:抖音取内部 SKU(canonical SKU) */
canonicalSku: string
matchMode: string
}
type FulfillmentItemCandidate = {
externalItemId: string
externalSkuCode: string
@@ -53,6 +65,7 @@ type FulfillmentItemCandidate = {
affiliateDashMatch: AffiliateDashSkuMatch | null
fulfillmentRoute: FulfillmentRouteResult | null
isConfigured: boolean
classification?: FulfillmentItemClassification | null
}
export type ResolvedFulfillmentItem = FulfillmentItem & {
@@ -83,19 +96,25 @@ export async function resolveOrderItemForFulfillment({
externalSkuNameNormalized,
affiliateDashMatch,
fulfillmentRoute,
classification,
} = candidate
const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || ''
// 抖音:`sku_code` 是**分类键**(内部 SKU),必须稳定,不能被英文 sku 覆写;
// 91 保持历史行为(命中映射后用英文 sku 覆盖),避免影响线上 32.9 万单。
const isDouyinMashida = isDouyinMashidaOrder(provider, platform)
const selectedAffiliateDash =
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH && !isDouyinMashida
const resolvedSkuCode = pickFirstNonEmpty([
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH ? affiliateDashMatch?.sku : '',
selectedAffiliateDash ? affiliateDashMatch?.sku : '',
externalSkuCode,
externalItemId,
])
// 运营侧展示用中文商品名;英文 sku 只写在 skuCode。
// 抖音:展示用内部 SKU(商品标识),引流长标题另存 snapshot.productTitle。
const resolvedSkuName = pickFirstNonEmpty([
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
? affiliateDashMatch?.productNo || affiliateDashMatch?.skuName
: '',
selectedAffiliateDash ? affiliateDashMatch?.productNo || affiliateDashMatch?.skuName : '',
isDouyinMashida ? externalSkuCode : '',
item.skuName,
externalSkuName,
resolvedSkuCode,
@@ -121,6 +140,12 @@ export async function resolveOrderItemForFulfillment({
}
: null,
isConfigured: candidate.isConfigured,
// 抖音:引流长标题单独存档(skuName 已改存内部 SKU,标题不能丢)
...(isDouyinMashida
? { productTitle: pickFirstNonEmpty([item.skuName, externalSkuName]) }
: {}),
// 抖音:分类结果,供发单层维护「一个单子只有一个执行载体」与后台诊断
...(classification ? { classification } : {}),
}
return {
@@ -243,14 +268,20 @@ async function resolveConfiguredItemCandidate({
}
/**
* 抖音码速达(抖店)订单分流。
* 抖音码速达(抖店)订单分流 —— **按 SKU 分类**。
*
* 平台字段口径:`sku_order_list[].code` = **内部 SKU**(如「幸运币2个」,才是真正的商品),
* `product_name` = 引流长标题(如「【测试专用】和平精英皮肤小店入口…」)。
* 因此分流都按内部 SKU 走:
* - 内部 SKU 命中权威表 `AFFILIATE_DASH_SKU_MAPPING` → 复用现成的 `affiliate_dash` 通道
* (买家输 UID → 二维码 → 扫码绑定 → 比对 UID → 发货完成);
* - 未命中 → 显式映射拿不到通道,按现有语义回退 `manual_dispatch`(接单平台/人工)。
*
* 判定顺序(与 91/快手 同一套框架):
* 1. 内部 SKU 命中映射(常量表 + 后台增量)→ 指定 `affiliate_dash`(皮肤/道具自动发货);
* 2. 未命中 → **不写死兜底**,交给 `resolveFulfillmentRoute`:商品路由规则 → 全局优先级
* → `unmatchedExecutorKey`(生产配置为空串 → 不选执行器 = 不建履约任务);
* 3. 都不命中 → 分类为 `unmatched`:由发单层决定是否派打手(命中接单模板 → 建工单),
* 两者都不中就是人工(订单进「待人工」队列,判据 = 无任务且无工单)。
*
* ⚠️ 历史实现(错误流程,已废弃):未命中时**写死** `manual_dispatch`,
* 导致未命中单被强行建人工任务(91 是「不建任务」),且抢占了工单层的判定。
*/
function resolveDouyinMashidaItemCandidate({
item,
@@ -270,7 +301,6 @@ function resolveDouyinMashidaItemCandidate({
// 内部 SKU(平台 code,如「幸运币2个」)→ affiliate-dash 英文 SKU(与商品图同一张权威表)。
// 皮肤/礼包类商品的交付**直接复用现成的 affiliate_dash 通道**:
// 买家输 UID → 出二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」→ 发货完成。
// 不另建执行器、不另做一套页面(与旧快手皮肤自动发货完全一致)。
const dashSku = matchDouyinMashidaAffiliateDashSku(internalSku)
const affiliateDashMatch: AffiliateDashSkuMatch | null = dashSku
? {
@@ -283,24 +313,22 @@ function resolveDouyinMashidaItemCandidate({
const fulfillmentRoute = resolveFulfillmentRoute({
productName: productTitle,
internalSku,
// 命中 → 自动发货;未命中 → **显式**回落人工(接单平台)。
// 不依赖全局 `unmatchedExecutorKey`:线上/联调环境该值可能是空串(dev 库就是空串),
// 那样未命中的抖音订单会既不建任务、也不进人工,等于静默漏单。
preferredExecutorKey: affiliateDashMatch
? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
// 命中映射 = 运营显式意图 → 指定 affiliate_dash;
// 未命中**不设 preferred**,让规则/优先级决定。
preferredExecutorKey: affiliateDashMatch ? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH : '',
candidates: [
{
executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
available: Boolean(affiliateDashMatch),
reason: affiliateDashMatch ? '' : '内部 SKU 未命中商品映射',
},
{
executorKey: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
available: true,
reason: '接单平台/人工兜底',
},
],
// ⚠️ 抖音**不允许未命中兜底 `manual_dispatch`**(覆盖配置里的 unmatchedExecutorKey):
// 履约判定发生在发单判定**之前**,若这里先建人工任务,命中接单模板的单就会出现
// 「人工任务 + 工单」双载体,破坏「一个单子只有一个执行载体」的不变式。
// 抖音的「人工」= 无执行载体(无任务无工单)→ 订单进「待人工」队列;
// 运营仍可用商品路由规则显式把某个 SKU 指到人工(规则优先于本兜底设置)。
config: { ...getFulfillmentRoutingConfig(), unmatchedExecutorKey: '' },
})
return {
@@ -313,19 +341,29 @@ function resolveDouyinMashidaItemCandidate({
affiliateDashMatch,
fulfillmentRoute,
isConfigured: Boolean(fulfillmentRoute.selectedExecutorKey),
classification: {
channel: affiliateDashMatch ? 'affiliate_dash' : 'unmatched',
canonicalSku: internalSku,
matchMode: affiliateDashMatch?.matchMode || '',
},
}
}
/**
* 内部 SKU → affiliate-dash 英文 sku:只查权威表(与商品图 `resolveProductImageUrl` 同一套来源),
* 保证「能路由到 affiliate-dash 自动发货」与「能取到商品图」永远一致。
* 内部 SKU → affiliate-dash 英文 sku。
*
* 映射来源 = **权威常量表 + 后台增量覆盖**(`affiliate-dash` 配置面板的 `skuMapping`),
* 与 91 分支同一套来源:新增抖音商品在后台加一行即可,不必改代码发版。
*/
export function matchDouyinMashidaAffiliateDashSku(internalSku: unknown): string {
const raw = String(internalSku ?? '').trim()
if (!raw) {
return ''
}
return matchAffiliateDashSku(AFFILIATE_DASH_SKU_MAPPING, raw)
return matchAffiliateDashSku(
mergeAffiliateDashSkuMapping(getAffiliateDashConfig().skuMapping),
raw,
)
}
async function resolveAffiliateDashSkuByProductNo(
@@ -11,7 +11,13 @@ import {
resolveRedeemVerifyCertNo,
type RedeemCertificateRow,
} from '../../repositories/redeem-certificate-repo.js'
import {
createWorkOrderEvent,
getWorkOrderByOrderItemId,
updateWorkOrder,
} from '../../repositories/worker-platform/index.js'
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'
@@ -24,9 +30,14 @@ import {
syncDouyinMashidaCoupons,
verifyDouyinMashidaCoupon,
} from '../platforms/douyin-mashida/gateway-client.js'
import {
buildDouyinMashidaBuyerClaimUrl,
isDouyinMashidaH5ClaimBase,
} from '../platforms/douyin-mashida/claim-link.js'
import {
DOUYIN_MASHIDA_PROVIDER,
extractDouyinMashidaSubOrderId,
isDouyinMashidaProvider,
} from '../platforms/douyin-mashida/order-mapper.js'
import {
DEFAULT_AFTER_VERIFY_MESSAGE,
@@ -116,23 +127,188 @@ export async function ensureCertificateForOrderItem(input: {
}
if (certificate.sync_status === 'success' || certificate.status === 'invalid') {
await attachDouyinMashidaCertificateToWorkOrder({
order: input.order,
orderItem: input.orderItem,
certificate,
})
return certificate
}
return syncCertificateToPlatform(certificate, config, certLink)
const syncedCertificate = await syncCertificateToPlatform(certificate, config, certLink)
await attachDouyinMashidaCertificateToWorkOrder({
order: input.order,
orderItem: input.orderItem,
certificate: syncedCertificate,
})
return syncedCertificate
}
/** 工单 material_json 里承载抖音券的键(与快手 `kuaishouIndustryVoucher` 同位)。 */
export const DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY = 'douyinMashidaVoucher'
export type DouyinMashidaWorkOrderVoucher = {
certNo: string
platformCertNo: string
certLink: string
status: string
syncStatus: string
grantTime: string
}
/** 券行 → 工单侧券快照(纯函数,回填与建单两处共用,避免口径漂移)。 */
export function buildDouyinMashidaWorkOrderVoucher(
certificate: Pick<
RedeemCertificateRow,
'cert_no' | 'platform_cert_no' | 'cert_link' | 'status' | 'sync_status' | 'grant_time'
>,
): DouyinMashidaWorkOrderVoucher {
return {
certNo: String(certificate.cert_no || '').trim(),
platformCertNo: String(certificate.platform_cert_no || '').trim(),
certLink: String(certificate.cert_link || '').trim(),
status: String(certificate.status || '').trim(),
syncStatus: String(certificate.sync_status || '').trim(),
grantTime: String(certificate.grant_time || '').trim(),
}
}
/**
* 把抖音券并入工单 `material_json`(对齐快手把电子凭证挂工单的做法)。
*
* 打手拿到的工单必须知道「交付什么券」,因此券一旦生成/同步就把它的快照写到工单上:
* `material_json.douyinMashidaVoucher = [{ certNo, platformCertNo, certLink, status, syncStatus, grantTime }]`。
*
* 幂等:按 `certNo` 合并,同券内容不变时返回 `changed: false`(调用方据此跳过写库/写事件)。
* 纯函数,便于单测。
*/
export function mergeDouyinMashidaVoucherIntoMaterial(
material: unknown,
voucher: DouyinMashidaWorkOrderVoucher,
): { material: JsonObject; changed: boolean } {
const source = isPlainObject(material) ? { ...material } : {}
const existingRows = Array.isArray(source[DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY])
? (source[DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY] as unknown[])
: []
const existingVouchers = existingRows.filter(isPlainObject)
const current = existingVouchers.find((row) => String(row.certNo || '').trim() === voucher.certNo)
if (current && isSameDouyinMashidaVoucher(current, voucher)) {
return { material: source, changed: false }
}
source[DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY] = [
...existingVouchers.filter((row) => String(row.certNo || '').trim() !== voucher.certNo),
{ ...voucher },
]
return { material: source, changed: true }
}
function isSameDouyinMashidaVoucher(
current: JsonObject,
voucher: DouyinMashidaWorkOrderVoucher,
): boolean {
return (
String(current.platformCertNo || '') === voucher.platformCertNo &&
String(current.certLink || '') === voucher.certLink &&
String(current.status || '') === voucher.status &&
String(current.syncStatus || '') === voucher.syncStatus
)
}
/**
* 券生成/同步后回填工单 `material_json`(仅抖音;该订单项没有工单时静默跳过 = 正常情况)。
*
* best-effort:任何失败只记 warn,绝不影响发券主流程。
*/
async function attachDouyinMashidaCertificateToWorkOrder(input: {
order: OrderRow
orderItem: OrderItemRow
certificate: RedeemCertificateRow
}): Promise<void> {
if (!isDouyinMashidaProvider(input.order.provider)) {
return
}
try {
const workOrder = await getWorkOrderByOrderItemId(input.orderItem.id)
if (!workOrder) {
// 命中自动发货的订单项没有工单(载体唯一),属正常路径
return
}
const voucher = buildDouyinMashidaWorkOrderVoucher(input.certificate)
if (!voucher.certNo) {
return
}
const merged = mergeDouyinMashidaVoucherIntoMaterial(workOrder.material_json, voucher)
if (!merged.changed) {
return
}
const now = nowIso()
const updated = await updateWorkOrder(workOrder.id, {
material_json: JSON.stringify(merged.material),
updated_at: now,
})
if (!updated) {
return
}
await createWorkOrderEvent({
workOrderId: Number(workOrder.id),
actorType: 'system',
actorId: DOUYIN_MASHIDA_PROVIDER,
eventType: 'douyin_voucher_backfilled',
fromStatus: String(workOrder.status || ''),
toStatus: String(updated.status || workOrder.status || ''),
payloadJson: JSON.stringify({
certNo: voucher.certNo,
platformCertNo: voucher.platformCertNo,
syncStatus: voucher.syncStatus,
}),
now,
})
logIntegration('[douyin-mashida/certificate]', '券信息已回填工单 material_json', {
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no,
orderItemId: input.orderItem.id,
certNo: voucher.certNo,
})
} catch (error) {
logIntegration(
'[douyin-mashida/certificate]',
'券信息回填工单失败(不影响发券)',
{
orderId: input.order.id,
orderItemId: input.orderItem.id,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
}
}
/**
* 解析本次同步要交给平台的 `cert_link`。
*
* 优先级(**只对新单生效**:已同步成功的券不会再改动):
* 1. 该订单项已有履约任务 claim token,且配置了 `claimLinkBase`
* → `claimLinkBase/token`(买家领链接);
* 2. 其它情况 → `config.certLink`(固定兑换链接,历史行为完全不变)。
* 判据:券对**所有订单**都要发(业务口径),链接按基址形态决定:
* - `/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。
*
* ⚠️ 只对新单生效:已同步成功的券不会被改写。
*/
export async function resolveDouyinMashidaCertLink(input: {
order: OrderRow
@@ -147,36 +323,65 @@ export async function resolveDouyinMashidaCertLink(input: {
return fallback
}
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
if (claimLinkBase.endsWith('/h5') || claimLinkBase.includes('/h5/')) {
return `${claimLinkBase}/${orderNo}`
}
const claimLink = await resolveOrderItemClaimLink({
orderId: input.order.id,
orderItemId: input.orderItem.id,
claimLinkBase: input.config.claimLinkBase,
})
if (!claimLink.claimUrl) {
// 有履约任务却拿不到 token = 任务没被正确准备(配置/执行器问题),值得留痕;
// 连任务都还没有属于建单顺序上的正常瞬时状态,静默回退即可。
if (claimLink.taskCount > 0) {
logIntegration(
'[douyin-mashida/certificate]',
'该订单项有履约任务但尚无 claim token,cert_link 回退固定兑换链接',
{
orderId: input.order.id,
orderItemId: input.orderItem.id,
taskCount: claimLink.taskCount,
},
{ level: 'warn' },
)
}
// 有履约任务却拿不到 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,
})
}
/**
* `cert_link` 的**纯函数**判定(便于单测)。
*
* 业务口径:**券对所有订单都要发**(与是否命中自动发货无关),因此链接不能退化成裸基址:
* - `/h5` 形态基址 → 一律按**订单号**拼(买家直接落到自己订单的状态页);
* - 其它形态基址 → 有 token 才给领取链接,否则回退固定 `certLink`。
*/
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
}
return claimLink.claimUrl
const orderNoLink = isDouyinMashidaH5ClaimBase(base)
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
claimLinkBase: base,
orderNo: input.orderNo,
hasAutoClaimUnit: orderNoLink || Boolean(String(input.tokenClaimUrl ?? '').trim()),
tokenClaimUrl: input.tokenClaimUrl,
})
return claimUrl || fallback
}
/** 调 `coupons.syncV2` 把券码 + 兑换/领取链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */
@@ -11,7 +11,9 @@ import {
buildAfterVerifyMessage,
formatGrantTime,
isPlatformCouponVerified,
mergeDouyinMashidaVoucherIntoMaterial,
pickPlatformCouponRow,
resolveDouyinMashidaCertLinkFromClaim,
} from './certificate-service.js'
import { maskCertNo, normalizeOrderNo } from './redeem-service.js'
@@ -126,3 +128,111 @@ test('平台侧是否已核销:status=3 或 verify_num>0', () => {
assert.equal(isPlatformCouponVerified({ status: 2, verify_num: 0 }), false)
assert.equal(isPlatformCouponVerified(null), false)
})
// ---- 抖音 cert_link:券对所有订单都发,链接按基址形态决定 ----
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 页认订单号)', () => {
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 领取链接', () => {
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',
)
})
test('抖音 cert_link:token 形态基址 + 无 token(人工单 / 工单单)→ 回退固定兑换链接', () => {
assert.equal(
resolveDouyinMashidaCertLinkFromClaim({
certLink: 'https://ks.khhao.com/redeem',
claimLinkBase: 'https://ks.khhao.com/claim',
orderNo: '6930318258716179555',
tokenClaimUrl: '',
}),
'https://ks.khhao.com/redeem',
)
})
test('抖音 cert_link:未配 claimLinkBase 时回退固定链接(不报错)', () => {
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',
)
})
// ---- 抖音券回填工单 material_json(对齐快手把电子凭证挂工单) ----
const WORK_ORDER_VOUCHER = {
certNo: 'DYC1234567890ABCD',
platformCertNo: '',
certLink: 'https://ks.khhao.com/h5/6930318258716179555',
status: 'synced',
syncStatus: 'success',
grantTime: '2026-10-14 12:00:00',
}
test('抖音券回填工单:material 为空时新建 douyinMashidaVoucher 数组(保留原有 source)', () => {
const merged = mergeDouyinMashidaVoucherIntoMaterial(
{ source: { skuCode: '套装-牛仔情诗' } },
WORK_ORDER_VOUCHER,
)
assert.equal(merged.changed, true)
assert.deepEqual(merged.material.source, { skuCode: '套装-牛仔情诗' })
assert.deepEqual(merged.material.douyinMashidaVoucher, [WORK_ORDER_VOUCHER])
})
test('抖音券回填工单:同券内容不变 → changed=false(幂等,不重复写库/写事件)', () => {
const first = mergeDouyinMashidaVoucherIntoMaterial({}, WORK_ORDER_VOUCHER)
const second = mergeDouyinMashidaVoucherIntoMaterial(first.material, WORK_ORDER_VOUCHER)
assert.equal(second.changed, false)
assert.deepEqual(second.material.douyinMashidaVoucher, [WORK_ORDER_VOUCHER])
})
test('抖音券回填工单:平台券码变化 → 就地更新该券(不新增重复行)', () => {
const first = mergeDouyinMashidaVoucherIntoMaterial({}, WORK_ORDER_VOUCHER)
const merged = mergeDouyinMashidaVoucherIntoMaterial(first.material, {
...WORK_ORDER_VOUCHER,
platformCertNo: 'PLATFORM-9876',
})
assert.equal(merged.changed, true)
const rows = merged.material.douyinMashidaVoucher as Array<Record<string, unknown>>
assert.equal(rows.length, 1)
assert.equal(rows[0]?.platformCertNo, 'PLATFORM-9876')
})
test('抖音券回填工单:material 是脏数据(字符串)时不抛错', () => {
const merged = mergeDouyinMashidaVoucherIntoMaterial('not-an-object', WORK_ORDER_VOUCHER)
assert.equal(merged.changed, true)
assert.deepEqual(merged.material.douyinMashidaVoucher, [WORK_ORDER_VOUCHER])
})
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { isAffiliateDashCarrierSnapshot } from './sync-worker-orders-service.js'
// 载体唯一不变式:一个单子只有一个执行载体。
// 命中 affiliate_dash 自动发货的订单项(抖音按 SKU 分类后在 snapshot 标注)不再建工单。
test('载体唯一守卫:分类为 affiliate_dash 的订单项不建工单', () => {
assert.equal(
isAffiliateDashCarrierSnapshot({ classification: { channel: 'affiliate_dash' } }),
true,
)
})
test('载体唯一守卫:多余的空白/大小写仍然命中(保守判定)', () => {
assert.equal(
isAffiliateDashCarrierSnapshot({ classification: { channel: ' affiliate_dash ' } }),
true,
)
})
test('载体唯一守卫:未命中分类 / 无字段 / 脏数据一律放行(不误伤发单)', () => {
assert.equal(isAffiliateDashCarrierSnapshot({ classification: { channel: 'unmatched' } }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: { channel: '' } }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: {} }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: 'affiliate_dash' }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: null }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: ['affiliate_dash'] }), false)
assert.equal(isAffiliateDashCarrierSnapshot({}), false)
assert.equal(isAffiliateDashCarrierSnapshot(null), false)
assert.equal(isAffiliateDashCarrierSnapshot(undefined), false)
assert.equal(isAffiliateDashCarrierSnapshot('affiliate_dash'), false)
})
@@ -16,6 +16,7 @@ import {
listWorkProductRules,
type WorkOrderRow,
} from '../../repositories/worker-platform/index.js'
import { findRedeemCertificateByOrder } from '../../repositories/redeem-certificate-repo.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { generateWorkOrderNo, randomId } from '../../utils/random.js'
@@ -23,6 +24,16 @@ import { nowIso } from '../../utils/time.js'
import { logDebug, logInfo, logWarn } from '../../utils/logger.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { createWorkOrderMaterialAdminNotification } from '../admin/admin-notification-service.js'
import {
DOUYIN_MASHIDA_PROVIDER,
extractDouyinMashidaSubOrderId,
isDouyinMashidaProvider,
} from '../platforms/douyin-mashida/order-mapper.js'
import {
buildDouyinMashidaWorkOrderVoucher,
mergeDouyinMashidaVoucherIntoMaterial,
type DouyinMashidaWorkOrderVoucher,
} from '../redeem/certificate-service.js'
import {
mapWorkOrderAdmin,
normalizeRequirementFields,
@@ -49,6 +60,19 @@ export async function syncWorkerOrdersForSourceOrder(
const decisions: Array<Record<string, unknown>> = []
for (const item of orderItems) {
// 载体唯一不变式(一个单子只有一个执行载体):
// 命中 affiliate_dash 自动发货的订单项(抖音在 snapshot.classification 标注)不再建工单。
if (isAffiliateDashCarrierSnapshot(safeParseJson(item.item_snapshot_json))) {
skipped.push({ orderItemId: Number(item.id), reason: 'affiliate_dash_carrier_taken' })
decisions.push(
buildWorkerConsumerDecision(item, {
result: 'skipped',
reason: 'affiliate_dash_carrier_taken',
}),
)
continue
}
const match = resolveMatchingProductRuleDecision(order, item, rules, mappings, {
legacyFallbackEnabled: productMatchConfig.legacyFallbackEnabled,
})
@@ -182,6 +206,31 @@ export async function syncWorkerOrdersForSourceOrder(
: resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, sharingTotalQuantity)
const now = nowIso()
const materialComplete = fields.every((field) => !field.required)
// 工单素材 = source 摘要 +(抖音)该单已有的券快照:打手要能看到「交付什么券」。
// 券通常在发券后回填(certificate-service),这里覆盖「运营补模板后重新匹配」的建单路径。
let material: JsonObject = {
source: {
orderId: Number(order.id),
orderItemId: Number(item.id),
provider: order.provider,
platform: order.platform,
shopId: order.shop_id,
shopName: order.shop_name,
skuCode: item.sku_code,
skuName: item.sku_name,
skuQuantity,
quantity: Number(item.quantity || 1),
orderAmountFen: Number(order.total_amount || 0),
currency: order.currency || 'CNY',
syncSource: options.source || 'source_order',
...options.sourceMetadata,
},
}
const voucher = await resolveDouyinMashidaWorkOrderVoucher(order, item)
if (voucher) {
material = mergeDouyinMashidaVoucherIntoMaterial(material, voucher).material
}
const workOrder = await createWorkOrder({
workOrderNo: generateWorkOrderNo(),
productRuleId: Number(rule.id),
@@ -203,24 +252,7 @@ export async function syncWorkerOrdersForSourceOrder(
acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode),
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours),
giftCooldownMinutes: normalizeWorkOrderGiftCooldownMinutes(rule.gift_cooldown_minutes),
materialJson: JSON.stringify({
source: {
orderId: Number(order.id),
orderItemId: Number(item.id),
provider: order.provider,
platform: order.platform,
shopId: order.shop_id,
shopName: order.shop_name,
skuCode: item.sku_code,
skuName: item.sku_name,
skuQuantity,
quantity: Number(item.quantity || 1),
orderAmountFen: Number(order.total_amount || 0),
currency: order.currency || 'CNY',
syncSource: options.source || 'source_order',
...options.sourceMetadata,
},
}),
materialJson: JSON.stringify(material),
requirementJson: JSON.stringify({ fields }),
now,
})
@@ -307,6 +339,70 @@ export async function syncWorkerOrdersForSourceOrder(
}
}
/**
* 抖音单在建工单时,把该订单项**已生成**的券快照取出来(用于写进工单素材)。
*
* 正常顺序是「先建工单 → 再发券」,券由 `certificate-service` 回填;
* 但运营补完接单模板后「重新匹配」会**先有券、后建工单**,这条路径靠本函数补上。
* 非抖音订单、查不到券、查询失败一律返回 null(不阻塞发单)。
*/
async function resolveDouyinMashidaWorkOrderVoucher(
order: OrderRow,
item: OrderItemRow,
): Promise<DouyinMashidaWorkOrderVoucher | null> {
if (!isDouyinMashidaProvider(order.provider)) {
return null
}
const receiptOrderId = String(
extractDouyinMashidaSubOrderId(item.item_snapshot_json) || order.platform_order_id || '',
).trim()
if (!receiptOrderId) {
return null
}
try {
const certificate = await findRedeemCertificateByOrder({
provider: DOUYIN_MASHIDA_PROVIDER,
orderId: receiptOrderId,
})
if (!certificate) {
return null
}
const voucher = buildDouyinMashidaWorkOrderVoucher(certificate)
return voucher.certNo ? voucher : null
} catch (error) {
logWarn('[worker-order-consumer]', '读取抖音券失败(工单素材不含券,不阻塞建单)', {
orderId: order.id,
orderItemId: item.id,
receiptOrderId,
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
/**
* 该订单项的执行载体是否**已被 affiliate_dash 自动发货占用**。
*
* 依据:抖音按 SKU 分类后写入的 `snapshot.classification.channel === 'affiliate_dash'`
* (见 `fulfillment/product-resolution-service.ts`)。
* 一个单子只能有一个执行载体:自动发货与派打手互斥,命中前者时不再建工单。
*/
export function isAffiliateDashCarrierSnapshot(snapshot: unknown): boolean {
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
return false
}
const classification = (snapshot as { classification?: unknown }).classification
if (!classification || typeof classification !== 'object' || Array.isArray(classification)) {
return false
}
return String((classification as { channel?: unknown }).channel || '').trim() === 'affiliate_dash'
}
function buildWorkerConsumerDecision(
item: OrderItemRow,
detail: Record<string, unknown>,