核销时间提前到 91 返回链接

This commit is contained in:
yml2213
2026-07-11 21:09:25 +08:00
parent b0c77e5842
commit bae8715f5d
4 changed files with 276 additions and 3 deletions
@@ -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<KuaishouIndustryVoucherRow> & {
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)
})
@@ -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<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
}
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,
}
}
@@ -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,
})