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 角色表存在关联关系,它们生成了一个中间表,存储它们之间的关联关系;

相关推荐
用户5812441541571 小时前
GemDesign MCP协议详解:从原型到代码的完整技术链路
前端
半个烧饼不加肉1 小时前
JS 底层探究-- 事件循环
开发语言·前端·javascript
goDeep2 小时前
useMemo 和 useCallback 的区别,我终于搞懂了
前端
小亮学前端2 小时前
在1Panel中部署Nuxt项目
前端·vue.js
产品研究员2 小时前
AI生成可用的React交互代码实测:Lovable vs Stitch vs Paico
前端·react.js·aigc
labixiong2 小时前
Prompt 工程:当一段文字学会了思考、行动与统治
前端·ai编程
BingoGo2 小时前
PHP 在领域驱动(DDD)设计中的核心实践
后端·php
biubiubiu_LYQ2 小时前
入门开发者必学篇之JS事件循环:为什么你的代码输出总翻车?
前端·javascript
程序员黑豆2 小时前
AI全栈开发之Java:怎么安装JDK
前端·ai编程·全栈