Files
gitlab-instance-0a899031_di…/app/api/pay/route.ts
2026-03-12 03:54:22 -05:00

345 lines
13 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 { getPaymentConfig, getJoinPaymentConfig, SITE_ID } from "@/config/services.config";
import { SITE_URL } from "../../lib/env";
async function createPayOrder(
user_id: string,
return_url: string,
total_fee?: number,
type?: string,
channel = "alipay"
) {
const joinConfig = getJoinPaymentConfig();
const fee = total_fee ?? joinConfig.amount;
const orderType = type ?? joinConfig.type;
const config = getPaymentConfig();
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
const payload = {
user_id,
site_id: SITE_ID,
total_fee: Number(fee) || joinConfig.amount,
type: orderType,
channel,
return_url,
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${config.apiUrl}/digital/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 || config.zpaySubmitUrl;
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 joinConfig = getJoinPaymentConfig();
const payConfig = getPaymentConfig();
const user_id = String(body?.user_id || "").trim();
const return_url = String(body?.return_url || "").trim();
const total_fee = Number(body?.total_fee) || joinConfig.amount;
const type = String(body?.type || joinConfig.type);
const channel = String(body?.channel || "alipay");
const device = String(body?.device || "pc"); // pc | h5 | wechat微信内传 wechat
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;
// Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径
const finalReturnUrl = return_url || `${origin}/zh/join/paid`;
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,
site_id: SITE_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(`${payConfig.apiUrl}/digital/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);
// payh5/redirect 创建的是新订单,必须用其返回的 order_id非 createPayOrder 的 params
const orderId =
redirectRes.headers.get("x-pay-order-id")?.trim() ||
String(params?.out_trade_no || "").trim();
if (orderId) {
// 附带时间戳,前端只对 15 分钟内设置的 cookie 轮询,避免陈旧 cookie 误触发
const payload = `${orderId}|${Date.now()}`;
res.cookies.set("join_pay_order", payload, { path: "/", maxAge: 900 });
}
return res;
}
}
const resData = await redirectRes.json().catch(() => ({}));
// 微信内payurl2 会提示「请外微信外打开」,改用收银台表单由 Z-Pay 识别 UA
if (redirectRes.status === 200 && resData?.use_form) {
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" },
});
const orderId =
resData?.order_id?.trim() || String(params?.out_trade_no || "").trim();
if (orderId) {
res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, {
path: "/",
maxAge: 900,
});
}
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;}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(payConfig.zpaySubmitUrl, {
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) {
const res = NextResponse.redirect(location);
const outTradeNo = String(params?.out_trade_no || "").trim();
if (outTradeNo) {
const payload = `${outTradeNo}|${Date.now()}`;
res.cookies.set("join_pay_order", payload, { path: "/", maxAge: 900 });
}
return res;
}
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 }
);
}
}