Files
2026-03-10 04:41:20 -05:00

228 lines
8.9 KiB
TypeScript
Raw Permalink 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";
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, "&lt;")
.replace(/>/g, "&gt;");
}
function buildPayHtml(payUrl: string, params: Record<string, unknown>): 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");
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;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 });
}
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 envApiUrl = process.env.PAYMENT_API_URL || process.env.NEXT_PUBLIC_API_URL;
const apiUrl = (envApiUrl ? payConfig.apiUrl : getApiUrlFromRequest(hostHeader)).replace(/\/$/, "");
const user_id = String(body?.user_id || "").trim();
const return_url = String(body?.return_url || "").trim();
const total_fee = Number(body?.total_fee) || 18800;
const type = String(body?.type || "vip");
const channel = String(body?.channel || "alipay");
const device = String(body?.device || "pc");
if (!user_id) {
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 wantHtml = String(body?.html) === "1";
if (!wantHtml) {
return NextResponse.json({ ok: false, error: "请传 html=1" }, { status: 400 });
}
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"";
const redirectPayload = {
user_id,
total_fee,
type,
channel,
return_url: finalReturnUrl,
device,
...(clientIp && { client_ip: clientIp }),
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
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 res = NextResponse.redirect(location);
const orderId = redirectRes.headers.get("x-pay-order-id")?.trim();
if (orderId) setPayOrderCookie(res, orderId);
return res;
}
}
const resData = await redirectRes.json().catch(() => ({}));
if (redirectRes.status === 200 && resData?.use_form) {
// 微信内:直接用 payh5 获取 params构建收银台表单不调用 redirect 避免重复创建订单)
const createRes = await fetch(`${apiUrl}/nomadvip/payh5`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id, total_fee, type, channel }),
});
const createData = await createRes.json().catch(() => ({}));
const params = createData?.params || {};
const orderId = String(params?.out_trade_no || "").trim();
if (!params || Object.keys(params).length === 0) {
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
}
const html = buildPayHtml(payConfig.zpaySubmitUrl, params as Record<string, unknown>);
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) => ({ "&": "&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 || "alipay").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>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:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;}img{width:220px;height:220px;margin:20px 0;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}.tip{font-size:12px;color:#94a3b8;margin-top:8px;}</style></head>
<body>
<h2>${esc(title)}</h2>
<p>${esc(hint)}</p>
<img src="${imgSrc}" alt="支付二维码" />
<p class="tip" id="tip">支付成功后将自动跳转...</p>
<script>
(function(){
var orderId="${orderId}";
var returnUrl="${returnUrl}";
if(!orderId){return;}
var tip=document.getElementById("tip");
var t=0;
var maxT=300;
function check(){
t++;
if(t>maxT){tip.textContent="轮询超时";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;
}
if (redirectRes.status === 400 || redirectRes.status === 500) {
return NextResponse.json(
{ ok: false, error: resData?.detail || resData?.msg || "支付跳转失败" },
{ status: redirectRes.status }
);
}
return NextResponse.json({ ok: false, error: "支付创建失败" }, { 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);
return NextResponse.json({ ok: false, error: `支付请求失败: ${hint}` }, { status: 500 });
}
}