This commit is contained in:
eric
2026-03-31 12:44:54 -05:00
parent 2ffbddff2e
commit 4497604242
10 changed files with 359 additions and 28 deletions

View File

@@ -180,6 +180,8 @@ export default function HomeClient() {
() => 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);
@@ -192,6 +194,110 @@ export default function HomeClient() {
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()
: "";
if (/^user\d+$/.test(linkedAnonymousUserId)) {
setStorage("userId", linkedAnonymousUserId);
setStorage("memosAccount", linkedAnonymousUserId);
setAnonymousUserCookie(linkedAnonymousUserId);
}
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 {
// 忽略失败,不影响主流程
}
}, []);
const getStoredAnonymousUserId = useCallback((): string | null => {
const memosAccount = getStorage("memosAccount");
if (memosAccount && /^user\d+$/.test(memosAccount)) {
@@ -278,6 +384,9 @@ export default function HomeClient() {
): 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" },
@@ -285,6 +394,7 @@ export default function HomeClient() {
email: normalizedEmail,
password,
...(user_id?.trim() && { user_id: user_id.trim() }),
...(wechatUserId && { wechat_userid: wechatUserId }),
}),
credentials: "include",
});
@@ -523,6 +633,14 @@ export default function HomeClient() {
isH5 ? payPendingOrderId ?? undefined : undefined
);
useEffect(() => {
const nick = getStorage("wechatNick");
const avatar = getStorage("wechatAvatar");
if (nick) setWechatNick(nick);
if (avatar) setWechatAvatar(avatar);
void syncWechatProfile();
}, [syncWechatProfile]);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("type")?.trim() === "clean") {
@@ -583,7 +701,23 @@ export default function HomeClient() {
}
// 校验逻辑:有本地存储(登录资料+VIP时优先使用并校验无身份/VIP 时点击按钮直接进入支付,不校验
const anonymousCandidate = getStoredAnonymousUserId();
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 =
@@ -634,7 +768,28 @@ export default function HomeClient() {
) {
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);
@@ -667,6 +822,7 @@ export default function HomeClient() {
getOrCreateUserId,
getStoredAnonymousUserId,
checkPaymentFromPB,
recoverAnonymousUserId,
savePaidType,
saveUserName,
tryRecoverByAnonymousUserId,
@@ -734,7 +890,14 @@ export default function HomeClient() {
return (
<>
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>
<VipLayout
isLoggedIn={isLoggedIn}
userName={userName}
userEmail={userEmail}
wechatNick={wechatNick}
wechatAvatar={wechatAvatar}
onLogout={handleLogout}
>
{isLoggedIn ? (
<DashboardPage userName={userName} userEmail={userEmail} />
) : (

View File

@@ -66,9 +66,10 @@ async function checkVipByEmailFromPayjsapi(
request: NextRequest,
email: string,
password: string,
userId?: string
userId?: string,
wechatUserId?: string
): Promise<PayjsVipEmailResult | null> {
const inflightKey = `${email}::${userId || ""}`;
const inflightKey = `${email}::${userId || ""}::${wechatUserId || ""}`;
const existed = inflightEmailChecks.get(inflightKey);
if (existed) {
return existed;
@@ -96,6 +97,7 @@ async function checkVipByEmailFromPayjsapi(
email,
password,
...(userId ? { user_id: userId } : {}),
...(wechatUserId ? { wechat_userid: wechatUserId } : {}),
}),
cache: "no-store",
signal: AbortSignal.timeout(PAYJS_CHECK_BY_EMAIL_TIMEOUT),
@@ -291,6 +293,9 @@ export async function POST(request: NextRequest) {
const requestedAnonymousUserId = String(
body?.user_id ?? body?.anonymousUserId ?? ""
).trim();
const wechatUserId = String(
body?.wechat_userid ?? body?.userid ?? ""
).trim();
console.log(
"[check-by-email] request email=%s user_id=%s",
@@ -326,7 +331,8 @@ export async function POST(request: NextRequest) {
request,
email,
password,
requestedAnonymousUserId || undefined
requestedAnonymousUserId || undefined,
wechatUserId || undefined
);
// PB 登录失败时,尝试使用 payjsapi 返回的 token/record 兜底恢复
if (payjsResult?.vip && payjsResult.token && payjsResult.record?.id) {
@@ -357,7 +363,8 @@ export async function POST(request: NextRequest) {
request,
email,
password,
requestedAnonymousUserId || undefined
requestedAnonymousUserId || undefined,
wechatUserId || undefined
);
}
if (payjsResult?.vip) {
@@ -397,7 +404,8 @@ export async function POST(request: NextRequest) {
request,
email,
password,
requestedAnonymousUserId || undefined
requestedAnonymousUserId || undefined,
wechatUserId || undefined
);
recovery = await resolveRecovery(
adminToken,
@@ -451,6 +459,20 @@ export async function POST(request: NextRequest) {
anonymousUserId
);
if (wechatUserId) {
const apiUrl = resolvePayApiUrl(request);
await fetch(`${apiUrl}/nomadvip/wechat_profile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: anonymousUserId || authData.record.id,
email,
wechat_userid: wechatUserId,
}),
cache: "no-store",
}).catch(() => null);
}
console.log(
"[check-by-email] success source=%s pbUserId=%s anonymousUserId=%s",
recovery.source ?? "pocketbase",

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config";
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) || payConfig.apiUrl
).replace(/\/$/, "");
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const apiUrl = resolvePayApiUrl(request);
const res = await fetch(`${apiUrl}/nomadvip/identity_diagnose`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
cache: "no-store",
}).catch(() => null);
if (!res) {
return NextResponse.json(
{ ok: false, error: "upstream unavailable" },
{ status: 502 }
);
}
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.ok ? 200 : 502 });
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config";
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) ||
payConfig.apiUrl
).replace(/\/$/, "");
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const apiUrl = resolvePayApiUrl(request);
const res = await fetch(`${apiUrl}/nomadvip/wechat_profile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
cache: "no-store",
}).catch(() => null);
if (!res) {
return NextResponse.json({ ok: false, found: false, error: "upstream unavailable" }, { status: 502 });
}
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.ok ? 200 : 502 });
}

View File

@@ -13,6 +13,7 @@ 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 VIP_PAY_TEST_MODE_KEY = "vipPayTestMode";
function getStorage(key: string): string | null {
if (typeof window === "undefined") return null;
@@ -114,6 +115,33 @@ async function getOrCreateUserId(): Promise<string> {
return syncId;
}
async function recoverAnonymousUserIdByWechatUserId(): Promise<string | null> {
const wechatUserId =
(getStorage("wechatUserId") || "").trim() ||
(getStorage("userid") || "").trim();
if (!wechatUserId) return null;
try {
const res = await fetch("/api/vip/identity-diagnose", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ wechat_userid: wechatUserId }),
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;
}
}
function redirectToPayZpay(params: {
user_id: string;
return_url: string;
@@ -248,8 +276,10 @@ function payWechatXorpaySync(params: {
export async function triggerPay(
onPendingOrder?: (orderId: string) => void
): Promise<void> {
const userId =
typeof navigator !== "undefined" && /MicroMessenger/i.test(navigator.userAgent)
const recoveredByWechat = await recoverAnonymousUserIdByWechatUserId();
const userId = recoveredByWechat
? recoveredByWechat
: typeof navigator !== "undefined" && /MicroMessenger/i.test(navigator.userAgent)
? getOrCreateUserIdSync()
: await getOrCreateUserId();
if (/^user\d+$/.test(userId)) {
@@ -260,7 +290,8 @@ export async function triggerPay(
const returnUrl = `${window.location.origin}/paid`;
const env = getPayEnv();
const device = getDeviceFromEnv(env);
const totalFee = getPaymentConfig().defaultAmount ?? 8800;
const isPayTestMode = getStorage(VIP_PAY_TEST_MODE_KEY) === "1";
const totalFee = isPayTestMode ? 100 : getPaymentConfig().defaultAmount ?? 8800;
const base = {
user_id: userId,
return_url: returnUrl,

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { LandingHero } from "./landing/LandingHero";
import { AssetStats } from "./landing/AssetStats";
import { TopicEbookShowcase } from "./landing/TopicEbookShowcase";
@@ -31,6 +31,22 @@ export function LandingPage({
}: LandingPageProps) {
const [loginModalOpen, setLoginModalOpen] = useState(false);
const [promoModalOpen, setPromoModalOpen] = useState(false);
const [payTestMode, setPayTestMode] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("vipPayTestMode") === "1";
});
const titleTapCountRef = useRef(0);
const handleHeroTitleTap = () => {
titleTapCountRef.current += 1;
if (titleTapCountRef.current < 10) return;
titleTapCountRef.current = 0;
const nextMode = !payTestMode;
setPayTestMode(nextMode);
if (typeof window !== "undefined") {
localStorage.setItem("vipPayTestMode", nextMode ? "1" : "0");
}
};
return (
<>
@@ -43,7 +59,7 @@ export function LandingPage({
<div className={styles.landingNoise} />
</div>
<div className={styles.landingSceneInner}>
<LandingHero />
<LandingHero onTitleTap={handleHeroTitleTap} />
<AssetStats />
<TopicEbookShowcase onLockedClick={() => setLoginModalOpen(true)} />
<CompareSection />
@@ -54,6 +70,7 @@ export function LandingPage({
<LandingCTA
onJoin={onJoin}
onLogin={() => setLoginModalOpen(true)}
payTestMode={payTestMode}
/>
</div>
</div>

View File

@@ -17,10 +17,19 @@ type VipHeaderProps = {
isLoggedIn: boolean;
userName?: string | null;
userEmail?: string | null;
wechatNick?: string | null;
wechatAvatar?: string | null;
onLogout?: () => void;
};
export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHeaderProps) {
export function VipHeader({
isLoggedIn,
userName,
userEmail,
wechatNick,
wechatAvatar,
onLogout,
}: VipHeaderProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -38,8 +47,9 @@ export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHead
onLogout?.();
};
const showUser = isLoggedIn || userEmail;
const displayName = userName || userEmail || "会员";
const hasWechatProfile = !!(wechatNick || wechatAvatar);
const showUser = isLoggedIn || !!userEmail || hasWechatProfile;
const displayName = wechatNick || userName || userEmail || "会员";
return (
<header className={styles.header}>
@@ -54,10 +64,15 @@ export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHead
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-500 text-sm font-semibold text-white shadow-sm hover:bg-amber-600"
className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-amber-500 text-sm font-semibold text-white shadow-sm hover:bg-amber-600"
aria-label="用户菜单"
>
{getAvatarLetter(displayName)}
{wechatAvatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={wechatAvatar} alt={displayName} className="h-full w-full object-cover" />
) : (
getAvatarLetter(displayName)
)}
</button>
{isLoggedIn && (
<span

View File

@@ -9,13 +9,30 @@ type VipLayoutProps = {
isLoggedIn: boolean;
userName?: string | null;
userEmail?: string | null;
wechatNick?: string | null;
wechatAvatar?: string | null;
onLogout?: () => void;
};
export function VipLayout({ children, isLoggedIn, userName, userEmail, onLogout }: VipLayoutProps) {
export function VipLayout({
children,
isLoggedIn,
userName,
userEmail,
wechatNick,
wechatAvatar,
onLogout,
}: VipLayoutProps) {
return (
<div className={styles.root}>
<VipHeader isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={onLogout} />
<VipHeader
isLoggedIn={isLoggedIn}
userName={userName}
userEmail={userEmail}
wechatNick={wechatNick}
wechatAvatar={wechatAvatar}
onLogout={onLogout}
/>
<main className={styles.main}>{children}</main>
</div>
);

View File

@@ -1,22 +1,20 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { formatRestoreDateShort } from "@/config/promo.config";
import styles from "../vip.module.css";
type LandingCTAProps = {
onJoin: () => void;
onLogin: () => void;
payTestMode?: boolean;
};
/** 设为 true 可重新展示「适合你 / 请止步」区块(下方 JSX 与 vip.module.css 内样式均保留) */
const SHOW_CTA_AUDIENCE_BOUNDARY = false;
export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
const [restoreDate, setRestoreDate] = useState<string>("");
useEffect(() => {
setRestoreDate(formatRestoreDateShort());
}, []);
export function LandingCTA({ onJoin, onLogin, payTestMode }: LandingCTAProps) {
const [restoreDate] = useState<string>(() => formatRestoreDateShort());
return (
<section className={`${styles.section} ${styles.ctaSection}`} aria-labelledby="cta-heading">
@@ -74,6 +72,7 @@ export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
type="button"
onClick={onJoin}
className={`${styles.btnPrimary} ${styles.btnLarge} ${styles.ctaBtnPrimary}`}
style={payTestMode ? { background: "linear-gradient(135deg,#16a34a,#22c55e)" } : undefined}
>
🔒
</button>

View File

@@ -2,12 +2,18 @@
import styles from "../vip.module.css";
export function LandingHero() {
type LandingHeroProps = {
onTitleTap?: () => void;
};
export function LandingHero({ onTitleTap }: LandingHeroProps) {
return (
<section className={`${styles.section} ${styles.heroSection} ${styles.heroSection3d}`} style={{ textAlign: "center" }}>
<p
className={`${styles.heroEyebrow} animate__animated animate__fadeInDown`}
style={{ animationDelay: "0.1s", animationFillMode: "both" }}
style={{ animationDelay: "0.1s", animationFillMode: "both", cursor: "pointer" }}
onClick={onTitleTap}
title="连续点击 10 次切换测试价格"
>
</p>