优化券码操作类型, 增加手动销毁

This commit is contained in:
yml2213
2026-07-12 18:46:30 +08:00
parent 1b8cdf16d3
commit 3d760f987c
16 changed files with 481 additions and 29 deletions
@@ -18,6 +18,7 @@ import {
} from './response.js'
import { destroyCallback } from './destroy-callback-service.js'
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
import { resolveKuaishouIndustryEticketType } from './voucher-service.js'
export async function handleDestroyCode(rawBody: JsonObject = {}) {
const config = getKuaishouIndustryConfig()
@@ -39,6 +40,9 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
const callbackSellerId = params.sellerId
|| String(targetVouchers.find((voucher) => String(voucher.seller_id || '').trim())?.seller_id || '').trim()
const callbackToken = params.token || resolveDestroyCallbackToken(targetVouchers)
const eticketType = resolveKuaishouIndustryEticketType(
targetVouchers[0] || null,
)
for (const voucher of targetVouchers) {
const status = String(voucher.status || '').trim().toUpperCase()
@@ -81,6 +85,7 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
})),
reason: params.reason,
token: callbackToken,
...(eticketType ? { eticketType } : {}),
})
return buildIndustrySuccessResponse({ oid: normalizedOid })
@@ -28,6 +28,7 @@ import {
buildKuaishouIndustryEticketFromVoucher,
isKuaishouIndustryVoucherSendCallbackSuccess,
KUAISHOU_INDUSTRY_SEND_CALLBACK_STATUS,
resolveKuaishouIndustryEticketType,
resolveKuaishouIndustryVoucherValidity,
} from './voucher-service.js'
import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js'
@@ -83,6 +84,7 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
unitIndex,
sellerId: params.sellerId,
token: params.token,
eticketType: params.eticketType,
orderId: order?.id || null,
taskId: task?.id || null,
status: 'UNUSED',
@@ -558,7 +560,7 @@ function resolveSendCodeCallbackParams(
sendType: String(body.sendType || 'VIRTUAL').trim() || 'VIRTUAL',
sellerId: String(body.sellerId || voucher.seller_id || '').trim(),
token: String(body.token || voucher.token || '').trim(),
eticketType: String(body.eticketType || '').trim(),
eticketType: resolveKuaishouIndustryEticketType(voucher, body.eticketType),
ext: String(body.ext || '').trim(),
}
}
@@ -6,6 +6,8 @@ import { buildKuaishouIndustryVoucherContext } from './voucher-binding-service.j
import {
buildKuaishouIndustryEticketFromVoucher,
normalizeKuaishouIndustryConsumeType,
normalizeKuaishouIndustryDestroyReason,
resolveKuaishouIndustryEticketType,
resolveKuaishouIndustryVoucherValidity,
} from './voucher-service.js'
@@ -82,6 +84,41 @@ test('buildKuaishouIndustryEticketFromVoucher mirrors voucher code into id and c
assert.equal(eticket.eticketType, 'GAME_OPEN_TICKET_CONSUME')
})
test('resolveKuaishouIndustryEticketType prefers explicit then column then raw payload', () => {
assert.equal(
resolveKuaishouIndustryEticketType(
{
eticket_type: 'GAME_OPEN_TICKET_CONSUME',
raw_payload_json: { body: { eticketType: 'DINING_OPEN_TICKET' } },
} as any,
'EXPLICIT_TYPE',
),
'EXPLICIT_TYPE',
)
assert.equal(
resolveKuaishouIndustryEticketType({
eticket_type: 'GAME_OPEN_TICKET_CONSUME',
raw_payload_json: { body: { eticketType: 'DINING_OPEN_TICKET' } },
} as any),
'GAME_OPEN_TICKET_CONSUME',
)
assert.equal(
resolveKuaishouIndustryEticketType({
eticket_type: '',
raw_payload_json: { body: { eticketType: 'GAME_OPEN_TICKET_CONSUME' } },
} as any),
'GAME_OPEN_TICKET_CONSUME',
)
})
test('normalizeKuaishouIndustryDestroyReason defaults to SYS_ADMIN_DESTROY', () => {
assert.equal(normalizeKuaishouIndustryDestroyReason(''), 'SYS_ADMIN_DESTROY')
assert.equal(normalizeKuaishouIndustryDestroyReason('supply_destroy'), 'SUPPLY_DESTROY')
assert.equal(normalizeKuaishouIndustryDestroyReason('unknown'), 'SYS_ADMIN_DESTROY')
})
test('buildKuaishouIndustryVoucherContext maps consumed voucher state', () => {
const context = buildKuaishouIndustryVoucherContext(
{
@@ -1,13 +1,17 @@
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { getTaskById, updateTask } from '../../../repositories/task-repo.js'
import { asJsonObject, type JsonObject } from '../../../types/json.js'
import {
findKuaishouIndustryVoucherByCode,
listKuaishouIndustryVouchersByTaskId,
updateKuaishouIndustryVoucherByCode,
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
import { TASK_STATUS } from '../../../domain/task-status.js'
import { logWarn } from '../../../utils/logger.js'
import { normalizeTimestampIso } from '../../../utils/time.js'
import { buildIndustryEticketItem } from './response.js'
import { consumeCallback } from './consume-callback-service.js'
import { destroyCallback } from './destroy-callback-service.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../../types/repository/rows.js'
export const KUAISHOU_INDUSTRY_DEFAULT_VALID_DAYS = 3
@@ -17,6 +21,12 @@ export const KUAISHOU_INDUSTRY_SEND_CALLBACK_STATUS = {
FAILED: 'failed',
} as const
export const KUAISHOU_INDUSTRY_DEFAULT_CONSUME_TYPE = 'consume'
export const KUAISHOU_INDUSTRY_DESTROY_REASONS = {
ETICKET_EXPIRED: 'ETICKET_EXPIRED',
USER_APPLY_REFUND: 'USER_APPLY_REFUND',
SUPPLY_DESTROY: 'SUPPLY_DESTROY',
SYS_ADMIN_DESTROY: 'SYS_ADMIN_DESTROY',
} as const
export function resolveKuaishouIndustryVoucherValidity(
input: {
@@ -50,6 +60,33 @@ export function resolveKuaishouIndustryVoucherValidity(
}
}
/**
* 解析电子凭证类型。优先级:
* 显式入参 > voucher.eticket_type > raw_payload.body.eticketType > raw_payload.eticketType
*/
export function resolveKuaishouIndustryEticketType(
voucher?: Pick<KuaishouIndustryVoucherRow, 'eticket_type' | 'raw_payload_json'> | null,
explicit?: unknown,
): string {
const fromInput = String(explicit || '').trim()
if (fromInput) {
return fromInput
}
if (!voucher) {
return ''
}
const fromColumn = String(voucher.eticket_type || '').trim()
if (fromColumn) {
return fromColumn
}
const rawPayload = parseJsonObject(voucher.raw_payload_json)
const body = parseJsonObject(rawPayload.body)
return String(body.eticketType || rawPayload.eticketType || '').trim()
}
export function buildKuaishouIndustryEticketFromVoucher(
voucher: KuaishouIndustryVoucherRow,
eticketType = '',
@@ -61,7 +98,7 @@ export function buildKuaishouIndustryEticketFromVoucher(
num: 1,
validStartTime: Number(voucher.valid_start_time || 0) || 0,
validEndTime: Number(voucher.valid_end_time || 0) || 0,
eticketType,
eticketType: resolveKuaishouIndustryEticketType(voucher, eticketType),
consumeDetails: resolveVoucherConsumeDetails(voucher),
})
}
@@ -274,6 +311,128 @@ export async function consumeKuaishouIndustryVoucher(
}
}
/**
* 主动销毁电子凭证(管理端/运维补救)。
* 先调快手销毁回调,成功后再落本地 DESTROYED 并关闭关联任务。
* 已 DESTROYED 时仅重试回调(幂等)。
*/
export async function destroyKuaishouIndustryVoucher(
voucher: KuaishouIndustryVoucherRow,
input: {
source?: string
reason?: string
token?: string
eticketType?: string
task?: TaskRow | null
skipCallback?: boolean
goodsValue?: number
} = {},
) {
const currentStatus = normalizeVoucherStatus(voucher.status)
if (currentStatus === 'CONSUMED') {
return {
ok: false,
voucher,
callbackSuccess: false,
errorMessage: '电子凭证已核销,不能销毁;如需作废请先冲正',
}
}
const reason = normalizeKuaishouIndustryDestroyReason(input.reason)
const now = normalizeTimestampIso(new Date().toISOString())
const token = String(input.token || voucher.token || '').trim()
const eticketType = resolveKuaishouIndustryEticketType(voucher, input.eticketType)
const alreadyDestroyed = currentStatus === 'DESTROYED'
const callbackResult = input.skipCallback
? { success: true as const }
: await destroyCallback({
oid: voucher.oid,
sellerId: String(voucher.seller_id || '').trim(),
etickets: [{
id: voucher.voucher_code,
code: voucher.voucher_code,
num: 1,
...(input.goodsValue != null ? { goodsValue: input.goodsValue } : {}),
}],
reason,
token,
...(eticketType ? { eticketType } : {}),
})
if (!callbackResult.success) {
return {
ok: false,
voucher,
callbackSuccess: false,
errorMessage: callbackResult.error || '电子凭证销毁回调失败',
}
}
let updated = voucher
if (!alreadyDestroyed) {
const next = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
status: 'DESTROYED',
destroyedAt: now,
updatedAt: now,
...(eticketType && !String(voucher.eticket_type || '').trim()
? { eticketType }
: {}),
})
updated = next || voucher
}
let task =
input.task ||
(voucher.task_id ? await getTaskById(voucher.task_id).catch(() => null) : null)
if (task && !alreadyDestroyed) {
task = await updateTask(task.id, {
task_status: TASK_STATUS.CLOSED,
delivery_status: 'cancelled',
result_code: reason,
result_message: `电子凭证销毁: ${reason}`,
updated_at: now,
}) || task
await createTaskEvent(
task.id,
'kuaishou_industry_voucher_destroyed',
{
oid: voucher.oid,
voucherCode: voucher.voucher_code,
reason,
source: input.source || 'admin_tool_manual_destroy',
eticketType,
},
now,
)
}
return {
ok: true,
voucher: updated,
callbackSuccess: true,
alreadyDestroyed,
reason,
task: task || null,
}
}
export function normalizeKuaishouIndustryDestroyReason(value: unknown): string {
const normalized = String(value || '').trim().toUpperCase()
if (
normalized === KUAISHOU_INDUSTRY_DESTROY_REASONS.ETICKET_EXPIRED ||
normalized === KUAISHOU_INDUSTRY_DESTROY_REASONS.USER_APPLY_REFUND ||
normalized === KUAISHOU_INDUSTRY_DESTROY_REASONS.SUPPLY_DESTROY ||
normalized === KUAISHOU_INDUSTRY_DESTROY_REASONS.SYS_ADMIN_DESTROY
) {
return normalized
}
return KUAISHOU_INDUSTRY_DESTROY_REASONS.SYS_ADMIN_DESTROY
}
function resolveTaskVouchers(task: TaskRow): Promise<KuaishouIndustryVoucherRow[]> {
const taskId = Number(task?.id || 0)
if (taskId > 0) {