diff --git a/.env.example b/.env.example index d56eaf4..6000a87 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ PAYMENT_API_URL=http://127.0.0.1:8007 NEXT_PUBLIC_API_URL=http://127.0.0.1:8007 # 生产同机部署时取消注释(Next.js 与 payjsapi 同服务器) # PAYMENT_API_INTERNAL_URL=http://127.0.0.1:8007 +# 内网/慢网络可适当提高超时(毫秒):check_vip 默认 15s,payh5/redirect 默认 60s +# PAYJSAPI_CHECK_TIMEOUT=15000 +# PAYJSAPI_PAYH5_TIMEOUT=60000 +# PAYJSAPI_REDIRECT_TIMEOUT=60000 PAYMENT_PROVIDER=zpay PAYMENT_DEFAULT_AMOUNT=100 NEXT_PUBLIC_SITE_ID=vip diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index 34bc339..d2cc548 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -90,9 +90,10 @@ async function createPayOrder( ...(provider && { provider }), }; const payh5Url = `${apiUrl}/nomadvip/payh5`; - console.log("[pay] createPayOrder 请求 payjsapi url=%s", payh5Url); + 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(), 10000); + const timeout = setTimeout(() => controller.abort(), payh5Timeout); const res = await fetch(payh5Url, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -100,14 +101,18 @@ async function createPayOrder( 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 || "支付创建失败"); + 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") { + if (!params || typeof params !== "object" || Object.keys(params).length === 0) { + console.error("[pay] createPayOrder 参数为空 data.keys=", Object.keys(data || {})); throw new Error("payjsapi 未返回支付参数"); } return { @@ -149,7 +154,9 @@ export async function POST(request: NextRequest) { 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; + // 渠道规则: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"; @@ -175,139 +182,94 @@ export async function POST(request: NextRequest) { request.headers.get("x-real-ip") || ""; - const { params, payUrl, provider } = await createPayOrder( - apiUrl, - user_id, - finalReturnUrl, - total_fee, - type, - channel, - clientIp, - reqProvider - ); + // 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 (!params || Object.keys(params).length === 0) { - if (wantHtml) { - return new NextResponse(buildErrorHtml("支付参数为空"), { - status: 500, + 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" }, }); - } - 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 = ` + 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)}