diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 22eeebf..7e07949 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,10 +1,15 @@ -import { NextResponse } from "next/server"; -import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; +import { NextRequest, NextResponse } from "next/server"; +import { getAuthCookieDomain } from "@/config/domain.config"; const COOKIE_NAME = "pb_session"; -export async function POST() { +export async function POST(request: NextRequest) { + const domain = getAuthCookieDomain(request); const res = NextResponse.json({ ok: true }); - res.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 }); + res.cookies.set(COOKIE_NAME, "", { + ...(domain && { domain }), + path: "/", + maxAge: 0, + }); return res; } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index 461a2bd..c1b7864 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -1,12 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { getPocketBaseConfig } from "@/config/services.config"; -import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; +import { getAuthCookieDomain } 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; + const domain = getAuthCookieDomain(request); if (!cookie) { return NextResponse.json({ ok: true, user: null }); } @@ -21,7 +22,7 @@ export async function GET(request: NextRequest) { }); if (!res.ok) { const r = NextResponse.json({ ok: true, user: null }); - r.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 }); + r.cookies.set(COOKIE_NAME, "", { ...(domain && { domain }), path: "/", maxAge: 0 }); return r; } const data = await res.json(); @@ -41,7 +42,7 @@ export async function GET(request: NextRequest) { token: newToken, }); r.cookies.set(COOKIE_NAME, encoded, { - domain: AUTH_COOKIE_DOMAIN, + ...(domain && { domain }), path: "/", maxAge: COOKIE_MAX_AGE, secure: process.env.NODE_ENV === "production", diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts index d36ebaa..6fa0780 100644 --- a/app/api/auth/sync-session/route.ts +++ b/app/api/auth/sync-session/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config"; +import { getAuthCookieDomain } from "@/config/domain.config"; const COOKIE_NAME = "pb_session"; const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出 @@ -19,9 +19,10 @@ export async function POST(request: NextRequest) { }); const encoded = Buffer.from(payload, "utf-8").toString("base64url"); + const domain = getAuthCookieDomain(request); const res = NextResponse.json({ ok: true }); res.cookies.set(COOKIE_NAME, encoded, { - domain: AUTH_COOKIE_DOMAIN, + ...(domain && { domain }), path: "/", maxAge: COOKIE_MAX_AGE, secure: process.env.NODE_ENV === "production", diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 63e0c19..39bff0f 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -2,9 +2,10 @@ import { useState, useEffect } from "react"; import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation"; -import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase"; +import { getStoredUserEmail } from "@/app/lib/pocketbase"; import AuthModal from "./AuthModal"; import ThemeToggle from "./ThemeToggle"; +import UserAvatar from "./UserAvatar"; const navLinks = [ { labelKey: "roadmap" as const, href: "#roadmap" }, @@ -45,11 +46,6 @@ export default function Header() { return () => { mounted = false; }; }, []); - const handleLogout = async () => { - await pbLogout(); - setUserEmail(null); - }; - return (
{userEmail ? ( -
- - {userEmail} - - -
+ setUserEmail(null)} /> ) : ( {userEmail ? ( -
- {userEmail} - +
+ { setUserEmail(null); setMobileOpen(false); }} /> + {userEmail}
) : ( + {open && ( +
+ +
+ )} +
+ ); +} diff --git a/app/lib/pocketbase/auth.ts b/app/lib/pocketbase/auth.ts index 2e38382..d6ac569 100644 --- a/app/lib/pocketbase/auth.ts +++ b/app/lib/pocketbase/auth.ts @@ -79,19 +79,26 @@ export async function pbLogout(): Promise { } } -/** 获取当前存储的用户邮箱 */ -export function getStoredUserEmail(): string | null { +/** 获取当前存储的用户(id + email) */ +export function getStoredUser(): AuthUser | null { if (typeof window === "undefined") return null; try { const raw = localStorage.getItem(PB_STORAGE_KEYS.user); if (!raw) return null; const user = JSON.parse(raw); - return user?.email ?? null; + if (!user?.id || !user?.email) return null; + return { id: user.id, email: user.email }; } catch { return null; } } +/** 获取当前存储的用户邮箱 */ +export function getStoredUserEmail(): string | null { + const user = getStoredUser(); + return user?.email ?? null; +} + /** 获取存储的 token(用于 API 请求) */ export function getStoredToken(): string | null { if (typeof window === "undefined") return null; diff --git a/app/lib/pocketbase/index.ts b/app/lib/pocketbase/index.ts index 48fd101..aea22de 100644 --- a/app/lib/pocketbase/index.ts +++ b/app/lib/pocketbase/index.ts @@ -3,6 +3,7 @@ export { pbRegister, pbSaveAuth, pbLogout, + getStoredUser, getStoredUserEmail, getStoredToken, } from "./auth"; diff --git a/app/lib/useAuthAndVip.ts b/app/lib/useAuthAndVip.ts index 7abb90e..9c746e7 100644 --- a/app/lib/useAuthAndVip.ts +++ b/app/lib/useAuthAndVip.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { getStoredUser, getStoredToken } from "@/app/lib/pocketbase"; export interface AuthAndVipState { user: { id: string; email: string } | null; @@ -14,7 +15,7 @@ export function useAuthAndVip(): AuthAndVipState { const [vip, setVip] = useState(false); const [loading, setLoading] = useState(true); - const refetch = useCallback(async (): Promise<{ user: typeof user; vip: boolean }> => { + const refetch = useCallback(async (): Promise<{ user: { id: string; email: string } | null; vip: boolean }> => { setLoading(true); try { const [meRes, vipRes] = await Promise.all([ @@ -23,15 +24,36 @@ export function useAuthAndVip(): AuthAndVipState { ]); const meData = await meRes.json(); const vipData = await vipRes.json(); - const newUser = meData?.user ?? null; - const newVip = vipData?.vip === true; + let newUser = meData?.user ?? null; + let newVip = vipData?.vip === true; + + // 与 Header 一致:API 无用户时,尝试从 localStorage 恢复(如 cookie 未同步、localhost 开发等) + if (!newUser) { + const storedUser = getStoredUser(); + const storedToken = getStoredToken(); + if (storedUser && storedToken) { + newUser = storedUser; + try { + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: storedToken, record: storedUser }), + credentials: "include", + }); + } catch { + /* ignore */ + } + } + } + setUser(newUser); setVip(newVip); return { user: newUser, vip: newVip }; } catch { - setUser(null); + const storedUser = getStoredUser(); + setUser(storedUser); setVip(false); - return { user: null, vip: false }; + return { user: storedUser, vip: false }; } finally { setLoading(false); } diff --git a/config/domain.config.ts b/config/domain.config.ts index a719971..89c9ec3 100644 --- a/config/domain.config.ts +++ b/config/domain.config.ts @@ -20,6 +20,15 @@ export const ROOT_DOMAIN = export const AUTH_COOKIE_DOMAIN = process.env.AUTH_COOKIE_DOMAIN || `.${ROOT_DOMAIN}`; +/** 根据请求 host 返回 Cookie domain。localhost 时不设 domain,生产环境用根域实现跨站 SSO */ +export function getAuthCookieDomain(request: { headers: { get: (name: string) => string | null } }): string | undefined { + const envDomain = process.env.AUTH_COOKIE_DOMAIN; + if (envDomain) return envDomain; + const host = request.headers.get("host") ?? ""; + if (host.includes("localhost") || host.startsWith("127.0.0.1")) return undefined; + return AUTH_COOKIE_DOMAIN; +} + /** 支付 API 默认地址 */ export const DEFAULT_PAYMENT_API_URL = isDev ? "http://127.0.0.1:8700" diff --git a/docs/AUTH_PAYMENT_VIP_CHECK_REPORT.md b/docs/AUTH_PAYMENT_VIP_CHECK_REPORT.md new file mode 100644 index 0000000..eb9fac4 --- /dev/null +++ b/docs/AUTH_PAYMENT_VIP_CHECK_REPORT.md @@ -0,0 +1,55 @@ +# 登录/支付/VIP 权限检查报告 + +检查范围:digital、cnomadcna、nomadvip、payjsapi + +## 一、登录流程 + +| 项目 | /api/auth/me | /api/auth/sync-session | /api/auth/logout | credentials | +|------|--------------|------------------------|------------------|-------------| +| digital | ✅ 读 pb_session,refresh 回写 | ✅ 设置 Cookie | ✅ 清除 Cookie | ✅ include | +| cnomadcna | ✅ | ✅ | ✅ | ✅ AuthModal/JoinModal | +| nomadvip | ✅ | ✅ | ✅ | ✅ AuthForm | + +**Cookie domain:** +- digital:已用 `getAuthCookieDomain(request)`,localhost 不设 domain +- cnomadcna:已改为 `getAuthCookieDomain`,localhost 兼容 +- nomadvip:已改为 `getAuthCookieDomain`,localhost 兼容 + +## 二、支付流程 + +| 项目 | 前端入口 | Next.js API | payjsapi 路径 | site_id | +|------|----------|-------------|---------------|---------| +| digital | join 页 / Roadmap | /api/pay | /digital/payh5 | digital | +| cnomadcna | HeroSection JoinModal | /api/pay | /cnomadcna/payh5 | meetup | +| nomadvip | 支付页 | 直接调 payjsapi | /nomadvip/payh5 | vip | + +**payjsapi 回调:** +- digital/cnomadcna:`_digital_base`,xorpay_notify / zpay_notify → `handle_payment_success` → site_vip +- nomadvip:xorpay_notify / zpay_notify → `handle_payment_success` → site_vip +- meetup 类型:effective_site_id 固定为 "meetup" +- vip 类型:effective_site_id 为 "vip" + +## 三、VIP 权限 + +| 项目 | SITE_ID | VIP 校验逻辑 | +|------|---------|--------------| +| digital | digital | site_id=digital 或 site_id=vip | +| cnomadcna | meetup | site_id=meetup | +| nomadvip | vip | site_id=vip | + +**site_vip 表:** user_id + site_id + order_id + expires_at + +## 四、环境变量 + +| 变量 | 说明 | +|------|------| +| AUTH_COOKIE_DOMAIN | 生产环境根域,如 .hackrobot.cn | +| POCKETBASE_EMAIL / POCKETBASE_PASSWORD | VIP 查询用 admin | +| PAYMENT_API_URL | payjsapi 地址,如 https://api.hackrobot.cn | +| NEXT_PUBLIC_POCKETBASE_URL | PocketBase 地址 | +| NEXT_PUBLIC_SITE_ID | digital / meetup / vip | + +## 五、本次修复 + +1. **cnomadcna**:auth/me、sync-session、logout 改用 `getAuthCookieDomain`,支持 localhost +2. **nomadvip**:同上,新增 `app/lib/auth-cookie-domain.ts` diff --git a/docs/MULTI_SITE_SSO_VIP_SOLUTION.md b/docs/MULTI_SITE_SSO_VIP_SOLUTION.md index e5a52b2..f3a8f41 100644 --- a/docs/MULTI_SITE_SSO_VIP_SOLUTION.md +++ b/docs/MULTI_SITE_SSO_VIP_SOLUTION.md @@ -439,6 +439,19 @@ digital 专题站 VIP 校验:用户有 VIP 若满足其一:① 本专题站 --- +## 七.2 meetup / vip 站跨站 SSO 实现清单 + +若 meetup.hackrobot.cn、vip.hackrobot.cn 刷新后仍无登录态,请确认: + +1. **必须实现** `/api/auth/me`(GET):读取 `pb_session` Cookie,解析 token,调用 PocketBase refresh 验证,返回 `{ user, token }`。digital 站已实现,可参考 `app/api/auth/me/route.ts`。 +2. **必须实现** `/api/auth/sync-session`(POST):登录成功后由前端调用,将 token 写入 `Domain=.hackrobot.cn` 的 Cookie。 +3. **必须实现** `/api/auth/logout`(POST):清除 `pb_session` Cookie。 +4. **启动时**:先调 `GET /api/auth/me`,若有 `user` 则 `pbSaveAuth` 并视为已登录;若无则读 localStorage 兼容。 +5. **Cookie domain**:生产环境(`*.hackrobot.cn`)必须设置 `domain: ".hackrobot.cn"`;localhost 开发时 domain 应为 `undefined`(不设),否则 Cookie 无法写入。digital 已通过 `getAuthCookieDomain(request)` 自动处理。 +6. **顶部头像**:登录后在顶部显示圆形头像(邮箱首字母),点击展开退出按钮。digital 已实现 `UserAvatar` 组件(`app/components/UserAvatar.tsx`),meetup/vip 可复制该组件并在 Header 中替换原有登录态展示。 + +--- + ## 八、安全与注意点 1. **Cookie 安全**:`pb_session` 建议 `httpOnly`,防止 XSS 窃取。 diff --git a/docs/PRODUCTION_ENV_CONFIG.md b/docs/PRODUCTION_ENV_CONFIG.md new file mode 100644 index 0000000..c8a1ca2 --- /dev/null +++ b/docs/PRODUCTION_ENV_CONFIG.md @@ -0,0 +1,133 @@ +# 生产环境配置指南 + +各项目在部署到生产环境时,需在项目根目录创建 `.env.local`(或 `.env.production`),并配置以下变量。 + +--- + +## 一、digital(数字游民指南) + +路径:`digital/.env.local` + +```bash +# ========== 根域与 Cookie(跨站 SSO 必填)========== +ROOT_DOMAIN=hackrobot.cn +AUTH_COOKIE_DOMAIN=.hackrobot.cn + +# ========== PocketBase ========== +NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn +POCKETBASE_EMAIL=你的PocketBase管理员邮箱 +POCKETBASE_PASSWORD=你的PocketBase管理员密码 + +# ========== 支付(payjsapi)========== +PAYMENT_API_URL=https://api.hackrobot.cn + +# ========== 站点 ========== +NEXT_PUBLIC_SITE_ID=digital +NEXT_PUBLIC_SITE_URL=https://digital.hackrobot.cn +``` + +--- + +## 二、cnomadcna(游牧中国) + +路径:`cnomadcna/.env.local` + +```bash +# ========== 根域与 Cookie(跨站 SSO 必填)========== +ROOT_DOMAIN=hackrobot.cn +AUTH_COOKIE_DOMAIN=.hackrobot.cn + +# ========== PocketBase ========== +NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn +POCKETBASE_EMAIL=你的PocketBase管理员邮箱 +POCKETBASE_PASSWORD=你的PocketBase管理员密码 + +# ========== 支付(payjsapi)========== +PAYMENT_API_URL=https://api.hackrobot.cn + +# ========== 站点 ========== +NEXT_PUBLIC_SITE_ID=meetup +NEXT_PUBLIC_SITE_URL=https://meetup.hackrobot.cn +``` + +--- + +## 三、nomadvip(异度星球) + +路径:`nomadvip/.env.local` + +```bash +# ========== 根域与 Cookie(跨站 SSO 必填)========== +AUTH_COOKIE_DOMAIN=.hackrobot.cn + +# ========== PocketBase ========== +NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn + +# ========== 支付(payjsapi)========== +PAYMENT_API_URL=https://api.hackrobot.cn +NEXT_PUBLIC_API_URL=https://api.hackrobot.cn + +# ========== 站点 ========== +NEXT_PUBLIC_SITE_ID=vip +``` + +--- + +## 四、payjsapi(支付后端) + +路径:`payjsapi/.env` + +```bash +# ========== 回调地址(ZPAY 异步通知必填)========== +BASE_URL=https://api.hackrobot.cn + +# ========== 支付渠道 ========== +PAYMENT_PROVIDER=zpay + +# ========== ZPAY ========== +ZPAY_PID=你的ZPAY商户ID +ZPAY_KEY=你的ZPAY密钥 + +# ========== PocketBase ========== +PB_URL=https://pocketbase.hackrobot.cn +PB_ADMIN_EMAIL=你的PocketBase管理员邮箱 +PB_ADMIN_PASSWORD=你的PocketBase管理员密码 + +# ========== Memos(nomadvip 专用,可选)========== +# MEMOS_API=... +# MEMOS_TOKEN=... +``` + +--- + +## 五、变量说明 + +| 变量 | 作用 | 说明 | +|------|------|------| +| AUTH_COOKIE_DOMAIN | 跨站 SSO | 设为 `.hackrobot.cn`,三站共享登录态 | +| POCKETBASE_EMAIL / POCKETBASE_PASSWORD | VIP 查询 | 需 PocketBase 管理员账号,用于 `/api/vip/check` | +| PAYMENT_API_URL | 支付接口 | payjsapi 部署地址 | +| NEXT_PUBLIC_SITE_ID | VIP 隔离 | digital / meetup / vip | + +--- + +## 六、配置步骤 + +1. 在对应项目根目录创建 `.env.local`(Next.js 项目)或 `.env`(payjsapi) +2. 复制上述示例,按实际域名和账号修改 +3. 重启服务使配置生效 + +```bash +# 示例:digital 项目 +cd digital +cp .env.example .env.local # 或手动创建 +# 编辑 .env.local 填入生产配置 +``` + +--- + +## 七、注意事项 + +- `.env.local` 已在 `.gitignore` 中,不会被提交 +- 更换域名时,同时修改 `ROOT_DOMAIN` 和 `AUTH_COOKIE_DOMAIN`(如 `.nomadro.com`) +- 确保域名解析到正确服务器,nginx 反向代理指向 `https://api.hackrobot.cn` → payjsapi 端口(如 8700)