This commit is contained in:
2025-12-05 00:09:32 +08:00
parent bb3d22eabf
commit 3d1dbb3f97
214 changed files with 113060 additions and 1232 deletions
@@ -0,0 +1,702 @@
# 基于路由的权限管理方案
## 📋 问题描述
后端在查询用户路由时(`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`