《NestJS 实战:RBAC 系统管理模块开发 (二)》:菜单与权限路由设计

前言

大家好,我是 elk。在上篇文章中,我们完成了系统管理模块的准备工作。本文我们将聚焦系统管理的核心功能 ------ 菜单管理权限路由控制,这是实现RBAC(基于角色的访问控制)的关键环节。让我们开始编写业务逻辑!

本文代码基于上一篇文章搭建的NestJS项目骨架,使用Prisma作为ORM工具

项目结构与模块创建

使用 Nest CLI 命令快速创建菜单管理模块:

bash 复制代码
    # 创建标准CRUD模板
    nest g res menu

该命令会自动生成控制器、服务、DTO等文件,位于 src/module/system/menu 目录下。

用户信息与路由权限处理

在后台管理系统中,用户登录后通常还需要调用两个核心接口:

  1. 获取用户信息接口
  2. 获取路由权限接口

我们将这两个接口放在auth模块下实现:

1. 用户信息接口实现

接口定义 (system/auth/controllers/auth-user-controller.ts)

typescript 复制代码
    @Controller()
    export class AuthUserController {
      constructor(
        private readonly userService: UserService,
        private readonly roleService: RoleService,
      ) {}

      @Get('getUserInfo')
      @ApiOperation({ summary: '获取用户信息', description: '登录后获取用户详情' })
      async getUserInfo(@AuthUser() req: IAuthUser) {
        const userId = req.user.sub;
        return this.userService.findOneWithRole(userId);
      }
    }

业务逻辑 (user.service.ts)

TypeScript 复制代码
 async findOneWithRole(userId: number) {
   const user = await this.prisma.sys_user.findFirst({
     where: { user_id: userId },
     omit: { password: true }, // 排除敏感字段
     include: {
       roles: {
         include: { role: true },
       },
     },
   });

   if (user) {
     // 扁平化角色数据结构
     const roles = user.roles.map(roleItem => roleItem.role);
     return {
       ...user,
       roles,
       permissions: [PermissionContant.PERMISSIONADMIN]
     };
   }
   return null;
 }

2. 动态路由权限实现(核心)

后台系统的路由权限需要满足:

  • 支持多级嵌套菜单
  • 根据角色动态生成可见路由
  • 适配前端路由结构

接口定义

TypeScript 复制代码
 @Get('generateRouters')
 @ApiOperation({ summary: '获取用户路由菜单', description: '动态生成用户权限路由' })
 async generateRouters(@AuthUser() req: IAuthUser) {
   const userId = Number(req.user.sub);
   const roleIds = await this.roleService.findRoleByUserId(userId);
   return this.menuService.getMenus(roleIds);
 }

路由数据处理流程

  1. 获取用户角色ID集合
TypeScript 复制代码
 // role.service.ts
 async findRoleByUserId(userId: number) {
   const roles = await this.prisma.sys_user_role.findMany({
     where: { user_id: userId }
   });
   return roles.map(role => role.role_id);
 }
  1. 根据角色获取菜单数据并转换结构
TypeScript 复制代码
 // menu.service.ts
 async getMenus(roleIds: number[]) {
   const result = await this.prisma.sys_role.findFirst({
     where: { role_id: { in: roleIds } },
     include: {
       menus: {
         include: { menu: true }
       }
     }
   });
   
   // 转换数据结构
   const menuData = plainToInstance(
     ListMenuDto,
     result.menus.map(item => item.menu)
   );
   
   // 转换为树形路由结构
   return transformToRoutes(menuData);
 }

3. 路由结构转换算法

核心工具函数 (src/utils/permission.util.ts):

typescript 复制代码
    // 路由类型定义
    export interface IRouter {
      id: number;
      title: string;
      name: string;
      path: string;
      icon: string;
      redirect?: string;
      component?: string;
      isLink?: boolean;
      children?: IRouter[];
    }

    // 转换路由结构
    export function transformToRoutes(menus: ListMenuDto[]): IRouter[] {
      const routerMap = new Map<number, IRouter>();
      const rootRouters: IRouter[] = [];

      // 第一遍:创建所有节点
      menus.forEach(menu => {
        if (menu.status === '1' || menu.visible === '1') return; // 过滤禁用/隐藏
        
        const router: IRouter = {
          id: menu.menuId,
          title: menu.menuName,
          name: menu.path.replace(/-/g, ''),
          path: menu.path,
          icon: menu.icon || 'el-icon-menu',
          component: menu.component || 'Layout'
        };
        
        if (menu.menuType === 'C') { // 目录
          router.redirect = 'noRedirect';
          router.children = [];
        } else { // 菜单
          router.isLink = menu.isFrame === 0;
        }
        
        routerMap.set(menu.menuId, router);
      });

      // 第二遍:构建层级关系
      menus.forEach(menu => {
        if (!routerMap.has(menu.menuId)) return;
        
        const current = routerMap.get(menu.menuId);
        const parentId = menu.parentId;
        
        if (parentId === 0) {
          rootRouters.push(current);
        } else if (routerMap.has(parentId)) {
          const parent = routerMap.get(parentId);
          parent.children = parent.children || [];
          parent.children.push(current);
        }
      });

      return rootRouters;
    }

接口返回效果

Json 复制代码
 [
   {
     "id": 1,
     "title": "系统管理",
     "name": "System",
     "path": "/system",
     "icon": "system",
     "redirect": "noRedirect",
     "children": [
       {
         "id": 101,
         "title": "用户管理",
         "name": "User",
         "path": "user",
         "icon": "user",
         "component": "system/user/index"
       },
       {
         "id": 102,
         "title": "菜单管理",
         "name": "Menu",
         "path": "menu",
         "icon": "menu",
         "component": "system/menu/index"
       }
     ]
   }
 ]

前端根据动态生成的路由结构自动渲染导航菜单

菜单管理 CRUD 实现

接口设计概览

功能 方法 路径 参数
创建菜单 POST /system/menu/create CreateMenuDto
菜单列表 GET /system/menu/list pageNum, pageSize
菜单详情 GET /system/menu/:id id
更新菜单 PUT /system/menu UpdateMenuDto
删除菜单 DELETE /system/menu/:id id

控制器设计 (menu.controller.ts)

TypeScript 复制代码
 @ApiTags('菜单管理')
 @Controller('/system/menu')
 export class MenuController {
   constructor(private readonly menuService: MenuService) {}

   @Post('/create')
   @ApiOperation({ summary: '创建菜单' })
   create(@Body() dto: CreateMenuDto) {
     return this.menuService.create(dto);
   }

   @Get('/list')
   @ApiOperation({ summary: '菜单列表' })
   list(@Query() params: PaginationDto) {
     return this.menuService.paginate(params);
   }

   @Get(':id')
   @ApiOperation({ summary: '菜单详情' })
   detail(@Param('id') id: string) {
     return this.menuService.findOne(+id);
   }

   @ApiOperation({ summary: '更新菜单', description: '更新菜单' })
   @ApiBody({ type: ListMenuDto })
   @Put('')
   update(@Body() updateMenuDto: ListMenuDto) {
     return this.menuService.update(updateMenuDto);
   }

   @Delete(':id')
   @ApiOperation({ summary: '删除菜单' })
   remove(@Param('id') id: string) {
     return this.menuService.delete(+id);
   }
 }

服务层核心逻辑 (menu.service.ts)

TypeScript 复制代码
 @Injectable()
 export class MenuService {
   constructor(private prisma: PrismaService) {}
   
   // 创建菜单
   async create(dto: CreateMenuDto) {
     const data = decamelizeKeys(dto); // 转换字段格式
     await this.prisma.sys_menu.create({ data });
     return '菜单创建成功';
   }
   
   // 分页查询
   async paginate({ page, pageSize }: PaginationDto) {
     const [total, items] = await Promise.all([
       this.prisma.sys_menu.count(),
       this.prisma.sys_menu.findMany({
         skip: (page - 1) * pageSize,
         take: pageSize
       })
     ]);
     
     return {
       total,
       items: camelizeKeys(items) // 转换字段格式
     };
   }
   
   // 删除菜单(级联处理)
   async delete(menuId: number) {
     return this.prisma.$transaction([
       // 删除关联的角色菜单
       this.prisma.sys_role_menu.deleteMany({
         where: { menu_id: menuId }
       }),
       // 删除菜单
       this.prisma.sys_menu.delete({
         where: { menu_id: menuId }
       })
     ]);
   }
 }

开发经验与最佳实践

字段命名转换策略

在Prisma模型定义和DTO之间处理字段命名差异时,推荐两种方案:

方案1:Prisma层映射(推荐)

kotlin 复制代码
Prisma
model sys_menu {
  menuId   Int    @id @default(autoincrement()) @map("menu_id")
  menuName String @map("menu_name")
  
  @@map("sys_menu")
}

方案2:DTO层转换

TypeScript 复制代码
 // 使用 class-transformer
 @Expose({ name: 'menu_name' })
 menuName: string;

 // 或使用 humps 库
 import { camelizeKeys, decamelizeKeys } from 'humps';

 // 入库转换
 const dbData = decamelizeKeys(dto); 

 // 出库转换
 return camelizeKeys(dbResult);

事务处理要点

对于级联删除操作,务必使用事务保证数据一致性:

TypeScript 复制代码
 async deleteWithRelations(id: number) {
   return this.prisma.$transaction(async (tx) => {
     await tx.role_menu.deleteMany({ where: { menu_id: id } });
     await tx.menu.delete({ where: { menu_id: id } });
   });
 }

树形结构处理技巧

构建树形菜单时:

  1. 使用Map存储节点引用
  2. 两遍遍历法(第一遍创建节点,第二遍构建关系)
  3. 复杂度优化至O(n)

总结与预告

本文我们完成了RBAC系统中关键的用户信息管理、动态路由生成和菜单管理等核心功能。重点实现了:

  1. 基于角色的动态路由生成算法
  2. 菜单管理的完整CRUD实现
  3. 字段命名转换的最佳实践
  4. 树形结构处理的高效方案

下篇预告:在下一篇文章中,我们将深入探讨角色管理模块的设计与实现,包括:

  • 角色权限分配策略
  • 权限验证守卫实现
  • 数据范围权限控制
  • 角色用户批量管理

敬请期待《NestJS 实战:RBAC 系统管理模块开发 (三)》:角色与权限深度设计!

讨论点:在您的项目中如何处理复杂的权限路由结构?欢迎在评论区分享您的实现方案和经验!

相关推荐
步行cgn34 分钟前
Vue 中的数据代理机制
前端·javascript·vue.js
GH小杨38 分钟前
JS之Dom模型和Bom模型
前端·javascript·html
星月心城2 小时前
JS深入之从原型到原型链
前端·javascript
你的人类朋友2 小时前
🤔Token 存储方案有哪些
前端·javascript·后端
烛阴2 小时前
从零开始:使用Node.js和Cheerio进行轻量级网页数据提取
前端·javascript·后端
liuyang___2 小时前
日期的数据格式转换
前端·后端·学习·node.js·node
贩卖纯净水.3 小时前
webpack其余配置
前端·webpack·node.js
码上奶茶4 小时前
HTML 列表、表格、表单
前端·html·表格·标签·列表·文本·表单
抹茶san4 小时前
和 Trae 一起开发可视化拖拽编辑项目(1) :迈出第一步
前端·trae