436 lines
18 KiB
TypeScript
436 lines
18 KiB
TypeScript
/**
|
||
* nomadvip 支付 API - 参考 digital
|
||
* PC: 二维码 | H5: 302 跳转 | 微信内: 收银台表单
|
||
*/
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { getPaymentConfig } from "@/config/services.config";
|
||
|
||
const ZPAY_REQUIRED = ["pid", "type", "out_trade_no", "notify_url", "return_url", "name", "money", "sign", "sign_type"];
|
||
|
||
function escapeAttr(s: string): string {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/"/g, """)
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
function buildErrorHtml(errorMsg: string): string {
|
||
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>支付失败</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;}h2{color:#dc2626;margin-bottom:12px;}p{color:#64748b;margin-bottom:20px;text-align:center;padding:0 20px;}a{color:#2563eb;text-decoration:none;font-weight:500;}a:hover{text-decoration:underline;}</style></head><body><h2>支付发起失败</h2><p>${esc(errorMsg)}</p><p><a href="/">返回首页重试</a></p></body></html>`;
|
||
}
|
||
|
||
function buildPayHtml(
|
||
payUrl: string,
|
||
params: Record<string, unknown>,
|
||
options?: { directToCashier?: boolean }
|
||
): string {
|
||
const flat: Record<string, string> = {};
|
||
for (const [k, v] of Object.entries(params)) {
|
||
if (v == null) continue;
|
||
const s = String(v).trim();
|
||
if (s === "") continue;
|
||
flat[k] = s;
|
||
}
|
||
const inputs = Object.entries(flat)
|
||
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
|
||
.join("\n");
|
||
const direct = options?.directToCashier;
|
||
// direct:微信内无中转,直接提交 | 非 direct:PC/H5 有中转页提示
|
||
return direct
|
||
? `<!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" enctype="application/x-www-form-urlencoded">${inputs}</form><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="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">${inputs}</form><script>document.getElementById("zpayForm").submit();</script></body></html>`;
|
||
}
|
||
|
||
function setPayOrderCookie(res: NextResponse, orderId: string) {
|
||
res.cookies.set("nomadvip_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
|
||
}
|
||
|
||
async function createPayOrder(
|
||
apiUrl: string,
|
||
user_id: string,
|
||
return_url: string,
|
||
total_fee: number,
|
||
type: string,
|
||
channel: string,
|
||
clientIp?: string,
|
||
provider?: string
|
||
) {
|
||
const payload: Record<string, unknown> = {
|
||
user_id,
|
||
site_id: "vip",
|
||
total_fee,
|
||
type,
|
||
channel,
|
||
return_url,
|
||
...(clientIp && { client_ip: clientIp }),
|
||
...(provider && { provider }),
|
||
};
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||
const res = await fetch(`${apiUrl}/nomadvip/payh5`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
signal: controller.signal,
|
||
}).finally(() => clearTimeout(timeout));
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok || data?.status !== "ok") {
|
||
throw new Error(data?.detail || data?.msg || "支付创建失败");
|
||
}
|
||
// xorpay: xorpay_params + xorpay_url | zpay: params + pay_url
|
||
const params = (data.xorpay_params ?? data.params) || {};
|
||
const payUrl = data.xorpay_url || data.pay_url || "";
|
||
if (!params || typeof params !== "object") {
|
||
throw new Error("payjsapi 未返回支付参数");
|
||
}
|
||
return {
|
||
params: params as Record<string, string>,
|
||
payUrl,
|
||
provider: data.provider || "zpay",
|
||
};
|
||
}
|
||
|
||
export async function POST(request: NextRequest) {
|
||
try {
|
||
let body: Record<string, string | number>;
|
||
const contentType = request.headers.get("content-type") || "";
|
||
if (contentType.includes("application/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 payConfig = getPaymentConfig();
|
||
const apiUrl = payConfig.apiUrl.replace(/\/$/, "");
|
||
let user_id = String(body?.user_id || "").trim();
|
||
if (user_id.startsWith("{") && user_id.includes("data")) {
|
||
try {
|
||
const parsed = JSON.parse(user_id) as { data?: string };
|
||
if (parsed?.data) user_id = String(parsed.data).trim();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
const return_url = String(body?.return_url || "").trim();
|
||
const total_fee = Number(body?.total_fee) || payConfig.defaultAmount || 100;
|
||
const type = String(body?.type || "vip");
|
||
const channel = String(body?.channel || "wxpay");
|
||
const device = String(body?.device || "pc");
|
||
const reqProvider = String(body?.provider || "").trim().toLowerCase() || undefined;
|
||
const accept = request.headers.get("accept") || "";
|
||
const wantHtml = String(body?.html) === "1" || accept.includes("text/html");
|
||
const preferJson = String(body?.prefer_json) === "1";
|
||
|
||
if (!user_id) {
|
||
if (wantHtml) {
|
||
return new NextResponse(buildErrorHtml("缺少 user_id"), {
|
||
status: 400,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
}
|
||
return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 });
|
||
}
|
||
|
||
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") || "http://localhost:3000";
|
||
const finalReturnUrl = return_url || `${origin}/paid`;
|
||
|
||
const clientIp =
|
||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||
request.headers.get("x-real-ip") ||
|
||
"";
|
||
|
||
const { params, payUrl, provider } = await createPayOrder(
|
||
apiUrl,
|
||
user_id,
|
||
finalReturnUrl,
|
||
total_fee,
|
||
type,
|
||
channel,
|
||
clientIp,
|
||
reqProvider
|
||
);
|
||
|
||
if (!params || Object.keys(params).length === 0) {
|
||
if (wantHtml) {
|
||
return new NextResponse(buildErrorHtml("支付参数为空"), {
|
||
status: 500,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
}
|
||
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
|
||
}
|
||
|
||
// wantHtml=0:返回 JSON,供微信 fetch 后直接提交表单(无中转页)
|
||
if (!wantHtml) {
|
||
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
|
||
const res = NextResponse.json({
|
||
ok: true,
|
||
params,
|
||
payUrl: provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl,
|
||
provider,
|
||
order_id: orderId,
|
||
});
|
||
if (orderId) setPayOrderCookie(res, orderId);
|
||
return res;
|
||
}
|
||
|
||
// xorpay:直接 payh5 返回收银台参数,无需 redirect 中转
|
||
const submitUrl = provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl;
|
||
if (provider === "xorpay") {
|
||
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
|
||
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
|
||
directToCashier: device === "wechat" || device === "h5",
|
||
});
|
||
const res = new NextResponse(html, {
|
||
status: 200,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
if (orderId) setPayOrderCookie(res, orderId);
|
||
return res;
|
||
}
|
||
|
||
// zpay:需要 redirect 获取 device 相关跳转
|
||
const redirectPayload = {
|
||
user_id,
|
||
site_id: "vip",
|
||
total_fee,
|
||
type,
|
||
channel,
|
||
return_url: finalReturnUrl,
|
||
device,
|
||
provider: "zpay",
|
||
...(clientIp && { client_ip: clientIp }),
|
||
};
|
||
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 45000);
|
||
const redirectRes = await fetch(`${apiUrl}/nomadvip/payh5/redirect`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(redirectPayload),
|
||
redirect: "manual",
|
||
signal: controller.signal,
|
||
}).finally(() => clearTimeout(timeout));
|
||
|
||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||
const location = redirectRes.headers.get("location");
|
||
if (location) {
|
||
const orderId =
|
||
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
||
String(params?.out_trade_no || "").trim();
|
||
if (preferJson && orderId) {
|
||
return NextResponse.json({
|
||
ok: true,
|
||
order_id: orderId,
|
||
redirect_url: location,
|
||
provider: "zpay",
|
||
});
|
||
}
|
||
const res = NextResponse.redirect(location);
|
||
if (orderId) setPayOrderCookie(res, orderId);
|
||
return res;
|
||
}
|
||
}
|
||
|
||
const resData = await redirectRes.json().catch(() => ({}));
|
||
|
||
if (redirectRes.status === 200 && resData?.use_form) {
|
||
const orderId = String(params?.out_trade_no || "").trim();
|
||
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
|
||
directToCashier: device === "wechat",
|
||
});
|
||
const res = new NextResponse(html, {
|
||
status: 200,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
if (orderId) setPayOrderCookie(res, orderId);
|
||
return res;
|
||
}
|
||
|
||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||
const returnUrl = String(resData.return_url || finalReturnUrl).replace(
|
||
/[&<>"']/g,
|
||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||
);
|
||
const imgSrc = String(resData.img).replace(
|
||
/[&<>"']/g,
|
||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||
);
|
||
const orderId = String(resData.order_id || "").replace(
|
||
/[&<>"']/g,
|
||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||
);
|
||
const ch = String(resData.channel || "alipay").toLowerCase();
|
||
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
|
||
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>
|
||
*{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">感谢您的支持,VIP 权益已开通</div>
|
||
<div class="success-countdown" id="successCountdown">10 秒后返回首页</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
(function(){
|
||
var orderId="${orderId}";
|
||
var returnUrl="${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;
|
||
var 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=10;
|
||
var t=setInterval(function(){
|
||
s--;
|
||
successCountdown.textContent=s+" 秒后返回首页";
|
||
if(s<=0){
|
||
clearInterval(t);
|
||
window.location.href=returnUrl;
|
||
}
|
||
},1000);
|
||
}
|
||
function check(){
|
||
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
|
||
.then(function(r){return r.json();})
|
||
.then(function(d){
|
||
if(d.ok&&d.paid){showSuccess();return;}
|
||
setTimeout(check,1000);
|
||
})
|
||
.catch(function(){setTimeout(check,1000);});
|
||
}
|
||
setTimeout(check,1000);
|
||
})();
|
||
</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;
|
||
}
|
||
|
||
if (redirectRes.status === 400 || redirectRes.status === 500) {
|
||
const errMsg = resData?.detail || resData?.msg || "支付跳转失败";
|
||
if (wantHtml) {
|
||
return new NextResponse(buildErrorHtml(errMsg), {
|
||
status: redirectRes.status,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
}
|
||
return NextResponse.json({ ok: false, error: errMsg }, { status: redirectRes.status });
|
||
}
|
||
|
||
const createFailMsg = "支付创建失败";
|
||
if (wantHtml) {
|
||
return new NextResponse(buildErrorHtml(createFailMsg), {
|
||
status: 500,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
}
|
||
return NextResponse.json({ ok: false, error: createFailMsg }, { status: 500 });
|
||
} catch (e) {
|
||
const err = e instanceof Error ? e : new Error(String(e));
|
||
const msg = err.message || "";
|
||
const hint =
|
||
msg.includes("fetch") || msg.includes("ECONNREFUSED")
|
||
? "请确认 payjsapi 已启动"
|
||
: err.name === "AbortError"
|
||
? "请求超时"
|
||
: msg;
|
||
console.error("Pay API error:", err);
|
||
const errorMsg = `支付请求失败: ${hint}`;
|
||
const ct = request.headers.get("content-type") || "";
|
||
const isForm = ct.includes("form") || ct.includes("x-www-form-urlencoded");
|
||
if (isForm) {
|
||
return new NextResponse(buildErrorHtml(errorMsg), {
|
||
status: 500,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
}
|
||
return NextResponse.json({ ok: false, error: errorMsg }, { status: 500 });
|
||
}
|
||
}
|