feat: integrate Stripe payment with bugfixes and active timeout cancellation

- Add Stripe payment provider with Checkout Session flow
- Payment provider abstraction layer (EasyPay + Stripe unified interface)
- Stripe webhook with proper raw body handling and signature verification
- Frontend: Stripe button with URL validation, anti-duplicate click, noopener
- Active timeout cancellation: query platform before expiring, recover paid orders
- Singleton Stripe client, idempotency keys, Math.round for amounts
- Handle async_payment events, return null for unknown webhook events
- Set Checkout Session expires_at aligned with order timeout
- Add cancelPayment to provider interface (Stripe: sessions.expire, EasyPay: no-op)
- Enable stripe in frontend payment type list
This commit is contained in:
erio
2026-03-01 17:58:08 +08:00
parent 2f45044073
commit d9ab65ecf2
59 changed files with 1571 additions and 432 deletions

View File

@@ -154,7 +154,9 @@ function OrdersContent() {
if (isMobile) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}>
<div
className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}
>
Tab...
</div>
);
@@ -184,7 +186,9 @@ function OrdersContent() {
onClick={loadOrders}
className={[
'rounded-lg border px-3 py-2 text-xs font-medium',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
@@ -193,7 +197,9 @@ function OrdersContent() {
href={payUrl}
className={[
'rounded-lg border px-3 py-2 text-xs font-medium',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>

View File

@@ -1,7 +1,7 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useState, useEffect, Suspense, useMemo } from 'react';
import { useState, useEffect, Suspense } from 'react';
import PaymentForm from '@/components/PaymentForm';
import PaymentQRCode from '@/components/PaymentQRCode';
import OrderStatus from '@/components/OrderStatus';
@@ -13,9 +13,10 @@ interface OrderResult {
orderId: string;
amount: number;
status: string;
paymentType: 'alipay' | 'wxpay';
paymentType: 'alipay' | 'wxpay' | 'stripe';
payUrl?: string | null;
qrCode?: string | null;
checkoutUrl?: string | null;
expiresAt: string;
}
@@ -47,7 +48,7 @@ function PayContent() {
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
const [config] = useState<AppConfig>({
enabledPaymentTypes: ['alipay', 'wxpay'],
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
minAmount: 1,
maxAmount: 10000,
});
@@ -185,6 +186,7 @@ function PayContent() {
paymentType: data.paymentType || paymentType,
payUrl: data.payUrl,
qrCode: data.qrCode,
checkoutUrl: data.checkoutUrl,
expiresAt: data.expiresAt,
});
@@ -385,6 +387,7 @@ function PayContent() {
orderId={orderResult.orderId}
payUrl={orderResult.payUrl}
qrCode={orderResult.qrCode}
checkoutUrl={orderResult.checkoutUrl}
paymentType={orderResult.paymentType}
amount={orderResult.amount}
expiresAt={orderResult.expiresAt}

View File

@@ -5,8 +5,9 @@ import { useEffect, useState, Suspense } from 'react';
function ResultContent() {
const searchParams = useSearchParams();
const outTradeNo = searchParams.get('out_trade_no');
const tradeStatus = searchParams.get('trade_status');
// Support both ZPAY (out_trade_no) and Stripe (order_id) callback params
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
const tradeStatus = searchParams.get('trade_status') || searchParams.get('status');
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -62,9 +63,7 @@ function ResultContent() {
{status === 'COMPLETED' ? '充值成功' : '充值处理中'}
</h1>
<p className="mt-2 text-gray-500">
{status === 'COMPLETED'
? '余额已成功到账!'
: '支付成功,余额正在充值中...'}
{status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}
</p>
</>
) : isPending ? (
@@ -89,9 +88,7 @@ function ResultContent() {
</>
)}
<p className="mt-4 text-xs text-gray-400">
: {outTradeNo || '未知'}
</p>
<p className="mt-4 text-xs text-gray-400">: {outTradeNo || '未知'}</p>
</div>
</div>
);
@@ -99,11 +96,13 @@ function ResultContent() {
export default function PayResultPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}>
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<ResultContent />
</Suspense>
);