From e7dde61e31b4a677f90f9f0f30c6c587bf8a76bc Mon Sep 17 00:00:00 2001 From: eric Date: Thu, 12 Mar 2026 03:54:22 -0500 Subject: [PATCH] 's' --- .../[moduleIndex]/[lessonIndex]/page.tsx | 4 +- app/[locale]/join/page.tsx | 31 +- app/api/auth/logout/route.ts | 10 + app/api/auth/me/route.ts | 61 +++ app/api/auth/sync-session/route.ts | 32 ++ app/api/join/route.ts | 15 +- app/api/pay/route.ts | 8 +- app/api/pay/status/route.ts | 2 +- app/api/vip/check/route.ts | 61 +++ app/components/AuthModal.tsx | 9 + app/components/Header.tsx | 23 +- app/components/Roadmap.tsx | 55 ++- app/components/course/CTASection.tsx | 35 +- app/components/course/CourseHero.tsx | 37 +- app/components/course/CurriculumSection.tsx | 4 +- app/lib/course-payment.ts | 29 +- app/lib/env.ts | 12 +- app/lib/pocketbase/auth.ts | 9 +- app/lib/useAuthAndVip.ts | 45 ++ config/domain.config.ts | 37 ++ config/services.config.ts | 22 +- config/site.config.ts | 2 +- docs/MULTI_SITE_SSO_VIP_SOLUTION.md | 458 ++++++++++++++++++ 23 files changed, 876 insertions(+), 125 deletions(-) create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/me/route.ts create mode 100644 app/api/auth/sync-session/route.ts create mode 100644 app/api/vip/check/route.ts create mode 100644 app/lib/useAuthAndVip.ts create mode 100644 config/domain.config.ts create mode 100644 docs/MULTI_SITE_SSO_VIP_SOLUTION.md diff --git a/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx b/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx index 374d004..a52199c 100644 --- a/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx +++ b/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx @@ -7,6 +7,7 @@ import { VideoPlayer } from "@/app/components/course/VideoPlayer"; import { LessonMarkdown } from "./LessonMarkdown"; import { getLesson } from "@/config/lessons"; import { canWatchLesson } from "@/app/lib/course-payment"; +import { useAuthAndVip } from "@/app/lib/useAuthAndVip"; import { setLastWatched, markCompleted } from "@/app/lib/learning"; import { DOWNLOAD_CONFIG } from "@/config/course"; import Header from "@/app/components/Header"; @@ -15,12 +16,13 @@ export default function LessonPage() { const params = useParams(); const router = useRouter(); const { t } = useTranslation("community"); + const { vip } = useAuthAndVip(); const [toast, setToast] = useState(false); const moduleIndex = Number(params.moduleIndex); const lessonIndex = Number(params.lessonIndex); const lesson = getLesson(moduleIndex, lessonIndex); - const unlocked = canWatchLesson(moduleIndex, lessonIndex); + const unlocked = canWatchLesson(moduleIndex, lessonIndex, vip); const isFreeTrial = moduleIndex === 0 && lessonIndex < 2; useEffect(() => { diff --git a/app/[locale]/join/page.tsx b/app/[locale]/join/page.tsx index d9fe8a6..6c57a3a 100644 --- a/app/[locale]/join/page.tsx +++ b/app/[locale]/join/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react"; +import { useState, useRef, useEffect, useCallback, type FormEvent, type ChangeEvent } from "react"; import { Link } from "@/i18n/navigation"; import { getPayEnv, @@ -11,6 +11,8 @@ import { } from "@/app/lib/payment"; import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll"; import type { PayChannel, PayEnv } from "@/app/lib/payment"; +import { getStoredToken } from "@/app/lib/pocketbase"; +import AuthModal from "@/app/components/AuthModal"; const genderOptions = ["男", "女"]; const singleOptions = ["单身", "恋爱中", "已婚"]; @@ -56,6 +58,18 @@ export default function JoinPage() { const [payRedirecting, setPayRedirecting] = useState(false); const [payEnv, setPayEnv] = useState(null); const [payChannel, setPayChannel] = useState("alipay"); + const [authOpen, setAuthOpen] = useState(false); + + const checkAuth = useCallback(async () => { + if (getStoredToken()) return true; + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + return !!(data?.user && data?.token); + } catch { + return false; + } + }, []); useEffect(() => { if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") { @@ -79,6 +93,12 @@ export default function JoinPage() { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setError(null); + const loggedIn = await checkAuth(); + if (!loggedIn) { + setAuthOpen(true); + setError("请先登录或注册后再提交申请"); + return; + } setSubmitting(true); try { const res = await fetch("/api/join", { @@ -515,6 +535,15 @@ export default function JoinPage() { + + setAuthOpen(false)} + onSuccess={() => { + setAuthOpen(false); + setError(null); + }} + /> ); } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..22eeebf --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; + +const COOKIE_NAME = "pb_session"; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 }); + return res; +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..461a2bd --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig } from "@/config/services.config"; +import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; + +const COOKIE_NAME = "pb_session"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +export async function GET(request: NextRequest) { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) { + return NextResponse.json({ ok: true, user: null }); + } + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + const { token, userId, email } = payload; + if (!token || !userId) return NextResponse.json({ ok: true, user: null }); + + const pb = getPocketBaseConfig(); + const res = await fetch(`${pb.url}/api/users/refresh`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const r = NextResponse.json({ ok: true, user: null }); + r.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 }); + return r; + } + const data = await res.json(); + const newToken = data.token; + const newRecord = data.record; + // 刷新成功:回写新 token 到 Cookie,保持登录态长期有效 + if (newToken && newRecord?.id) { + const newPayload = JSON.stringify({ + token: newToken, + userId: newRecord.id, + email: newRecord.email ?? email, + }); + const encoded = Buffer.from(newPayload, "utf-8").toString("base64url"); + const r = NextResponse.json({ + ok: true, + user: { id: newRecord.id, email: newRecord.email ?? email }, + token: newToken, + }); + r.cookies.set(COOKIE_NAME, encoded, { + domain: AUTH_COOKIE_DOMAIN, + path: "/", + maxAge: COOKIE_MAX_AGE, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + httpOnly: true, + }); + return r; + } + return NextResponse.json({ + ok: true, + user: { id: data.record?.id ?? userId, email: data.record?.email ?? email }, + token: data.token, + }); + } catch { + return NextResponse.json({ ok: true, user: null }); + } +} diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts new file mode 100644 index 0000000..d36ebaa --- /dev/null +++ b/app/api/auth/sync-session/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; + +const COOKIE_NAME = "pb_session"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出 + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const token = body?.token; + const record = body?.record; + if (!token || !record?.id) { + return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 }); + } + + const payload = JSON.stringify({ + token, + userId: record.id, + email: record.email ?? "", + }); + const encoded = Buffer.from(payload, "utf-8").toString("base64url"); + + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE_NAME, encoded, { + domain: AUTH_COOKIE_DOMAIN, + path: "/", + maxAge: COOKIE_MAX_AGE, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + httpOnly: true, + }); + return res; +} diff --git a/app/api/join/route.ts b/app/api/join/route.ts index 6ee6a84..d7d4112 100644 --- a/app/api/join/route.ts +++ b/app/api/join/route.ts @@ -2,7 +2,18 @@ import { NextRequest, NextResponse } from "next/server"; import { getPocketBaseConfig } from "@/config/services.config"; import { getThemeConfig } from "@/config"; -function getOrCreateUserId(): string { +const COOKIE_NAME = "pb_session"; + +function getOrCreateUserId(request: NextRequest): string { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (cookie) { + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + if (payload?.userId) return payload.userId; + } catch { + /* ignore */ + } + } return `user${Date.now()}`; } @@ -24,7 +35,7 @@ async function getAdminToken(): Promise { export async function POST(request: NextRequest) { try { - const userId = getOrCreateUserId(); + const userId = getOrCreateUserId(request); const body = await request.json(); const formData = body && typeof body === "object" ? body : {}; diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index 6f58bde..57e8bb8 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPaymentConfig, getJoinPaymentConfig } from "@/config/services.config"; +import { getPaymentConfig, getJoinPaymentConfig, SITE_ID } from "@/config/services.config"; import { SITE_URL } from "../../lib/env"; async function createPayOrder( @@ -17,6 +17,7 @@ async function createPayOrder( const payload = { user_id, + site_id: SITE_ID, total_fee: Number(fee) || joinConfig.amount, type: orderType, channel, @@ -26,7 +27,7 @@ async function createPayOrder( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); - const res = await fetch(`${config.apiUrl}/payh5`, { + const res = await fetch(`${config.apiUrl}/digital/payh5`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -148,6 +149,7 @@ export async function POST(request: NextRequest) { // 使用 payjsapi /payh5/redirect:mapi.php API接口支付,按 device 返回 payurl/payurl2 const redirectPayload = { user_id, + site_id: SITE_ID, total_fee, type, channel, @@ -157,7 +159,7 @@ export async function POST(request: NextRequest) { }; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); - const redirectRes = await fetch(`${payConfig.apiUrl}/payh5/redirect`, { + const redirectRes = await fetch(`${payConfig.apiUrl}/digital/payh5/redirect`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(redirectPayload), diff --git a/app/api/pay/status/route.ts b/app/api/pay/status/route.ts index 82b175b..dd4eaa5 100644 --- a/app/api/pay/status/route.ts +++ b/app/api/pay/status/route.ts @@ -14,7 +14,7 @@ export async function GET(request: NextRequest) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 25000); const res = await fetch( - `${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`, + `${config.apiUrl}/digital/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`, { cache: "no-store", signal: controller.signal } ).finally(() => clearTimeout(timeout)); const data = await res.json().catch(() => ({})); diff --git a/app/api/vip/check/route.ts b/app/api/vip/check/route.ts new file mode 100644 index 0000000..af5c154 --- /dev/null +++ b/app/api/vip/check/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig, SITE_ID, VIP_MASTER_SITE_ID } from "@/config/services.config"; + +const COOKIE_NAME = "pb_session"; + +async function getAdminToken(): Promise { + const email = process.env.POCKETBASE_EMAIL; + const password = process.env.POCKETBASE_PASSWORD; + if (!email || !password) return null; + const pb = getPocketBaseConfig(); + const res = await fetch(`${pb.url}/api/admins/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + }); + if (!res.ok) return null; + const data = await res.json(); + return data?.token ?? null; +} + +/** 检查单条 site_vip 是否有效 */ +function isValidVip(record: { expires_at?: string | null } | null): boolean { + if (!record) return false; + const now = new Date().toISOString(); + return !(record.expires_at && record.expires_at < now); +} + +export async function GET(request: NextRequest) { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) { + return NextResponse.json({ ok: true, vip: false }); + } + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + const userId = payload?.userId; + if (!userId) return NextResponse.json({ ok: true, vip: false }); + + const token = await getAdminToken(); + if (!token) return NextResponse.json({ ok: true, vip: false }); + + const pb = getPocketBaseConfig(); + // 1) 本专题站 VIP(site_id = SITE_ID)2) vip 站 master VIP(site_id = vip)可解锁所有 digital 专题站 + const filter = `user_id = "${userId}" && (site_id = "${SITE_ID}" || site_id = "${VIP_MASTER_SITE_ID}")`; + const res = await fetch( + `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=2&sort=-expires_at`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) return NextResponse.json({ ok: true, vip: false }); + const data = await res.json(); + const items = data?.items ?? []; + const record = items.find((r: { expires_at?: string | null }) => isValidVip(r)); + const vip = !!record; + return NextResponse.json({ + ok: true, + vip, + expires_at: record?.expires_at ?? null, + }); + } catch { + return NextResponse.json({ ok: true, vip: false }); + } +} diff --git a/app/components/AuthModal.tsx b/app/components/AuthModal.tsx index bd21a5a..2d7540c 100644 --- a/app/components/AuthModal.tsx +++ b/app/components/AuthModal.tsx @@ -44,6 +44,15 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps result = await pbLogin(email, password); } pbSaveAuth(result.token, result.record); + try { + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: result.token, record: result.record }), + }); + } catch { + /* ignore */ + } onSuccess(email); onClose(); setEmail(""); diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 8dff541..53eb633 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -25,11 +25,28 @@ export default function Header() { const [userEmail, setUserEmail] = useState(null); useEffect(() => { - setUserEmail(getStoredUserEmail()); + let mounted = true; + (async () => { + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + if (!mounted) return; + if (data?.user && data?.token) { + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(data.token, { id: data.user.id, email: data.user.email }); + setUserEmail(data.user.email); + return; + } + } catch { + /* ignore */ + } + if (mounted) setUserEmail(getStoredUserEmail()); + })(); + return () => { mounted = false; }; }, []); - const handleLogout = () => { - pbLogout(); + const handleLogout = async () => { + await pbLogout(); setUserEmail(null); }; diff --git a/app/components/Roadmap.tsx b/app/components/Roadmap.tsx index 9c3d1ed..84a3974 100644 --- a/app/components/Roadmap.tsx +++ b/app/components/Roadmap.tsx @@ -1,7 +1,9 @@ "use client"; import { useState } from "react"; -import { Link, useTranslation } from "@/i18n/navigation"; +import { Link, useTranslation, useRouter } from "@/i18n/navigation"; +import { useAuthAndVip } from "@/app/lib/useAuthAndVip"; +import AuthModal from "@/app/components/AuthModal"; const days = [ { day: 1, icon: "👋", color: "bg-sky-500" }, @@ -15,7 +17,9 @@ const days = [ export default function Roadmap() { const { t } = useTranslation("roadmap"); - const [courseToast, setCourseToast] = useState(false); + const router = useRouter(); + const { user, vip, loading, refetch } = useAuthAndVip(); + const [authOpen, setAuthOpen] = useState(false); return (
@@ -86,7 +90,22 @@ export default function Roadmap() { -
+ -
+ +
- {courseToast && ( -
- {t("course.updating")} -
- )} + setAuthOpen(false)} + onSuccess={async () => { + setAuthOpen(false); + const { vip: isVip } = await refetch(); + router.replace(isVip ? "/course" : "/join"); + }} + />
); diff --git a/app/components/course/CTASection.tsx b/app/components/course/CTASection.tsx index b9b100a..ca11a4b 100644 --- a/app/components/course/CTASection.tsx +++ b/app/components/course/CTASection.tsx @@ -1,21 +1,12 @@ "use client"; -import { useState, useEffect } from "react"; import { Link } from "@/i18n/navigation"; -import { useTranslation } from "@/i18n/navigation"; import { CTA_CONFIG } from "@/config/course"; -import { isPaid } from "@/app/lib/course-payment"; +import { useAuthAndVip } from "@/app/lib/useAuthAndVip"; export function CTASection() { - const { t } = useTranslation("community"); - const [paid, setPaid] = useState(false); - const [toast, setToast] = useState(false); - useEffect(() => { - setPaid(isPaid()); - const onPayUpdate = () => setPaid(isPaid()); - window.addEventListener("pay:updated", onPayUpdate); - return () => window.removeEventListener("pay:updated", onPayUpdate); - }, []); + const { user, vip } = useAuthAndVip(); + const paid = !!user && vip; return (
@@ -45,26 +36,14 @@ export function CTASection() { 开始学习 → ) : ( - + )} - {toast && ( -
- {t("updating")} -
- )}
); } diff --git a/app/components/course/CourseHero.tsx b/app/components/course/CourseHero.tsx index 040375f..fdd6b7a 100644 --- a/app/components/course/CourseHero.tsx +++ b/app/components/course/CourseHero.tsx @@ -2,37 +2,30 @@ import { useState, useEffect } from "react"; import { Link } from "@/i18n/navigation"; -import { useTranslation } from "@/i18n/navigation"; import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/course"; -import { isPaid } from "@/app/lib/course-payment"; +import { useAuthAndVip } from "@/app/lib/useAuthAndVip"; import { getLastWatched, getProgress } from "@/app/lib/learning"; const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0); export function CourseHero() { - const { t } = useTranslation("community"); - const [paid, setPaid] = useState(false); - const [toast, setToast] = useState(false); + const { user, vip } = useAuthAndVip(); const [last, setLast] = useState>(null); const [progress, setProgress] = useState({ completed: 0, percent: 0 }); useEffect(() => { - setPaid(isPaid()); setLast(getLastWatched()); setProgress(getProgress(TOTAL_LESSONS)); - const onPay = () => setPaid(isPaid()); const onProgress = () => { setLast(getLastWatched()); setProgress(getProgress(TOTAL_LESSONS)); }; - window.addEventListener("pay:updated", onPay); window.addEventListener("learning:progress", onProgress); - return () => { - window.removeEventListener("pay:updated", onPay); - window.removeEventListener("learning:progress", onProgress); - }; + return () => window.removeEventListener("learning:progress", onProgress); }, []); + const paid = !!user && vip; + return (
@@ -71,16 +64,12 @@ export function CourseHero() { 进入课程 → ) : ( - + )}
{paid && progress.completed > 0 && ( @@ -98,14 +87,6 @@ export function CourseHero() { )} - {toast && ( -
- {t("updating")} -
- )}
); } diff --git a/app/components/course/CurriculumSection.tsx b/app/components/course/CurriculumSection.tsx index cb8369f..a7eed9e 100644 --- a/app/components/course/CurriculumSection.tsx +++ b/app/components/course/CurriculumSection.tsx @@ -5,9 +5,11 @@ import { Link } from "@/i18n/navigation"; import { Section } from "./Section"; import { CURRICULUM_CONFIG } from "@/config/course"; import { canWatchLesson } from "@/app/lib/course-payment"; +import { useAuthAndVip } from "@/app/lib/useAuthAndVip"; import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/lib/learning"; export function CurriculumSection() { + const { vip } = useAuthAndVip(); const [expanded, setExpanded] = useState>(() => Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0])) ); @@ -98,7 +100,7 @@ export function CurriculumSection() {
    {c.lessons.map((l) => { const free = l.moduleIndex === 0 && l.lessonIndex < 2; - const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex); + const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex, vip); const key = `${l.moduleIndex}-${l.lessonIndex}`; const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex); const isCompleted = completed.has(key); diff --git a/app/lib/course-payment.ts b/app/lib/course-payment.ts index e65de3f..0173a74 100644 --- a/app/lib/course-payment.ts +++ b/app/lib/course-payment.ts @@ -1,31 +1,20 @@ /** - * 课程支付状态 - localStorage(来自 nomadlms) - * 与 join 报名支付分离,课程解锁用此模块 + * 课程解锁 - 校验登录态 + VIP(/api/vip/check) + * 与 join 报名支付分离,课程解锁用 VIP 状态 */ -const PAY_STORAGE_KEY = "lms-pay"; - -/** 是否已支付(课程已解锁) */ -export function isPaid(): boolean { - if (typeof window === "undefined") return false; - return localStorage.getItem(PAY_STORAGE_KEY) === "ok"; -} - -/** 设置已支付(支付成功后调用) */ -export function setPaid(): void { - if (typeof window === "undefined") return; - localStorage.setItem(PAY_STORAGE_KEY, "ok"); - window.dispatchEvent(new CustomEvent("pay:updated")); -} - /** 前 2 章为试看,无需支付 */ export function isFreeTrial(moduleIndex: number, lessonIndex: number): boolean { if (moduleIndex === 0 && lessonIndex < 2) return true; return false; } -/** 章节是否可观看 */ -export function canWatchLesson(moduleIndex: number, lessonIndex: number): boolean { +/** 章节是否可观看(需传入 vip 状态,来自 useAuthAndVip 或 /api/vip/check) */ +export function canWatchLesson( + moduleIndex: number, + lessonIndex: number, + vip: boolean +): boolean { if (isFreeTrial(moduleIndex, lessonIndex)) return true; - return isPaid(); + return vip; } diff --git a/app/lib/env.ts b/app/lib/env.ts index 9c25072..2d9c970 100644 --- a/app/lib/env.ts +++ b/app/lib/env.ts @@ -1,19 +1,17 @@ /** * 环境配置:区分本地调试与生产部署 - * 生产:https://api.hackrobot.cn(payjsapi) + * 域名更换:设置 ROOT_DOMAIN(如 nomadro.com)可推导默认值 */ -const isDev = process.env.NODE_ENV === "development"; +import { DEFAULT_PAYMENT_API_URL, DEFAULT_SITE_URL } from "@/config/domain.config"; /** 支付 API 地址(payjsapi) */ export const PAYMENT_API_URL = - process.env.PAYMENT_API_URL || - (isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"); + process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL; /** 站点根地址(用于 return_url 等回跳,生产需配置) */ export const SITE_URL = - process.env.NEXT_PUBLIC_SITE_URL || - (isDev ? "http://localhost:3001" : "https://hackrobot.cn"); + process.env.NEXT_PUBLIC_SITE_URL || DEFAULT_SITE_URL; /** 是否为本地开发 */ -export const IS_DEV = isDev; +export const IS_DEV = process.env.NODE_ENV === "development"; diff --git a/app/lib/pocketbase/auth.ts b/app/lib/pocketbase/auth.ts index 245df10..2e38382 100644 --- a/app/lib/pocketbase/auth.ts +++ b/app/lib/pocketbase/auth.ts @@ -67,11 +67,16 @@ export function pbSaveAuth(token: string, record: AuthUser): void { localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record)); } -/** 登出 - 清除本地存储 */ -export function pbLogout(): void { +/** 登出 - 清除本地存储和根域 Cookie */ +export async function pbLogout(): Promise { if (typeof window === "undefined") return; localStorage.removeItem(PB_STORAGE_KEYS.token); localStorage.removeItem(PB_STORAGE_KEYS.user); + try { + await fetch("/api/auth/logout", { method: "POST" }); + } catch { + /* ignore */ + } } /** 获取当前存储的用户邮箱 */ diff --git a/app/lib/useAuthAndVip.ts b/app/lib/useAuthAndVip.ts new file mode 100644 index 0000000..7abb90e --- /dev/null +++ b/app/lib/useAuthAndVip.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +export interface AuthAndVipState { + user: { id: string; email: string } | null; + vip: boolean; + loading: boolean; + refetch: () => Promise<{ user: { id: string; email: string } | null; vip: boolean }>; +} + +export function useAuthAndVip(): AuthAndVipState { + const [user, setUser] = useState<{ id: string; email: string } | null>(null); + const [vip, setVip] = useState(false); + const [loading, setLoading] = useState(true); + + const refetch = useCallback(async (): Promise<{ user: typeof user; vip: boolean }> => { + setLoading(true); + try { + const [meRes, vipRes] = await Promise.all([ + fetch("/api/auth/me", { credentials: "include" }), + fetch("/api/vip/check", { credentials: "include" }), + ]); + const meData = await meRes.json(); + const vipData = await vipRes.json(); + const newUser = meData?.user ?? null; + const newVip = vipData?.vip === true; + setUser(newUser); + setVip(newVip); + return { user: newUser, vip: newVip }; + } catch { + setUser(null); + setVip(false); + return { user: null, vip: false }; + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refetch(); + }, [refetch]); + + return { user, vip, loading, refetch }; +} diff --git a/config/domain.config.ts b/config/domain.config.ts new file mode 100644 index 0000000..a719971 --- /dev/null +++ b/config/domain.config.ts @@ -0,0 +1,37 @@ +/** + * 域名与根域配置 - 特色标识 + * 更换域名(如 *.hackrobot.cn -> *.nomadro.com)时,只需设置 ROOT_DOMAIN 或各独立变量 + * + * 环境变量: + * - ROOT_DOMAIN: 根域名,如 nomadro.com,用于推导 Cookie、API 等默认值 + * - AUTH_COOKIE_DOMAIN: Cookie 根域,如 .nomadro.com(优先于 ROOT_DOMAIN) + * - PAYMENT_API_URL: 支付 API 地址 + * - NEXT_PUBLIC_POCKETBASE_URL: PocketBase 地址 + * - NEXT_PUBLIC_SITE_URL: 站点根地址 + */ + +const isDev = process.env.NODE_ENV === "development"; + +/** 根域名,用于推导默认值。如 nomadro.com、hackrobot.cn */ +export const ROOT_DOMAIN = + process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn"; + +/** Cookie 根域,SSO 跨站登录用。如 .nomadro.com、.hackrobot.cn */ +export const AUTH_COOKIE_DOMAIN = + process.env.AUTH_COOKIE_DOMAIN || `.${ROOT_DOMAIN}`; + +/** 支付 API 默认地址 */ +export const DEFAULT_PAYMENT_API_URL = isDev + ? "http://127.0.0.1:8700" + : `https://api.${ROOT_DOMAIN}`; + +/** PocketBase 默认地址 */ +export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`; + +/** 站点根地址默认值 */ +export const DEFAULT_SITE_URL = isDev + ? "http://localhost:3001" + : `https://${ROOT_DOMAIN}`; + +/** MinIO 端点默认主机 */ +export const DEFAULT_MINIO_HOST = `minioweb.${ROOT_DOMAIN}`; diff --git a/config/services.config.ts b/config/services.config.ts index be7a875..2bff55d 100644 --- a/config/services.config.ts +++ b/config/services.config.ts @@ -1,9 +1,15 @@ /** * 服务配置 - PocketBase、支付等第三方服务 * 可通过环境变量覆盖,不同主题可配置不同集合/金额 + * 域名更换:设置 ROOT_DOMAIN(如 nomadro.com)可推导默认 API 地址 */ import { getThemeConfig } from "./site.config"; +import { + DEFAULT_PAYMENT_API_URL, + DEFAULT_POCKETBASE_URL, + DEFAULT_MINIO_HOST, +} from "./domain.config"; export interface PocketBaseConfig { enabled: boolean; @@ -59,6 +65,12 @@ export interface ServicesConfig { const isDev = process.env.NODE_ENV === "development"; +/** 站点标识,用于 VIP 按站点隔离。digital 专题站(ai.xx/android.xx)各自独立,如 digital_ai、digital_android */ +export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "digital"; + +/** vip 站(vip.hackrobot.cn)的 site_id,其付费 VIP 可解锁所有 digital 专题站 */ +export const VIP_MASTER_SITE_ID = "vip"; + /** 获取 PocketBase 配置(可传入主题覆盖) */ export function getPocketBaseConfig(themeOverride?: { joinCollection?: string }): PocketBaseConfig { const joinCollection = @@ -67,7 +79,7 @@ export function getPocketBaseConfig(themeOverride?: { joinCollection?: string }) "solan"; return { enabled: true, - url: process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.POCKETBASE_URL || "https://pocketbase.hackrobot.cn", + url: process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.POCKETBASE_URL || DEFAULT_POCKETBASE_URL, usersCollection: "users", joinCollection, }; @@ -77,11 +89,9 @@ export function getPocketBaseConfig(themeOverride?: { joinCollection?: string }) export function getPaymentConfig(themeOverride?: { amount?: number; type?: string }): PaymentConfig { const base = { enabled: true, - apiUrl: - process.env.PAYMENT_API_URL || - (isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"), + apiUrl: process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL, provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay", - defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 80, + defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 100, defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup", zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php", }; @@ -94,7 +104,7 @@ export function getPaymentConfig(themeOverride?: { amount?: number; type?: strin /** 获取 MinIO 配置(可传入主题覆盖 bucket/prefix) */ export function getMinioConfig(themeOverride?: { bucket?: string; uploadPrefix?: string }): MinioConfig { - const endPoint = process.env.MINIO_ENDPOINT || "minioweb.hackrobot.cn"; + const endPoint = process.env.MINIO_ENDPOINT || DEFAULT_MINIO_HOST; const port = parseInt(process.env.MINIO_PORT || "9035", 10); const useSSL = process.env.MINIO_USE_SSL === "true"; const bucket = themeOverride?.bucket || process.env.MINIO_BUCKET || "hackrobot"; diff --git a/config/site.config.ts b/config/site.config.ts index b983351..f9d087f 100644 --- a/config/site.config.ts +++ b/config/site.config.ts @@ -95,7 +95,7 @@ export const THEME_CONFIGS: Record = { accent: "amber", }, services: { - join: { amount: 80, type: "meetup" }, + join: { amount: 100, type: "meetup" }, pocketbaseJoinCollection: "solan", shopUrl: process.env.NEXT_PUBLIC_SHOP_URL || "https://store.hackrobot.cn/", // 独立站域名 }, diff --git a/docs/MULTI_SITE_SSO_VIP_SOLUTION.md b/docs/MULTI_SITE_SSO_VIP_SOLUTION.md new file mode 100644 index 0000000..e5a52b2 --- /dev/null +++ b/docs/MULTI_SITE_SSO_VIP_SOLUTION.md @@ -0,0 +1,458 @@ +# 多站点 SSO + 站点独立 VIP 解决方案 + +## 一、现状与需求 + +### 1.1 当前架构 +| 组件 | 域名 | 说明 | +|------|------|------| +| digital | digital.hackrobot.cn | 数字游民指南 | +| cnomadcna | meetup.hackrobot.cn | 线下聚会社区 | +| nomadvip | vip.hackrobot.cn | VIP 会员站 | +| payjsapi | api.hackrobot.cn | 统一支付后端 | +| PocketBase | pocketbase.hackrobot.cn | 统一数据库 | + +### 1.2 需求 +1. **统一账号**:任一前端注册后,其他前端可用同一账号密码登录 +2. **跨站 SSO**:从 A 站跳转到 B/C 站时,若 A 已登录,B/C 自动为登录态 +3. **站点独立 VIP**:每个站点有独立 VIP,VIP 权限仅作用于对应域名 + +--- + +## 二、整体方案 + +### 2.1 架构图 + +``` + ┌─────────────────────────────────────────┐ + │ .hackrobot.cn 根域 │ + │ Cookie: pb_session (Domain=.hackrobot.cn) │ + └─────────────────────────────────────────┘ + │ + ┌──────────────────────────────┼──────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ digital │ │ meetup │ │ vip │ +│ digital.xxx.cn │ │ meetup.xxx.cn │ │ vip.xxx.cn │ +│ - 读 cookie 登录 │ │ - 读 cookie 登录 │ │ - 读 cookie 登录 │ +│ - VIP: digital │ │ - VIP: meetup │ │ - VIP: vip │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └─────────────────────────────┼─────────────────────────────┘ + │ + ┌──────────────────┼──────────────────┐ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ api.xxx.cn │ │ pocketbase │ │ payjsapi │ + │ (可选中转) │ │ .xxx.cn │ │ 订单/VIP 查询 │ + └──────────────┘ └──────────────┘ └──────────────┘ +``` + +### 2.2 方案要点 + +| 需求 | 实现方式 | +|------|----------| +| 统一账号 | 所有前端共用 PocketBase `users` 集合,已满足 | +| 跨站 SSO | 登录成功后,由后端设置 `Domain=.hackrobot.cn` 的 Cookie | +| 站点独立 VIP | payjsapi 增加 `site_id`,PocketBase 增加 `site_vip` 表 | + +--- + +## 三、数据库设计(PocketBase) + +### 3.1 新增集合:`sites`(站点配置) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | 自动 | | +| site_id | Text 唯一 | 站点标识,如 digital / meetup / vip | +| domain | Text | 主域名,如 digital.hackrobot.cn | +| name | Text | 站点名称 | +| created | DateTime | | + +**预置数据:** +``` +site_id: "digital", domain: "digital.hackrobot.cn", name: "数字游民指南" +site_id: "meetup", domain: "meetup.hackrobot.cn", name: "线下聚会" +site_id: "vip", domain: "vip.hackrobot.cn", name: "VIP会员站" +``` + +### 3.2 新增集合:`site_vip`(站点 VIP 权限) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | 自动 | | +| user_id | Relation → users | PocketBase 用户 ID | +| site_id | Text | 对应 sites.site_id | +| order_id | Text | 支付订单号(来自 payjsapi) | +| expires_at | DateTime | VIP 到期时间,null 表示永久 | +| created | DateTime | | + +**索引:** `(user_id, site_id)` 唯一,同一用户在同一站点仅一条有效记录。 + +### 3.3 修改 payjsapi 订单逻辑(若订单存 PocketBase) + +若 payjsapi 使用自建库,需有等价表结构,核心字段: +- `user_id`(关联 PocketBase 用户 ID 或邮箱) +- `site_id`(digital / meetup / vip) +- `out_trade_no`、`paid`、`paid_at` 等 + +--- + +## 四、后端改造 + +### 4.1 payjsapi 改造 + +#### 4.1.1 订单创建接口 `/payh5` 增加 `site_id` + +**请求体增加:** +```json +{ + "user_id": "xxx", + "site_id": "digital", // 新增:digital | meetup | vip + "total_fee": 80, + "type": "meetup", + "channel": "alipay", + "return_url": "..." +} +``` + +**逻辑:** +- 创建订单时写入 `site_id` +- 支付成功回调时:根据 `out_trade_no` 找到订单,取 `user_id`、`site_id`,调用 PocketBase 或内部 API 写入/更新 `site_vip` + +#### 4.1.2 新增 VIP 查询接口 + +``` +GET /api/vip/check?user_id=xxx&site_id=digital +或 +GET /api/vip/check +Header: Authorization: Bearer +Query: site_id=digital +``` + +**响应:** +```json +{ + "ok": true, + "vip": true, + "expires_at": "2026-12-31T23:59:59Z" +} +``` + +实现:查 `site_vip` 表,`user_id` + `site_id` 匹配且 `expires_at` 为空或大于当前时间。 + +### 4.2 各前端 Next.js API 路由 + +#### 4.2.1 新增 `/api/auth/sync-session`(POST) + +**作用:** 登录成功后,将 PocketBase token 同步到根域 Cookie,实现跨站 SSO。 + +**请求体:** +```json +{ + "token": "pb_jwt_token", + "record": { "id": "xxx", "email": "user@example.com" } +} +``` + +**实现要点:** +```typescript +// app/api/auth/sync-session/route.ts +import { NextRequest, NextResponse } from "next/server"; + +const COOKIE_NAME = "pb_session"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 天 + +export async function POST(request: NextRequest) { + const body = await request.json(); + const token = body?.token; + const record = body?.record; + if (!token || !record?.id) { + return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 }); + } + + // 可选:用 PocketBase 验证 token 有效性 + // const pb = getPocketBaseConfig(); + // const res = await fetch(`${pb.url}/api/users/refresh`, { headers: { Authorization: `Bearer ${token}` } }); + // if (!res.ok) return NextResponse.json({ ok: false }, { status: 401 }); + + const payload = JSON.stringify({ token, userId: record.id, email: record.email }); + const encoded = Buffer.from(payload, "utf-8").toString("base64url"); + + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE_NAME, encoded, { + domain: ".hackrobot.cn", + path: "/", + maxAge: COOKIE_MAX_AGE, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + httpOnly: true, + }); + return res; +} +``` + +#### 4.2.2 新增 `/api/auth/me`(GET) + +**作用:** 根据 Cookie 返回当前用户,供各子站判断登录态。 + +```typescript +// app/api/auth/me/route.ts +export async function GET(request: NextRequest) { + const cookie = request.cookies.get("pb_session")?.value; + if (!cookie) { + return NextResponse.json({ ok: true, user: null }); + } + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + const { token, userId, email } = payload; + if (!token || !userId) return NextResponse.json({ ok: true, user: null }); + + // 可选:向 PocketBase 验证 token 是否仍有效 + const pb = getPocketBaseConfig(); + const res = await fetch(`${pb.url}/api/users/refresh`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + // token 失效,清除 cookie + const r = NextResponse.json({ ok: true, user: null }); + r.cookies.set("pb_session", "", { domain: ".hackrobot.cn", path: "/", maxAge: 0 }); + return r; + } + const data = await res.json(); + return NextResponse.json({ + ok: true, + user: { id: data.record?.id ?? userId, email: data.record?.email ?? email }, + token: data.token, + }); + } catch { + return NextResponse.json({ ok: true, user: null }); + } +} +``` + +#### 4.2.3 新增 `/api/auth/logout`(POST) + +清除根域 Cookie: + +```typescript +export async function POST() { + const res = NextResponse.json({ ok: true }); + res.cookies.set("pb_session", "", { domain: ".hackrobot.cn", path: "/", maxAge: 0 }); + return res; +} +``` + +--- + +## 五、前端改造 + +### 5.1 登录成功后同步 Cookie + +修改 `AuthModal.tsx` 或 `pbSaveAuth` 调用处: + +```typescript +// 登录/注册成功后 +const result = await pbLogin(email, password); // 或 pbRegister +pbSaveAuth(result.token, result.record); + +// 新增:同步到根域 Cookie,实现跨站 SSO +await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: result.token, record: result.record }), +}); +``` + +### 5.2 应用启动时:Cookie 优先 + +各站点的「获取当前用户」逻辑改为: + +1. 先调 `GET /api/auth/me` +2. 若有 `user`,用返回的 `token` 更新 localStorage,并视为已登录 +3. 若无,再读 localStorage(兼容旧会话) + +```typescript +// 示例:useAuth 或 layout 中的初始化 +async function initAuth() { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + if (data?.user && data?.token) { + pbSaveAuth(data.token, { id: data.user.id, email: data.user.email }); + return data.user; + } + const token = getStoredToken(); + const user = getStoredUser(); + if (token && user) return user; + return null; +} +``` + +### 5.3 登出时清除 Cookie + +```typescript +async function pbLogout() { + localStorage.removeItem(PB_STORAGE_KEYS.token); + localStorage.removeItem(PB_STORAGE_KEYS.user); + await fetch("/api/auth/logout", { method: "POST" }); +} +``` + +### 5.4 站点标识配置 + +每个前端项目需配置自己的 `site_id`: + +```typescript +// config/site.config.ts 或 env +export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "digital"; +// digital -> "digital", meetup -> "meetup", vip -> "vip" +``` + +环境变量示例: +- digital: `NEXT_PUBLIC_SITE_ID=digital` +- meetup: `NEXT_PUBLIC_SITE_ID=meetup` +- vip: `NEXT_PUBLIC_SITE_ID=vip` + +### 5.5 VIP 校验(按站点) + +支付、课程解锁等需要 VIP 的地方,改为按 `site_id` 查询: + +```typescript +// 调用 payjsapi 或自建 API +const res = await fetch( + `${apiUrl}/api/vip/check?site_id=${SITE_ID}`, + { headers: { Authorization: `Bearer ${token}` } } +); +const { vip } = await res.json(); +``` + +或通过 Next.js API 转发(避免暴露 payjsapi 地址): + +```typescript +// app/api/vip/check/route.ts +export async function GET(request: NextRequest) { + const token = request.cookies.get("pb_session")?.value; + // 解析 token 得 user_id,再调 payjsapi + const siteId = process.env.NEXT_PUBLIC_SITE_ID || "digital"; + const res = await fetch(`${payjsapiUrl}/api/vip/check?user_id=${userId}&site_id=${siteId}`); + const data = await res.json(); + return NextResponse.json(data); +} +``` + +--- + +## 六、payjsapi 与 PocketBase 的衔接 + +### 6.1 用户 ID 统一 + +- **PocketBase 用户 ID**:`users.id`,用于登录态和 `site_vip.user_id` +- **payjsapi 订单**:需使用 PocketBase 的 `users.id` 作为 `user_id`,不再使用 `user${timestamp}` + +**join 流程调整建议:** +- 若用户已登录:`user_id` = PocketBase `users.id` +- 若未登录:可先创建匿名订单,支付成功后要求登录/注册,再绑定 `user_id`;或强制登录后再支付 + +### 6.2 支付成功回调 → 写入 site_vip + +payjsapi 支付成功时: + +1. 根据 `out_trade_no` 取订单的 `user_id`、`site_id` +2. 调用 PocketBase API 创建/更新 `site_vip` 记录: + +``` +POST https://pocketbase.hackrobot.cn/api/collections/site_vip/records +Authorization: Bearer +{ + "user_id": "pb_user_id", + "site_id": "digital", + "order_id": "out_trade_no", + "expires_at": null +} +``` + +需在 PocketBase 配置 admin 账号,供 payjsapi 调用。 + +--- + +## 七、实施步骤(已完成) + +| 步骤 | 内容 | 状态 | +|------|------|------| +| 1 | PocketBase 创建 `sites`、`site_vip` 集合 | ✅ 已创建 | +| 2 | payjsapi 支持 `site_id`、支付回调写 `site_vip` | ✅ 已实现 | +| 3 | 各前端新增 `/api/auth/sync-session`、`/api/auth/me`、`/api/auth/logout` | ✅ 已实现 | +| 4 | 各前端登录/登出时调用上述 API,Cookie 优先 auth 初始化 | ✅ 已实现 | +| 5 | 各前端配置 `NEXT_PUBLIC_SITE_ID`,VIP 校验按 `site_id` 查询 | ✅ 已实现 | +| 6 | join 流程改为使用 PocketBase 用户 ID(Cookie 有则用) | ✅ 已实现 | + +## 七.1 环境变量 + +| 变量 | 说明 | 示例 | +|------|------|------| +| `ROOT_DOMAIN` | 根域名,用于推导 Cookie/API 默认值 | `nomadro.com` | +| `AUTH_COOKIE_DOMAIN` | 根域 Cookie 域名(优先于 ROOT_DOMAIN) | `.nomadro.com` | +| `PAYMENT_API_URL` | 支付 API 地址 | `https://api.nomadro.com` | +| `NEXT_PUBLIC_POCKETBASE_URL` | PocketBase 地址 | `https://pocketbase.nomadro.com` | +| `NEXT_PUBLIC_SITE_URL` | 站点根地址 | `https://digital.nomadro.com` | +| `POCKETBASE_EMAIL` | PocketBase 管理员邮箱(VIP 查询、join 用) | - | +| `POCKETBASE_PASSWORD` | PocketBase 管理员密码 | - | +| `NEXT_PUBLIC_SITE_ID` | 站点标识。digital 专题站各自独立,如 `digital_ai`、`digital_android` | - | + +### 域名更换(特色标识) + +更换域名(如 `*.hackrobot.cn` → `*.nomadro.com`)时,设置: + +```bash +ROOT_DOMAIN=nomadro.com +# 或单独设置各变量 +AUTH_COOKIE_DOMAIN=.nomadro.com +PAYMENT_API_URL=https://api.nomadro.com +NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.nomadro.com +NEXT_PUBLIC_SITE_URL=https://digital.nomadro.com +``` + +### 双域同时部署(*.hackrobot.cn + *.nomad.com) + +前端/后端源码相同、共用同一 PocketBase 时: + +| 配置项 | hackrobot.cn 部署 | nomad.com 部署 | +|--------|-------------------|----------------| +| `ROOT_DOMAIN` | `hackrobot.cn` | `nomad.com` | +| `AUTH_COOKIE_DOMAIN` | `.hackrobot.cn` | `.nomad.com` | +| `PAYMENT_API_URL` | `https://api.hackrobot.cn` | `https://api.nomad.com` | +| `NEXT_PUBLIC_POCKETBASE_URL` | 同一地址(如 `https://pocketbase.hackrobot.cn`) | 同上 | +| `NEXT_PUBLIC_SITE_URL` | `https://digital.hackrobot.cn` | `https://digital.nomad.com` | + +- **PocketBase**:两边指向同一实例,用户、`site_vip` 等数据共享。 +- **Cookie**:不同根域无法共享 Cookie,用户从 hackrobot.cn 跳到 nomad.com 需重新登录(同一账号)。 + +### digital 专题站 vs vip 站 VIP 逻辑 + +| 站点类型 | VIP 逻辑 | +|----------|----------| +| **digital 专题站**(ai.xx、android.xx 等) | 各自独立 VIP,`NEXT_PUBLIC_SITE_ID` 如 `digital_ai`、`digital_android` | +| **vip 站**(vip.hackrobot.cn) | `site_id=vip`,其付费 VIP 可解锁**所有** digital 专题站 | + +digital 专题站 VIP 校验:用户有 VIP 若满足其一:① 本专题站付费(`site_vip.site_id = SITE_ID`)② vip 站付费(`site_vip.site_id = vip`)。 + +--- + +## 八、安全与注意点 + +1. **Cookie 安全**:`pb_session` 建议 `httpOnly`,防止 XSS 窃取。 +2. **HTTPS**:生产环境必须 HTTPS,Cookie 设置 `Secure`。 +3. **SameSite**:`Lax` 可在站内跳转时携带 Cookie,同时降低 CSRF 风险。 +4. **Token 刷新**:PocketBase token 有过期时间,`/api/auth/me` 中可用 refresh 接口续期,并回写新 token 到 Cookie。 +5. **站点校验**:`/api/vip/check` 等服务端接口需校验请求来源或 `site_id` 与当前域名一致,防止越权。 + +--- + +## 九、小结 + +| 需求 | 实现 | +|------|------| +| 1. 统一账号 | 共用 PocketBase `users`,各站用同一套登录/注册 | +| 2. 跨站 SSO | 登录后调用 `/api/auth/sync-session` 设置 `Domain=.hackrobot.cn` Cookie,各站通过 `/api/auth/me` 读取 | +| 3. 站点独立 VIP | `site_vip` 表存 `(user_id, site_id)`。digital 专题站各自独立;vip 站(`site_id=vip`)付费可解锁所有 digital 专题站 |