's'
This commit is contained in:
458
docs/MULTI_SITE_SSO_VIP_SOLUTION.md
Normal file
458
docs/MULTI_SITE_SSO_VIP_SOLUTION.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# 多站点 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**:每个站点有独立 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`
|
||||
|
||||
**请求体增加:**
|
||||
```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 | 各前端登录/登出时调用上述 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`)时,设置:
|
||||
|
||||
```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`)。
|
||||
|
||||
---
|
||||
|
||||
## 八、安全与注意点
|
||||
|
||||
1. **Cookie 安全**:`pb_session` 建议 `httpOnly`,防止 XSS 窃取。
|
||||
2. **HTTPS**:生产环境必须 HTTPS,Cookie 设置 `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 专题站 |
|
||||
Reference in New Issue
Block a user