nestjs实战-权限一: 菜单模块

打算使用 RBAC 模式开发权限系统;

用户 (User) → 角色 (Role) → 菜单/权限 (Menu/Permission)

首先创建 菜单menu 模块;

css 复制代码
nest g res role 
nest g res menu

菜单模块开发

生成两个模块之后,开始编写实体代码,定义字段、定义表与表之间的关系;

代码:

less 复制代码
import { Column, Entity, Index, ManyToMany, ManyToOne, OneToMany, Relation } from 'typeorm'
import { CommonEntity } from '~/common/entity/common.entity'
​
import { RoleEntity } from '../../role/entities/role.entity'
​
import { MenuType } from '../dto/menu.dto'
​
/**
 * 菜单实体(支持树形结构)
 * - `parentId` 形成父子层级
 * - `type` 用于区分:目录/菜单/按钮
 * - `permission` 用于按钮级/接口级权限标识(若后续做 RBAC 可直接复用)
 */
@Entity('sys_menu')
export class MenuEntity extends CommonEntity {
  /** 父级菜单 ID;为空表示根节点 */
  @Index()
  @Column({ name: 'parent_id', type: 'int', nullable: true })
  parentId: number | null
​
  /** 显示标题 */
  @Column({ length: 50 })
  title: string
​
  /** 类型:0(目录) / 1(菜单) / 2(按钮) */
  @Column({ type: 'tinyint', default: MenuType.MENU })
  type: MenuType
​
  /** 路由路径(目录/菜单常用;按钮可为空) */
  @Column({ nullable: true })
  path: string
​
  /** 前端组件路径(仅菜单常用,可为空) */
  @Column({ nullable: true })
  component: string
​
  /** 图标 */
  @Column({ nullable: true })
  icon: string
​
  /** 排序(越小越靠前) */
  @Index()
  @Column({ type: 'int', default: 0 })
  sort: number
​
  /** 权限标识(如:system:menu:create),用于鉴权/按钮权限 */
  @Column({ name: 'permission', nullable: true })
  permission: string
​
  /** 是否可见:1 可见;0 隐藏 */
  @Column({ type: 'tinyint', default: 1 })
  visible: number
​
  /** 状态:1 启用;0 禁用 */
  @Column({ type: 'tinyint', default: 1 })
  status: number
​
  @ManyToMany(() => RoleEntity, role => role.menus, {
    onDelete: 'CASCADE',
  })
  roles: Relation<RoleEntity[]>
}
less 复制代码
import {
  IsIn,
  IsInt,
  IsNotEmpty,
  IsOptional,
  IsString,
  MaxLength,
  Min,
  MinLength,
  ValidateIf,
} from 'class-validator'
​
export enum MenuType {
  /** 菜单 */
  MENU = 0,
  /** 目录 */
  MENU_GROUP = 1,
  /** 权限 */
  PERMISSION = 2,
}
​
export class MenuDto {
  /**
   * 父级菜单 ID;不传则作为根菜单
   * 可选,不传则作为根菜单
   * @example 1
   * @example null
   */
  @IsOptional()
  parentId: number | null
​
  /**
   * 菜单标题(展示用)
   * 必填,长度不能小于2,不能超过50
   * */
  @IsString({ message: '标题必须是字符串' })
  @IsNotEmpty({ message: '标题不能为空' })
  @MaxLength(50, { message: '标题长度不能超过50' })
  @MinLength(2, { message: '标题长度不能小于2' })
  title: string
​
  /** 类型:目录/菜单/按钮(默认 menu) */
  @IsIn([0, 1, 2])
  type: MenuType
​
  /** 路由路径 */
  @ValidateIf(o => o.type !== MenuType.PERMISSION)
  path: string
​
  /** 前端组件路径(menu 常用) */
  @IsString({ message: '组件必须是字符串' })
  @ValidateIf((o: MenuDto) => o.type !== MenuType.PERMISSION)
  @IsString({ message: '组件必须是字符串' })
  @IsOptional()
  component?: string
​
  /** 图标 */
  @IsString({ message: '图标必须是字符串' })
  @IsOptional()
  @ValidateIf((o: MenuDto) => o.type !== MenuType.PERMISSION)
  @IsString()
  icon?: string
​
  /** 排序(越小越靠前) */
  @IsInt({ message: '排序必须是整数' })
  @IsOptional()
  @Min(0, { message: '排序值不合法' })
  sort: number
​
  /** 权限标识(按钮/接口鉴权用) */
  @IsString({ message: '权限标识必须是字符串' })
  @ValidateIf((o: MenuDto) => o.type === MenuType.PERMISSION)
  @IsOptional()
  @IsString({ message: '权限标识必须是字符串' })
  permission: string
​
  /** 是否可见:1 可见;0 隐藏, 可选,不传则默认为 1 */
  @ValidateIf((o: MenuDto) => o.type !== MenuType.PERMISSION)
  @IsIn([0, 1])
  @IsOptional()
  visible: number
​
  /** 状态:1 启用;0 禁用 */
  @IsIn([0, 1])
  @IsOptional()
  status: number
}
less 复制代码
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import { MenuService } from './menu.service';
import { MenuDto } from './dto/menu.dto';
// import { UpdateMenuDto } from './dto/update-menu.dto';
import { Public } from '~/common/decorators/public.decorator';
​
/**
 * 菜单管理接口
 * 路由前缀:/menu
 */
@Controller('menu')
@Public()
export class MenuController {
  constructor(private readonly menuService: MenuService) {}
​
  /** 创建菜单 */
  @Post()
  create(@Body() createMenuDto: MenuDto) {
    console.log('createMenuDto', createMenuDto);
    return this.menuService.create(createMenuDto);
  }
​
  /** 更新菜单(部分更新) */
  @Patch(':id')
  update(@Param('id', ParseIntPipe) id: number, @Body() updateMenuDto: MenuDto) {
    console.log('updateMenuDto', updateMenuDto, id);
    return this.menuService.update(id, updateMenuDto);
  }
​
  /** 获取菜单树 */
  @Get()
  findAll() {
    return this.menuService.findAll();
  }
​
  /** 获取菜单详情 */
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.menuService.findOne(id);
  }
​
  /** 删除菜单(存在子菜单则拒绝) */
  @Delete(':id')
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.menuService.remove(id);
  }
}
​
typescript 复制代码
import { HttpException, Injectable } from '@nestjs/common';
import { MenuDto } from './dto/menu.dto';
// import { UpdateMenuDto } from './dto/update-menu.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MenuEntity } from './entities/menu.entity';
​
/**
 * 将平铺菜单列表构造成树形结构
 * - 不依赖数据库递归查询,适用于中小规模菜单数据
 * - `parentId` 找不到对应父节点时,该节点会被当作根节点
 */
function buildMenuTree(list: MenuEntity[]) {
  const map = new Map<number, any>()
  const roots: any[] = []
  for (const item of list) {
    map.set(item.id, { ...item, children: [] })
  }
  for (const item of list) {
    const node = map.get(item.id)
    const parentId = item.parentId
    if (parentId && map.has(parentId)) {
      map.get(parentId).children.push(node)
    }
    else {
      roots.push(node)
    }
  }
  return roots
}
​
@Injectable()
export class MenuService {
  constructor(
    @InjectRepository(MenuEntity)
    private readonly menuRepository: Repository<MenuEntity>,
  ) {}
​
  /**
   * 创建菜单
   * - 若传了 `parentId`,需要保证父级存在
   * - 默认值:type=menu、sort=0、visible=1、status=1
   */
  async create(createMenuDto: MenuDto) {
    const parentId = createMenuDto.parentId ?? null
    if (parentId) {
      const parent = await this.menuRepository.findOne({ where: { id: parentId } })
      if (!parent)
        throw new HttpException('父级菜单不存在', 400)
    }
​
    const entity = this.menuRepository.create({
      parentId,
      title: createMenuDto.title,
      type: createMenuDto.type ?? 0,
      path: createMenuDto.path ?? null,
      component: createMenuDto.component ?? null,
      icon: createMenuDto.icon ?? null,
      sort: createMenuDto.sort ?? 0,
      permission: createMenuDto.permission ?? null,
      visible: createMenuDto.visible ?? 1,
      status: createMenuDto.status ?? 1,
    })
    return await this.menuRepository.save(entity)
  }
​
  /**
   * 更新菜单
   * - 防止把自己设置为父级(形成环)
   * - 若更新 parentId,需校验父级存在;传 null 表示改为根节点
   */
  async update(id: number, updateMenuDto: MenuDto) {
    const menu = await this.menuRepository.findOne({ where: { id } })
    if (!menu)
      throw new HttpException('菜单不存在', 404)
​
    if (updateMenuDto.parentId === id)
      throw new HttpException('父级菜单不能是自己', 400)
​
    if (typeof updateMenuDto.parentId !== 'undefined') {
      if (updateMenuDto.parentId === null) {
        // root
      }
      else {
        const parent = await this.menuRepository.findOne({ where: { id: updateMenuDto.parentId } })
        if (!parent)
          throw new HttpException('父级菜单不存在', 400)
      }
    }
​
    await this.menuRepository.update(id, updateMenuDto as any)
    return await this.findOne(id)
  }
​
  /**
   * 获取菜单树
   * - 返回树结构,便于前端直接渲染侧边栏/权限菜单
   */
  async findAll() {
    const list = await this.menuRepository.find({
      order: { sort: 'ASC', id: 'ASC' },
    })
    return buildMenuTree(list)
  }
​
  /** 获取菜单详情 */
  async findOne(id: number) {
    const menu = await this.menuRepository.findOne({ where: { id } })
    if (!menu)
      throw new HttpException('菜单不存在', 404)
    return menu
  }
​
  /**
   * 删除菜单
   * - 若存在子菜单则拒绝删除,避免树结构断裂
   */
  async remove(id: number) {
    const menu = await this.menuRepository.findOne({ where: { id } })
    if (!menu)
      throw new HttpException('菜单不存在', 404)
​
    const child = await this.menuRepository.findOne({ where: { parentId: id } })
    if (child)
      throw new HttpException('请先删除子菜单', 400)
​
    await this.menuRepository.delete(id)
    return true
  }
}
​

代码解析:

首先需要介绍一些基础知识:

  • 表与表之间的关系

ManyToMany

javascript 复制代码
ManyToMany(() => Tag, (tag) => tag.posts, {
  cascade: true // 级联操作,例如保存文章时也会保存关联的标签
})
  • 第一个参数:目标实体类型

    • 作用:告诉 TypeORM 当前字段关联的是哪一张表(哪一个实体类)。

    • 为什么要用箭头函数 () =>

      • 这是为了解决 循环依赖 问题。因为 Post 类里引用了 Tag,而 Tag 类里通常也会引用 Post。如果直接写 Tag,在编译时可能会因为 Tag 还没定义而报错。
      • 使用箭头函数(懒加载函数)可以让 TypeORM 在真正需要用到这个类型时再去获取它,从而避免报错。
  • 第二个参数:关系的反向侧 (inverseSide)

    • 作用 :告诉 TypeORM 在对方(Tag 实体)身上,哪个属性是用来维护这个关系的。

    • 解析

      • 这里 (tag) => tag.posts 的意思是:"在 Tag 类中,有一个叫 posts 的属性,它代表了与当前 Post 的关系"。

      • 多对多的双向性:多对多关系中,双方都需要知道对方的存在。

        • Post 说:"我有多个 tags"。
        • Tag 说:"我也有多个 posts"。
      • 如果不写这个参数,TypeORM 就不知道这两个实体是双向关联的,查询时也就无法正确地进行双向导航。

  • 第三个参数:配置选项 (options)

    • 作用 :这是一个配置对象,用来定义关系的行为

    • 核心配置 cascade: true

      • 这是最常用的配置之一。

      • 含义:开启"级联"模式。

      • 实际效果 :当你保存(save)一个 Post 对象时,如果它的 tags 数组里有新的 Tag 对象,TypeORM 会自动 帮你把新的 Tag 也保存到数据库里,而不需要你手动先 save(tag)save(post)

      • 其他常见值

        • false:默认值,不自动保存关联对象。
        • ["insert", "update"]:只在插入和更新时级联,删除时不级联。
        • ["remove"]:删除主实体时,级联删除关联实体(慎用,容易误删数据)

JoinTable

关系拥有方: 使用 @JoinTable() 装饰器的一方。

关系反向方 (Inverse Side): 不使用 @JoinTable() 的一方。

less 复制代码
@ManyToMany(() => MenuEntity, menu => menu.roles, {})
@JoinTable({
  name: 'sys_role_menus', // 自定义中间表名
  joinColumn: { name: 'role_id', referencedColumnName: 'id' }, // 当前实体的外键列名
  inverseJoinColumn: { name: 'menu_id', referencedColumnName: 'id' }, // 目标实体的外键列名
})
menus: Relation<MenuEntity[]>

总结:

菜单模块主要就是 CRUD,其中注意它与 role 角色表存在关联关系,它们生成了一个中间表,存储它们之间的关联关系;

相关推荐
qq_225891746642 分钟前
基于Flask的空气质量监测与预测分析系统
开发语言·后端·python·信息可视化·django·flask
恋猫de小郭1 小时前
社区版 Dart macros?一个可以解决 JSON 序列化的Fluter “宏编程”第三方包
android·前端·flutter
覆东流1 小时前
4.Java运算符与表达式
java·开发语言·后端
马丁玩编程1 小时前
GitHub 3.5k Star 之后,Ragent AI 框架新版本来了
后端·面试·github
微石科技1 小时前
医疗设备维保如何从被动维修转向预防管理?宁波微石科技让运行数据提前发信号
java·后端·struts
2601_965798471 小时前
Contractor Web Setup: Deploying Bathrooms & Kitchens Theme Fast
前端·web3·php·wordpress
AINative软件工程1 小时前
LLM 应用的 Warm-Up 工程实践:冷启动延迟从 12 秒砍到 800ms 的 5 个工程手段
后端·llm·ai编程
Dxy12393102161 小时前
Python XPath position() 完整使用指南,避坑合集(lxml适用)
前端·javascript·python
SomeB1oody1 小时前
【RustyML入门】5.1. 回归指标
开发语言·后端·机器学习·rust·教程
名字还没想好☜2 小时前
React 19 useOptimistic 实战:点赞评论先更新 UI,失败自动回滚
前端·javascript·react.js·ui·react·useoptimistic