Files
2025-12-05 00:09:32 +08:00

583 lines
20 KiB
Markdown
Raw Permalink 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.
# 权限系统原理详解
## 🎯 核心问题
**如何实现"一次请求,全局共享"?**
用户访问任何页面时:
- ✅ 只调用一次权限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