feat(kuaishou): 增加历史电子券补发并核销

This commit is contained in:
yml2213
2026-11-06 10:38:26 +08:00
parent 1bf1280877
commit 398eafa911
10 changed files with 607 additions and 1 deletions
@@ -9,6 +9,7 @@ import {
listAdminKuaishouIndustryRefunds,
listAdminKuaishouIndustryShops,
listAdminKuaishouIndustryVouchers,
reissueAndConsumeAdminKuaishouIndustryVoucher,
resendAdminKuaishouIndustryVoucherCode,
reverseAdminKuaishouIndustryVoucher,
} from '../../services/admin/kuaishou-industry-admin-service.js'
@@ -21,6 +22,7 @@ import type {
AdminKuaishouIndustryVoucherCheckAvailableRouteBody,
AdminKuaishouIndustryVoucherConsumeRouteBody,
AdminKuaishouIndustryVoucherDestroyRouteBody,
AdminKuaishouIndustryVoucherReissueConsumeRouteBody,
AdminKuaishouIndustryVoucherResendRouteBody,
AdminKuaishouIndustryVoucherReverseRouteBody,
} from '../../types/admin/route-inputs.js'
@@ -204,6 +206,24 @@ router.post(
),
)
router.post(
'/kuaishou-industry/vouchers/reissue-consume',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
reissueAndConsumeAdminKuaishouIndustryVoucher(
req.body as AdminKuaishouIndustryVoucherReissueConsumeRouteBody,
),
{
successMessage: '历史券补发并核销已执行',
errorMessage: '历史券补发并核销失败',
scope: '[admin/kuaishou-industry/vouchers/reissue-consume]',
audit: (req, data) =>
buildKuaishouIndustryAudit('kuaishou_industry_voucher_reissue_consume', req.body, data),
},
),
)
router.post(
'/kuaishou-industry/vouchers/destroy',
requireAdminRoles(['admin', 'operator']),
@@ -238,6 +258,13 @@ function buildKuaishouIndustryAudit(action: string, body: unknown, data: unknown
success: Boolean(result.success),
httpStatus: Number(result.httpStatus || 0) || 0,
error: String(result.error || ''),
stage: String(result.stage || ''),
reason: String(result.reason || ''),
validMinutes: Number(result.validMinutes || 0) || 0,
originalValidEndTime: Number(result.originalValidEndTime || 0) || 0,
validEndTime: Number(result.validEndTime || 0) || 0,
sendSuccess: Boolean(result.sendSuccess),
consumeSuccess: Boolean(result.consumeSuccess),
},
}
}
@@ -27,6 +27,7 @@ import { resendKuaishouIndustryVoucherSendCallback } from '../platforms/kuaishou
import {
consumeKuaishouIndustryVoucher,
destroyKuaishouIndustryVoucher,
isKuaishouIndustryVoucherSendCallbackSuccess,
resolveKuaishouIndustryEticketType,
} from '../platforms/kuaishou-industry/voucher-service.js'
import {
@@ -39,6 +40,8 @@ import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-indus
import type { KuaishouIndustryOpenApiCallResult } from '../platforms/kuaishou-industry/openapi-client.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
const historicalReissueInFlight = new Set<string>()
export async function listAdminKuaishouIndustryVouchers(
input: KuaishouIndustryVoucherAdminListQuery = {},
) {
@@ -201,11 +204,204 @@ export async function resendAdminKuaishouIndustryVoucherCode(input: JsonObject =
return {
success: result.success,
response: 'response' in result ? result.response || null : null,
error: result.error || '',
error:
result.error ||
resolveKuaishouOpenApiErrorMessage('response' in result ? result.response : null),
voucher: result.voucher ? mapAdminKuaishouIndustryVoucher(result.voucher) : null,
}
}
/**
* 历史发码失败券的受控补偿入口。
*
* 只允许未使用券进入;先把本地有效期临时改到未来,再复用原券码发码。
* 发码成功后立即核销,避免为了补回调把历史券重新暴露给买家。
*/
export async function reissueAndConsumeAdminKuaishouIndustryVoucher(input: JsonObject = {}) {
const voucher = await resolveRequiredVoucher(input)
const voucherCode = String(voucher.voucher_code || '').trim()
const accountStatus = String(voucher.status || '')
.trim()
.toUpperCase()
const nowMs = Date.now()
const originalValidEndTime = Number(voucher.valid_end_time || 0) || 0
if (accountStatus !== 'UNUSED') {
throw createHttpError('仅未使用电子凭证可以补发并核销', {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_reissue_voucher_not_unused',
})
}
if (isKuaishouIndustryVoucherSendCallbackSuccess(voucher) && originalValidEndTime > nowMs) {
throw createHttpError('该电子凭证已发码成功且仍在有效期,请直接执行手动核销', {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_reissue_voucher_already_active',
})
}
if (historicalReissueInFlight.has(voucherCode)) {
throw createHttpError('该电子凭证正在补发,请勿重复提交', {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_reissue_in_progress',
})
}
const validity = resolveKuaishouIndustryHistoricalReissueWindow(input.validMinutes, nowMs)
const reason = normalizeKuaishouIndustryHistoricalReissueReason(input.reason)
const originalValidStartTime = Number(voucher.valid_start_time || 0) || 0
const validStartTime =
originalValidStartTime > 0 ? Math.min(originalValidStartTime, nowMs - 1_000) : nowMs - 1_000
historicalReissueInFlight.add(voucherCode)
try {
const prepared = await updateKuaishouIndustryVoucherByCode(voucherCode, {
validStartTime,
validEndTime: validity.validEndTime,
sendCallbackStatus: 'pending',
sendCallbackLastError: '',
sendCallbackResponseJson: {},
sendCallbackSentAt: null,
updatedAt: new Date().toISOString(),
})
if (!prepared) {
throw createHttpError('准备历史券补发时未找到电子凭证', {
statusCode: 404,
errorCode: 'admin_kuaishou_industry_reissue_voucher_not_found',
})
}
const sendResult = await resendAdminKuaishouIndustryVoucherCode({
voucherCode,
oid: voucher.oid,
orderId: voucher.oid,
})
if (!sendResult.success) {
return {
success: false,
stage: 'send',
reason,
validMinutes: validity.validMinutes,
originalValidEndTime,
validEndTime: validity.validEndTime,
sendSuccess: false,
consumeSuccess: false,
response: sendResult.response,
error: sendResult.error || '电子凭证发码回调失败',
voucher: sendResult.voucher,
task: null,
}
}
const sentVoucher = await findKuaishouIndustryVoucherByCode(voucherCode, voucher.oid)
if (!sentVoucher || !isKuaishouIndustryVoucherSendCallbackSuccess(sentVoucher)) {
return {
success: false,
stage: 'send',
reason,
validMinutes: validity.validMinutes,
originalValidEndTime,
validEndTime: validity.validEndTime,
sendSuccess: false,
consumeSuccess: false,
response: sendResult.response,
error: '电子凭证发码回调返回成功,但本地状态未更新',
voucher: sentVoucher ? mapAdminKuaishouIndustryVoucher(sentVoucher) : null,
task: null,
}
}
const task = sentVoucher.task_id ? await getTaskById(sentVoucher.task_id) : null
const consumeType = String(input.consumeType || '').trim()
const consumeInput: Parameters<typeof consumeKuaishouIndustryVoucher>[1] = {
source: 'admin_historical_reissue_consume',
token: String(input.token || sentVoucher.token || '').trim(),
consumeTime: Date.now(),
...(consumeType ? { consumeType } : {}),
...(input.serialNum ? { serialNum: String(input.serialNum).trim() } : {}),
...(input.storeName ? { storeName: String(input.storeName).trim() } : {}),
...(input.storeAddress ? { storeAddress: String(input.storeAddress).trim() } : {}),
...(input.expressCode ? { expressCode: String(input.expressCode).trim() } : {}),
...(input.expressNo ? { expressNo: String(input.expressNo).trim() } : {}),
}
if (task) {
consumeInput.task = task
}
const consumeResult = await consumeKuaishouIndustryVoucher(sentVoucher, consumeInput)
if (!consumeResult.ok || !consumeResult.voucher) {
return {
success: false,
stage: 'consume',
reason,
validMinutes: validity.validMinutes,
originalValidEndTime,
validEndTime: validity.validEndTime,
sendSuccess: true,
consumeSuccess: false,
response: sendResult.response,
error: consumeResult.errorMessage || '电子凭证发码成功,但核销失败',
voucher: consumeResult.voucher
? mapAdminKuaishouIndustryVoucher(consumeResult.voucher)
: mapAdminKuaishouIndustryVoucher(sentVoucher),
task: task ? mapTaskReference(task) : null,
}
}
return {
success: true,
stage: 'completed',
reason,
validMinutes: validity.validMinutes,
originalValidEndTime,
validEndTime: validity.validEndTime,
sendSuccess: true,
consumeSuccess: true,
response: sendResult.response,
error: '',
voucher: mapAdminKuaishouIndustryVoucher(consumeResult.voucher),
task: task ? mapTaskReference(task) : null,
}
} finally {
historicalReissueInFlight.delete(voucherCode)
}
}
export function resolveKuaishouIndustryHistoricalReissueWindow(
rawMinutes: unknown,
nowMs = Date.now(),
) {
const validMinutes = Number(rawMinutes)
if (!Number.isInteger(validMinutes) || validMinutes < 1 || validMinutes > 60) {
throw createHttpError('临时有效期必须是 1 到 60 分钟的整数', {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_reissue_valid_minutes_invalid',
})
}
return {
validMinutes,
validEndTime: nowMs + validMinutes * 60_000,
}
}
export function normalizeKuaishouIndustryHistoricalReissueReason(rawReason: unknown) {
const reason = String(rawReason || '').trim()
if (!reason) {
throw createHttpError('补发原因不能为空', {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_reissue_reason_required',
})
}
if (reason.length > 200) {
throw createHttpError('补发原因不能超过 200 个字符', {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_reissue_reason_too_long',
})
}
return reason
}
export async function destroyAdminKuaishouIndustryVoucherByCode(input: JsonObject = {}) {
const voucher = await resolveRequiredVoucher(input)
const checkPayload = buildVoucherOpenApiPayload(input, voucher)
@@ -485,6 +681,14 @@ function mapOpenApiResult(result: KuaishouIndustryOpenApiCallResult) {
}
}
function resolveKuaishouOpenApiErrorMessage(response: unknown) {
if (!response || typeof response !== 'object' || Array.isArray(response)) {
return ''
}
const current = response as Record<string, unknown>
return String(current.error_msg || current.sub_msg || current.message || current.msg || '').trim()
}
function sanitizeOpenApiRequest(request: JsonObject | undefined): JsonObject | null {
if (!request) {
return null
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import {
normalizeKuaishouIndustryHistoricalReissueReason,
resolveKuaishouIndustryHistoricalReissueWindow,
} from './kuaishou-industry-admin-service.js'
test('历史券补发窗口只允许 1 到 60 分钟', () => {
const nowMs = 1_800_000_000_000
assert.deepEqual(resolveKuaishouIndustryHistoricalReissueWindow(10, nowMs), {
validMinutes: 10,
validEndTime: nowMs + 10 * 60_000,
})
assert.throws(() => resolveKuaishouIndustryHistoricalReissueWindow(0, nowMs), /1 到 60 分钟/)
assert.throws(() => resolveKuaishouIndustryHistoricalReissueWindow(61, nowMs), /1 到 60 分钟/)
assert.throws(() => resolveKuaishouIndustryHistoricalReissueWindow('1.5', nowMs), /1 到 60 分钟/)
})
test('历史券补发原因必填且限制长度', () => {
assert.equal(
normalizeKuaishouIndustryHistoricalReissueReason(' 历史发码失败补发 '),
'历史发码失败补发',
)
assert.throws(() => normalizeKuaishouIndustryHistoricalReissueReason(' '), /不能为空/)
assert.throws(
() => normalizeKuaishouIndustryHistoricalReissueReason('x'.repeat(201)),
/不能超过 200/,
)
})
test('受控补发必须先重置有效期,再发码,最后立即核销', async () => {
const source = await readFile(
new URL('./kuaishou-industry-admin-service.ts', import.meta.url),
'utf8',
)
const start = source.indexOf(
'export async function reissueAndConsumeAdminKuaishouIndustryVoucher',
)
assert.ok(start > 0, '未找到历史券补发并核销服务')
const body = source.slice(
start,
source.indexOf('\nexport function resolveKuaishouIndustryHistoricalReissueWindow', start),
)
const prepareIndex = body.indexOf('updateKuaishouIndustryVoucherByCode')
const sendIndex = body.indexOf('resendAdminKuaishouIndustryVoucherCode')
const consumeIndex = body.indexOf('consumeKuaishouIndustryVoucher(sentVoucher')
assert.ok(prepareIndex > 0, '必须先更新本地临时有效期')
assert.ok(sendIndex > prepareIndex, '必须先重置有效期再重发发码')
assert.ok(consumeIndex > sendIndex, '必须等发码成功后再执行核销')
assert.ok(body.includes("source: 'admin_historical_reissue_consume'"), '核销来源必须可审计')
assert.ok(body.includes("sendCallbackStatus: 'pending'"), '重发前必须重置发码状态')
assert.ok(body.includes("stage: 'send'"), '发码失败必须返回明确阶段')
assert.ok(body.includes("stage: 'consume'"), '核销失败必须返回明确阶段')
assert.ok(body.includes("stage: 'completed'"), '成功必须返回完成阶段')
})
test('补发并核销路由只允许管理员和运营执行', async () => {
const source = await readFile(
new URL('../../routes/admin/kuaishou-industry.ts', import.meta.url),
'utf8',
)
const routeIndex = source.indexOf("'/kuaishou-industry/vouchers/reissue-consume'")
assert.ok(routeIndex > 0, '未注册历史券补发并核销路由')
const routeBody = source.slice(routeIndex, source.indexOf('\nrouter.post(', routeIndex + 1))
assert.ok(routeBody.includes("requireAdminRoles(['admin', 'operator'])"), '必须限制管理员和运营')
assert.ok(
routeBody.includes("'kuaishou_industry_voucher_reissue_consume'"),
'必须写入独立审计动作',
)
})
@@ -20,6 +20,7 @@ import type {
AdminKuaishouIndustryVoucherCheckAvailableInput,
AdminKuaishouIndustryVoucherConsumeInput,
AdminKuaishouIndustryVoucherDestroyInput,
AdminKuaishouIndustryVoucherReissueConsumeInput,
AdminKuaishouIndustryVoucherResendInput,
AdminKuaishouIndustryVoucherReverseInput,
AdminCloudtentaclesTestLoginInput,
@@ -49,6 +50,8 @@ export type AdminKuaishouIndustryVoucherCheckAvailableRouteBody =
export type AdminKuaishouIndustryVoucherReverseRouteBody = AdminKuaishouIndustryVoucherReverseInput
export type AdminKuaishouIndustryVoucherConsumeRouteBody = AdminKuaishouIndustryVoucherConsumeInput
export type AdminKuaishouIndustryVoucherResendRouteBody = AdminKuaishouIndustryVoucherResendInput
export type AdminKuaishouIndustryVoucherReissueConsumeRouteBody =
AdminKuaishouIndustryVoucherReissueConsumeInput
export type AdminKuaishouIndustryVoucherDestroyRouteBody = AdminKuaishouIndustryVoucherDestroyInput
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
export type AdminNotificationTestRouteBody = AdminNotificationTestInput
@@ -354,6 +354,25 @@ export type AdminKuaishouIndustryVoucherResendInput = {
voucherCode?: string
}
/**
* 历史券补偿:临时重建有效期,重发原券码发码回调,成功后立即核销。
* validMinutes 只允许较短窗口,避免发码成功但核销失败时长期暴露可用券。
*/
export type AdminKuaishouIndustryVoucherReissueConsumeInput = {
oid?: string
orderId?: string
taskId?: number | string
voucherCode?: string
validMinutes?: number | string
reason?: string
consumeType?: string
serialNum?: string
storeName?: string
storeAddress?: string
expressCode?: string
expressNo?: string
}
export type AdminKuaishouIndustryVoucherDestroyInput = {
oid?: string
orderId?: string