's'
This commit is contained in:
@@ -7,6 +7,7 @@ import { VideoPlayer } from "@/app/components/course/VideoPlayer";
|
|||||||
import { LessonMarkdown } from "./LessonMarkdown";
|
import { LessonMarkdown } from "./LessonMarkdown";
|
||||||
import { getLesson } from "@/config/lessons";
|
import { getLesson } from "@/config/lessons";
|
||||||
import { canWatchLesson } from "@/app/lib/course-payment";
|
import { canWatchLesson } from "@/app/lib/course-payment";
|
||||||
|
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
|
||||||
import { setLastWatched, markCompleted } from "@/app/lib/learning";
|
import { setLastWatched, markCompleted } from "@/app/lib/learning";
|
||||||
import { DOWNLOAD_CONFIG } from "@/config/course";
|
import { DOWNLOAD_CONFIG } from "@/config/course";
|
||||||
import Header from "@/app/components/Header";
|
import Header from "@/app/components/Header";
|
||||||
@@ -15,12 +16,13 @@ export default function LessonPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation("community");
|
const { t } = useTranslation("community");
|
||||||
|
const { vip } = useAuthAndVip();
|
||||||
const [toast, setToast] = useState(false);
|
const [toast, setToast] = useState(false);
|
||||||
const moduleIndex = Number(params.moduleIndex);
|
const moduleIndex = Number(params.moduleIndex);
|
||||||
const lessonIndex = Number(params.lessonIndex);
|
const lessonIndex = Number(params.lessonIndex);
|
||||||
|
|
||||||
const lesson = getLesson(moduleIndex, lessonIndex);
|
const lesson = getLesson(moduleIndex, lessonIndex);
|
||||||
const unlocked = canWatchLesson(moduleIndex, lessonIndex);
|
const unlocked = canWatchLesson(moduleIndex, lessonIndex, vip);
|
||||||
const isFreeTrial = moduleIndex === 0 && lessonIndex < 2;
|
const isFreeTrial = moduleIndex === 0 && lessonIndex < 2;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { Link } from "@/i18n/navigation";
|
||||||
import {
|
import {
|
||||||
getPayEnv,
|
getPayEnv,
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from "@/app/lib/payment";
|
} from "@/app/lib/payment";
|
||||||
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
|
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
|
||||||
import type { PayChannel, PayEnv } from "@/app/lib/payment";
|
import type { PayChannel, PayEnv } from "@/app/lib/payment";
|
||||||
|
import { getStoredToken } from "@/app/lib/pocketbase";
|
||||||
|
import AuthModal from "@/app/components/AuthModal";
|
||||||
|
|
||||||
const genderOptions = ["男", "女"];
|
const genderOptions = ["男", "女"];
|
||||||
const singleOptions = ["单身", "恋爱中", "已婚"];
|
const singleOptions = ["单身", "恋爱中", "已婚"];
|
||||||
@@ -56,6 +58,18 @@ export default function JoinPage() {
|
|||||||
const [payRedirecting, setPayRedirecting] = useState(false);
|
const [payRedirecting, setPayRedirecting] = useState(false);
|
||||||
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
|
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
|
||||||
const [payChannel, setPayChannel] = useState<PayChannel>("alipay");
|
const [payChannel, setPayChannel] = useState<PayChannel>("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(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
|
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) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const loggedIn = await checkAuth();
|
||||||
|
if (!loggedIn) {
|
||||||
|
setAuthOpen(true);
|
||||||
|
setError("请先登录或注册后再提交申请");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/join", {
|
const res = await fetch("/api/join", {
|
||||||
@@ -515,6 +535,15 @@ export default function JoinPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AuthModal
|
||||||
|
isOpen={authOpen}
|
||||||
|
onClose={() => setAuthOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setAuthOpen(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
10
app/api/auth/logout/route.ts
Normal file
10
app/api/auth/logout/route.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
61
app/api/auth/me/route.ts
Normal file
61
app/api/auth/me/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/auth/sync-session/route.ts
Normal file
32
app/api/auth/sync-session/route.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,18 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getPocketBaseConfig } from "@/config/services.config";
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
import { getThemeConfig } from "@/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()}`;
|
return `user${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +35,7 @@ async function getAdminToken(): Promise<string | null> {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = getOrCreateUserId();
|
const userId = getOrCreateUserId(request);
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const formData = body && typeof body === "object" ? body : {};
|
const formData = body && typeof body === "object" ? body : {};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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";
|
import { SITE_URL } from "../../lib/env";
|
||||||
|
|
||||||
async function createPayOrder(
|
async function createPayOrder(
|
||||||
@@ -17,6 +17,7 @@ async function createPayOrder(
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
user_id,
|
user_id,
|
||||||
|
site_id: SITE_ID,
|
||||||
total_fee: Number(fee) || joinConfig.amount,
|
total_fee: Number(fee) || joinConfig.amount,
|
||||||
type: orderType,
|
type: orderType,
|
||||||
channel,
|
channel,
|
||||||
@@ -26,7 +27,7 @@ async function createPayOrder(
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
const res = await fetch(`${config.apiUrl}/payh5`, {
|
const res = await fetch(`${config.apiUrl}/digital/payh5`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -148,6 +149,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// 使用 payjsapi /payh5/redirect:mapi.php API接口支付,按 device 返回 payurl/payurl2
|
// 使用 payjsapi /payh5/redirect:mapi.php API接口支付,按 device 返回 payurl/payurl2
|
||||||
const redirectPayload = {
|
const redirectPayload = {
|
||||||
user_id,
|
user_id,
|
||||||
|
site_id: SITE_ID,
|
||||||
total_fee,
|
total_fee,
|
||||||
type,
|
type,
|
||||||
channel,
|
channel,
|
||||||
@@ -157,7 +159,7 @@ export async function POST(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(redirectPayload),
|
body: JSON.stringify(redirectPayload),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 25000);
|
const timeout = setTimeout(() => controller.abort(), 25000);
|
||||||
const res = await fetch(
|
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 }
|
{ cache: "no-store", signal: controller.signal }
|
||||||
).finally(() => clearTimeout(timeout));
|
).finally(() => clearTimeout(timeout));
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|||||||
61
app/api/vip/check/route.ts
Normal file
61
app/api/vip/check/route.ts
Normal file
@@ -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<string | null> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,15 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
|
|||||||
result = await pbLogin(email, password);
|
result = await pbLogin(email, password);
|
||||||
}
|
}
|
||||||
pbSaveAuth(result.token, result.record);
|
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);
|
onSuccess(email);
|
||||||
onClose();
|
onClose();
|
||||||
setEmail("");
|
setEmail("");
|
||||||
|
|||||||
@@ -25,11 +25,28 @@ export default function Header() {
|
|||||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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 = () => {
|
const handleLogout = async () => {
|
||||||
pbLogout();
|
await pbLogout();
|
||||||
setUserEmail(null);
|
setUserEmail(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 = [
|
const days = [
|
||||||
{ day: 1, icon: "👋", color: "bg-sky-500" },
|
{ day: 1, icon: "👋", color: "bg-sky-500" },
|
||||||
@@ -15,7 +17,9 @@ const days = [
|
|||||||
|
|
||||||
export default function Roadmap() {
|
export default function Roadmap() {
|
||||||
const { t } = useTranslation("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 (
|
return (
|
||||||
<section id="roadmap" className="bg-slate-50 py-20 sm:py-28 dark:bg-slate-900">
|
<section id="roadmap" className="bg-slate-50 py-20 sm:py-28 dark:bg-slate-900">
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
@@ -86,7 +90,22 @@ export default function Roadmap() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col overflow-hidden rounded-2xl border-2 border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50/50 p-8 shadow-md transition-all dark:border-amber-800 dark:from-amber-900/20 dark:to-orange-900/10">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (loading) return;
|
||||||
|
if (!user) {
|
||||||
|
setAuthOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!vip) {
|
||||||
|
router.replace("/join");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace("/course");
|
||||||
|
}}
|
||||||
|
className="group flex w-full flex-col overflow-hidden rounded-2xl border-2 border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50/50 p-8 text-left shadow-md transition-all hover:border-amber-300 hover:shadow-lg dark:border-amber-800 dark:from-amber-900/20 dark:to-orange-900/10 dark:hover:border-amber-700"
|
||||||
|
>
|
||||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 text-3xl shadow-lg shadow-amber-200/50 dark:shadow-amber-900/30">
|
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 text-3xl shadow-lg shadow-amber-200/50 dark:shadow-amber-900/30">
|
||||||
🎓
|
🎓
|
||||||
</div>
|
</div>
|
||||||
@@ -99,28 +118,22 @@ export default function Roadmap() {
|
|||||||
<p className="mt-2 flex-1 text-slate-600 dark:text-slate-400">
|
<p className="mt-2 flex-1 text-slate-600 dark:text-slate-400">
|
||||||
{t("course.desc")}
|
{t("course.desc")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<span className="mt-6 inline-flex items-center gap-2 self-start rounded-full bg-amber-500 px-6 py-3 font-semibold text-white transition group-hover:bg-amber-600 dark:bg-amber-600 dark:group-hover:bg-amber-500">
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setCourseToast(true);
|
|
||||||
setTimeout(() => setCourseToast(false), 2000);
|
|
||||||
}}
|
|
||||||
className="mt-6 inline-flex cursor-not-allowed items-center gap-2 self-start rounded-full bg-amber-500 px-6 py-3 font-semibold text-white opacity-90 transition hover:opacity-100 dark:bg-amber-600"
|
|
||||||
>
|
|
||||||
{t("course.cta")}
|
{t("course.cta")}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{courseToast && (
|
<AuthModal
|
||||||
<div
|
isOpen={authOpen}
|
||||||
className="fixed bottom-8 left-1/2 z-50 -translate-x-1/2 rounded-lg bg-slate-800 px-6 py-3 text-sm font-medium text-white shadow-lg dark:bg-slate-700"
|
onClose={() => setAuthOpen(false)}
|
||||||
role="alert"
|
onSuccess={async () => {
|
||||||
>
|
setAuthOpen(false);
|
||||||
{t("course.updating")}
|
const { vip: isVip } = await refetch();
|
||||||
</div>
|
router.replace(isVip ? "/course" : "/join");
|
||||||
)}
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
import { useTranslation } from "@/i18n/navigation";
|
|
||||||
import { CTA_CONFIG } from "@/config/course";
|
import { CTA_CONFIG } from "@/config/course";
|
||||||
import { isPaid } from "@/app/lib/course-payment";
|
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
|
||||||
|
|
||||||
export function CTASection() {
|
export function CTASection() {
|
||||||
const { t } = useTranslation("community");
|
const { user, vip } = useAuthAndVip();
|
||||||
const [paid, setPaid] = useState(false);
|
const paid = !!user && vip;
|
||||||
const [toast, setToast] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
setPaid(isPaid());
|
|
||||||
const onPayUpdate = () => setPaid(isPaid());
|
|
||||||
window.addEventListener("pay:updated", onPayUpdate);
|
|
||||||
return () => window.removeEventListener("pay:updated", onPayUpdate);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-t border-[var(--border)] py-16 sm:py-20 md:py-24">
|
<section className="border-t border-[var(--border)] py-16 sm:py-20 md:py-24">
|
||||||
@@ -45,26 +36,14 @@ export function CTASection() {
|
|||||||
开始学习 →
|
开始学习 →
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href="/join"
|
||||||
onClick={() => {
|
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition hover:opacity-95 sm:px-10"
|
||||||
setToast(true);
|
|
||||||
setTimeout(() => setToast(false), 2000);
|
|
||||||
}}
|
|
||||||
className="inline-flex cursor-not-allowed items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] opacity-90 shadow-lg shadow-[var(--accent)]/25 transition hover:opacity-100 sm:px-10"
|
|
||||||
>
|
>
|
||||||
立即加入 →
|
立即加入 →
|
||||||
</button>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{toast && (
|
|
||||||
<div
|
|
||||||
className="fixed bottom-8 left-1/2 z-50 -translate-x-1/2 rounded-lg bg-slate-800 px-6 py-3 text-sm font-medium text-white shadow-lg dark:bg-slate-700"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{t("updating")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,37 +2,30 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
import { useTranslation } from "@/i18n/navigation";
|
|
||||||
import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/course";
|
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";
|
import { getLastWatched, getProgress } from "@/app/lib/learning";
|
||||||
|
|
||||||
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
|
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||||
|
|
||||||
export function CourseHero() {
|
export function CourseHero() {
|
||||||
const { t } = useTranslation("community");
|
const { user, vip } = useAuthAndVip();
|
||||||
const [paid, setPaid] = useState(false);
|
|
||||||
const [toast, setToast] = useState(false);
|
|
||||||
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
|
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
|
||||||
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
|
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPaid(isPaid());
|
|
||||||
setLast(getLastWatched());
|
setLast(getLastWatched());
|
||||||
setProgress(getProgress(TOTAL_LESSONS));
|
setProgress(getProgress(TOTAL_LESSONS));
|
||||||
const onPay = () => setPaid(isPaid());
|
|
||||||
const onProgress = () => {
|
const onProgress = () => {
|
||||||
setLast(getLastWatched());
|
setLast(getLastWatched());
|
||||||
setProgress(getProgress(TOTAL_LESSONS));
|
setProgress(getProgress(TOTAL_LESSONS));
|
||||||
};
|
};
|
||||||
window.addEventListener("pay:updated", onPay);
|
|
||||||
window.addEventListener("learning:progress", onProgress);
|
window.addEventListener("learning:progress", onProgress);
|
||||||
return () => {
|
return () => window.removeEventListener("learning:progress", onProgress);
|
||||||
window.removeEventListener("pay:updated", onPay);
|
|
||||||
window.removeEventListener("learning:progress", onProgress);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const paid = !!user && vip;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
|
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
|
||||||
@@ -71,16 +64,12 @@ export function CourseHero() {
|
|||||||
进入课程 →
|
进入课程 →
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href="/join"
|
||||||
onClick={() => {
|
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||||
setToast(true);
|
|
||||||
setTimeout(() => setToast(false), 2000);
|
|
||||||
}}
|
|
||||||
className="inline-flex shrink-0 cursor-not-allowed items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] opacity-90 transition hover:opacity-100 sm:px-10"
|
|
||||||
>
|
>
|
||||||
立即报名 →
|
立即报名 →
|
||||||
</button>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{paid && progress.completed > 0 && (
|
{paid && progress.completed > 0 && (
|
||||||
@@ -98,14 +87,6 @@ export function CourseHero() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{toast && (
|
|
||||||
<div
|
|
||||||
className="fixed bottom-8 left-1/2 z-50 -translate-x-1/2 rounded-lg bg-slate-800 px-6 py-3 text-sm font-medium text-white shadow-lg dark:bg-slate-700"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{t("updating")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { Link } from "@/i18n/navigation";
|
|||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { CURRICULUM_CONFIG } from "@/config/course";
|
import { CURRICULUM_CONFIG } from "@/config/course";
|
||||||
import { canWatchLesson } from "@/app/lib/course-payment";
|
import { canWatchLesson } from "@/app/lib/course-payment";
|
||||||
|
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
|
||||||
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/lib/learning";
|
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/lib/learning";
|
||||||
|
|
||||||
export function CurriculumSection() {
|
export function CurriculumSection() {
|
||||||
|
const { vip } = useAuthAndVip();
|
||||||
const [expanded, setExpanded] = useState<Record<number, boolean>>(() =>
|
const [expanded, setExpanded] = useState<Record<number, boolean>>(() =>
|
||||||
Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0]))
|
Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0]))
|
||||||
);
|
);
|
||||||
@@ -98,7 +100,7 @@ export function CurriculumSection() {
|
|||||||
<ul className="border-t border-[var(--border)]">
|
<ul className="border-t border-[var(--border)]">
|
||||||
{c.lessons.map((l) => {
|
{c.lessons.map((l) => {
|
||||||
const free = l.moduleIndex === 0 && l.lessonIndex < 2;
|
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 key = `${l.moduleIndex}-${l.lessonIndex}`;
|
||||||
const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
|
const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
|
||||||
const isCompleted = completed.has(key);
|
const isCompleted = completed.has(key);
|
||||||
|
|||||||
@@ -1,31 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* 课程支付状态 - localStorage(来自 nomadlms)
|
* 课程解锁 - 校验登录态 + VIP(/api/vip/check)
|
||||||
* 与 join 报名支付分离,课程解锁用此模块
|
* 与 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 章为试看,无需支付 */
|
/** 前 2 章为试看,无需支付 */
|
||||||
export function isFreeTrial(moduleIndex: number, lessonIndex: number): boolean {
|
export function isFreeTrial(moduleIndex: number, lessonIndex: number): boolean {
|
||||||
if (moduleIndex === 0 && lessonIndex < 2) return true;
|
if (moduleIndex === 0 && lessonIndex < 2) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 章节是否可观看 */
|
/** 章节是否可观看(需传入 vip 状态,来自 useAuthAndVip 或 /api/vip/check) */
|
||||||
export function canWatchLesson(moduleIndex: number, lessonIndex: number): boolean {
|
export function canWatchLesson(
|
||||||
|
moduleIndex: number,
|
||||||
|
lessonIndex: number,
|
||||||
|
vip: boolean
|
||||||
|
): boolean {
|
||||||
if (isFreeTrial(moduleIndex, lessonIndex)) return true;
|
if (isFreeTrial(moduleIndex, lessonIndex)) return true;
|
||||||
return isPaid();
|
return vip;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) */
|
/** 支付 API 地址(payjsapi) */
|
||||||
export const PAYMENT_API_URL =
|
export const PAYMENT_API_URL =
|
||||||
process.env.PAYMENT_API_URL ||
|
process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
|
||||||
(isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn");
|
|
||||||
|
|
||||||
/** 站点根地址(用于 return_url 等回跳,生产需配置) */
|
/** 站点根地址(用于 return_url 等回跳,生产需配置) */
|
||||||
export const SITE_URL =
|
export const SITE_URL =
|
||||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
process.env.NEXT_PUBLIC_SITE_URL || DEFAULT_SITE_URL;
|
||||||
(isDev ? "http://localhost:3001" : "https://hackrobot.cn");
|
|
||||||
|
|
||||||
/** 是否为本地开发 */
|
/** 是否为本地开发 */
|
||||||
export const IS_DEV = isDev;
|
export const IS_DEV = process.env.NODE_ENV === "development";
|
||||||
|
|||||||
@@ -67,11 +67,16 @@ export function pbSaveAuth(token: string, record: AuthUser): void {
|
|||||||
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
|
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登出 - 清除本地存储 */
|
/** 登出 - 清除本地存储和根域 Cookie */
|
||||||
export function pbLogout(): void {
|
export async function pbLogout(): Promise<void> {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
localStorage.removeItem(PB_STORAGE_KEYS.token);
|
localStorage.removeItem(PB_STORAGE_KEYS.token);
|
||||||
localStorage.removeItem(PB_STORAGE_KEYS.user);
|
localStorage.removeItem(PB_STORAGE_KEYS.user);
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前存储的用户邮箱 */
|
/** 获取当前存储的用户邮箱 */
|
||||||
|
|||||||
45
app/lib/useAuthAndVip.ts
Normal file
45
app/lib/useAuthAndVip.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
37
config/domain.config.ts
Normal file
37
config/domain.config.ts
Normal file
@@ -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}`;
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* 服务配置 - PocketBase、支付等第三方服务
|
* 服务配置 - PocketBase、支付等第三方服务
|
||||||
* 可通过环境变量覆盖,不同主题可配置不同集合/金额
|
* 可通过环境变量覆盖,不同主题可配置不同集合/金额
|
||||||
|
* 域名更换:设置 ROOT_DOMAIN(如 nomadro.com)可推导默认 API 地址
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getThemeConfig } from "./site.config";
|
import { getThemeConfig } from "./site.config";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAYMENT_API_URL,
|
||||||
|
DEFAULT_POCKETBASE_URL,
|
||||||
|
DEFAULT_MINIO_HOST,
|
||||||
|
} from "./domain.config";
|
||||||
|
|
||||||
export interface PocketBaseConfig {
|
export interface PocketBaseConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -59,6 +65,12 @@ export interface ServicesConfig {
|
|||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
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 配置(可传入主题覆盖) */
|
/** 获取 PocketBase 配置(可传入主题覆盖) */
|
||||||
export function getPocketBaseConfig(themeOverride?: { joinCollection?: string }): PocketBaseConfig {
|
export function getPocketBaseConfig(themeOverride?: { joinCollection?: string }): PocketBaseConfig {
|
||||||
const joinCollection =
|
const joinCollection =
|
||||||
@@ -67,7 +79,7 @@ export function getPocketBaseConfig(themeOverride?: { joinCollection?: string })
|
|||||||
"solan";
|
"solan";
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
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",
|
usersCollection: "users",
|
||||||
joinCollection,
|
joinCollection,
|
||||||
};
|
};
|
||||||
@@ -77,11 +89,9 @@ export function getPocketBaseConfig(themeOverride?: { joinCollection?: string })
|
|||||||
export function getPaymentConfig(themeOverride?: { amount?: number; type?: string }): PaymentConfig {
|
export function getPaymentConfig(themeOverride?: { amount?: number; type?: string }): PaymentConfig {
|
||||||
const base = {
|
const base = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
apiUrl:
|
apiUrl: process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL,
|
||||||
process.env.PAYMENT_API_URL ||
|
|
||||||
(isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"),
|
|
||||||
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
|
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",
|
defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup",
|
||||||
zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
|
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) */
|
/** 获取 MinIO 配置(可传入主题覆盖 bucket/prefix) */
|
||||||
export function getMinioConfig(themeOverride?: { bucket?: string; uploadPrefix?: string }): MinioConfig {
|
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 port = parseInt(process.env.MINIO_PORT || "9035", 10);
|
||||||
const useSSL = process.env.MINIO_USE_SSL === "true";
|
const useSSL = process.env.MINIO_USE_SSL === "true";
|
||||||
const bucket = themeOverride?.bucket || process.env.MINIO_BUCKET || "hackrobot";
|
const bucket = themeOverride?.bucket || process.env.MINIO_BUCKET || "hackrobot";
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const THEME_CONFIGS: Record<ThemeId, ThemeConfig> = {
|
|||||||
accent: "amber",
|
accent: "amber",
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
join: { amount: 80, type: "meetup" },
|
join: { amount: 100, type: "meetup" },
|
||||||
pocketbaseJoinCollection: "solan",
|
pocketbaseJoinCollection: "solan",
|
||||||
shopUrl: process.env.NEXT_PUBLIC_SHOP_URL || "https://store.hackrobot.cn/", // 独立站域名
|
shopUrl: process.env.NEXT_PUBLIC_SHOP_URL || "https://store.hackrobot.cn/", // 独立站域名
|
||||||
},
|
},
|
||||||
|
|||||||
458
docs/MULTI_SITE_SSO_VIP_SOLUTION.md
Normal file
458
docs/MULTI_SITE_SSO_VIP_SOLUTION.md
Normal file
@@ -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 <pb_token>
|
||||||
|
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 <admin_token>
|
||||||
|
{
|
||||||
|
"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 专题站 |
|
||||||
Reference in New Issue
Block a user