/** * nomadvip 支付 API - 参考 digital * PC: 二维码 | H5: 302 跳转 | 微信内: 收银台表单 */ import { NextRequest, NextResponse } from "next/server"; import { getPaymentConfig, getApiUrlFromRequest } from "@/config/services.config"; 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, ">"); } function buildPayHtml(payUrl: string, params: Record): 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"); return ` 跳转支付...

正在跳转到支付页面...

${inputs}
`; } function setPayOrderCookie(res: NextResponse, orderId: string) { res.cookies.set("nomadvip_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 }); } 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"); const envApiUrl = process.env.PAYMENT_API_URL || process.env.NEXT_PUBLIC_API_URL; const apiUrl = (envApiUrl ? payConfig.apiUrl : getApiUrlFromRequest(hostHeader)).replace(/\/$/, ""); const user_id = String(body?.user_id || "").trim(); const return_url = String(body?.return_url || "").trim(); const total_fee = Number(body?.total_fee) || 18800; const type = String(body?.type || "vip"); const channel = String(body?.channel || "alipay"); const device = String(body?.device || "pc"); 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") || "http://localhost:3000"; const finalReturnUrl = return_url || `${origin}/paid`; const wantHtml = String(body?.html) === "1"; if (!wantHtml) { return NextResponse.json({ ok: false, error: "请传 html=1" }, { status: 400 }); } const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || ""; const redirectPayload = { user_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(`${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 res = NextResponse.redirect(location); const orderId = redirectRes.headers.get("x-pay-order-id")?.trim(); if (orderId) setPayOrderCookie(res, orderId); return res; } } const resData = await redirectRes.json().catch(() => ({})); if (redirectRes.status === 200 && resData?.use_form) { // 微信内:直接用 payh5 获取 params,构建收银台表单(不调用 redirect 避免重复创建订单) const createRes = await fetch(`${apiUrl}/nomadvip/payh5`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id, total_fee, type, channel }), }); const createData = await createRes.json().catch(() => ({})); const params = createData?.params || {}; const orderId = String(params?.out_trade_no || "").trim(); if (!params || Object.keys(params).length === 0) { return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 }); } const html = buildPayHtml(payConfig.zpaySubmitUrl, params as Record); const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" }, }); if (orderId) setPayOrderCookie(res, orderId); 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 = ` ${esc(title)}

${esc(title)}

${esc(hint)}

支付二维码

支付成功后将自动跳转...

`; const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" }, }); if (orderId) setPayOrderCookie(res, orderId); return res; } if (redirectRes.status === 400 || redirectRes.status === 500) { return NextResponse.json( { ok: false, error: resData?.detail || resData?.msg || "支付跳转失败" }, { status: redirectRes.status } ); } 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 hint = msg.includes("fetch") || msg.includes("ECONNREFUSED") ? "请确认 payjsapi 已启动" : err.name === "AbortError" ? "请求超时" : msg; console.error("Pay API error:", err); return NextResponse.json({ ok: false, error: `支付请求失败: ${hint}` }, { status: 500 }); } }