fix: harden alipay direct pay flow

This commit is contained in:
daguimu
2026-03-10 11:52:37 +08:00
parent 2492031e13
commit 8b10bc3bd5
30 changed files with 1893 additions and 437 deletions

View File

@@ -3,23 +3,7 @@ import { Prisma } from '@prisma/client';
import { prisma } from '@/lib/db';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { OrderStatus } from '@prisma/client';
/** 业务时区偏移(东八区,+8 小时) */
const BIZ_TZ_OFFSET_MS = 8 * 60 * 60 * 1000;
const BIZ_TZ_NAME = 'Asia/Shanghai';
/** 获取业务时区下的 YYYY-MM-DD */
function toBizDateStr(d: Date): string {
const local = new Date(d.getTime() + BIZ_TZ_OFFSET_MS);
return local.toISOString().split('T')[0];
}
/** 获取业务时区下"今天 00:00"对应的 UTC 时间 */
function getBizDayStartUTC(d: Date): Date {
const bizDateStr = toBizDateStr(d);
// bizDateStr 00:00 在业务时区 = bizDateStr 00:00 - offset 在 UTC
return new Date(`${bizDateStr}T00:00:00+08:00`);
}
import { BIZ_TZ_NAME, getBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();

View File

@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { queryMethodLimits } from '@/lib/order/limits';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { getNextBizDayStartUTC } from '@/lib/time/biz-day';
/**
* GET /api/limits
@@ -13,19 +14,14 @@ import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
* wxpay: { dailyLimit: 10000, used: 10000, remaining: 0, available: false },
* stripe: { dailyLimit: 0, used: 500, remaining: null, available: true }
* },
* resetAt: "2026-03-02T00:00:00.000Z" // UTC 次日零点(限额重置时间
* resetAt: "2026-03-02T16:00:00.000Z" // 业务时区Asia/Shanghai次日零点对应的 UTC 时间
* }
*/
export async function GET() {
initPaymentProviders();
const types = paymentRegistry.getSupportedTypes();
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const resetAt = new Date(todayStart);
resetAt.setUTCDate(resetAt.getUTCDate() + 1);
const methods = await queryMethodLimits(types);
const resetAt = getNextBizDayStartUTC();
return NextResponse.json({ methods, resetAt });
}

View File

@@ -1,18 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyAdminToken } from '@/lib/admin-auth';
import { deriveOrderState } from '@/lib/order/status';
import { ORDER_STATUS_ACCESS_QUERY_KEY, verifyOrderStatusAccessToken } from '@/lib/order/status-access';
/**
* 订单状态轮询接口 — 仅返回 status / expiresAt 两个字段
* 订单状态轮询接口。
*
* 安全考虑
* - 订单 ID 使用 CUID25 位随机字符),具有足够的不可预测性,
* 暴力猜测的成本远高于信息价值。
* - 仅暴露 status 和 expiresAt不涉及用户隐私或金额信息。
* - 前端 PaymentQRCode 组件每 2 秒轮询此接口以更新支付状态,
* 添加认证会增加不必要的复杂度且影响轮询性能。
* 返回最小必要信息供前端判断
* - 原始订单状态status / expiresAt
* - 支付是否成功paymentSuccess
* - 充值是否成功 / 当前充值阶段rechargeSuccess / rechargeStatus
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const accessToken = request.nextUrl.searchParams.get(ORDER_STATUS_ACCESS_QUERY_KEY);
const isAuthorized = verifyOrderStatusAccessToken(id, accessToken) || (await verifyAdminToken(request));
if (!isAuthorized) {
return NextResponse.json({ error: '未授权访问该订单状态' }, { status: 401 });
}
const order = await prisma.order.findUnique({
where: { id },
@@ -20,6 +27,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
id: true,
status: true,
expiresAt: true,
paidAt: true,
completedAt: true,
},
});
@@ -27,9 +36,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
}
const derived = deriveOrderState(order);
return NextResponse.json({
id: order.id,
status: order.status,
expiresAt: order.expiresAt,
paymentSuccess: derived.paymentSuccess,
rechargeSuccess: derived.rechargeSuccess,
rechargeStatus: derived.rechargeStatus,
});
}

View File

@@ -0,0 +1,302 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { ORDER_STATUS } from '@/lib/constants';
import { getEnv } from '@/lib/config';
import { buildAlipayPaymentUrl } from '@/lib/alipay/provider';
import { deriveOrderState, getOrderDisplayState, type OrderStatusLike } from '@/lib/order/status';
import { buildOrderResultUrl } from '@/lib/order/status-access';
export const dynamic = 'force-dynamic';
const MOBILE_UA_PATTERN = /AlipayClient|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i;
const ALIPAY_APP_UA_PATTERN = /AlipayClient/i;
type ShortLinkOrderStatus = OrderStatusLike & { id: string };
function getUserAgent(request: NextRequest): string {
return request.headers.get('user-agent') || '';
}
function isMobileRequest(request: NextRequest): boolean {
return MOBILE_UA_PATTERN.test(getUserAgent(request));
}
function isAlipayAppRequest(request: NextRequest): boolean {
return ALIPAY_APP_UA_PATTERN.test(getUserAgent(request));
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function buildAppUrl(pathname = '/'): string {
return new URL(pathname, getEnv().NEXT_PUBLIC_APP_URL).toString();
}
function buildResultUrl(orderId: string): string {
return buildOrderResultUrl(getEnv().NEXT_PUBLIC_APP_URL, orderId);
}
function serializeScriptString(value: string): string {
return JSON.stringify(value).replace(/</g, '\\u003c');
}
function getStatusDisplay(order: OrderStatusLike) {
return getOrderDisplayState({
status: order.status,
...deriveOrderState(order),
});
}
function renderHtml(title: string, body: string, headExtra = ''): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex,nofollow" />
<title>${escapeHtml(title)}</title>
${headExtra}
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(180deg, #f5faff 0%, #eef6ff 100%);
color: #0f172a;
}
.card {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 20px;
padding: 28px 24px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12);
text-align: center;
}
.icon {
width: 60px;
height: 60px;
margin: 0 auto 18px;
border-radius: 18px;
background: #1677ff;
color: #fff;
font-size: 30px;
line-height: 60px;
font-weight: 700;
}
h1 {
margin: 0;
font-size: 22px;
line-height: 1.35;
}
p {
margin: 12px 0 0;
font-size: 14px;
line-height: 1.7;
color: #475569;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 46px;
margin-top: 20px;
padding: 12px 16px;
border-radius: 12px;
background: #1677ff;
color: #fff;
font-weight: 600;
text-decoration: none;
}
.button.secondary {
margin-top: 12px;
background: #eff6ff;
color: #1677ff;
}
.spinner {
width: 30px;
height: 30px;
margin: 18px auto 0;
border-radius: 9999px;
border: 3px solid rgba(22, 119, 255, 0.18);
border-top-color: #1677ff;
animation: spin 1s linear infinite;
}
.order {
margin-top: 18px;
padding: 10px 12px;
border-radius: 12px;
background: #f8fafc;
color: #334155;
font-size: 12px;
word-break: break-all;
}
.hint {
margin-top: 16px;
font-size: 13px;
color: #64748b;
}
.text-link {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 14px;
color: #1677ff;
font-size: 14px;
font-weight: 500;
text-decoration: none;
}
.text-link:hover {
text-decoration: underline;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
${body}
</body>
</html>`;
}
function renderErrorPage(title: string, message: string, orderId?: string, status = 400): NextResponse {
const html = renderHtml(
title,
`<main class="card">
<div class="icon">!</div>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>
${orderId ? `<div class="order">订单号:${escapeHtml(orderId)}</div>` : ''}
<a class="button secondary" href="${escapeHtml(buildAppUrl('/'))}">返回支付首页</a>
</main>`,
);
return new NextResponse(html, {
status,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
});
}
function renderStatusPage(order: ShortLinkOrderStatus): NextResponse {
const display = getStatusDisplay(order);
const html = renderHtml(
display.label,
`<main class="card">
<div class="icon">${escapeHtml(display.icon)}</div>
<h1>${escapeHtml(display.label)}</h1>
<p>${escapeHtml(display.message)}</p>
<div class="order">订单号:${escapeHtml(order.id)}</div>
<a class="button secondary" href="${escapeHtml(buildResultUrl(order.id))}">查看订单结果</a>
</main>`,
);
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
});
}
function renderRedirectPage(orderId: string, payUrl: string): NextResponse {
const html = renderHtml(
'正在跳转支付宝',
`<main class="card">
<div class="icon">支</div>
<h1>正在拉起支付宝</h1>
<p>请稍候,系统正在自动跳转到支付宝完成支付。</p>
<div class="spinner"></div>
<div class="order">订单号:${escapeHtml(orderId)}</div>
<p class="hint">如未自动拉起支付宝,请返回原充值页后重新发起支付。</p>
<a class="text-link" href="${escapeHtml(buildResultUrl(orderId))}">已支付?查看订单结果</a>
<script>
const payUrl = ${serializeScriptString(payUrl)};
window.location.replace(payUrl);
setTimeout(() => {
if (document.visibilityState === 'visible') {
window.location.replace(payUrl);
}
}, 800);
</script>
</main>`,
`<noscript><meta http-equiv="refresh" content="0;url=${escapeHtml(payUrl)}" /></noscript>`,
);
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
});
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ orderId: string }> }) {
const { orderId } = await params;
const order = await prisma.order.findUnique({
where: { id: orderId },
select: {
id: true,
amount: true,
payAmount: true,
paymentType: true,
status: true,
expiresAt: true,
paidAt: true,
completedAt: true,
},
});
if (!order) {
return renderErrorPage('订单不存在', '未找到对应订单,请确认二维码是否正确', orderId, 404);
}
if (order.paymentType !== 'alipay_direct') {
return renderErrorPage('支付方式不匹配', '该订单不是支付宝直连订单,无法通过当前链接支付', orderId, 400);
}
if (order.status !== ORDER_STATUS.PENDING) {
return renderStatusPage(order);
}
if (order.expiresAt.getTime() <= Date.now()) {
return renderStatusPage({
id: order.id,
status: ORDER_STATUS.EXPIRED,
paidAt: order.paidAt,
completedAt: order.completedAt,
});
}
const payAmount = Number(order.payAmount ?? order.amount);
if (!Number.isFinite(payAmount) || payAmount <= 0) {
return renderErrorPage('订单金额异常', '订单金额无效,请返回原页面重新发起支付', order.id, 500);
}
const env = getEnv();
const payUrl = buildAlipayPaymentUrl({
orderId: order.id,
amount: payAmount,
subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
notifyUrl: env.ALIPAY_NOTIFY_URL,
returnUrl: isAlipayAppRequest(request) ? null : buildResultUrl(order.id),
isMobile: isMobileRequest(request),
});
return renderRedirectPage(order.id, payUrl);
}

View File

@@ -9,6 +9,7 @@ import PayPageLayout from '@/components/PayPageLayout';
import MobileOrderList from '@/components/MobileOrderList';
import { resolveLocale, pickLocaleText, applyLocaleToSearchParams } from '@/lib/locale';
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
import type { MethodLimitInfo } from '@/components/PaymentForm';
interface OrderResult {
@@ -21,6 +22,7 @@ interface OrderResult {
qrCode?: string | null;
clientSecret?: string | null;
expiresAt: string;
statusAccessToken: string;
}
interface AppConfig {
@@ -51,7 +53,7 @@ function PayContent() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [orderResult, setOrderResult] = useState<OrderResult | null>(null);
const [finalStatus, setFinalStatus] = useState('');
const [finalOrderState, setFinalOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
@@ -184,16 +186,16 @@ function PayContent() {
}, [token, locale]);
useEffect(() => {
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
if (step !== 'result' || finalOrderState?.status !== 'COMPLETED') return;
loadUserAndOrders();
const timer = setTimeout(() => {
setStep('form');
setOrderResult(null);
setFinalStatus('');
setFinalOrderState(null);
setError('');
}, 2200);
return () => clearTimeout(timer);
}, [step, finalStatus]);
}, [step, finalOrderState]);
if (!hasToken) {
return (
@@ -292,6 +294,7 @@ function PayContent() {
qrCode: data.qrCode,
clientSecret: data.clientSecret,
expiresAt: data.expiresAt,
statusAccessToken: data.statusAccessToken,
});
setStep('paying');
@@ -302,8 +305,8 @@ function PayContent() {
}
};
const handleStatusChange = (status: string) => {
setFinalStatus(status);
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
setFinalOrderState(order);
setStep('result');
if (isMobile) {
setActiveMobileTab('orders');
@@ -313,7 +316,7 @@ function PayContent() {
const handleBack = () => {
setStep('form');
setOrderResult(null);
setFinalStatus('');
setFinalOrderState(null);
setError('');
};
@@ -538,6 +541,7 @@ function PayContent() {
amount={orderResult.amount}
payAmount={orderResult.payAmount}
expiresAt={orderResult.expiresAt}
statusAccessToken={orderResult.statusAccessToken}
onStatusChange={handleStatusChange}
onBack={handleBack}
dark={isDark}
@@ -547,7 +551,18 @@ function PayContent() {
/>
)}
{step === 'result' && <OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} locale={locale} />}
{step === 'result' && orderResult && finalOrderState && (
<OrderStatus
orderId={orderResult.orderId}
order={finalOrderState}
statusAccessToken={orderResult.statusAccessToken}
onStateChange={setFinalOrderState}
onBack={handleBack}
dark={isDark}
locale={locale}
/>
)}
{helpImageOpen && helpImageUrl && (
<div

View File

@@ -1,12 +1,122 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState, Suspense } from 'react';
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale';
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale, type Locale } from '@/lib/locale';
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
type WindowWithAlipayBridge = Window & {
AlipayJSBridge?: {
call: (name: string, params?: unknown, callback?: (...args: unknown[]) => void) => void;
};
};
function tryCloseViaAlipayBridge(): boolean {
const bridge = (window as WindowWithAlipayBridge).AlipayJSBridge;
if (!bridge?.call) {
return false;
}
try {
bridge.call('closeWebview');
return true;
} catch {
return false;
}
}
function closeCurrentWindow() {
if (tryCloseViaAlipayBridge()) {
return;
}
let settled = false;
const handleBridgeReady = () => {
if (settled) {
return;
}
settled = true;
document.removeEventListener('AlipayJSBridgeReady', handleBridgeReady);
if (!tryCloseViaAlipayBridge()) {
window.close();
}
};
document.addEventListener('AlipayJSBridgeReady', handleBridgeReady, { once: true });
window.setTimeout(() => {
if (settled) {
return;
}
settled = true;
document.removeEventListener('AlipayJSBridgeReady', handleBridgeReady);
window.close();
}, 250);
}
function buildOrderStatusUrl(orderId: string, accessToken?: string | null): string {
const query = new URLSearchParams();
if (accessToken) {
query.set('access_token', accessToken);
}
const suffix = query.toString();
return suffix ? `/api/orders/${orderId}?${suffix}` : `/api/orders/${orderId}`;
}
function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale, hasAccessToken: boolean) {
if (!order) {
return locale === 'en'
? { label: 'Payment Error', color: 'text-red-600', icon: '✗', message: hasAccessToken ? 'Unable to load the order status. Please try again later.' : 'Missing order access token. Please go back to the recharge page.' }
: { label: '支付异常', color: 'text-red-600', icon: '✗', message: hasAccessToken ? '未查询到订单状态,请稍后重试。' : '订单访问凭证缺失,请返回原充值页查看订单结果。' };
}
if (order.rechargeSuccess) {
return locale === 'en'
? { label: 'Recharge Successful', color: 'text-green-600', icon: '✓', message: 'Your balance has been credited successfully.' }
: { label: '充值成功', color: 'text-green-600', icon: '✓', message: '余额已成功到账!' };
}
if (order.paymentSuccess) {
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
return locale === 'en'
? { label: 'Top-up Processing', color: 'text-blue-600', icon: '⟳', message: 'Payment succeeded, and the balance top-up is being processed.' }
: { label: '充值处理中', color: 'text-blue-600', icon: '⟳', message: '支付成功,余额正在充值中...' };
}
if (order.rechargeStatus === 'failed') {
return locale === 'en'
? { label: 'Payment Successful', color: 'text-amber-600', icon: '!', message: 'Payment succeeded, but the balance top-up has not completed yet. Please check again later or contact the administrator.' }
: { label: '支付成功', color: 'text-amber-600', icon: '!', message: '支付成功,但余额充值暂未完成,请稍后查看订单结果或联系管理员。' };
}
}
if (order.status === 'PENDING') {
return locale === 'en'
? { label: 'Awaiting Payment', color: 'text-yellow-600', icon: '⏳', message: 'The order has not been paid yet.' }
: { label: '等待支付', color: 'text-yellow-600', icon: '⏳', message: '订单尚未完成支付。' };
}
if (order.status === 'EXPIRED') {
return locale === 'en'
? { label: 'Order Expired', color: 'text-gray-500', icon: '⏰', message: 'This order has expired. Please create a new order.' }
: { label: '订单已超时', color: 'text-gray-500', icon: '⏰', message: '订单已超时,请重新充值。' };
}
if (order.status === 'CANCELLED') {
return locale === 'en'
? { label: 'Order Cancelled', color: 'text-gray-500', icon: '✗', message: 'This order has been cancelled.' }
: { label: '订单已取消', color: 'text-gray-500', icon: '✗', message: '订单已被取消。' };
}
return locale === 'en'
? { label: 'Payment Error', color: 'text-red-600', icon: '✗', message: 'Please contact the administrator.' }
: { label: '支付异常', color: 'text-red-600', icon: '✗', message: '请联系管理员处理。' };
}
function ResultContent() {
const searchParams = useSearchParams();
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
const accessToken = searchParams.get('access_token');
const isPopup = searchParams.get('popup') === '1';
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const locale = resolveLocale(searchParams.get('lang'));
@@ -14,30 +124,16 @@ function ResultContent() {
const text = {
checking: pickLocaleText(locale, '查询支付结果中...', 'Checking payment result...'),
success: pickLocaleText(locale, '充值成功', 'Top-up successful'),
processing: pickLocaleText(locale, '充值处理中', 'Top-up processing'),
successMessage: pickLocaleText(locale, '余额已成功到账!', 'Balance has been credited successfully!'),
processingMessage: pickLocaleText(locale, '支付成功,余额正在充值中...', 'Payment succeeded, balance is being credited...'),
returning: pickLocaleText(locale, '正在返回...', 'Returning...'),
returnNow: pickLocaleText(locale, '立即返回', 'Return now'),
pending: pickLocaleText(locale, '等待支付', 'Awaiting payment'),
pendingMessage: pickLocaleText(locale, '订单尚未完成支付', 'The order has not been paid yet'),
expired: pickLocaleText(locale, '订单已超时', 'Order expired'),
cancelled: pickLocaleText(locale, '订单已取消', 'Order cancelled'),
abnormal: pickLocaleText(locale, '支付异常', 'Payment error'),
expiredMessage: pickLocaleText(locale, '订单已超时,请重新充值', 'This order has expired. Please create a new one.'),
cancelledMessage: pickLocaleText(locale, '订单已被取消', 'This order has been cancelled.'),
abnormalMessage: pickLocaleText(locale, '请联系管理员处理', 'Please contact the administrator.'),
back: pickLocaleText(locale, '返回', 'Back'),
closeSoon: pickLocaleText(locale, '此窗口将在 3 秒后自动关闭', 'This window will close automatically in 3 seconds'),
closeNow: pickLocaleText(locale, '立即关闭窗口', 'Close now'),
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
unknown: pickLocaleText(locale, '未知', 'Unknown'),
loading: pickLocaleText(locale, '加载中...', 'Loading...'),
};
const [status, setStatus] = useState<string | null>(null);
const [orderState, setOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [isInPopup, setIsInPopup] = useState(false);
const [countdown, setCountdown] = useState(5);
useEffect(() => {
if (isPopup || window.opener) {
@@ -46,17 +142,17 @@ function ResultContent() {
}, [isPopup]);
useEffect(() => {
if (!outTradeNo) {
if (!outTradeNo || !accessToken) {
setLoading(false);
return;
}
const checkOrder = async () => {
try {
const res = await fetch(`/api/orders/${outTradeNo}`);
const res = await fetch(buildOrderStatusUrl(outTradeNo, accessToken));
if (res.ok) {
const data = await res.json();
setStatus(data.status);
const data = (await res.json()) as PublicOrderStatusSnapshot;
setOrderState(data);
}
} catch {
} finally {
@@ -71,13 +167,13 @@ function ResultContent() {
clearInterval(timer);
clearTimeout(timeout);
};
}, [outTradeNo]);
}, [outTradeNo, accessToken]);
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
const shouldAutoClose = Boolean(orderState?.paymentSuccess);
const goBack = () => {
if (isInPopup) {
window.close();
closeCurrentWindow();
return;
}
@@ -93,20 +189,12 @@ function ResultContent() {
};
useEffect(() => {
if (!isSuccess) return;
setCountdown(5);
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
goBack();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [isSuccess, isInPopup]);
if (!isInPopup || !shouldAutoClose) return;
const timer = setTimeout(() => {
closeCurrentWindow();
}, 3000);
return () => clearTimeout(timer);
}, [isInPopup, shouldAutoClose]);
if (loading) {
return (
@@ -116,8 +204,7 @@ function ResultContent() {
);
}
const isPending = status === 'PENDING';
const countdownText = countdown > 0 ? pickLocaleText(locale, `${countdown} 秒后自动返回`, `${countdown} seconds before returning`) : text.returning;
const display = getStatusConfig(orderState, locale, Boolean(accessToken));
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
@@ -127,58 +214,31 @@ function ResultContent() {
isDark ? 'bg-slate-900 text-slate-100' : 'bg-white',
].join(' ')}
>
{isSuccess ? (
<>
<div className="text-6xl text-green-500"></div>
<h1 className="mt-4 text-xl font-bold text-green-600">{status === 'COMPLETED' ? text.success : text.processing}</h1>
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>
{status === 'COMPLETED' ? text.successMessage : text.processingMessage}
</p>
<div className={`text-6xl ${display.color}`}>{display.icon}</div>
<h1 className={`mt-4 text-xl font-bold ${display.color}`}>{display.label}</h1>
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>{display.message}</p>
{isInPopup ? (
shouldAutoClose && (
<div className="mt-4 space-y-2">
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>{countdownText}</p>
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>{text.closeSoon}</p>
<button
type="button"
onClick={goBack}
onClick={closeCurrentWindow}
className="text-sm text-blue-600 underline hover:text-blue-700"
>
{text.returnNow}
{text.closeNow}
</button>
</div>
</>
) : isPending ? (
<>
<div className="text-6xl text-yellow-500"></div>
<h1 className="mt-4 text-xl font-bold text-yellow-600">{text.pending}</h1>
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>{text.pendingMessage}</p>
<button
type="button"
onClick={goBack}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
{text.back}
</button>
</>
)
) : (
<>
<div className="text-6xl text-red-500"></div>
<h1 className="mt-4 text-xl font-bold text-red-600">
{status === 'EXPIRED' ? text.expired : status === 'CANCELLED' ? text.cancelled : text.abnormal}
</h1>
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>
{status === 'EXPIRED'
? text.expiredMessage
: status === 'CANCELLED'
? text.cancelledMessage
: text.abnormalMessage}
</p>
<button
type="button"
onClick={goBack}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
{text.back}
</button>
</>
<button
type="button"
onClick={goBack}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
{text.back}
</button>
)}
<p className={isDark ? 'mt-4 text-xs text-slate-500' : 'mt-4 text-xs text-gray-400'}>

View File

@@ -11,6 +11,7 @@ function StripePopupContent() {
const amount = parseFloat(searchParams.get('amount') || '0') || 0;
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const method = searchParams.get('method') || '';
const accessToken = searchParams.get('access_token');
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const isAlipay = method === 'alipay';
@@ -50,9 +51,12 @@ function StripePopupContent() {
returnUrl.searchParams.set('status', 'success');
returnUrl.searchParams.set('popup', '1');
returnUrl.searchParams.set('theme', theme);
if (accessToken) {
returnUrl.searchParams.set('access_token', accessToken);
}
applyLocaleToSearchParams(returnUrl.searchParams, locale);
return returnUrl.toString();
}, [orderId, theme, locale]);
}, [orderId, theme, locale, accessToken]);
useEffect(() => {
const handler = (event: MessageEvent) => {