all in
This commit is contained in:
@@ -0,0 +1,582 @@
|
||||
# 权限系统原理详解
|
||||
|
||||
## 🎯 核心问题
|
||||
|
||||
**如何实现"一次请求,全局共享"?**
|
||||
|
||||
用户访问任何页面时:
|
||||
- ✅ 只调用一次权限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
|
||||
Reference in New Issue
Block a user