核销时间提前到 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_PLATFORM,
|
||||||
OPEN_91_PROVIDER,
|
OPEN_91_PROVIDER,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
|
import { consumeIndustryVouchersBeforeOpen91Deliver } from './consume-on-deliver.js'
|
||||||
import {
|
import {
|
||||||
assertOpen91QueryPayload,
|
assertOpen91QueryPayload,
|
||||||
assertOpen91Timestamp,
|
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 outTradeNo = buildOpen91OutTradeNo(order)
|
||||||
const encryptedCards = buildOpen91Cards(cardItems, config)
|
const encryptedCards = buildOpen91Cards(cardItems, config)
|
||||||
const responseData = {
|
const responseData = {
|
||||||
@@ -178,6 +208,8 @@ export async function queryOpen91Order(payload: JsonObject = {}, { requestId = '
|
|||||||
cardsEncoding: config.cardsEncoding,
|
cardsEncoding: config.cardsEncoding,
|
||||||
cardsPlain: cardItems,
|
cardsPlain: cardItems,
|
||||||
cardsEncrypted: encryptedCards,
|
cardsEncrypted: encryptedCards,
|
||||||
|
deliverConsumedTaskIds: deliverConsume.consumedTaskIds,
|
||||||
|
deliverAlreadyConsumedTaskIds: deliverConsume.alreadyConsumedTaskIds,
|
||||||
responseData,
|
responseData,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1201,11 +1201,11 @@ function KuaishouCloudPanel({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'consume',
|
key: 'consume',
|
||||||
label: '电子凭证收口',
|
label: '电子凭证核销',
|
||||||
status: flow.consume.status || 'pending',
|
status: flow.consume.status || 'pending',
|
||||||
detail: flow.consume.consumedAt
|
detail: flow.consume.consumedAt
|
||||||
? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 完成收口`
|
? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 核销(链接交付或履约收口)`
|
||||||
: flow.consume.errorMessage || '等待退号后收口',
|
: flow.consume.errorMessage || '等待 91 交付链接时核销,或发货收口时补核销',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user