From 8a095f7d45c310af4007c70c725a9016b3c7e70c Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 13 Mar 2026 05:14:28 -0500 Subject: [PATCH] 'c1 --- app/[locale]/join/page.tsx | 11 +- app/[locale]/join/paid/page.tsx | 19 +- app/api/meetup/_paymentApi.ts | 19 +- app/api/meetup/check-user/route.ts | 26 +- app/api/meetup/complete-order/route.ts | 58 ++- app/api/pay/route.ts | 480 ++++++++++++++++++++++--- app/api/pay/status/route.ts | 91 ++++- app/components/HeroSection.tsx | 12 +- app/components/JoinModal.tsx | 7 +- app/lib/payment/usePayStatusPoll.ts | 127 +++++-- app/lib/payment/xorpayStatus.ts | 49 +++ app/lib/payment/zpayStatus.ts | 45 +++ config/services.config.ts | 8 + 13 files changed, 820 insertions(+), 132 deletions(-) create mode 100644 app/lib/payment/xorpayStatus.ts create mode 100644 app/lib/payment/zpayStatus.ts diff --git a/app/[locale]/join/page.tsx b/app/[locale]/join/page.tsx index 01334e2..5373586 100644 --- a/app/[locale]/join/page.tsx +++ b/app/[locale]/join/page.tsx @@ -4,13 +4,11 @@ import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "r import { Link } from "@/i18n/navigation"; import { getPayEnv, - getRecommendedChannel, - mustUseWxPay, redirectToPay, getDeviceFromEnv, } from "@/app/lib/payment"; import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll"; -import type { PayChannel, PayEnv } from "@/app/lib/payment"; +import type { PayEnv } from "@/app/lib/payment"; import AuthModal from "@/app/components/AuthModal"; const genderOptions = ["男", "女"]; @@ -57,7 +55,6 @@ export default function JoinPage() { const [error, setError] = useState(null); const [payRedirecting, setPayRedirecting] = useState(false); const [payEnv, setPayEnv] = useState(null); - const [payChannel, setPayChannel] = useState("wxpay"); const [authOpen, setAuthOpen] = useState(false); const [pendingSubmit, setPendingSubmit] = useState(false); const [isAlreadyRecorded, setIsAlreadyRecorded] = useState(false); @@ -97,9 +94,7 @@ export default function JoinPage() { useEffect(() => { if (typeof window === "undefined") return; - const env = getPayEnv(); - setPayEnv(env); - setPayChannel(getRecommendedChannel(env)); + setPayEnv(getPayEnv()); }, []); const set = (key: keyof FormData, value: string) => @@ -137,7 +132,7 @@ export default function JoinPage() { setPayRedirecting(true); const returnUrl = `${window.location.origin}${window.location.pathname}/paid`; const env = getPayEnv(); - const channel = mustUseWxPay(env) ? "wxpay" : payChannel; + const channel = "wxpay"; const device = getDeviceFromEnv(env); redirectToPay({ user_id: userId, diff --git a/app/[locale]/join/paid/page.tsx b/app/[locale]/join/paid/page.tsx index af7224c..adefe37 100644 --- a/app/[locale]/join/paid/page.tsx +++ b/app/[locale]/join/paid/page.tsx @@ -5,6 +5,13 @@ import { Link } from "@/i18n/navigation"; const DEFAULT_PASSWORD = "12345678"; +function clearPendingOrder(): void { + try { + localStorage.removeItem("join_pay_order"); + } catch {} + document.cookie = "join_pay_order=; path=/; max-age=0"; +} + /** * 支付完成回跳页 * Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页 @@ -60,9 +67,17 @@ export default function JoinPaidPage() { const run = async () => { const cookie = document.cookie.split(";").find((c) => c.trim().startsWith("join_pay_order=")); const rawValue = cookie?.split("=")[1]?.trim(); - const decoded = rawValue ? decodeURIComponent(rawValue) : ""; + const storageValue = (() => { + try { + return localStorage.getItem("join_pay_order") || ""; + } catch { + return ""; + } + })(); + const decoded = rawValue ? decodeURIComponent(rawValue) : storageValue; const orderId = decoded.split("|")[0]?.trim() || ""; if (!orderId) { + clearPendingOrder(); window.location.replace(`/${window.location.pathname.split("/")[1] || "zh"}/join`); return; } @@ -75,7 +90,7 @@ export default function JoinPaidPage() { try { const result = await tryComplete(orderId); if (result.ok) { - document.cookie = "join_pay_order=; path=/; max-age=0"; + clearPendingOrder(); if (!cancelled) setCompleteStatus("success"); return; } diff --git a/app/api/meetup/_paymentApi.ts b/app/api/meetup/_paymentApi.ts index 50d6a1b..9b2da19 100644 --- a/app/api/meetup/_paymentApi.ts +++ b/app/api/meetup/_paymentApi.ts @@ -40,6 +40,9 @@ function getMeetupApiCandidates(request: NextRequest): string[] { return urls.filter((url, index) => url && urls.indexOf(url) === index); } +/** 上游 API 请求超时时间(毫秒),避免 serverless 超时导致 502 */ +const MEETUP_API_TIMEOUT_MS = 20000; + export async function postMeetupApi( request: NextRequest, path: string, @@ -54,13 +57,19 @@ export async function postMeetupApi( let lastError: unknown = null; for (const apiUrl of apiCandidates) { + const url = `${apiUrl}${path}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MEETUP_API_TIMEOUT_MS); + try { - const res = await fetch(`${apiUrl}${path}`, { + const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), cache: "no-store", + signal: controller.signal, }); + clearTimeout(timeoutId); const data = await res.json().catch(() => ({})); if (res.ok || (res.status !== 404 && res.status < 500)) { @@ -68,7 +77,15 @@ export async function postMeetupApi( } lastResponse = { status: res.status, data }; + if (res.status >= 500) { + console.error(`[meetup-api] ${path} ${apiUrl} returned ${res.status}`, data); + } } catch (error) { + clearTimeout(timeoutId); + const errMsg = error instanceof Error ? error.message : String(error); + const errName = error instanceof Error && "name" in error ? (error as { name?: string }).name : ""; + const isTimeout = errName === "AbortError" || errMsg.toLowerCase().includes("abort") || errMsg.toLowerCase().includes("timeout"); + console.error(`[meetup-api] ${path} ${apiUrl} failed:`, isTimeout ? "timeout" : errMsg); lastError = error; } } diff --git a/app/api/meetup/check-user/route.ts b/app/api/meetup/check-user/route.ts index 5ca881c..a4dc789 100644 --- a/app/api/meetup/check-user/route.ts +++ b/app/api/meetup/check-user/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { postMeetupApi } from "../_paymentApi"; +/** Vercel serverless 超时时间(秒),避免上游慢导致 502 */ +export const maxDuration = 20; + export async function POST(request: NextRequest) { try { const body = await request.json().catch(() => ({})); @@ -10,9 +13,30 @@ export async function POST(request: NextRequest) { } const { status, data } = await postMeetupApi(request, "/api/meetup/check-user", { email }); + + // 上游返回 5xx 时,统一为 503 并返回友好提示,避免直接透传 502 + if (status >= 500) { + const payload = typeof data === "object" && data !== null ? (data as Record) : {}; + return NextResponse.json( + { ok: false, error: (payload.error as string) || "服务暂时不可用,请稍后重试" }, + { status: 503 } + ); + } + return NextResponse.json(data, { status }); } catch (error) { + const isTimeout = + error instanceof Error && + (error.name === "AbortError" || error.message.toLowerCase().includes("abort")); console.error("check-user error:", error); - return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 }); + return NextResponse.json( + { + ok: false, + error: isTimeout + ? "请求超时,请确认 payjsapi 已启动(cd payjsapi && python run.py)" + : "服务暂时不可用,请稍后重试", + }, + { status: 503 } + ); } } diff --git a/app/api/meetup/complete-order/route.ts b/app/api/meetup/complete-order/route.ts index bb53064..a255a48 100644 --- a/app/api/meetup/complete-order/route.ts +++ b/app/api/meetup/complete-order/route.ts @@ -1,6 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPocketBaseConfig, getPaymentConfig, SITE_ID } from "@/config/services.config"; +import { + getApiUrlFromRequest, + getPocketBaseConfig, + getPaymentConfig, + SITE_ID, +} from "@/config/services.config"; import { getAdminToken } from "@/app/lib/pocketbase/admin"; +import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus"; +import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus"; const DEFAULT_PASSWORD = "12345678"; @@ -26,27 +33,44 @@ function decodePendingEmail(userId: string): string | null { } } -async function queryZpayPaid(orderId: string): Promise { +async function verifyOrderPaid(request: NextRequest, orderId: string): Promise { const config = getPaymentConfig(); - if (!config.apiUrl) { - console.error("queryZpayPaid: PAYMENT_API_URL not configured"); - return false; - } + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + const apiUrl = ( + getApiUrlFromRequest(hostHeader) || + process.env.PAYMENT_API_URL || + config.apiUrl + ).replace(/\/$/, ""); + try { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - const url = `${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`; - const res = await fetch(url, { cache: "no-store", signal: controller.signal }) - .finally(() => clearTimeout(timeout)); + const timeout = setTimeout(() => controller.abort(), 3000); + const url = `${apiUrl}/cnomadcna/order_status?order_id=${encodeURIComponent(orderId)}`; + console.log(`complete-order: check payjsapi order_id=${orderId} url=${url}`); + const res = await fetch(url, { cache: "no-store", signal: controller.signal }).finally(() => + clearTimeout(timeout) + ); const data = await res.json().catch(() => ({})); - if (!data?.paid) { - console.warn(`queryZpayPaid: not paid yet, order_id=${orderId}, response=`, JSON.stringify(data)); + if (data?.paid) { + console.log(`complete-order: payjsapi paid order_id=${orderId}`); + return true; } - return !!data?.paid; } catch (e) { - console.error(`queryZpayPaid: fetch error, order_id=${orderId}`, e); - return false; + console.error(`complete-order: payjsapi fetch error order_id=${orderId}`, e); } + + const xorpay = await queryXorPayOrderStatus(orderId, 5000); + console.log( + `complete-order: xorpay fallback order_id=${orderId} paid=${!!xorpay?.paid} status=${xorpay?.status || ""} error=${xorpay?.error || ""}` + ); + if (xorpay?.paid) return true; + + const zpay = await queryZPayOrderStatus(orderId, 5000); + console.log( + `complete-order: zpay fallback order_id=${orderId} paid=${!!zpay?.paid} status=${zpay?.status || ""} error=${zpay?.error || ""}` + ); + return !!zpay?.paid; } async function ensureSiteVip( @@ -145,8 +169,8 @@ export async function POST(request: NextRequest) { // 2. 没找到 → 查 ZPAY 确认是否已支付,已支付则直接创建 site_vip if (!found) { - const zpayPaid = await queryZpayPaid(orderId); - if (!zpayPaid) { + const paid = await verifyOrderPaid(request, orderId); + if (!paid) { console.error(`complete-order: ZPAY 未确认支付 order_id=${orderId}`); return NextResponse.json({ ok: false, error: "订单不存在或未支付成功" }, { status: 400 }); } diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index 1d61181..809c7eb 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -1,30 +1,226 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPaymentConfig } from "@/config/services.config"; +import { + getApiUrlFromRequest, + getJoinPaymentConfig, + getPaymentConfig, + SITE_ID, +} from "@/config/services.config"; import { SITE_URL } from "@/app/lib/env"; -async function createPayOrder( - user_id: string, - return_url: string, - total_fee?: number, - type?: string, - channel = "alipay" -) { - const config = getPaymentConfig(); - if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL"); +const ORDER_COOKIE = "join_pay_order"; - const payload = { - user_id, - site_id: "meetup", - total_fee: Number(total_fee) || config.defaultAmount, - type: type || config.defaultType, +function resolvePayApiUrl(request: NextRequest): string { + const config = getPaymentConfig(); + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ( + getApiUrlFromRequest(hostHeader) || + process.env.PAYMENT_API_URL || + config.apiUrl + ).replace(/\/$/, ""); +} + +function setPayOrderCookie(response: NextResponse, orderId: string) { + response.cookies.set(ORDER_COOKIE, `${orderId}|${Date.now()}`, { + path: "/", + maxAge: 900, + }); +} + +function escapeAttr(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function buildPayHtml( + payUrl: string, + params: Record, + options?: { directToCashier?: boolean; orderId?: string } +): string { + const inputs = Object.entries(params) + .filter(([, value]) => value != null && String(value).trim() !== "") + .map( + ([key, value]) => + `` + ) + .join("\n"); + + const pendingOrderScript = options?.orderId + ? `` + : ""; + + return options?.directToCashier + ? `支付
${inputs}
${pendingOrderScript}` + : `跳转支付

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

${inputs}
${pendingOrderScript}`; +} + +function buildQrHtml( + imgSrc: string, + returnUrl: string, + orderId: string, + opts?: { channel?: string; successDesc?: string; confirmUrl?: string } +): string { + const ch = (opts?.channel || "wxpay").toLowerCase(); + const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat"; + const title = isWx ? "微信扫码支付" : "支付宝扫码支付"; + const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付"; + const successDesc = opts?.successDesc || "支付成功,即将跳转"; + const confirmUrl = opts?.confirmUrl || "/api/pay/confirm"; + const esc = (s: string) => + s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); + return ` + +${esc(title)} + + +
+
+

${esc(title)}

+

${esc(hint)}

+
+
+ + + + +
+ 5:00 + +
+
+
+ 支付二维码 +
+
+

等待支付中...

+
+
+
+
支付成功
+
${esc(successDesc)}
+
3 秒后跳转
+
+
+ +`; +} + +async function createPayOrder( + apiUrl: string, + userId: string, + returnUrl: string, + totalFee: number, + type: string, + channel: string, + provider?: "xorpay", + clientIp?: string +) { + const payload: Record = { + user_id: userId, + site_id: SITE_ID, + total_fee: totalFee, + type, channel, - return_url, + return_url: returnUrl, + ...(provider ? { provider } : {}), + ...(clientIp ? { client_ip: clientIp } : {}), }; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); - - const res = await fetch(`${config.apiUrl}/cnomadcna/payh5`, { + const res = await fetch(`${apiUrl}/cnomadcna/payh5`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -33,15 +229,42 @@ async function createPayOrder( const data = await res.json().catch(() => ({})); if (!res.ok || data?.status !== "ok") { + console.error("[pay] createPayOrder failed status=%s body=%j", res.status, data); throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败"); } - const params = data.params || data.xorpay_params || {}; - const payUrl = data.pay_url || data.xorpay_url || config.zpaySubmitUrl; - if (!params || typeof params !== "object") { - throw new Error("payjsapi 未返回支付参数"); + return { + params: (data.params ?? data.xorpay_params ?? {}) as Record, + payUrl: + data.pay_url || data.xorpay_url || getPaymentConfig().zpaySubmitUrl, + provider: String(data.provider || (provider ? "xorpay" : "zpay")).trim(), + orderId: String( + data.order_id || + data.params?.out_trade_no || + data.xorpay_params?.order_id || + "" + ).trim(), + }; +} + +async function requestRedirect( + apiUrl: string, + path: string, + payload: Record +) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 12000); + try { + return await fetch(`${apiUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + redirect: "manual", + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); } - return { params: JSON.parse(JSON.stringify(params)), payUrl }; } export async function POST(request: NextRequest) { @@ -49,50 +272,199 @@ export async function POST(request: NextRequest) { let body: Record; const contentType = request.headers.get("content-type") || ""; if (contentType.includes("application/json")) { - body = await request.json(); + body = (await request.json()) as Record; } else { const form = await request.formData(); body = Object.fromEntries(form.entries()) as Record; } - const user_id = String(body?.user_id || "").trim(); - const return_url = String(body?.return_url || "").trim() || `${SITE_URL}/zh/join/paid`; - const total_fee = Number(body?.total_fee) || 100; - const type = String(body?.type || "meetup"); - const channel = String(body?.channel || "alipay"); + const joinConfig = getJoinPaymentConfig(); + const payConfig = getPaymentConfig(); + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + const apiUrl = resolvePayApiUrl(request); + const userId = String(body?.user_id || "").trim(); + const totalFee = Number(body?.total_fee) || joinConfig.amount; + const type = String(body?.type || joinConfig.type); + const channel = String(body?.channel || "wxpay"); const device = String(body?.device || "pc"); + const forcedProvider = device === "wechat" ? "xorpay" : undefined; - if (!user_id) { - return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 }); + if (!userId) { + return NextResponse.json( + { ok: false, error: "缺少 user_id" }, + { status: 400 } + ); } - const { params, payUrl } = await createPayOrder(user_id, return_url, total_fee, type, channel); + 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") || SITE_URL; + const returnUrl = + String(body?.return_url || "").trim() || `${origin}/zh/join/paid`; + const clientIp = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + ""; + const wantHtml = + String(body?.html) === "1" || + (request.headers.get("accept") || "").includes("text/html"); - const wantHtml = String(body?.html) === "1" || (request.headers.get("accept") || "").includes("text/html"); - if (wantHtml) { - const escapeAttr = (s: string) => - String(s) - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); - const inputs = Object.entries(params as Record) - .filter(([, v]) => v != null && String(v).trim() !== "") - .map(([k, v]) => ``) - .join("\n"); - const orderId = String((params as Record)?.out_trade_no || "").trim(); - const html = `跳转支付
${inputs}
`; - const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); - if (orderId) { - res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 }); + console.log( + "[pay] cnomadcna apiUrl=%s host=%s provider=%s device=%s", + apiUrl, + hostHeader, + forcedProvider || "default", + device + ); + + const created = await createPayOrder( + apiUrl, + userId, + returnUrl, + totalFee, + type, + channel, + forcedProvider, + clientIp + ); + + if (!wantHtml) { + const response = NextResponse.json({ + ok: true, + params: created.params, + payUrl: created.payUrl, + provider: created.provider, + order_id: created.orderId, + }); + if (created.orderId) setPayOrderCookie(response, created.orderId); + return response; + } + + if (created.provider === "xorpay") { + const html = buildPayHtml(created.payUrl, created.params, { + directToCashier: true, + orderId: created.orderId, + }); + const response = new NextResponse(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + if (created.orderId) setPayOrderCookie(response, created.orderId); + return response; + } + + const redirectPayload: Record = { + user_id: userId, + site_id: SITE_ID, + total_fee: totalFee, + type, + channel, + return_url: returnUrl, + device, + ...(clientIp ? { client_ip: clientIp } : {}), + }; + + try { + const redirectRes = await requestRedirect( + apiUrl, + "/cnomadcna/payh5/redirect", + redirectPayload + ); + + if (redirectRes.status >= 301 && redirectRes.status <= 308) { + const location = redirectRes.headers.get("location"); + if (location) { + const response = NextResponse.redirect(location); + const orderId = + redirectRes.headers.get("x-pay-order-id")?.trim() || + created.orderId; + if (orderId) setPayOrderCookie(response, orderId); + return response; + } } - return res; + + const resData = await redirectRes.json().catch(() => ({})); + if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) { + const safeReturnUrl = String(resData.return_url || returnUrl).replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ + c + ] || c) + ); + const imgSrc = String(resData.img).replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ + c + ] || c) + ); + const rawOrderId = String(resData.order_id || created.orderId).trim(); + const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId, { + channel: resData.channel || "wxpay", + successDesc: "支付成功,申请已提交", + confirmUrl: "/api/meetup/complete-order", + }); + const response = new NextResponse(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + if (rawOrderId) setPayOrderCookie(response, rawOrderId); + return response; + } + + if (redirectRes.status === 200 && resData?.use_form) { + const html = buildPayHtml(payConfig.zpaySubmitUrl, created.params, { + directToCashier: true, + orderId: String(resData.order_id || created.orderId).trim(), + }); + const response = new NextResponse(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + const orderId = String(resData.order_id || created.orderId).trim(); + if (orderId) setPayOrderCookie(response, orderId); + return response; + } + + if (redirectRes.status >= 400) { + console.warn( + "[pay] redirect fallback status=%s body=%j", + redirectRes.status, + resData + ); + } + } catch (error) { + console.warn( + "[pay] redirect fallback error=%s", + error instanceof Error ? error.message : String(error) + ); } - return NextResponse.json({ ok: true, pay_url: payUrl, params, provider: "zpay" }); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); + const html = buildPayHtml(created.payUrl, created.params, { + orderId: created.orderId, + }); + const response = new NextResponse(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + if (created.orderId) setPayOrderCookie(response, created.orderId); + return response; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + const msg = err.message || ""; + const hint = + msg.includes("fetch") || msg.includes("ECONNREFUSED") + ? "请确认 payjsapi 已启动:cd C:\\project\\payjsapi && python run.py" + : err.name === "AbortError" + ? "请求超时,请检查 payjsapi 是否正常运行" + : msg; console.error("Pay API error:", err); - return NextResponse.json({ ok: false, error: err.message }, { status: 500 }); + return NextResponse.json( + { ok: false, error: `支付请求失败: ${hint}` }, + { status: 500 } + ); } } diff --git a/app/api/pay/status/route.ts b/app/api/pay/status/route.ts index 67abc72..c1e4569 100644 --- a/app/api/pay/status/route.ts +++ b/app/api/pay/status/route.ts @@ -1,25 +1,88 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPaymentConfig } from "@/config/services.config"; +import { + getApiUrlFromRequest, + getPaymentConfig, +} from "@/config/services.config"; +import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus"; +import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus"; + +function resolvePayApiUrl(request: NextRequest): string { + const config = getPaymentConfig(); + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ( + getApiUrlFromRequest(hostHeader) || + process.env.PAYMENT_API_URL || + config.apiUrl + ).replace(/\/$/, ""); +} export async function GET(request: NextRequest) { const orderId = request.nextUrl.searchParams.get("order_id"); if (!orderId) { - return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 }); - } - const config = getPaymentConfig(); - if (!config.apiUrl) { - return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 }); + return NextResponse.json( + { ok: false, error: "missing order_id" }, + { status: 400 } + ); } + + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + const apiUrl = resolvePayApiUrl(request); + const url = `${apiUrl}/cnomadcna/order_status?order_id=${encodeURIComponent(orderId)}`; + console.log("[pay/status] request order_id=%s host=%s url=%s", orderId, hostHeader, url); + try { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 25000); - const res = await fetch( - `${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`, - { cache: "no-store", signal: controller.signal } - ).finally(() => clearTimeout(timeout)); + const timeout = setTimeout(() => controller.abort(), 3000); + const res = await fetch(url, { + cache: "no-store", + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); const data = await res.json().catch(() => ({})); - return NextResponse.json({ ok: true, paid: !!data?.paid }); - } catch { - return NextResponse.json({ ok: true, paid: false }); + const paid = !!data?.paid; + console.log( + "[pay/status] result order_id=%s paid=%s status=%s", + orderId, + paid, + res.status + ); + if (paid) { + return NextResponse.json({ ok: true, paid: true }); + } + } catch (error) { + console.log( + "[pay/status] error order_id=%s error=%s", + orderId, + error instanceof Error ? error.message : String(error) + ); } + + const xorpay = await queryXorPayOrderStatus(orderId, 5000); + console.log( + "[pay/status] xorpay fallback order_id=%s paid=%s status=%s error=%s url=%s", + orderId, + !!xorpay?.paid, + xorpay?.status || "", + xorpay?.error || "", + xorpay?.url || "" + ); + if (xorpay?.paid) { + return NextResponse.json({ ok: true, paid: true }); + } + + const zpay = await queryZPayOrderStatus(orderId, 5000); + console.log( + "[pay/status] zpay fallback order_id=%s paid=%s status=%s error=%s url=%s", + orderId, + !!zpay?.paid, + zpay?.status || "", + zpay?.error || "", + zpay?.url || "" + ); + if (zpay?.paid) { + return NextResponse.json({ ok: true, paid: true }); + } + + return NextResponse.json({ ok: true, paid: false }); } diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx index 49a839d..edb1675 100644 --- a/app/components/HeroSection.tsx +++ b/app/components/HeroSection.tsx @@ -5,8 +5,7 @@ import { Link, useLocale } from "@/i18n/navigation"; import { useTranslation } from "@/i18n/navigation"; import JoinModal from "./JoinModal"; import { - getRecommendedChannel, - mustUseWxPay, + getPayEnv, redirectToPay, getDeviceFromEnv, } from "@/app/lib/payment"; @@ -149,13 +148,8 @@ export default function HeroSection() { } const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid"; - const env: PayEnv = - typeof window !== "undefined" - ? navigator.userAgent.includes("MicroMessenger") - ? "wechat" - : "pc" - : "pc"; - const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env); + const env = getPayEnv(); + const channel: PayChannel = "wxpay"; redirectToPay({ user_id, return_url: returnUrl, diff --git a/app/components/JoinModal.tsx b/app/components/JoinModal.tsx index 42c3140..1520a38 100644 --- a/app/components/JoinModal.tsx +++ b/app/components/JoinModal.tsx @@ -4,8 +4,7 @@ import { useState, useEffect, type FormEvent } from "react"; import { createPortal } from "react-dom"; import { useLocale } from "@/i18n/navigation"; import { - getRecommendedChannel, - mustUseWxPay, + getPayEnv, redirectToPay, getDeviceFromEnv, } from "@/app/lib/payment"; @@ -71,8 +70,8 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly return; } const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid"; - const env: PayEnv = typeof window !== "undefined" ? (navigator.userAgent.includes("MicroMessenger") ? "wechat" : "pc") : "pc"; - const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env); + const env = getPayEnv(); + const channel: PayChannel = "wxpay"; redirectToPay({ user_id: data.user_id, return_url: returnUrl, diff --git a/app/lib/payment/usePayStatusPoll.ts b/app/lib/payment/usePayStatusPoll.ts index 5f0ed4f..c0c3f63 100644 --- a/app/lib/payment/usePayStatusPoll.ts +++ b/app/lib/payment/usePayStatusPoll.ts @@ -1,11 +1,9 @@ -/** - * 微信 H5 支付完成后可能不跳转,通过 cookie 中的订单号轮询检测支付状态 - */ import { useEffect, useRef, useState } from "react"; const COOKIE_NAME = "join_pay_order"; -const POLL_INTERVAL = 2000; -const MAX_POLLS = 150; +const STORAGE_NAME = "join_pay_order"; +const POLL_INTERVAL_MS = 1200; +const MAX_POLLS = 60; const MAX_AGE_MS = 15 * 60 * 1000; function getCookie(name: string): string | null { @@ -19,7 +17,33 @@ function clearCookie(name: string): void { document.cookie = `${name}=; path=/; max-age=0`; } -function parsePayOrderCookie(raw: string | null): [string | null, boolean] { +function getStorage(name: string): string | null { + if (typeof window === "undefined") return null; + return window.localStorage.getItem(name); +} + +function removeStorage(name: string): void { + if (typeof window === "undefined") return; + window.localStorage.removeItem(name); +} + +function clearPendingOrder(): void { + clearCookie(COOKIE_NAME); + removeStorage(STORAGE_NAME); +} + +function persistPendingOrder(raw: string): void { + if (typeof document !== "undefined") { + document.cookie = `${COOKIE_NAME}=${encodeURIComponent(raw)}; path=/; max-age=900`; + } + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(STORAGE_NAME, raw); + } catch {} + } +} + +function parsePendingOrder(raw: string | null): [string | null, boolean] { if (!raw?.trim()) return [null, false]; const parts = raw.split("|"); const orderId = parts[0]?.trim() || raw.trim(); @@ -36,37 +60,96 @@ export function usePayStatusPoll(onPaid: () => void): boolean { onPaidRef.current = onPaid; useEffect(() => { - const raw = getCookie(COOKIE_NAME); - const [orderId, isValid] = parsePayOrderCookie(raw); - if (!orderId || !isValid) { - if (raw) clearCookie(COOKIE_NAME); + const cookieRaw = getCookie(COOKIE_NAME); + const [cookieOrderId, cookieValid] = parsePendingOrder(cookieRaw); + const storageRaw = getStorage(STORAGE_NAME); + const [storageOrderId, storageValid] = parsePendingOrder(storageRaw); + const orderId = cookieValid ? cookieOrderId : storageValid ? storageOrderId : null; + const activeRaw = cookieValid ? cookieRaw : storageValid ? storageRaw : null; + + if (!orderId) { + if (cookieRaw || storageRaw) { + clearPendingOrder(); + } return; } + if (activeRaw) { + persistPendingOrder(activeRaw); + } + setPolling(true); - let count = 0; - const timer = setInterval(async () => { - count++; - if (count > MAX_POLLS) { - clearInterval(timer); - clearCookie(COOKIE_NAME); + let attempts = 0; + let stopped = false; + let timer: number | undefined; + let inFlight = false; + + const stop = () => { + stopped = true; + if (timer) window.clearTimeout(timer); + }; + + const scheduleNext = () => { + if (stopped) return; + timer = window.setTimeout(tick, POLL_INTERVAL_MS); + }; + + const tick = async () => { + if (stopped) return; + if (inFlight) { + scheduleNext(); + return; + } + + attempts += 1; + if (attempts > MAX_POLLS) { + stop(); + clearPendingOrder(); setPolling(false); return; } + + inFlight = true; try { - const res = await fetch(`/api/pay/status?order_id=${encodeURIComponent(orderId)}`); - const data = await res.json(); + const res = await fetch( + `/api/pay/status?order_id=${encodeURIComponent(orderId)}`, + { cache: "no-store" } + ); + const data = await res.json().catch(() => ({})); if (data?.ok && data?.paid) { - clearInterval(timer); + stop(); + clearPendingOrder(); setPolling(false); onPaidRef.current(); + return; } } catch { - // 继续轮询 + // keep polling + } finally { + inFlight = false; } - }, POLL_INTERVAL); - return () => clearInterval(timer); + scheduleNext(); + }; + + const handleResume = () => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } + void tick(); + }; + + document.addEventListener("visibilitychange", handleResume); + window.addEventListener("pageshow", handleResume); + window.addEventListener("focus", handleResume); + void tick(); + + return () => { + document.removeEventListener("visibilitychange", handleResume); + window.removeEventListener("pageshow", handleResume); + window.removeEventListener("focus", handleResume); + stop(); + }; }, []); return polling; diff --git a/app/lib/payment/xorpayStatus.ts b/app/lib/payment/xorpayStatus.ts new file mode 100644 index 0000000..618fdc9 --- /dev/null +++ b/app/lib/payment/xorpayStatus.ts @@ -0,0 +1,49 @@ +import { createHash } from "crypto"; + +const XORPAY_AID = process.env.XORPAY_AID || "8220"; +const XORPAY_SECRET = + process.env.XORPAY_SECRET || "afcacd99570945f88de62624aaa3578e"; +const XORPAY_QUERY_URL = + process.env.XORPAY_QUERY_URL || "https://xorpay.com/api/query2"; + +export async function queryXorPayOrderStatus( + orderId: string, + timeoutMs = 5000 +): Promise<{ paid: boolean; status?: string; error?: string; url: string } | null> { + const normalizedOrderId = orderId.trim(); + if (!normalizedOrderId || !XORPAY_AID || !XORPAY_SECRET) { + return null; + } + + const sign = createHash("md5") + .update(`${normalizedOrderId}${XORPAY_SECRET}`, "utf8") + .digest("hex") + .toLowerCase(); + + const url = new URL( + `${XORPAY_QUERY_URL.replace(/\/$/, "")}/${encodeURIComponent(XORPAY_AID)}` + ); + url.searchParams.set("order_id", normalizedOrderId); + url.searchParams.set("sign", sign); + + try { + const res = await fetch(url.toString(), { + cache: "no-store", + signal: AbortSignal.timeout(timeoutMs), + }); + const data = await res.json().catch(() => ({})); + const status = String(data?.status || "").trim().toLowerCase(); + return { + paid: res.ok && (status === "payed" || status === "success"), + status, + url: url.toString(), + error: res.ok ? undefined : `status=${res.status}`, + }; + } catch (error) { + return { + paid: false, + url: url.toString(), + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/app/lib/payment/zpayStatus.ts b/app/lib/payment/zpayStatus.ts new file mode 100644 index 0000000..4bf9a0c --- /dev/null +++ b/app/lib/payment/zpayStatus.ts @@ -0,0 +1,45 @@ +const ZPAY_PID = process.env.ZPAY_PID || "2025121809351743"; +const ZPAY_KEY = process.env.ZPAY_KEY || "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89"; +const ZPAY_QUERY_URL = + process.env.ZPAY_QUERY_URL || "https://zpayz.cn/api.php"; + +export async function queryZPayOrderStatus( + orderId: string, + timeoutMs = 5000 +): Promise<{ paid: boolean; status?: string; error?: string; url: string } | null> { + const normalizedOrderId = orderId.trim(); + if (!normalizedOrderId || !ZPAY_PID || !ZPAY_KEY) { + return null; + } + + const url = new URL(ZPAY_QUERY_URL); + url.searchParams.set("act", "order"); + url.searchParams.set("pid", ZPAY_PID); + url.searchParams.set("key", ZPAY_KEY); + url.searchParams.set("out_trade_no", normalizedOrderId); + + try { + const res = await fetch(url.toString(), { + cache: "no-store", + signal: AbortSignal.timeout(timeoutMs), + }); + const data = await res.json().catch(() => ({})); + const code = String(data?.code || "").trim(); + const status = String(data?.status || "").trim(); + return { + paid: res.ok && code === "1" && status === "1", + status, + url: url.toString(), + error: + res.ok && code === "1" + ? undefined + : String(data?.msg || `status=${res.status}`), + }; + } catch (error) { + return { + paid: false, + url: url.toString(), + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/config/services.config.ts b/config/services.config.ts index 7812e51..50edadf 100644 --- a/config/services.config.ts +++ b/config/services.config.ts @@ -39,6 +39,7 @@ export interface MinioConfig { } const isDev = process.env.NODE_ENV === "development"; +const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007"; /** 站点标识,用于 VIP 按站点隔离 */ export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "meetup"; @@ -46,6 +47,13 @@ export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "meetup"; /** meetup 加入游牧中国 价格(分),测试用1元 */ export const MEETUP_JOIN_AMOUNT = 100; +export function getApiUrlFromRequest(hostHeader: string | null): string | null { + if (!hostHeader) return null; + if (!isDev) return process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL; + const hostname = hostHeader.split(":")[0]; + return `http://${hostname}:${PAYJSAPI_PORT}`; +} + export function getPocketBaseConfig(): PocketBaseConfig { return { enabled: true,