Files
gitlab-instance-0a899031_salon/app/api/pay/route.ts
2026-03-16 11:45:38 -05:00

484 lines
17 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 {
getApiUrlFromRequest,
getJoinPaymentConfig,
getPaymentConfig,
SITE_ID,
} from "@/config/services.config";
import { SITE_URL } from "@/app/lib/env";
/** ZPAY 创建订单较慢(约 610s需延长超时避免手机浏览器发起支付超时 */
export const maxDuration = 30;
const ORDER_COOKIE = "join_pay_order";
const SALON_PAY_PREFIX = "/salon";
function resolvePayApiUrl(request: NextRequest): string {
const config = getPaymentConfig();
const hostHeader =
request.headers.get("host") || request.headers.get("x-forwarded-host");
return (getApiUrlFromRequest(hostHeader) || 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, "&lt;")
.replace(/>/g, "&gt;");
}
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,
opts?: { channel?: string; confirmUrl?: string }
): string {
const ch = (opts?.channel || "wxpay").toLowerCase();
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
const confirmUrl = opts?.confirmUrl || "/api/pay/complete-order";
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<style>
*{box-sizing:border-box;}
body{font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);}
.card{background:#fff;border-radius:20px;padding:40px;box-shadow:0 25px 50px -12px rgba(0,0,0,.25);max-width:420px;}
h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
.hint{color:#64748b;font-size:14px;margin-bottom:20px;}
.wait-row{display:flex;align-items:center;justify-content:center;gap:24px;margin:20px 0;}
.qr-wrap img{width:200px;height:200px;border-radius:12px;border:2px solid #e2e8f0;background:#fff;}
.countdown-wrap{width:72px;height:72px;flex-shrink:0;position:relative;}
.countdown-ring{transform:rotate(-90deg);}
.countdown-ring circle{fill:none;stroke-width:5;transition:stroke-dashoffset .5s linear;}
.countdown-ring .bg{stroke:#e2e8f0;}
.countdown-ring .progress{stroke:#22c55e;stroke-linecap:round;}
.countdown-inner{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;}
.countdown-num{font-size:1rem;font-weight:700;color:#1e293b;line-height:1;}
.countdown-unit{font-size:9px;color:#94a3b8;}
.tip{font-size:13px;color:#64748b;margin-top:16px;}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.7;}}
.pulse{animation:pulse 2s ease-in-out infinite;}
</style></head>
<body>
<div class="card">
<div class="wait-wrap" id="waitWrap">
<h2>${esc(title)}</h2>
<p class="hint">${esc(hint)}</p>
<div class="wait-row">
<div class="countdown-wrap">
<svg class="countdown-ring" width="72" height="72" viewBox="0 0 72 72">
<circle class="bg" cx="36" cy="36" r="32"/>
<circle class="progress" id="progressCircle" cx="36" cy="36" r="32" stroke-dasharray="201" stroke-dashoffset="0"/>
</svg>
<div class="countdown-inner">
<span class="countdown-num" id="countdownNum">5:00</span>
<span class="countdown-unit">分</span>
</div>
</div>
<div class="qr-wrap">
<img src="${imgSrc}" alt="支付二维码" />
</div>
</div>
<p class="tip pulse" id="tip">等待支付中...</p>
</div>
</div>
<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;}
var waitWrap=document.getElementById("waitWrap");
var tip=document.getElementById("tip");
var countdownNum=document.getElementById("countdownNum");
var progressCircle=document.getElementById("progressCircle");
var maxT=300,left=maxT;
var circumference=2*Math.PI*32;
function fmt(t){var m=Math.floor(t/60),s=t%60;return m+":"+(s<10?"0":"")+s;}
var countdownTimer=setInterval(function(){
left--;
countdownNum.textContent=left>60?fmt(left):left;
progressCircle.style.strokeDashoffset=circumference*(1-left/maxT);
if(left<=0){clearInterval(countdownTimer);tip.textContent="支付超时,返回首页";setTimeout(function(){window.location.href="/";},800);}
},1000);
function showSuccess(){
clearInterval(countdownTimer);
window.location.href=returnUrl;
}
var confirmUrl=${JSON.stringify(confirmUrl)};
var confirmTimeoutMs=8000;
function doConfirm(attempt){
if(attempt>=3){showSuccess();return;}
if(tip){tip.textContent="检测到支付,正在确认…";}
var ctrl=new AbortController();
var tid=setTimeout(function(){ctrl.abort();},confirmTimeoutMs);
fetch(confirmUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({order_id:orderId}),signal:ctrl.signal})
.then(function(r){return r.json();})
.then(function(cd){
clearTimeout(tid);
if(cd&&cd.ok){showSuccess();}
else{setTimeout(function(){doConfirm(attempt+1);},800);}
})
.catch(function(){clearTimeout(tid);if(tip){tip.textContent="确认失败,重试中…";}setTimeout(function(){doConfirm(attempt+1);},800);});
}
function check(){
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId),{cache:"no-store",credentials:"same-origin"})
.then(function(r){return r.json();})
.then(function(d){
if(d&&d.paid){doConfirm(0);return;}
setTimeout(check,800);
})
.catch(function(){setTimeout(check,1500);});
}
setTimeout(check,500);
})();
</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}${SALON_PAY_PREFIX}/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] salon 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(), 25000);
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}/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] salon apiUrl=%s host=%s provider=%s device=%s",
apiUrl,
hostHeader,
forcedProvider || "default",
device
);
// 微信内xorpay 收银台,需先 createPayOrder
// PC/手机浏览器zpay参考 nomadvip 直接走 redirect跳过 payh5 避免重复建单
const isWechat = device === "wechat";
let created: Awaited<ReturnType<typeof createPayOrder>> | null = null;
if (isWechat) {
created = await createPayOrder(
apiUrl,
userId,
returnUrl,
totalFee,
type,
channel,
"xorpay",
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;
}
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;
}
// PC/手机zpay 直接 redirect手机 h5 得 302 跳转支付页PC 得二维码页)
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,
`${SALON_PAY_PREFIX}/payh5/redirect`,
redirectPayload
);
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
const location = redirectRes.headers.get("location");
if (location) {
const orderId = redirectRes.headers.get("x-pay-order-id")?.trim();
if (!wantHtml) {
const res = NextResponse.json({
ok: true,
order_id: orderId,
redirect_url: location,
provider: "zpay",
});
if (orderId) setPayOrderCookie(res, orderId);
return res;
}
const response = NextResponse.redirect(location);
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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
c
] || c)
);
const imgSrc = String(resData.img).replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
c
] || c)
);
const rawOrderId = String(resData.order_id || "").trim();
const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId, {
channel: resData.channel || "wxpay",
confirmUrl: "/api/pay/complete-order",
});
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) {
created = await createPayOrder(
apiUrl,
userId,
returnUrl,
totalFee,
type,
channel,
undefined,
clientIp
);
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)
);
}
// 兜底redirect 失败时回退到 payh5
created = await createPayOrder(
apiUrl,
userId,
returnUrl,
totalFee,
type,
channel,
undefined,
clientIp
);
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 payjsapi && python run.py"
: err.name === "AbortError"
? "请求超时,请检查 payjsapi 是否正常运行"
: msg;
console.error("Pay API error:", err);
return NextResponse.json(
{ ok: false, error: `支付请求失败: ${hint}` },
{ status: 500 }
);
}
}