From bae8715f5dbcd37560fa7403078bac835b46ab11 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 11 Jul 2026 21:09:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B8=E9=94=80=E6=97=B6=E9=97=B4=E6=8F=90?= =?UTF-8?q?=E5=89=8D=E5=88=B0=2091=20=E8=BF=94=E5=9B=9E=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../open-91/consume-on-deliver.test.ts | 60 ++++++ .../services/open-91/consume-on-deliver.ts | 181 ++++++++++++++++++ .../services/open-91/order-query-service.ts | 32 ++++ .../src/pages/admin/AdminTaskDetailPage.tsx | 6 +- 4 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/services/open-91/consume-on-deliver.test.ts create mode 100644 apps/backend/src/services/open-91/consume-on-deliver.ts diff --git a/apps/backend/src/services/open-91/consume-on-deliver.test.ts b/apps/backend/src/services/open-91/consume-on-deliver.test.ts new file mode 100644 index 00000000..d65677b1 --- /dev/null +++ b/apps/backend/src/services/open-91/consume-on-deliver.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveIndustryVoucherForTask } from './consume-on-deliver.js' +import type { KuaishouIndustryVoucherRow } from '../../types/repository/rows.js' + +function voucher(partial: Partial & { + voucher_code: string +}): KuaishouIndustryVoucherRow { + return { + id: partial.id || 1, + oid: partial.oid || 'OID-1', + seller_id: partial.seller_id || 'seller', + order_id: partial.order_id ?? null, + task_id: partial.task_id ?? null, + unit_index: partial.unit_index ?? 0, + voucher_code: partial.voucher_code, + token: partial.token || 'token', + status: partial.status || 'UNUSED', + send_callback_status: partial.send_callback_status || 'success', + send_callback_attempt_count: partial.send_callback_attempt_count || 1, + send_callback_last_error: partial.send_callback_last_error || '', + send_callback_sent_at: partial.send_callback_sent_at || null, + valid_start_time: partial.valid_start_time || 0, + valid_end_time: partial.valid_end_time || 0, + consume_serial_num: partial.consume_serial_num || '', + consume_details_json: partial.consume_details_json || [], + consumed_at: partial.consumed_at || null, + destroyed_at: partial.destroyed_at || null, + created_at: partial.created_at || '2026-07-01T00:00:00.000Z', + updated_at: partial.updated_at || '2026-07-01T00:00:00.000Z', + } as KuaishouIndustryVoucherRow +} + +test('resolveIndustryVoucherForTask prefers task_id over unit_index', () => { + const vouchers = [ + voucher({ id: 1, voucher_code: 'A', task_id: 10, unit_index: 1 }), + voucher({ id: 2, voucher_code: 'B', task_id: 11, unit_index: 2 }), + ] + + const matched = resolveIndustryVoucherForTask({ id: 11, unit_index: 1 }, vouchers) + assert.equal(matched?.voucher_code, 'B') +}) + +test('resolveIndustryVoucherForTask falls back to unit_index when task_id missing', () => { + const vouchers = [ + voucher({ id: 1, voucher_code: 'A', task_id: null, unit_index: 2 }), + ] + + const matched = resolveIndustryVoucherForTask({ id: 99, unit_index: 2 }, vouchers) + assert.equal(matched?.voucher_code, 'A') +}) + +test('resolveIndustryVoucherForTask returns null when no match', () => { + const vouchers = [ + voucher({ id: 1, voucher_code: 'A', task_id: 1, unit_index: 1 }), + ] + + assert.equal(resolveIndustryVoucherForTask({ id: 2, unit_index: 3 }, vouchers), null) +}) diff --git a/apps/backend/src/services/open-91/consume-on-deliver.ts b/apps/backend/src/services/open-91/consume-on-deliver.ts new file mode 100644 index 00000000..5889e5f7 --- /dev/null +++ b/apps/backend/src/services/open-91/consume-on-deliver.ts @@ -0,0 +1,181 @@ +/** + * 91 卡券交付前核销: + * 在查询接口即将返回 orderStatus=20 + cards 前,同步核销行业电子凭证, + * 把「核销时间」提前到链接交付时刻,降低「拿链即退」误退窗口。 + * + * 语义:核销 = 领取链接已交付(售出闭环),≠ 游戏内道具已到账。 + */ +import { + attachKuaishouIndustryVoucherToTask, +} from '../platforms/kuaishou-industry/voucher-binding-service.js' +import { + consumeKuaishouIndustryVoucher, +} from '../platforms/kuaishou-industry/voucher-service.js' +import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js' +import { logIntegration, logWarn } from '../../utils/logger.js' +import type { + KuaishouIndustryVoucherRow, + OrderRow, + TaskRow, +} from '../../types/repository/rows.js' + +export const OPEN_91_DELIVER_CONSUME_SOURCE = 'open91_query_deliver' + +export type Open91DeliverConsumeResult = + | { + ok: true + consumedTaskIds: number[] + alreadyConsumedTaskIds: number[] + } + | { + ok: false + errorMessage: string + failedTaskIds: number[] + consumedTaskIds: number[] + alreadyConsumedTaskIds: number[] + } + +/** + * 按 task_id / unit_index 解析任务对应的行业电子凭证(与 91 查询就绪判定一致)。 + */ +export function resolveIndustryVoucherForTask( + task: Pick, + vouchers: KuaishouIndustryVoucherRow[], +): KuaishouIndustryVoucherRow | null { + const taskId = Number(task.id || 0) + const unitIndex = Number(task.unit_index || 0) + + if (taskId > 0) { + const byTaskId = vouchers.find((row) => Number(row.task_id || 0) === taskId) + if (byTaskId) { + return byTaskId + } + } + + if (unitIndex > 0) { + const byUnit = vouchers.find((row) => Number(row.unit_index || 0) === unitIndex) + if (byUnit) { + return byUnit + } + } + + return null +} + +function isVoucherConsumed(voucher: Pick): boolean { + return String(voucher.status || '').trim().toUpperCase() === 'CONSUMED' +} + +/** + * 对即将交付给 91 的 ready 任务同步核销行业凭证(幂等)。 + * 失败时调用方应返回 orderStatus=10,避免「未核销却已发链」。 + */ +export async function consumeIndustryVouchersBeforeOpen91Deliver(options: { + order: OrderRow + tasks: TaskRow[] + readyTaskIds: number[] + requestId?: string + source?: string +}): Promise { + const source = String(options.source || OPEN_91_DELIVER_CONSUME_SOURCE).trim() + || OPEN_91_DELIVER_CONSUME_SOURCE + const readySet = new Set( + (Array.isArray(options.readyTaskIds) ? options.readyTaskIds : []) + .map((id) => Number(id)) + .filter((id) => id > 0), + ) + const readyTasks = (Array.isArray(options.tasks) ? options.tasks : []).filter((task) => + readySet.has(Number(task.id || 0)), + ) + + if (readyTasks.length === 0) { + return { + ok: true, + consumedTaskIds: [], + alreadyConsumedTaskIds: [], + } + } + + const oid = String(options.order.platform_order_id || '').trim() + const vouchers = oid ? await listKuaishouIndustryVouchersByOid(oid) : [] + const consumedTaskIds: number[] = [] + const alreadyConsumedTaskIds: number[] = [] + const failed: Array<{ taskId: number; errorMessage: string }> = [] + + for (const task of readyTasks) { + const taskId = Number(task.id || 0) + const voucher = resolveIndustryVoucherForTask(task, vouchers) + + if (!voucher) { + failed.push({ + taskId, + errorMessage: '交付前核销失败:未找到对应电子凭证', + }) + continue + } + + const alreadyConsumed = isVoucherConsumed(voucher) + const result = await consumeKuaishouIndustryVoucher(voucher, { + source, + task, + token: String(voucher.token || '').trim(), + consumeTime: Date.now(), + }) + + if (!result.ok || !result.voucher) { + failed.push({ + taskId, + errorMessage: result.errorMessage || '电子凭证核销回调失败', + }) + continue + } + + await attachKuaishouIndustryVoucherToTask(task, result.voucher, { + source, + }) + + if (alreadyConsumed) { + alreadyConsumedTaskIds.push(taskId) + } else { + consumedTaskIds.push(taskId) + } + } + + if (failed.length > 0) { + const errorMessage = failed[0]?.errorMessage || '电子凭证核销失败,暂缓交付领取链接' + logWarn('[open-91/query]', '91交付前核销失败,暂不返回 cards', { + requestId: options.requestId || '', + orderId: options.order.id, + orderNo: oid, + failedTaskIds: failed.map((item) => item.taskId), + failed, + consumedTaskIds, + alreadyConsumedTaskIds, + errorMessage, + }) + + return { + ok: false, + errorMessage, + failedTaskIds: failed.map((item) => item.taskId), + consumedTaskIds, + alreadyConsumedTaskIds, + } + } + + logIntegration('[open-91/query]', '91交付前核销完成', { + requestId: options.requestId || '', + orderId: options.order.id, + orderNo: oid, + readyTaskCount: readyTasks.length, + consumedTaskIds, + alreadyConsumedTaskIds, + source, + }) + + return { + ok: true, + consumedTaskIds, + alreadyConsumedTaskIds, + } +} diff --git a/apps/backend/src/services/open-91/order-query-service.ts b/apps/backend/src/services/open-91/order-query-service.ts index 5f6df514..1803564a 100644 --- a/apps/backend/src/services/open-91/order-query-service.ts +++ b/apps/backend/src/services/open-91/order-query-service.ts @@ -18,6 +18,7 @@ import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, } from './config.js' +import { consumeIndustryVouchersBeforeOpen91Deliver } from './consume-on-deliver.js' import { assertOpen91QueryPayload, assertOpen91Timestamp, @@ -158,6 +159,35 @@ export async function queryOpen91Order(payload: JsonObject = {}, { requestId = ' }) } + // 即将交付 cards:先同步核销行业电子凭证,成功后再返回 20,避免未核销发链导致误退款。 + const deliverConsume = await consumeIndustryVouchersBeforeOpen91Deliver({ + order, + tasks, + readyTaskIds, + requestId, + }) + + if (!deliverConsume.ok) { + logIntegration('[open-91/query]', '91卡券订单查询因交付前核销失败返回处理中', { + requestId, + orderNo: normalized.orderNo, + orderId: order.id, + readyTaskCount: readyTaskIds.length, + failedTaskIds: deliverConsume.failedTaskIds, + errorMessage: deliverConsume.errorMessage, + }) + + return buildOpen91SuccessResponse({ + orderNo: normalized.orderNo, + outTradeNo: buildOpen91OutTradeNo(order), + orderStatus: 10, + failCode: 0, + failReason: '', + orderCost: buildOpen91OrderCost(0), + cards: '', + }) + } + const outTradeNo = buildOpen91OutTradeNo(order) const encryptedCards = buildOpen91Cards(cardItems, config) const responseData = { @@ -178,6 +208,8 @@ export async function queryOpen91Order(payload: JsonObject = {}, { requestId = ' cardsEncoding: config.cardsEncoding, cardsPlain: cardItems, cardsEncrypted: encryptedCards, + deliverConsumedTaskIds: deliverConsume.consumedTaskIds, + deliverAlreadyConsumedTaskIds: deliverConsume.alreadyConsumedTaskIds, responseData, }) diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index fc799d78..f7c8f954 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -1201,11 +1201,11 @@ function KuaishouCloudPanel({ }, { key: 'consume', - label: '电子凭证收口', + label: '电子凭证核销', status: flow.consume.status || 'pending', detail: flow.consume.consumedAt - ? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 完成收口` - : flow.consume.errorMessage || '等待退号后收口', + ? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 核销(链接交付或履约收口)` + : flow.consume.errorMessage || '等待 91 交付链接时核销,或发货收口时补核销', }, ]