Files
leaudit-platform-frontend/auth_doc/基于路由的权限管理方案.md
T
2025-12-05 00:09:32 +08:00

703 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 基于路由的权限管理方案
## 📋 问题描述
后端在查询用户路由时(`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<string, string[]>;
/**
* 从路由树中提取权限映射表
* @param routes 路由树
* @returns 权限映射表 (路径 -> 权限列表)
*/
export function buildPermissionMap(routes: BackendRouteInfo[]): PermissionMap {
const permissionMap = new Map<string, string[]>();
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<string, string[]> {
const obj: Record<string, string[]> = {};
map.forEach((value, key) => {
obj[key] = value;
});
return obj;
}
/**
* 从对象恢复权限映射表
*/
export function objectToPermissionMap(obj: Record<string, string[]>): PermissionMap {
const map = new Map<string, string[]>();
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<string, string[]>; // ✅ 新增:返回权限映射表
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<string, string[]>; // ✅ 新增:权限映射表
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 (
<div>
{/* 🔐 新增按钮:需要 create 权限 */}
{canCreateTemplate && (
<Button onClick={() => navigate("/prompts/new")}>
</Button>
)}
{/* 🔐 编辑按钮:需要 update 权限 */}
{canEditTemplate ? (
<button onClick={handleEdit}></button>
) : (
<button onClick={handleView}></button>
)}
{/* 🔐 删除按钮:需要 delete 权限 */}
{canDeleteTemplate && (
<button onClick={handleDelete}></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>
)}
<div>
{documentPermissions.join(', ')}
</div>
</div>
);
}
```
---
## 🎯 权限映射表示例
```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`