;s;
This commit is contained in:
@@ -1,10 +1,15 @@
|
|||||||
import { NextResponse } from "next/server";
|
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_NAME = "pb_session";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST(request: NextRequest) {
|
||||||
|
const domain = getAuthCookieDomain(request);
|
||||||
const res = NextResponse.json({ ok: true });
|
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;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getPocketBaseConfig } from "@/config/services.config";
|
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_NAME = "pb_session";
|
||||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
const domain = getAuthCookieDomain(request);
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
return NextResponse.json({ ok: true, user: null });
|
return NextResponse.json({ ok: true, user: null });
|
||||||
}
|
}
|
||||||
@@ -21,7 +22,7 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const r = NextResponse.json({ ok: true, user: null });
|
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;
|
return r;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -41,7 +42,7 @@ export async function GET(request: NextRequest) {
|
|||||||
token: newToken,
|
token: newToken,
|
||||||
});
|
});
|
||||||
r.cookies.set(COOKIE_NAME, encoded, {
|
r.cookies.set(COOKIE_NAME, encoded, {
|
||||||
domain: AUTH_COOKIE_DOMAIN,
|
...(domain && { domain }),
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: COOKIE_MAX_AGE,
|
maxAge: COOKIE_MAX_AGE,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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_NAME = "pb_session";
|
||||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
|
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 encoded = Buffer.from(payload, "utf-8").toString("base64url");
|
||||||
|
|
||||||
|
const domain = getAuthCookieDomain(request);
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.set(COOKIE_NAME, encoded, {
|
res.cookies.set(COOKIE_NAME, encoded, {
|
||||||
domain: AUTH_COOKIE_DOMAIN,
|
...(domain && { domain }),
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: COOKIE_MAX_AGE,
|
maxAge: COOKIE_MAX_AGE,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
|
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 AuthModal from "./AuthModal";
|
||||||
import ThemeToggle from "./ThemeToggle";
|
import ThemeToggle from "./ThemeToggle";
|
||||||
|
import UserAvatar from "./UserAvatar";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ labelKey: "roadmap" as const, href: "#roadmap" },
|
{ labelKey: "roadmap" as const, href: "#roadmap" },
|
||||||
@@ -45,11 +46,6 @@ export default function Header() {
|
|||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await pbLogout();
|
|
||||||
setUserEmail(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={`fixed top-0 left-0 right-0 z-50 w-full max-w-[100vw] transition-all duration-300 ${
|
className={`fixed top-0 left-0 right-0 z-50 w-full max-w-[100vw] transition-all duration-300 ${
|
||||||
@@ -81,17 +77,7 @@ export default function Header() {
|
|||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1 sm:gap-3">
|
<div className="flex shrink-0 items-center gap-1 sm:gap-3">
|
||||||
{userEmail ? (
|
{userEmail ? (
|
||||||
<div className="flex items-center gap-2">
|
<UserAvatar email={userEmail} onLogout={() => setUserEmail(null)} />
|
||||||
<span className="hidden max-w-[120px] truncate text-sm text-slate-600 dark:text-slate-400 sm:inline">
|
|
||||||
{userEmail}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800"
|
|
||||||
>
|
|
||||||
{t("logout")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setAuthOpen(true)}
|
onClick={() => setAuthOpen(true)}
|
||||||
@@ -160,17 +146,9 @@ export default function Header() {
|
|||||||
{locale === "zh" ? "EN" : "中文"}
|
{locale === "zh" ? "EN" : "中文"}
|
||||||
</button>
|
</button>
|
||||||
{userEmail ? (
|
{userEmail ? (
|
||||||
<div className="mt-2 flex items-center justify-between rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
|
<div className="mt-2 flex items-center gap-3 rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
|
||||||
<span className="truncate text-sm text-slate-600">{userEmail}</span>
|
<UserAvatar email={userEmail} onLogout={() => { setUserEmail(null); setMobileOpen(false); }} />
|
||||||
<button
|
<span className="truncate text-sm text-slate-600 dark:text-slate-400">{userEmail}</span>
|
||||||
onClick={() => {
|
|
||||||
handleLogout();
|
|
||||||
setMobileOpen(false);
|
|
||||||
}}
|
|
||||||
className="text-sm font-medium text-sky-500"
|
|
||||||
>
|
|
||||||
{t("logout")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
60
app/components/UserAvatar.tsx
Normal file
60
app/components/UserAvatar.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useTranslation } from "@/i18n/navigation";
|
||||||
|
import { pbLogout } from "@/app/lib/pocketbase";
|
||||||
|
|
||||||
|
function getAvatarLetter(email: string): string {
|
||||||
|
const local = email.split("@")[0];
|
||||||
|
if (!local) return "?";
|
||||||
|
return local.slice(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserAvatarProps {
|
||||||
|
email: string;
|
||||||
|
onLogout?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 圆形头像 + 点击展开退出按钮,供 digital / meetup / vip 三站复用 */
|
||||||
|
export default function UserAvatar({ email, onLogout }: UserAvatarProps) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await pbLogout();
|
||||||
|
setOpen(false);
|
||||||
|
onLogout?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-slate-900 hover:bg-sky-600"
|
||||||
|
aria-label={t("logout")}
|
||||||
|
>
|
||||||
|
{getAvatarLetter(email)}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
{t("logout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,19 +79,26 @@ export async function pbLogout(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前存储的用户邮箱 */
|
/** 获取当前存储的用户(id + email) */
|
||||||
export function getStoredUserEmail(): string | null {
|
export function getStoredUser(): AuthUser | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
|
const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const user = JSON.parse(raw);
|
const user = JSON.parse(raw);
|
||||||
return user?.email ?? null;
|
if (!user?.id || !user?.email) return null;
|
||||||
|
return { id: user.id, email: user.email };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取当前存储的用户邮箱 */
|
||||||
|
export function getStoredUserEmail(): string | null {
|
||||||
|
const user = getStoredUser();
|
||||||
|
return user?.email ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取存储的 token(用于 API 请求) */
|
/** 获取存储的 token(用于 API 请求) */
|
||||||
export function getStoredToken(): string | null {
|
export function getStoredToken(): string | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export {
|
|||||||
pbRegister,
|
pbRegister,
|
||||||
pbSaveAuth,
|
pbSaveAuth,
|
||||||
pbLogout,
|
pbLogout,
|
||||||
|
getStoredUser,
|
||||||
getStoredUserEmail,
|
getStoredUserEmail,
|
||||||
getStoredToken,
|
getStoredToken,
|
||||||
} from "./auth";
|
} from "./auth";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { getStoredUser, getStoredToken } from "@/app/lib/pocketbase";
|
||||||
|
|
||||||
export interface AuthAndVipState {
|
export interface AuthAndVipState {
|
||||||
user: { id: string; email: string } | null;
|
user: { id: string; email: string } | null;
|
||||||
@@ -14,7 +15,7 @@ export function useAuthAndVip(): AuthAndVipState {
|
|||||||
const [vip, setVip] = useState(false);
|
const [vip, setVip] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
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);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [meRes, vipRes] = await Promise.all([
|
const [meRes, vipRes] = await Promise.all([
|
||||||
@@ -23,15 +24,36 @@ export function useAuthAndVip(): AuthAndVipState {
|
|||||||
]);
|
]);
|
||||||
const meData = await meRes.json();
|
const meData = await meRes.json();
|
||||||
const vipData = await vipRes.json();
|
const vipData = await vipRes.json();
|
||||||
const newUser = meData?.user ?? null;
|
let newUser = meData?.user ?? null;
|
||||||
const newVip = vipData?.vip === true;
|
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);
|
setUser(newUser);
|
||||||
setVip(newVip);
|
setVip(newVip);
|
||||||
return { user: newUser, vip: newVip };
|
return { user: newUser, vip: newVip };
|
||||||
} catch {
|
} catch {
|
||||||
setUser(null);
|
const storedUser = getStoredUser();
|
||||||
|
setUser(storedUser);
|
||||||
setVip(false);
|
setVip(false);
|
||||||
return { user: null, vip: false };
|
return { user: storedUser, vip: false };
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ export const ROOT_DOMAIN =
|
|||||||
export const AUTH_COOKIE_DOMAIN =
|
export const AUTH_COOKIE_DOMAIN =
|
||||||
process.env.AUTH_COOKIE_DOMAIN || `.${ROOT_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 默认地址 */
|
/** 支付 API 默认地址 */
|
||||||
export const DEFAULT_PAYMENT_API_URL = isDev
|
export const DEFAULT_PAYMENT_API_URL = isDev
|
||||||
? "http://127.0.0.1:8700"
|
? "http://127.0.0.1:8700"
|
||||||
|
|||||||
55
docs/AUTH_PAYMENT_VIP_CHECK_REPORT.md
Normal file
55
docs/AUTH_PAYMENT_VIP_CHECK_REPORT.md
Normal file
@@ -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`
|
||||||
@@ -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 窃取。
|
1. **Cookie 安全**:`pb_session` 建议 `httpOnly`,防止 XSS 窃取。
|
||||||
|
|||||||
133
docs/PRODUCTION_ENV_CONFIG.md
Normal file
133
docs/PRODUCTION_ENV_CONFIG.md
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user