's'
This commit is contained in:
@@ -114,9 +114,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const return_url = String(body?.return_url || "").trim();
|
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 type = String(body?.type || "vip");
|
||||||
const channel = String(body?.channel || "alipay");
|
const channel = String(body?.channel || "wxpay");
|
||||||
const device = String(body?.device || "pc");
|
const device = String(body?.device || "pc");
|
||||||
|
|
||||||
if (!user_id) {
|
if (!user_id) {
|
||||||
@@ -178,10 +178,65 @@ export async function POST(request: NextRequest) {
|
|||||||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||||
const location = redirectRes.headers.get("location");
|
const location = redirectRes.headers.get("location");
|
||||||
if (location) {
|
if (location) {
|
||||||
const res = NextResponse.redirect(location);
|
|
||||||
const orderId =
|
const orderId =
|
||||||
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
||||||
String(params?.out_trade_no || "").trim();
|
String(params?.out_trade_no || "").trim();
|
||||||
|
if (device === "h5") {
|
||||||
|
const returnUrl = finalReturnUrl.replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||||
|
);
|
||||||
|
const payUrl = location.replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||||
|
);
|
||||||
|
const isWx = ["wxpay", "wx", "wechat"].includes(channel.toLowerCase());
|
||||||
|
const title = isWx ? "微信支付" : "支付宝支付";
|
||||||
|
const hint = isWx
|
||||||
|
? "点击下方按钮跳转至支付页面,支付完成后将自动返回"
|
||||||
|
: "点击下方按钮跳转至支付页面,支付完成后将自动返回";
|
||||||
|
const esc = (s: string) =>
|
||||||
|
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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);
|
if (orderId) setPayOrderCookie(res, orderId);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -229,7 +284,7 @@ export async function POST(request: NextRequest) {
|
|||||||
<h2>${esc(title)}</h2>
|
<h2>${esc(title)}</h2>
|
||||||
<p>${esc(hint)}</p>
|
<p>${esc(hint)}</p>
|
||||||
<img src="${imgSrc}" alt="支付二维码" />
|
<img src="${imgSrc}" alt="支付二维码" />
|
||||||
<p class="tip" id="tip">支付成功后将自动跳转...</p>
|
<p class="tip" id="tip">支付成功后将自动跳转,1 分钟内未支付将返回首页</p>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var orderId="${orderId}";
|
var orderId="${orderId}";
|
||||||
@@ -237,10 +292,10 @@ export async function POST(request: NextRequest) {
|
|||||||
if(!orderId){return;}
|
if(!orderId){return;}
|
||||||
var tip=document.getElementById("tip");
|
var tip=document.getElementById("tip");
|
||||||
var t=0;
|
var t=0;
|
||||||
var maxT=300;
|
var maxT=30;
|
||||||
function check(){
|
function check(){
|
||||||
t++;
|
t++;
|
||||||
if(t>maxT){tip.textContent="轮询超时";return;}
|
if(t>maxT){tip.textContent="支付超时,返回首页";window.location.href="/";return;}
|
||||||
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
|
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
|
||||||
.then(function(r){return r.json();})
|
.then(function(r){return r.json();})
|
||||||
.then(function(d){
|
.then(function(d){
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
const COOKIE_NAME = "nomadvip_pay_order";
|
const COOKIE_NAME = "nomadvip_pay_order";
|
||||||
const POLL_INTERVAL = 2000;
|
const POLL_INTERVAL = 2000;
|
||||||
const MAX_POLLS = 150;
|
const MAX_POLLS = 30;
|
||||||
const MAX_AGE_MS = 15 * 60 * 1000;
|
const MAX_AGE_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
function getCookie(name: string): string | null {
|
function getCookie(name: string): string | null {
|
||||||
@@ -31,10 +31,12 @@ function parsePayOrderCookie(raw: string | null): [string | null, boolean] {
|
|||||||
return [orderId, true];
|
return [orderId, true];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePayStatusPoll(onPaid: () => void): boolean {
|
export function usePayStatusPoll(onPaid: () => void, onTimeout?: () => void): boolean {
|
||||||
const [polling, setPolling] = useState(false);
|
const [polling, setPolling] = useState(false);
|
||||||
const onPaidRef = useRef(onPaid);
|
const onPaidRef = useRef(onPaid);
|
||||||
|
const onTimeoutRef = useRef(onTimeout);
|
||||||
onPaidRef.current = onPaid;
|
onPaidRef.current = onPaid;
|
||||||
|
onTimeoutRef.current = onTimeout;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const raw = getCookie(COOKIE_NAME);
|
const raw = getCookie(COOKIE_NAME);
|
||||||
@@ -52,6 +54,7 @@ export function usePayStatusPoll(onPaid: () => void): boolean {
|
|||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
clearCookie(COOKIE_NAME);
|
clearCookie(COOKIE_NAME);
|
||||||
setPolling(false);
|
setPolling(false);
|
||||||
|
onTimeoutRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
12
app/page.tsx
12
app/page.tsx
@@ -6,12 +6,7 @@ import { marked } from "marked";
|
|||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { indexMd } from "./index-content";
|
import { indexMd } from "./index-content";
|
||||||
import styles from "./index.module.css";
|
import styles from "./index.module.css";
|
||||||
import {
|
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
|
||||||
getPayEnv,
|
|
||||||
getRecommendedChannel,
|
|
||||||
mustUseWxPay,
|
|
||||||
getDeviceFromEnv,
|
|
||||||
} from "@/app/lib/payEnv";
|
|
||||||
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
|
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
|
||||||
|
|
||||||
const PB_URL =
|
const PB_URL =
|
||||||
@@ -152,14 +147,13 @@ export default function Home() {
|
|||||||
const userId = getOrCreateUserId();
|
const userId = getOrCreateUserId();
|
||||||
const returnUrl = `${window.location.origin}/paid`;
|
const returnUrl = `${window.location.origin}/paid`;
|
||||||
const env = getPayEnv();
|
const env = getPayEnv();
|
||||||
const channel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
|
|
||||||
const device = getDeviceFromEnv(env);
|
const device = getDeviceFromEnv(env);
|
||||||
redirectToPay({
|
redirectToPay({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
return_url: returnUrl,
|
return_url: returnUrl,
|
||||||
total_fee: 180,
|
total_fee: 8800,
|
||||||
type: "vip",
|
type: "vip",
|
||||||
channel,
|
channel: "wxpay",
|
||||||
device,
|
device,
|
||||||
});
|
});
|
||||||
}, [getOrCreateUserId]);
|
}, [getOrCreateUserId]);
|
||||||
|
|||||||
@@ -2,13 +2,41 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
|
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() {
|
export default function PaidPage() {
|
||||||
const [status, setStatus] = useState<"pending" | "success" | "fail">("pending");
|
const [status, setStatus] = useState<"pending" | "success" | "fail">("pending");
|
||||||
const [fromUrl, setFromUrl] = useState<string | null>(null);
|
const [fromUrl, setFromUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
usePayStatusPoll(() => setStatus("success"));
|
const router = useRouter();
|
||||||
|
usePayStatusPoll(() => setStatus("success"), () => router.replace("/"));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -29,7 +57,7 @@ export default function PaidPage() {
|
|||||||
<p className="mt-3 text-sm text-slate-500">
|
<p className="mt-3 text-sm text-slate-500">
|
||||||
正在查询支付状态,请稍候…
|
正在查询支付状态,请稍候…
|
||||||
<br />
|
<br />
|
||||||
(支付完成后将自动跳转)
|
(支付完成后将自动跳转,1 分钟内未支付将返回首页)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,21 +66,7 @@ export default function PaidPage() {
|
|||||||
|
|
||||||
if (status === "success") {
|
if (status === "success") {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
<SuccessWithRedirect />
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user