Nestjs框架: 菜单Menu接口功能的开发和设计
概述
-
在系统权限体系中,菜单权限与接口权限、数据权限存在本质区别。其核心不在于字段级的访问控制,也不依赖复杂的策略决策逻辑,而在于根据用户角色所对应的菜单决策结果,动态返回前端可展示的菜单数据。前端依据这些数据进行界面渲染,决定哪些页面对用户可见,哪些不可见。
-
需要明确的是,前端控制菜单显示与否的安全性远低于接口权限与数据权限的后端控制机制。菜单权限本质上是一种“提示性”控制——它告诉前端“该用户被授权访问哪些菜单项”,但并不保证用户无法通过其他方式(如直接调用接口)绕过限制。真正的安全边界应由后端在接口鉴权和数据库查询层面完成。因此,系统的整体安全性不仅依赖前端展示逻辑,更关键的是服务端对资源访问的严格校验。
-
为实现上述机制,系统架构中引入了决策层,用于处理角色与菜单之间的映射关系。该层负责解析用户所属角色,并据此生成其可访问的菜单列表。在此基础上,可通过REST 接口能力,快速构建角色-菜单的 ETO(Entity Transfer Object)模型,进而实现菜单模块的增删改查功能
菜单数据结构设计:复杂层级与存储挑战
-
典型的企业级管理后台菜单往往包含多级嵌套结构——从一级主菜单到二级、三级甚至四级子菜单,构成典型的树形结构(Tree Structure)。这种结构带来了数据建模上的复杂性,尤其是在使用关系型数据库时,如何高效存储和查询此类递归结构成为关键技术点
-
相较于 MongoDB 等文档型数据库天然支持嵌套结构存储,PostgreSQL、MySQL 等关系型数据库需通过特定模式设计来模拟树形结构。常见的设计方案包括邻接表(Adjacency List)、路径枚举(Path Enumeration)、闭包表(Closure Table)等。本文采用的是邻接表结合自引用的方式,在 Prisma ORM 中实现灵活且高效的菜单建模
Prisma 模型定义:一对多自关联与元信息分离
1 )前端表单设计
- 路由名称
- 路由路径
- 路由元信息
- 组件路径
- 子组件children
2 ) prisma.schema 设计
model Role {// ....roleMenu RoleMenu[] // 这里也同步添加// ...@@map("roles")
}model RoleMenu {roleId IntmenuId Introle Role @relation(fields: [roleId], references: [id])menu Menu @relation(fields: [menuId], references: [id])@@id([roleId, menuId])@@map("role_menus")
}model Menu {id Int @id @default(autoincrement())name String? @uniquepath Stringcomponent String?redirect String?fullPath String?alias String?label String?// self-relationparentId Int?parent Menu? @relation("menu_relation", fields: [parentId], references: [id])children Menu[] @relation("menu_relation")meta Meta?roleMenu RoleMenu[]@@map("menus")
}model Meta {id Int @id @default(autoincrement())title String?layout String?order Int? @default(100)icon String?hideMenu Boolean @default(false)disabled Boolean @default(false)menuId Int @uniquemenu Menu @relation(fields: [menuId], references: [id])@@map("menu_meta")
}
关键设计要点解析
1 ) 自引用关系(Self-Relation)
在 Menu 模型中,通过 children 与 parent 字段形成一对多的自我关联。children 是一个数组类型,表示当前节点的所有子菜单;parent 是可选类型(带问号),表示上级菜单。两者通过 “MenuChildren” 这一命名一致的 relation 名称建立双向链接。这是 Prisma 实现树形结构的标准做法。
2 ) 唯一外键约束确保一对一映射
Meta.menuId 被标记为 @unique,确保每个 Meta 实例仅对应一个 Menu 实例,形成严格的 一对一关系(One-to-One Relation)。这防止了元信息错乱或重复绑定的问题
3 ) 父子层级字段设计合理性
parentId 存在于 Menu 表中,作为外键指向同一表的 id 字段。对于根菜单(一级菜单),parentId 为空(nullable)。这种设计符合邻接表模式的基本原则,便于通过单次 SQL 查询获取某节点的父级或子级
4 )元信息解耦提升扩展性
将标题、图标、排序、是否隐藏等 UI 相关字段独立至 Meta,使 Menu 主体专注于路由行为逻辑(如路径、组件、重定向)。这种解耦使得未来新增展示属性时无需改动核心菜单结构,提高了系统的可维护性
数据库同步与模块化工程实践
完成 Prisma Schema 定义后,执行以下命令将模型同步至数据库:
npx prisma db push
该命令会自动创建对应的数据库表结构,并生成 TypeORM/Prisma Client 可用的访问接口。与此同时,建议采用模块化项目结构,将业务功能拆分为独立模块。例如,使用 CLI 工具生成菜单模块:
nest g module modules/menu
此操作将在 modules/ 目录下创建 menu 模块,避免根目录下文件堆积,提升项目可读性与协作效率。所有业务相关模块(如用户、权限、日志等)均应遵循此规范集中管理。
随后,基于 Menu 和 Meta 模型创建 DTO(Data Transfer Object),如 CreateMenuDTO、UpdateMenuDTO 等,用于接口参数校验与数据传输。这部分属于标准 CRUD 流程,开发者可根据已有经验快速完成
相关代码示例
1 ) DTO相关
1.1 create-menu.dto
import { Type } from "class-transformer"
import { IsBoolean, IsInt, IsOptional, IsString, ValidateIf, ValidateNested } from "class-validator"export class Meta {@IsOptional()@IsInt()id?:number/*@ValidateIf((o) => !o.id)@IsString()name: string@ValidateIf((o) => !o.id)@IsString()path: string*/@IsOptional()@IsString()layout?: string@IsOptional()@IsInt()order?: number@IsOptional()@IsString()icon?: string@IsOptional()@IsBoolean()hideMenu?: boolean@IsOptional()@IsBoolean()disabled?: boolean
}export class CreateMenuDto {@IsOptional()@IsInt()id?:number;@IsString()@ValidateIf((o) => !o.id)name: string@IsString()@ValidateIf((o) => !o.id)path: string;@IsOptional()@IsString()@ValidateIf((o) => !o.id)component?: string;@IsOptional()@IsString()@ValidateIf((o) => !o.id)redirect?: string;@IsOptional()@IsString()@ValidateIf((o) => !o.id)fullPath?: string;@IsOptional()@IsString()@ValidateIf((o) => !o.id)alias?: string;@IsOptional()@IsString()@ValidateIf((o) => !o.id)label?: string;@IsOptional()@Type(() => Meta)@ValidateNested({ each: true })@ValidateIf((o) => !o.id)meta?: Meta;@IsOptional()@Type(() => CreateMenuDto)@ValidateIf((o) => !o.id)children?: CreateMenuDto[];
}
1.2 update-menu.dto
import { PartialType } from '@nestjs/mapped-types';
import { CreateMenuDto } from './create-menu.dto';
import { IsInt } from 'class-validator';export class UpdateMenuDto extends PartialType(CreateMenuDto) {@IsInt()id: number;
}
2 ) controller 相关
// src/modules/menu/menu.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query, BadRequestException } from '@nestjs/common';
import { MenuService } from './menu.service';
import { CreateMenuDto } from './dto/create-menu.dto';
import { UpdateMenuDto } from './dto/update-menu.dto';
import { CustomParseIntPipe } from '@/common/pipes/custom-parse-int.pipe';@Controller('menu')
export class MenuController {constructor(private readonly menuService: MenuService) {}@Post()create(@Body() createMenuDto: CreateMenuDto) {return this.menuService.create(createMenuDto);}@Get()findAll(// 原因是 ParseIntPipe 不能接收 -1@Query('page', new CustomParseIntPipe( {optional: true })) page: number,@Query('limit', new CustomParseIntPipe( {optional: true })) limit: number,@Query('args') args: any,) {let parsedArgs: any;if (args) {try {parsedArgs = JSON.parse(args);} catch(error) {throw new BadRequestException('args: 无效的json数据格式');}}return this.menuService.findAll(page, limit, parsedArgs);}@Get(':id')findOne(@Param('id') id: string) {return this.menuService.findOne(+id);}@Patch(':id')update(@Param('id') id: string, @Body() updateMenuDto: UpdateMenuDto) {return this.menuService.update(+id, updateMenuDto);}@Delete(':id')remove(@Param('id') id: string) {return this.menuService.remove(+id);}
}
3 ) service 相关
import { Inject, Injectable } from '@nestjs/common';
import { CreateMenuDto } from './dto/create-menu.dto';
import { UpdateMenuDto } from './dto/update-menu.dto';
import { PRISMA_DB_CLIENT } from '@/database/database.constants';
import { PrismaClient } from 'prisma-postgresql';@Injectable()
export class MenuService {constructor(@Inject(PRISMA_DB_CLIENT) private prismaClient: PrismaClient) {}async createNested (dto: CreateMenuDto, prismaClient: PrismaClient, parentId?: number) {const { meta, children, ...restData } = dto;const parent = await prismaClient.menu.create({data: {parentId,...restData,meta: {create: meta}}})if (children?.length) {await Promise.all(children.map( child => this.createNested(child, prismaClient, parent.id)))}return parent;}async create(createMenuDto: CreateMenuDto) {const data = await this.createNested(createMenuDto, this.prismaClient);// 最多查两层即可return this.prismaClient.menu.findUnique({where: {id: data.id,},include: {meta: true,children: {include: {meta: true,children: true,}}}})}// 这是测试代码,查询前端的菜单不多,一次性全部查询出来,之后私用js进行递归嵌套组合// 如果是文档类型的,则没有问题,会把所有 children 都带上findOneTest(id: number) {return this.prismaClient.menu.findUnique({where: { id },include: {meta: true,children: true,}})}// 这是测试代码,下面嵌套 查询出来的 会很耗费性能findOneTest2(id: number) {return this.prismaClient.menu.findUnique({where: { id },include: {meta: true,children: {include: {meta: true,children: {include: {meta: true}}}}}})}findOne(id: number) {return this.prismaClient.menu.findUnique({where: { id },include: {meta: true,children: {include: {meta: true,children: true,}}}})}findAll(page: number = 1, limit: number = 10, args?: any) {const skip = (page - 1) * limit;let pagination: any = {skip,take: limit,}if (limit === -1) {pagination = {};}const includeArg = {meta: true,children: {include: {meta: true,children: true,}},...(args || {}),}return this.prismaClient.menu.findMany({...pagination,include: includeArg})}async update(id: number, updateMenuDto: UpdateMenuDto) {// menu -> children 这是嵌套结构// 可能有新增, 也可能有删除,实现起来有两种方式// 1. 复杂版本// 看看哪些id存在数据库中,这些id需要更新// 还有数据没有传递id, 需要新增,这就很复杂,需要分成好几个部分// 每个部分要判断 children 和 meta 数据(关联表, meta是否存在,如果存在,更新,不存在,新增)// 2. 简单版本 相对来说 删除后,新增,一般来说 id 都是自增的,删除后,新增的就不是原来的了// 采用 2const { children, meta, ...restData } = updateMenuDto;return this.prismaClient.$transaction(async (prisma: PrismaClient) => {await prisma.menu.update({where: { id },data: {...restData,meta: {update: meta,},/*// 这里会有问题,放到下面处理children: {deleteMany: {} // 删除所有 children 数据}*/}});// 判断传入的数据是否有 children, 存在 children 则递归创建if (children?.length) {const menuIds = (await this.collectMenuIds(id)).filter((o) => o !== id);// 删除 meta 数据await prisma.meta.deleteMany({where: {menuId: { in: menuIds }}});await prisma.menu.deleteMany({where: {id : { in: menuIds }},});await Promise.all(children.map((child) => {return this.createNested(child, prisma, id);}))}return prisma.menu.findUnique({where: { id },include: {meta: true,children: {include: {meta: true,children: true,}}}})})}async collectMenuIds(id) {const idsToDelete = [];idsToDelete.push(id);const menu = await this.prismaClient.menu.findUnique({where: { id },include: { children: true }})if (menu.children) {const childMenuIds = await Promise.all(menu.children.map(child => this.collectMenuIds(child.id)));for (const childIds of childMenuIds) {idsToDelete.push(...childIds);}}return idsToDelete;}async remove(id: number) {// 同时要删除 children 数据,并且删除关联表中的 meta 数据// 1. 删除关联表Meta数据// 2. 删除Menu中的children数据 查询出来,// 推荐的方式是把所有相关的menu数据全部查询出来,之后用 remove 语句 where id in [] 来做// 同时 meta 和 menu 是一对一的关系, 当获取了所有需要去删除的 menu id 之后,再去删除 meta// 删除之前要先进行查询操作(递归查)const idsToDelete = await this.collectMenuIds(id);// console.log('idsToDelete: ', idsToDelete);return this.prismaClient.$transaction(async (prisma: PrismaClient) => {// 现在删除关联表meta数据await prisma.meta.deleteMany({where: { menuId: { in : idsToDelete} },});// 之后,删除menu中的children 数据return await prisma.menu.deleteMany({where: { id: { in: idsToDelete }},});})}
}
总结
-
综上所述,菜单权限的核心在于以角色为基础,通过后端决策生成可视菜单列表,并以前端动态渲染实现界面级控制。其安全性虽弱于接口与数据权限,但仍需配合完整的认证鉴权体系共同构筑系统防护网。
-
在技术实现上,采用 Prisma 的 self-relation 机制构建树形菜单模型,结合 Menu 与 Meta 的分离设计,既满足了多级嵌套的业务需求,又保证了数据结构的清晰与可扩展性。最终通过模块化组织代码,提升了大型项目的工程管理水平