18 KiB
18 KiB
多站点 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 需求
- 统一账号:任一前端注册后,其他前端可用同一账号密码登录
- 跨站 SSO:从 A 站跳转到 B/C 站时,若 A 已登录,B/C 自动为登录态
- 站点独立 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
请求体增加:
{
"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
响应:
{
"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。
请求体:
{
"token": "pb_jwt_token",
"record": { "id": "xxx", "email": "user@example.com" }
}
实现要点:
// 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 返回当前用户,供各子站判断登录态。
// 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:
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 调用处:
// 登录/注册成功后
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 优先
各站点的「获取当前用户」逻辑改为:
- 先调
GET /api/auth/me - 若有
user,用返回的token更新 localStorage,并视为已登录 - 若无,再读 localStorage(兼容旧会话)
// 示例: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
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:
// 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 查询:
// 调用 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 地址):
// 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= PocketBaseusers.id - 若未登录:可先创建匿名订单,支付成功后要求登录/注册,再绑定
user_id;或强制登录后再支付
6.2 支付成功回调 → 写入 site_vip
payjsapi 支付成功时:
- 根据
out_trade_no取订单的user_id、site_id - 调用 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)时,设置:
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)。
七.2 meetup / vip 站跨站 SSO 实现清单
若 meetup.hackrobot.cn、vip.hackrobot.cn 刷新后仍无登录态,请确认:
- 必须实现
/api/auth/me(GET):读取pb_sessionCookie,解析 token,调用 PocketBase refresh 验证,返回{ user, token }。digital 站已实现,可参考app/api/auth/me/route.ts。 - 必须实现
/api/auth/sync-session(POST):登录成功后由前端调用,将 token 写入Domain=.hackrobot.cn的 Cookie。 - 必须实现
/api/auth/logout(POST):清除pb_sessionCookie。 - 启动时:先调
GET /api/auth/me,若有user则pbSaveAuth并视为已登录;若无则读 localStorage 兼容。 - Cookie domain:生产环境(
*.hackrobot.cn)必须设置domain: ".hackrobot.cn";localhost 开发时 domain 应为undefined(不设),否则 Cookie 无法写入。digital 已通过getAuthCookieDomain(request)自动处理。 - 顶部头像:登录后在顶部显示圆形头像(邮箱首字母),点击展开退出按钮。digital 已实现
UserAvatar组件(app/components/UserAvatar.tsx),meetup/vip 可复制该组件并在 Header 中替换原有登录态展示。
八、安全与注意点
- Cookie 安全:
pb_session建议httpOnly,防止 XSS 窃取。 - HTTPS:生产环境必须 HTTPS,Cookie 设置
Secure。 - SameSite:
Lax可在站内跳转时携带 Cookie,同时降低 CSRF 风险。 - Token 刷新:PocketBase token 有过期时间,
/api/auth/me中可用 refresh 接口续期,并回写新 token 到 Cookie。 - 站点校验:
/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 专题站 |