打算使用 RBAC 模式开发权限系统;
用户 (User) → 角色 (Role) → 菜单/权限 (Menu/Permission)
首先创建 菜单menu 模块;
css
nest g res role
nest g res menu
菜单模块开发
生成两个模块之后,开始编写实体代码,定义字段、定义表与表之间的关系;
代码:
menu.entity.ts
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[]>
}
menu.dto.ts
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
}
menu.controller.ts
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);
}
}
menu.service.ts
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 角色表存在关联关系,它们生成了一个中间表,存储它们之间的关联关系;