This commit is contained in:
eric
2026-03-27 19:22:37 -05:00
parent 3eb0334114
commit e1ed5a56dc
4 changed files with 124 additions and 60 deletions

View File

@@ -85,6 +85,57 @@ function getPendingOrderId(): string | null {
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;
@@ -305,36 +356,45 @@ export default function HomeClient() {
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(() => ({}));
if (data?.vip) {
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;
const data = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const probe = probeFromCheckByUserBody(data, res.ok);
if (probe === "unknown") {
return false;
}
savePaidType(null);
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;
@@ -501,32 +561,21 @@ export default function HomeClient() {
} catch { /* ignore */ }
}
// 后台悄悄校验:user 被删或 VIP 被撤则清除本地存储并更新状态
// 后台悄悄校验:仅当服务端明确返回「非 VIP」才清本地弱网/异常/uncertain 一律保留(避免微信内误踢)
const verifyInBackground = async () => {
const isAnonymous = /^user\d+$/.test(localUserId);
let verified = false;
let skipClear = 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();
if (vipData?.adminUnavailable) {
skipClear = true;
} else {
verified = !!vipData?.vip;
}
}
} catch { /* ignore */ }
if (!verified && !skipClear) {
removeStorage("paidType");
removeStorage("userName");
removeStorage("userEmail");
savePaidType(null);
setUserName(null);
setUserEmail(null);
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;
@@ -704,7 +753,7 @@ export default function HomeClient() {
/>
</>
) : (
<div className="min-h-[50vh]" aria-hidden />
<div className="min-h-screen bg-[var(--background)]" aria-hidden />
)}
</VipLayout>
</>

View File

@@ -4,7 +4,9 @@ import dynamic from "next/dynamic";
const HomeClient = dynamic(() => import("./HomeClient"), {
ssr: false,
loading: () => <div className="min-h-screen bg-[var(--background)]" aria-hidden />,
loading: () => (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]" aria-hidden />
),
});
export function HomeClientLoader() {

View File

@@ -20,6 +20,8 @@ type UserCheckPayload = {
ok: boolean;
vip: boolean;
alreadyLinked: boolean;
/** 服务端异常/超时时为 true前端勿据此清空本地已登录 VIP */
uncertain?: boolean;
email?: string;
linkedUserId?: string;
};
@@ -292,18 +294,25 @@ export async function POST(request: NextRequest) {
};
} catch (error) {
console.error("[check-by-user] failed:", error);
// 接口容错:即使 PB 回退失败,也不抛 500 给前端
return { ok: true, vip: false, alreadyLinked: false };
// 勿伪装成「确定非 VIP」否则微信/H5 弱网会误清本地态
return {
ok: false,
vip: false,
alreadyLinked: false,
uncertain: true,
};
}
})();
inflightUserRequests.set(userId, task);
try {
const payload = await task;
userCheckResultCache.set(userId, {
payload,
expiresAt: Date.now() + USER_CHECK_CACHE_TTL_MS,
});
if (!payload.uncertain) {
userCheckResultCache.set(userId, {
payload,
expiresAt: Date.now() + USER_CHECK_CACHE_TTL_MS,
});
}
return NextResponse.json(payload);
} finally {
inflightUserRequests.delete(userId);

View File

@@ -86,6 +86,10 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ ok: true, vip: false });
} catch {
return NextResponse.json({ ok: true, vip: false });
return NextResponse.json({
ok: true,
vip: false,
uncertain: true,
});
}
}