'c1
This commit is contained in:
@@ -4,13 +4,11 @@ import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "r
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import {
|
||||
getPayEnv,
|
||||
getRecommendedChannel,
|
||||
mustUseWxPay,
|
||||
redirectToPay,
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/lib/payment";
|
||||
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
|
||||
import type { PayChannel, PayEnv } from "@/app/lib/payment";
|
||||
import type { PayEnv } from "@/app/lib/payment";
|
||||
import AuthModal from "@/app/components/AuthModal";
|
||||
|
||||
const genderOptions = ["男", "女"];
|
||||
@@ -57,7 +55,6 @@ export default function JoinPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [payRedirecting, setPayRedirecting] = useState(false);
|
||||
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
|
||||
const [payChannel, setPayChannel] = useState<PayChannel>("wxpay");
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
const [pendingSubmit, setPendingSubmit] = useState(false);
|
||||
const [isAlreadyRecorded, setIsAlreadyRecorded] = useState(false);
|
||||
@@ -97,9 +94,7 @@ export default function JoinPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const env = getPayEnv();
|
||||
setPayEnv(env);
|
||||
setPayChannel(getRecommendedChannel(env));
|
||||
setPayEnv(getPayEnv());
|
||||
}, []);
|
||||
|
||||
const set = (key: keyof FormData, value: string) =>
|
||||
@@ -137,7 +132,7 @@ export default function JoinPage() {
|
||||
setPayRedirecting(true);
|
||||
const returnUrl = `${window.location.origin}${window.location.pathname}/paid`;
|
||||
const env = getPayEnv();
|
||||
const channel = mustUseWxPay(env) ? "wxpay" : payChannel;
|
||||
const channel = "wxpay";
|
||||
const device = getDeviceFromEnv(env);
|
||||
redirectToPay({
|
||||
user_id: userId,
|
||||
|
||||
@@ -5,6 +5,13 @@ import { Link } from "@/i18n/navigation";
|
||||
|
||||
const DEFAULT_PASSWORD = "12345678";
|
||||
|
||||
function clearPendingOrder(): void {
|
||||
try {
|
||||
localStorage.removeItem("join_pay_order");
|
||||
} catch {}
|
||||
document.cookie = "join_pay_order=; path=/; max-age=0";
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付完成回跳页
|
||||
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
|
||||
@@ -60,9 +67,17 @@ export default function JoinPaidPage() {
|
||||
const run = async () => {
|
||||
const cookie = document.cookie.split(";").find((c) => c.trim().startsWith("join_pay_order="));
|
||||
const rawValue = cookie?.split("=")[1]?.trim();
|
||||
const decoded = rawValue ? decodeURIComponent(rawValue) : "";
|
||||
const storageValue = (() => {
|
||||
try {
|
||||
return localStorage.getItem("join_pay_order") || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
const decoded = rawValue ? decodeURIComponent(rawValue) : storageValue;
|
||||
const orderId = decoded.split("|")[0]?.trim() || "";
|
||||
if (!orderId) {
|
||||
clearPendingOrder();
|
||||
window.location.replace(`/${window.location.pathname.split("/")[1] || "zh"}/join`);
|
||||
return;
|
||||
}
|
||||
@@ -75,7 +90,7 @@ export default function JoinPaidPage() {
|
||||
try {
|
||||
const result = await tryComplete(orderId);
|
||||
if (result.ok) {
|
||||
document.cookie = "join_pay_order=; path=/; max-age=0";
|
||||
clearPendingOrder();
|
||||
if (!cancelled) setCompleteStatus("success");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ function getMeetupApiCandidates(request: NextRequest): string[] {
|
||||
return urls.filter((url, index) => url && urls.indexOf(url) === index);
|
||||
}
|
||||
|
||||
/** 上游 API 请求超时时间(毫秒),避免 serverless 超时导致 502 */
|
||||
const MEETUP_API_TIMEOUT_MS = 20000;
|
||||
|
||||
export async function postMeetupApi(
|
||||
request: NextRequest,
|
||||
path: string,
|
||||
@@ -54,13 +57,19 @@ export async function postMeetupApi(
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const apiUrl of apiCandidates) {
|
||||
const url = `${apiUrl}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), MEETUP_API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}${path}`, {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (res.ok || (res.status !== 404 && res.status < 500)) {
|
||||
@@ -68,7 +77,15 @@ export async function postMeetupApi(
|
||||
}
|
||||
|
||||
lastResponse = { status: res.status, data };
|
||||
if (res.status >= 500) {
|
||||
console.error(`[meetup-api] ${path} ${apiUrl} returned ${res.status}`, data);
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
const errName = error instanceof Error && "name" in error ? (error as { name?: string }).name : "";
|
||||
const isTimeout = errName === "AbortError" || errMsg.toLowerCase().includes("abort") || errMsg.toLowerCase().includes("timeout");
|
||||
console.error(`[meetup-api] ${path} ${apiUrl} failed:`, isTimeout ? "timeout" : errMsg);
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { postMeetupApi } from "../_paymentApi";
|
||||
|
||||
/** Vercel serverless 超时时间(秒),避免上游慢导致 502 */
|
||||
export const maxDuration = 20;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
@@ -10,9 +13,30 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { status, data } = await postMeetupApi(request, "/api/meetup/check-user", { email });
|
||||
|
||||
// 上游返回 5xx 时,统一为 503 并返回友好提示,避免直接透传 502
|
||||
if (status >= 500) {
|
||||
const payload = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: (payload.error as string) || "服务暂时不可用,请稍后重试" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data, { status });
|
||||
} catch (error) {
|
||||
const isTimeout =
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || error.message.toLowerCase().includes("abort"));
|
||||
console.error("check-user error:", error);
|
||||
return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: isTimeout
|
||||
? "请求超时,请确认 payjsapi 已启动(cd payjsapi && python run.py)"
|
||||
: "服务暂时不可用,请稍后重试",
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPocketBaseConfig, getPaymentConfig, SITE_ID } from "@/config/services.config";
|
||||
import {
|
||||
getApiUrlFromRequest,
|
||||
getPocketBaseConfig,
|
||||
getPaymentConfig,
|
||||
SITE_ID,
|
||||
} from "@/config/services.config";
|
||||
import { getAdminToken } from "@/app/lib/pocketbase/admin";
|
||||
import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus";
|
||||
import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus";
|
||||
|
||||
const DEFAULT_PASSWORD = "12345678";
|
||||
|
||||
@@ -26,27 +33,44 @@ function decodePendingEmail(userId: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function queryZpayPaid(orderId: string): Promise<boolean> {
|
||||
async function verifyOrderPaid(request: NextRequest, orderId: string): Promise<boolean> {
|
||||
const config = getPaymentConfig();
|
||||
if (!config.apiUrl) {
|
||||
console.error("queryZpayPaid: PAYMENT_API_URL not configured");
|
||||
return false;
|
||||
}
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
const apiUrl = (
|
||||
getApiUrlFromRequest(hostHeader) ||
|
||||
process.env.PAYMENT_API_URL ||
|
||||
config.apiUrl
|
||||
).replace(/\/$/, "");
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const url = `${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`;
|
||||
const res = await fetch(url, { cache: "no-store", signal: controller.signal })
|
||||
.finally(() => clearTimeout(timeout));
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const url = `${apiUrl}/cnomadcna/order_status?order_id=${encodeURIComponent(orderId)}`;
|
||||
console.log(`complete-order: check payjsapi order_id=${orderId} url=${url}`);
|
||||
const res = await fetch(url, { cache: "no-store", signal: controller.signal }).finally(() =>
|
||||
clearTimeout(timeout)
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data?.paid) {
|
||||
console.warn(`queryZpayPaid: not paid yet, order_id=${orderId}, response=`, JSON.stringify(data));
|
||||
if (data?.paid) {
|
||||
console.log(`complete-order: payjsapi paid order_id=${orderId}`);
|
||||
return true;
|
||||
}
|
||||
return !!data?.paid;
|
||||
} catch (e) {
|
||||
console.error(`queryZpayPaid: fetch error, order_id=${orderId}`, e);
|
||||
return false;
|
||||
console.error(`complete-order: payjsapi fetch error order_id=${orderId}`, e);
|
||||
}
|
||||
|
||||
const xorpay = await queryXorPayOrderStatus(orderId, 5000);
|
||||
console.log(
|
||||
`complete-order: xorpay fallback order_id=${orderId} paid=${!!xorpay?.paid} status=${xorpay?.status || ""} error=${xorpay?.error || ""}`
|
||||
);
|
||||
if (xorpay?.paid) return true;
|
||||
|
||||
const zpay = await queryZPayOrderStatus(orderId, 5000);
|
||||
console.log(
|
||||
`complete-order: zpay fallback order_id=${orderId} paid=${!!zpay?.paid} status=${zpay?.status || ""} error=${zpay?.error || ""}`
|
||||
);
|
||||
return !!zpay?.paid;
|
||||
}
|
||||
|
||||
async function ensureSiteVip(
|
||||
@@ -145,8 +169,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 2. 没找到 → 查 ZPAY 确认是否已支付,已支付则直接创建 site_vip
|
||||
if (!found) {
|
||||
const zpayPaid = await queryZpayPaid(orderId);
|
||||
if (!zpayPaid) {
|
||||
const paid = await verifyOrderPaid(request, orderId);
|
||||
if (!paid) {
|
||||
console.error(`complete-order: ZPAY 未确认支付 order_id=${orderId}`);
|
||||
return NextResponse.json({ ok: false, error: "订单不存在或未支付成功" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,30 +1,226 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPaymentConfig } from "@/config/services.config";
|
||||
import {
|
||||
getApiUrlFromRequest,
|
||||
getJoinPaymentConfig,
|
||||
getPaymentConfig,
|
||||
SITE_ID,
|
||||
} from "@/config/services.config";
|
||||
import { SITE_URL } from "@/app/lib/env";
|
||||
|
||||
async function createPayOrder(
|
||||
user_id: string,
|
||||
return_url: string,
|
||||
total_fee?: number,
|
||||
type?: string,
|
||||
channel = "alipay"
|
||||
) {
|
||||
const config = getPaymentConfig();
|
||||
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
|
||||
const ORDER_COOKIE = "join_pay_order";
|
||||
|
||||
const payload = {
|
||||
user_id,
|
||||
site_id: "meetup",
|
||||
total_fee: Number(total_fee) || config.defaultAmount,
|
||||
type: type || config.defaultType,
|
||||
function resolvePayApiUrl(request: NextRequest): string {
|
||||
const config = getPaymentConfig();
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
return (
|
||||
getApiUrlFromRequest(hostHeader) ||
|
||||
process.env.PAYMENT_API_URL ||
|
||||
config.apiUrl
|
||||
).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function setPayOrderCookie(response: NextResponse, orderId: string) {
|
||||
response.cookies.set(ORDER_COOKIE, `${orderId}|${Date.now()}`, {
|
||||
path: "/",
|
||||
maxAge: 900,
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function buildPayHtml(
|
||||
payUrl: string,
|
||||
params: Record<string, unknown>,
|
||||
options?: { directToCashier?: boolean; orderId?: string }
|
||||
): string {
|
||||
const inputs = Object.entries(params)
|
||||
.filter(([, value]) => value != null && String(value).trim() !== "")
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`<input type="hidden" name="${escapeAttr(key)}" value="${escapeAttr(
|
||||
String(value)
|
||||
)}" />`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const pendingOrderScript = options?.orderId
|
||||
? `<script>(function(){var v=${JSON.stringify(
|
||||
`${options.orderId}|${Date.now()}`
|
||||
)};try{localStorage.setItem("join_pay_order",v);}catch(e){}document.cookie="join_pay_order="+encodeURIComponent(v)+"; path=/; max-age=900";})();</script>`
|
||||
: "";
|
||||
|
||||
return options?.directToCashier
|
||||
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>支付</title><style>body{margin:0;overflow:hidden}</style></head><body><form id="f" action="${escapeAttr(
|
||||
payUrl
|
||||
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`
|
||||
: `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style></head><body><p>正在跳转到支付页面...</p><form id="f" action="${escapeAttr(
|
||||
payUrl
|
||||
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`;
|
||||
}
|
||||
|
||||
function buildQrHtml(
|
||||
imgSrc: string,
|
||||
returnUrl: string,
|
||||
orderId: string,
|
||||
opts?: { channel?: string; successDesc?: string; confirmUrl?: string }
|
||||
): string {
|
||||
const ch = (opts?.channel || "wxpay").toLowerCase();
|
||||
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
|
||||
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
|
||||
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
|
||||
const successDesc = opts?.successDesc || "支付成功,即将跳转";
|
||||
const confirmUrl = opts?.confirmUrl || "/api/pay/confirm";
|
||||
const esc = (s: string) =>
|
||||
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${esc(title)}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;}
|
||||
body{font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);}
|
||||
.card{background:#fff;border-radius:20px;padding:40px;box-shadow:0 25px 50px -12px rgba(0,0,0,.25);max-width:420px;}
|
||||
h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
|
||||
.hint{color:#64748b;font-size:14px;margin-bottom:20px;}
|
||||
.wait-row{display:flex;align-items:center;justify-content:center;gap:24px;margin:20px 0;}
|
||||
.qr-wrap img{width:200px;height:200px;border-radius:12px;border:2px solid #e2e8f0;background:#fff;}
|
||||
.countdown-wrap{width:72px;height:72px;flex-shrink:0;position:relative;}
|
||||
.countdown-ring{transform:rotate(-90deg);}
|
||||
.countdown-ring circle{fill:none;stroke-width:5;transition:stroke-dashoffset .5s linear;}
|
||||
.countdown-ring .bg{stroke:#e2e8f0;}
|
||||
.countdown-ring .progress{stroke:#22c55e;stroke-linecap:round;}
|
||||
.countdown-inner{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;}
|
||||
.countdown-num{font-size:1rem;font-weight:700;color:#1e293b;line-height:1;}
|
||||
.countdown-unit{font-size:9px;color:#94a3b8;}
|
||||
.tip{font-size:13px;color:#64748b;margin-top:16px;}
|
||||
.success-wrap{display:none;}
|
||||
.success-wrap.show{display:block;}
|
||||
.wait-wrap.hide{display:none;}
|
||||
.success-icon{font-size:4rem;margin-bottom:16px;animation:scaleIn .4s ease;}
|
||||
.success-title{font-size:1.25rem;color:#16a34a;font-weight:600;margin-bottom:8px;}
|
||||
.success-desc{color:#64748b;font-size:14px;margin-bottom:24px;}
|
||||
.success-countdown{font-size:1.5rem;font-weight:700;color:#667eea;}
|
||||
@keyframes scaleIn{from{transform:scale(0);opacity:0;}to{transform:scale(1);opacity:1;}}
|
||||
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.7;}}
|
||||
.pulse{animation:pulse 2s ease-in-out infinite;}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="wait-wrap" id="waitWrap">
|
||||
<h2>${esc(title)}</h2>
|
||||
<p class="hint">${esc(hint)}</p>
|
||||
<div class="wait-row">
|
||||
<div class="countdown-wrap">
|
||||
<svg class="countdown-ring" width="72" height="72" viewBox="0 0 72 72">
|
||||
<circle class="bg" cx="36" cy="36" r="32"/>
|
||||
<circle class="progress" id="progressCircle" cx="36" cy="36" r="32" stroke-dasharray="201" stroke-dashoffset="0"/>
|
||||
</svg>
|
||||
<div class="countdown-inner">
|
||||
<span class="countdown-num" id="countdownNum">5:00</span>
|
||||
<span class="countdown-unit">分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qr-wrap">
|
||||
<img src="${imgSrc}" alt="支付二维码" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="tip pulse" id="tip">等待支付中...</p>
|
||||
</div>
|
||||
<div class="success-wrap" id="successWrap">
|
||||
<div class="success-icon">✅</div>
|
||||
<div class="success-title">支付成功</div>
|
||||
<div class="success-desc">${esc(successDesc)}</div>
|
||||
<div class="success-countdown" id="successCountdown">3 秒后跳转</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
try{localStorage.setItem("join_pay_order",${JSON.stringify(`${orderId}|${Date.now()}`)});}catch(e){}
|
||||
var orderId=${JSON.stringify(orderId)};
|
||||
var returnUrl=${JSON.stringify(returnUrl)};
|
||||
if(!orderId){return;}
|
||||
var waitWrap=document.getElementById("waitWrap");
|
||||
var successWrap=document.getElementById("successWrap");
|
||||
var tip=document.getElementById("tip");
|
||||
var countdownNum=document.getElementById("countdownNum");
|
||||
var progressCircle=document.getElementById("progressCircle");
|
||||
var successCountdown=document.getElementById("successCountdown");
|
||||
var maxT=300,left=maxT;
|
||||
var circumference=2*Math.PI*32;
|
||||
function fmt(t){var m=Math.floor(t/60),s=t%60;return m+":"+(s<10?"0":"")+s;}
|
||||
var countdownTimer=setInterval(function(){
|
||||
left--;
|
||||
countdownNum.textContent=left>60?fmt(left):left;
|
||||
progressCircle.style.strokeDashoffset=circumference*(1-left/maxT);
|
||||
if(left<=0){clearInterval(countdownTimer);tip.textContent="支付超时,返回首页";setTimeout(function(){window.location.href="/";},800);}
|
||||
},1000);
|
||||
function showSuccess(){
|
||||
clearInterval(countdownTimer);
|
||||
waitWrap.classList.add("hide");
|
||||
successWrap.classList.add("show");
|
||||
var s=3;
|
||||
var t=setInterval(function(){
|
||||
s--;
|
||||
successCountdown.textContent=s>0?s+" 秒后跳转":"跳转中…";
|
||||
if(s<=0){clearInterval(t);window.location.href=returnUrl;}
|
||||
},1000);
|
||||
}
|
||||
var confirmUrl=${JSON.stringify(confirmUrl)};
|
||||
function doConfirm(attempt){
|
||||
if(attempt>=3){showSuccess();return;}
|
||||
fetch(confirmUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({order_id:orderId})})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(cd){
|
||||
if(cd&&cd.ok){showSuccess();}
|
||||
else{setTimeout(function(){doConfirm(attempt+1);},800);}
|
||||
})
|
||||
.catch(function(){setTimeout(function(){doConfirm(attempt+1);},800);});
|
||||
}
|
||||
function check(){
|
||||
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId),{cache:"no-store"})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d.ok&&d.paid){doConfirm(0);return;}
|
||||
setTimeout(check,500);
|
||||
})
|
||||
.catch(function(){setTimeout(check,500);});
|
||||
}
|
||||
setTimeout(check,500);
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function createPayOrder(
|
||||
apiUrl: string,
|
||||
userId: string,
|
||||
returnUrl: string,
|
||||
totalFee: number,
|
||||
type: string,
|
||||
channel: string,
|
||||
provider?: "xorpay",
|
||||
clientIp?: string
|
||||
) {
|
||||
const payload: Record<string, unknown> = {
|
||||
user_id: userId,
|
||||
site_id: SITE_ID,
|
||||
total_fee: totalFee,
|
||||
type,
|
||||
channel,
|
||||
return_url,
|
||||
return_url: returnUrl,
|
||||
...(provider ? { provider } : {}),
|
||||
...(clientIp ? { client_ip: clientIp } : {}),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const res = await fetch(`${config.apiUrl}/cnomadcna/payh5`, {
|
||||
const res = await fetch(`${apiUrl}/cnomadcna/payh5`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -33,15 +229,42 @@ async function createPayOrder(
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.status !== "ok") {
|
||||
console.error("[pay] createPayOrder failed status=%s body=%j", res.status, data);
|
||||
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
|
||||
}
|
||||
|
||||
const params = data.params || data.xorpay_params || {};
|
||||
const payUrl = data.pay_url || data.xorpay_url || config.zpaySubmitUrl;
|
||||
if (!params || typeof params !== "object") {
|
||||
throw new Error("payjsapi 未返回支付参数");
|
||||
return {
|
||||
params: (data.params ?? data.xorpay_params ?? {}) as Record<string, string>,
|
||||
payUrl:
|
||||
data.pay_url || data.xorpay_url || getPaymentConfig().zpaySubmitUrl,
|
||||
provider: String(data.provider || (provider ? "xorpay" : "zpay")).trim(),
|
||||
orderId: String(
|
||||
data.order_id ||
|
||||
data.params?.out_trade_no ||
|
||||
data.xorpay_params?.order_id ||
|
||||
""
|
||||
).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestRedirect(
|
||||
apiUrl: string,
|
||||
path: string,
|
||||
payload: Record<string, unknown>
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
return await fetch(`${apiUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
return { params: JSON.parse(JSON.stringify(params)), payUrl };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -49,50 +272,199 @@ export async function POST(request: NextRequest) {
|
||||
let body: Record<string, string | number>;
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
body = await request.json();
|
||||
body = (await request.json()) as Record<string, string | number>;
|
||||
} else {
|
||||
const form = await request.formData();
|
||||
body = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
}
|
||||
|
||||
const user_id = String(body?.user_id || "").trim();
|
||||
const return_url = String(body?.return_url || "").trim() || `${SITE_URL}/zh/join/paid`;
|
||||
const total_fee = Number(body?.total_fee) || 100;
|
||||
const type = String(body?.type || "meetup");
|
||||
const channel = String(body?.channel || "alipay");
|
||||
const joinConfig = getJoinPaymentConfig();
|
||||
const payConfig = getPaymentConfig();
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
const apiUrl = resolvePayApiUrl(request);
|
||||
const userId = String(body?.user_id || "").trim();
|
||||
const totalFee = Number(body?.total_fee) || joinConfig.amount;
|
||||
const type = String(body?.type || joinConfig.type);
|
||||
const channel = String(body?.channel || "wxpay");
|
||||
const device = String(body?.device || "pc");
|
||||
const forcedProvider = device === "wechat" ? "xorpay" : undefined;
|
||||
|
||||
if (!user_id) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 });
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "缺少 user_id" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { params, payUrl } = await createPayOrder(user_id, return_url, total_fee, type, channel);
|
||||
const origin =
|
||||
request.headers.get("x-forwarded-host")
|
||||
? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
|
||||
: request.headers.get("origin") || SITE_URL;
|
||||
const returnUrl =
|
||||
String(body?.return_url || "").trim() || `${origin}/zh/join/paid`;
|
||||
const clientIp =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"";
|
||||
const wantHtml =
|
||||
String(body?.html) === "1" ||
|
||||
(request.headers.get("accept") || "").includes("text/html");
|
||||
|
||||
const wantHtml = String(body?.html) === "1" || (request.headers.get("accept") || "").includes("text/html");
|
||||
if (wantHtml) {
|
||||
const escapeAttr = (s: string) =>
|
||||
String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const inputs = Object.entries(params as Record<string, unknown>)
|
||||
.filter(([, v]) => v != null && String(v).trim() !== "")
|
||||
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(String(v))}" />`)
|
||||
.join("\n");
|
||||
const orderId = String((params as Record<string, unknown>)?.out_trade_no || "").trim();
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{margin:0;background:#f0f4f8;opacity:0;}</style></head><body><form id="f" action="${escapeAttr(payUrl)}" method="POST">${inputs}</form><script>document.getElementById("f").submit();</script></body></html>`;
|
||||
const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
||||
if (orderId) {
|
||||
res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
|
||||
console.log(
|
||||
"[pay] cnomadcna apiUrl=%s host=%s provider=%s device=%s",
|
||||
apiUrl,
|
||||
hostHeader,
|
||||
forcedProvider || "default",
|
||||
device
|
||||
);
|
||||
|
||||
const created = await createPayOrder(
|
||||
apiUrl,
|
||||
userId,
|
||||
returnUrl,
|
||||
totalFee,
|
||||
type,
|
||||
channel,
|
||||
forcedProvider,
|
||||
clientIp
|
||||
);
|
||||
|
||||
if (!wantHtml) {
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
params: created.params,
|
||||
payUrl: created.payUrl,
|
||||
provider: created.provider,
|
||||
order_id: created.orderId,
|
||||
});
|
||||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (created.provider === "xorpay") {
|
||||
const html = buildPayHtml(created.payUrl, created.params, {
|
||||
directToCashier: true,
|
||||
orderId: created.orderId,
|
||||
});
|
||||
const response = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||||
return response;
|
||||
}
|
||||
|
||||
const redirectPayload: Record<string, unknown> = {
|
||||
user_id: userId,
|
||||
site_id: SITE_ID,
|
||||
total_fee: totalFee,
|
||||
type,
|
||||
channel,
|
||||
return_url: returnUrl,
|
||||
device,
|
||||
...(clientIp ? { client_ip: clientIp } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const redirectRes = await requestRedirect(
|
||||
apiUrl,
|
||||
"/cnomadcna/payh5/redirect",
|
||||
redirectPayload
|
||||
);
|
||||
|
||||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||
const location = redirectRes.headers.get("location");
|
||||
if (location) {
|
||||
const response = NextResponse.redirect(location);
|
||||
const orderId =
|
||||
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
||||
created.orderId;
|
||||
if (orderId) setPayOrderCookie(response, orderId);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
|
||||
const resData = await redirectRes.json().catch(() => ({}));
|
||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||
const safeReturnUrl = String(resData.return_url || returnUrl).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||||
c
|
||||
] || c)
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||||
c
|
||||
] || c)
|
||||
);
|
||||
const rawOrderId = String(resData.order_id || created.orderId).trim();
|
||||
const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId, {
|
||||
channel: resData.channel || "wxpay",
|
||||
successDesc: "支付成功,申请已提交",
|
||||
confirmUrl: "/api/meetup/complete-order",
|
||||
});
|
||||
const response = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (rawOrderId) setPayOrderCookie(response, rawOrderId);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 200 && resData?.use_form) {
|
||||
const html = buildPayHtml(payConfig.zpaySubmitUrl, created.params, {
|
||||
directToCashier: true,
|
||||
orderId: String(resData.order_id || created.orderId).trim(),
|
||||
});
|
||||
const response = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
const orderId = String(resData.order_id || created.orderId).trim();
|
||||
if (orderId) setPayOrderCookie(response, orderId);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (redirectRes.status >= 400) {
|
||||
console.warn(
|
||||
"[pay] redirect fallback status=%s body=%j",
|
||||
redirectRes.status,
|
||||
resData
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[pay] redirect fallback error=%s",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, pay_url: payUrl, params, provider: "zpay" });
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const html = buildPayHtml(created.payUrl, created.params, {
|
||||
orderId: created.orderId,
|
||||
});
|
||||
const response = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const msg = err.message || "";
|
||||
const hint =
|
||||
msg.includes("fetch") || msg.includes("ECONNREFUSED")
|
||||
? "请确认 payjsapi 已启动:cd C:\\project\\payjsapi && python run.py"
|
||||
: err.name === "AbortError"
|
||||
? "请求超时,请检查 payjsapi 是否正常运行"
|
||||
: msg;
|
||||
console.error("Pay API error:", err);
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `支付请求失败: ${hint}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,88 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPaymentConfig } from "@/config/services.config";
|
||||
import {
|
||||
getApiUrlFromRequest,
|
||||
getPaymentConfig,
|
||||
} from "@/config/services.config";
|
||||
import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus";
|
||||
import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus";
|
||||
|
||||
function resolvePayApiUrl(request: NextRequest): string {
|
||||
const config = getPaymentConfig();
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
return (
|
||||
getApiUrlFromRequest(hostHeader) ||
|
||||
process.env.PAYMENT_API_URL ||
|
||||
config.apiUrl
|
||||
).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const orderId = request.nextUrl.searchParams.get("order_id");
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
|
||||
}
|
||||
const config = getPaymentConfig();
|
||||
if (!config.apiUrl) {
|
||||
return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "missing order_id" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
const apiUrl = resolvePayApiUrl(request);
|
||||
const url = `${apiUrl}/cnomadcna/order_status?order_id=${encodeURIComponent(orderId)}`;
|
||||
console.log("[pay/status] request order_id=%s host=%s url=%s", orderId, hostHeader, url);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 25000);
|
||||
const res = await fetch(
|
||||
`${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
|
||||
{ cache: "no-store", signal: controller.signal }
|
||||
).finally(() => clearTimeout(timeout));
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json({ ok: true, paid: !!data?.paid });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: true, paid: false });
|
||||
const paid = !!data?.paid;
|
||||
console.log(
|
||||
"[pay/status] result order_id=%s paid=%s status=%s",
|
||||
orderId,
|
||||
paid,
|
||||
res.status
|
||||
);
|
||||
if (paid) {
|
||||
return NextResponse.json({ ok: true, paid: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"[pay/status] error order_id=%s error=%s",
|
||||
orderId,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
|
||||
const xorpay = await queryXorPayOrderStatus(orderId, 5000);
|
||||
console.log(
|
||||
"[pay/status] xorpay fallback order_id=%s paid=%s status=%s error=%s url=%s",
|
||||
orderId,
|
||||
!!xorpay?.paid,
|
||||
xorpay?.status || "",
|
||||
xorpay?.error || "",
|
||||
xorpay?.url || ""
|
||||
);
|
||||
if (xorpay?.paid) {
|
||||
return NextResponse.json({ ok: true, paid: true });
|
||||
}
|
||||
|
||||
const zpay = await queryZPayOrderStatus(orderId, 5000);
|
||||
console.log(
|
||||
"[pay/status] zpay fallback order_id=%s paid=%s status=%s error=%s url=%s",
|
||||
orderId,
|
||||
!!zpay?.paid,
|
||||
zpay?.status || "",
|
||||
zpay?.error || "",
|
||||
zpay?.url || ""
|
||||
);
|
||||
if (zpay?.paid) {
|
||||
return NextResponse.json({ ok: true, paid: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, paid: false });
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import { Link, useLocale } from "@/i18n/navigation";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import JoinModal from "./JoinModal";
|
||||
import {
|
||||
getRecommendedChannel,
|
||||
mustUseWxPay,
|
||||
getPayEnv,
|
||||
redirectToPay,
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/lib/payment";
|
||||
@@ -149,13 +148,8 @@ export default function HeroSection() {
|
||||
}
|
||||
const returnUrl =
|
||||
typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid";
|
||||
const env: PayEnv =
|
||||
typeof window !== "undefined"
|
||||
? navigator.userAgent.includes("MicroMessenger")
|
||||
? "wechat"
|
||||
: "pc"
|
||||
: "pc";
|
||||
const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
|
||||
const env = getPayEnv();
|
||||
const channel: PayChannel = "wxpay";
|
||||
redirectToPay({
|
||||
user_id,
|
||||
return_url: returnUrl,
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useState, useEffect, type FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useLocale } from "@/i18n/navigation";
|
||||
import {
|
||||
getRecommendedChannel,
|
||||
mustUseWxPay,
|
||||
getPayEnv,
|
||||
redirectToPay,
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/lib/payment";
|
||||
@@ -71,8 +70,8 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
|
||||
return;
|
||||
}
|
||||
const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid";
|
||||
const env: PayEnv = typeof window !== "undefined" ? (navigator.userAgent.includes("MicroMessenger") ? "wechat" : "pc") : "pc";
|
||||
const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
|
||||
const env = getPayEnv();
|
||||
const channel: PayChannel = "wxpay";
|
||||
redirectToPay({
|
||||
user_id: data.user_id,
|
||||
return_url: returnUrl,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* 微信 H5 支付完成后可能不跳转,通过 cookie 中的订单号轮询检测支付状态
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const COOKIE_NAME = "join_pay_order";
|
||||
const POLL_INTERVAL = 2000;
|
||||
const MAX_POLLS = 150;
|
||||
const STORAGE_NAME = "join_pay_order";
|
||||
const POLL_INTERVAL_MS = 1200;
|
||||
const MAX_POLLS = 60;
|
||||
const MAX_AGE_MS = 15 * 60 * 1000;
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
@@ -19,7 +17,33 @@ function clearCookie(name: string): void {
|
||||
document.cookie = `${name}=; path=/; max-age=0`;
|
||||
}
|
||||
|
||||
function parsePayOrderCookie(raw: string | null): [string | null, boolean] {
|
||||
function getStorage(name: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem(name);
|
||||
}
|
||||
|
||||
function removeStorage(name: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(name);
|
||||
}
|
||||
|
||||
function clearPendingOrder(): void {
|
||||
clearCookie(COOKIE_NAME);
|
||||
removeStorage(STORAGE_NAME);
|
||||
}
|
||||
|
||||
function persistPendingOrder(raw: string): void {
|
||||
if (typeof document !== "undefined") {
|
||||
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(raw)}; path=/; max-age=900`;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_NAME, raw);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function parsePendingOrder(raw: string | null): [string | null, boolean] {
|
||||
if (!raw?.trim()) return [null, false];
|
||||
const parts = raw.split("|");
|
||||
const orderId = parts[0]?.trim() || raw.trim();
|
||||
@@ -36,37 +60,96 @@ export function usePayStatusPoll(onPaid: () => void): boolean {
|
||||
onPaidRef.current = onPaid;
|
||||
|
||||
useEffect(() => {
|
||||
const raw = getCookie(COOKIE_NAME);
|
||||
const [orderId, isValid] = parsePayOrderCookie(raw);
|
||||
if (!orderId || !isValid) {
|
||||
if (raw) clearCookie(COOKIE_NAME);
|
||||
const cookieRaw = getCookie(COOKIE_NAME);
|
||||
const [cookieOrderId, cookieValid] = parsePendingOrder(cookieRaw);
|
||||
const storageRaw = getStorage(STORAGE_NAME);
|
||||
const [storageOrderId, storageValid] = parsePendingOrder(storageRaw);
|
||||
const orderId = cookieValid ? cookieOrderId : storageValid ? storageOrderId : null;
|
||||
const activeRaw = cookieValid ? cookieRaw : storageValid ? storageRaw : null;
|
||||
|
||||
if (!orderId) {
|
||||
if (cookieRaw || storageRaw) {
|
||||
clearPendingOrder();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRaw) {
|
||||
persistPendingOrder(activeRaw);
|
||||
}
|
||||
|
||||
setPolling(true);
|
||||
let count = 0;
|
||||
const timer = setInterval(async () => {
|
||||
count++;
|
||||
if (count > MAX_POLLS) {
|
||||
clearInterval(timer);
|
||||
clearCookie(COOKIE_NAME);
|
||||
let attempts = 0;
|
||||
let stopped = false;
|
||||
let timer: number | undefined;
|
||||
let inFlight = false;
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
if (timer) window.clearTimeout(timer);
|
||||
};
|
||||
|
||||
const scheduleNext = () => {
|
||||
if (stopped) return;
|
||||
timer = window.setTimeout(tick, POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (stopped) return;
|
||||
if (inFlight) {
|
||||
scheduleNext();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if (attempts > MAX_POLLS) {
|
||||
stop();
|
||||
clearPendingOrder();
|
||||
setPolling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight = true;
|
||||
try {
|
||||
const res = await fetch(`/api/pay/status?order_id=${encodeURIComponent(orderId)}`);
|
||||
const data = await res.json();
|
||||
const res = await fetch(
|
||||
`/api/pay/status?order_id=${encodeURIComponent(orderId)}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.ok && data?.paid) {
|
||||
clearInterval(timer);
|
||||
stop();
|
||||
clearPendingOrder();
|
||||
setPolling(false);
|
||||
onPaidRef.current();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 继续轮询
|
||||
// keep polling
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
scheduleNext();
|
||||
};
|
||||
|
||||
const handleResume = () => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
||||
return;
|
||||
}
|
||||
void tick();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleResume);
|
||||
window.addEventListener("pageshow", handleResume);
|
||||
window.addEventListener("focus", handleResume);
|
||||
void tick();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleResume);
|
||||
window.removeEventListener("pageshow", handleResume);
|
||||
window.removeEventListener("focus", handleResume);
|
||||
stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return polling;
|
||||
|
||||
49
app/lib/payment/xorpayStatus.ts
Normal file
49
app/lib/payment/xorpayStatus.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createHash } from "crypto";
|
||||
|
||||
const XORPAY_AID = process.env.XORPAY_AID || "8220";
|
||||
const XORPAY_SECRET =
|
||||
process.env.XORPAY_SECRET || "afcacd99570945f88de62624aaa3578e";
|
||||
const XORPAY_QUERY_URL =
|
||||
process.env.XORPAY_QUERY_URL || "https://xorpay.com/api/query2";
|
||||
|
||||
export async function queryXorPayOrderStatus(
|
||||
orderId: string,
|
||||
timeoutMs = 5000
|
||||
): Promise<{ paid: boolean; status?: string; error?: string; url: string } | null> {
|
||||
const normalizedOrderId = orderId.trim();
|
||||
if (!normalizedOrderId || !XORPAY_AID || !XORPAY_SECRET) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sign = createHash("md5")
|
||||
.update(`${normalizedOrderId}${XORPAY_SECRET}`, "utf8")
|
||||
.digest("hex")
|
||||
.toLowerCase();
|
||||
|
||||
const url = new URL(
|
||||
`${XORPAY_QUERY_URL.replace(/\/$/, "")}/${encodeURIComponent(XORPAY_AID)}`
|
||||
);
|
||||
url.searchParams.set("order_id", normalizedOrderId);
|
||||
url.searchParams.set("sign", sign);
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const status = String(data?.status || "").trim().toLowerCase();
|
||||
return {
|
||||
paid: res.ok && (status === "payed" || status === "success"),
|
||||
status,
|
||||
url: url.toString(),
|
||||
error: res.ok ? undefined : `status=${res.status}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
paid: false,
|
||||
url: url.toString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
45
app/lib/payment/zpayStatus.ts
Normal file
45
app/lib/payment/zpayStatus.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const ZPAY_PID = process.env.ZPAY_PID || "2025121809351743";
|
||||
const ZPAY_KEY = process.env.ZPAY_KEY || "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89";
|
||||
const ZPAY_QUERY_URL =
|
||||
process.env.ZPAY_QUERY_URL || "https://zpayz.cn/api.php";
|
||||
|
||||
export async function queryZPayOrderStatus(
|
||||
orderId: string,
|
||||
timeoutMs = 5000
|
||||
): Promise<{ paid: boolean; status?: string; error?: string; url: string } | null> {
|
||||
const normalizedOrderId = orderId.trim();
|
||||
if (!normalizedOrderId || !ZPAY_PID || !ZPAY_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(ZPAY_QUERY_URL);
|
||||
url.searchParams.set("act", "order");
|
||||
url.searchParams.set("pid", ZPAY_PID);
|
||||
url.searchParams.set("key", ZPAY_KEY);
|
||||
url.searchParams.set("out_trade_no", normalizedOrderId);
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const code = String(data?.code || "").trim();
|
||||
const status = String(data?.status || "").trim();
|
||||
return {
|
||||
paid: res.ok && code === "1" && status === "1",
|
||||
status,
|
||||
url: url.toString(),
|
||||
error:
|
||||
res.ok && code === "1"
|
||||
? undefined
|
||||
: String(data?.msg || `status=${res.status}`),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
paid: false,
|
||||
url: url.toString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export interface MinioConfig {
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007";
|
||||
|
||||
/** 站点标识,用于 VIP 按站点隔离 */
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "meetup";
|
||||
@@ -46,6 +47,13 @@ export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "meetup";
|
||||
/** meetup 加入游牧中国 价格(分),测试用1元 */
|
||||
export const MEETUP_JOIN_AMOUNT = 100;
|
||||
|
||||
export function getApiUrlFromRequest(hostHeader: string | null): string | null {
|
||||
if (!hostHeader) return null;
|
||||
if (!isDev) return process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
|
||||
const hostname = hostHeader.split(":")[0];
|
||||
return `http://${hostname}:${PAYJSAPI_PORT}`;
|
||||
}
|
||||
|
||||
export function getPocketBaseConfig(): PocketBaseConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
|
||||
Reference in New Issue
Block a user