586 lines
14 KiB
Markdown
586 lines
14 KiB
Markdown
# 路由权限系统实施记录
|
||
|
||
## 📋 实施概览
|
||
|
||
**实施日期**: 2025-11-27
|
||
**实施状态**: ✅ 已完成
|
||
**实施内容**: 基于路由的细粒度权限管理系统
|
||
|
||
---
|
||
|
||
## 🎯 实施目标
|
||
|
||
实现以下功能:
|
||
1. 后端为每个路由返回该用户在该路由下拥有的权限列表
|
||
2. 前端构建权限映射表(路由路径 → 权限列表)
|
||
3. 权限数据在整个应用中全局共享,避免重复请求
|
||
4. 支持基于当前路由的自动权限检查
|
||
5. 支持跨路由权限查询
|
||
|
||
---
|
||
|
||
## ✅ 已完成的修改
|
||
|
||
### 1. 后端接口类型更新
|
||
|
||
**文件**: `app/api/auth/user-routes.ts`
|
||
|
||
#### 修改内容:
|
||
|
||
1. **更新 `BackendRouteInfo` 接口** (第5-19行)
|
||
```typescript
|
||
export interface BackendRouteInfo {
|
||
// ... 现有字段
|
||
permissions?: string[]; // ✅ 新增:该路由下用户拥有的权限列表
|
||
children?: BackendRouteInfo[];
|
||
}
|
||
```
|
||
|
||
2. **更新 `MenuItem` 接口** (第56-66行)
|
||
```typescript
|
||
export interface MenuItem {
|
||
// ... 现有字段
|
||
permissions?: string[]; // ✅ 新增:该菜单项的权限列表
|
||
children?: MenuItem[];
|
||
}
|
||
```
|
||
|
||
3. **新增权限映射相关类型和工具函数** (第489-542行)
|
||
- `PermissionMap` 类型定义
|
||
- `buildPermissionMap()` - 从路由树提取权限映射表
|
||
- `permissionMapToObject()` - 将Map转换为普通对象
|
||
- `objectToPermissionMap()` - 从对象恢复Map
|
||
|
||
4. **更新 `getUserRoutesByRole` 返回类型** (第551-561行)
|
||
```typescript
|
||
Promise<{
|
||
success: boolean;
|
||
data?: MenuItem[];
|
||
permissionMap?: Record<string, string[]>; // ✅ 新增
|
||
error?: string;
|
||
shouldRedirectToHome?: boolean
|
||
}>
|
||
```
|
||
|
||
5. **构建并返回权限映射表** (第668-682行)
|
||
```typescript
|
||
const permissionMapObj = permissionMapToObject(buildPermissionMap(routes));
|
||
return {
|
||
success: true,
|
||
data: menuItems,
|
||
permissionMap: permissionMapObj // ✅ 返回权限映射表
|
||
};
|
||
```
|
||
|
||
6. **路由转换时传递权限** (第872-880行)
|
||
```typescript
|
||
const menuItem: MenuItem = {
|
||
// ... 其他字段
|
||
permissions: route.permissions // ✅ 传递权限列表
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
### 2. Root Loader 更新
|
||
|
||
**文件**: `app/root.tsx`
|
||
|
||
#### 修改内容:
|
||
|
||
1. **声明权限映射表变量** (第128行)
|
||
```typescript
|
||
let permissionMap: Record<string, string[]> = {};
|
||
```
|
||
|
||
2. **保存权限映射表** (第171-175行)
|
||
```typescript
|
||
if (routesResult.permissionMap) {
|
||
permissionMap = routesResult.permissionMap;
|
||
}
|
||
```
|
||
|
||
3. **返回权限映射表给客户端** (第251行)
|
||
```typescript
|
||
return Response.json({
|
||
userRole,
|
||
pathname,
|
||
frontendJWT,
|
||
isPublicPath,
|
||
permissionMap, // ✅ 传递权限映射表
|
||
ENV: { }
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
### 3. usePermission Hook 更新
|
||
|
||
**文件**: `app/hooks/usePermission.tsx`
|
||
|
||
#### 修改内容:
|
||
|
||
1. **导入 useLocation** (第25行)
|
||
```typescript
|
||
import { useRouteLoaderData, useLocation } from "@remix-run/react";
|
||
```
|
||
|
||
2. **更新接口定义** (第27-36行)
|
||
```typescript
|
||
interface RootLoaderData {
|
||
permissions?: string[];
|
||
permissionMap?: Record<string, string[]>; // ✅ 新增
|
||
userRole: string;
|
||
userInfo?: { /* ... */ };
|
||
}
|
||
```
|
||
|
||
3. **获取当前路由权限** (第38-51行)
|
||
```typescript
|
||
const location = useLocation();
|
||
const permissionMap = rootData?.permissionMap || {};
|
||
const currentPath = location.pathname;
|
||
const currentPermissions = permissionMap[currentPath] || [];
|
||
const legacyPermissions = rootData?.permissions || [];
|
||
```
|
||
|
||
4. **更新权限检查逻辑** (第58-81行)
|
||
```typescript
|
||
const hasPermission = (permissionKey: string): boolean => {
|
||
// 优先使用当前路由的权限列表
|
||
if (currentPermissions.length > 0) {
|
||
return currentPermissions.includes(permissionKey);
|
||
}
|
||
// 向后兼容:支持旧的permissions数组
|
||
if (legacyPermissions.length > 0) {
|
||
return legacyPermissions.includes(permissionKey);
|
||
}
|
||
// 降级方案...
|
||
};
|
||
```
|
||
|
||
5. **新增路由权限查询方法** (第83-109行)
|
||
- `hasRoutePermission(path, permissionKey)` - 检查指定路由的权限
|
||
- `getCurrentPermissions()` - 获取当前路由的所有权限
|
||
- `getRoutePermissions(path)` - 获取指定路由的所有权限
|
||
|
||
6. **更新返回对象** (第184-209行)
|
||
```typescript
|
||
return {
|
||
permissions: currentPermissions, // ✅ 返回当前路由的权限
|
||
permissionMap, // ✅ 返回完整的权限映射表
|
||
// ...
|
||
hasRoutePermission, // ✅ 新增
|
||
getCurrentPermissions, // ✅ 新增
|
||
getRoutePermissions, // ✅ 新增
|
||
// ...
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 📖 使用示例
|
||
|
||
### 1. 基本用法:当前路由权限检查
|
||
|
||
```typescript
|
||
// 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. 跨路由权限检查
|
||
|
||
```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 (
|
||
<div>
|
||
{canAccessPrompts && (
|
||
<Link to="/prompts">前往提示词管理</Link>
|
||
)}
|
||
<p>文档权限: {documentPermissions.join(', ')}</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 3. 查看完整权限映射表(调试)
|
||
|
||
```typescript
|
||
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` 字段:
|
||
|
||
```json
|
||
{
|
||
"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')
|
||
```
|
||
|
||
### 权限映射表构建示例
|
||
|
||
**输入(后端返回的路由树)**:
|
||
```typescript
|
||
[
|
||
{
|
||
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: []
|
||
}
|
||
]
|
||
```
|
||
|
||
**输出(权限映射表)**:
|
||
```typescript
|
||
{
|
||
"/prompts": ["prompt_template:list:read", "prompt_template:delete:delete"],
|
||
"/documents": ["document:list:read", "document:create:write"]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔍 调试指南
|
||
|
||
### 1. 检查后端返回的数据
|
||
|
||
在浏览器控制台:
|
||
```javascript
|
||
// 查看 root loader 返回的数据
|
||
window.__remixContext.state.loaderData.root
|
||
|
||
// 查看权限映射表
|
||
window.__remixContext.state.loaderData.root.permissionMap
|
||
```
|
||
|
||
### 2. 检查当前路由权限
|
||
|
||
在组件中:
|
||
```typescript
|
||
const { getCurrentPermissions } = usePermission();
|
||
|
||
useEffect(() => {
|
||
console.log('当前路由:', location.pathname);
|
||
console.log('当前权限:', getCurrentPermissions());
|
||
}, [location.pathname]);
|
||
```
|
||
|
||
### 3. 检查权限检查逻辑
|
||
|
||
```typescript
|
||
const { hasPermission } = usePermission();
|
||
|
||
const result = hasPermission('prompt_template:create:write');
|
||
console.log('权限检查结果:', result);
|
||
```
|
||
|
||
### 4. 开发模式日志
|
||
|
||
添加调试日志(仅在开发环境):
|
||
```typescript
|
||
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**: 使用父路由权限
|
||
```typescript
|
||
const { getRoutePermissions } = usePermission();
|
||
const permissions = getRoutePermissions('/documents');
|
||
```
|
||
|
||
**方案2**: 增强匹配逻辑(在 usePermission 中)
|
||
```typescript
|
||
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. **优先级1**: 当前路由权限(permissionMap[currentPath])
|
||
2. **优先级2**: 旧的 permissions 数组(向后兼容)
|
||
3. **优先级3**: 基于 userRole 的角色判断
|
||
4. **优先级4**: 默认查看权限(:read)
|
||
|
||
---
|
||
|
||
## 📈 后续优化建议
|
||
|
||
### 1. Session 缓存(可选)
|
||
|
||
减少 root loader 执行频率:
|
||
|
||
```typescript
|
||
// 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 备份(可选)
|
||
|
||
提高离线体验:
|
||
|
||
```typescript
|
||
// app/hooks/usePermission.tsx
|
||
useEffect(() => {
|
||
if (rootData?.permissionMap) {
|
||
localStorage.setItem('permissionMap', JSON.stringify(rootData.permissionMap));
|
||
}
|
||
}, [rootData?.permissionMap]);
|
||
```
|
||
|
||
### 3. 权限变更实时通知
|
||
|
||
使用 WebSocket 推送权限变更:
|
||
|
||
```typescript
|
||
// 当管理员修改用户权限时
|
||
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 错误)
|
||
|
||
---
|
||
|
||
## 📚 相关文档
|
||
|
||
1. [基于路由的权限管理方案](./基于路由的权限管理方案.md) - 详细设计方案
|
||
2. [权限系统原理详解](./权限系统原理详解.md) - 技术原理说明
|
||
3. [权限控制实施指南](./权限控制实施指南.md) - 通用RBAC实施指南
|
||
|
||
---
|
||
|
||
## 📝 变更历史
|
||
|
||
| 日期 | 版本 | 修改内容 | 修改人 |
|
||
|------|------|---------|--------|
|
||
| 2025-11-27 | 1.0.0 | 初始实施完成 | Claude Code |
|
||
|
||
---
|
||
|
||
**文档版本**: 1.0.0
|
||
**最后更新**: 2025-11-27
|
||
**状态**: ✅ 实施完成,待后端集成测试
|