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

@@ -75,11 +75,13 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-xl"
onClick={e => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-bold"></h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
</button>
</div>
<div className="grid grid-cols-2 gap-3">
@@ -99,21 +101,13 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
<div key={log.id} className="rounded-lg border border-gray-100 bg-gray-50 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{log.action}</span>
<span className="text-xs text-gray-400">
{new Date(log.createdAt).toLocaleString('zh-CN')}
</span>
<span className="text-xs text-gray-400">{new Date(log.createdAt).toLocaleString('zh-CN')}</span>
</div>
{log.detail && (
<div className="mt-1 break-all text-xs text-gray-500">{log.detail}</div>
)}
{log.operator && (
<div className="mt-1 text-xs text-gray-400">: {log.operator}</div>
)}
{log.detail && <div className="mt-1 break-all text-xs text-gray-500">{log.detail}</div>}
{log.operator && <div className="mt-1 text-xs text-gray-400">: {log.operator}</div>}
</div>
))}
{order.auditLogs.length === 0 && (
<div className="text-center text-sm text-gray-400"></div>
)}
{order.auditLogs.length === 0 && <div className="text-center text-sm text-gray-400"></div>}
</div>
</div>

View File

@@ -55,14 +55,14 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{orders.map((order) => {
const statusInfo = STATUS_LABELS[order.status] || { label: order.status, className: 'bg-gray-100 text-gray-800' };
const statusInfo = STATUS_LABELS[order.status] || {
label: order.status,
className: 'bg-gray-100 text-gray-800',
};
return (
<tr key={order.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-3 text-sm">
<button
onClick={() => onViewDetail(order.id)}
className="text-blue-600 hover:underline"
>
<button onClick={() => onViewDetail(order.id)} className="text-blue-600 hover:underline">
{order.id.slice(0, 12)}...
</button>
</td>
@@ -70,9 +70,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
<div>{order.userName || '-'}</div>
<div className="text-xs text-gray-400">{order.userEmail || `ID: ${order.userId}`}</div>
</td>
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium">
¥{order.amount.toFixed(2)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium">¥{order.amount.toFixed(2)}</td>
<td className="whitespace-nowrap px-4 py-3 text-sm">
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${statusInfo.className}`}>
{statusInfo.label}
@@ -109,9 +107,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
})}
</tbody>
</table>
{orders.length === 0 && (
<div className="py-12 text-center text-gray-500"></div>
)}
{orders.length === 0 && <div className="py-12 text-center text-gray-500"></div>}
</div>
);
}

View File

@@ -34,7 +34,7 @@ export default function RefundDialog({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-gray-900">退</h3>
<div className="mt-4 space-y-3">
@@ -48,11 +48,7 @@ export default function RefundDialog({
<div className="text-lg font-bold text-red-600">¥{amount.toFixed(2)}</div>
</div>
{warning && (
<div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">
{warning}
</div>
)}
{warning && <div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">{warning}</div>}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">退</label>