This commit is contained in:
eric
2026-03-13 21:38:04 -05:00
parent 0885e00b25
commit 04995f91dd
22 changed files with 856 additions and 482 deletions

View File

@@ -1,150 +1,21 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { triggerPay } from "@/app/lib/triggerPay";
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
import { getPaymentConfig } from "@/config/services.config";
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
import { VipLayout } from "@/app/vip/components/VipLayout";
import { LandingPage } from "@/app/vip/components/LandingPage";
import { DashboardPage } from "@/app/vip/components/DashboardPage";
import styles from "@/app/vip/components/vip.module.css";
/** 微信内支付渠道:zpay | xorpay */
const WECHAT_PAY_PROVIDER: "zpay" | "xorpay" = "xorpay";
// 渠道规则:pc/手机浏览器 -> zpay,仅微信内 -> xorpay
const DEFAULT_EMAIL_PASSWORD = "12345678";
const ANONYMOUS_USER_COOKIE = "nomadvip_anon_uid";
const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
const PAY_ORDER_COOKIE = "nomadvip_pay_order";
const PAY_ORDER_MAX_AGE = 60 * 15;
function redirectToPayZpay(params: {
user_id: string;
return_url: string;
total_fee: number;
type: string;
channel: "alipay" | "wxpay";
device: "pc" | "h5" | "wechat";
}) {
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/pay";
form.target = "_self";
form.style.display = "none";
const fields: [string, string][] = [
["user_id", params.user_id],
["return_url", params.return_url],
["total_fee", String(params.total_fee)],
["type", params.type],
["channel", params.channel],
["device", params.device],
["provider", "zpay"],
["html", "1"],
];
fields.forEach(([k, v]) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = v;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
async function redirectToPayZpayH5(params: {
user_id: string;
return_url: string;
total_fee: number;
type: string;
channel: "alipay" | "wxpay";
}, onPendingOrder?: (orderId: string) => void): Promise<string | null> {
const res = await fetch("/api/pay", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...params,
device: "h5",
provider: "zpay",
html: "0",
}),
});
const data = await res.json().catch(() => ({}));
if (!data?.ok || !data?.params || !data?.payUrl) {
alert(data?.error || "支付发起失败");
return null;
}
const orderId = data?.order_id?.trim() || null;
if (orderId) {
savePendingOrder(orderId);
onPendingOrder?.(orderId);
}
if (data?.redirect_url) {
window.location.href = data.redirect_url;
return orderId;
}
const form = document.createElement("form");
form.method = "POST";
form.action = data.payUrl;
form.target = "_self";
form.style.display = "none";
for (const [k, v] of Object.entries(data.params as Record<string, string>)) {
if (v == null) continue;
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = String(v);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
return orderId;
}
async function payWechatXorpay(params: {
user_id: string;
return_url: string;
total_fee: number;
type: string;
channel: "alipay" | "wxpay";
}) {
const res = await fetch("/api/pay", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...params,
total_fee: params.total_fee,
device: "wechat",
provider: "xorpay",
html: "0",
}),
});
const data = await res.json().catch(() => ({}));
if (!data?.ok || !data?.params || !data?.payUrl) {
alert(data?.error || "支付发起失败");
return;
}
const form = document.createElement("form");
form.method = "POST";
form.action = data.payUrl;
const orderId = data?.order_id?.trim();
if (orderId) {
savePendingOrder(orderId);
}
form.target = "_self";
form.style.display = "none";
for (const [k, v] of Object.entries(data.params as Record<string, string>)) {
if (v == null) continue;
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = String(v);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
function getStorage(key: string): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(key);
@@ -213,11 +84,11 @@ function getPendingOrderId(): string | null {
return orderId || null;
}
/** 首页永远不显示 loading,所有接口在后台静默执行 */
export default function Home() {
const [paidType, setPaidType] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(null);
const savePaidType = useCallback((type: string | null) => {
@@ -490,7 +361,6 @@ export default function Home() {
clearPendingOrder();
setPaidType(null);
setUserName(null);
setLoading(false);
const path = window.location.pathname || "/";
window.location.replace(path);
} catch (error) {
@@ -498,37 +368,9 @@ export default function Home() {
}
}, []);
const doPay = useCallback(async () => {
const userId = await getOrCreateUserId();
if (/^user\d+$/.test(userId)) {
setStorage("memosAccount", userId);
setStorage("userId", userId);
setAnonymousUserCookie(userId);
}
const returnUrl = `${window.location.origin}/paid`;
const env = getPayEnv();
const device = getDeviceFromEnv(env);
const totalFee = getPaymentConfig().defaultAmount ?? 8800;
const base = {
user_id: userId,
return_url: returnUrl,
total_fee: totalFee,
type: "vip",
channel: "wxpay" as const,
};
if (device === "wechat" && WECHAT_PAY_PROVIDER === "xorpay") {
payWechatXorpay(base);
} else if (device === "h5") {
const orderId = await redirectToPayZpayH5(base, setPayPendingOrderId);
if (orderId) setPayPendingOrderId(orderId);
} else {
redirectToPayZpay({ ...base, device });
}
}, [getOrCreateUserId]);
const handlePay = useCallback(() => {
doPay();
}, [doPay]);
triggerPay(setPayPendingOrderId);
}, []);
const handleVerifySuccess = useCallback(() => {
window.location.reload();
@@ -593,46 +435,56 @@ export default function Home() {
const pendingOrderId = getPendingOrderId();
if (pendingOrderId) {
setPayPendingOrderId(pendingOrderId);
setLoading(false);
return;
}
// 本地已有 VIP 标识时优先使用,不因跨站登录态覆盖
// 本地已有 VIP 标识时优先使用,立即展示;后台悄悄校验,若 user 被删或 VIP 被撤则更新本地状态
const localType = getStorage("paidType");
const localUserName = getStorage("userName");
const localUserEmail = getStorage("userEmail");
const localUserId = getStorage("userId");
if (localType === "vip" && (localUserName || localUserEmail) && localUserId) {
const isAnonymous = /^user\d+$/.test(localUserId);
let verified = false;
if (isAnonymous) {
verified = await checkPaymentFromPB(localUserId);
} else {
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
verified = !!vipData?.vip;
// 立即展示 VIP 状态,不阻塞首屏
setPaidType(localType);
setUserName(localUserEmail || localUserName || "");
setUserEmail(localUserEmail || (localUserName?.includes("@") ? localUserName : null));
if (!localUserEmail && !localUserName?.includes("@")) {
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
const meData = await meRes.json();
if (meData?.user?.email) setUserEmail(meData.user.email);
} catch { /* ignore */ }
}
if (verified) {
setPaidType(localType);
setUserName(localUserEmail || localUserName || "");
setUserEmail(localUserEmail || (localUserName?.includes("@") ? localUserName : null));
if (!localUserEmail && !localUserName?.includes("@")) {
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
const meData = await meRes.json();
if (meData?.user?.email) setUserEmail(meData.user.email);
} catch { /* ignore */ }
// 后台悄悄校验:user 被删或 VIP 被撤则清除本地存储并更新状态
const verifyInBackground = async () => {
const isAnonymous = /^user\d+$/.test(localUserId);
let verified = false;
try {
if (isAnonymous) {
verified = await checkPaymentFromPB(localUserId);
} else {
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
verified = !!vipData?.vip;
}
} catch { /* ignore */ }
if (!verified) {
removeStorage("paidType");
removeStorage("userName");
removeStorage("userEmail");
savePaidType(null);
setUserName(null);
setUserEmail(null);
}
setLoading(false);
return;
} else {
removeStorage("paidType");
removeStorage("userName");
removeStorage("userEmail");
}
};
void verifyInBackground();
return;
}
// 校验逻辑:有本地存储(登录资料+VIP)时优先使用并校验;无身份/VIP 时点击按钮直接进入支付,不校验
const anonymousCandidate = getStoredAnonymousUserId();
const hadStoredUserId = !!anonymousCandidate;
if (anonymousCandidate) {
const recovered = await tryRecoverByAnonymousUserId(anonymousCandidate);
if (recovered) {
@@ -642,7 +494,6 @@ export default function Home() {
const name = getStorage("userEmail") || getStorage("userName") || anonymousCandidate;
setUserName(name);
setUserEmail(name.includes("@") ? name : getStorage("userEmail"));
setLoading(false);
return;
}
}
@@ -656,7 +507,6 @@ export default function Home() {
setPaidType(afterLocalType);
setUserName(afterLocalUserEmail || afterLocalUserName || "");
setUserEmail(afterLocalUserEmail || (afterLocalUserName?.includes("@") ? afterLocalUserName : null));
setLoading(false);
return;
}
@@ -688,7 +538,6 @@ export default function Home() {
}
savePaidType("vip");
saveUserName(meData.user.email || meData.user.id);
setLoading(false);
return;
}
}
@@ -696,15 +545,17 @@ export default function Home() {
/* ignore */
}
const userIdForCheck = getStoredAnonymousUserId() || getStorage("userId");
if (userIdForCheck) {
const found = await checkPaymentFromPB(userIdForCheck);
if (found) {
const name = getStorage("userEmail") || getStorage("userName");
if (name) setUserName(name);
// 仅当有本地存储的 userId 时才校验(无身份/VIP 时跳过,点击按钮直接进入支付流程)
if (hadStoredUserId) {
const userIdForCheck = getStoredAnonymousUserId() || getStorage("userId");
if (userIdForCheck) {
const found = await checkPaymentFromPB(userIdForCheck);
if (found) {
const name = getStorage("userEmail") || getStorage("userName");
if (name) setUserName(name);
}
}
}
setLoading(false);
};
init();
@@ -778,15 +629,6 @@ export default function Home() {
const isLoggedIn = paidType === "vip";
if (loading) {
return (
<div className={styles.loading}>
<div className={styles.loadingSpinner} />
<p>加载中...</p>
</div>
);
}
return (
<>
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>