# 权限系统原理详解 ## 🎯 核心问题 **如何实现"一次请求,全局共享"?** 用户访问任何页面时: - ✅ 只调用一次权限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 标签: // ========================================== // 第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 (
{/* 🔐 根据权限控制按钮显示 */} {canCreate && } {/* ❌ 不显示 */} {canDelete && } {/* ✅ 显示 */}
); } ``` --- ## 🚀 为什么是"全局共享"? ### 关键点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
{/* 提交后会自动重新加载 loader 数据 */}
``` --- ## 🎨 数据流可视化 ``` ┌─────────────────────────────────────────────────────────────┐ │ 浏览器首次访问 │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 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并返回 │ │ │ │ │ └──────────────────┬────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 浏览器端 │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Remix 全局状态(内存) │ │ │ │ │ │ │ │ loaderData = { │ │ │ │ "root": { │ │ │ │ permissionMap: { │ │ │ │ "/prompts": ["list:read", "delete:delete"] │ │ │ │ } │ │ │ │ } │ │ │ │ } │ │ │ └─────────────────────────────────────────────────────┘ │ │ ↑ │ │ │ useRouteLoaderData("root") │ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ PromptsIndex 组件 │ │ │ │ │ │ │ │ const { hasPermission } = usePermission(); │ │ │ │ // ↑ │ │ │ │ // 读取 root 的 permissionMap │ │ │ │ // 查询当前路由的权限 │ │ │ │ │ │ │ │ const canCreate = hasPermission("...create..."); │ │ │ │ // false │ │ │ │ │ │ │ │ return ( │ │ │ │ {canCreate && } ❌ 不渲染 │ │ │ │ ); │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` --- ## 💡 核心总结 ### 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