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 (
+
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);
}