373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import {
|
||
getApiUrlFromRequest,
|
||
getJoinPaymentConfig,
|
||
getPaymentConfig,
|
||
SITE_ID,
|
||
} from "@/config/services.config";
|
||
import { SITE_URL } from "../../lib/env";
|
||
|
||
const ORDER_COOKIE = "join_pay_order";
|
||
|
||
function resolvePayApiUrl(request: NextRequest): string {
|
||
const config = getPaymentConfig();
|
||
const hostHeader =
|
||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||
return (
|
||
getApiUrlFromRequest(hostHeader) ||
|
||
process.env.PAYMENT_API_URL ||
|
||
config.apiUrl
|
||
).replace(/\/$/, "");
|
||
}
|
||
|
||
function setPayOrderCookie(response: NextResponse, orderId: string) {
|
||
response.cookies.set(ORDER_COOKIE, `${orderId}|${Date.now()}`, {
|
||
path: "/",
|
||
maxAge: 900,
|
||
});
|
||
}
|
||
|
||
function escapeAttr(s: string): string {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/"/g, """)
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
function buildPayHtml(
|
||
payUrl: string,
|
||
params: Record<string, unknown>,
|
||
options?: { directToCashier?: boolean; orderId?: string }
|
||
): string {
|
||
const inputs = Object.entries(params)
|
||
.filter(([, value]) => value != null && String(value).trim() !== "")
|
||
.map(
|
||
([key, value]) =>
|
||
`<input type="hidden" name="${escapeAttr(key)}" value="${escapeAttr(
|
||
String(value)
|
||
)}" />`
|
||
)
|
||
.join("\n");
|
||
|
||
const pendingOrderScript = options?.orderId
|
||
? `<script>(function(){var v=${JSON.stringify(
|
||
`${options.orderId}|${Date.now()}`
|
||
)};try{localStorage.setItem("join_pay_order",v);}catch(e){}document.cookie="join_pay_order="+encodeURIComponent(v)+"; path=/; max-age=900";})();</script>`
|
||
: "";
|
||
|
||
return options?.directToCashier
|
||
? `<!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">${inputs}</form>${pendingOrderScript}<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="f" action="${escapeAttr(
|
||
payUrl
|
||
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`;
|
||
}
|
||
|
||
function buildQrHtml(
|
||
imgSrc: string,
|
||
returnUrl: string,
|
||
orderId: string
|
||
): string {
|
||
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:#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;}</style></head>
|
||
<body>
|
||
<h2>请扫码支付</h2>
|
||
<p>支付成功后将自动跳转</p>
|
||
<img src="${imgSrc}" alt="支付二维码" />
|
||
<script>
|
||
(function(){
|
||
try {
|
||
localStorage.setItem("join_pay_order", ${JSON.stringify(
|
||
`${orderId}|${Date.now()}`
|
||
)});
|
||
} catch (e) {}
|
||
var orderId=${JSON.stringify(orderId)};
|
||
var returnUrl=${JSON.stringify(returnUrl)};
|
||
if(!orderId){return;}
|
||
function check(){
|
||
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId), { cache: "no-store" })
|
||
.then(function(r){return r.json();})
|
||
.then(function(d){
|
||
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
|
||
setTimeout(check,1200);
|
||
})
|
||
.catch(function(){setTimeout(check,1200);});
|
||
}
|
||
check();
|
||
})();
|
||
</script>
|
||
</body></html>`;
|
||
}
|
||
|
||
async function createPayOrder(
|
||
apiUrl: string,
|
||
userId: string,
|
||
returnUrl: string,
|
||
totalFee: number,
|
||
type: string,
|
||
channel: string,
|
||
provider?: "xorpay",
|
||
clientIp?: string
|
||
) {
|
||
const payload: Record<string, unknown> = {
|
||
user_id: userId,
|
||
site_id: SITE_ID,
|
||
total_fee: totalFee,
|
||
type,
|
||
channel,
|
||
return_url: returnUrl,
|
||
...(provider ? { provider } : {}),
|
||
...(clientIp ? { client_ip: clientIp } : {}),
|
||
};
|
||
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||
const res = await fetch(`${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") {
|
||
console.error("[pay] createPayOrder failed status=%s body=%j", res.status, data);
|
||
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
|
||
}
|
||
|
||
return {
|
||
params: (data.params ?? data.xorpay_params ?? {}) as Record<string, string>,
|
||
payUrl:
|
||
data.pay_url || data.xorpay_url || getPaymentConfig().zpaySubmitUrl,
|
||
provider: String(data.provider || (provider ? "xorpay" : "zpay")).trim(),
|
||
orderId: String(
|
||
data.order_id ||
|
||
data.params?.out_trade_no ||
|
||
data.xorpay_params?.order_id ||
|
||
""
|
||
).trim(),
|
||
};
|
||
}
|
||
|
||
async function requestRedirect(
|
||
apiUrl: string,
|
||
path: string,
|
||
payload: Record<string, unknown>
|
||
) {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||
try {
|
||
return await fetch(`${apiUrl}${path}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
redirect: "manual",
|
||
signal: controller.signal,
|
||
});
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
|
||
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 joinConfig = getJoinPaymentConfig();
|
||
const payConfig = getPaymentConfig();
|
||
const hostHeader =
|
||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||
const apiUrl = resolvePayApiUrl(request);
|
||
const userId = String(body?.user_id || "").trim();
|
||
const totalFee = Number(body?.total_fee) || joinConfig.amount;
|
||
const type = String(body?.type || joinConfig.type);
|
||
const channel = String(body?.channel || "wxpay");
|
||
const device = String(body?.device || "pc");
|
||
const forcedProvider = device === "wechat" ? "xorpay" : undefined;
|
||
|
||
if (!userId) {
|
||
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 returnUrl =
|
||
String(body?.return_url || "").trim() || `${origin}/zh/join/paid`;
|
||
const clientIp =
|
||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||
request.headers.get("x-real-ip") ||
|
||
"";
|
||
const wantHtml =
|
||
String(body?.html) === "1" ||
|
||
(request.headers.get("accept") || "").includes("text/html");
|
||
|
||
console.log(
|
||
"[pay] digital apiUrl=%s host=%s provider=%s device=%s",
|
||
apiUrl,
|
||
hostHeader,
|
||
forcedProvider || "default",
|
||
device
|
||
);
|
||
|
||
const created = await createPayOrder(
|
||
apiUrl,
|
||
userId,
|
||
returnUrl,
|
||
totalFee,
|
||
type,
|
||
channel,
|
||
forcedProvider,
|
||
clientIp
|
||
);
|
||
|
||
if (!wantHtml) {
|
||
const response = NextResponse.json({
|
||
ok: true,
|
||
params: created.params,
|
||
payUrl: created.payUrl,
|
||
provider: created.provider,
|
||
order_id: created.orderId,
|
||
});
|
||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||
return response;
|
||
}
|
||
|
||
if (created.provider === "xorpay") {
|
||
const html = buildPayHtml(created.payUrl, created.params, {
|
||
directToCashier: true,
|
||
orderId: created.orderId,
|
||
});
|
||
const response = new NextResponse(html, {
|
||
status: 200,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||
return response;
|
||
}
|
||
|
||
const redirectPayload: Record<string, unknown> = {
|
||
user_id: userId,
|
||
site_id: SITE_ID,
|
||
total_fee: totalFee,
|
||
type,
|
||
channel,
|
||
return_url: returnUrl,
|
||
device,
|
||
...(clientIp ? { client_ip: clientIp } : {}),
|
||
};
|
||
|
||
try {
|
||
const redirectRes = await requestRedirect(
|
||
apiUrl,
|
||
"/digital/payh5/redirect",
|
||
redirectPayload
|
||
);
|
||
|
||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||
const location = redirectRes.headers.get("location");
|
||
if (location) {
|
||
const response = NextResponse.redirect(location);
|
||
const orderId =
|
||
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
||
created.orderId;
|
||
if (orderId) setPayOrderCookie(response, orderId);
|
||
return response;
|
||
}
|
||
}
|
||
|
||
const resData = await redirectRes.json().catch(() => ({}));
|
||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||
const safeReturnUrl = String(resData.return_url || returnUrl).replace(
|
||
/[&<>"']/g,
|
||
(c) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||
c
|
||
] || c)
|
||
);
|
||
const imgSrc = String(resData.img).replace(
|
||
/[&<>"']/g,
|
||
(c) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||
c
|
||
] || c)
|
||
);
|
||
const rawOrderId = String(resData.order_id || created.orderId).trim();
|
||
const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId);
|
||
const response = new NextResponse(html, {
|
||
status: 200,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
if (rawOrderId) setPayOrderCookie(response, rawOrderId);
|
||
return response;
|
||
}
|
||
|
||
if (redirectRes.status === 200 && resData?.use_form) {
|
||
const html = buildPayHtml(payConfig.zpaySubmitUrl, created.params, {
|
||
directToCashier: true,
|
||
orderId: String(resData.order_id || created.orderId).trim(),
|
||
});
|
||
const response = new NextResponse(html, {
|
||
status: 200,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
const orderId = String(resData.order_id || created.orderId).trim();
|
||
if (orderId) setPayOrderCookie(response, orderId);
|
||
return response;
|
||
}
|
||
|
||
if (redirectRes.status >= 400) {
|
||
console.warn(
|
||
"[pay] redirect fallback status=%s body=%j",
|
||
redirectRes.status,
|
||
resData
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.warn(
|
||
"[pay] redirect fallback error=%s",
|
||
error instanceof Error ? error.message : String(error)
|
||
);
|
||
}
|
||
|
||
const html = buildPayHtml(created.payUrl, created.params, {
|
||
orderId: created.orderId,
|
||
});
|
||
const response = new NextResponse(html, {
|
||
status: 200,
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||
return response;
|
||
} catch (error) {
|
||
const err = error instanceof Error ? error : new Error(String(error));
|
||
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 }
|
||
);
|
||
}
|
||
}
|