# 基于路由的权限管理方案 ## 📋 问题描述 后端在查询用户路由时(`GET /rbac/user/routes`),会为每个路由附加该用户在该路由下的权限列表。 **示例场景**: - 用户访问 `/prompts` 路由 - 后端返回该路由的权限:`['prompt_template:list:read', 'prompt_template:delete:delete']` - **没有返回**:`['prompt_template:detail:read', 'prompt_template:create:write', 'prompt_template:update:write']` - 因此,页面上的"新增"、"编辑"、"查看详情"按钮应该隐藏或禁用 ## 🎯 核心挑战 1. 如何存储每个路由的权限列表? 2. 如何让权限数据在所有页面中快速访问? 3. 如何避免每次切换页面都重新请求权限? 4. 如何处理嵌套路由的权限继承? --- ## 💡 解决方案 ### 方案概述 采用**扁平化权限映射表** + **全局状态管理**的方式: 1. 后端返回路由时,每个路由附带权限列表 2. 前端在 `root.tsx` loader 中构建权限映射表 3. 将权限映射表通过 `useRouteLoaderData` 暴露给所有子路由 4. 更新 `usePermission` Hook 支持从映射表中查询权限 --- ## 🔧 实施步骤 ### 第一步:更新后端API返回格式 后端需要在路由接口返回时,为每个路由添加 `permissions` 字段: ```typescript // 后端API返回格式 interface BackendRouteInfo { id: number; route_path: string; route_name: string; route_title: string; icon: string | null; parent_id: number | null; sort_order: number; is_hidden: boolean; permissions?: string[]; // ✅ 新增:该路由下用户拥有的权限列表 children?: BackendRouteInfo[]; } // 返回示例 { "code": 0, "msg": "成功", "data": { "user_id": 123, "username": "张三", "routes": [ { "id": 10, "route_path": "/prompts", "route_name": "prompts", "route_title": "提示词管理", "icon": "ri-chat-1-line", "parent_id": 5, "sort_order": 3, "is_hidden": false, "permissions": [ "prompt_template:list:read", // 有列表查看权限 "prompt_template:detail:read", // 有详情查看权限 "prompt_template:delete:delete" // 有删除权限 // ❌ 没有 create:write, update:write 权限 ] }, { "id": 11, "route_path": "/documents", "route_name": "documents", "route_title": "文档列表", "icon": "ri-file-list-3-line", "parent_id": 3, "sort_order": 2, "is_hidden": false, "permissions": [ "document:list:read", "document:detail:read", "document:create:write", "document:update:write", "document:delete:delete" ] } ] } } ``` ### 第二步:更新前端接口定义 ```typescript // app/api/auth/user-routes.ts export interface BackendRouteInfo { id: number; route_path: string; route_name: string; component: string; parent_id: number | null; route_title: string; icon: string | null; sort_order: number; is_hidden: boolean; is_cache: boolean; meta: string; permissions?: string[]; // ✅ 新增:该路由的权限列表 children?: BackendRouteInfo[]; } export interface MenuItem { id: string; title: string; path: string; icon: string; order: number; hideBreadcrumb?: boolean; requiredRole?: string; permissions?: string[]; // ✅ 新增:该菜单项的权限列表 children?: MenuItem[]; } ``` ### 第三步:构建权限映射表 在 `app/api/auth/user-routes.ts` 中添加工具函数: ```typescript /** * 权限映射表类型 * key: 路由路径 (如 '/prompts', '/documents') * value: 该路由下的权限列表 */ export type PermissionMap = Map; /** * 从路由树中提取权限映射表 * @param routes 路由树 * @returns 权限映射表 (路径 -> 权限列表) */ export function buildPermissionMap(routes: BackendRouteInfo[]): PermissionMap { const permissionMap = new Map(); function traverse(routeList: BackendRouteInfo[]) { for (const route of routeList) { // 存储当前路由的权限 if (route.permissions && route.permissions.length > 0) { permissionMap.set(route.route_path, route.permissions); } // 递归处理子路由 if (route.children && route.children.length > 0) { traverse(route.children); } } } traverse(routes); return permissionMap; } /** * 将权限映射表转换为普通对象(用于JSON序列化) */ export function permissionMapToObject(map: PermissionMap): Record { const obj: Record = {}; map.forEach((value, key) => { obj[key] = value; }); return obj; } /** * 从对象恢复权限映射表 */ export function objectToPermissionMap(obj: Record): PermissionMap { const map = new Map(); Object.entries(obj).forEach(([key, value]) => { map.set(key, value); }); return map; } ``` 更新 `getUserRoutesByRole` 函数: ```typescript export async function getUserRoutesByRole( roleKey: string, jwt?: string, includeHidden: boolean = false ): Promise<{ success: boolean; data?: MenuItem[]; permissionMap?: Record; // ✅ 新增:返回权限映射表 error?: string; shouldRedirectToHome?: boolean; }> { try { // ... 现有的API请求代码 ... const routes = backendResponse.data.routes; // ✅ 构建权限映射表 const permissionMapObj = permissionMapToObject(buildPermissionMap(routes)); // 将后端路由格式转换为前端 MenuItem 格式 const menuItems = convertBackendRoutesToMenuItems(routes, includeHidden); return { success: true, data: menuItems, permissionMap: permissionMapObj // ✅ 返回权限映射表 }; } catch (error) { // ... 错误处理 ... } } ``` ### 第四步:在 root.tsx 中存储权限映射表 ```typescript // app/root.tsx export async function loader({ request }: LoaderFunctionArgs) { try { const session = await getUserSession(request); const { userRole, frontendJWT, userInfo } = session; // 获取用户路由(包含权限信息) const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); if (!routesResult.success) { // 错误处理... } return Response.json({ userRole, userInfo, frontendJWT, permissionMap: routesResult.permissionMap || {}, // ✅ 传递权限映射表 ENV: { // 环境变量... } }); } catch (error) { // 错误处理... } } ``` ### 第五步:更新 usePermission Hook ```typescript // app/hooks/usePermission.tsx import { useRouteLoaderData, useLocation } from "@remix-run/react"; interface RootLoaderData { permissions?: string[]; permissionMap?: Record; // ✅ 新增:权限映射表 userRole: string; userInfo?: { role_id?: number; role_key?: string; role_name?: string; }; } export function usePermission() { const rootData = useRouteLoaderData("root") as RootLoaderData; const location = useLocation(); // 从root loader获取权限映射表 const permissionMap = rootData?.permissionMap || {}; const userRole = rootData?.userRole || 'common'; // 🔑 根据当前路由获取权限列表 const currentPath = location.pathname; const currentPermissions = permissionMap[currentPath] || []; /** * 检查是否有指定权限 * @param permissionKey 权限键,如 "prompt_template:create:write" * @returns boolean */ const hasPermission = (permissionKey: string): boolean => { // 优先使用当前路由的权限列表 if (currentPermissions.length > 0) { return currentPermissions.includes(permissionKey); } // 降级方案:如果没有当前路由的权限,使用角色判断 if (userRole.toLowerCase().includes('provin')) { return true; } // 默认只有查看权限 if (permissionKey.includes(':read')) { return true; } return false; }; /** * 检查是否有指定路由的权限 * @param path 路由路径,如 "/prompts" * @param permissionKey 权限键 * @returns boolean */ const hasRoutePermission = (path: string, permissionKey: string): boolean => { const routePermissions = permissionMap[path] || []; return routePermissions.includes(permissionKey); }; /** * 获取当前路由的所有权限 * @returns 权限列表 */ const getCurrentPermissions = (): string[] => { return currentPermissions; }; /** * 获取指定路由的所有权限 * @param path 路由路径 * @returns 权限列表 */ const getRoutePermissions = (path: string): string[] => { return permissionMap[path] || []; }; // ... 其他便捷方法 ... return { // 原始数据 permissions: currentPermissions, permissionMap, userRole, // 基础检查方法 hasPermission, hasRoutePermission, getCurrentPermissions, getRoutePermissions, // 便捷方法 canCreate, canRead, canUpdate, canDelete, canList, canView }; } ``` --- ## 📊 使用示例 ### 示例1:在提示词管理页面中使用 ```typescript // app/routes/prompts._index.tsx import { usePermission } from "~/hooks/usePermission"; export default function PromptsIndex() { const { hasPermission, getCurrentPermissions, canCreate, canUpdate, canDelete } = usePermission(); // 调试:查看当前路由的所有权限 useEffect(() => { console.log('📋 [Prompts] 当前路由权限:', getCurrentPermissions()); }, []); // 检查各项权限 const canCreateTemplate = canCreate('prompt_template'); // 等同于: hasPermission('prompt_template:create:write') const canEditTemplate = canUpdate('prompt_template'); // 等同于: hasPermission('prompt_template:update:write') const canDeleteTemplate = canDelete('prompt_template'); // 等同于: hasPermission('prompt_template:delete:delete') return (
{/* 🔐 新增按钮:需要 create 权限 */} {canCreateTemplate && ( )} {/* 🔐 编辑按钮:需要 update 权限 */} {canEditTemplate ? ( ) : ( )} {/* 🔐 删除按钮:需要 delete 权限 */} {canDeleteTemplate && ( )}
); } ``` ### 示例2:跨路由检查权限 ```typescript // 在任意页面检查其他路由的权限 import { usePermission } from "~/hooks/usePermission"; export default function Dashboard() { const { hasRoutePermission, getRoutePermissions } = usePermission(); // 检查用户是否有访问提示词管理的权限 const canAccessPrompts = hasRoutePermission( '/prompts', 'prompt_template:list:read' ); // 获取文档管理路由的所有权限 const documentPermissions = getRoutePermissions('/documents'); return (
{canAccessPrompts && ( 前往提示词管理 )}
文档管理权限:{documentPermissions.join(', ')}
); } ``` --- ## 🎯 权限映射表示例 ```typescript // permissionMap 的数据结构 { "/prompts": [ "prompt_template:list:read", "prompt_template:detail:read", "prompt_template:delete:delete" // ❌ 没有 create 和 update 权限 ], "/documents": [ "document:list:read", "document:detail:read", "document:create:write", "document:update:write", "document:delete:delete" ], "/rule-groups": [ "rule_group:list:read", "rule_group:detail:read" // ❌ 没有任何写权限 ] } ``` --- ## 🚀 性能优化 ### 1. 缓存策略 ```typescript // app/root.tsx export async function loader({ request }: LoaderFunctionArgs) { const session = await getUserSession(request); // 检查 session 中是否已有缓存的权限映射表 const cachedPermissions = session.get("permissionMap"); const cacheTimestamp = session.get("permissionMapTimestamp"); // 如果缓存未过期(比如5分钟内),直接使用缓存 const now = Date.now(); const CACHE_TTL = 5 * 60 * 1000; // 5分钟 if (cachedPermissions && cacheTimestamp && (now - cacheTimestamp < CACHE_TTL)) { return Response.json({ // ... 其他数据 permissionMap: cachedPermissions }); } // 否则重新获取 const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); // 缓存权限映射表到 session session.set("permissionMap", routesResult.permissionMap); session.set("permissionMapTimestamp", now); return Response.json({ // ... 数据 permissionMap: routesResult.permissionMap }); } ``` ### 2. LocalStorage 备份 ```typescript // app/hooks/usePermission.tsx export function usePermission() { const rootData = useRouteLoaderData("root") as RootLoaderData; const location = useLocation(); // 从 root 或 localStorage 获取权限映射表 let permissionMap = rootData?.permissionMap || {}; // 如果 root 中没有,尝试从 localStorage 读取 if (Object.keys(permissionMap).length === 0 && typeof window !== 'undefined') { const cached = localStorage.getItem('permissionMap'); if (cached) { try { permissionMap = JSON.parse(cached); } catch (e) { console.error('解析权限缓存失败:', e); } } } // 如果 root 中有新数据,更新 localStorage useEffect(() => { if (rootData?.permissionMap && Object.keys(rootData.permissionMap).length > 0) { localStorage.setItem('permissionMap', JSON.stringify(rootData.permissionMap)); } }, [rootData?.permissionMap]); // ... 其他代码 } ``` --- ## 🔒 安全考虑 ### 1. 前端验证 + 后端验证 前端权限检查只是UI优化,后端必须再次验证: ```typescript // 后端API验证示例 app.post('/api/v3/prompt-templates', authenticate, async (req, res) => { const userId = req.user.id; const routePath = '/prompts'; // 查询用户在该路由下的权限 const userPermissions = await getUserRoutePermissions(userId, routePath); // 验证是否有创建权限 if (!userPermissions.includes('prompt_template:create:write')) { return res.status(403).json({ code: 403, msg: '权限不足:您没有创建提示词模板的权限' }); } // 执行创建逻辑... }); ``` ### 2. 权限变更实时性 权限变更后的处理: - **立即生效**:清除前端缓存,强制重新请求路由 - **下次登录生效**:不清除缓存,等待自然过期 ```typescript // 管理员修改用户权限后调用 export async function refreshUserPermissions() { if (typeof window !== 'undefined') { // 清除 localStorage 缓存 localStorage.removeItem('permissionMap'); // 刷新页面,重新加载权限 window.location.reload(); } } ``` --- ## 📝 最佳实践 ### 1. 权限粒度 建议权限粒度: ``` module:resource:action ├── module: 模块名(如 prompt_template, document) ├── resource: 资源类型(如 list, detail, create, update, delete) └── action: 操作类型(如 read, write, delete) ``` 示例: - `prompt_template:list:read` - 查看提示词列表 - `prompt_template:create:write` - 创建提示词 - `prompt_template:update:write` - 更新提示词 ### 2. 降级策略 始终提供降级方案,避免权限加载失败导致页面不可用: ```typescript const hasPermission = (key: string): boolean => { // 1. 优先使用路由权限 if (currentPermissions.length > 0) { return currentPermissions.includes(key); } // 2. 降级到角色判断 if (userRole.toLowerCase().includes('provin')) { return true; } // 3. 默认只读权限 if (key.includes(':read')) { return true; } return false; }; ``` ### 3. 调试工具 开发时在控制台输出权限信息: ```typescript useEffect(() => { if (process.env.NODE_ENV === 'development') { console.group('🔐 权限调试信息'); console.log('当前路由:', location.pathname); console.log('当前权限:', getCurrentPermissions()); console.log('完整权限映射表:', permissionMap); console.groupEnd(); } }, [location.pathname]); ``` --- ## 🎉 总结 ### 优势 ✅ **集中管理**:权限数据在 root loader 中统一获取和管理 ✅ **快速访问**:权限映射表在内存中,访问速度快 ✅ **灵活查询**:可以查询当前路由或任意路由的权限 ✅ **缓存优化**:支持 session 和 localStorage 多级缓存 ✅ **降级方案**:即使权限加载失败也能正常运行 ### 注意事项 ⚠️ **前端只做UI控制**:真正的权限验证必须在后端进行 ⚠️ **缓存同步**:权限变更后要及时清除缓存 ⚠️ **路由匹配**:注意动态路由的权限映射(如 `/documents/:id`) ⚠️ **嵌套路由**:子路由可能需要继承父路由的权限 --- **文档更新日期**: 2025-11-27 **作者**: Claude Code **相关文件**: - `app/api/auth/user-routes.ts` - `app/hooks/usePermission.tsx` - `app/root.tsx`