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