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:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
|
||||
|
||||
interface PaymentFormProps {
|
||||
userId: number;
|
||||
@@ -70,13 +71,62 @@ export default function PaymentForm({
|
||||
await onSubmit(selectedAmount, paymentType);
|
||||
};
|
||||
|
||||
const isAlipay = (type: string) => type === 'alipay';
|
||||
const renderPaymentIcon = (type: string) => {
|
||||
if (type === 'alipay') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
|
||||
支
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (type === 'wxpay') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
|
||||
<path
|
||||
d="M5 12.5 10.2 17 19 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (type === 'stripe') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#635bff] text-white">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<path d="M2 10h20" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div className={['rounded-xl border p-4', dark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>充值账户</div>
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-4',
|
||||
dark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
充值账户
|
||||
</div>
|
||||
<div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{userName || `用户 #${userId}`}
|
||||
</div>
|
||||
@@ -118,7 +168,13 @@ export default function PaymentForm({
|
||||
自定义金额
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>¥</span>
|
||||
<span
|
||||
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
¥
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
@@ -138,51 +194,50 @@ export default function PaymentForm({
|
||||
|
||||
{customAmount !== '' && !isValid && (
|
||||
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
|
||||
{'\u91D1\u989D\u9700\u5728\u8303\u56F4\u5185\uFF0C\u4E14\u6700\u591A\u652F\u6301 2 \u4F4D\u5C0F\u6570\uFF08\u7CBE\u786E\u5230\u5206\uFF09'}
|
||||
{
|
||||
'\u91D1\u989D\u9700\u5728\u8303\u56F4\u5185\uFF0C\u4E14\u6700\u591A\u652F\u6301 2 \u4F4D\u5C0F\u6570\uFF08\u7CBE\u786E\u5230\u5206\uFF09'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Type */}
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>支付方式</label>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
|
||||
支付方式
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{enabledPaymentTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setPaymentType(type)}
|
||||
className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${
|
||||
paymentType === type
|
||||
? isAlipay(type)
|
||||
? 'border-cyan-400 bg-cyan-50 text-slate-900 shadow-sm'
|
||||
: 'border-green-500 bg-green-50 text-slate-900 shadow-sm'
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{isAlipay(type) ? (
|
||||
{enabledPaymentTypes.map((type) => {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
const isSelected = paymentType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setPaymentType(type)}
|
||||
className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${
|
||||
isSelected
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
|
||||
支
|
||||
</span>
|
||||
{renderPaymentIcon(type)}
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-xl font-semibold tracking-tight">支付宝</span>
|
||||
<span className="text-[10px] tracking-[0.25em] text-slate-600">ALIPAY</span>
|
||||
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
||||
{meta?.sublabel && (
|
||||
<span
|
||||
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
|
||||
>
|
||||
{meta.sublabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
|
||||
<path d="M5 12.5 10.2 17 19 8" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-xl font-semibold tracking-tight">微信支付</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -192,8 +247,12 @@ export default function PaymentForm({
|
||||
disabled={!isValid || loading}
|
||||
className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${
|
||||
isValid && !loading
|
||||
? 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
|
||||
: dark ? 'cursor-not-allowed bg-slate-700 text-slate-300' : 'cursor-not-allowed bg-gray-300'
|
||||
? paymentType === 'stripe'
|
||||
? 'bg-[#635bff] hover:bg-[#5851db] active:bg-[#4b44c7]'
|
||||
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
|
||||
: dark
|
||||
? 'cursor-not-allowed bg-slate-700 text-slate-300'
|
||||
: 'cursor-not-allowed bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{loading ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`}
|
||||
|
||||
Reference in New Issue
Block a user