feat(kuaishou-industry): 实现电子凭证自动核销推进器与查码流程优化

- 新增 voucher-auto-consume-service,对非发单平台大厅待发布任务实现生成即核销与发码成功闭环核销
- 严格保留发单平台待发布工单(pending_material/unassigned)在大厅发布时核销逻辑
- manual-executor 在任务生成进入 MANUAL_REVIEW 时直接触发核销
- send-code-service 在发码出站回调成功后自动调度非大厅任务的凭证核销
- 优化 query-code-service 查码接口,避免发码初期误报 4012002 订单不存在
- 标记废弃 kuaishou_feifei 渠道模块
- 补齐 voucher-auto-consume-service 单元测试用例
This commit is contained in:
yml2213
2026-09-10 10:01:40 +08:00
parent 4544622cb3
commit 076b113ae5
8 changed files with 601 additions and 21 deletions
@@ -9,6 +9,9 @@ import {
} from './types.js'
import type { TaskRow } from '../../../types/repository/rows.js'
/**
* @deprecated 待移除,快手飞飞直充渠道已废弃,不再使用。
*/
export const kuaishouFeifeiExecutor: FulfillmentExecutor = {
key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
preparePaidTask,
@@ -1,4 +1,5 @@
import { TASK_STATUS } from '../../../domain/task-status.js'
import { autoConsumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-auto-consume-service.js'
import {
FULFILLMENT_EXECUTOR_KEYS,
type FulfillmentExecutor,
@@ -29,5 +30,12 @@ async function preparePaidTask(
source: 'manual_dispatch_profile',
})
const effectiveTask = updatedTask || task
await autoConsumeKuaishouIndustryVouchersForTask(effectiveTask, {
source: 'manual_dispatch_prepared',
}).catch(() => {
// 忽略异常,发码回调完成后仍会自动补核销
})
return updatedTask
}
@@ -18,6 +18,9 @@ import { buildKuaishouIndustryVoucherContext } from '../../platforms/kuaishou-in
import { normalizeShortLinkPayload } from '../../short-links/short-link-service.js'
import type { TaskRow } from '../../../types/repository/rows.js'
/**
* @deprecated 待移除,快手飞飞直充渠道已废弃,不再使用。
*/
export function isKuaishouFeifeiTask(task: Partial<TaskRow> | null | undefined) {
return String(task?.executor_key || '').trim() === 'kuaishou_feifei'
}
@@ -11,10 +11,7 @@ import {
buildIndustryErrorResponse,
buildIndustryQueryCodeData,
} from './response.js'
import {
buildKuaishouIndustryEticketFromVoucher,
isKuaishouIndustryVoucherSendCallbackSuccess,
} from './voucher-service.js'
import { buildKuaishouIndustryEticketFromVoucher } from './voucher-service.js'
export async function handleQueryCode(rawBody: JsonObject = {}) {
const config = getKuaishouIndustryConfig()
@@ -32,9 +29,6 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
if (!matched) {
return buildIndustryErrorResponse(4012005, `卡券不存在: ${params.eticketId}`)
}
if (!isKuaishouIndustryVoucherSendCallbackSuccess(matched)) {
return buildIndustryErrorResponse(4012005, `卡券尚未完成发码: ${params.eticketId}`)
}
const eticket = buildKuaishouIndustryEticketFromVoucher(matched, params.eticketType)
return buildIndustrySuccessResponse(
@@ -47,9 +41,7 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
)
}
const vouchers = (await listKuaishouIndustryVouchersByOid(normalizedOid)).filter(
isKuaishouIndustryVoucherSendCallbackSuccess,
)
const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid)
if (vouchers.length === 0) {
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
}
@@ -30,6 +30,7 @@ import {
resolveKuaishouIndustryVoucherValidity,
} from './voucher-service.js'
import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js'
import { autoConsumeKuaishouIndustryVouchersForOrder } from './voucher-auto-consume-service.js'
import { parseAmountToFen } from '../../../utils/money.js'
import {
publishAdminOrderRealtimeChange,
@@ -503,20 +504,45 @@ async function syncOpen91OrderAfterSendCallbackSuccess(
now: normalizeTimestampIso(new Date().toISOString()),
})
}
if (isOpen91OrderAwaitingFulfillmentConfig(error)) {
return
if (!isOpen91OrderAwaitingFulfillmentConfig(error)) {
logIntegration(
'[kuaishou-industry/send-code]',
'电子凭证发货回调成功后重试 91 订单失败',
{
source,
oid: normalizedOid,
orderId: order.id,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
}
logIntegration(
'[kuaishou-industry/send-code]',
'电子凭证发货回调成功后重试 91 订单失败',
{
}
try {
const autoConsumeResult = await autoConsumeKuaishouIndustryVouchersForOrder(normalizedOid, {
source: `${source}_auto_consume`,
})
if (autoConsumeResult.consumedCount > 0 || autoConsumeResult.failedCount > 0) {
logIntegration('[kuaishou-industry/send-code]', '发码回调成功后自动推进电子凭证核销完成', {
source,
oid: normalizedOid,
orderId: order.id,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
consumedCount: autoConsumeResult.consumedCount,
alreadyConsumedCount: autoConsumeResult.alreadyConsumedCount,
skippedCount: autoConsumeResult.skippedCount,
failedCount: autoConsumeResult.failedCount,
skippedReasons: autoConsumeResult.skippedReasons,
errors: autoConsumeResult.errors,
})
}
} catch (consumeError) {
logWarn('[kuaishou-industry/send-code]', '发码回调成功后自动推进电子凭证核销异常', {
source,
oid: normalizedOid,
orderId: order.id,
error: consumeError instanceof Error ? consumeError.message : String(consumeError),
})
}
}
@@ -0,0 +1,262 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
autoConsumeKuaishouIndustryVouchersForOrder,
autoConsumeKuaishouIndustryVouchersForTask,
} from './voucher-auto-consume-service.js'
import { WORK_ORDER_STATUS } from '../../../domain/work-order-status.js'
import type {
KuaishouIndustryVoucherRow,
OrderRow,
TaskRow,
} from '../../../types/repository/rows.js'
import type { WorkOrderRow } from '../../../repositories/worker-platform/index.js'
function buildMockVoucher(
overrides: Partial<KuaishouIndustryVoucherRow> = {},
): KuaishouIndustryVoucherRow {
return {
id: 1,
voucher_code: 'KSV001',
oid: '26250000000001',
order_id: 100,
task_id: 200,
unit_index: 1,
seller_id: '4269276762',
token: 'token-1',
eticket_type: 'GAME_OPEN_TICKET_CONSUME',
status: 'UNUSED',
valid_start_time: 1789000000000,
valid_end_time: 1791592000000,
consume_serial_num: '',
consume_details_json: '[]',
consumed_at: null,
destroyed_at: null,
raw_payload_json: '{}',
send_callback_status: 'success',
send_callback_attempt_count: 1,
send_callback_last_error: '',
send_callback_response_json: '{}',
send_callback_sent_at: '2026-09-10T09:00:00.000Z',
created_at: '2026-09-10T09:00:00.000Z',
updated_at: '2026-09-10T09:00:00.000Z',
...overrides,
}
}
function buildMockTask(overrides: Partial<TaskRow> = {}): TaskRow {
return {
id: 200,
order_id: 100,
order_item_id: 300,
provider: 'open_91',
platform: '91kaquan',
shop_id: '4269276762',
platform_order_id: '26250000000001',
task_no: 'TASK-001',
unit_index: 1,
task_status: 'manual_review',
delivery_status: 'pending',
user_action_status: 'not_required',
executor_key: 'manual_dispatch',
sku_code: 'SKU001',
sku_name: '测试商品',
quantity: 1,
requires_claim: false,
auto_dispatch: false,
claim_token: '',
claim_expires_at: null,
primary_claim_token_id: null,
primary_claim_token: '',
primary_claim_expires_at: null,
last_error: '',
result_code: '',
result_message: '',
context_json: '{}',
claimed_at: null,
role_confirmed_at: null,
redeemed_at: null,
created_at: '2026-09-10T09:00:00.000Z',
updated_at: '2026-09-10T09:00:00.000Z',
...overrides,
}
}
function buildMockWorkOrder(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
return {
id: 500,
work_order_no: 'WO-001',
order_id: 100,
order_item_id: 300,
task_id: 200,
source: 'test',
status: WORK_ORDER_STATUS.UNASSIGNED,
assigned_worker_id: null,
assigned_at: null,
completed_at: null,
reward_amount: 1000,
refund_amount: 0,
platform_order_id: '26250000000001',
product_name: '测试工单',
material_json: '{}',
created_at: '2026-09-10T09:00:00.000Z',
updated_at: '2026-09-10T09:00:00.000Z',
...overrides,
} as unknown as WorkOrderRow
}
test('缺少 oid 时直接返回 skipped', async () => {
const result = await autoConsumeKuaishouIndustryVouchersForOrder('')
assert.equal(result.skippedCount, 1)
assert.deepEqual(result.skippedReasons, ['missing_oid'])
})
test('未找到凭证时直接返回 skipped', async () => {
const result = await autoConsumeKuaishouIndustryVouchersForOrder(
'26250000000001',
{},
{
listVouchersByOid: async () => [],
},
)
assert.equal(result.voucherCount, 0)
assert.equal(result.skippedCount, 1)
assert.deepEqual(result.skippedReasons, ['voucher_not_found'])
})
test('已核销的凭证不重复核销,跳过并记录 already_consumed', async () => {
const voucher = buildMockVoucher({ status: 'CONSUMED' })
let consumeCalled = false
const result = await autoConsumeKuaishouIndustryVouchersForOrder(
'26250000000001',
{},
{
listVouchersByOid: async () => [voucher],
consumeVoucher: async () => {
consumeCalled = true
return { ok: true, voucher, callbackSuccess: true }
},
},
)
assert.equal(consumeCalled, false)
assert.equal(result.alreadyConsumedCount, 1)
assert.equal(result.skippedCount, 1)
assert.match(result.skippedReasons[0] || '', /already_consumed/)
})
test('发码出站回调未确认时(pending 或 failed),安全跳过不触发核销', async () => {
const voucher = buildMockVoucher({ send_callback_status: 'pending' })
let consumeCalled = false
const result = await autoConsumeKuaishouIndustryVouchersForOrder(
'26250000000001',
{},
{
listVouchersByOid: async () => [voucher],
consumeVoucher: async () => {
consumeCalled = true
return { ok: true, voucher, callbackSuccess: true }
},
},
)
assert.equal(consumeCalled, false)
assert.equal(result.consumedCount, 0)
assert.equal(result.skippedCount, 1)
assert.match(result.skippedReasons[0] || '', /send_callback_unconfirmed/)
})
test('发单平台待发布大厅工单(unassigned/pending_material):跳过自动核销,保留在发布大厅时核销', async () => {
const voucher = buildMockVoucher({ task_id: 200 })
const task = buildMockTask({ id: 200 })
const workOrder = buildMockWorkOrder({ task_id: 200, status: WORK_ORDER_STATUS.UNASSIGNED })
let consumeCalled = false
const result = await autoConsumeKuaishouIndustryVouchersForOrder(
'26250000000001',
{},
{
listVouchersByOid: async () => [voucher],
findOrder: async () => ({ id: 100 }) as OrderRow,
listTasks: async () => [task],
listWorkOrders: async () => [workOrder],
consumeVoucher: async () => {
consumeCalled = true
return { ok: true, voucher, callbackSuccess: true }
},
},
)
assert.equal(consumeCalled, false)
assert.equal(result.consumedCount, 0)
assert.equal(result.skippedCount, 1)
assert.match(result.skippedReasons[0] || '', /waiting_work_order_hall_publish/)
})
test('常规非大厅订单(人工派单/其他直接任务):发码成功后立即自动核销', async () => {
const voucher = buildMockVoucher({ task_id: 200, send_callback_status: 'success' })
const task = buildMockTask({ id: 200, executor_key: 'manual_dispatch' })
let consumeCalled = false
let attachCalled = false
const result = await autoConsumeKuaishouIndustryVouchersForOrder(
'26250000000001',
{ source: 'test_auto_consume' },
{
listVouchersByOid: async () => [voucher],
findOrder: async () => ({ id: 100 }) as OrderRow,
listTasks: async () => [task],
listWorkOrders: async () => [], // 无发单平台待发布工单
consumeVoucher: async (v, input) => {
consumeCalled = true
assert.equal(input.source, 'test_auto_consume')
return {
ok: true,
voucher: { ...v, status: 'CONSUMED' },
callbackSuccess: true,
}
},
attachVoucher: async () => {
attachCalled = true
return task
},
},
)
assert.equal(consumeCalled, true)
assert.equal(attachCalled, true)
assert.equal(result.consumedCount, 1)
assert.equal(result.failedCount, 0)
})
test('autoConsumeKuaishouIndustryVouchersForTask 正确提取任务 platform_order_id 并推进', async () => {
const task = buildMockTask({ platform_order_id: '26250000000001' })
const voucher = buildMockVoucher({ task_id: 200, send_callback_status: 'success' })
let consumeCalled = false
const result = await autoConsumeKuaishouIndustryVouchersForTask(
task,
{ source: 'task_test' },
{
listVouchersByOid: async () => [voucher],
findOrder: async () => ({ id: 100 }) as OrderRow,
listTasks: async () => [task],
listWorkOrders: async () => [],
consumeVoucher: async (v) => {
consumeCalled = true
return {
ok: true,
voucher: { ...v, status: 'CONSUMED' },
callbackSuccess: true,
}
},
attachVoucher: async () => task,
},
)
assert.equal(consumeCalled, true)
assert.equal(result.consumedCount, 1)
})
@@ -0,0 +1,286 @@
import { listKuaishouIndustryVouchersByOid } from '../../../repositories/kuaishou-industry-voucher-repo.js'
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
import { listWorkOrdersByPlatformOrderId } from '../../../repositories/worker-platform/work-order-query-repo.js'
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
import {
consumeKuaishouIndustryVoucher,
isKuaishouIndustryVoucherSendCallbackSuccess,
normalizeVoucherStatus,
} from './voucher-service.js'
import { logIntegration, logWarn } from '../../../utils/logger.js'
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js'
import { normalizeWorkOrderStatus, WORK_ORDER_STATUS } from '../../../domain/work-order-status.js'
import type {
KuaishouIndustryVoucherRow,
OrderRow,
TaskRow,
} from '../../../types/repository/rows.js'
import type { WorkOrderRow } from '../../../repositories/worker-platform/index.js'
export type VoucherAutoConsumeResult = {
oid: string
voucherCount: number
consumedCount: number
alreadyConsumedCount: number
skippedCount: number
failedCount: number
skippedReasons: string[]
errors: string[]
}
type VoucherAutoConsumeDeps = {
listVouchersByOid?: typeof listKuaishouIndustryVouchersByOid
findOrder?: typeof findLatestOrderByPlatformOrderId
listTasks?: typeof listTasksByOrderId
listWorkOrders?: typeof listWorkOrdersByPlatformOrderId
consumeVoucher?: typeof consumeKuaishouIndustryVoucher
attachVoucher?: typeof attachKuaishouIndustryVoucherToTask
}
/**
* 自动推进快手电子凭证核销:
*
* 规则:
* 1. 凭证必须已完成发码出站回调(send_callback_status = success),否则快手开放平台不可核销;
* 2. 路由到发单平台的工单:如果工单处于待发布大厅/待派单状态(pending_material / unassigned),
* 严格跳过,保留在「发布到大厅 / 指派接单员」时由 publish-voucher-service 触发核销;
* 3. 其他所有订单(人工派单 manual_dispatch、分销代充 affiliate_dash、已在工单大厅等其他常规任务):
* 在任务生成或发码确认后,立即执行出站核销并回写任务上下文,消除买家未消费极速退款窗口。
*/
export async function autoConsumeKuaishouIndustryVouchersForOrder(
oid: string,
options: { source?: string } = {},
deps: VoucherAutoConsumeDeps = {},
): Promise<VoucherAutoConsumeResult> {
const normalizedOid = String(oid || '').trim()
const source = String(options.source || 'auto_consume').trim() || 'auto_consume'
const result: VoucherAutoConsumeResult = {
oid: normalizedOid,
voucherCount: 0,
consumedCount: 0,
alreadyConsumedCount: 0,
skippedCount: 0,
failedCount: 0,
skippedReasons: [],
errors: [],
}
if (!normalizedOid) {
result.skippedCount += 1
result.skippedReasons.push('missing_oid')
return result
}
const listVouchers = deps.listVouchersByOid || listKuaishouIndustryVouchersByOid
const findOrder = deps.findOrder || findLatestOrderByPlatformOrderId
const listTasks = deps.listTasks || listTasksByOrderId
const listWorkOrders = deps.listWorkOrders || listWorkOrdersByPlatformOrderId
const consumeVoucher = deps.consumeVoucher || consumeKuaishouIndustryVoucher
const attachVoucher = deps.attachVoucher || attachKuaishouIndustryVoucherToTask
const vouchers = await listVouchers(normalizedOid)
result.voucherCount = vouchers.length
if (vouchers.length === 0) {
result.skippedCount += 1
result.skippedReasons.push('voucher_not_found')
return result
}
const candidateVouchers: KuaishouIndustryVoucherRow[] = []
for (const voucher of vouchers) {
const voucherCode = String(voucher.voucher_code || '').trim()
const status = normalizeVoucherStatus(voucher.status)
if (status === 'CONSUMED') {
result.alreadyConsumedCount += 1
result.skippedCount += 1
result.skippedReasons.push(`voucher_${voucherCode}:already_consumed`)
continue
}
if (status === 'DESTROYED') {
result.skippedCount += 1
result.skippedReasons.push(`voucher_${voucherCode}:already_destroyed`)
continue
}
if (!isKuaishouIndustryVoucherSendCallbackSuccess(voucher)) {
result.skippedCount += 1
result.skippedReasons.push(`voucher_${voucherCode}:send_callback_unconfirmed`)
continue
}
candidateVouchers.push(voucher)
}
if (candidateVouchers.length === 0) {
return result
}
const [order, workOrders] = await Promise.all([
findOrder({
provider: OPEN_91_PROVIDER,
platform: OPEN_91_PLATFORM,
platformOrderId: normalizedOid,
}),
listWorkOrders(normalizedOid),
])
const tasks = order ? await listTasks(order.id) : []
for (const voucher of candidateVouchers) {
const voucherCode = String(voucher.voucher_code || '').trim()
const matchedTask = resolveTaskForVoucher(voucher, tasks)
const matchedWorkOrder = resolveWorkOrderForVoucher(voucher, matchedTask, workOrders)
// 发单平台工单:待发布到大厅或待补充资料时,不提前核销,等待发布大厅动作
if (matchedWorkOrder && isWorkOrderWaitingHallPublish(matchedWorkOrder)) {
result.skippedCount += 1
result.skippedReasons.push(
`voucher_${voucherCode}:waiting_work_order_hall_publish(wo_${matchedWorkOrder.id})`,
)
continue
}
try {
const consumeRes = await consumeVoucher(voucher, {
source,
consumeTime: Date.now(),
...(matchedTask ? { task: matchedTask } : {}),
})
if (!consumeRes.ok || !consumeRes.voucher) {
result.failedCount += 1
const errMsg = consumeRes.errorMessage || `电子凭证 ${voucherCode} 核销失败`
result.errors.push(errMsg)
logWarn('[kuaishou-industry/auto-consume]', `电子凭证 ${voucherCode} 自动核销失败`, {
oid: normalizedOid,
voucherCode,
source,
error: errMsg,
})
continue
}
result.consumedCount += 1
if (matchedTask) {
await attachVoucher(matchedTask, consumeRes.voucher, {
source,
now: new Date().toISOString(),
}).catch((err) => {
logWarn('[kuaishou-industry/auto-consume]', '核销后回写任务上下文异常', {
oid: normalizedOid,
voucherCode,
taskId: matchedTask.id,
error: err instanceof Error ? err.message : String(err),
})
})
}
logIntegration('[kuaishou-industry/auto-consume]', `电子凭证 ${voucherCode} 自动核销成功`, {
oid: normalizedOid,
voucherCode,
source,
taskId: matchedTask?.id || null,
})
} catch (error) {
result.failedCount += 1
const errMsg = error instanceof Error ? error.message : String(error)
result.errors.push(errMsg)
logWarn('[kuaishou-industry/auto-consume]', `电子凭证 ${voucherCode} 自动核销异常`, {
oid: normalizedOid,
voucherCode,
source,
error: errMsg,
})
}
}
return result
}
/**
* 针对指定履约任务执行电子凭证自动核销。
*/
export async function autoConsumeKuaishouIndustryVouchersForTask(
task: TaskRow,
options: { source?: string } = {},
deps: VoucherAutoConsumeDeps = {},
): Promise<VoucherAutoConsumeResult> {
const oid = String(task.platform_order_id || '').trim()
if (!oid) {
return {
oid: '',
voucherCount: 0,
consumedCount: 0,
alreadyConsumedCount: 0,
skippedCount: 1,
failedCount: 0,
skippedReasons: ['task_missing_platform_order_id'],
errors: [],
}
}
return autoConsumeKuaishouIndustryVouchersForOrder(
oid,
{ source: options.source || 'task_prepared_auto_consume' },
deps,
)
}
function resolveTaskForVoucher(
voucher: KuaishouIndustryVoucherRow,
tasks: TaskRow[],
): TaskRow | null {
const voucherTaskId = Number(voucher.task_id || 0)
if (voucherTaskId > 0) {
const byId = tasks.find((t) => Number(t.id) === voucherTaskId)
if (byId) return byId
}
const unitIndex = Number(voucher.unit_index || 0)
if (unitIndex > 0) {
const byUnit = tasks.find((t) => Number(t.unit_index || 0) === unitIndex)
if (byUnit) return byUnit
}
return tasks[0] || null
}
function resolveWorkOrderForVoucher(
voucher: KuaishouIndustryVoucherRow,
task: TaskRow | null,
workOrders: WorkOrderRow[],
): WorkOrderRow | null {
if (workOrders.length === 0) {
return null
}
const voucherTaskId = Number(voucher.task_id || 0)
if (voucherTaskId > 0) {
const byTaskId = workOrders.find((wo) => Number(wo.task_id || 0) === voucherTaskId)
if (byTaskId) return byTaskId
}
if (task) {
const taskId = Number(task.id || 0)
const byTask = workOrders.find((wo) => Number(wo.task_id || 0) === taskId)
if (byTask) return byTask
const orderItemId = Number(task.order_item_id || 0)
if (orderItemId > 0) {
const byItem = workOrders.find((wo) => Number(wo.order_item_id || 0) === orderItemId)
if (byItem) return byItem
}
}
return null
}
function isWorkOrderWaitingHallPublish(workOrder: WorkOrderRow): boolean {
const status = normalizeWorkOrderStatus(workOrder.status)
return status === WORK_ORDER_STATUS.PENDING_MATERIAL || status === WORK_ORDER_STATUS.UNASSIGNED
}
@@ -497,7 +497,7 @@ function appendConsumeDetail(details: JsonObject[], nextDetail: JsonObject): Jso
return [...details, nextDetail]
}
function normalizeVoucherStatus(value: unknown): string {
export function normalizeVoucherStatus(value: unknown): string {
const normalized = String(value || '')
.trim()
.toUpperCase()