Files
gitlab-instance-0a899031_no…/app/api/pay/route.ts
2026-03-13 10:59:08 -05:00

487 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* nomadvip 支付 API - 参考 digital
* PC: 二维码 | H5: 302 跳转 | 微信内: 收银台表单
*/
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig, getApiUrlFromRequest } from "@/config/services.config";
import { getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
const ZPAY_REQUIRED = ["pid", "type", "out_trade_no", "notify_url", "return_url", "name", "money", "sign", "sign_type"];
const ANONYMOUS_USER_COOKIE_NAME = "nomadvip_anon_uid";
const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
function escapeAttr(s: string): string {
return String(s)
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function buildErrorHtml(errorMsg: string): string {
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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微信内无中转直接提交 | 非 directPC/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 });
}
function setAnonymousUserCookie(
res: NextResponse,
request: NextRequest,
userId: string
) {
if (!/^user\d+$/.test(userId)) {
return;
}
const options = {
...(getCookieOptions(false, getHostFromRequest(request)) as Record<
string,
string | number | boolean
>),
httpOnly: false,
maxAge: ANONYMOUS_USER_COOKIE_MAX_AGE,
};
res.cookies.set(ANONYMOUS_USER_COOKIE_NAME, userId, options);
}
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 payh5Url = `${apiUrl}/nomadvip/payh5`;
console.log("[pay] createPayOrder 请求 payjsapi url=%s", payh5Url);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(payh5Url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({}));
console.log("[pay] createPayOrder 响应 status=%s ok=%s", res.status, data?.status);
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 hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
const apiUrl = (
(hostHeader ? getApiUrlFromRequest(hostHeader) : null) ||
payConfig.apiUrl
).replace(/\/$/, "");
console.log("[pay] 立即加入 apiUrl=%s host=%s", apiUrl, hostHeader);
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);
setAnonymousUserCookie(res, request, user_id);
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);
setAnonymousUserCookie(res, request, user_id);
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(), 20000);
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?.order_id || 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);
setAnonymousUserCookie(res, request, user_id);
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);
setAnonymousUserCookie(res, request, user_id);
return res;
}
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const returnUrl = String(resData.return_url || finalReturnUrl).replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const imgSrc = String(resData.img).replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const orderId = String(resData.order_id || "").replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const ch = String(resData.channel || "wxpay").toLowerCase();
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
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>
*{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}";
var userId="${userId}";
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=2;
var t=setInterval(function(){
s--;
successCountdown.textContent=s>0?s+" 秒后返回首页":"返回首页中…";
if(s<=0){
clearInterval(t);
try{localStorage.setItem("userId",userId);if(/^user\\d+$/.test(userId))localStorage.setItem("memosAccount",userId);localStorage.setItem("paidType","vip");if(!localStorage.getItem("emailLinked")&&!localStorage.getItem("userEmail"))localStorage.setItem("userName",userId);}catch(e){}
window.location.href="/";
}
},1000);
}
function doConfirm(attempt){
if(!userId||attempt>=3){showSuccess();return;}
fetch("/api/pay/confirm",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({order_id:orderId,user_id:userId})})
.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))
.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>`;
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
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 payjsapiHint = "请确认 payjsapi 已启动 (npm run dev 或 python run.py端口 8007)";
const hint =
msg.includes("fetch") || msg.includes("ECONNREFUSED")
? payjsapiHint
: err.name === "AbortError"
? `请求超时,${payjsapiHint}`
: 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 });
}
}