From 5a315a8f08a56bbd08b19f60eff18aa7d1bafce4 Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 1 Mar 2026 18:44:49 +0800 Subject: [PATCH] refactor: unify cancel logic with cancelOrderCore and show blocked UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract cancelOrderCore() shared by user cancel, admin cancel, and timeout expire - Query payment platform before cancelling: if already paid, run confirmPayment - Simplify timeout.ts to delegate to cancelOrderCore - Cancel API returns already_paid outcome for frontend handling - Show "订单已支付,无法取消" prompt with back button when cancel is blocked --- src/app/api/admin/orders/[id]/cancel/route.ts | 5 +- src/app/api/orders/[id]/cancel/route.ts | 5 +- src/components/PaymentQRCode.tsx | 24 ++++ src/lib/order/service.ts | 127 +++++++++++++----- src/lib/order/timeout.ts | 53 ++------ 5 files changed, 135 insertions(+), 79 deletions(-) diff --git a/src/app/api/admin/orders/[id]/cancel/route.ts b/src/app/api/admin/orders/[id]/cancel/route.ts index 3df4c53..9c29315 100644 --- a/src/app/api/admin/orders/[id]/cancel/route.ts +++ b/src/app/api/admin/orders/[id]/cancel/route.ts @@ -7,7 +7,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ try { const { id } = await params; - await adminCancelOrder(id); + const outcome = await adminCancelOrder(id); + if (outcome === 'already_paid') { + return NextResponse.json({ success: true, status: 'PAID', message: '订单已支付完成' }); + } return NextResponse.json({ success: true }); } catch (error) { if (error instanceof OrderError) { diff --git a/src/app/api/orders/[id]/cancel/route.ts b/src/app/api/orders/[id]/cancel/route.ts index 4a1914d..8367897 100644 --- a/src/app/api/orders/[id]/cancel/route.ts +++ b/src/app/api/orders/[id]/cancel/route.ts @@ -16,7 +16,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 }); } - await cancelOrder(id, parsed.data.user_id); + const outcome = await cancelOrder(id, parsed.data.user_id); + if (outcome === 'already_paid') { + return NextResponse.json({ success: true, status: 'PAID', message: '订单已支付完成' }); + } return NextResponse.json({ success: true }); } catch (error) { if (error instanceof OrderError) { diff --git a/src/components/PaymentQRCode.tsx b/src/components/PaymentQRCode.tsx index 931c7cd..ac1cc0b 100644 --- a/src/components/PaymentQRCode.tsx +++ b/src/components/PaymentQRCode.tsx @@ -50,6 +50,7 @@ export default function PaymentQRCode({ const [qrDataUrl, setQrDataUrl] = useState(''); const [imageLoading, setImageLoading] = useState(false); const [stripeOpened, setStripeOpened] = useState(false); + const [cancelBlocked, setCancelBlocked] = useState(false); const qrPayload = useMemo(() => { const value = (qrCode || payUrl || '').trim(); @@ -151,6 +152,11 @@ export default function PaymentQRCode({ body: JSON.stringify({ user_id: data.user_id }), }); if (cancelRes.ok) { + const cancelData = await cancelRes.json(); + if (cancelData.status === 'PAID') { + setCancelBlocked(true); + return; + } onStatusChange('CANCELLED'); } else { // Cancel failed (e.g. order was paid between the two requests) — re-check status @@ -167,6 +173,24 @@ export default function PaymentQRCode({ const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D'; const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]'; + if (cancelBlocked) { + return ( +
+
{'\u2713'}
+

{'\u8BA2\u5355\u5DF2\u652F\u4ED8'}

+

+ {'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'} +

+ +
+ ); + } + return (
diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index a218e74..5735ca6 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -114,48 +114,109 @@ export async function createOrder(input: CreateOrderInput): Promise { - const result = await prisma.order.updateMany({ - where: { id: orderId, userId, status: 'PENDING' }, - data: { status: 'CANCELLED', updatedAt: new Date() }, - }); +export type CancelOutcome = 'cancelled' | 'already_paid'; - if (result.count === 0) { - const order = await prisma.order.findUnique({ where: { id: orderId } }); - if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); - if (order.userId !== userId) throw new OrderError('FORBIDDEN', 'Forbidden', 403); - throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400); +/** + * 核心取消逻辑 — 所有取消路径共用。 + * 调用前由 caller 负责权限校验(userId / admin 身份)。 + */ +export async function cancelOrderCore(options: { + orderId: string; + paymentTradeNo: string | null; + paymentType: string | null; + finalStatus: 'CANCELLED' | 'EXPIRED'; + operator: string; + auditDetail: string; +}): Promise { + const { orderId, paymentTradeNo, paymentType, finalStatus, operator, auditDetail } = options; + + // 1. 平台侧处理 + if (paymentTradeNo && paymentType) { + try { + initPaymentProviders(); + const provider = paymentRegistry.getProvider(paymentType as PaymentType); + const queryResult = await provider.queryOrder(paymentTradeNo); + + if (queryResult.status === 'paid') { + await confirmPayment({ + orderId, + tradeNo: paymentTradeNo, + paidAmount: queryResult.amount, + providerName: provider.name, + }); + console.log(`Order ${orderId} was paid during cancel (${operator}), processed as success`); + return 'already_paid'; + } + + if (provider.cancelPayment) { + try { + await provider.cancelPayment(paymentTradeNo); + } catch (cancelErr) { + console.warn(`Failed to cancel payment for order ${orderId}:`, cancelErr); + } + } + } catch (platformErr) { + console.warn(`Platform check failed for order ${orderId}, cancelling locally:`, platformErr); + } } - await prisma.auditLog.create({ - data: { - orderId, - action: 'ORDER_CANCELLED', - detail: 'User cancelled order', - operator: `user:${userId}`, - }, + // 2. DB 更新 (WHERE status='PENDING' 保证幂等) + const result = await prisma.order.updateMany({ + where: { id: orderId, status: 'PENDING' }, + data: { status: finalStatus, updatedAt: new Date() }, + }); + + // 3. 审计日志 + if (result.count > 0) { + await prisma.auditLog.create({ + data: { + orderId, + action: finalStatus === 'EXPIRED' ? 'ORDER_EXPIRED' : 'ORDER_CANCELLED', + detail: auditDetail, + operator, + }, + }); + } + + return 'cancelled'; +} + +export async function cancelOrder(orderId: string, userId: number): Promise { + const order = await prisma.order.findUnique({ + where: { id: orderId }, + select: { id: true, userId: true, status: true, paymentTradeNo: true, paymentType: true }, + }); + + if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); + if (order.userId !== userId) throw new OrderError('FORBIDDEN', 'Forbidden', 403); + if (order.status !== 'PENDING') throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400); + + return cancelOrderCore({ + orderId: order.id, + paymentTradeNo: order.paymentTradeNo, + paymentType: order.paymentType, + finalStatus: 'CANCELLED', + operator: `user:${userId}`, + auditDetail: 'User cancelled order', }); } -export async function adminCancelOrder(orderId: string): Promise { - const result = await prisma.order.updateMany({ - where: { id: orderId, status: 'PENDING' }, - data: { status: 'CANCELLED', updatedAt: new Date() }, +export async function adminCancelOrder(orderId: string): Promise { + const order = await prisma.order.findUnique({ + where: { id: orderId }, + select: { id: true, status: true, paymentTradeNo: true, paymentType: true }, }); - if (result.count === 0) { - const order = await prisma.order.findUnique({ where: { id: orderId } }); - if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); - throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400); - } + if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); + if (order.status !== 'PENDING') throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400); - await prisma.auditLog.create({ - data: { - orderId, - action: 'ORDER_CANCELLED', - detail: 'Admin cancelled order', - operator: 'admin', - }, + return cancelOrderCore({ + orderId: order.id, + paymentTradeNo: order.paymentTradeNo, + paymentType: order.paymentType, + finalStatus: 'CANCELLED', + operator: 'admin', + auditDetail: 'Admin cancelled order', }); } diff --git a/src/lib/order/timeout.ts b/src/lib/order/timeout.ts index a3f165e..8e8b10a 100644 --- a/src/lib/order/timeout.ts +++ b/src/lib/order/timeout.ts @@ -1,7 +1,5 @@ import { prisma } from '@/lib/db'; -import { initPaymentProviders, paymentRegistry } from '@/lib/payment'; -import type { PaymentType } from '@/lib/payment'; -import { confirmPayment } from './service'; +import { cancelOrderCore } from './service'; const INTERVAL_MS = 30_000; // 30 seconds let timer: ReturnType | null = null; @@ -25,49 +23,16 @@ export async function expireOrders(): Promise { for (const order of orders) { try { - // If order has a payment on the platform, check its actual status - if (order.paymentTradeNo && order.paymentType) { - try { - initPaymentProviders(); - const provider = paymentRegistry.getProvider(order.paymentType as PaymentType); - - // Query the real payment status before expiring - const queryResult = await provider.queryOrder(order.paymentTradeNo); - - if (queryResult.status === 'paid') { - // User already paid — process as success instead of expiring - await confirmPayment({ - orderId: order.id, - tradeNo: order.paymentTradeNo, - paidAmount: queryResult.amount, - providerName: provider.name, - }); - console.log(`Order ${order.id} was paid during timeout, processed as success`); - continue; - } - - // Not paid — cancel on the platform - if (provider.cancelPayment) { - try { - await provider.cancelPayment(order.paymentTradeNo); - } catch (cancelErr) { - // Cancel may fail if session already expired on platform side — that's fine - console.warn(`Failed to cancel payment for order ${order.id}:`, cancelErr); - } - } - } catch (platformErr) { - // Platform unreachable — still expire the order locally - console.warn(`Platform check failed for order ${order.id}, expiring anyway:`, platformErr); - } - } - - // Mark as expired in database (WHERE status='PENDING' ensures idempotency) - const result = await prisma.order.updateMany({ - where: { id: order.id, status: 'PENDING' }, - data: { status: 'EXPIRED' }, + const outcome = await cancelOrderCore({ + orderId: order.id, + paymentTradeNo: order.paymentTradeNo, + paymentType: order.paymentType, + finalStatus: 'EXPIRED', + operator: 'timeout', + auditDetail: 'Order expired', }); - if (result.count > 0) expiredCount++; + if (outcome === 'cancelled') expiredCount++; } catch (err) { console.error(`Error expiring order ${order.id}:`, err); }