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:
@@ -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])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user