17 KiB
17 KiB
基于路由的权限管理方案
📋 问题描述
后端在查询用户路由时(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'] - 因此,页面上的"新增"、"编辑"、"查看详情"按钮应该隐藏或禁用
🎯 核心挑战
- 如何存储每个路由的权限列表?
- 如何让权限数据在所有页面中快速访问?
- 如何避免每次切换页面都重新请求权限?
- 如何处理嵌套路由的权限继承?
💡 解决方案
方案概述
采用扁平化权限映射表 + 全局状态管理的方式:
- 后端返回路由时,每个路由附带权限列表
- 前端在
root.tsxloader 中构建权限映射表 - 将权限映射表通过
useRouteLoaderData暴露给所有子路由 - 更新
usePermissionHook 支持从映射表中查询权限
🔧 实施步骤
第一步:更新后端API返回格式
后端需要在路由接口返回时,为每个路由添加 permissions 字段:
// 后端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"
]
}
]
}
}
第二步:更新前端接口定义
// 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 中添加工具函数:
/**
* 权限映射表类型
* 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 函数:
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 中存储权限映射表
// 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
// 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:在提示词管理页面中使用
// 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:跨路由检查权限
// 在任意页面检查其他路由的权限
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>
);
}
🎯 权限映射表示例
// 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. 缓存策略
// 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 备份
// 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优化,后端必须再次验证:
// 后端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. 权限变更实时性
权限变更后的处理:
- 立即生效:清除前端缓存,强制重新请求路由
- 下次登录生效:不清除缓存,等待自然过期
// 管理员修改用户权限后调用
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. 降级策略
始终提供降级方案,避免权限加载失败导致页面不可用:
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. 调试工具
开发时在控制台输出权限信息:
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.tsapp/hooks/usePermission.tsxapp/root.tsx