This commit is contained in:
eric
2026-03-10 07:23:18 -05:00
parent a1696c2d0b
commit e9c6ec4d84
4 changed files with 100 additions and 34 deletions

View File

@@ -114,9 +114,9 @@ export async function POST(request: NextRequest) {
}
}
const return_url = String(body?.return_url || "").trim();
const total_fee = Number(body?.total_fee) || 180;
const total_fee = Number(body?.total_fee) || 8800;
const type = String(body?.type || "vip");
const channel = String(body?.channel || "alipay");
const channel = String(body?.channel || "wxpay");
const device = String(body?.device || "pc");
if (!user_id) {
@@ -178,10 +178,65 @@ export async function POST(request: NextRequest) {
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
const location = redirectRes.headers.get("location");
if (location) {
const res = NextResponse.redirect(location);
const orderId =
redirectRes.headers.get("x-pay-order-id")?.trim() ||
String(params?.out_trade_no || "").trim();
if (device === "h5") {
const returnUrl = finalReturnUrl.replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const payUrl = location.replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const isWx = ["wxpay", "wx", "wechat"].includes(channel.toLowerCase());
const title = isWx ? "微信支付" : "支付宝支付";
const hint = isWx
? "点击下方按钮跳转至支付页面,支付完成后将自动返回"
: "点击下方按钮跳转至支付页面,支付完成后将自动返回";
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<style>body{font-family:system-ui;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;padding:20px;}h2{color:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;text-align:center;}.btn{display:block;width:100%;max-width:280px;margin:24px auto;padding:14px 24px;font-size:16px;font-weight:600;color:#fff;background:#07c160;border:none;border-radius:8px;cursor:pointer;text-align:center;text-decoration:none;}.btn:active{opacity:0.9;}.tip{font-size:12px;color:#94a3b8;margin-top:16px;}</style></head>
<body>
<h2>${esc(title)}</h2>
<p>${esc(hint)}</p>
<a href="${payUrl}" class="btn">跳转支付</a>
<p class="tip" id="tip">支付成功后将自动跳转1 分钟内未支付将返回首页</p>
<script>
(function(){
var orderId="${String(orderId).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}";
var returnUrl="${returnUrl}";
if(!orderId){return;}
var tip=document.getElementById("tip");
var t=0;
var maxT=30;
function check(){
t++;
if(t>maxT){tip.textContent="支付超时,返回首页";window.location.href="/";return;}
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
.then(function(r){return r.json();})
.then(function(d){
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
setTimeout(check,2000);
})
.catch(function(){setTimeout(check,2000);});
}
setTimeout(check,2000);
})();
</script>
</body></html>`;
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) setPayOrderCookie(res, orderId);
return res;
}
const res = NextResponse.redirect(location);
if (orderId) setPayOrderCookie(res, orderId);
return res;
}
@@ -229,7 +284,7 @@ export async function POST(request: NextRequest) {
<h2>${esc(title)}</h2>
<p>${esc(hint)}</p>
<img src="${imgSrc}" alt="支付二维码" />
<p class="tip" id="tip">支付成功后将自动跳转...</p>
<p class="tip" id="tip">支付成功后将自动跳转1 分钟内未支付将返回首页</p>
<script>
(function(){
var orderId="${orderId}";
@@ -237,10 +292,10 @@ export async function POST(request: NextRequest) {
if(!orderId){return;}
var tip=document.getElementById("tip");
var t=0;
var maxT=300;
var maxT=30;
function check(){
t++;
if(t>maxT){tip.textContent="轮询超时";return;}
if(t>maxT){tip.textContent="支付超时,返回首页";window.location.href="/";return;}
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
.then(function(r){return r.json();})
.then(function(d){

View File

@@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from "react";
const COOKIE_NAME = "nomadvip_pay_order";
const POLL_INTERVAL = 2000;
const MAX_POLLS = 150;
const MAX_POLLS = 30;
const MAX_AGE_MS = 15 * 60 * 1000;
function getCookie(name: string): string | null {
@@ -31,10 +31,12 @@ function parsePayOrderCookie(raw: string | null): [string | null, boolean] {
return [orderId, true];
}
export function usePayStatusPoll(onPaid: () => void): boolean {
export function usePayStatusPoll(onPaid: () => void, onTimeout?: () => void): boolean {
const [polling, setPolling] = useState(false);
const onPaidRef = useRef(onPaid);
const onTimeoutRef = useRef(onTimeout);
onPaidRef.current = onPaid;
onTimeoutRef.current = onTimeout;
useEffect(() => {
const raw = getCookie(COOKIE_NAME);
@@ -52,6 +54,7 @@ export function usePayStatusPoll(onPaid: () => void): boolean {
clearInterval(timer);
clearCookie(COOKIE_NAME);
setPolling(false);
onTimeoutRef.current?.();
return;
}
try {

View File

@@ -6,12 +6,7 @@ import { marked } from "marked";
import PocketBase from "pocketbase";
import { indexMd } from "./index-content";
import styles from "./index.module.css";
import {
getPayEnv,
getRecommendedChannel,
mustUseWxPay,
getDeviceFromEnv,
} from "@/app/lib/payEnv";
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
const PB_URL =
@@ -152,14 +147,13 @@ export default function Home() {
const userId = getOrCreateUserId();
const returnUrl = `${window.location.origin}/paid`;
const env = getPayEnv();
const channel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
const device = getDeviceFromEnv(env);
redirectToPay({
user_id: userId,
return_url: returnUrl,
total_fee: 180,
total_fee: 8800,
type: "vip",
channel,
channel: "wxpay",
device,
});
}, [getOrCreateUserId]);

View File

@@ -2,13 +2,41 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
function SuccessWithRedirect() {
const router = useRouter();
useEffect(() => {
const t = setTimeout(() => router.replace("/"), 2000);
return () => clearTimeout(t);
}, [router]);
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
<div className="w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 text-5xl">
</div>
<h2 className="text-2xl font-bold text-slate-800"></h2>
<p className="mt-3 text-slate-500">VIP </p>
<p className="mt-2 text-xs text-slate-400">2 </p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
>
</Link>
</div>
</div>
);
}
export default function PaidPage() {
const [status, setStatus] = useState<"pending" | "success" | "fail">("pending");
const [fromUrl, setFromUrl] = useState<string | null>(null);
usePayStatusPoll(() => setStatus("success"));
const router = useRouter();
usePayStatusPoll(() => setStatus("success"), () => router.replace("/"));
useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -29,7 +57,7 @@ export default function PaidPage() {
<p className="mt-3 text-sm text-slate-500">
<br />
1
</p>
</div>
</div>
@@ -38,21 +66,7 @@ export default function PaidPage() {
if (status === "success") {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
<div className="w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 text-5xl">
</div>
<h2 className="text-2xl font-bold text-slate-800"></h2>
<p className="mt-3 text-slate-500">VIP </p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
>
</Link>
</div>
</div>
<SuccessWithRedirect />
);
}