From a193fe4d45e34a68f2f9bf16403da960262d1db6 Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 13 Mar 2026 04:41:51 -0500 Subject: [PATCH] s'c1' --- .../[moduleIndex]/[lessonIndex]/page.tsx | 43 +- app/[locale]/join/page.tsx | 49 +- app/[locale]/join/paid/page.tsx | 8 + app/api/pay/confirm/route.ts | 170 +++++ app/api/pay/route.ts | 582 +++++++++--------- app/api/pay/status/route.ts | 91 ++- app/api/vip/check/route.ts | 72 ++- app/components/AuthModal.tsx | 3 + app/components/Header.tsx | 60 +- app/components/PayStatusPollProvider.tsx | 13 + app/components/UserAvatar.tsx | 21 +- app/components/course/CTASection.tsx | 42 +- app/components/course/CourseHero.tsx | 42 +- app/layout.tsx | 6 +- app/lib/payEnv.ts | 12 +- app/lib/payment/usePayStatusPoll.ts | 150 ++++- app/lib/payment/xorpayStatus.ts | 49 ++ app/lib/payment/zpayStatus.ts | 45 ++ app/lib/pocketbase/auth.ts | 8 +- app/lib/useAuthAndVip.ts | 14 +- config/services.config.ts | 8 + scripts/test-vip-check.mjs | 99 +++ 22 files changed, 1119 insertions(+), 468 deletions(-) create mode 100644 app/api/pay/confirm/route.ts create mode 100644 app/components/PayStatusPollProvider.tsx create mode 100644 app/lib/payment/xorpayStatus.ts create mode 100644 app/lib/payment/zpayStatus.ts create mode 100644 scripts/test-vip-check.mjs diff --git a/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx b/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx index 9869957..dc1c297 100644 --- a/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx +++ b/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx @@ -15,17 +15,14 @@ import Header from "@/app/components/Header"; import AuthModal from "@/app/components/AuthModal"; import { getPayEnv, - getRecommendedChannel, - mustUseWxPay, redirectToPay, getDeviceFromEnv, } from "@/app/lib/payment"; - export default function LessonPage() { const params = useParams(); const router = useRouter(); const { t } = useTranslation("community"); - const { user, vip } = useAuthAndVip(); + const { user, vip, refetch } = useAuthAndVip(); const [toast, setToast] = useState(false); const [authOpen, setAuthOpen] = useState(false); const [payRedirecting, setPayRedirecting] = useState(false); @@ -42,15 +39,16 @@ export default function LessonPage() { const startPay = (userId: string) => { const env = getPayEnv(); - const channel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env); + const channel = "wxpay"; const device = getDeviceFromEnv(env); - const returnUrl = `${window.location.origin}${window.location.pathname}`; + const returnUrl = `${window.location.origin}/`; redirectToPay({ user_id: userId, return_url: returnUrl, channel, device, + type: "video", useJoinConfig: true, }); }; @@ -60,14 +58,17 @@ export default function LessonPage() { setPayRedirecting(true); try { - if (user?.id) { - startPay(user.id); - return; - } - - const meRes = await fetch("/api/auth/me", { credentials: "include" }); - const meData = await meRes.json().catch(() => ({})); + // 先验证登录态 + let meRes = await fetch("/api/auth/me", { credentials: "include" }); + let meData = await meRes.json().catch(() => ({})); if (meData?.user?.id) { + // 已登录:先校验 VIP,已是 VIP 则无需支付 + const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" }); + const vipData = await vipRes.json().catch(() => ({})); + if (vipData?.vip) { + await refetch(); + return; + } startPay(meData.user.id); return; } @@ -81,8 +82,18 @@ export default function LessonPage() { body: JSON.stringify({ token: storedToken, record: storedUser }), credentials: "include", }).catch(() => null); - startPay(storedUser.id); - return; + meRes = await fetch("/api/auth/me", { credentials: "include" }); + meData = await meRes.json().catch(() => ({})); + if (meData?.user?.id) { + const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" }); + const vipData = await vipRes.json().catch(() => ({})); + if (vipData?.vip) { + await refetch(); + return; + } + startPay(meData.user.id); + return; + } } setAuthOpen(true); @@ -219,7 +230,7 @@ export default function LessonPage() { onClose={() => setAuthOpen(false)} onSuccess={() => { setAuthOpen(false); - void handleEnroll(); + void refetch(); }} /> diff --git a/app/[locale]/join/page.tsx b/app/[locale]/join/page.tsx index 91b77b0..573dc2d 100644 --- a/app/[locale]/join/page.tsx +++ b/app/[locale]/join/page.tsx @@ -4,13 +4,10 @@ import { useState, useRef, useEffect, useCallback, type FormEvent, type ChangeEv 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 { getStoredToken } from "@/app/lib/pocketbase"; import AuthModal from "@/app/components/AuthModal"; @@ -57,7 +54,6 @@ export default function JoinPage() { const [error, setError] = useState(null); const [payRedirecting, setPayRedirecting] = useState(false); const [payEnv, setPayEnv] = useState(null); - const [payChannel, setPayChannel] = useState("alipay"); const [authOpen, setAuthOpen] = useState(false); const checkAuth = useCallback(async () => { @@ -77,14 +73,10 @@ export default function JoinPage() { } }, []); - // 微信 H5 支付完成后可能不跳转,后台静默轮询,检测到已支付则显示成功页 - usePayStatusPoll(() => setSubmitted(true)); - useEffect(() => { if (typeof window === "undefined") return; const env = getPayEnv(); setPayEnv(env); - setPayChannel(getRecommendedChannel(env)); }, []); const set = (key: keyof FormData, value: string) => @@ -118,9 +110,9 @@ export default function JoinPage() { setSubmitting(false); setPayRedirecting(true); // Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径 - const returnUrl = `${window.location.origin}${window.location.pathname}/paid`; + const returnUrl = `${window.location.origin}/`; const env = getPayEnv(); - const channel = mustUseWxPay(env) ? "wxpay" : payChannel; + const channel = "wxpay"; const device = getDeviceFromEnv(env); redirectToPay({ user_id: userId, @@ -463,39 +455,12 @@ export default function JoinPage() { )} - {/* ── 支付通道选择(PC/H5 可选,微信内强制微信支付)── */} + {/* ── 支付方式:默认微信支付,支付宝暂不提供 ── */}

💳 支付方式

- {payEnv === "wechat" ? ( -

- 当前在微信内打开,将使用 微信支付 -

- ) : ( -
- - -
- )} +

+ 使用 微信支付 +

{/* ── Submit ── */} diff --git a/app/[locale]/join/paid/page.tsx b/app/[locale]/join/paid/page.tsx index 4cab2ea..1b4c13a 100644 --- a/app/[locale]/join/paid/page.tsx +++ b/app/[locale]/join/paid/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import { Link } from "@/i18n/navigation"; /** @@ -7,6 +8,13 @@ import { Link } from "@/i18n/navigation"; * Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页 */ export default function JoinPaidPage() { + useEffect(() => { + try { + localStorage.removeItem("join_pay_order"); + } catch {} + document.cookie = "join_pay_order=; path=/; max-age=0"; + }, []); + return (
diff --git a/app/api/pay/confirm/route.ts b/app/api/pay/confirm/route.ts new file mode 100644 index 0000000..a4d6df4 --- /dev/null +++ b/app/api/pay/confirm/route.ts @@ -0,0 +1,170 @@ +/** + * 支付确认:对齐 nomadvip 标准。payjsapi 异步通知未到达时,由前端直接写入 PocketBase site_vip + */ +import { NextRequest, NextResponse } from "next/server"; +import { + getApiUrlFromRequest, + getPocketBaseConfig, + getPaymentConfig, + getJoinPaymentConfig, + SITE_ID, +} from "@/config/services.config"; +import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus"; +import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus"; + +function parseUserIdFromOrderId(orderId: string): string | null { + if (!orderId || !orderId.includes("_order_")) return null; + const prefix = orderId.split("_order_")[0]; + if (!prefix || !prefix.includes("_")) return prefix || null; + const parts = prefix.split("_"); + const type = parts[parts.length - 1]; + if (["vip", "video", "meetup", "book"].includes(type?.toLowerCase() || "")) { + return parts.slice(0, -1).join("_") || null; + } + return prefix; +} + +async function getAdminToken(): Promise { + const email = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL; + const password = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD; + if (!email || !password) return null; + const pb = getPocketBaseConfig(); + const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"]; + for (const path of paths) { + const res = await fetch(`${pb.url}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + }); + if (res.ok) { + const data = await res.json(); + if (data?.token) return data.token; + } + } + return null; +} + +function resolvePayApiUrl(request: NextRequest): string { + const payConfig = getPaymentConfig(); + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ( + (hostHeader ? getApiUrlFromRequest(hostHeader) : null) || + process.env.PAYMENT_API_URL || + payConfig.apiUrl + ).replace(/\/$/, ""); +} + +async function verifyOrderPaid(request: NextRequest, orderId: string): Promise { + const apiUrl = resolvePayApiUrl(request); + const url = `${apiUrl}/digital/order_status?order_id=${encodeURIComponent(orderId)}`; + try { + const res = await fetch(url, { cache: "no-store" }); + const data = await res.json().catch(() => ({})); + if (data?.paid) return true; + } catch (e) { + console.error("[pay/confirm] verifyOrderPaid fetch error:", e); + } + if (await queryXorPayOrderStatus(orderId, 5000).then((r) => r?.paid)) return true; + if (await queryZPayOrderStatus(orderId, 5000).then((r) => r?.paid)) return true; + return false; +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + let orderId = String(body?.order_id ?? body?.orderId ?? "").trim(); + let userId = String(body?.user_id ?? body?.userId ?? "").trim(); + + if (!orderId) { + return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 }); + } + if (!userId) { + userId = parseUserIdFromOrderId(orderId) || ""; + } + if (!userId) { + return NextResponse.json({ ok: false, error: "缺少 user_id 且无法从 order_id 解析" }, { status: 400 }); + } + + const paid = await verifyOrderPaid(request, orderId); + if (!paid) { + return NextResponse.json({ ok: false, error: "订单未支付" }, { status: 400 }); + } + + const adminToken = await getAdminToken(); + if (!adminToken) { + return NextResponse.json({ ok: false, error: "PocketBase 未配置" }, { status: 500 }); + } + + const pb = getPocketBaseConfig(); + const joinConfig = getJoinPaymentConfig(); + const totalFee = Number(joinConfig?.amount ?? 100); + + const checkPayments = await fetch( + `${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(`order_id="${orderId}"`)}&perPage=1`, + { headers: { Authorization: `Bearer ${adminToken}` } } + ); + const paymentsData = await checkPayments.json().catch(() => ({})); + if ((paymentsData?.items?.length ?? 0) === 0) { + await fetch(`${pb.url}/api/collections/payments/records`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ + user_id: userId, + type: "video", + order_id: orderId, + total_fee: totalFee, + amount: totalFee, + channel: "wxpay", + }), + }); + } + + const siteVipCheck = await fetch( + `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(`user_id="${userId}" && site_id="${SITE_ID}"`)}&perPage=1`, + { headers: { Authorization: `Bearer ${adminToken}` } } + ); + const siteVipData = await siteVipCheck.json().catch(() => ({})); + const siteVipItems = siteVipData?.items ?? []; + + if (siteVipItems.length > 0) { + await fetch(`${pb.url}/api/collections/site_vip/records/${siteVipItems[0].id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ order_id: orderId }), + }); + } else { + const siteVipRes = await fetch(`${pb.url}/api/collections/site_vip/records`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ + user_id: userId, + site_id: SITE_ID, + order_id: orderId, + }), + }); + if (!siteVipRes.ok) { + const err = await siteVipRes.json().catch(() => ({})); + if (!err?.data?.order_id?.message?.includes("unique") && !err?.message?.includes("unique")) { + console.error("[pay/confirm] site_vip create failed:", await siteVipRes.text()); + } + } + } + + return NextResponse.json({ ok: true }); + } catch (e) { + console.error("[pay/confirm] error:", e); + return NextResponse.json( + { ok: false, error: e instanceof Error ? e.message : "确认失败" }, + { status: 500 } + ); + } +} diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index 57e8bb8..d48752d 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -1,57 +1,31 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPaymentConfig, getJoinPaymentConfig, SITE_ID } from "@/config/services.config"; +import { + getApiUrlFromRequest, + getJoinPaymentConfig, + getPaymentConfig, + SITE_ID, +} from "@/config/services.config"; import { SITE_URL } from "../../lib/env"; -async function createPayOrder( - user_id: string, - return_url: string, - total_fee?: number, - type?: string, - channel = "alipay" -) { - const joinConfig = getJoinPaymentConfig(); - const fee = total_fee ?? joinConfig.amount; - const orderType = type ?? joinConfig.type; +const ORDER_COOKIE = "join_pay_order"; + +function resolvePayApiUrl(request: NextRequest): string { const config = getPaymentConfig(); - if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL"); - - const payload = { - user_id, - site_id: SITE_ID, - total_fee: Number(fee) || joinConfig.amount, - type: orderType, - channel, - return_url, - }; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - - const res = await fetch(`${config.apiUrl}/digital/payh5`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - signal: controller.signal, - }).finally(() => clearTimeout(timeout)); - - const data = await res.json().catch(() => ({})); - if (!res.ok || data?.status !== "ok") { - throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败"); - } - - let 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 未返回支付参数"); - } - params = JSON.parse(JSON.stringify(params)); - if (data.provider === "xorpay") { - throw new Error("当前 payjsapi 使用 xorpay,请设置 PAYMENT_PROVIDER=zpay 后重启 payjsapi"); - } - return { params, payUrl }; + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ( + getApiUrlFromRequest(hostHeader) || + process.env.PAYMENT_API_URL || + config.apiUrl + ).replace(/\/$/, ""); } -const ZPAY_REQUIRED = ["pid", "type", "out_trade_no", "notify_url", "return_url", "name", "money", "sign", "sign_type"]; +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) @@ -61,39 +35,142 @@ function escapeAttr(s: string): string { .replace(/>/g, ">"); } -function buildPayHtml(payUrl: string, params: Record): 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; - } - for (const key of ZPAY_REQUIRED) { - if (!flat[key]) { - console.error(`ZPAY 缺少必填参数: ${key}`, flat); - } - } - const inputs = Object.entries(flat) - .map(([k, v]) => ``) +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 +): string { return ` - - - - - 跳转支付... - - + +扫码支付 + -

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

-
- ${inputs} -
- - -`; +

请扫码支付

+

支付成功后将自动跳转

+支付二维码 + +`; +} + +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: returnUrl, + ...(provider ? { provider } : {}), + ...(clientIp ? { client_ip: clientIp } : {}), + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + const res = await fetch(`${apiUrl}/digital/payh5`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + + 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 || "支付创建失败"); + } + + 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); + } } export async function POST(request: NextRequest) { @@ -101,7 +178,7 @@ 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; @@ -109,225 +186,176 @@ export async function POST(request: NextRequest) { const joinConfig = getJoinPaymentConfig(); const payConfig = getPaymentConfig(); - const user_id = String(body?.user_id || "").trim(); - const return_url = String(body?.return_url || "").trim(); - const total_fee = Number(body?.total_fee) || joinConfig.amount; + 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 || "alipay"); - const device = String(body?.device || "pc"); // pc | h5 | wechat,微信内传 wechat + 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 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; - // Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径 - const finalReturnUrl = return_url || `${origin}/zh/join/paid`; + 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 { params, payUrl } = await createPayOrder( - user_id, - finalReturnUrl, - total_fee, - type, - channel + console.log( + "[pay] digital apiUrl=%s host=%s provider=%s device=%s", + apiUrl, + hostHeader, + forcedProvider || "default", + device ); - if (!params || Object.keys(params).length === 0) { - return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 }); + 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; } - const accept = request.headers.get("accept") || ""; - const wantHtml = String(body?.html) === "1" || accept.includes("text/html"); - if (wantHtml) { - const clientIp = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - ""; - // 使用 payjsapi /payh5/redirect:mapi.php API接口支付,按 device 返回 payurl/payurl2 - const redirectPayload = { - user_id, - site_id: SITE_ID, - total_fee, - type, - channel, - return_url: finalReturnUrl, - device, - ...(clientIp && { client_ip: clientIp }), - }; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15000); - const redirectRes = await fetch(`${payConfig.apiUrl}/digital/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 res = NextResponse.redirect(location); - // payh5/redirect 创建的是新订单,必须用其返回的 order_id(非 createPayOrder 的 params) - const orderId = - redirectRes.headers.get("x-pay-order-id")?.trim() || - String(params?.out_trade_no || "").trim(); - if (orderId) { - // 附带时间戳,前端只对 15 分钟内设置的 cookie 轮询,避免陈旧 cookie 误触发 - const payload = `${orderId}|${Date.now()}`; - res.cookies.set("join_pay_order", payload, { path: "/", maxAge: 900 }); - } - return res; - } - } - const resData = await redirectRes.json().catch(() => ({})); - // 微信内:payurl2 会提示「请外微信外打开」,改用收银台表单由 Z-Pay 识别 UA - if (redirectRes.status === 200 && resData?.use_form) { - const html = buildPayHtml(payConfig.zpaySubmitUrl, params as Record); - const res = new NextResponse(html, { - status: 200, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - const orderId = - resData?.order_id?.trim() || String(params?.out_trade_no || "").trim(); - if (orderId) { - res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, { - path: "/", - maxAge: 900, - }); - } - return res; - } - if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) { - 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 || "alipay").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)}

-支付二维码 -

支付成功后将自动跳转...

- -`; - return new NextResponse(html, { - status: 200, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - const errData = resData; - if (redirectRes.status === 400 || redirectRes.status === 500) { - return NextResponse.json( - { ok: false, error: errData?.detail || errData?.msg || "支付跳转失败" }, - { status: redirectRes.status } - ); - } - // 降级:payjsapi 未提供 redirect 或非 zpay,使用原有表单提交逻辑 - const formParams = new URLSearchParams(); - const missing: string[] = []; - for (const key of ZPAY_REQUIRED) { - const v = params[key]; - if (v == null || String(v).trim() === "") { - missing.push(key); - } else { - formParams.append(key, String(v)); - } - } - for (const [k, v] of Object.entries(params)) { - if (!ZPAY_REQUIRED.includes(k) && v != null && String(v).trim() !== "") { - formParams.append(k, String(v)); - } - } - if (missing.length > 0) { - return NextResponse.json( - { - ok: false, - error: `payjsapi 返回参数缺少: ${missing.join(", ")}。请确认 payjsapi 已设置 PAYMENT_PROVIDER=zpay`, - }, - { status: 500 } - ); - } - const zpayRes = await fetch(payConfig.zpaySubmitUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: formParams.toString(), - redirect: "manual", + if (created.provider === "xorpay") { + const html = buildPayHtml(created.payUrl, created.params, { + directToCashier: true, + orderId: created.orderId, }); - const location = zpayRes.headers.get("location"); - if ((zpayRes.status === 301 || zpayRes.status === 302) && location) { - const res = NextResponse.redirect(location); - const outTradeNo = String(params?.out_trade_no || "").trim(); - if (outTradeNo) { - const payload = `${outTradeNo}|${Date.now()}`; - res.cookies.set("join_pay_order", payload, { path: "/", maxAge: 900 }); - } - return res; - } - const text = await zpayRes.text(); - try { - const errJson = JSON.parse(text); - if (errJson?.code === "error") { - return NextResponse.json( - { ok: false, error: errJson.msg || "ZPAY 返回错误" }, - { status: 500 } - ); - } - } catch { - /* ignore */ - } - const html = buildPayHtml(payUrl, params as Record); - return new NextResponse(html, { + const response = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" }, }); + if (created.orderId) setPayOrderCookie(response, created.orderId); + return response; } - return NextResponse.json({ - ok: true, - pay_url: payUrl, - params, - provider: "zpay", - submit_method: "POST", + 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, + "/digital/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; + } + } + + 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); + 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) + ); + } + + const html = buildPayHtml(created.payUrl, created.params, { + orderId: created.orderId, }); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); + 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") diff --git a/app/api/pay/status/route.ts b/app/api/pay/status/route.ts index dd4eaa5..77449e9 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}/digital/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}/digital/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/api/vip/check/route.ts b/app/api/vip/check/route.ts index af5c154..d879d26 100644 --- a/app/api/vip/check/route.ts +++ b/app/api/vip/check/route.ts @@ -4,18 +4,23 @@ import { getPocketBaseConfig, SITE_ID, VIP_MASTER_SITE_ID } from "@/config/servi const COOKIE_NAME = "pb_session"; async function getAdminToken(): Promise { - const email = process.env.POCKETBASE_EMAIL; - const password = process.env.POCKETBASE_PASSWORD; + const email = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL; + const password = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD; if (!email || !password) return null; const pb = getPocketBaseConfig(); - const res = await fetch(`${pb.url}/api/admins/auth-with-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: email, password }), - }); - if (!res.ok) return null; - const data = await res.json(); - return data?.token ?? null; + const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"]; + for (const path of paths) { + const res = await fetch(`${pb.url}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + }); + if (res.ok) { + const data = await res.json(); + if (data?.token) return data.token; + } + } + return null; } /** 检查单条 site_vip 是否有效 */ @@ -26,18 +31,45 @@ function isValidVip(record: { expires_at?: string | null } | null): boolean { } export async function GET(request: NextRequest) { - const cookie = request.cookies.get(COOKIE_NAME)?.value; - if (!cookie) { - return NextResponse.json({ ok: true, vip: false }); + const token = await getAdminToken(); + if (!token) return NextResponse.json({ ok: true, vip: false }); + + let userId: string | null = null; + + // 优先用邮箱查(Header 传 email,更可靠) + const email = request.nextUrl.searchParams.get("email")?.trim(); + if (email) { + const pb = getPocketBaseConfig(); + const filter = `email="${email.replace(/"/g, '\\"')}"`; + const collections = [pb.usersCollection, "_pb_users_auth_"]; + for (const coll of collections) { + const userRes = await fetch( + `${pb.url}/api/collections/${coll}/records?filter=${encodeURIComponent(filter)}&perPage=1`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (userRes.ok) { + const userData = await userRes.json(); + userId = userData?.items?.[0]?.id ?? null; + if (userId) break; + } + } } + + // 无邮箱或未查到:回退到 cookie 中的 userId + if (!userId) { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) return NextResponse.json({ ok: true, vip: false }); + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + userId = payload?.userId ?? null; + } catch { + return NextResponse.json({ ok: true, vip: false }); + } + } + + if (!userId) return NextResponse.json({ ok: true, vip: false }); + try { - const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); - const userId = payload?.userId; - if (!userId) return NextResponse.json({ ok: true, vip: false }); - - const token = await getAdminToken(); - if (!token) return NextResponse.json({ ok: true, vip: false }); - const pb = getPocketBaseConfig(); // 1) 本专题站 VIP(site_id = SITE_ID)2) vip 站 master VIP(site_id = vip)可解锁所有 digital 专题站 const filter = `user_id = "${userId}" && (site_id = "${SITE_ID}" || site_id = "${VIP_MASTER_SITE_ID}")`; diff --git a/app/components/AuthModal.tsx b/app/components/AuthModal.tsx index 1692ee6..fb4b9b9 100644 --- a/app/components/AuthModal.tsx +++ b/app/components/AuthModal.tsx @@ -64,6 +64,9 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps pbSaveAuth(data.token, { id: data.record.id, email: data.record.email ?? email }); onSuccess(email); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:success")); + } onClose(); setEmail(""); setPassword(""); diff --git a/app/components/Header.tsx b/app/components/Header.tsx index ffba8c8..e060154 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation"; import { getStoredUserEmail } from "@/app/lib/pocketbase"; import { useCrossSiteUrls } from "@/app/lib/useCrossSiteUrls"; @@ -18,6 +18,7 @@ export default function Header() { const [scrolled, setScrolled] = useState(false); const [authOpen, setAuthOpen] = useState(false); const [userEmail, setUserEmail] = useState(null); + const [vip, setVip] = useState(false); const { vipUrl } = useCrossSiteUrls(); const navLinks = [ { labelKey: "roadmap" as const, href: "#roadmap" }, @@ -26,27 +27,42 @@ export default function Header() { { labelKey: "resources" as const, href: "#resources" }, ]; - useEffect(() => { - let mounted = true; - (async () => { - try { - const res = await fetch("/api/auth/me", { credentials: "include" }); - const data = await res.json(); - if (!mounted) return; - if (data?.user && data?.token) { - const { pbSaveAuth } = await import("@/app/lib/pocketbase"); - pbSaveAuth(data.token, { id: data.user.id, email: data.user.email }); - setUserEmail(data.user.email); - return; - } - } catch { - /* ignore */ + const fetchAuthAndVip = useCallback(async () => { + try { + const meRes = await fetch("/api/auth/me", { credentials: "include" }); + const meData = await meRes.json(); + if (meData?.user && meData?.token) { + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(meData.token, { id: meData.user.id, email: meData.user.email }); + setUserEmail(meData.user.email); + // 用邮箱查 VIP,比 cookie userId 更可靠 + const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" }); + const vipData = await vipRes.json(); + setVip(vipData?.vip === true); + } else { + setUserEmail(getStoredUserEmail()); + setVip(false); } - if (mounted) setUserEmail(getStoredUserEmail()); - })(); - return () => { mounted = false; }; + } catch { + /* ignore */ + } }, []); + useEffect(() => { + fetchAuthAndVip(); + }, [fetchAuthAndVip]); + + useEffect(() => { + const onVipUpdated = () => fetchAuthAndVip(); + const onAuthSuccess = () => fetchAuthAndVip(); + window.addEventListener("vip:updated", onVipUpdated); + window.addEventListener("auth:success", onAuthSuccess); + return () => { + window.removeEventListener("vip:updated", onVipUpdated); + window.removeEventListener("auth:success", onAuthSuccess); + }; + }, [fetchAuthAndVip]); + return (
{userEmail ? ( - setUserEmail(null)} /> + { setUserEmail(null); setVip(false); router.replace("/"); }} /> ) : ( {userEmail ? (
- { setUserEmail(null); setMobileOpen(false); }} /> + { setUserEmail(null); setVip(false); setMobileOpen(false); router.replace("/"); }} /> {userEmail}
) : ( @@ -176,7 +192,7 @@ export default function Header() { setAuthOpen(false)} - onSuccess={(email) => setUserEmail(email)} + onSuccess={() => fetchAuthAndVip()} />
); diff --git a/app/components/PayStatusPollProvider.tsx b/app/components/PayStatusPollProvider.tsx new file mode 100644 index 0000000..94d95c8 --- /dev/null +++ b/app/components/PayStatusPollProvider.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll"; + +/** + * 全局支付轮询:支付返回任意页面时检测 paid,调用 confirm 后跳转首页 + */ +export function PayStatusPollProvider() { + usePayStatusPoll(() => { + window.location.href = "/"; + }); + return null; +} diff --git a/app/components/UserAvatar.tsx b/app/components/UserAvatar.tsx index 1986247..eb8bb14 100644 --- a/app/components/UserAvatar.tsx +++ b/app/components/UserAvatar.tsx @@ -12,11 +12,13 @@ function getAvatarLetter(email: string): string { interface UserAvatarProps { email: string; + vip?: boolean; onLogout?: () => void; + onRefreshVip?: () => void; } -/** 圆形头像 + 点击展开退出按钮,供 digital / meetup / vip 三站复用 */ -export default function UserAvatar({ email, onLogout }: UserAvatarProps) { +/** 圆形头像 + VIP 标识 + 点击展开退出按钮,供 digital / meetup / vip 三站复用 */ +export default function UserAvatar({ email, vip, onLogout }: UserAvatarProps) { const { t } = useTranslation("common"); const [open, setOpen] = useState(false); const ref = useRef(null); @@ -40,13 +42,26 @@ export default function UserAvatar({ email, onLogout }: UserAvatarProps) { {open && (
+ {vip && ( +
+ 👑 VIP +
+ )}