From 4b081d88c5ad90d37fb8ea0632efec7f771c99f7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 14 Oct 2026 12:57:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(douyin-mashida):=20=E6=8A=96=E9=9F=B3?= =?UTF-8?q?=E6=8C=89=20SKU=20=E4=B8=89=E5=B1=82=E5=88=86=E7=B1=BB=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=EF=BC=88=E5=AF=B9=E9=BD=90=2091/=E5=BF=AB=E6=89=8B?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sku_code 恒为内部 SKU(不再被英文 sku 覆写);英文 sku / 引流标题分别落 snapshot - 命中映射 → affiliate_dash;未命中不再兜底 manual_dispatch(无执行载体 = 待人工,不建任务) - 抖音映射支持「常量表 + 后台 skuMapping 增量」,新增商品不必发版 - 载体唯一守卫:命中自动发货的订单项不再建工单 - 券 cert_link 与命中解耦(/h5 一律按订单号拼);券写入工单 material_json.douyinMashidaVoucher - 新增/修正 16 例单测;后端全量 717 例 / 0 失败 --- .../product-resolution-service.test.ts | 53 +++- .../fulfillment/product-resolution-service.ts | 94 +++++-- .../services/redeem/certificate-service.ts | 257 ++++++++++++++++-- .../services/redeem/redeem-service.test.ts | 110 ++++++++ .../sync-worker-orders-service.test.ts | 34 +++ .../sync-worker-orders-service.ts | 132 +++++++-- 6 files changed, 599 insertions(+), 81 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts index 2013ad24..5ffdfd5e 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index 0989f8c9..1d067eda 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -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( diff --git a/apps/backend/src/services/redeem/certificate-service.ts b/apps/backend/src/services/redeem/certificate-service.ts index 00782afd..3b0e9eec 100644 --- a/apps/backend/src/services/redeem/certificate-service.ts +++ b/apps/backend/src/services/redeem/certificate-service.ts @@ -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 { + 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` 把券码 + 兑换/领取链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */ diff --git a/apps/backend/src/services/redeem/redeem-service.test.ts b/apps/backend/src/services/redeem/redeem-service.test.ts index 997d7442..e5f7f3de 100644 --- a/apps/backend/src/services/redeem/redeem-service.test.ts +++ b/apps/backend/src/services/redeem/redeem-service.test.ts @@ -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> + 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]) +}) diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts new file mode 100644 index 00000000..4368229d --- /dev/null +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts index 6188113d..34b1de51 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts @@ -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> = [] 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 { + 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,