Files
gitlab-instance-0a899031_di…/app/api/pay/route.ts
2026-03-08 03:40:53 -05:00

302 lines
11 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.
import { NextRequest, NextResponse } from "next/server";
import { PAYMENT_API_URL, SITE_URL } from "../../lib/env";
const ZPAY_SUBMIT = "https://zpayz.cn/submit.php";
async function createPayOrder(
user_id: string,
return_url: string,
total_fee = 80,
type = "meetup",
channel = "alipay"
) {
if (!PAYMENT_API_URL) throw new Error("未配置 PAYMENT_API_URL");
const payload = {
user_id,
total_fee: Number(total_fee) || 80,
type,
channel,
return_url,
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${PAYMENT_API_URL}/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 || data?.message || "支付创建失败");
}
let params = data.params || data.xorpay_params || {};
const payUrl = data.pay_url || data.xorpay_url || ZPAY_SUBMIT;
if (!params || typeof params !== "object") {
throw new Error("payjsapi 未返回支付参数");
}
params = JSON.parse(JSON.stringify(params));
if (data.provider === "xorpay") {
throw new Error("当前 payjsapi 使用 xorpay请设置 PAYMENT_PROVIDER=zpay 后重启 payjsapi");
}
return { params, payUrl };
}
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;
}
for (const key of ZPAY_REQUIRED) {
if (!flat[key]) {
console.error(`ZPAY 缺少必填参数: ${key}`, flat);
}
}
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>`;
}
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();
} 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();
const total_fee = Number(body?.total_fee) || 80;
const type = String(body?.type || "meetup");
const channel = String(body?.channel || "alipay");
const device = String(body?.device || "pc"); // pc | h5前端根据 UA 传入
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") || SITE_URL;
const finalReturnUrl = return_url || `${origin}/join?paid=1`;
const { params, payUrl } = await createPayOrder(
user_id,
finalReturnUrl,
total_fee,
type,
channel
);
if (!params || Object.keys(params).length === 0) {
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
}
const accept = request.headers.get("accept") || "";
const wantHtml = String(body?.html) === "1" || accept.includes("text/html");
if (wantHtml) {
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"";
// 使用 payjsapi /payh5/redirectmapi.php API接口支付按 device 返回 payurl/payurl2
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(`${PAYMENT_API_URL}/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) return NextResponse.redirect(location);
}
const resData = await redirectRes.json().catch(() => ({}));
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;}a{color:#0ea5e9;margin-top:16px;}.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>`;
return new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
const errData = resData;
if (redirectRes.status === 400 || redirectRes.status === 500) {
return NextResponse.json(
{ ok: false, error: errData?.detail || errData?.msg || "支付跳转失败" },
{ status: redirectRes.status }
);
}
// 降级payjsapi 未提供 redirect 或非 zpay使用原有表单提交逻辑
const formParams = new URLSearchParams();
const missing: string[] = [];
for (const key of ZPAY_REQUIRED) {
const v = params[key];
if (v == null || String(v).trim() === "") {
missing.push(key);
} else {
formParams.append(key, String(v));
}
}
for (const [k, v] of Object.entries(params)) {
if (!ZPAY_REQUIRED.includes(k) && v != null && String(v).trim() !== "") {
formParams.append(k, String(v));
}
}
if (missing.length > 0) {
return NextResponse.json(
{
ok: false,
error: `payjsapi 返回参数缺少: ${missing.join(", ")}。请确认 payjsapi 已设置 PAYMENT_PROVIDER=zpay`,
},
{ status: 500 }
);
}
const zpayRes = await fetch(ZPAY_SUBMIT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formParams.toString(),
redirect: "manual",
});
const location = zpayRes.headers.get("location");
if ((zpayRes.status === 301 || zpayRes.status === 302) && location) {
return NextResponse.redirect(location);
}
const text = await zpayRes.text();
try {
const errJson = JSON.parse(text);
if (errJson?.code === "error") {
return NextResponse.json(
{ ok: false, error: errJson.msg || "ZPAY 返回错误" },
{ status: 500 }
);
}
} catch {
/* ignore */
}
const html = buildPayHtml(payUrl, params as Record<string, unknown>);
return new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({
ok: true,
pay_url: payUrl,
params,
provider: "zpay",
submit_method: "POST",
});
} 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 已启动cd C:\\project\\payjsapi && python run.py"
: err.name === "AbortError"
? "请求超时,请检查 payjsapi 是否正常运行"
: msg;
console.error("Pay API error:", err);
return NextResponse.json(
{ ok: false, error: `支付请求失败: ${hint}` },
{ status: 500 }
);
}
}