/** * 91 卡券交付前核销: * 在查询接口即将返回 orderStatus=20 + cards 前,同步核销行业电子凭证, * 把「核销时间」提前到链接交付时刻,降低「拿链即退」误退窗口。 * * 语义:核销 = 领取链接已交付(售出闭环),≠ 游戏内道具已到账。 * * 交付后会 best-effort 自动准备 Cloud 绑定资源(取号/绑链),避免 * 「已核销却停在待准备资源」只能靠后台手动点「准备资源」。 */ import { attachKuaishouIndustryVoucherToTask, } from '../platforms/kuaishou-industry/voucher-binding-service.js' import { consumeKuaishouIndustryVoucher, } from '../platforms/kuaishou-industry/voucher-service.js' import { prepareFulfillmentBinding } from '../fulfillment/executors/registry.js' import { normalizeKuaishouCloudFlow } from '../fulfillment/kuaishou-cloud/index.js' import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js' import { logIntegration, logWarn } from '../../utils/logger.js' import { parseTaskContext } from '../../utils/task-json.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 } const attachedTask = (await attachKuaishouIndustryVoucherToTask(task, result.voucher, { source, })) || task // 核销后立刻补准备绑定资源;失败不阻断 cards(领取页仍会再兜底 prepare) await ensureBindingPreparedAfterDeliver({ task: attachedTask, source, requestId: options.requestId || '', orderId: options.order.id, orderNo: oid, }) 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, } } async function ensureBindingPreparedAfterDeliver(options: { task: TaskRow source: string requestId: string orderId: number orderNo: string }) { const executorKey = String(options.task.executor_key || '').trim() // lewan / 行业凭证任务才需要 Cloud 绑定资源 if ( executorKey !== 'kuaishou_ct_assisted' && executorKey !== 'kuaishou-industry' && !parseTaskContext(options.task).kuaishouCloudFulfillment ) { return } const flow = normalizeKuaishouCloudFlow( parseTaskContext(options.task).kuaishouCloudFulfillment, ) if (flow.binding.prepareStatus === 'ready' && String(flow.binding.bindUrl || '').trim()) { return } try { await prepareFulfillmentBinding(options.task, { source: `${options.source}_prepare_binding`, actor: { source: options.source }, }) logIntegration('[open-91/query]', '91交付后自动准备绑定资源成功', { requestId: options.requestId, orderId: options.orderId, orderNo: options.orderNo, taskId: options.task.id, source: options.source, }) } catch (error) { logWarn('[open-91/query]', '91交付后自动准备绑定资源失败(不阻断 cards,领取页将再试)', { requestId: options.requestId, orderId: options.orderId, orderNo: options.orderNo, taskId: options.task.id, error: error instanceof Error ? error.message : String(error || ''), }) } }