This commit is contained in:
2025-12-05 00:09:32 +08:00
parent bb3d22eabf
commit 3d1dbb3f97
214 changed files with 113060 additions and 1232 deletions
+582
View File
@@ -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步:浏览器接收HTMLReact 开始 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>
);
}
```
---
## 🚀 为什么是"全局共享"
### 关键点1root 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