977 lines
34 KiB
TypeScript
977 lines
34 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { triggerPay } from "@/app/lib/triggerPay";
|
||
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
|
||
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";
|
||
|
||
// 渠道规则: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;
|
||
const EMAIL_VERIFY_TS_KEY = "emailVerifyAt";
|
||
const EMAIL_VERIFY_COOLDOWN_MS = 60 * 1000;
|
||
|
||
function getStorage(key: string): string | null {
|
||
if (typeof window === "undefined") return null;
|
||
return localStorage.getItem(key);
|
||
}
|
||
|
||
function setStorage(key: string, value: string): void {
|
||
if (typeof window === "undefined") return;
|
||
localStorage.setItem(key, value);
|
||
}
|
||
|
||
function removeStorage(key: string): void {
|
||
if (typeof window === "undefined") return;
|
||
localStorage.removeItem(key);
|
||
}
|
||
|
||
function getCookie(name: string): string | null {
|
||
if (typeof document === "undefined") return null;
|
||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||
return match ? decodeURIComponent(match[1]) : null;
|
||
}
|
||
|
||
function setAnonymousUserCookie(userId: string): void {
|
||
if (typeof document === "undefined") return;
|
||
if (!/^user\d+$/.test(userId)) return;
|
||
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
||
document.cookie = `${ANONYMOUS_USER_COOKIE}=${encodeURIComponent(
|
||
userId
|
||
)}; Path=/; Max-Age=${ANONYMOUS_USER_COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
|
||
}
|
||
|
||
function clearCookie(name: string): void {
|
||
if (typeof document === "undefined") return;
|
||
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
||
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax${secure}`;
|
||
}
|
||
|
||
function clearAnonymousUserCookie(): void {
|
||
clearCookie(ANONYMOUS_USER_COOKIE);
|
||
}
|
||
|
||
function setPayOrderCookie(orderId: string): void {
|
||
if (typeof document === "undefined") return;
|
||
const normalizedOrderId = orderId.trim();
|
||
if (!normalizedOrderId) return;
|
||
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
||
document.cookie = `${PAY_ORDER_COOKIE}=${encodeURIComponent(
|
||
`${normalizedOrderId}|${Date.now()}`
|
||
)}; Path=/; Max-Age=${PAY_ORDER_MAX_AGE}; SameSite=Lax${secure}`;
|
||
}
|
||
|
||
function savePendingOrder(orderId: string): void {
|
||
const normalizedOrderId = orderId.trim();
|
||
if (!normalizedOrderId) return;
|
||
setPayOrderCookie(normalizedOrderId);
|
||
setStorage(PAY_ORDER_COOKIE, `${normalizedOrderId}|${Date.now()}`);
|
||
}
|
||
|
||
function clearPendingOrder(): void {
|
||
clearCookie(PAY_ORDER_COOKIE);
|
||
removeStorage(PAY_ORDER_COOKIE);
|
||
}
|
||
|
||
function getPendingOrderId(): string | null {
|
||
const raw = getCookie(PAY_ORDER_COOKIE) || getStorage(PAY_ORDER_COOKIE);
|
||
const orderId = raw?.split("|")[0]?.trim() || "";
|
||
return orderId || null;
|
||
}
|
||
|
||
type VipProbeResult = "yes" | "no" | "unknown";
|
||
|
||
/** 仅当明确 yes/no;弱网/5xx/服务端 uncertain 一律 unknown,禁止用来清空本地 VIP */
|
||
function probeFromCheckByUserBody(
|
||
data: Record<string, unknown> | null,
|
||
resOk: boolean
|
||
): VipProbeResult {
|
||
if (!resOk || !data || typeof data !== "object") return "unknown";
|
||
if (data.uncertain === true) return "unknown";
|
||
if (data.vip === true) return "yes";
|
||
if (data.ok === true && data.vip === false) return "no";
|
||
if (data.vip === false && data.ok !== false) return "no";
|
||
return "unknown";
|
||
}
|
||
|
||
async function probeCheckByUserId(userId: string): Promise<VipProbeResult> {
|
||
const trimmed = userId.trim();
|
||
if (!trimmed) return "unknown";
|
||
try {
|
||
const res = await fetch("/api/vip/check-by-user", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ user_id: trimmed }),
|
||
credentials: "same-origin",
|
||
cache: "no-store",
|
||
});
|
||
const data = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||
return probeFromCheckByUserBody(data, res.ok);
|
||
} catch {
|
||
return "unknown";
|
||
}
|
||
}
|
||
|
||
async function probeSessionVip(): Promise<VipProbeResult> {
|
||
try {
|
||
const res = await fetch("/api/vip/check", {
|
||
credentials: "include",
|
||
cache: "no-store",
|
||
});
|
||
const data = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||
if (!res.ok) return "unknown";
|
||
if (!data || typeof data !== "object") return "unknown";
|
||
if (data.adminUnavailable === true) return "unknown";
|
||
if (data.uncertain === true) return "unknown";
|
||
if (data.vip === true) return "yes";
|
||
return "no";
|
||
} catch {
|
||
return "unknown";
|
||
}
|
||
}
|
||
|
||
/** 仅在本组件内使用(由 page.tsx dynamic ssr:false 仅客户端挂载,首帧可读 localStorage) */
|
||
function readVipBootstrapState(): {
|
||
paidType: string | null;
|
||
userName: string | null;
|
||
userEmail: string | null;
|
||
} {
|
||
if (typeof window === "undefined") {
|
||
return { paidType: null, userName: null, userEmail: null };
|
||
}
|
||
const localType = localStorage.getItem("paidType");
|
||
const localUserName = localStorage.getItem("userName");
|
||
const localUserEmail = localStorage.getItem("userEmail");
|
||
const localUserId = localStorage.getItem("userId");
|
||
if (
|
||
localType === "vip" &&
|
||
(localUserName || localUserEmail) &&
|
||
localUserId
|
||
) {
|
||
return {
|
||
paidType: localType,
|
||
userName: localUserEmail || localUserName || "",
|
||
userEmail:
|
||
localUserEmail ||
|
||
(localUserName?.includes("@") ? localUserName : null),
|
||
};
|
||
}
|
||
return { paidType: null, userName: null, userEmail: null };
|
||
}
|
||
|
||
/**
|
||
* 首页:首帧以 localStorage 为准展示 VIP 控制台或访客落地页;init 仅后台同步/校验,
|
||
* 仅在服务端明确「非 VIP」等异常时更新状态(见 verifyInBackground / probe)。
|
||
*/
|
||
export default function HomeClient() {
|
||
const [paidType, setPaidType] = useState<string | null>(
|
||
() => readVipBootstrapState().paidType
|
||
);
|
||
const [userName, setUserName] = useState<string | null>(
|
||
() => readVipBootstrapState().userName
|
||
);
|
||
const [userEmail, setUserEmail] = useState<string | null>(
|
||
() => readVipBootstrapState().userEmail
|
||
);
|
||
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(null);
|
||
const [wechatNick, setWechatNick] = useState<string | null>(null);
|
||
const [wechatAvatar, setWechatAvatar] = useState<string | null>(null);
|
||
|
||
const savePaidType = useCallback((type: string | null) => {
|
||
if (type) setStorage("paidType", type);
|
||
else removeStorage("paidType");
|
||
setPaidType(type);
|
||
}, []);
|
||
|
||
const saveUserName = useCallback((name: string) => {
|
||
setStorage("userName", name);
|
||
setUserName(name);
|
||
}, []);
|
||
|
||
const recoverAnonymousUserId = useCallback(
|
||
async (params: { userId?: string; email?: string; wechatUserId?: string }) => {
|
||
const payload: Record<string, string> = {};
|
||
if (params.userId?.trim()) payload.user_id = params.userId.trim();
|
||
if (params.email?.trim()) payload.email = params.email.trim().toLowerCase();
|
||
if (params.wechatUserId?.trim()) payload.wechat_userid = params.wechatUserId.trim();
|
||
if (!payload.user_id && !payload.email && !payload.wechat_userid) return null;
|
||
|
||
try {
|
||
const res = await fetch("/api/vip/identity-diagnose", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
credentials: "same-origin",
|
||
cache: "no-store",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
const candidate = [
|
||
data?.resolved?.linked_anonymous_user_id,
|
||
data?.vip_email_link?.by_anonymous_user_id?.anonymous_user_id,
|
||
data?.vip_email_link?.by_email?.anonymous_user_id,
|
||
data?.payments?.by_user_id?.user_id,
|
||
data?.payments?.by_linked_user_id?.user_id,
|
||
].find((x) => typeof x === "string" && /^user\d+$/.test(x.trim()));
|
||
return typeof candidate === "string" ? candidate.trim() : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
[]
|
||
);
|
||
|
||
const syncWechatProfile = useCallback(async () => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const urlWechatUserId =
|
||
params.get("userid")?.trim() ||
|
||
params.get("wechat_userid")?.trim() ||
|
||
"";
|
||
if (urlWechatUserId) {
|
||
// 兼容历史字段:userid(微信私信真实用户ID)
|
||
setStorage("userid", urlWechatUserId);
|
||
setStorage("wechatUserId", urlWechatUserId);
|
||
// 安全要求:落本地后立即去掉 URL 中敏感 userid 参数,避免被转发扩散
|
||
const cleanPath = window.location.pathname || "/";
|
||
window.location.replace(cleanPath);
|
||
return;
|
||
}
|
||
const wechatUserId =
|
||
(getStorage("wechatUserId") || "").trim() ||
|
||
(getStorage("userid") || "").trim();
|
||
if (!wechatUserId) return;
|
||
|
||
const userId = getStorage("userId") || "";
|
||
const userEmail = (getStorage("userEmail") || "").trim().toLowerCase();
|
||
try {
|
||
const res = await fetch("/api/vip/wechat-profile", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
user_id: userId || undefined,
|
||
email: userEmail || undefined,
|
||
userid: wechatUserId,
|
||
wechat_userid: wechatUserId,
|
||
}),
|
||
credentials: "same-origin",
|
||
cache: "no-store",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
const profile = data?.profile || null;
|
||
const relations = data?.relations || null;
|
||
const nickname =
|
||
typeof profile?.nickname === "string" ? profile.nickname.trim() : "";
|
||
const avatar =
|
||
typeof profile?.avatar === "string" ? profile.avatar.trim() : "";
|
||
const linkedAnonymousUserId =
|
||
typeof relations?.linked_anonymous_user_id === "string"
|
||
? relations.linked_anonymous_user_id.trim()
|
||
: typeof relations?.anonymous_user_id === "string"
|
||
? relations.anonymous_user_id.trim()
|
||
: "";
|
||
const linkedPbUserId =
|
||
typeof relations?.linked_pb_user_id === "string"
|
||
? relations.linked_pb_user_id.trim()
|
||
: typeof relations?.pb_user_id === "string"
|
||
? relations.pb_user_id.trim()
|
||
: "";
|
||
const relationUserId =
|
||
typeof relations?.user_id === "string" ? relations.user_id.trim() : "";
|
||
const linkedEmail =
|
||
typeof relations?.linked_email === "string"
|
||
? relations.linked_email.trim().toLowerCase()
|
||
: typeof relations?.email === "string"
|
||
? relations.email.trim().toLowerCase()
|
||
: "";
|
||
const hasVipFromRelation =
|
||
relations?.has_vip_by_user_id === true ||
|
||
relations?.has_vip_by_pb_user === true;
|
||
if (/^user\d+$/.test(linkedAnonymousUserId)) {
|
||
setStorage("userId", linkedAnonymousUserId);
|
||
setStorage("memosAccount", linkedAnonymousUserId);
|
||
setAnonymousUserCookie(linkedAnonymousUserId);
|
||
}
|
||
// 关键修复:若 wechat_profile 已确认关联到 VIP 用户,立即回填关联 user_id 并进入 VIP 态
|
||
if (hasVipFromRelation) {
|
||
const resolvedVipUserId =
|
||
linkedPbUserId || relationUserId || linkedAnonymousUserId;
|
||
if (resolvedVipUserId) {
|
||
setStorage("userId", resolvedVipUserId);
|
||
if (/^user\d+$/.test(resolvedVipUserId)) {
|
||
setStorage("memosAccount", resolvedVipUserId);
|
||
setAnonymousUserCookie(resolvedVipUserId);
|
||
}
|
||
savePaidType("vip");
|
||
if (linkedEmail) {
|
||
setStorage("userEmail", linkedEmail);
|
||
setStorage("userName", linkedEmail);
|
||
setStorage("emailLinked", "1");
|
||
setUserEmail(linkedEmail);
|
||
saveUserName(linkedEmail);
|
||
} else if (nickname) {
|
||
saveUserName(nickname);
|
||
} else {
|
||
saveUserName(resolvedVipUserId);
|
||
}
|
||
}
|
||
}
|
||
if (nickname) {
|
||
setWechatNick(nickname);
|
||
setStorage("wechatNick", nickname);
|
||
} else {
|
||
setWechatNick(null);
|
||
removeStorage("wechatNick");
|
||
}
|
||
if (avatar) {
|
||
setWechatAvatar(avatar);
|
||
setStorage("wechatAvatar", avatar);
|
||
} else {
|
||
setWechatAvatar(null);
|
||
removeStorage("wechatAvatar");
|
||
}
|
||
} catch {
|
||
// 忽略失败,不影响主流程
|
||
}
|
||
}, [savePaidType, saveUserName]);
|
||
|
||
const getStoredAnonymousUserId = useCallback((): string | null => {
|
||
const memosAccount = getStorage("memosAccount");
|
||
if (memosAccount && /^user\d+$/.test(memosAccount)) {
|
||
return memosAccount;
|
||
}
|
||
|
||
const userId = getStorage("userId");
|
||
if (userId && /^user\d+$/.test(userId)) {
|
||
return userId;
|
||
}
|
||
|
||
const cookieUserId = getCookie(ANONYMOUS_USER_COOKIE);
|
||
if (cookieUserId && /^user\d+$/.test(cookieUserId)) {
|
||
setStorage("memosAccount", cookieUserId);
|
||
return cookieUserId;
|
||
}
|
||
|
||
return null;
|
||
}, []);
|
||
|
||
const applyRecoveredEmailSession = useCallback(
|
||
async (
|
||
data: {
|
||
token?: string;
|
||
record?: { id?: string; email?: string };
|
||
anonymousUserId?: string | null;
|
||
memosAccount?: string | null;
|
||
},
|
||
normalizedEmail: string,
|
||
fallbackAnonymousUserId?: string
|
||
) => {
|
||
if (!data?.token || !data?.record?.id) {
|
||
return false;
|
||
}
|
||
|
||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||
pbSaveAuth(data.token, { id: data.record.id, email: data.record.email ?? normalizedEmail });
|
||
|
||
const anonymousUserId =
|
||
(typeof data?.anonymousUserId === "string" &&
|
||
/^user\d+$/.test(data.anonymousUserId)
|
||
? data.anonymousUserId
|
||
: typeof data?.memosAccount === "string" &&
|
||
/^user\d+$/.test(data.memosAccount)
|
||
? data.memosAccount
|
||
: /^user\d+$/.test(fallbackAnonymousUserId?.trim() || "")
|
||
? fallbackAnonymousUserId?.trim()
|
||
: "") || "";
|
||
|
||
await fetch("/api/auth/sync-session", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
token: data.token,
|
||
record: data.record,
|
||
anonymousUserId: anonymousUserId || undefined,
|
||
}),
|
||
credentials: "include",
|
||
});
|
||
|
||
setStorage("userId", data.record.id);
|
||
setStorage("userEmail", normalizedEmail);
|
||
setStorage("userName", normalizedEmail);
|
||
setStorage("emailLinked", "1");
|
||
if (anonymousUserId) {
|
||
setStorage("memosAccount", anonymousUserId);
|
||
setAnonymousUserCookie(anonymousUserId);
|
||
}
|
||
|
||
savePaidType("vip");
|
||
setUserEmail(normalizedEmail);
|
||
saveUserName(normalizedEmail);
|
||
setStorage(EMAIL_VERIFY_TS_KEY, String(Date.now()));
|
||
return true;
|
||
},
|
||
[savePaidType, saveUserName]
|
||
);
|
||
|
||
const recoverLinkedVipSession = useCallback(
|
||
async (
|
||
email: string,
|
||
password: string,
|
||
user_id?: string
|
||
): Promise<{ ok: boolean; error?: string }> => {
|
||
try {
|
||
const normalizedEmail = email.trim().toLowerCase();
|
||
const wechatUserId =
|
||
(getStorage("wechatUserId") || "").trim() ||
|
||
(getStorage("userid") || "").trim();
|
||
const res = await fetch("/api/vip/check-by-email", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
email: normalizedEmail,
|
||
password,
|
||
...(user_id?.trim() && { user_id: user_id.trim() }),
|
||
...(wechatUserId && { wechat_userid: wechatUserId }),
|
||
}),
|
||
credentials: "include",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!data?.vip || !data?.token || !data?.record) {
|
||
return {
|
||
ok: false,
|
||
error:
|
||
data?.error ||
|
||
"未找到关联的会员记录,请确认已支付并绑定邮箱",
|
||
};
|
||
}
|
||
|
||
const ok = await applyRecoveredEmailSession(
|
||
data,
|
||
normalizedEmail,
|
||
user_id
|
||
);
|
||
return ok ? { ok: true } : { ok: false, error: "登录恢复失败" };
|
||
} catch (error) {
|
||
const msg = error instanceof Error ? error.message : "验证失败";
|
||
return { ok: false, error: msg };
|
||
}
|
||
},
|
||
[applyRecoveredEmailSession]
|
||
);
|
||
|
||
const getOrCreateUserId = useCallback(async () => {
|
||
const anonymousUserId = getStoredAnonymousUserId();
|
||
if (anonymousUserId) {
|
||
setStorage("userId", anonymousUserId);
|
||
setAnonymousUserCookie(anonymousUserId);
|
||
return anonymousUserId;
|
||
}
|
||
try {
|
||
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||
const meData = await meRes.json();
|
||
if (meData?.user?.id) {
|
||
const pbUserId = meData.user.id;
|
||
setStorage("userId", pbUserId);
|
||
return pbUserId;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
let userId = getStorage("userId");
|
||
if (userId?.startsWith("{") && userId.includes("data")) {
|
||
try {
|
||
const parsed = JSON.parse(userId) as { data?: string };
|
||
if (parsed?.data) {
|
||
userId = parsed.data;
|
||
setStorage("userId", userId);
|
||
}
|
||
} catch {
|
||
userId = null;
|
||
}
|
||
}
|
||
if (!userId) {
|
||
userId = `user${Date.now()}`;
|
||
setStorage("userId", userId);
|
||
}
|
||
return userId;
|
||
}, [getStoredAnonymousUserId]);
|
||
|
||
/** user_id 验证:调用后端 API(优先 payjsapi,回退 PocketBase) */
|
||
const checkVerifyByUserId = useCallback(
|
||
async (userId: string): Promise<boolean> => {
|
||
try {
|
||
const normalizedUserId = userId.trim();
|
||
const res = await fetch("/api/vip/check-by-user", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ user_id: normalizedUserId }),
|
||
credentials: "same-origin",
|
||
cache: "no-store",
|
||
});
|
||
const data = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||
const probe = probeFromCheckByUserBody(data, res.ok);
|
||
if (probe === "unknown") {
|
||
return false;
|
||
}
|
||
if (probe === "no") {
|
||
savePaidType(null);
|
||
return false;
|
||
}
|
||
if (!data?.vip) {
|
||
return false;
|
||
}
|
||
setStorage("userId", normalizedUserId);
|
||
if (/^user\d+$/.test(normalizedUserId)) {
|
||
setStorage("memosAccount", normalizedUserId);
|
||
setAnonymousUserCookie(normalizedUserId);
|
||
}
|
||
savePaidType("vip");
|
||
if (data?.alreadyLinked && data?.email) {
|
||
const linkedEmail = String(data.email).trim().toLowerCase();
|
||
setStorage("emailLinked", "1");
|
||
setStorage("userEmail", linkedEmail);
|
||
setStorage("userName", linkedEmail);
|
||
setUserEmail(linkedEmail);
|
||
saveUserName(linkedEmail);
|
||
await recoverLinkedVipSession(
|
||
linkedEmail,
|
||
DEFAULT_EMAIL_PASSWORD,
|
||
normalizedUserId
|
||
).catch(() => {});
|
||
} else {
|
||
const linked = getStorage("emailLinked") === "1";
|
||
const hasEmail = getStorage("userEmail") || getStorage("userName")?.includes("@");
|
||
if (!linked && !hasEmail) saveUserName(normalizedUserId);
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
console.error("验证失败:", err);
|
||
return false;
|
||
}
|
||
},
|
||
[recoverLinkedVipSession, savePaidType, saveUserName]
|
||
);
|
||
|
||
const checkPaymentFromPB = checkVerifyByUserId;
|
||
|
||
const tryRecoverByAnonymousUserId = useCallback(
|
||
async (anonymousUserId: string): Promise<boolean> => {
|
||
if (!anonymousUserId || !/^user\d+$/.test(anonymousUserId)) {
|
||
return false;
|
||
}
|
||
|
||
const firstHit = await checkPaymentFromPB(anonymousUserId);
|
||
if (firstHit) {
|
||
return true;
|
||
}
|
||
|
||
const isWechat = getDeviceFromEnv(getPayEnv()) === "wechat";
|
||
if (!isWechat) {
|
||
return false;
|
||
}
|
||
|
||
// WeChat can close page immediately after pay success; notify write may lag.
|
||
for (let attempt = 0; attempt < 4; attempt++) {
|
||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||
const recovered = await checkPaymentFromPB(anonymousUserId);
|
||
if (recovered) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
},
|
||
[checkPaymentFromPB]
|
||
);
|
||
|
||
/** 会员账号恢复:用用户输入的邮箱+密码调后端查库验证 VIP,可选 user_id 用于自动关联 */
|
||
const checkVerifyByEmail = useCallback(
|
||
async (
|
||
email: string,
|
||
password: string,
|
||
user_id?: string
|
||
): Promise<{ ok: boolean; error?: string }> => {
|
||
return recoverLinkedVipSession(email, password, user_id);
|
||
},
|
||
[recoverLinkedVipSession]
|
||
);
|
||
|
||
const clearAllStorage = useCallback(() => {
|
||
try {
|
||
if (typeof window === "undefined") return;
|
||
try {
|
||
localStorage.clear();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
clearAnonymousUserCookie();
|
||
clearPendingOrder();
|
||
setPaidType(null);
|
||
setUserName(null);
|
||
const path = window.location.pathname || "/";
|
||
window.location.replace(path);
|
||
} catch (error) {
|
||
console.error("清空存储失败:", error);
|
||
}
|
||
}, []);
|
||
|
||
const handlePay = useCallback(() => {
|
||
triggerPay(setPayPendingOrderId);
|
||
}, []);
|
||
|
||
const handleVerifySuccess = useCallback(() => {
|
||
window.location.reload();
|
||
}, []);
|
||
|
||
const env = getPayEnv();
|
||
const device = getDeviceFromEnv(env);
|
||
const isH5 = device === "h5";
|
||
usePayStatusPoll(
|
||
async (orderId) => {
|
||
let uid = getStorage("userId");
|
||
if (!uid && orderId?.includes("_order_")) {
|
||
const prefix = orderId.split("_order_")[0];
|
||
const parts = prefix.split("_");
|
||
if (parts.length >= 2) uid = parts.slice(0, -1).join("_");
|
||
}
|
||
if (uid) {
|
||
let confirmed = false;
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
try {
|
||
const confirmRes = await fetch("/api/pay/confirm", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ order_id: orderId, user_id: uid }),
|
||
});
|
||
const confirmData = await confirmRes.json().catch(() => ({}));
|
||
if (confirmData?.ok) {
|
||
confirmed = true;
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
console.error("pay/confirm request error:", e);
|
||
}
|
||
if (attempt < 2) await new Promise((r) => setTimeout(r, 1000));
|
||
}
|
||
if (confirmed) {
|
||
saveUserName(uid);
|
||
savePaidType("vip");
|
||
}
|
||
/* confirm 失败时不设置 paidType,避免显示 VIP 但 PB/Memos 无记录 */
|
||
}
|
||
clearPendingOrder();
|
||
if (isH5) setPayPendingOrderId(null);
|
||
window.location.reload();
|
||
},
|
||
() => {
|
||
clearPendingOrder();
|
||
if (isH5) setPayPendingOrderId(null);
|
||
},
|
||
isH5 ? payPendingOrderId ?? undefined : undefined
|
||
);
|
||
|
||
useEffect(() => {
|
||
const nick = getStorage("wechatNick");
|
||
const avatar = getStorage("wechatAvatar");
|
||
if (nick) setWechatNick(nick);
|
||
if (avatar) setWechatAvatar(avatar);
|
||
void syncWechatProfile();
|
||
}, [syncWechatProfile, paidType, userEmail]);
|
||
|
||
useEffect(() => {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
if (urlParams.get("type")?.trim() === "clean") {
|
||
clearAllStorage();
|
||
return;
|
||
}
|
||
/* paid=1 来自 URL 时不再无条件设置,需通过 confirm 或 PB 校验 */
|
||
|
||
const init = async () => {
|
||
const pendingOrderId = getPendingOrderId();
|
||
// 已有 VIP 时忽略陈旧支付轮询标记,否则会 early return,init 从不恢复 VIP,表现为「登录后仍显示未解锁」
|
||
if (pendingOrderId) {
|
||
const alreadyVip = getStorage("paidType") === "vip";
|
||
if (alreadyVip) {
|
||
clearPendingOrder();
|
||
} else {
|
||
setPayPendingOrderId(pendingOrderId);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 本地已有 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) {
|
||
// 立即展示 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 */ }
|
||
}
|
||
|
||
// 后台悄悄校验:仅当服务端明确返回「非 VIP」才清本地;弱网/异常/uncertain 一律保留(避免微信内误踢)
|
||
const verifyInBackground = async () => {
|
||
const isAnonymous = /^user\d+$/.test(localUserId);
|
||
const probe = isAnonymous
|
||
? await probeCheckByUserId(localUserId)
|
||
: await probeSessionVip();
|
||
if (probe !== "no") {
|
||
return;
|
||
}
|
||
removeStorage("paidType");
|
||
removeStorage("userName");
|
||
removeStorage("userEmail");
|
||
savePaidType(null);
|
||
setUserName(null);
|
||
setUserEmail(null);
|
||
};
|
||
void verifyInBackground();
|
||
return;
|
||
}
|
||
|
||
// 校验逻辑:有本地存储(登录资料+VIP)时优先使用并校验;无身份/VIP 时点击按钮直接进入支付,不校验
|
||
const wechatUserId =
|
||
(getStorage("wechatUserId") || "").trim() ||
|
||
(getStorage("userid") || "").trim();
|
||
let anonymousCandidate = getStoredAnonymousUserId();
|
||
if (!anonymousCandidate && wechatUserId) {
|
||
const recoveredFromWechat = await recoverAnonymousUserId({
|
||
wechatUserId,
|
||
email: (getStorage("userEmail") || "").trim().toLowerCase(),
|
||
userId: (getStorage("userId") || "").trim(),
|
||
});
|
||
if (recoveredFromWechat && /^user\d+$/.test(recoveredFromWechat)) {
|
||
anonymousCandidate = recoveredFromWechat;
|
||
setStorage("userId", recoveredFromWechat);
|
||
setStorage("memosAccount", recoveredFromWechat);
|
||
setAnonymousUserCookie(recoveredFromWechat);
|
||
}
|
||
}
|
||
const hadStoredUserId = !!anonymousCandidate;
|
||
const emailVerifiedAt = Number(getStorage(EMAIL_VERIFY_TS_KEY) || "0");
|
||
const skipAnonymousRecover =
|
||
!!(getStorage("emailLinked") === "1" && Date.now() - emailVerifiedAt < EMAIL_VERIFY_COOLDOWN_MS);
|
||
if (anonymousCandidate && !skipAnonymousRecover) {
|
||
const recovered = await tryRecoverByAnonymousUserId(anonymousCandidate);
|
||
if (recovered) {
|
||
setStorage("userId", anonymousCandidate);
|
||
setStorage("memosAccount", anonymousCandidate);
|
||
setAnonymousUserCookie(anonymousCandidate);
|
||
const name = getStorage("userEmail") || getStorage("userName") || anonymousCandidate;
|
||
setUserName(name);
|
||
setUserEmail(name.includes("@") ? name : getStorage("userEmail"));
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 无匿名 userId 时,若存在真实 wechat userid,也要尝试直接按该 ID 查 VIP
|
||
// 场景:该 userid 本身就是支付链路 user_id,或者已在后端可关联到 VIP
|
||
if (!anonymousCandidate && wechatUserId) {
|
||
const recoveredByWechatUserId = await checkPaymentFromPB(wechatUserId);
|
||
if (recoveredByWechatUserId) {
|
||
setStorage("userId", wechatUserId);
|
||
const name =
|
||
getStorage("wechatNick") ||
|
||
getStorage("userEmail") ||
|
||
getStorage("userName") ||
|
||
wechatUserId;
|
||
setUserName(name);
|
||
setUserEmail(
|
||
(getStorage("userEmail") || "").includes("@")
|
||
? getStorage("userEmail")
|
||
: null
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
await getOrCreateUserId();
|
||
|
||
const afterLocalType = getStorage("paidType");
|
||
const afterLocalUserName = getStorage("userName");
|
||
const afterLocalUserEmail = getStorage("userEmail");
|
||
if (afterLocalType && (afterLocalUserName || afterLocalUserEmail)) {
|
||
setPaidType(afterLocalType);
|
||
setUserName(afterLocalUserEmail || afterLocalUserName || "");
|
||
setUserEmail(afterLocalUserEmail || (afterLocalUserName?.includes("@") ? afterLocalUserName : null));
|
||
return;
|
||
}
|
||
|
||
// 检查本站 Cookie 登录态
|
||
try {
|
||
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||
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 || null);
|
||
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
|
||
const vipData = await vipRes.json();
|
||
if (vipData?.vip) {
|
||
// 匿名 id 不覆盖;仅当当前无匿名 id 时才写入(可能是纯邮箱登录)
|
||
const cur = getStorage("userId");
|
||
if (cur && /^user\d+$/.test(cur)) {
|
||
setStorage("memosAccount", cur);
|
||
} else if (
|
||
typeof meData.user.anonymousUserId === "string" &&
|
||
/^user\d+$/.test(meData.user.anonymousUserId)
|
||
) {
|
||
setStorage("memosAccount", meData.user.anonymousUserId);
|
||
}
|
||
const wechatUserId =
|
||
(getStorage("wechatUserId") || "").trim() ||
|
||
(getStorage("userid") || "").trim();
|
||
const recoveredAnonymousUserId =
|
||
(cur && /^user\d+$/.test(cur) ? cur : "") ||
|
||
(typeof meData.user.anonymousUserId === "string" &&
|
||
/^user\d+$/.test(meData.user.anonymousUserId)
|
||
? meData.user.anonymousUserId.trim()
|
||
: "") ||
|
||
((await recoverAnonymousUserId({
|
||
userId: cur || meData.user.id || "",
|
||
email: meData.user.email || "",
|
||
wechatUserId,
|
||
})) ||
|
||
"");
|
||
if (/^user\d+$/.test(recoveredAnonymousUserId)) {
|
||
setStorage("userId", recoveredAnonymousUserId);
|
||
setStorage("memosAccount", recoveredAnonymousUserId);
|
||
setAnonymousUserCookie(recoveredAnonymousUserId);
|
||
} else {
|
||
setStorage("userId", meData.user.id);
|
||
}
|
||
setStorage("emailLinked", "1");
|
||
if (meData.user.email) {
|
||
setStorage("userEmail", meData.user.email);
|
||
}
|
||
savePaidType("vip");
|
||
saveUserName(meData.user.email || meData.user.id);
|
||
return;
|
||
}
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
|
||
// 仅当有本地存储的 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);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
void init();
|
||
}, [
|
||
clearAllStorage,
|
||
getOrCreateUserId,
|
||
getStoredAnonymousUserId,
|
||
checkPaymentFromPB,
|
||
recoverAnonymousUserId,
|
||
savePaidType,
|
||
saveUserName,
|
||
tryRecoverByAnonymousUserId,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!isH5) return;
|
||
|
||
const syncPendingOrder = () => {
|
||
const pendingOrderId = getPendingOrderId();
|
||
if (!pendingOrderId) return;
|
||
setPayPendingOrderId((current) =>
|
||
current === pendingOrderId ? current : pendingOrderId
|
||
);
|
||
};
|
||
|
||
const onVisible = () => {
|
||
if (document.visibilityState === "visible") {
|
||
syncPendingOrder();
|
||
}
|
||
};
|
||
|
||
syncPendingOrder();
|
||
document.addEventListener("visibilitychange", onVisible);
|
||
window.addEventListener("pageshow", syncPendingOrder);
|
||
window.addEventListener("focus", syncPendingOrder);
|
||
return () => {
|
||
document.removeEventListener("visibilitychange", onVisible);
|
||
window.removeEventListener("pageshow", syncPendingOrder);
|
||
window.removeEventListener("focus", syncPendingOrder);
|
||
};
|
||
}, [isH5]);
|
||
|
||
useEffect(() => {
|
||
const syncFromStorage = () => {
|
||
const localType = getStorage("paidType");
|
||
const localName = getStorage("userEmail") || getStorage("userName");
|
||
if (localType && localName && !paidType) {
|
||
setPaidType(localType);
|
||
setUserName(localName);
|
||
}
|
||
};
|
||
const onVisible = () => syncFromStorage();
|
||
const onPageShow = (e: PageTransitionEvent) => {
|
||
if (e.persisted) syncFromStorage();
|
||
};
|
||
if (typeof document !== "undefined" && document.addEventListener) {
|
||
document.addEventListener("visibilitychange", onVisible);
|
||
window.addEventListener("pageshow", onPageShow);
|
||
return () => {
|
||
document.removeEventListener("visibilitychange", onVisible);
|
||
window.removeEventListener("pageshow", onPageShow);
|
||
};
|
||
}
|
||
}, [paidType]);
|
||
|
||
const handleLogout = useCallback(async () => {
|
||
const { pbLogout } = await import("@/app/lib/pocketbase");
|
||
await pbLogout();
|
||
setUserEmail(null);
|
||
clearAllStorage();
|
||
}, [clearAllStorage]);
|
||
|
||
const isLoggedIn = paidType === "vip";
|
||
|
||
return (
|
||
<>
|
||
<VipLayout
|
||
isLoggedIn={isLoggedIn}
|
||
userName={userName}
|
||
userEmail={userEmail}
|
||
wechatNick={wechatNick}
|
||
wechatAvatar={wechatAvatar}
|
||
onLogout={handleLogout}
|
||
>
|
||
{isLoggedIn ? (
|
||
<DashboardPage userName={userName} userEmail={userEmail} />
|
||
) : (
|
||
<LandingPage
|
||
onJoin={handlePay}
|
||
onVerify={checkPaymentFromPB}
|
||
onVerifyByEmail={checkVerifyByEmail}
|
||
onVerifySuccess={handleVerifySuccess}
|
||
/>
|
||
)}
|
||
</VipLayout>
|
||
</>
|
||
);
|
||
}
|