Merge branch 'worktree-feature/stripe'
This commit is contained in:
@@ -7,7 +7,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OrderError) {
|
if (error instanceof OrderError) {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OrderError) {
|
if (error instanceof OrderError) {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function PaymentQRCode({
|
|||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
const [imageLoading, setImageLoading] = useState(false);
|
const [imageLoading, setImageLoading] = useState(false);
|
||||||
const [stripeOpened, setStripeOpened] = useState(false);
|
const [stripeOpened, setStripeOpened] = useState(false);
|
||||||
|
const [cancelBlocked, setCancelBlocked] = useState(false);
|
||||||
|
|
||||||
const qrPayload = useMemo(() => {
|
const qrPayload = useMemo(() => {
|
||||||
const value = (qrCode || payUrl || '').trim();
|
const value = (qrCode || payUrl || '').trim();
|
||||||
@@ -151,6 +152,11 @@ export default function PaymentQRCode({
|
|||||||
body: JSON.stringify({ user_id: data.user_id }),
|
body: JSON.stringify({ user_id: data.user_id }),
|
||||||
});
|
});
|
||||||
if (cancelRes.ok) {
|
if (cancelRes.ok) {
|
||||||
|
const cancelData = await cancelRes.json();
|
||||||
|
if (cancelData.status === 'PAID') {
|
||||||
|
setCancelBlocked(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
onStatusChange('CANCELLED');
|
onStatusChange('CANCELLED');
|
||||||
} else {
|
} else {
|
||||||
// Cancel failed (e.g. order was paid between the two requests) — re-check status
|
// 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 channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
|
||||||
const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]';
|
const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]';
|
||||||
|
|
||||||
|
if (cancelBlocked) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center space-y-4 py-8">
|
||||||
|
<div className="text-6xl text-green-600">{'\u2713'}</div>
|
||||||
|
<h2 className="text-xl font-bold text-green-600">{'\u8BA2\u5355\u5DF2\u652F\u4ED8'}</h2>
|
||||||
|
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||||
|
{'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="mt-4 w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{'\u8FD4\u56DE\u5145\u503C'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@@ -114,51 +114,112 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelOrder(orderId: string, userId: number): Promise<void> {
|
export type CancelOutcome = 'cancelled' | 'already_paid';
|
||||||
const result = await prisma.order.updateMany({
|
|
||||||
where: { id: orderId, userId, status: 'PENDING' },
|
|
||||||
data: { status: 'CANCELLED', updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.count === 0) {
|
/**
|
||||||
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
* 核心取消逻辑 — 所有取消路径共用。
|
||||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
* 调用前由 caller 负责权限校验(userId / admin 身份)。
|
||||||
if (order.userId !== userId) throw new OrderError('FORBIDDEN', 'Forbidden', 403);
|
*/
|
||||||
throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
export async function cancelOrderCore(options: {
|
||||||
}
|
orderId: string;
|
||||||
|
paymentTradeNo: string | null;
|
||||||
|
paymentType: string | null;
|
||||||
|
finalStatus: 'CANCELLED' | 'EXPIRED';
|
||||||
|
operator: string;
|
||||||
|
auditDetail: string;
|
||||||
|
}): Promise<CancelOutcome> {
|
||||||
|
const { orderId, paymentTradeNo, paymentType, finalStatus, operator, auditDetail } = options;
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
// 1. 平台侧处理
|
||||||
data: {
|
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,
|
orderId,
|
||||||
action: 'ORDER_CANCELLED',
|
tradeNo: paymentTradeNo,
|
||||||
detail: 'User cancelled order',
|
paidAmount: queryResult.amount,
|
||||||
operator: `user:${userId}`,
|
providerName: provider.name,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
console.log(`Order ${orderId} was paid during cancel (${operator}), processed as success`);
|
||||||
|
return 'already_paid';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adminCancelOrder(orderId: string): Promise<void> {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. DB 更新 (WHERE status='PENDING' 保证幂等)
|
||||||
const result = await prisma.order.updateMany({
|
const result = await prisma.order.updateMany({
|
||||||
where: { id: orderId, status: 'PENDING' },
|
where: { id: orderId, status: 'PENDING' },
|
||||||
data: { status: 'CANCELLED', updatedAt: new Date() },
|
data: { status: finalStatus, updatedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.count === 0) {
|
// 3. 审计日志
|
||||||
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
if (result.count > 0) {
|
||||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
|
||||||
throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
orderId,
|
orderId,
|
||||||
action: 'ORDER_CANCELLED',
|
action: finalStatus === 'EXPIRED' ? 'ORDER_EXPIRED' : 'ORDER_CANCELLED',
|
||||||
detail: 'Admin cancelled order',
|
detail: auditDetail,
|
||||||
operator: 'admin',
|
operator,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return 'cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelOrder(orderId: string, userId: number): Promise<CancelOutcome> {
|
||||||
|
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<CancelOutcome> {
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { id: true, status: true, paymentTradeNo: true, paymentType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return cancelOrderCore({
|
||||||
|
orderId: order.id,
|
||||||
|
paymentTradeNo: order.paymentTradeNo,
|
||||||
|
paymentType: order.paymentType,
|
||||||
|
finalStatus: 'CANCELLED',
|
||||||
|
operator: 'admin',
|
||||||
|
auditDetail: 'Admin cancelled order',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider-agnostic: confirm a payment and trigger recharge.
|
* Provider-agnostic: confirm a payment and trigger recharge.
|
||||||
* Called by any provider's webhook/notify handler after verification.
|
* Called by any provider's webhook/notify handler after verification.
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
import { cancelOrderCore } from './service';
|
||||||
import type { PaymentType } from '@/lib/payment';
|
|
||||||
import { confirmPayment } from './service';
|
|
||||||
|
|
||||||
const INTERVAL_MS = 30_000; // 30 seconds
|
const INTERVAL_MS = 30_000; // 30 seconds
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -25,49 +23,16 @@ export async function expireOrders(): Promise<number> {
|
|||||||
|
|
||||||
for (const order of orders) {
|
for (const order of orders) {
|
||||||
try {
|
try {
|
||||||
// If order has a payment on the platform, check its actual status
|
const outcome = await cancelOrderCore({
|
||||||
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,
|
orderId: order.id,
|
||||||
tradeNo: order.paymentTradeNo,
|
paymentTradeNo: order.paymentTradeNo,
|
||||||
paidAmount: queryResult.amount,
|
paymentType: order.paymentType,
|
||||||
providerName: provider.name,
|
finalStatus: 'EXPIRED',
|
||||||
});
|
operator: 'timeout',
|
||||||
console.log(`Order ${order.id} was paid during timeout, processed as success`);
|
auditDetail: 'Order expired',
|
||||||
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' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.count > 0) expiredCount++;
|
if (outcome === 'cancelled') expiredCount++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Error expiring order ${order.id}:`, err);
|
console.error(`Error expiring order ${order.id}:`, err);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user