Files
sub2apipay/src/components/PaymentQRCode.tsx
miwei d7d91857c7 fix: Stripe 弹窗安全加固 + 清理未使用依赖
安全修复:
- client_secret 和 publishableKey 不再通过 URL 传递,改用 postMessage
  弹窗发送 STRIPE_POPUP_READY 信号,父页面响应 STRIPE_POPUP_INIT 传递敏感数据
  校验 event.origin 防止跨域消息伪造
- confirmAlipayPayment 改为显式调用,移除动态方法查找
- handleStripeSubmit 中 returnUrl 清理残留 query params

依赖清理:
- 移除未使用的 @stripe/react-stripe-js

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 15:27:51 +08:00

486 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import QRCode from 'qrcode';
interface PaymentQRCodeProps {
orderId: string;
token?: string;
payUrl?: string | null;
qrCode?: string | null;
clientSecret?: string | null;
stripePublishableKey?: string | null;
paymentType?: 'alipay' | 'wxpay' | 'stripe';
amount: number;
payAmount?: number;
expiresAt: string;
onStatusChange: (status: string) => void;
onBack: () => void;
dark?: boolean;
isEmbedded?: boolean;
}
const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6';
const TEXT_REMAINING = '\u5269\u4F59\u652F\u4ED8\u65F6\u95F4';
const TEXT_GO_PAY = '\u70B9\u51FB\u524D\u5F80\u652F\u4ED8';
const TEXT_SCAN_PAY = '\u8BF7\u4F7F\u7528\u652F\u4ED8\u5E94\u7528\u626B\u7801\u652F\u4ED8';
const TEXT_BACK = '\u8FD4\u56DE';
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
export default function PaymentQRCode({
orderId,
token,
payUrl,
qrCode,
clientSecret,
stripePublishableKey,
paymentType,
amount,
payAmount: payAmountProp,
expiresAt,
onStatusChange,
onBack,
dark = false,
isEmbedded = false,
}: PaymentQRCodeProps) {
const displayAmount = payAmountProp ?? amount;
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
const [timeLeft, setTimeLeft] = useState('');
const [expired, setExpired] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState('');
const [imageLoading, setImageLoading] = useState(false);
const [cancelBlocked, setCancelBlocked] = useState(false);
// Stripe Payment Element state
const [stripeLoaded, setStripeLoaded] = useState(false);
const [stripeSubmitting, setStripeSubmitting] = useState(false);
const [stripeError, setStripeError] = useState('');
const [stripeSuccess, setStripeSuccess] = useState(false);
const [stripeLib, setStripeLib] = useState<{
stripe: import('@stripe/stripe-js').Stripe;
elements: import('@stripe/stripe-js').StripeElements;
} | null>(null);
// Track selected payment method in Payment Element (for embedded popup decision)
const [stripePaymentMethod, setStripePaymentMethod] = useState('card');
const [popupBlocked, setPopupBlocked] = useState(false);
const paymentMethodListenerAdded = useRef(false);
const qrPayload = useMemo(() => {
const value = (qrCode || payUrl || '').trim();
return value;
}, [qrCode, payUrl]);
useEffect(() => {
let cancelled = false;
if (!qrPayload) {
setQrDataUrl('');
return;
}
setImageLoading(true);
QRCode.toDataURL(qrPayload, {
width: 224,
margin: 1,
errorCorrectionLevel: 'M',
})
.then((url) => {
if (!cancelled) {
setQrDataUrl(url);
}
})
.catch(() => {
if (!cancelled) {
setQrDataUrl('');
}
})
.finally(() => {
if (!cancelled) {
setImageLoading(false);
}
});
return () => {
cancelled = true;
};
}, [qrPayload]);
// Initialize Stripe Payment Element
const isStripe = paymentType === 'stripe';
useEffect(() => {
if (!isStripe || !clientSecret || !stripePublishableKey) return;
let cancelled = false;
import('@stripe/stripe-js').then(({ loadStripe }) => {
loadStripe(stripePublishableKey).then((stripe) => {
if (cancelled) return;
if (!stripe) {
setStripeError('支付组件加载失败,请刷新页面重试');
setStripeLoaded(true);
return;
}
const elements = stripe.elements({
clientSecret,
appearance: {
theme: dark ? 'night' : 'stripe',
variables: {
borderRadius: '8px',
},
},
});
setStripeLib({ stripe, elements });
setStripeLoaded(true);
});
});
return () => {
cancelled = true;
};
}, [isStripe, clientSecret, stripePublishableKey, dark]);
// Mount Payment Element when container is available
const stripeContainerRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || !stripeLib) return;
let pe = stripeLib.elements.getElement('payment');
if (pe) {
pe.mount(node);
} else {
pe = stripeLib.elements.create('payment', { layout: 'tabs' });
pe.mount(node);
}
if (!paymentMethodListenerAdded.current) {
paymentMethodListenerAdded.current = true;
pe.on('change', (event: { value?: { type?: string } }) => {
if (event.value?.type) {
setStripePaymentMethod(event.value.type);
}
});
}
},
[stripeLib],
);
const handleStripeSubmit = async () => {
if (!stripeLib || stripeSubmitting) return;
// In embedded mode, Alipay redirects to a page with X-Frame-Options that breaks iframe
if (isEmbedded && stripePaymentMethod === 'alipay') {
handleOpenPopup();
return;
}
setStripeSubmitting(true);
setStripeError('');
const { stripe, elements } = stripeLib;
const returnUrl = new URL(window.location.href);
returnUrl.pathname = '/pay/result';
returnUrl.search = '';
returnUrl.searchParams.set('order_id', orderId);
returnUrl.searchParams.set('status', 'success');
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: 'if_required',
});
if (error) {
setStripeError(error.message || '支付失败,请重试');
setStripeSubmitting(false);
} else {
// Payment succeeded (or no redirect needed)
setStripeSuccess(true);
setStripeSubmitting(false);
// Polling will pick up the status change
}
};
const handleOpenPopup = () => {
if (!clientSecret || !stripePublishableKey) return;
setPopupBlocked(false);
// Only pass display params in URL — sensitive data sent via postMessage
const popupUrl = new URL(window.location.href);
popupUrl.pathname = '/pay/stripe-popup';
popupUrl.search = '';
popupUrl.searchParams.set('order_id', orderId);
popupUrl.searchParams.set('amount', String(amount));
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
popupUrl.searchParams.set('method', stripePaymentMethod);
const popup = window.open(
popupUrl.toString(),
'stripe_payment',
'width=500,height=700,scrollbars=yes',
);
if (!popup || popup.closed) {
setPopupBlocked(true);
return;
}
// Send sensitive data via postMessage after popup loads
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
window.removeEventListener('message', onReady);
popup.postMessage({
type: 'STRIPE_POPUP_INIT',
clientSecret,
publishableKey: stripePublishableKey,
}, window.location.origin);
};
window.addEventListener('message', onReady);
};
useEffect(() => {
const updateTimer = () => {
const now = Date.now();
const expiry = new Date(expiresAt).getTime();
const diff = expiry - now;
if (diff <= 0) {
setTimeLeft(TEXT_EXPIRED);
setExpired(true);
return;
}
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`);
};
updateTimer();
const timer = setInterval(updateTimer, 1000);
return () => clearInterval(timer);
}, [expiresAt]);
const pollStatus = useCallback(async () => {
try {
const res = await fetch(`/api/orders/${orderId}`);
if (res.ok) {
const data = await res.json();
if (TERMINAL_STATUSES.has(data.status)) {
onStatusChange(data.status);
}
}
} catch {
// ignore polling errors
}
}, [orderId, onStatusChange]);
useEffect(() => {
if (expired) return;
pollStatus();
const timer = setInterval(pollStatus, 2000);
return () => clearInterval(timer);
}, [pollStatus, expired]);
const handleCancel = async () => {
if (!token) return;
try {
// 先检查当前订单状态
const res = await fetch(`/api/orders/${orderId}`);
if (!res.ok) return;
const data = await res.json();
if (TERMINAL_STATUSES.has(data.status)) {
onStatusChange(data.status);
return;
}
const cancelRes = await fetch(`/api/orders/${orderId}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (cancelRes.ok) {
const cancelData = await cancelRes.json();
if (cancelData.status === 'PAID') {
setCancelBlocked(true);
return;
}
onStatusChange('CANCELLED');
} else {
await pollStatus();
}
} catch {
// ignore
}
};
const isWx = paymentType === 'wxpay';
const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
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 (
<div className="flex flex-col items-center space-y-4">
<div className="text-center">
<div className="text-4xl font-bold text-blue-600">{'\u00A5'}{displayAmount.toFixed(2)}</div>
{hasFeeDiff && (
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
¥{amount.toFixed(2)}
</div>
)}
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
</div>
</div>
{!expired && (
<>
{isStripe ? (
<div className="w-full max-w-md space-y-4">
{!clientSecret || !stripePublishableKey ? (
<div className={['rounded-lg border-2 border-dashed p-8 text-center', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
</p>
</div>
) : !stripeLoaded ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
...
</span>
</div>
) : stripeError && !stripeLib ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
) : (
<>
<div
ref={stripeContainerRef}
className={['rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}
/>
{stripeError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
)}
{stripeSuccess ? (
<div className="text-center">
<div className="text-4xl text-green-600">{'\u2713'}</div>
<p className={['mt-2 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
...
</p>
</div>
) : (
<button
type="button"
disabled={stripeSubmitting}
onClick={handleStripeSubmit}
className={[
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
stripeSubmitting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
].join(' ')}
>
{stripeSubmitting ? (
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</span>
) : (
`支付 ¥${amount.toFixed(2)}`
)}
</button>
)}
{popupBlocked && (
<div className={['rounded-lg border p-3 text-sm', dark ? 'border-amber-700 bg-amber-900/30 text-amber-300' : 'border-amber-200 bg-amber-50 text-amber-700'].join(' ')}>
</div>
)}
</>
)}
</div>
) : (
<>
{qrDataUrl && (
<div className={['relative rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}>
{imageLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
)}
<img src={qrDataUrl} alt="payment qrcode" className="h-56 w-56 rounded" />
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className={`rounded-full p-2 shadow ring-2 ring-white ${iconBgClass}`}>
<img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />
</span>
</div>
</div>
)}
{!qrDataUrl && payUrl && (
<a
href={payUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg bg-blue-600 px-8 py-3 font-medium text-white hover:bg-blue-700"
>
{TEXT_GO_PAY}
</a>
)}
{!qrDataUrl && !payUrl && (
<div className="text-center">
<div className={['rounded-lg border-2 border-dashed p-8', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
</div>
</div>
)}
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`}
</p>
</>
)}
</>
)}
<div className="flex w-full gap-3">
<button
onClick={onBack}
className={[
'flex-1 rounded-lg border py-2 text-sm',
dark ? 'border-slate-700 text-slate-300 hover:bg-slate-800' : 'border-gray-300 text-gray-600 hover:bg-gray-50',
].join(' ')}
>
{TEXT_BACK}
</button>
{!expired && token && (
<button
onClick={handleCancel}
className="flex-1 rounded-lg border border-red-300 py-2 text-sm text-red-600 hover:bg-red-50"
>
{TEXT_CANCEL_ORDER}
</button>
)}
</div>
</div>
);
}