345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
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, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
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/redirect:mapi.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) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>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 }
|
||
);
|
||
}
|
||
}
|