前言
大家好,我是 elk。在上篇文章中,我们完成了系统管理模块的准备工作。本文我们将聚焦系统管理的核心功能 ------ 菜单管理 和权限路由控制,这是实现RBAC(基于角色的访问控制)的关键环节。让我们开始编写业务逻辑!
本文代码基于上一篇文章搭建的NestJS项目骨架,使用Prisma作为ORM工具
项目结构与模块创建
使用 Nest CLI 命令快速创建菜单管理模块:
bash
# 创建标准CRUD模板
nest g res menu
该命令会自动生成控制器、服务、DTO等文件,位于 src/module/system/menu
目录下。
用户信息与路由权限处理
在后台管理系统中,用户登录后通常还需要调用两个核心接口:
- 获取用户信息接口
- 获取路由权限接口
我们将这两个接口放在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);
}
路由数据处理流程:
- 获取用户角色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);
}
- 根据角色获取菜单数据并转换结构
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 } });
});
}
树形结构处理技巧
构建树形菜单时:
- 使用Map存储节点引用
- 两遍遍历法(第一遍创建节点,第二遍构建关系)
- 复杂度优化至O(n)
总结与预告
本文我们完成了RBAC系统中关键的用户信息管理、动态路由生成和菜单管理等核心功能。重点实现了:
- 基于角色的动态路由生成算法
- 菜单管理的完整CRUD实现
- 字段命名转换的最佳实践
- 树形结构处理的高效方案
下篇预告:在下一篇文章中,我们将深入探讨角色管理模块的设计与实现,包括:
- 角色权限分配策略
- 权限验证守卫实现
- 数据范围权限控制
- 角色用户批量管理
敬请期待《NestJS 实战:RBAC 系统管理模块开发 (三)》:角色与权限深度设计!
讨论点:在您的项目中如何处理复杂的权限路由结构?欢迎在评论区分享您的实现方案和经验!