This commit is contained in:
eric
2026-03-13 04:41:51 -05:00
parent 37d0883ce9
commit a193fe4d45
22 changed files with 1119 additions and 468 deletions

View File

@@ -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();
}}
/>
</div>

View File

@@ -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<string | null>(null);
const [payRedirecting, setPayRedirecting] = useState(false);
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
const [payChannel, setPayChannel] = useState<PayChannel>("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() {
)}
</div>
{/* ── 支付通道选择PC/H5 可选,微信内强制微信支付)── */}
{/* ── 支付方式:默认微信支付,支付宝暂不提供 ── */}
<div className="mt-6 rounded-xl border border-slate-100 bg-slate-50/50 px-4 py-4 sm:px-5">
<p className="mb-3 text-sm font-bold text-slate-700">💳 </p>
{payEnv === "wechat" ? (
<p className="text-sm text-slate-500">
使 <span className="font-medium text-emerald-600"></span>
</p>
) : (
<div className="flex gap-3">
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-sky-500 has-[:checked]:bg-sky-50">
<input
type="radio"
name="payChannel"
value="alipay"
checked={payChannel === "alipay"}
onChange={() => setPayChannel("alipay")}
className="h-4 w-4 border-slate-300 text-sky-500"
/>
<span className="text-sm font-medium text-slate-700"></span>
</label>
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-emerald-500 has-[:checked]:bg-emerald-50">
<input
type="radio"
name="payChannel"
value="wxpay"
checked={payChannel === "wxpay"}
onChange={() => setPayChannel("wxpay")}
className="h-4 w-4 border-slate-300 text-emerald-500"
/>
<span className="text-sm font-medium text-slate-700"></span>
</label>
</div>
)}
<p className="text-sm text-slate-500">
使 <span className="font-medium text-emerald-600"></span>
</p>
</div>
{/* ── Submit ── */}

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] px-4">
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white p-10 text-center shadow-lg">

View File

@@ -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<string | null> {
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<boolean> {
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 }
);
}
}

View File

@@ -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, "&gt;");
}
function buildPayHtml(payUrl: string, params: Record<string, unknown>): string {
const flat: Record<string, string> = {};
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]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
function buildPayHtml(
payUrl: string,
params: Record<string, unknown>,
options?: { directToCashier?: boolean; orderId?: string }
): string {
const inputs = Object.entries(params)
.filter(([, value]) => value != null && String(value).trim() !== "")
.map(
([key, value]) =>
`<input type="hidden" name="${escapeAttr(key)}" value="${escapeAttr(
String(value)
)}" />`
)
.join("\n");
const pendingOrderScript = options?.orderId
? `<script>(function(){var v=${JSON.stringify(
`${options.orderId}|${Date.now()}`
)};try{localStorage.setItem("join_pay_order",v);}catch(e){}document.cookie="join_pay_order="+encodeURIComponent(v)+"; path=/; max-age=900";})();</script>`
: "";
return options?.directToCashier
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>支付</title><style>body{margin:0;overflow:hidden}</style></head><body><form id="f" action="${escapeAttr(
payUrl
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`
: `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style></head><body><p>正在跳转到支付页面...</p><form id="f" action="${escapeAttr(
payUrl
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`;
}
function buildQrHtml(
imgSrc: string,
returnUrl: string,
orderId: string
): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>跳转支付...</title>
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style>
</head>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>扫码支付</title>
<style>body{font-family:system-ui;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;}h2{color:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;}img{width:220px;height:220px;margin:20px 0;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}</style></head>
<body>
<p>正在跳转到支付页面...</p>
<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">
${inputs}
</form>
<script>document.getElementById("zpayForm").submit();</script>
</body>
</html>`;
<h2>请扫码支付</h2>
<p>支付成功后将自动跳转</p>
<img src="${imgSrc}" alt="支付二维码" />
<script>
(function(){
try {
localStorage.setItem("join_pay_order", ${JSON.stringify(
`${orderId}|${Date.now()}`
)});
} catch (e) {}
var orderId=${JSON.stringify(orderId)};
var returnUrl=${JSON.stringify(returnUrl)};
if(!orderId){return;}
function check(){
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId), { cache: "no-store" })
.then(function(r){return r.json();})
.then(function(d){
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
setTimeout(check,1200);
})
.catch(function(){setTimeout(check,1200);});
}
check();
})();
</script>
</body></html>`;
}
async function createPayOrder(
apiUrl: string,
userId: string,
returnUrl: string,
totalFee: number,
type: string,
channel: string,
provider?: "xorpay",
clientIp?: string
) {
const payload: Record<string, unknown> = {
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<string, string>,
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<string, unknown>
) {
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<string, string | number>;
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
body = await request.json();
body = (await request.json()) as Record<string, string | number>;
} else {
const form = await request.formData();
body = Object.fromEntries(form.entries()) as Record<string, string>;
@@ -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/redirectmapi.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<string, unknown>);
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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const imgSrc = String(resData.img).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const orderId = String(resData.order_id || "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<style>body{font-family:system-ui;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;}h2{color:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;}img{width:220px;height:220px;margin:20px 0;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}a{color:#0ea5e9;margin-top:16px;}.tip{font-size:12px;color:#94a3b8;margin-top:8px;}</style></head>
<body>
<h2>${esc(title)}</h2>
<p>${esc(hint)}</p>
<img src="${imgSrc}" alt="支付二维码" />
<p class="tip" id="tip">支付成功后将自动跳转...</p>
<script>
(function(){
var orderId="${orderId}";
var returnUrl="${returnUrl}";
if(!orderId){return;}
var tip=document.getElementById("tip");
var t=0;
var maxT=300;
function check(){
t++;
if(t>maxT){tip.textContent="轮询超时";return;}
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
.then(function(r){return r.json();})
.then(function(d){
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
setTimeout(check,2000);
})
.catch(function(){setTimeout(check,2000);});
}
setTimeout(check,2000);
})();
</script>
</body></html>`;
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<string, unknown>);
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<string, unknown> = {
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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
c
] || c)
);
const imgSrc = String(resData.img).replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
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")

View File

@@ -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 });
}

View File

@@ -4,18 +4,23 @@ import { getPocketBaseConfig, SITE_ID, VIP_MASTER_SITE_ID } from "@/config/servi
const COOKIE_NAME = "pb_session";
async function getAdminToken(): Promise<string | null> {
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) 本专题站 VIPsite_id = SITE_ID2) vip 站 master VIPsite_id = vip可解锁所有 digital 专题站
const filter = `user_id = "${userId}" && (site_id = "${SITE_ID}" || site_id = "${VIP_MASTER_SITE_ID}")`;

View File

@@ -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("");

View File

@@ -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<string | null>(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 (
<header
className={`fixed top-0 left-0 right-0 z-50 w-full max-w-[100vw] transition-all duration-300 ${
@@ -78,7 +94,7 @@ export default function Header() {
<div className="flex shrink-0 items-center gap-1 sm:gap-3">
{userEmail ? (
<UserAvatar email={userEmail} onLogout={() => setUserEmail(null)} />
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); router.replace("/"); }} />
) : (
<button
onClick={() => setAuthOpen(true)}
@@ -148,7 +164,7 @@ export default function Header() {
</button>
{userEmail ? (
<div className="mt-2 flex items-center gap-3 rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<UserAvatar email={userEmail} onLogout={() => { setUserEmail(null); setMobileOpen(false); }} />
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); setMobileOpen(false); router.replace("/"); }} />
<span className="truncate text-sm text-slate-600 dark:text-slate-400">{userEmail}</span>
</div>
) : (
@@ -176,7 +192,7 @@ export default function Header() {
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={(email) => setUserEmail(email)}
onSuccess={() => fetchAuthAndVip()}
/>
</header>
);

View File

@@ -0,0 +1,13 @@
"use client";
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
/**
* 全局支付轮询:支付返回任意页面时检测 paid调用 confirm 后跳转首页
*/
export function PayStatusPollProvider() {
usePayStatusPoll(() => {
window.location.href = "/";
});
return null;
}

View File

@@ -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<HTMLDivElement>(null);
@@ -40,13 +42,26 @@ export default function UserAvatar({ email, onLogout }: UserAvatarProps) {
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-slate-900 hover:bg-sky-600"
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-slate-900 hover:bg-sky-600"
aria-label={t("logout")}
>
{getAvatarLetter(email)}
{vip && (
<span
className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-amber-400 text-[10px] text-amber-900"
title="VIP"
>
👑
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
{vip && (
<div className="border-b border-slate-100 px-4 py-2 text-xs font-medium text-amber-600 dark:border-slate-700 dark:text-amber-400">
👑 VIP
</div>
)}
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700"

View File

@@ -7,30 +7,29 @@ import { getStoredToken, getStoredUser } from "@/app/lib/pocketbase";
import AuthModal from "@/app/components/AuthModal";
import {
getPayEnv,
getRecommendedChannel,
mustUseWxPay,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import { useState } from "react";
export function CTASection() {
const { user, vip } = useAuthAndVip();
const { user, vip, refetch } = useAuthAndVip();
const paid = !!user && vip;
const [authOpen, setAuthOpen] = useState(false);
const [payRedirecting, setPayRedirecting] = useState(false);
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}/course/0/0`;
const returnUrl = `${window.location.origin}/`;
redirectToPay({
user_id: userId,
return_url: returnUrl,
channel,
device,
type: "video",
useJoinConfig: true,
});
};
@@ -40,14 +39,17 @@ export function CTASection() {
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;
}
@@ -61,8 +63,18 @@ export function CTASection() {
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);
@@ -110,7 +122,7 @@ export function CTASection() {
onClose={() => setAuthOpen(false)}
onSuccess={() => {
setAuthOpen(false);
void handleEnroll();
void refetch();
}}
/>
</section>

View File

@@ -9,8 +9,6 @@ import { getLastWatched, getProgress } from "@/app/lib/learning";
import AuthModal from "@/app/components/AuthModal";
import {
getPayEnv,
getRecommendedChannel,
mustUseWxPay,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
@@ -18,7 +16,7 @@ import {
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
export function CourseHero() {
const { user, vip } = useAuthAndVip();
const { user, vip, refetch } = useAuthAndVip();
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
const [authOpen, setAuthOpen] = useState(false);
@@ -39,15 +37,16 @@ export function CourseHero() {
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}/course/0/0`;
const returnUrl = `${window.location.origin}/`;
redirectToPay({
user_id: userId,
return_url: returnUrl,
channel,
device,
type: "video",
useJoinConfig: true,
});
};
@@ -57,14 +56,17 @@ export function CourseHero() {
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;
}
@@ -78,8 +80,18 @@ export function CourseHero() {
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);
@@ -149,7 +161,7 @@ export function CourseHero() {
onClose={() => setAuthOpen(false)}
onSuccess={() => {
setAuthOpen(false);
void handleEnroll();
void refetch();
}}
/>
</section>

View File

@@ -3,6 +3,7 @@ import "@fontsource-variable/geist";
import "@fontsource-variable/geist-mono";
import "./globals.css";
import { ThemeProvider } from "./context/ThemeContext";
import { PayStatusPollProvider } from "./components/PayStatusPollProvider";
import { getThemeConfig } from "@/config";
export const viewport: Viewport = {
@@ -49,7 +50,10 @@ export default function RootLayout({
<html lang="zh-CN" suppressHydrationWarning>
<body className="antialiased font-sans">
<ThemeScript />
<ThemeProvider>{children}</ThemeProvider>
<ThemeProvider>
<PayStatusPollProvider />
{children}
</ThemeProvider>
</body>
</html>
);

View File

@@ -36,15 +36,11 @@ export function getPayEnv(): PayEnv {
}
/**
* 根据环境推荐/强制支付通道
* - 微信浏览器:只能用 wxpay支付宝在微信内无法唤起
* - 支付宝浏览器:优先 alipay
* - PC/H5用户可选
* 根据环境推荐支付通道
* 所有支付默认微信支付,支付宝暂不提供
*/
export function getRecommendedChannel(env: PayEnv): PayChannel {
if (env === "wechat") return "wxpay";
if (env === "pc" || env === "h5") return "alipay"; // 默认支付宝
return "alipay";
export function getRecommendedChannel(_env: PayEnv): PayChannel {
return "wxpay";
}
/** 微信内是否只能选微信支付 */

View File

@@ -1,13 +1,10 @@
/**
* 微信 H5 支付完成后可能不跳转,通过 cookie 中的订单号轮询检测支付状态
* cookie 格式order_id|timestamp仅对 15 分钟内设置的 cookie 轮询,避免陈旧 cookie 误触发
*/
import { useEffect, useRef, useState } from "react";
const COOKIE_NAME = "join_pay_order";
const POLL_INTERVAL = 2000;
const MAX_POLLS = 150; // 约 5 分钟
const MAX_AGE_MS = 15 * 60 * 1000; // 15 分钟
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 {
if (typeof document === "undefined") return null;
@@ -20,15 +17,40 @@ function clearCookie(name: string): void {
document.cookie = `${name}=; path=/; max-age=0`;
}
/** 解析 cookieorder_id|timestamp返回 [orderId, isValid]。无时间戳视为过期并清除 */
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();
const ts = parts[1] ? parseInt(parts[1], 10) : NaN;
if (!orderId) return [null, false];
if (Number.isNaN(ts)) return [orderId, false]; // 旧格式无时间戳,视为过期
if (Date.now() - ts > MAX_AGE_MS) return [orderId, false]; // 超过 15 分钟
if (Number.isNaN(ts)) return [orderId, false];
if (Date.now() - ts > MAX_AGE_MS) return [orderId, false];
return [orderId, true];
}
@@ -38,38 +60,110 @@ 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);
clearCookie(COOKIE_NAME);
stop();
clearPendingOrder();
setPolling(false);
// 对齐 nomadvip调用 confirm 写入 PocketBase site_vip不依赖 payjsapi 异步 notify
try {
const confirmRes = await fetch("/api/pay/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
cache: "no-store",
});
if (confirmRes.ok && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("vip:updated"));
}
} catch {
/* 忽略,不影响跳转 */
}
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;

View File

@@ -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),
};
}
}

View File

@@ -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),
};
}
}

View File

@@ -67,11 +67,17 @@ export function pbSaveAuth(token: string, record: AuthUser): void {
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
}
/** 登出 - 清除本地存储和根域 Cookie */
/** 登出 - 清除本地存储、支付订单 cookie 和根域 Cookie */
export async function pbLogout(): Promise<void> {
if (typeof window === "undefined") return;
localStorage.removeItem(PB_STORAGE_KEYS.token);
localStorage.removeItem(PB_STORAGE_KEYS.user);
localStorage.removeItem("join_pay_order");
try {
document.cookie = "join_pay_order=; path=/; max-age=0";
} catch {
/* ignore */
}
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch {

View File

@@ -18,14 +18,9 @@ export function useAuthAndVip(): AuthAndVipState {
const refetch = useCallback(async (): Promise<{ user: { id: string; email: string } | null; vip: boolean }> => {
setLoading(true);
try {
const [meRes, vipRes] = await Promise.all([
fetch("/api/auth/me", { credentials: "include" }),
fetch("/api/vip/check", { credentials: "include" }),
]);
const meRes = await fetch("/api/auth/me", { credentials: "include" });
const meData = await meRes.json();
const vipData = await vipRes.json();
let newUser = meData?.user ?? null;
let newVip = vipData?.vip === true;
if (!newUser) {
const storedUser = getStoredUser();
@@ -45,6 +40,13 @@ export function useAuthAndVip(): AuthAndVipState {
}
}
let newVip = false;
if (newUser?.email) {
const vipRes = await fetch(`/api/vip/check?email=${encodeURIComponent(newUser.email)}`, { credentials: "include" });
const vipData = await vipRes.json();
newVip = vipData?.vip === true;
}
setUser(newUser);
setVip(newVip);
return { user: newUser, vip: newVip };

View File

@@ -64,6 +64,7 @@ export interface ServicesConfig {
}
const isDev = process.env.NODE_ENV === "development";
const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007";
/** 站点标识,用于 VIP 按站点隔离。digital 专题站ai.xx/android.xx各自独立如 digital_ai、digital_android */
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "digital";
@@ -71,6 +72,13 @@ export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "digital";
/** vip 站vip.hackrobot.cn的 site_id其付费 VIP 可解锁所有 digital 专题站 */
export const VIP_MASTER_SITE_ID = "vip";
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}`;
}
/** 获取 PocketBase 配置(可传入主题覆盖) */
export function getPocketBaseConfig(themeOverride?: { joinCollection?: string }): PocketBaseConfig {
const joinCollection =

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* 测试 vip/check 逻辑:用邮箱查 users -> user_id -> site_vip
* 用法: node scripts/test-vip-check.mjs 283810867@qq.com
*/
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { readFileSync, existsSync } from "fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, "..");
function loadEnv() {
const paths = [join(root, ".env.local"), join(root, ".env")];
for (const p of paths) {
if (existsSync(p)) {
const content = readFileSync(p, "utf-8");
for (const line of content.split("\n")) {
const m = line.match(/^([^#=]+)=(.*)$/);
if (m) {
const key = m[1].trim();
const val = m[2].trim().replace(/^["']|["']$/g, "");
if (!process.env[key]) process.env[key] = val;
}
}
}
}
}
loadEnv();
const email = process.argv[2] || "283810867@qq.com";
const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
const adminEmail = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL;
const adminPassword = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD;
async function main() {
console.log("=== VIP Check 测试 ===");
console.log("邮箱:", email);
console.log("PocketBase URL:", pbUrl);
console.log("Admin 已配置:", !!(adminEmail && adminPassword));
if (!adminEmail || !adminPassword) {
console.error("请配置 POCKETBASE_EMAIL 和 POCKETBASE_PASSWORD");
process.exit(1);
}
const authPaths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"];
let authRes;
for (const path of authPaths) {
authRes = await fetch(`${pbUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: adminEmail, password: adminPassword }),
});
if (authRes.ok) break;
}
if (!authRes.ok) {
console.error("Admin 登录失败:", authRes.status, await authRes.text());
process.exit(1);
}
const { token } = await authRes.json();
console.log("Admin token 获取成功");
const userFilter = `email="${email}"`;
const userUrl = `${pbUrl}/api/collections/users/records?filter=${encodeURIComponent(userFilter)}&perPage=1`;
console.log("查询 users:", userUrl);
const userRes = await fetch(userUrl, { headers: { Authorization: `Bearer ${token}` } });
const userData = await userRes.json();
console.log("users 响应 status:", userRes.status);
console.log("users 响应:", JSON.stringify(userData, null, 2));
const userId = userData?.items?.[0]?.id;
if (!userId) {
console.error("未找到用户,请检查邮箱或 users 集合");
process.exit(1);
}
console.log("user_id:", userId);
const siteVipFilter = `user_id = "${userId}" && (site_id = "digital" || site_id = "vip")`;
const siteVipUrl = `${pbUrl}/api/collections/site_vip/records?filter=${encodeURIComponent(siteVipFilter)}&perPage=2&sort=-expires_at`;
console.log("查询 site_vip:", siteVipUrl);
const vipRes = await fetch(siteVipUrl, { headers: { Authorization: `Bearer ${token}` } });
const vipData = await vipRes.json();
console.log("site_vip 响应 status:", vipRes.status);
console.log("site_vip 响应:", JSON.stringify(vipData, null, 2));
const items = vipData?.items ?? [];
const valid = items.filter((r) => !r.expires_at || r.expires_at > new Date().toISOString());
console.log("\n=== 结果 ===");
console.log("VIP:", valid.length > 0);
if (valid[0]) console.log("记录:", valid[0]);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});