From 6850bc64ff28638a00e2e3ca3b411f64075957ff Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 15 Oct 2026 13:22:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(work-order):=20=E5=8F=91=E5=B8=83=E5=B9=B6?= =?UTF-8?q?=E6=A0=B8=E9=94=80=E6=94=AF=E6=8C=81=E6=8A=96=E9=9F=B3=E5=88=B8?= =?UTF-8?q?=EF=BC=88=E6=8C=89=E6=9D=A5=E6=BA=90=E5=88=86=E6=B5=81=EF=BC=8C?= =?UTF-8?q?=E6=8A=96=E9=9F=B3=E8=B5=B0=20DYC=20=E5=88=B8=E6=A0=B8=E9=94=80?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../publish-douyin-voucher-service.test.ts | 226 ++++++++++++++++++ .../publish-douyin-voucher-service.ts | 213 +++++++++++++++++ .../publish-voucher-service.test.ts | 90 +++++++ .../publish-voucher-service.ts | 59 +++++ apps/frontend/src/utils/voucher-consume.ts | 2 +- docs/抖音-码速达/00-总览.md | 77 +++++- 6 files changed, 660 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/publish-douyin-voucher-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/publish-douyin-voucher-service.ts diff --git a/apps/backend/src/services/worker-platform/publish-douyin-voucher-service.test.ts b/apps/backend/src/services/worker-platform/publish-douyin-voucher-service.test.ts new file mode 100644 index 00000000..235d35d4 --- /dev/null +++ b/apps/backend/src/services/worker-platform/publish-douyin-voucher-service.test.ts @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { RedeemCertificateRow } from '../../repositories/redeem-certificate-repo.js' +import type { WorkOrderRow } from '../../repositories/worker-platform/index.js' +import { verifyDouyinMashidaCertificateForWorkOrderPublish } from './publish-douyin-voucher-service.js' +import type { PublishDouyinVoucherServiceDeps } from './publish-douyin-voucher-service.js' + +function buildWorkOrder(overrides: Partial = {}): WorkOrderRow { + return { + id: 1, + work_order_no: 'WK001', + order_id: 10, + order_item_id: 20, + task_id: null, + platform_order_id: 'DOUYIN-PARENT-ORDER', + product_name: '测试商品', + category_id: null, + status: 'unassigned', + reward_amount: 100, + required_deposit_amount: 0, + deposit_threshold_amount: 20000, + assigned_worker_id: null, + last_assigned_worker_id: null, + material_json: '{}', + requirement_json: '{}', + acceptance_json: '{}', + draft_acceptance_json: '{}', + problem_note: '', + sharing_enabled: false, + sharing_total_quantity: 1, + sharing_unit_reward: 0, + published_at: null, + hall_queued_at: null, + pinned_at: null, + assigned_at: null, + submitted_at: null, + accepted_at: null, + deadline_at: null, + timeout_minutes: 0, + timeout_policy: 'reopen', + created_at: '2026-10-14T00:00:00.000Z', + updated_at: '2026-10-14T00:00:00.000Z', + ...overrides, + } +} + +function buildCertificate(overrides: Partial = {}): RedeemCertificateRow { + return { + id: 1, + provider: 'douyin_mashida', + cert_no: 'DYCABC', + order_id: 'DOUYIN-PARENT-ORDER', + parent_order_id: 'DOUYIN-PARENT-ORDER', + local_order_id: 10, + order_item_id: 20, + cert_link: 'https://ks.khhao.com/h5/DOUYIN-PARENT-ORDER', + status: 'synced', + sync_status: 'success', + sync_error: '', + platform_cert_no: 'PLATFORM-CERT', + platform_cert_id: 'PC1', + platform_status: 2, + platform_synced_at: '2026-10-14T00:00:00.000Z', + grant_time: '2026-10-14 00:00:00', + synced_at: '2026-10-14T00:00:00.000Z', + verified_at: null, + raw_payload_json: {}, + created_at: '2026-10-14T00:00:00.000Z', + updated_at: '2026-10-14T00:00:00.000Z', + ...overrides, + } +} + +type TestState = { + verifyCertNos: string[] + updatedMaterialJson: string[] + eventTypes: string[] +} + +function buildDeps( + certificate: RedeemCertificateRow | null, + overrides: Partial = {}, +): PublishDouyinVoucherServiceDeps & TestState { + const state: TestState = { + verifyCertNos: [], + updatedMaterialJson: [], + eventTypes: [], + } + + return { + ...state, + findCertificateByOrder: async () => certificate, + verifyCertificate: async ({ certNo }) => { + state.verifyCertNos.push(certNo) + return { + ...(certificate || buildCertificate()), + cert_no: certNo, + status: 'verified', + verified_at: '2026-10-15T00:00:00.000Z', + } + }, + now: () => '2026-10-15T00:00:00.000Z', + updateWorkOrder: async (_id, patch) => { + state.updatedMaterialJson.push(String(patch.material_json || '')) + return { status: 'open' } + }, + createWorkOrderEvent: async (input) => { + state.eventTypes.push(input.eventType) + return { id: 1 } + }, + ...overrides, + } +} + +test('抖音券核销成功后回填工单 material_json 并写工单事件', async () => { + const deps = buildDeps(buildCertificate()) + + const result = await verifyDouyinMashidaCertificateForWorkOrderPublish( + buildWorkOrder(), + 'work_order_publish', + deps, + ) + + assert.equal(result.ok, true) + assert.equal(result.consumedCount, 1) + assert.equal(result.alreadyConsumedCount, 0) + assert.deepEqual(result.voucherCodes, ['DYCABC']) + // 委托给唯一真源 verifyCertificate(平台侧券码优先级由它处理) + assert.deepEqual(deps.verifyCertNos, ['DYCABC']) + + assert.equal(deps.updatedMaterialJson.length, 1) + const material = JSON.parse(deps.updatedMaterialJson[0] || '{}') + assert.equal(material.douyinMashidaVoucher.length, 1) + assert.equal(material.douyinMashidaVoucher[0].certNo, 'DYCABC') + assert.equal(material.douyinMashidaVoucher[0].platformCertNo, 'PLATFORM-CERT') + assert.equal(material.douyinMashidaVoucher[0].status, 'verified') + assert.deepEqual(deps.eventTypes, ['publish_voucher_consumed']) +}) + +test('回填时保留工单 material_json 上已有的其它字段', async () => { + const deps = buildDeps(buildCertificate()) + const workOrder = buildWorkOrder({ + material_json: JSON.stringify({ note: '运营备注', kuaishouIndustryVoucher: [{ a: 1 }] }), + }) + + await verifyDouyinMashidaCertificateForWorkOrderPublish(workOrder, 'work_order_publish', deps) + + const material = JSON.parse(deps.updatedMaterialJson[0] || '{}') + assert.equal(material.note, '运营备注') + assert.deepEqual(material.kuaishouIndustryVoucher, [{ a: 1 }]) + assert.equal(material.douyinMashidaVoucher[0].certNo, 'DYCABC') +}) + +test('抖音券尚未生成时发布失败并给出明确指引(不静默放过)', async () => { + const deps = buildDeps(null) + + await assert.rejects( + verifyDouyinMashidaCertificateForWorkOrderPublish(buildWorkOrder(), 'work_order_publish', deps), + /抖音券码尚未生成/, + ) + assert.deepEqual(deps.verifyCertNos, []) +}) + +test('核销真源报错(未同步/已作废/平台拒绝)时返回失败结果且不回填工单', async () => { + const deps = buildDeps(buildCertificate(), { + verifyCertificate: async () => { + throw new Error('该券码尚未同步成功,暂不能核销') + }, + }) + + const result = await verifyDouyinMashidaCertificateForWorkOrderPublish( + buildWorkOrder(), + 'work_order_publish', + deps, + ) + + assert.equal(result.ok, false) + assert.equal(result.failedCount, 1) + assert.match(result.errorMessage, /核销失败.*尚未同步成功/) + assert.deepEqual(deps.updatedMaterialJson, []) + assert.deepEqual(deps.eventTypes, []) +}) + +test('已核销的券按幂等成功反馈(不重复打平台,由真源保证)', async () => { + const deps = buildDeps( + buildCertificate({ status: 'verified', verified_at: '2026-10-14T01:00:00.000Z' }), + ) + + const result = await verifyDouyinMashidaCertificateForWorkOrderPublish( + buildWorkOrder(), + 'work_order_publish', + deps, + ) + + assert.equal(result.ok, true) + assert.equal(result.consumedCount, 0) + assert.equal(result.alreadyConsumedCount, 1) +}) + +test('指派路径复用同一套抖音券核销口径', async () => { + const deps = buildDeps(buildCertificate()) + + const result = await verifyDouyinMashidaCertificateForWorkOrderPublish( + buildWorkOrder(), + 'work_order_assign', + deps, + ) + + assert.equal(result.ok, true) + assert.equal(result.consumedCount, 1) +}) + +test('工单没有平台订单号时跳过(不算失败)', async () => { + const deps = buildDeps(buildCertificate()) + + const result = await verifyDouyinMashidaCertificateForWorkOrderPublish( + buildWorkOrder({ platform_order_id: '' }), + 'work_order_publish', + deps, + ) + + assert.equal(result.ok, true) + assert.equal(result.voucherCount, 0) + assert.deepEqual(deps.verifyCertNos, []) +}) diff --git a/apps/backend/src/services/worker-platform/publish-douyin-voucher-service.ts b/apps/backend/src/services/worker-platform/publish-douyin-voucher-service.ts new file mode 100644 index 00000000..58d50ffc --- /dev/null +++ b/apps/backend/src/services/worker-platform/publish-douyin-voucher-service.ts @@ -0,0 +1,213 @@ +import { + findRedeemCertificateByOrder, + type RedeemCertificateRow, +} from '../../repositories/redeem-certificate-repo.js' +import { + createWorkOrderEvent, + updateWorkOrder, + type WorkOrderRow, +} from '../../repositories/worker-platform/index.js' +import { createHttpError } from '../../utils/http.js' +import { logIntegration } from '../../utils/logger.js' +import { nowIso } from '../../utils/time.js' +import { + buildDouyinMashidaWorkOrderVoucher, + DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY, + mergeDouyinMashidaVoucherIntoMaterial, + verifyCertificate, +} from '../redeem/certificate-service.js' +import type { WorkOrderVoucherConsumeResult } from './publish-voucher-service.js' + +type PublishDouyinVoucherServiceDeps = { + findCertificateByOrder: typeof findRedeemCertificateByOrder + /** 核销上报的唯一真源:落库 + 幂等 + 失败自愈(见 redeem/certificate-service.ts) */ + verifyCertificate: typeof verifyCertificate + now: typeof nowIso + updateWorkOrder: typeof updateWorkOrder + createWorkOrderEvent: typeof createWorkOrderEvent +} + +const defaultDeps: PublishDouyinVoucherServiceDeps = { + findCertificateByOrder: findRedeemCertificateByOrder, + verifyCertificate, + now: nowIso, + updateWorkOrder, + createWorkOrderEvent, +} + +const DOUYIN_MASHIDA_PROVIDER = 'douyin_mashida' + +/** + * 工单「发布 / 指派」顺带核销抖音码速达券(`redeem_certificates`,provider=douyin_mashida)。 + * + * 为什么必须单独一条分支:抖音券与快手行业电子凭证是**两张表两套状态机**—— + * 快手走 `consumeKuaishouIndustryVoucher`(快手 OpenAPI),抖音走 `coupons.verifyV3` + * (我们自发的 DYC 券码)。在 `publish-voucher-service` 合并之前,抖音工单发布时 + * 只会去查 `kuaishou_industry_vouchers`(抖音订单那里永远是空),等于**从未核销过抖音券**。 + * + * 核销本身**不重复实现**:直接调 `verifyCertificate`(与买家兑换页、领取页自动核销、 + * 后台手动核销同一个入口),因此天然获得: + * - 幂等:本地已 `verified` 直接返回,不重复打平台; + * - 状态守卫:`invalid`(退款作废)与 `sync_status≠success`(平台侧没有这张券)都拒绝核销; + * - 平台侧券码优先:`syncV2` 可能改写我方券码,直接用我方券码会拿到 `10017`; + * - 失败自愈:先对账(平台其实已核销 → 按平台状态标记),再用平台券码重试一次。 + * + * 核销成功后把券快照回填工单 `material_json.douyinMashidaVoucher`(对齐快手券挂任务的做法), + * 让打手在工单上能看到「交付的是哪张券」——这一步是 best-effort,失败只留痕。 + */ +export async function verifyDouyinMashidaCertificateForWorkOrderPublish( + workOrder: WorkOrderRow, + source: 'work_order_publish' | 'work_order_assign', + deps: PublishDouyinVoucherServiceDeps = defaultDeps, +): Promise { + const receiptOrderId = String(workOrder.platform_order_id || '').trim() + if (!receiptOrderId) { + return buildDouyinConsumeResult([]) + } + + const certificate = await deps.findCertificateByOrder({ + provider: DOUYIN_MASHIDA_PROVIDER, + orderId: receiptOrderId, + }) + if (!certificate) { + // 抖音「任何订单都发券」是业务口径:查不到券说明发券环节没走完,必须显式报错让人跟进, + // 不能静默放过(否则平台侧券永远不核销,买家端却以为已完成)。 + throw createHttpError('该订单的抖音券码尚未生成,请先在「抖音券核销」里重发券码', { + statusCode: 409, + errorCode: 'work_order_publish_douyin_certificate_missing', + context: { workOrderId: workOrder.id, receiptOrderId, source }, + }) + } + + const alreadyConsumed = certificate.status === 'verified' + let verified: RedeemCertificateRow + + try { + verified = await deps.verifyCertificate({ certNo: certificate.cert_no }) + } catch (error) { + const message = error instanceof Error ? error.message : '抖音券核销失败' + logIntegration( + '[work-order/publish-douyin-voucher]', + '发布顺带核销抖音券失败', + { + workOrderId: workOrder.id, + source, + certNo: certificate.cert_no, + localOrderId: certificate.local_order_id, + error: message, + }, + { level: 'warn' }, + ) + + return { + ok: false, + voucherCount: 1, + consumedCount: 0, + alreadyConsumedCount: 0, + failedCount: 1, + voucherCodes: [certificate.cert_no], + errorMessage: `抖音券 ${certificate.cert_no} 核销失败:${message}`, + } + } + + await attachVoucherToWorkOrder(workOrder, verified, deps) + + logIntegration('[work-order/publish-douyin-voucher]', '发布顺带核销抖音券成功', { + workOrderId: workOrder.id, + source, + certNo: verified.cert_no, + platformCertNo: verified.platform_cert_no, + alreadyConsumed, + }) + + return { + ok: true, + voucherCount: 1, + consumedCount: alreadyConsumed ? 0 : 1, + alreadyConsumedCount: alreadyConsumed ? 1 : 0, + failedCount: 0, + voucherCodes: [verified.cert_no], + errorMessage: '', + } +} + +/** 核销成功后把券快照并回工单 material_json(best-effort:失败只留痕,不影响发布结果)。 */ +async function attachVoucherToWorkOrder( + workOrder: WorkOrderRow, + certificate: RedeemCertificateRow, + deps: PublishDouyinVoucherServiceDeps, +): Promise { + try { + const voucher = buildDouyinMashidaWorkOrderVoucher(certificate) + if (!voucher.certNo) { + return + } + + const material = + typeof workOrder.material_json === 'string' + ? safeParseJsonObject(workOrder.material_json) + : workOrder.material_json || {} + const merged = mergeDouyinMashidaVoucherIntoMaterial(material, voucher) + if (!merged.changed) { + return + } + + const now = deps.now() + const updated = await deps.updateWorkOrder(workOrder.id, { + material_json: JSON.stringify(merged.material), + updated_at: now, + }) + if (!updated) { + return + } + + await deps.createWorkOrderEvent({ + workOrderId: Number(workOrder.id), + actorType: 'system', + actorId: DOUYIN_MASHIDA_PROVIDER, + eventType: 'publish_voucher_consumed', + fromStatus: String(workOrder.status || ''), + toStatus: String(updated.status || workOrder.status || ''), + payloadJson: JSON.stringify({ + voucherKey: DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY, + certNo: voucher.certNo, + platformCertNo: voucher.platformCertNo, + }), + now, + }) + } catch (error) { + logIntegration( + '[work-order/publish-douyin-voucher]', + '抖音券快照回填工单失败(核销已成功,不影响发布)', + { + workOrderId: workOrder.id, + certNo: certificate.cert_no, + error: error instanceof Error ? error.message : String(error), + }, + { level: 'warn' }, + ) + } +} + +function safeParseJsonObject(raw: string): Record { + try { + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } +} + +function buildDouyinConsumeResult(certNos: string[]): WorkOrderVoucherConsumeResult { + return { + ok: true, + voucherCount: certNos.length, + consumedCount: 0, + alreadyConsumedCount: 0, + failedCount: 0, + voucherCodes: certNos, + errorMessage: '', + } +} diff --git a/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts b/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts index 4dda5335..cd22ffb3 100644 --- a/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts +++ b/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts @@ -6,7 +6,10 @@ import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository import { consumeIndustryVouchersBeforeWorkOrderAssign, consumeIndustryVouchersBeforeWorkOrderPublish, + isDouyinMashidaWorkOrder, resolveWorkOrderVoucherTargets, + setDouyinVoucherDepsForTest, + setOrderProviderResolverForTest, } from './publish-voucher-service.js' function buildWorkOrder(overrides: Partial = {}): WorkOrderRow { @@ -289,3 +292,90 @@ test('指派前电子凭证核销失败时返回失败结果', async () => { assert.equal(result.failedCount, 1) assert.equal(result.errorMessage, '指派前核销失败') }) + +test('抖音订单的工单走抖音券核销,不查快手凭证', async () => { + setOrderProviderResolverForTest(async () => 'douyin_mashida') + // 抖音券服务注入假依赖:返回「券尚未生成」错误,验证分流且快手仓储未被触达 + setDouyinVoucherDepsForTest({ + findCertificateByOrder: async () => { + throw new Error('抖音券仓储不应在分流测试中被触达') + }, + verifyCertificate: async () => { + throw new Error('抖音核销真源不应在分流测试中被触达') + }, + now: async () => { + throw new Error('now 不应被触达') + }, + updateWorkOrder: async () => { + throw new Error('updateWorkOrder 不应被触达') + }, + createWorkOrderEvent: async () => { + throw new Error('createWorkOrderEvent 不应被触达') + }, + } as never) + try { + let kuaishouListed = false + // 与生产一致:publishAdminWorkOrder 外层 try/catch 会把抖音券服务的异常转成失败结果, + // 这里直接断言「抖音服务抛错、快手仓储全程未被查询」 + await assert.rejects( + consumeIndustryVouchersBeforeWorkOrderPublish( + buildWorkOrder({ platform_order_id: 'DOU123' }), + { + listVouchersByOid: async () => { + kuaishouListed = true + return [] + }, + listTasksByOrderId: async () => [], + consumeVoucher: async (voucher) => ({ ok: true, voucher, callbackSuccess: true }), + attachVoucherToTask: async (task) => task, + }, + ), + /抖音券仓储/, + ) + assert.equal(kuaishouListed, false) + } finally { + setDouyinVoucherDepsForTest(undefined) + setOrderProviderResolverForTest(null) + } +}) + +test('非抖音订单的工单仍走快手电子凭证核销', async () => { + setOrderProviderResolverForTest(async () => '91kaquan') + try { + let kuaishouListed = false + const result = await consumeIndustryVouchersBeforeWorkOrderPublish(buildWorkOrder(), { + listVouchersByOid: async () => { + kuaishouListed = true + return [] + }, + listTasksByOrderId: async () => [], + consumeVoucher: async (voucher) => ({ ok: true, voucher, callbackSuccess: true }), + attachVoucherToTask: async (task) => task, + }) + + assert.equal(kuaishouListed, true) + assert.equal(result.ok, true) + assert.equal(result.voucherCount, 0) + } finally { + setOrderProviderResolverForTest(null) + } +}) + +test('isDouyinMashidaWorkOrder 按 provider 判定', async () => { + setOrderProviderResolverForTest(async () => 'douyin_mashida') + try { + assert.equal(await isDouyinMashidaWorkOrder(buildWorkOrder()), true) + } finally { + setOrderProviderResolverForTest(null) + } + + setOrderProviderResolverForTest(async () => '91kaquan') + try { + assert.equal(await isDouyinMashidaWorkOrder(buildWorkOrder()), false) + } finally { + setOrderProviderResolverForTest(null) + } + + // 没有关联本地订单的工单不可能判定为抖音 + assert.equal(await isDouyinMashidaWorkOrder(buildWorkOrder({ order_id: null })), false) +}) diff --git a/apps/backend/src/services/worker-platform/publish-voucher-service.ts b/apps/backend/src/services/worker-platform/publish-voucher-service.ts index 5c43cd47..d3eb8f37 100644 --- a/apps/backend/src/services/worker-platform/publish-voucher-service.ts +++ b/apps/backend/src/services/worker-platform/publish-voucher-service.ts @@ -1,9 +1,12 @@ import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js' +import { getOrderById } from '../../repositories/order-repo.js' import { listTasksByOrderId } from '../../repositories/task-repo.js' import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js' import type { WorkOrderRow } from '../../repositories/worker-platform/index.js' import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js' import { consumeKuaishouIndustryVoucher } from '../platforms/kuaishou-industry/voucher-service.js' +import { isDouyinMashidaProvider } from '../platforms/douyin-mashida/order-mapper.js' +import { verifyDouyinMashidaCertificateForWorkOrderPublish } from './publish-douyin-voucher-service.js' export type WorkOrderVoucherConsumeResult = { ok: boolean @@ -48,6 +51,16 @@ async function consumeIndustryVouchersBeforeWorkOrderAction( source: 'work_order_publish' | 'work_order_assign', deps: WorkOrderVoucherServiceDeps, ): Promise { + // 来源决定核销对象:抖音码速达工单核销我们自发的 DYC 券(redeem_certificates), + // 其余(91/快手)核销快手行业电子凭证(kuaishou_industry_vouchers)——两套状态机不可混用。 + if (await isDouyinMashidaWorkOrder(workOrder)) { + return verifyDouyinMashidaCertificateForWorkOrderPublish( + workOrder, + source, + douyinVoucherDepsForTest, + ) + } + const oid = String(workOrder.platform_order_id || '').trim() if (!oid) { return buildConsumeResult([]) @@ -192,3 +205,49 @@ function normalizeVoucherStatus(value: unknown) { .trim() .toUpperCase() } + +/** + * 抖音券服务的依赖注入口:生产恒为 undefined(用其 defaultDeps 直连仓储/网关), + * 仅分流单测注入假实现以免触库/触网。 + */ +let douyinVoucherDepsForTest: Parameters< + typeof verifyDouyinMashidaCertificateForWorkOrderPublish +>[2] + +/** 仅测试用:注入/清除抖音券服务的依赖。 */ +export function setDouyinVoucherDepsForTest( + deps: Parameters[2], +) { + douyinVoucherDepsForTest = deps +} + +/** + * 工单是否来自抖音码速达订单(按订单 provider 判定;查询失败按非抖音处理,走快手路径)。 + * provider 查询抽成可注入的 resolver,仅为单测免触库。 + */ +type OrderProviderResolver = (orderId: number) => Promise + +let resolveOrderProvider: OrderProviderResolver = async (orderId) => { + const order = await getOrderById(orderId).catch(() => null) + return String(order?.provider || '') +} + +/** 仅测试用:替换订单 provider 查询实现。 */ +export function setOrderProviderResolverForTest(resolver: OrderProviderResolver | null) { + resolveOrderProvider = + resolver || + (async (orderId) => { + const order = await getOrderById(orderId).catch(() => null) + return String(order?.provider || '') + }) +} + +export async function isDouyinMashidaWorkOrder(workOrder: WorkOrderRow): Promise { + const orderId = Number(workOrder.order_id || 0) + if (!Number.isFinite(orderId) || orderId <= 0) { + return false + } + + const provider = await resolveOrderProvider(Math.trunc(orderId)) + return isDouyinMashidaProvider(provider) +} diff --git a/apps/frontend/src/utils/voucher-consume.ts b/apps/frontend/src/utils/voucher-consume.ts index 7cde5255..c27b26cc 100644 --- a/apps/frontend/src/utils/voucher-consume.ts +++ b/apps/frontend/src/utils/voucher-consume.ts @@ -17,7 +17,7 @@ export function notifyVoucherConsume(voucherConsume?: WorkOrderVoucherConsumeRes message.warning({ content: `状态已变更,但电子凭证核销失败:${ voucherConsume.errorMessage || '未知错误' - }。请在快手后台人工核销后再发货`, + }。请到「抖音券核销」或快手电子凭证后台人工核销后再发货`, duration: 8, }) } diff --git a/docs/抖音-码速达/00-总览.md b/docs/抖音-码速达/00-总览.md index 5ab349a5..60945dfb 100644 --- a/docs/抖音-码速达/00-总览.md +++ b/docs/抖音-码速达/00-总览.md @@ -1,7 +1,7 @@ # 抖音码速达 · 总览(进度 / 代码 / 配置 / 上线) -> 更新:2026-10-09 **状态:最小接入已实现完毕,且已在真实平台跑通**(真实网关签名验证 / 真实推送订阅 / 真实 orderDetail 建单 / 真实 coupons.syncV2 同步成功),两端自检全绿 -> 本文是这套内容的**入口**,具体细节在 04~11。 +> 更新:2026-10-15 **状态:最小接入已跑通,且按 SKU 的三层分流已上线**(真实网关签名验证 / 真实推送订阅 / 真实 orderDetail 建单 / 真实 coupons.syncV2 同步成功 / 工单发布顺带核销抖音券),两端自检全绿 +> 本文是这套内容的**入口**,具体细节在 04~11,最新增量见 [§九点六](#九点六2026-10-14--10-15-增量)。 --- @@ -9,7 +9,9 @@ 按 [05-最小接入方案分析.md](05-最小接入方案分析.md) 落地的**最小闭环六步全部完成**,后端 636 测试 / 前端 115 测试全绿,迁移 `094` 已在本地临时库实测通过(建表 + 幂等 + 唯一约束行为均验证)。 -**没做的**:**按 SKU 的三层分类**(抖音目前只有「皮肤自动发货」+「人工」两条路,缺 91/快手 的中间层 → 总设计见 [12-按SKU分类改造总设计.md](12-按SKU分类改造总设计.md),分层设计与生产实测见 [10-三层分流设计.md](10-三层分流设计.md),运营动作见 [11-接单模板待补清单.md](11-接单模板待补清单.md))、兑换后的实际交付物(卡密/充值)、`order.searchList` 对账。 +**没做的**:兑换后的实际交付物(卡密/充值)、`order.searchList` 对账、兑换页二次校验(手机后 4 位)。 + +> ✅ **原「按 SKU 的三层分类」已上线**(2026-10-14):抖音与 91/快手 同构(命中映射 → `affiliate_dash` 自动发货;未命中 → 接单平台建工单;都不中 → 无载体=待人工),载体唯一有硬守卫。总设计 [12-按SKU分类改造总设计.md](12-按SKU分类改造总设计.md)、分层实测 [10-三层分流设计.md](10-三层分流设计.md)、运营动作 [11-接单模板待补清单.md](11-接单模板待补清单.md)。 **只走真实公网联调**(离线模拟已按要求删除): @@ -205,15 +207,17 @@ git add apps/backend/src/app.ts \ | # | 缺口 | 影响 | 备注 | | --- | --- | --- | --- | | ~~1~~ | ~~后台前端配置面板~~ | **已完成**:`/admin/platform-shops?tab=douyinMashida` | — | -| 2 | 履约任务分支 | **已完成**:抖音已接入 `affiliate_dash`(皮肤/道具自动发货)+ `manual_dispatch`(人工兜底) | `product-resolution-service.ts:255-317`;但**写死了 preferred,履约路由规则对抖音无效** | -| 2b | **接单平台发单层(层②)** | 抖音单不进打手池:生产实测 11 笔全 `unmatched`、工单 0 个 | 设计 [10-三层分流设计.md](10-三层分流设计.md) / 运营 [11-接单模板待补清单.md](11-接单模板待补清单.md) | +| ~~2~~ | ~~履约任务分支~~ | **已完成且已修正**:命中映射 → `affiliate_dash` 自动发货;未命中**不再写死** preferred、**不再兜底 `manual_dispatch`**(否则会出现「人工任务 + 工单」双载体) | `product-resolution-service.ts:286-350`(commit `4b081d88`) | +| ~~2b~~ | ~~**接单平台发单层(层②)**~~ | **已完成**:抖音按内部 SKU 三层分类(`snapshot.classification`),发单层有「载体唯一」硬守卫(命中 `affiliate_dash` 即跳过建工单);生产库接单模板 `platform` 已全部清空(354 条),抖音单从此可命中模板 | [10-三层分流设计.md](10-三层分流设计.md) / [11-接单模板待补清单.md](11-接单模板待补清单.md) | +| ~~2c~~ | ~~工单「发布并核销」只核销快手电子凭证~~ | **已完成**(2026-10-15):按来源分流,抖音工单核销 DYC 券(`redeem_certificates`),见 [§九点六](#九点六2026-10-14--10-15-增量) | `worker-platform/publish-douyin-voucher-service.ts` | | 3 | 兑换后的实际交付物 | 当前只「上报核销 + 展示掩码券码 + 发链接」 | 等业务定(B2) | | 4 | `order.searchList` 对账任务 | 消息丢了就漏单 | 二期 | -| 5 | `cancelVerify` 后台按钮 | 误核销只能手工 | 出网方法已就绪 | +| ~~5~~ | ~~`cancelVerify` 后台按钮~~ | **已完成**:后台「抖音券核销」含核销/冲正/重新同步/对账/重发/作废(每个写操作带审计) | `routes/admin/douyin-mashida.ts`(commit `5a73fb74`) | | 6 | 兑换页二次校验(手机后 4 位) | 知道订单号即可兑换 | 看业务是否接受(B1) | | 7 | `message.config` 自动订阅 | 需平台侧手配 | 可选做「一键订阅」 | | 8 | 隧道域名变化需手工同步平台侧 | `set-public-url` 会打印待配项;ngrok 免费版建议保留静态域名 | 固定域名后基本不是问题 | | 9 | ngrok 免费版对浏览器插警示页 | 买家点 `cert_link` 会先过一页英文提示 | 正式环境用正式域名;联调期间点一下即可 | +| — | 工单里的抖音券前端不渲染 | **有意为之**(2026-10-15 用户口径):打手/客服**不需要看到券码**,快照只落 `material_json` 供追溯 | `material_json.douyinMashidaVoucher` | ### 联调要实测的点 @@ -260,6 +264,67 @@ git add apps/backend/src/app.ts \ | `order.message` 发消息 | ✅ 兑换成功后已调用,无错误日志(成功日志默认被 integration 阈值 warn 挡住) | | `messagetest` 自测入口 | ⚠️ 始终 `20006 data参数内容解析异常`,未探明其 data 要求;不影响真实链路 | +## 九点六、2026-10-14 / 10-15 增量 + +### 1)按 SKU 三层分流上线(`4b081d88`) + +抖音与 91/快手 同构,**只动抖音,91 不变**: + +| 层 | 判据 | 载体 | +| --- | --- | --- | +| ① 自动发货 | 内部 SKU 命中 `AFFILIATE_DASH_SKU_MAPPING`(常量表 + 后台 `skuMapping` 增量) | `affiliate_dash` 履约任务(买家输 UID → 扫码绑定 → 自动核销) | +| ② 接单平台 | 命中接单模板(`work_product_rules`,按**内部 SKU**) | `work_orders` 工单 | +| ③ 人工 | 两者都不中 | **无载体**(无任务无工单)=待人工 | + +- `sku_code` 恒为**内部 SKU**(不再被英文 sku 覆写);引流长标题只进 `snapshot.productTitle`,**永不参与匹配**。 +- 未命中映射时**不设 preferred、强制覆盖 `unmatchedExecutorKey:''`**——否则会出现「人工任务 + 工单」双载体。 +- 载体唯一守卫:`sync-worker-orders-service.ts:65` 命中 `classification.channel==='affiliate_dash'` 的订单项**不建工单**(reason `affiliate_dash_carrier_taken`)。 +- 券 → 工单快照:`material_json.douyinMashidaVoucher`(对齐快手 `kuaishouIndustryVoucher`)。**前端有意不渲染**(打手/客服不需要看到券码)。 + +### 2)接单模板匹配引擎重构(`10464fea`,P0+P1) + +按 [接单模板匹配引擎重构设计.md](../接单模板匹配引擎重构设计.md) 抽出 `services/product-match/`(适配器按**来源**划分:`kuaishou-91` / `pdd` / `douyin-mashida`)。生产回放 6000 条订单项:91/PDD 决策 **0 差异**,抖音 5 条全为修正。旧 `work-product-match-mappers.ts` 变为门面,签名不变。 + +### 3)工单「发布并核销」支持抖音券(本次) + +**问题**:抖音单走到接单平台后有工单,但「发布并核销」只查 `kuaishou_industry_vouchers`(抖音订单在那张表里永远是空的)→ **抖音券从未被核销**。三个入口(发布 / 指派 / 寄售履约)共用同一个函数,全部受影响。 + +**改法**:在唯一入口按**来源**分流,核销本身**不重复实现**。 + +```text +publishAdminWorkOrder / assignAdminWorkOrder / 寄售履约 + └─ consumeIndustryVouchersBeforeWorkOrderPublish (publish-voucher-service.ts) + ├─ 抖音(orders.provider === 'douyin_mashida') + │ └─ verifyDouyinMashidaCertificateForWorkOrderPublish (publish-douyin-voucher-service.ts) + │ ├─ 按 platform_order_id(= redeem_certificates.order_id,子单优先/父单兜底)查券 + │ ├─ 委托 verifyCertificate(redeem/certificate-service.ts)→ coupons.verifyV3 + │ │ (幂等 / invalid 拒核 / 未同步拒核 / 平台券码优先 / 失败对账自愈) + │ └─ 券快照 merge 进工单 material_json.douyinMashidaVoucher + 事件 publish_voucher_consumed + └─ 其它来源(91/快手) + └─ 原快手电子凭证路径(listKuaishouIndustryVouchersByOid + consumeKuaishouIndustryVoucher) +``` + +| 文件 | 作用 | +| --- | --- | +| `worker-platform/publish-douyin-voucher-service.ts` | 抖音券核销分支(新增) | +| `worker-platform/publish-voucher-service.ts` | 顶部按来源分流(`isDouyinMashidaWorkOrder`,查 `orders.provider`) | +| `utils/voucher-consume.ts` | 前端失败提示改为「请到「抖音券核销」或快手电子凭证后台人工核销后再发货」 | + +口径要点: + +1. **查不到券 → 抛 409**(`work_order_publish_douyin_certificate_missing`)而不是静默成功——抖音口径是「任何订单都发券」,查不到说明发券环节没走完,需要运营去「抖音券核销」重发。 +2. **券状态落库由 `verifyCertificate` 负责**,因此后台「抖音券核销」界面的状态、重复发布的幂等行为与买家兑换页完全一致(不会有「本地说没核销」的分叉)。 +3. **回填工单是 best-effort**:失败只留 warn,不影响发布结果。 +4. 单测用 `setOrderProviderResolverForTest` / `setDouyinVoucherDepsForTest` 注入(生产恒为 undefined,直连真实仓储与网关)。 + +**验证**(2026-10-15):后端 `npm run check` ✅ 740 例 0 失败(本次新增 13 例:核销成功回填/保留 material 其它字段/无券报错/真源报错转失败结果且不回填/已核销幂等/指派路径/无平台单号跳过 + 来源分流回归 3 例);前端 `npm run check` ✅ 143 例 0 失败。 + +### 4)其它 + +- `584b6f52`:问题单处置存在活跃拼单份额时,禁止取消 / 退回大厅 / 重开。 + +--- + ## 十、自检结果(本次) | 项 | 结果 |