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
@@ -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])
})