14 KiB
路由权限系统实施记录
📋 实施概览
实施日期: 2025-11-27 实施状态: ✅ 已完成 实施内容: 基于路由的细粒度权限管理系统
🎯 实施目标
实现以下功能:
- 后端为每个路由返回该用户在该路由下拥有的权限列表
- 前端构建权限映射表(路由路径 → 权限列表)
- 权限数据在整个应用中全局共享,避免重复请求
- 支持基于当前路由的自动权限检查
- 支持跨路由权限查询
✅ 已完成的修改
1. 后端接口类型更新
文件: app/api/auth/user-routes.ts
修改内容:
-
更新
BackendRouteInfo接口 (第5-19行)export interface BackendRouteInfo { // ... 现有字段 permissions?: string[]; // ✅ 新增:该路由下用户拥有的权限列表 children?: BackendRouteInfo[]; } -
更新
MenuItem接口 (第56-66行)export interface MenuItem { // ... 现有字段 permissions?: string[]; // ✅ 新增:该菜单项的权限列表 children?: MenuItem[]; } -
新增权限映射相关类型和工具函数 (第489-542行)
PermissionMap类型定义buildPermissionMap()- 从路由树提取权限映射表permissionMapToObject()- 将Map转换为普通对象objectToPermissionMap()- 从对象恢复Map
-
更新
getUserRoutesByRole返回类型 (第551-561行)Promise<{ success: boolean; data?: MenuItem[]; permissionMap?: Record<string, string[]>; // ✅ 新增 error?: string; shouldRedirectToHome?: boolean }> -
构建并返回权限映射表 (第668-682行)
const permissionMapObj = permissionMapToObject(buildPermissionMap(routes)); return { success: true, data: menuItems, permissionMap: permissionMapObj // ✅ 返回权限映射表 }; -
路由转换时传递权限 (第872-880行)
const menuItem: MenuItem = { // ... 其他字段 permissions: route.permissions // ✅ 传递权限列表 };
2. Root Loader 更新
文件: app/root.tsx
修改内容:
-
声明权限映射表变量 (第128行)
let permissionMap: Record<string, string[]> = {}; -
保存权限映射表 (第171-175行)
if (routesResult.permissionMap) { permissionMap = routesResult.permissionMap; } -
返回权限映射表给客户端 (第251行)
return Response.json({ userRole, pathname, frontendJWT, isPublicPath, permissionMap, // ✅ 传递权限映射表 ENV: { } });
3. usePermission Hook 更新
文件: app/hooks/usePermission.tsx
修改内容:
-
导入 useLocation (第25行)
import { useRouteLoaderData, useLocation } from "@remix-run/react"; -
更新接口定义 (第27-36行)
interface RootLoaderData { permissions?: string[]; permissionMap?: Record<string, string[]>; // ✅ 新增 userRole: string; userInfo?: { /* ... */ }; } -
获取当前路由权限 (第38-51行)
const location = useLocation(); const permissionMap = rootData?.permissionMap || {}; const currentPath = location.pathname; const currentPermissions = permissionMap[currentPath] || []; const legacyPermissions = rootData?.permissions || []; -
更新权限检查逻辑 (第58-81行)
const hasPermission = (permissionKey: string): boolean => { // 优先使用当前路由的权限列表 if (currentPermissions.length > 0) { return currentPermissions.includes(permissionKey); } // 向后兼容:支持旧的permissions数组 if (legacyPermissions.length > 0) { return legacyPermissions.includes(permissionKey); } // 降级方案... }; -
新增路由权限查询方法 (第83-109行)
hasRoutePermission(path, permissionKey)- 检查指定路由的权限getCurrentPermissions()- 获取当前路由的所有权限getRoutePermissions(path)- 获取指定路由的所有权限
-
更新返回对象 (第184-209行)
return { permissions: currentPermissions, // ✅ 返回当前路由的权限 permissionMap, // ✅ 返回完整的权限映射表 // ... hasRoutePermission, // ✅ 新增 getCurrentPermissions, // ✅ 新增 getRoutePermissions, // ✅ 新增 // ... };
📖 使用示例
1. 基本用法:当前路由权限检查
// app/routes/prompts._index.tsx
import { usePermission } from "~/hooks/usePermission";
export default function PromptsIndex() {
const {
canCreate,
canUpdate,
canDelete,
getCurrentPermissions
} = usePermission();
// 调试:查看当前路由权限
useEffect(() => {
console.log('当前路由权限:', getCurrentPermissions());
// 输出: ["prompt_template:list:read", "prompt_template:delete:delete"]
}, []);
const canCreateTemplate = canCreate('prompt_template');
const canEditTemplate = canUpdate('prompt_template');
const canDeleteTemplate = canDelete('prompt_template');
return (
<div>
{/* 根据权限显示/隐藏按钮 */}
{canCreateTemplate && <Button>新增模板</Button>}
{canEditTemplate && <Button>编辑</Button>}
{canDeleteTemplate && <Button>删除</Button>}
</div>
);
}
2. 跨路由权限检查
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 (
<div>
{canAccessPrompts && (
<Link to="/prompts">前往提示词管理</Link>
)}
<p>文档权限: {documentPermissions.join(', ')}</p>
</div>
);
}
3. 查看完整权限映射表(调试)
import { usePermission } from "~/hooks/usePermission";
export default function DebugPage() {
const { permissionMap } = usePermission();
return (
<pre>
{JSON.stringify(permissionMap, null, 2)}
</pre>
);
}
// 输出示例:
// {
// "/prompts": [
// "prompt_template:list:read",
// "prompt_template:delete:delete"
// ],
// "/documents": [
// "document:list:read",
// "document:create:write",
// ...
// ]
// }
🔧 后端集成要求
API 返回格式要求
后端 /rbac/user/routes 接口需要在每个路由对象中添加 permissions 字段:
{
"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"
]
}
]
}
}
权限键命名规范
格式:module:resource:action
示例:
prompt_template:list:read- 查看提示词列表prompt_template:create:write- 创建提示词prompt_template:update:write- 更新提示词prompt_template:delete:delete- 删除提示词prompt_template:detail:read- 查看提示词详情
📊 数据流程
完整的数据流
1. 用户访问任意路由 (如 /prompts)
↓
2. root.tsx loader 执行
↓
3. 调用 getUserRoutesByRole(userRole, jwt, true)
↓
4. 后端返回路由树(包含每个路由的 permissions 字段)
↓
5. 前端执行 buildPermissionMap(routes)
- 递归遍历路由树
- 提取每个路由的 route_path 和 permissions
- 构建 Map<string, string[]>
↓
6. 转换为普通对象 permissionMapToObject(map)
↓
7. 返回给客户端 Response.json({ permissionMap })
↓
8. 数据存储在 Remix 的全局状态
↓
9. 组件使用 usePermission() hook
- useRouteLoaderData("root") 获取 permissionMap
- useLocation() 获取当前路径
- 从 permissionMap[currentPath] 获取当前路由权限
↓
10. 权限检查:hasPermission('prompt_template:create:write')
权限映射表构建示例
输入(后端返回的路由树):
[
{
id: 1,
route_path: "/prompts",
permissions: ["prompt_template:list:read", "prompt_template:delete:delete"],
children: []
},
{
id: 2,
route_path: "/documents",
permissions: ["document:list:read", "document:create:write"],
children: []
}
]
输出(权限映射表):
{
"/prompts": ["prompt_template:list:read", "prompt_template:delete:delete"],
"/documents": ["document:list:read", "document:create:write"]
}
🔍 调试指南
1. 检查后端返回的数据
在浏览器控制台:
// 查看 root loader 返回的数据
window.__remixContext.state.loaderData.root
// 查看权限映射表
window.__remixContext.state.loaderData.root.permissionMap
2. 检查当前路由权限
在组件中:
const { getCurrentPermissions } = usePermission();
useEffect(() => {
console.log('当前路由:', location.pathname);
console.log('当前权限:', getCurrentPermissions());
}, [location.pathname]);
3. 检查权限检查逻辑
const { hasPermission } = usePermission();
const result = hasPermission('prompt_template:create:write');
console.log('权限检查结果:', result);
4. 开发模式日志
添加调试日志(仅在开发环境):
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
console.group('🔐 权限调试信息');
console.log('当前路由:', location.pathname);
console.log('当前权限:', getCurrentPermissions());
console.log('完整映射表:', permissionMap);
console.groupEnd();
}
}, [location.pathname]);
⚠️ 重要注意事项
1. 前端权限检查 ≠ 安全防护
- 前端权限检查仅用于UI控制(隐藏/禁用按钮)
- 真正的权限验证必须在后端进行
- 攻击者可以绕过前端检查直接调用API
- 永远不要依赖前端权限检查来保护敏感数据
2. 动态路由的权限处理
当前实现使用精确路径匹配 (permissionMap[pathname]),对于动态路由需要特殊处理:
问题场景:
当前路由: /documents/123
权限映射表: { "/documents": [...] }
结果: permissionMap["/documents/123"] 返回 undefined
解决方案:
方案1: 使用父路由权限
const { getRoutePermissions } = usePermission();
const permissions = getRoutePermissions('/documents');
方案2: 增强匹配逻辑(在 usePermission 中)
const findMatchingRoute = (pathname: string): string[] => {
// 精确匹配
if (permissionMap[pathname]) {
return permissionMap[pathname];
}
// 前缀匹配(找最长匹配)
const matchingRoutes = Object.keys(permissionMap)
.filter(route => pathname.startsWith(route))
.sort((a, b) => b.length - a.length);
return matchingRoutes.length > 0
? permissionMap[matchingRoutes[0]]
: [];
};
3. 性能考虑
- 权限映射表在 root loader 中加载一次
- 在 SPA 导航过程中不会重新加载
- 内存占用极小(通常 < 10KB)
- 无需额外的性能优化
4. 降级策略
系统实现了多级降级,确保健壮性:
- 优先级1: 当前路由权限(permissionMap[currentPath])
- 优先级2: 旧的 permissions 数组(向后兼容)
- 优先级3: 基于 userRole 的角色判断
- 优先级4: 默认查看权限(:read)
📈 后续优化建议
1. Session 缓存(可选)
减少 root loader 执行频率:
// app/root.tsx
const cachedPermissions = session.get("permissionMap");
const cacheTimestamp = session.get("permissionMapTimestamp");
const CACHE_TTL = 5 * 60 * 1000; // 5分钟
if (cachedPermissions && (Date.now() - cacheTimestamp < CACHE_TTL)) {
return Response.json({ permissionMap: cachedPermissions });
}
2. LocalStorage 备份(可选)
提高离线体验:
// app/hooks/usePermission.tsx
useEffect(() => {
if (rootData?.permissionMap) {
localStorage.setItem('permissionMap', JSON.stringify(rootData.permissionMap));
}
}, [rootData?.permissionMap]);
3. 权限变更实时通知
使用 WebSocket 推送权限变更:
// 当管理员修改用户权限时
socket.emit('permission-changed', { userId });
// 客户端监听
socket.on('permission-changed', () => {
window.location.reload(); // 重新加载权限
});
✅ 测试清单
实施完成后,请验证以下项目:
- 后端
/rbac/user/routes接口已添加permissions字段 - 用户登录后,root loader 成功获取权限映射表
- 在浏览器控制台可以查看
permissionMap数据 /prompts页面根据权限正确显示/隐藏按钮canCreate(),canUpdate(),canDelete()方法正常工作- 跨路由权限检查
hasRoutePermission()正常工作 getCurrentPermissions()返回正确的权限列表- 权限变更后刷新页面可以看到更新
- 降级策略正常(后端未返回 permissions 时系统仍能运行)
- TypeScript 类型检查通过,无类型错误
- 不同角色用户看到的按钮不同
- 后端 API 正确验证权限(403 错误)
📚 相关文档
- 基于路由的权限管理方案 - 详细设计方案
- 权限系统原理详解 - 技术原理说明
- 权限控制实施指南 - 通用RBAC实施指南
📝 变更历史
| 日期 | 版本 | 修改内容 | 修改人 |
|---|---|---|---|
| 2025-11-27 | 1.0.0 | 初始实施完成 | Claude Code |
文档版本: 1.0.0 最后更新: 2025-11-27 状态: ✅ 实施完成,待后端集成测试