583 lines
20 KiB
Markdown
583 lines
20 KiB
Markdown
# 权限系统原理详解
|
||
|
||
## 🎯 核心问题
|
||
|
||
**如何实现"一次请求,全局共享"?**
|
||
|
||
用户访问任何页面时:
|
||
- ✅ 只调用一次权限API(`/rbac/user/routes`)
|
||
- ✅ 所有子页面都能访问权限数据
|
||
- ✅ 页面切换时不需要重新请求
|
||
|
||
---
|
||
|
||
## 📚 Remix 数据加载机制
|
||
|
||
### 工作流程
|
||
|
||
```
|
||
1. 用户访问 /prompts
|
||
↓
|
||
2. Remix 识别路由层级
|
||
root.tsx (父)
|
||
└─ prompts._index.tsx (子)
|
||
↓
|
||
3. 同时并行调用所有 loader
|
||
┌─────────────────────┐
|
||
│ root.tsx loader │ ← 获取用户信息、权限映射表
|
||
│ (1 次 API 请求) │
|
||
└─────────────────────┘
|
||
┌─────────────────────┐
|
||
│ prompts loader │ ← 获取提示词列表
|
||
│ (另 1 次 API 请求) │
|
||
└─────────────────────┘
|
||
↓
|
||
4. 所有数据在服务端收集完成
|
||
{
|
||
root: { permissionMap, userRole, ... },
|
||
prompts: { templates, total, ... }
|
||
}
|
||
↓
|
||
5. 一次性返回给浏览器
|
||
↓
|
||
6. React 渲染页面,所有组件都能访问这些数据
|
||
```
|
||
|
||
### 关键点
|
||
|
||
1. **root loader 只在首次加载时执行**
|
||
- 用户打开网站 → 执行 root loader
|
||
- 权限数据被加载并缓存在 React Context 中
|
||
|
||
2. **页面切换时不会重新执行 root loader**
|
||
- `/prompts` → `/documents`
|
||
- ❌ 不会重新调用 root loader
|
||
- ✅ 只调用 documents._index.tsx 的 loader
|
||
- ✅ root 的数据(包括权限映射表)仍然可用
|
||
|
||
3. **手动触发重新加载**
|
||
- 用户刷新页面 → 重新执行所有 loader
|
||
- 调用 `revalidate()` → 重新执行指定的 loader
|
||
|
||
---
|
||
|
||
## 🪝 useRouteLoaderData 的工作原理
|
||
|
||
### 什么是 useRouteLoaderData?
|
||
|
||
这是 Remix 提供的 Hook,用于在**任意组件**中访问**任意路由**的 loader 数据。
|
||
|
||
```typescript
|
||
// 在任何组件中使用
|
||
const rootData = useRouteLoaderData("root");
|
||
// ↑
|
||
// 路由ID(在 root.tsx 中定义)
|
||
```
|
||
|
||
### 数据存储位置
|
||
|
||
Remix 在内部维护了一个类似这样的数据结构:
|
||
|
||
```typescript
|
||
// Remix 内部的数据存储(简化版)
|
||
const routeLoaderDataStore = {
|
||
"root": {
|
||
userRole: "admin",
|
||
permissionMap: {
|
||
"/prompts": ["prompt_template:list:read", ...],
|
||
"/documents": ["document:list:read", ...]
|
||
},
|
||
frontendJWT: "eyJhbGci...",
|
||
ENV: { ... }
|
||
},
|
||
"routes/prompts._index": {
|
||
templates: [...],
|
||
total: 27,
|
||
currentPage: 1
|
||
},
|
||
"routes/documents._index": {
|
||
documents: [...],
|
||
total: 150
|
||
}
|
||
};
|
||
```
|
||
|
||
**每个路由的 loader 数据都存储在 Remix 的全局状态中!**
|
||
|
||
### 访问机制
|
||
|
||
```typescript
|
||
// app/hooks/usePermission.tsx
|
||
|
||
export function usePermission() {
|
||
// 1️⃣ 通过路由ID访问 root loader 的数据
|
||
const rootData = useRouteLoaderData("root");
|
||
// ↓
|
||
// 这会从 Remix 的全局状态中读取:
|
||
// routeLoaderDataStore["root"]
|
||
|
||
// 2️⃣ 提取权限映射表
|
||
const permissionMap = rootData?.permissionMap || {};
|
||
// 如果 root loader 执行成功,permissionMap 就是:
|
||
// { "/prompts": [...], "/documents": [...] }
|
||
|
||
// 3️⃣ 获取当前路由路径
|
||
const location = useLocation();
|
||
const currentPath = location.pathname; // 如 "/prompts"
|
||
|
||
// 4️⃣ 根据当前路径查询权限
|
||
const currentPermissions = permissionMap[currentPath] || [];
|
||
// currentPermissions = permissionMap["/prompts"]
|
||
// = ["prompt_template:list:read", ...]
|
||
|
||
// 5️⃣ 提供权限检查方法
|
||
const hasPermission = (key: string) => {
|
||
return currentPermissions.includes(key);
|
||
};
|
||
|
||
return { hasPermission, currentPermissions, ... };
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔄 完整的数据流
|
||
|
||
让我用一个实际的例子演示整个流程:
|
||
|
||
### 场景:用户访问提示词管理页面
|
||
|
||
```typescript
|
||
// ==========================================
|
||
// 第1步:用户在浏览器输入 http://localhost:5173/prompts
|
||
// ==========================================
|
||
|
||
// ==========================================
|
||
// 第2步:Remix 服务器识别路由
|
||
// ==========================================
|
||
// 路由层级:
|
||
// - root.tsx (id: "root")
|
||
// └─ routes/prompts._index.tsx (id: "routes/prompts._index")
|
||
|
||
// ==========================================
|
||
// 第3步:并行执行所有 loader(服务端)
|
||
// ==========================================
|
||
|
||
// app/root.tsx
|
||
export async function loader({ request }: LoaderFunctionArgs) {
|
||
console.log("🔵 [Root Loader] 开始执行");
|
||
|
||
const session = await getUserSession(request);
|
||
const { userRole, frontendJWT } = session;
|
||
|
||
// 📡 调用后端API获取用户路由和权限
|
||
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
|
||
// 后端返回:
|
||
// {
|
||
// success: true,
|
||
// data: [...菜单数据],
|
||
// permissionMap: {
|
||
// "/prompts": ["prompt_template:list:read", "prompt_template:delete:delete"],
|
||
// "/documents": ["document:list:read", "document:create:write"]
|
||
// }
|
||
// }
|
||
|
||
console.log("🔵 [Root Loader] 权限映射表:", routesResult.permissionMap);
|
||
|
||
return Response.json({
|
||
userRole,
|
||
frontendJWT,
|
||
permissionMap: routesResult.permissionMap, // ✅ 关键:传递权限映射表
|
||
ENV: { ... }
|
||
});
|
||
}
|
||
|
||
// app/routes/prompts._index.tsx
|
||
export async function loader({ request }: LoaderFunctionArgs) {
|
||
console.log("🟢 [Prompts Loader] 开始执行");
|
||
|
||
const { getUserSession } = await import("~/api/login/auth.server");
|
||
const { frontendJWT } = await getUserSession(request);
|
||
|
||
// 📡 调用后端API获取提示词列表
|
||
const result = await getPromptTemplates({ page: 1, pageSize: 10 }, frontendJWT);
|
||
|
||
console.log("🟢 [Prompts Loader] 获取到", result.data?.templates.length, "条数据");
|
||
|
||
return Response.json({
|
||
templates: result.data?.templates || [],
|
||
total: result.data?.total || 0
|
||
});
|
||
}
|
||
|
||
// ==========================================
|
||
// 第4步:Remix 收集所有 loader 的数据
|
||
// ==========================================
|
||
const serverData = {
|
||
"root": {
|
||
userRole: "admin",
|
||
frontendJWT: "eyJhbGci...",
|
||
permissionMap: {
|
||
"/prompts": ["prompt_template:list:read", "prompt_template:delete:delete"],
|
||
"/documents": ["document:list:read", "document:create:write"]
|
||
},
|
||
ENV: { ... }
|
||
},
|
||
"routes/prompts._index": {
|
||
templates: [{ id: 1, name: "模板1" }, ...],
|
||
total: 27,
|
||
currentPage: 1
|
||
}
|
||
};
|
||
|
||
// ==========================================
|
||
// 第5步:Remix 将数据嵌入HTML并返回给浏览器(SSR)
|
||
// ==========================================
|
||
// HTML 中会包含这样的 script 标签:
|
||
<script>
|
||
window.__remixContext = {
|
||
state: {
|
||
loaderData: {
|
||
"root": { userRole: "admin", permissionMap: {...}, ... },
|
||
"routes/prompts._index": { templates: [...], total: 27 }
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
// ==========================================
|
||
// 第6步:浏览器接收HTML,React 开始 hydration(水合)
|
||
// ==========================================
|
||
|
||
// ==========================================
|
||
// 第7步:组件渲染,使用 Hook 访问数据
|
||
// ==========================================
|
||
|
||
// app/routes/prompts._index.tsx
|
||
export default function PromptsIndex() {
|
||
// 🪝 使用自定义 Hook
|
||
const { hasPermission, currentPermissions } = usePermission();
|
||
// ↓
|
||
// 内部调用 useRouteLoaderData("root")
|
||
// ↓
|
||
// 从 window.__remixContext.state.loaderData["root"] 中读取
|
||
// ↓
|
||
// rootData = {
|
||
// userRole: "admin",
|
||
// permissionMap: {
|
||
// "/prompts": ["prompt_template:list:read", "prompt_template:delete:delete"]
|
||
// }
|
||
// }
|
||
// ↓
|
||
// location.pathname = "/prompts"
|
||
// ↓
|
||
// currentPermissions = permissionMap["/prompts"]
|
||
// = ["prompt_template:list:read", "prompt_template:delete:delete"]
|
||
|
||
// 检查权限
|
||
const canCreate = hasPermission("prompt_template:create:write");
|
||
// ↓
|
||
// currentPermissions.includes("prompt_template:create:write")
|
||
// ↓
|
||
// false(因为权限列表中没有这个权限)
|
||
|
||
const canDelete = hasPermission("prompt_template:delete:delete");
|
||
// ↓
|
||
// true(权限列表中有这个权限)
|
||
|
||
console.log("🟡 [Prompts Page] 当前权限:", currentPermissions);
|
||
console.log("🟡 [Prompts Page] 可以创建:", canCreate); // false
|
||
console.log("🟡 [Prompts Page] 可以删除:", canDelete); // true
|
||
|
||
return (
|
||
<div>
|
||
{/* 🔐 根据权限控制按钮显示 */}
|
||
{canCreate && <Button>新增模板</Button>} {/* ❌ 不显示 */}
|
||
{canDelete && <Button>删除</Button>} {/* ✅ 显示 */}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🚀 为什么是"全局共享"?
|
||
|
||
### 关键点1:root loader 只执行一次
|
||
|
||
```typescript
|
||
用户首次访问网站
|
||
↓
|
||
root.tsx loader 执行
|
||
↓
|
||
权限映射表被加载到 Remix 全局状态
|
||
↓
|
||
用户切换页面 /prompts → /documents
|
||
↓
|
||
❌ root loader 不会重新执行
|
||
✅ permissionMap 仍然存在于全局状态中
|
||
↓
|
||
新页面的组件可以通过 useRouteLoaderData("root") 访问
|
||
```
|
||
|
||
### 关键点2:所有页面都能访问
|
||
|
||
```typescript
|
||
// 在 /prompts 页面
|
||
const { hasPermission } = usePermission();
|
||
// 读取 rootData.permissionMap["/prompts"]
|
||
|
||
// 切换到 /documents 页面
|
||
const { hasPermission } = usePermission();
|
||
// 读取 rootData.permissionMap["/documents"]
|
||
|
||
// ✅ permissionMap 数据始终在内存中
|
||
// ✅ 不需要重新请求 API
|
||
```
|
||
|
||
### 关键点3:何时重新加载?
|
||
|
||
只有这些情况会重新执行 root loader:
|
||
|
||
1. **用户刷新页面(F5)**
|
||
```
|
||
浏览器发起新的HTTP请求
|
||
↓
|
||
服务器重新执行所有 loader
|
||
↓
|
||
权限数据重新加载
|
||
```
|
||
|
||
2. **手动调用 revalidate**
|
||
```typescript
|
||
import { useRevalidator } from "@remix-run/react";
|
||
|
||
const revalidator = useRevalidator();
|
||
|
||
// 权限变更后手动刷新
|
||
revalidator.revalidate(); // 重新执行所有 loader
|
||
```
|
||
|
||
3. **表单提交后自动重新验证**
|
||
```typescript
|
||
// Remix 的 Form 组件提交后会自动 revalidate
|
||
<Form method="post" action="/update-permissions">
|
||
{/* 提交后会自动重新加载 loader 数据 */}
|
||
</Form>
|
||
```
|
||
|
||
---
|
||
|
||
## 🎨 数据流可视化
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 浏览器首次访问 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
↓
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Remix Server │
|
||
│ │
|
||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||
│ │ root.tsx │ │ prompts._index │ │
|
||
│ │ loader() │ │ loader() │ │
|
||
│ │ │ │ │ │
|
||
│ │ 📡 API Call │ │ 📡 API Call │ │
|
||
│ │ ↓ │ │ ↓ │ │
|
||
│ │ GET /rbac/ │ │ GET /api/v3/ │ │
|
||
│ │ user/routes │ │ prompt- │ │
|
||
│ │ │ │ templates │ │
|
||
│ │ ✅ 返回权限 │ │ ✅ 返回列表 │ │
|
||
│ └──────────────────┘ └──────────────────┘ │
|
||
│ │
|
||
└──────────────────────┬───────────────────────────────────────┘
|
||
↓
|
||
┌──────────────────────────────────┐
|
||
│ 收集所有 loader 数据 │
|
||
│ │
|
||
│ { │
|
||
│ "root": { │
|
||
│ permissionMap: {...} │
|
||
│ }, │
|
||
│ "routes/prompts": { │
|
||
│ templates: [...] │
|
||
│ } │
|
||
│ } │
|
||
└──────────────────┬────────────────┘
|
||
↓
|
||
┌──────────────────────────────────┐
|
||
│ 嵌入到HTML并返回 │
|
||
│ │
|
||
│ <script> │
|
||
│ window.__remixContext = { │
|
||
│ state: { │
|
||
│ loaderData: {...} │
|
||
│ } │
|
||
│ } │
|
||
│ </script> │
|
||
└──────────────────┬────────────────┘
|
||
↓
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 浏览器端 │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ Remix 全局状态(内存) │ │
|
||
│ │ │ │
|
||
│ │ loaderData = { │ │
|
||
│ │ "root": { │ │
|
||
│ │ permissionMap: { │ │
|
||
│ │ "/prompts": ["list:read", "delete:delete"] │ │
|
||
│ │ } │ │
|
||
│ │ } │ │
|
||
│ │ } │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ ↑ │
|
||
│ │ useRouteLoaderData("root") │
|
||
│ │ │
|
||
│ ┌──────────────────────────────────────────────────────┐ │
|
||
│ │ PromptsIndex 组件 │ │
|
||
│ │ │ │
|
||
│ │ const { hasPermission } = usePermission(); │ │
|
||
│ │ // ↑ │ │
|
||
│ │ // 读取 root 的 permissionMap │ │
|
||
│ │ // 查询当前路由的权限 │ │
|
||
│ │ │ │
|
||
│ │ const canCreate = hasPermission("...create..."); │ │
|
||
│ │ // false │ │
|
||
│ │ │ │
|
||
│ │ return ( │ │
|
||
│ │ {canCreate && <Button>新增</Button>} ❌ 不渲染 │ │
|
||
│ │ ); │ │
|
||
│ └──────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 💡 核心总结
|
||
|
||
### usePermission Hook 的作用
|
||
|
||
```typescript
|
||
export function usePermission() {
|
||
// 1️⃣ 从 Remix 全局状态中读取 root loader 的数据
|
||
const rootData = useRouteLoaderData("root");
|
||
|
||
// 2️⃣ 提取权限映射表
|
||
const permissionMap = rootData?.permissionMap || {};
|
||
|
||
// 3️⃣ 获取当前路由路径
|
||
const location = useLocation();
|
||
const currentPath = location.pathname;
|
||
|
||
// 4️⃣ 根据当前路径查询对应的权限列表
|
||
const currentPermissions = permissionMap[currentPath] || [];
|
||
|
||
// 5️⃣ 封装权限检查逻辑
|
||
const hasPermission = (key: string) => {
|
||
return currentPermissions.includes(key);
|
||
};
|
||
|
||
// 6️⃣ 提供便捷方法
|
||
const canCreate = (module: string) => {
|
||
return hasPermission(`${module}:create:write`);
|
||
};
|
||
|
||
return { hasPermission, canCreate, currentPermissions, ... };
|
||
}
|
||
```
|
||
|
||
**Hook 的作用就是:**
|
||
- 📖 **读取**全局状态中的权限数据
|
||
- 🎯 **匹配**当前路由对应的权限
|
||
- 🔍 **检查**用户是否有指定权限
|
||
- 🎁 **封装**成易用的方法
|
||
|
||
### 为什么是"一次请求,全局共享"?
|
||
|
||
| 步骤 | 说明 |
|
||
|------|------|
|
||
| 1️⃣ **一次请求** | root loader 只在首次加载时调用 `/rbac/user/routes` |
|
||
| 2️⃣ **全局存储** | 权限数据存储在 Remix 的全局状态中(内存) |
|
||
| 3️⃣ **所有页面共享** | 任何组件都可以通过 `useRouteLoaderData("root")` 访问 |
|
||
| 4️⃣ **页面切换不重新请求** | 切换路由时,root loader 不会重新执行 |
|
||
| 5️⃣ **刷新才重新加载** | 只有刷新页面或手动 revalidate 才会重新请求 |
|
||
|
||
---
|
||
|
||
## 🔧 对比传统方案
|
||
|
||
### ❌ 传统方案(每次都请求)
|
||
|
||
```typescript
|
||
// 每个页面都独立请求权限
|
||
export default function PromptsPage() {
|
||
const [permissions, setPermissions] = useState([]);
|
||
|
||
useEffect(() => {
|
||
// 📡 每次进入页面都调用API
|
||
fetch('/api/user/permissions')
|
||
.then(res => res.json())
|
||
.then(data => setPermissions(data));
|
||
}, []);
|
||
|
||
// 问题:
|
||
// 1. 每个页面都要写一遍请求逻辑
|
||
// 2. 页面切换时都会重新请求(浪费带宽)
|
||
// 3. 请求期间页面可能闪烁(loading状态)
|
||
}
|
||
```
|
||
|
||
### ✅ Remix 方案(一次请求,全局共享)
|
||
|
||
```typescript
|
||
// root.tsx - 只请求一次
|
||
export async function loader() {
|
||
const permissionMap = await getPermissions(); // 📡 只调用一次
|
||
return { permissionMap };
|
||
}
|
||
|
||
// 任何页面 - 直接使用
|
||
export default function PromptsPage() {
|
||
const { hasPermission } = usePermission();
|
||
// ✅ 不需要请求,直接从全局状态读取
|
||
// ✅ 页面切换时无需重新请求
|
||
// ✅ 数据立即可用,无loading状态
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 性能对比
|
||
|
||
### 场景:用户访问5个页面
|
||
|
||
**传统方案**:
|
||
```
|
||
/prompts → 📡 请求权限 (200ms)
|
||
/documents → 📡 请求权限 (200ms)
|
||
/rules → 📡 请求权限 (200ms)
|
||
/settings → 📡 请求权限 (200ms)
|
||
/reports → 📡 请求权限 (200ms)
|
||
────────────────────────────────
|
||
总计:5次请求,1000ms
|
||
```
|
||
|
||
**Remix 方案**:
|
||
```
|
||
首次加载 → 📡 请求权限 (200ms) ← 只有这一次!
|
||
/prompts → ✅ 从内存读取 (0ms)
|
||
/documents → ✅ 从内存读取 (0ms)
|
||
/rules → ✅ 从内存读取 (0ms)
|
||
/settings → ✅ 从内存读取 (0ms)
|
||
/reports → ✅ 从内存读取 (0ms)
|
||
────────────────────────────────
|
||
总计:1次请求,200ms
|
||
性能提升:80%!
|
||
```
|
||
|
||
---
|
||
|
||
**文档更新日期**: 2025-11-27
|
||
**作者**: Claude Code
|