核销时间提前到 91 返回链接
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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 交付链接时核销,或发货收口时补核销',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user