From 398eafa9113e2e92664bc75608fed6429e2083c7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 6 Nov 2026 10:38:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(kuaishou):=20=E5=A2=9E=E5=8A=A0=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E7=94=B5=E5=AD=90=E5=88=B8=E8=A1=A5=E5=8F=91=E5=B9=B6?= =?UTF-8?q?=E6=A0=B8=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/admin/kuaishou-industry.ts | 27 +++ .../admin/kuaishou-industry-admin-service.ts | 206 +++++++++++++++++- .../kuaishou-industry-reissue-consume.test.ts | 76 +++++++ apps/backend/src/types/admin/route-inputs.ts | 3 + apps/backend/src/types/admin/write-inputs.ts | 19 ++ .../pages/admin/AdminKuaishouIndustryPage.tsx | 194 +++++++++++++++++ .../src/services/admin/kuaishou-industry.ts | 11 + apps/frontend/src/types/admin/index.ts | 2 + .../src/types/admin/kuaishou-industry.ts | 25 +++ .../kuaishou-industry-reissue-consume.test.ts | 45 ++++ 10 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/services/admin/kuaishou-industry-reissue-consume.test.ts create mode 100644 apps/frontend/tests/kuaishou-industry-reissue-consume.test.ts diff --git a/apps/backend/src/routes/admin/kuaishou-industry.ts b/apps/backend/src/routes/admin/kuaishou-industry.ts index 76a03f9d..d0b6e6c4 100644 --- a/apps/backend/src/routes/admin/kuaishou-industry.ts +++ b/apps/backend/src/routes/admin/kuaishou-industry.ts @@ -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), }, } } diff --git a/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts b/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts index ff1ba098..8c676758 100644 --- a/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts +++ b/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts @@ -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() + 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[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 + return String(current.error_msg || current.sub_msg || current.message || current.msg || '').trim() +} + function sanitizeOpenApiRequest(request: JsonObject | undefined): JsonObject | null { if (!request) { return null diff --git a/apps/backend/src/services/admin/kuaishou-industry-reissue-consume.test.ts b/apps/backend/src/services/admin/kuaishou-industry-reissue-consume.test.ts new file mode 100644 index 00000000..dd505a68 --- /dev/null +++ b/apps/backend/src/services/admin/kuaishou-industry-reissue-consume.test.ts @@ -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'"), + '必须写入独立审计动作', + ) +}) diff --git a/apps/backend/src/types/admin/route-inputs.ts b/apps/backend/src/types/admin/route-inputs.ts index f952cf12..4cb5aba7 100644 --- a/apps/backend/src/types/admin/route-inputs.ts +++ b/apps/backend/src/types/admin/route-inputs.ts @@ -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 diff --git a/apps/backend/src/types/admin/write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts index bc1b268b..d224b99c 100644 --- a/apps/backend/src/types/admin/write-inputs.ts +++ b/apps/backend/src/types/admin/write-inputs.ts @@ -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 diff --git a/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx b/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx index 85f695d0..ba0a3275 100644 --- a/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx +++ b/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx @@ -6,15 +6,18 @@ import { SearchOutlined, SendOutlined, SyncOutlined, + ThunderboltOutlined, } from '@ant-design/icons' import { Alert, Button, Card, + Checkbox, Collapse, Descriptions, Input, InputNumber, + Modal, Popconfirm, Select, Space, @@ -39,6 +42,7 @@ import { fetchAdminKuaishouIndustryShops, fetchAdminKuaishouIndustryVouchers, listAdminKuaishouIndustryRefunds, + reissueAndConsumeAdminKuaishouIndustryVoucher, resendAdminKuaishouIndustryVoucherCode, reverseAdminKuaishouIndustryVoucher, } from '@/services/admin' @@ -69,6 +73,7 @@ type IndustryActionKind = | 'destroy' | 'reverse' | 'resend' + | 'reissue-consume' | 'refund-list' | 'refund-approve' | 'refund-disagree' @@ -120,6 +125,11 @@ export default function AdminKuaishouIndustryPage() { const [actionLoading, setActionLoading] = useState('') const [consumingVoucherId, setConsumingVoucherId] = useState(null) const [reversingVoucherId, setReversingVoucherId] = useState(null) + const [reissueConsumeOpen, setReissueConsumeOpen] = useState(false) + const [reissueConsumeMinutes, setReissueConsumeMinutes] = useState(10) + const [reissueConsumeReason, setReissueConsumeReason] = + useState('历史发码失败补偿,补发后立即核销') + const [reissueConsumeConfirmed, setReissueConsumeConfirmed] = useState(false) const [vouchers, setVouchers] = useState([]) const [selectedVoucher, setSelectedVoucher] = useState(null) const [voucherTotal, setVoucherTotal] = useState(0) @@ -646,6 +656,64 @@ export default function AdminKuaishouIndustryPage() { } } + function openReissueConsumeModal() { + if (!selectedVoucher && !String(toolForm.voucherCode || '').trim()) { + showError('请先选择或填写本地券码') + return + } + if (selectedVoucher) { + const sendSucceeded = selectedVoucher.sendCallbackStatus === 'success' + const stillValid = Number(selectedVoucher.validEndTime || 0) > Date.now() + if (selectedVoucher.status !== 'UNUSED') { + showError('仅未使用电子凭证可以补发并核销') + return + } + if (sendSucceeded && stillValid) { + showError('该券已发码成功且仍在有效期,请直接执行手动核销') + return + } + } + setReissueConsumeConfirmed(false) + setReissueConsumeOpen(true) + } + + async function runReissueConsume() { + if (!reissueConsumeConfirmed) { + showError('请先确认已核实订单仍需履约') + return + } + + setActionLoading('reissue-consume') + try { + const response = await reissueAndConsumeAdminKuaishouIndustryVoucher({ + ...buildVoucherToolPayload(), + validMinutes: reissueConsumeMinutes, + reason: reissueConsumeReason, + }) + publishActionResult( + buildIndustryActionResultView('reissue-consume', '历史券补发并核销', response.data), + ) + if (response.data.success) { + showSuccess('原券码已同步到快手并完成核销') + setReissueConsumeOpen(false) + } else { + showError(response.data.error || '历史券补发并核销失败') + } + await loadVouchers() + } catch (error) { + const message = error instanceof Error ? error.message : '历史券补发并核销失败' + publishActionResult( + buildIndustryActionResultView('reissue-consume', '历史券补发并核销', { + success: false, + error: message, + }), + ) + showError(message) + } finally { + setActionLoading('') + } + } + async function runDestroy() { setActionLoading('destroy') try { @@ -870,6 +938,98 @@ export default function AdminKuaishouIndustryPage() { onChange={(key) => setActiveTab(key as IndustryTab)} items={tabItems} /> + + 60, + }} + onOk={runReissueConsume} + onCancel={() => setReissueConsumeOpen(false)} + destroyOnHidden + > + + + + + + + + + + setReissueConsumeConfirmed(event.target.checked)} + > + 我已核实该订单仍需履约,并确认补发后立即核销 + + + ) @@ -1210,6 +1370,15 @@ export default function AdminKuaishouIndustryPage() { > 手动核销 +