Files
gitlab-instance-0a899031_no…/app/api/pay/route.ts
2026-04-01 02:23:17 -05:00

526 lines
24 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.
/**
* nomadvip 支付 API - 参考 digital
* PC: 二维码 | H5: 302 跳转 | 微信内: 收银台表单
*/
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig, getApiUrlFromRequest } from "@/config/services.config";
import { getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
const ZPAY_REQUIRED = ["pid", "type", "out_trade_no", "notify_url", "return_url", "name", "money", "sign", "sign_type"];
const ANONYMOUS_USER_COOKIE_NAME = "nomadvip_anon_uid";
const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
function escapeAttr(s: string): string {
return String(s)
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function buildErrorHtml(errorMsg: string): string {
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>支付失败</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:#dc2626;margin-bottom:12px;}p{color:#64748b;margin-bottom:20px;text-align:center;padding:0 20px;}a{color:#2563eb;text-decoration:none;font-weight:500;}a:hover{text-decoration:underline;}</style></head><body><h2>支付发起失败</h2><p>${esc(errorMsg)}</p><p><a href="/">返回首页重试</a></p></body></html>`;
}
function buildPayHtml(
payUrl: string,
params: Record<string, unknown>,
options?: { directToCashier?: boolean; displayName?: string; avatar?: string }
): 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;
}
const inputs = Object.entries(flat)
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
.join("\n");
const direct = options?.directToCashier;
const displayName = (options?.displayName || "当前用户").trim();
const avatar = (options?.avatar || "").trim();
const showPayerHint = !!avatar;
// direct微信内无中转直接提交 | 非 directPC/H5 有中转页提示
return direct
? `<!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" enctype="application/x-www-form-urlencoded">${inputs}</form><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;}.wrap{background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:14px 16px;box-shadow:0 8px 26px rgba(15,23,42,.08);}.row{display:flex;align-items:center;gap:10px;}.av{width:30px;height:30px;border-radius:9999px;object-fit:cover;background:#e2e8f0;display:flex;align-items:center;justify-content:center;font-size:13px;}.t1{font-size:13px;color:#0f172a;font-weight:600;}.t2{font-size:12px;color:#64748b;}</style></head><body>${showPayerHint ? `<div class="wrap"><div class="row"><img class="av" src="${escapeAttr(avatar)}" alt="${escapeAttr(displayName)}" /><div><div class="t1">开通对象:${escapeAttr(displayName)}</div><div class="t2">正在为你升级 VIP马上进入支付...</div></div></div></div>` : ""}<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">${inputs}</form><script>document.getElementById("zpayForm").submit();</script></body></html>`;
}
function setPayOrderCookie(res: NextResponse, orderId: string) {
res.cookies.set("nomadvip_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
}
function setAnonymousUserCookie(
res: NextResponse,
request: NextRequest,
userId: string
) {
if (!/^user\d+$/.test(userId)) {
return;
}
const options = {
...(getCookieOptions(false, getHostFromRequest(request)) as Record<
string,
string | number | boolean
>),
httpOnly: false,
maxAge: ANONYMOUS_USER_COOKIE_MAX_AGE,
};
res.cookies.set(ANONYMOUS_USER_COOKIE_NAME, userId, options);
}
async function createPayOrder(
apiUrl: string,
user_id: string,
wechat_userid: string | undefined,
return_url: string,
total_fee: number,
type: string,
channel: string,
clientIp?: string,
provider?: string
) {
const payload: Record<string, unknown> = {
user_id,
...(wechat_userid ? { wechat_userid } : {}),
site_id: "vip",
total_fee,
type,
channel,
return_url,
...(clientIp && { client_ip: clientIp }),
...(provider && { provider }),
};
const payh5Url = `${apiUrl}/nomadvip/payh5`;
const payh5Timeout = Number(process.env.PAYJSAPI_PAYH5_TIMEOUT) || 60000;
console.log("[pay] createPayOrder 请求 payjsapi url=%s timeout=%ds", payh5Url, payh5Timeout / 1000);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), payh5Timeout);
const res = await fetch(payh5Url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({}));
const statusOk = data?.status === "ok" || data?.status === "success" || (res.ok && (data?.xorpay_params || data?.params));
console.log("[pay] createPayOrder 响应 http=%s status=%s hasParams=%s", res.status, data?.status, !!(data?.xorpay_params || data?.params));
if (!res.ok || !statusOk) {
const errMsg = data?.detail || data?.msg || data?.error || "支付创建失败";
console.error("[pay] createPayOrder 失败:", errMsg, "body=", JSON.stringify(data).slice(0, 300));
throw new Error(errMsg);
}
// xorpay: xorpay_params + xorpay_url | zpay: params + pay_url
const params = (data.xorpay_params ?? data.params) || {};
const payUrl = data.xorpay_url || data.pay_url || "";
if (!params || typeof params !== "object" || Object.keys(params).length === 0) {
console.error("[pay] createPayOrder 参数为空 data.keys=", Object.keys(data || {}));
throw new Error("payjsapi 未返回支付参数");
}
return {
params: params as Record<string, string>,
payUrl,
provider: data.provider || "zpay",
};
}
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 payConfig = getPaymentConfig();
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
const apiUrl = (
(hostHeader ? getApiUrlFromRequest(hostHeader) : null) ||
payConfig.apiUrl
).replace(/\/$/, "");
console.log("[pay] 立即加入 apiUrl=%s host=%s", apiUrl, hostHeader);
let user_id = String(body?.user_id || "").trim();
const wechat_userid = String(body?.wechat_userid || body?.userid || "").trim();
if (user_id.startsWith("{") && user_id.includes("data")) {
try {
const parsed = JSON.parse(user_id) as { data?: string };
if (parsed?.data) user_id = String(parsed.data).trim();
} catch {
/* ignore */
}
}
const return_url = String(body?.return_url || "").trim();
const total_fee = Number(body?.total_fee) || payConfig.defaultAmount || 100;
const type = String(body?.type || "vip");
const channel = String(body?.channel || "wxpay");
const device = String(body?.device || "pc");
const wechatNick = String(body?.wechat_nick || body?.nick || body?.user_name || "").trim();
const wechatAvatar = String(body?.wechat_avatar || body?.avatar || "").trim();
// 渠道规则pc/手机浏览器 -> zpay仅微信内 -> xorpay
const effectiveProvider =
device === "wechat" ? "xorpay" : "zpay";
const accept = request.headers.get("accept") || "";
const wantHtml = String(body?.html) === "1" || accept.includes("text/html");
const preferJson = String(body?.prefer_json) === "1";
if (!user_id) {
if (wantHtml) {
return new NextResponse(buildErrorHtml("缺少 user_id"), {
status: 400,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
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") || "http://localhost:3000";
const finalReturnUrl = return_url || `${origin}/paid`;
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"";
// zpay直接走 redirect不先调 payh5避免 payh5 创建 zpay 订单时阻塞超时)
// payjsapi 的 payh5/redirect 可独立调用内部自建订单。digital/cnomadcna 仍用 payh5+redirect 流程,路由隔离互不影响。
if (effectiveProvider === "zpay") {
const redirectPayload = {
user_id,
...(wechat_userid ? { wechat_userid } : {}),
site_id: "vip",
total_fee,
type,
channel,
return_url: finalReturnUrl,
device,
provider: "zpay",
...(clientIp && { client_ip: clientIp }),
};
const redirectUrl = `${apiUrl}/nomadvip/payh5/redirect`;
const redirectTimeout = Number(process.env.PAYJSAPI_REDIRECT_TIMEOUT) || 60000;
console.log("[pay] zpay 直接 redirect url=%s timeout=%ds", redirectUrl, redirectTimeout / 1000);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), redirectTimeout);
const redirectRes = await fetch(redirectUrl, {
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 orderId = redirectRes.headers.get("x-pay-order-id")?.trim() || "";
if (!wantHtml || (preferJson && orderId)) {
const res = NextResponse.json({
ok: true,
order_id: orderId,
redirect_url: location,
provider: "zpay",
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
const res = NextResponse.redirect(location);
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
}
const resData = await redirectRes.json().catch(() => ({}));
if (redirectRes.status === 200 && resData?.use_form) {
const params = (resData.params ?? resData) as Record<string, string>;
const orderId = String(params?.out_trade_no ?? resData?.order_id ?? "").trim();
const submitUrl = resData?.pay_url || payConfig.zpaySubmitUrl;
const html = buildPayHtml(submitUrl, params || {}, {
directToCashier: device === "wechat",
displayName: wechatNick || user_id,
avatar: wechatAvatar,
});
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const userNick = String(wechatNick || userId).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const userAvatar = String(wechatAvatar || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
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 || "wxpay").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>
*{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;}
.payer{display:flex;align-items:center;justify-content:center;gap:10px;margin:0 0 14px;}
.payer-avatar{width:30px;height:30px;border-radius:9999px;border:1px solid #e2e8f0;object-fit:cover;background:#f1f5f9;display:flex;align-items:center;justify-content:center;font-size:12px;}
.payer-text{font-size:12px;color:#475569;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.success-wrap{display:none;}
.success-wrap.show{display:block;}
.wait-wrap.hide{display:none;}
.success-icon{font-size:4rem;margin-bottom:16px;animation:scaleIn .4s ease;}
.success-title{font-size:1.25rem;color:#16a34a;font-weight:600;margin-bottom:8px;}
.success-desc{color:#64748b;font-size:14px;margin-bottom:24px;}
.success-countdown{font-size:1.5rem;font-weight:700;color:#667eea;}
@keyframes scaleIn{from{transform:scale(0);opacity:0;}to{transform:scale(1);opacity:1;}}
@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>
${userAvatar ? `<div class="payer"><img class="payer-avatar" src="${userAvatar}" alt="${userNick}" /><span class="payer-text">开通对象:${userNick} · 正在升级 VIP</span></div>` : ""}
<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 class="success-wrap" id="successWrap">
<div class="success-icon">✅</div>
<div class="success-title">支付成功</div>
<div class="success-desc">感谢您的支持VIP 权益已开通</div>
<div class="success-countdown" id="successCountdown">10 秒后返回首页</div>
</div>
</div>
<script>
(function(){
var orderId="${orderId}";
var returnUrl="${returnUrl}";
var userId="${userId}";
if(!orderId){return;}
var waitWrap=document.getElementById("waitWrap");
var successWrap=document.getElementById("successWrap");
var tip=document.getElementById("tip");
var countdownNum=document.getElementById("countdownNum");
var progressCircle=document.getElementById("progressCircle");
var successCountdown=document.getElementById("successCountdown");
var maxT=300;
var 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);
waitWrap.classList.add("hide");
successWrap.classList.add("show");
var s=2;
var t=setInterval(function(){
s--;
successCountdown.textContent=s>0?s+" 秒后返回首页":"返回首页中…";
if(s<=0){
clearInterval(t);
try{localStorage.setItem("userId",userId);if(/^user\\d+$/.test(userId))localStorage.setItem("memosAccount",userId);localStorage.setItem("paidType","vip");if(!localStorage.getItem("emailLinked")&&!localStorage.getItem("userEmail"))localStorage.setItem("userName",userId);}catch(e){}
window.location.href="/";
}
},1000);
}
function doConfirm(attempt){
if(!userId||attempt>=3){showSuccess();return;}
fetch("/api/pay/confirm",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({order_id:orderId,user_id:userId})})
.then(function(r){return r.json();})
.then(function(cd){
if(cd&&cd.ok){showSuccess();}
else{setTimeout(function(){doConfirm(attempt+1);},800);}
})
.catch(function(){setTimeout(function(){doConfirm(attempt+1);},800);});
}
function check(){
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
.then(function(r){return r.json();})
.then(function(d){
if(d.ok&&d.paid){
doConfirm(0);return;
}
setTimeout(check,500);
})
.catch(function(){setTimeout(check,500);});
}
setTimeout(check,500);
})();
</script>
</body></html>`;
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
if (redirectRes.status === 400 || redirectRes.status === 500) {
const errMsg = resData?.detail || resData?.msg || "zpay redirect 失败";
if (wantHtml) {
return new NextResponse(buildErrorHtml(errMsg), {
status: redirectRes.status,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: errMsg }, { status: redirectRes.status });
}
const errMsg = resData?.detail || resData?.msg || "zpay 未返回有效响应";
if (wantHtml) {
return new NextResponse(buildErrorHtml(errMsg), {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: errMsg }, { status: 500 });
}
// xorpay调用 payh5 获取收银台参数
const { params, payUrl, provider } = await createPayOrder(
apiUrl,
user_id,
wechat_userid || undefined,
finalReturnUrl,
total_fee,
type,
channel,
clientIp,
effectiveProvider
);
if (!params || Object.keys(params).length === 0) {
if (wantHtml) {
return new NextResponse(buildErrorHtml("支付参数为空"), {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
}
// wantHtml=0返回 JSON供微信 fetch 后直接提交表单(无中转页)
if (!wantHtml) {
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
const res = NextResponse.json({
ok: true,
params,
payUrl: provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl,
provider,
order_id: orderId,
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
// xorpay直接 payh5 返回收银台参数,无需 redirect 中转
const submitUrl = provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl;
if (provider === "xorpay") {
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
directToCashier: device === "wechat" || device === "h5",
displayName: wechatNick || user_id,
avatar: wechatAvatar,
});
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
return NextResponse.json({ ok: false, error: "支付渠道异常" }, { status: 500 });
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
const msg = err.message || "";
const payjsapiHint =
"请确认 payjsapi 已启动且 nomadvip 能访问。同机部署可设 PAYMENT_API_INTERNAL_URL=http://127.0.0.1:8007";
const hint =
msg.includes("fetch") || msg.includes("ECONNREFUSED")
? payjsapiHint
: err.name === "AbortError"
? `请求超时(30s)${payjsapiHint}`
: msg;
console.error("Pay API error:", err);
const errorMsg = `支付请求失败: ${hint}`;
const ct = request.headers.get("content-type") || "";
const isForm = ct.includes("form") || ct.includes("x-www-form-urlencoded");
if (isForm) {
return new NextResponse(buildErrorHtml(errorMsg), {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: errorMsg }, { status: 500 });
}
}