Files
order_site/apps/backend/src/services/open-91/consume-on-deliver.ts
T

245 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<TaskRow, 'id' | 'unit_index'>,
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<KuaishouIndustryVoucherRow, 'status'>): 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<Open91DeliverConsumeResult> {
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 || ''),
})
}
}