fix: harden alipay direct pay flow
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 使用 CUID(25 位随机字符),具有足够的不可预测性,
|
||||
* 暴力猜测的成本远高于信息价值。
|
||||
* - 仅暴露 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,
|
||||
});
|
||||
}
|
||||
|
||||
302
src/app/pay/[orderId]/route.ts
Normal file
302
src/app/pay/[orderId]/route.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user