/**
* 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, ">");
}
function buildErrorHtml(errorMsg: string): string {
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
return `
支付失败支付发起失败
${esc(errorMsg)}
返回首页重试
`;
}
function buildPayHtml(
payUrl: string,
params: Record,
options?: { directToCashier?: boolean }
): string {
const flat: Record = {};
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]) => ``)
.join("\n");
const direct = options?.directToCashier;
// direct:微信内无中转,直接提交 | 非 direct:PC/H5 有中转页提示
return direct
? `支付`
: `跳转支付正在跳转到支付页面...
`;
}
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,
return_url: string,
total_fee: number,
type: string,
channel: string,
clientIp?: string,
provider?: string
) {
const payload: Record = {
user_id,
site_id: "vip",
total_fee,
type,
channel,
return_url,
...(clientIp && { client_ip: clientIp }),
...(provider && { provider }),
};
const payh5Url = `${apiUrl}/nomadvip/payh5`;
console.log("[pay] createPayOrder 请求 payjsapi url=%s", payh5Url);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
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(() => ({}));
console.log("[pay] createPayOrder 响应 status=%s ok=%s", res.status, data?.status);
if (!res.ok || data?.status !== "ok") {
throw new Error(data?.detail || data?.msg || "支付创建失败");
}
// 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") {
throw new Error("payjsapi 未返回支付参数");
}
return {
params: params as Record,
payUrl,
provider: data.provider || "zpay",
};
}
export async function POST(request: NextRequest) {
try {
let body: Record;
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
body = (await request.json()) as Record;
} else {
const form = await request.formData();
body = Object.fromEntries(form.entries()) as Record;
}
const payConfig = getPaymentConfig();
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
// 优先用请求 Host(等同 window.location.host)推断同机 payjsapi,访问 192.168.41.222:3002 即用 192.168.41.222:8007
const apiUrl = (
(hostHeader ? getApiUrlFromRequest(hostHeader) : null) ||
process.env.PAYMENT_API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
payConfig.apiUrl
)
.replace(/\/$/, "");
console.log("[pay] 立即加入 apiUrl=%s host=%s", apiUrl, hostHeader);
let user_id = String(body?.user_id || "").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 reqProvider = String(body?.provider || "").trim().toLowerCase() || undefined;
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") ||
"";
const { params, payUrl, provider } = await createPayOrder(
apiUrl,
user_id,
finalReturnUrl,
total_fee,
type,
channel,
clientIp,
reqProvider
);
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, {
directToCashier: device === "wechat" || device === "h5",
});
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;
}
// zpay:需要 redirect 获取 device 相关跳转
const redirectPayload = {
user_id,
site_id: "vip",
total_fee,
type,
channel,
return_url: finalReturnUrl,
device,
provider: "zpay",
...(clientIp && { client_ip: clientIp }),
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
const redirectRes = await fetch(`${apiUrl}/nomadvip/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 orderId =
redirectRes.headers.get("x-pay-order-id")?.trim() ||
String(params?.order_id || params?.out_trade_no || "").trim();
if (preferJson && orderId) {
return NextResponse.json({
ok: true,
order_id: orderId,
redirect_url: location,
provider: "zpay",
});
}
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 orderId = String(params?.out_trade_no || "").trim();
const html = buildPayHtml(submitUrl, params as Record, {
directToCashier: device === "wechat",
});
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
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 || "wxpay").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 = `
${esc(title)}
${esc(title)}
${esc(hint)}
等待支付中...
✅
支付成功
感谢您的支持,VIP 权益已开通
10 秒后返回首页
`;
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 || "支付跳转失败";
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 createFailMsg = "支付创建失败";
if (wantHtml) {
return new NextResponse(buildErrorHtml(createFailMsg), {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: createFailMsg }, { status: 500 });
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
const msg = err.message || "";
const payjsapiHint = "请确认 payjsapi 已启动 (npm run dev 或 python run.py,端口 8007)";
const hint =
msg.includes("fetch") || msg.includes("ECONNREFUSED")
? payjsapiHint
: err.name === "AbortError"
? `请求超时,${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 });
}
}