/** * 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`; 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)}

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 || "支付跳转失败"; 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 }); } }