From 6f6f0fa527b93c093a9c9f0ad7edafe2b9eb2d0c Mon Sep 17 00:00:00 2001 From: eric Date: Tue, 10 Mar 2026 08:46:58 -0500 Subject: [PATCH] 's' --- app/api/pay/route.ts | 11 +++++++- app/lib/usePayStatusPoll.ts | 52 ++++++++++++++++++++++++++++++---- app/page.tsx | 56 +++++++++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index 1d918c0..a0f56ef 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -121,6 +121,7 @@ export async function POST(request: NextRequest) { 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) { @@ -218,10 +219,18 @@ export async function POST(request: NextRequest) { 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() || String(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); return res; } diff --git a/app/lib/usePayStatusPoll.ts b/app/lib/usePayStatusPoll.ts index ba52e73..a2f259e 100644 --- a/app/lib/usePayStatusPoll.ts +++ b/app/lib/usePayStatusPoll.ts @@ -1,12 +1,14 @@ /** - * 微信 H5 支付完成后可能不跳转,通过 cookie 轮询检测支付状态 - * 参考 digital usePayStatusPoll + * 支付状态轮询:点击支付后轮询,支付成功刷新,超时重置 + * 优先使用 sessionStorage(pay_pending_order),其次 cookie */ import { useEffect, useRef, useState } from "react"; const COOKIE_NAME = "nomadvip_pay_order"; +const SESSION_KEY_ORDER = "pay_pending_order"; +const SESSION_KEY_TS = "pay_pending_ts"; const POLL_INTERVAL = 2000; -const MAX_POLLS = 160; +const MAX_POLLS = 160; // ~5 分钟 const MAX_AGE_MS = 15 * 60 * 1000; function getCookie(name: string): string | null { @@ -20,6 +22,29 @@ function clearCookie(name: string): void { document.cookie = `${name}=; path=/; max-age=0`; } +function getSessionOrder(): [string | null, boolean] { + if (typeof window === "undefined") return [null, false]; + const orderId = sessionStorage.getItem(SESSION_KEY_ORDER)?.trim() || null; + const ts = sessionStorage.getItem(SESSION_KEY_TS); + const timestamp = ts ? parseInt(ts, 10) : NaN; + if (!orderId) return [null, false]; + if (Number.isNaN(timestamp)) return [orderId, true]; + if (Date.now() - timestamp > MAX_AGE_MS) return [orderId, false]; + return [orderId, true]; +} + +function setSessionOrder(orderId: string): void { + if (typeof window === "undefined") return; + sessionStorage.setItem(SESSION_KEY_ORDER, orderId); + sessionStorage.setItem(SESSION_KEY_TS, String(Date.now())); +} + +function clearSessionOrder(): void { + if (typeof window === "undefined") return; + sessionStorage.removeItem(SESSION_KEY_ORDER); + sessionStorage.removeItem(SESSION_KEY_TS); +} + function parsePayOrderCookie(raw: string | null): [string | null, boolean] { if (!raw?.trim()) return [null, false]; const parts = raw.split("|"); @@ -31,6 +56,19 @@ function parsePayOrderCookie(raw: string | null): [string | null, boolean] { return [orderId, true]; } +function getOrderIdToPoll(): [string | null, "session" | "cookie" | null] { + const [sessionOrder, sessionValid] = getSessionOrder(); + if (sessionOrder && sessionValid) return [sessionOrder, "session"]; + const raw = getCookie(COOKIE_NAME); + const [cookieOrder, cookieValid] = parsePayOrderCookie(raw); + if (cookieOrder && cookieValid) return [cookieOrder, "cookie"]; + return [null, null]; +} + +export function setPayPendingOrder(orderId: string): void { + setSessionOrder(orderId); +} + export function usePayStatusPoll(onPaid: (orderId?: string) => void, onTimeout?: () => void): boolean { const [polling, setPolling] = useState(false); const onPaidRef = useRef(onPaid); @@ -39,9 +77,9 @@ export function usePayStatusPoll(onPaid: (orderId?: string) => void, onTimeout?: onTimeoutRef.current = onTimeout; useEffect(() => { - const raw = getCookie(COOKIE_NAME); - const [orderId, isValid] = parsePayOrderCookie(raw); - if (!orderId || !isValid) { + const [orderId] = getOrderIdToPoll(); + if (!orderId) { + const raw = getCookie(COOKIE_NAME); if (raw) clearCookie(COOKIE_NAME); return; } @@ -52,6 +90,7 @@ export function usePayStatusPoll(onPaid: (orderId?: string) => void, onTimeout?: count++; if (count > MAX_POLLS) { clearInterval(timer); + clearSessionOrder(); clearCookie(COOKIE_NAME); setPolling(false); onTimeoutRef.current?.(); @@ -62,6 +101,7 @@ export function usePayStatusPoll(onPaid: (orderId?: string) => void, onTimeout?: const data = await res.json(); if (data?.ok && data?.paid) { clearInterval(timer); + clearSessionOrder(); clearCookie(COOKIE_NAME); setPolling(false); onPaidRef.current(orderId); diff --git a/app/page.tsx b/app/page.tsx index 8aeb58b..ba0911f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,7 +8,7 @@ import { indexMd } from "./index-content"; import styles from "./index.module.css"; import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv"; import { getPaymentConfig } from "@/config/services.config"; -import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll"; +import { usePayStatusPoll, setPayPendingOrder } from "@/app/lib/usePayStatusPoll"; const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "https://pocketbase.hackrobot.cn"; @@ -16,7 +16,7 @@ const PB_URL = /** 微信内支付渠道:zpay | xorpay,改此参数即可切换 */ const WECHAT_PAY_PROVIDER: "zpay" | "xorpay" = "xorpay"; -/** PC/H5:form POST 到 /api/pay,有中转页,默认 zpay */ +/** PC:form POST 到 /api/pay,有中转页 */ function redirectToPayZpay(params: { user_id: string; return_url: string; @@ -51,7 +51,53 @@ function redirectToPayZpay(params: { form.submit(); } -/** 微信内:fetch 获取 xorpay 参数,直接提交表单到收银台,无中转页 */ +/** H5 手机:fetch 获取 order_id + 支付参数,写入 sessionStorage 后提交表单,支持轮询 */ +async function redirectToPayZpayH5(params: { + user_id: string; + return_url: string; + total_fee: number; + type: string; + channel: "alipay" | "wxpay"; +}) { + const res = await fetch("/api/pay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...params, + device: "h5", + provider: "zpay", + html: "0", + }), + }); + const data = await res.json().catch(() => ({})); + if (!data?.ok || !data?.params || !data?.payUrl) { + alert(data?.error || "支付发起失败"); + return; + } + const orderId = data?.order_id?.trim(); + if (orderId) setPayPendingOrder(orderId); + if (data?.redirect_url) { + window.location.href = data.redirect_url; + return; + } + const form = document.createElement("form"); + form.method = "POST"; + form.action = data.payUrl; + form.target = "_self"; + form.style.display = "none"; + for (const [k, v] of Object.entries(data.params as Record)) { + if (v == null) continue; + const input = document.createElement("input"); + input.type = "hidden"; + input.name = k; + input.value = String(v); + form.appendChild(input); + } + document.body.appendChild(form); + form.submit(); +} + +/** 微信内:fetch 获取 xorpay 参数,写入 order_id 后提交表单到收银台,无中转页 */ async function payWechatXorpay(params: { user_id: string; return_url: string; @@ -75,6 +121,8 @@ async function payWechatXorpay(params: { alert(data?.error || "支付发起失败"); return; } + const orderId = data?.order_id?.trim(); + if (orderId) setPayPendingOrder(orderId); const form = document.createElement("form"); form.method = "POST"; form.action = data.payUrl; @@ -211,6 +259,8 @@ export default function Home() { const base = { user_id: userId, return_url: returnUrl, total_fee: totalFee, type: "vip", channel: "wxpay" as const }; if (device === "wechat" && WECHAT_PAY_PROVIDER === "xorpay") { payWechatXorpay(base); + } else if (device === "h5") { + redirectToPayZpayH5(base); } else { redirectToPayZpay({ ...base, device }); }