diff --git a/app/HomeClient.tsx b/app/HomeClient.tsx index ea56ffc..cbde202 100644 --- a/app/HomeClient.tsx +++ b/app/HomeClient.tsx @@ -180,6 +180,8 @@ export default function HomeClient() { () => readVipBootstrapState().userEmail ); const [payPendingOrderId, setPayPendingOrderId] = useState(null); + const [wechatNick, setWechatNick] = useState(null); + const [wechatAvatar, setWechatAvatar] = useState(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 = {}; + 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); } - setStorage("userId", meData.user.id); + 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 ( <> - + {isLoggedIn ? ( ) : ( diff --git a/app/api/vip/check-by-email/route.ts b/app/api/vip/check-by-email/route.ts index 3af278c..79f3504 100644 --- a/app/api/vip/check-by-email/route.ts +++ b/app/api/vip/check-by-email/route.ts @@ -66,9 +66,10 @@ async function checkVipByEmailFromPayjsapi( request: NextRequest, email: string, password: string, - userId?: string + userId?: string, + wechatUserId?: string ): Promise { - 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", diff --git a/app/api/vip/identity-diagnose/route.ts b/app/api/vip/identity-diagnose/route.ts new file mode 100644 index 0000000..0bac68c --- /dev/null +++ b/app/api/vip/identity-diagnose/route.ts @@ -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 }); +} + diff --git a/app/api/vip/wechat-profile/route.ts b/app/api/vip/wechat-profile/route.ts new file mode 100644 index 0000000..6b7622f --- /dev/null +++ b/app/api/vip/wechat-profile/route.ts @@ -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 }); +} + diff --git a/app/lib/triggerPay.ts b/app/lib/triggerPay.ts index b570671..99a2c51 100644 --- a/app/lib/triggerPay.ts +++ b/app/lib/triggerPay.ts @@ -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 { return syncId; } +async function recoverAnonymousUserIdByWechatUserId(): Promise { + 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 { - 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, diff --git a/app/vip/components/LandingPage.tsx b/app/vip/components/LandingPage.tsx index 1f83d9d..28e0425 100644 --- a/app/vip/components/LandingPage.tsx +++ b/app/vip/components/LandingPage.tsx @@ -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({
- + setLoginModalOpen(true)} /> @@ -54,6 +70,7 @@ export function LandingPage({ setLoginModalOpen(true)} + payTestMode={payTestMode} />
diff --git a/app/vip/components/VipHeader.tsx b/app/vip/components/VipHeader.tsx index 0b26e61..4922a2a 100644 --- a/app/vip/components/VipHeader.tsx +++ b/app/vip/components/VipHeader.tsx @@ -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(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 (
@@ -54,10 +64,15 @@ export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHead {isLoggedIn && ( void; }; -export function VipLayout({ children, isLoggedIn, userName, userEmail, onLogout }: VipLayoutProps) { +export function VipLayout({ + children, + isLoggedIn, + userName, + userEmail, + wechatNick, + wechatAvatar, + onLogout, +}: VipLayoutProps) { return (
- +
{children}
); diff --git a/app/vip/components/landing/LandingCTA.tsx b/app/vip/components/landing/LandingCTA.tsx index 86231bf..ace27ff 100644 --- a/app/vip/components/landing/LandingCTA.tsx +++ b/app/vip/components/landing/LandingCTA.tsx @@ -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(""); - useEffect(() => { - setRestoreDate(formatRestoreDateShort()); - }, []); +export function LandingCTA({ onJoin, onLogin, payTestMode }: LandingCTAProps) { + const [restoreDate] = useState(() => formatRestoreDateShort()); return (
@@ -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} > 🔒 立即加入 diff --git a/app/vip/components/landing/LandingHero.tsx b/app/vip/components/landing/LandingHero.tsx index b05e117..ec1eaab 100644 --- a/app/vip/components/landing/LandingHero.tsx +++ b/app/vip/components/landing/LandingHero.tsx @@ -2,12 +2,18 @@ import styles from "../vip.module.css"; -export function LandingHero() { +type LandingHeroProps = { + onTitleTap?: () => void; +}; + +export function LandingHero({ onTitleTap }: LandingHeroProps) { return (

异度星球会员中心