Files
gitlab-instance-0a899031_di…/docs/MULTI_SITE_SSO_VIP_SOLUTION.md
2026-03-12 05:40:18 -05:00

18 KiB
Raw Blame History

多站点 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:每个站点有独立 VIPVIP 权限仅作用于对应域名

二、整体方案

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_idPocketBase 增加 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_iddigital / meetup / vip
  • out_trade_nopaidpaid_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_idsite_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-sessionPOST

作用: 登录成功后,将 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/meGET

作用: 根据 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/logoutPOST

清除根域 Cookie

export async function POST() {
  const res = NextResponse.json({ ok: true });
  res.cookies.set("pb_session", "", { domain: ".hackrobot.cn", path: "/", maxAge: 0 });
  return res;
}

五、前端改造

修改 AuthModal.tsxpbSaveAuth 调用处:

// 登录/注册成功后
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兼容旧会话
// 示例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;
}
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 用户 IDusers.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_idsite_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 创建 sitessite_vip 集合 已创建
2 payjsapi 支持 site_id、支付回调写 site_vip 已实现
3 各前端新增 /api/auth/sync-session/api/auth/me/api/auth/logout 已实现
4 各前端登录/登出时调用上述 APICookie 优先 auth 初始化 已实现
5 各前端配置 NEXT_PUBLIC_SITE_IDVIP 校验按 site_id 查询 已实现
6 join 流程改为使用 PocketBase 用户 IDCookie 有则用) 已实现

七.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_aidigital_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 等) 各自独立 VIPNEXT_PUBLIC_SITE_IDdigital_aidigital_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 刷新后仍无登录态,请确认:

  1. 必须实现 /api/auth/meGET读取 pb_session Cookie解析 token调用 PocketBase refresh 验证,返回 { user, token }。digital 站已实现,可参考 app/api/auth/me/route.ts
  2. 必须实现 /api/auth/sync-sessionPOST登录成功后由前端调用将 token 写入 Domain=.hackrobot.cn 的 Cookie。
  3. 必须实现 /api/auth/logoutPOST清除 pb_session Cookie。
  4. 启动时:先调 GET /api/auth/me,若有 userpbSaveAuth 并视为已登录;若无则读 localStorage 兼容。
  5. Cookie domain:生产环境(*.hackrobot.cn)必须设置 domain: ".hackrobot.cn"localhost 开发时 domain 应为 undefined(不设),否则 Cookie 无法写入。digital 已通过 getAuthCookieDomain(request) 自动处理。
  6. 顶部头像登录后在顶部显示圆形头像邮箱首字母点击展开退出按钮。digital 已实现 UserAvatar 组件(app/components/UserAvatar.tsxmeetup/vip 可复制该组件并在 Header 中替换原有登录态展示。

八、安全与注意点

  1. Cookie 安全pb_session 建议 httpOnly,防止 XSS 窃取。
  2. HTTPS:生产环境必须 HTTPSCookie 设置 Secure
  3. SameSiteLax 可在站内跳转时携带 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 专题站