245 lines
7.2 KiB
TypeScript
245 lines
7.2 KiB
TypeScript
/**
|
||
* 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 || ''),
|
||
})
|
||
}
|
||
}
|