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

472 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 多站点 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_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 | 各前端登录/登出时调用上述 APICookie 优先 auth 初始化 | ✅ 已实现 |
| 5 | 各前端配置 `NEXT_PUBLIC_SITE_ID`VIP 校验按 `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_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`)。
---
## 七.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 窃取。
2. **HTTPS**:生产环境必须 HTTPSCookie 设置 `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 专题站 |