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

17 KiB
Raw Blame History

基于路由的权限管理方案

📋 问题描述

后端在查询用户路由时(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 字段:

// 后端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.ts
  • app/hooks/usePermission.tsx
  • app/root.tsx