/** * 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 ? `支付
${inputs}
` : `跳转支付

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

${inputs}
`; } 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`; 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, 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"); 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(); 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"); // 渠道规则: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, 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; 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", }); 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)}

5:00
支付二维码

等待支付中...

支付成功
感谢您的支持,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 || "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, 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, { 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; } 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 }); } }