8-5 菜单权限:角色菜单关联(新增、更新、删除)
本节完成菜单与角色的关联操作(RoleMenu),实现角色的菜单权限分配。包括 Prisma Schema 设计、创建时关联菜单、更新时重建关联、删除时清理关联数据。
一、RoleMenu 数据库模型设计
在 schema.prisma 中新增 RoleMenu 关联表:
model RoleMenu {
id Int @id @default(autoincrement())
roleId Int
menuId Int
role Role @relation(fields: [roleId], references: [id])
menu Menu @relation(fields: [menuId], references: [id])
@@unique([roleId, menuId]) // 联合唯一约束:同一角色不能重复关联同一菜单
}
prisma
同步数据库:
npx prisma db push
bash
关联表建立后,Role 和 Menu 模型会自动获得反向关联字段:
Role → roleMenus: RoleMenu[] (一对多)
Menu → roleMenus: RoleMenu[] (一对多)
text
二、CreateRoleDto 添加 menus 字段
// dto/create-role.dto.ts
import { IsOptional, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { CreateMenuDto } from '../menu/dto/create-menu.dto';
export class CreateRoleDto {
@IsString()
name: string;
// ... 其他字段(permissions, policies)
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CreateMenuDto)
menus?: CreateMenuDto[];
}
typescript
与 permissions/policies 的区别:
| 维度 | RolePolicy / RolePermission | RoleMenu |
|---|---|---|
| 关联方式 | connectOrCreate(创建或关联) | connect(仅关联) |
| 数据特点 | Policy 可能在创建角色时新建 | 菜单一定先于角色存在 |
| 嵌套结构 | 无嵌套 | 菜单有 children 嵌套 |
设计决策:角色关联菜单时,只做
connect而不做connectOrCreate。原因是菜单一定是先创建好的,不存在"创建角色时同时创建菜单"的场景。这让逻辑更纯粹。
三、角色创建时关联菜单
// role.service.ts
async create(dto: CreateRoleDto) {
const { menus, permissions, policies, ...restData } = dto;
return this.prisma.role.create({
data: {
...restData,
// 关联菜单(仅 connect)
...(menus && {
roleMenus: {
create: menus.map((menu) => ({
menu: {
connect: menu.id
? { id: menu.id }
: { name: menu.name }, // 支持 ID 或 name 两种匹配方式
},
})),
},
}),
// 关联 permissions(connectOrCreate)
// 关联 policies(connectOrCreate)
},
include: {
rolePermissions: { include: { permission: true } },
rolePolicies: { include: { policy: true } },
roleMenus: { include: { menu: true } },
},
});
}
typescript
connect vs connectOrCreate
// connect:菜单必须已存在,直接关联
menu: { connect: { id: menu.id } }
// connectOrCreate:存在则关联,不存在则创建
policy: {
connectOrCreate: {
where: { encode },
create: { ...policy, encode },
},
}
typescript
注意:
connect方法直接使用where对象({ id: menu.id }),不需要嵌套where关键字。
四、支持仅传 ID 的 DTO 验证
当请求体只传 id 而不传其他字段时,需要使用 @ValidateIf 跳过验证:
// dto/create-menu.dto.ts(添加条件验证)
import { ValidateIf } from 'class-validator';
export class CreateMenuDto {
@IsOptional()
@IsInt()
id?: number;
@ValidateIf((o) => !o.id) // 有 ID 时跳过验证
@IsString()
name: string;
@ValidateIf((o) => !o.id)
@IsString()
path: string;
// ... 其他字段同理
}
typescript
这样前端可以直接传 { id: 14 } 或 { id: 1 } 来关联已有菜单。
五、角色更新时重建菜单关联
更新时采用 deleteMany + create 策略:
// role.service.ts
async update(id: number, dto: UpdateRoleDto) {
const { menus, permissions, policies, ...restData } = dto;
return this.prisma.role.update({
where: { id },
data: {
...restData,
...(menus && {
roleMenus: {
deleteMany: {}, // 删除所有旧的菜单关联
create: menus.map((menu) => ({
menu: {
connect: menu.id
? { id: menu.id }
: { name: menu.name },
},
})),
},
}),
},
include: {
rolePermissions: { include: { permission: true } },
rolePolicies: { include: { policy: true } },
roleMenus: { include: { menu: true } },
},
});
}
typescript
六、角色删除时清理关联数据
删除角色时,必须同时清理 rolePermissions、rolePolicies、roleMenus 三张关联表的数据:
// role.service.ts
async remove(id: number) {
return this.prisma.$transaction(async (prisma) => {
// 1. 先清理关联表数据
await prisma.role.update({
where: { id },
data: {
rolePermissions: { deleteMany: {} },
rolePolicies: { deleteMany: {} },
roleMenus: { deleteMany: {} },
},
});
// 2. 再删除角色
return prisma.role.delete({
where: { id },
});
});
}
typescript
为什么不删除 Permission 和 Policy:
- Permission 和 Policy 是独立的实体,有自己的 Controller 和 Service 管理
- 它们可能被其他角色引用,不能因为删除一个角色就级联删除
- 数据库中可能存在少量"孤儿数据",后期可通过定时任务清理
| 数据 | 删除角色时 | 原因 |
|---|---|---|
| rolePermissions | 删除 | 关联表,属于角色私有 |
| rolePolicies | 删除 | 关联表,属于角色私有 |
| roleMenus | 删除 | 关联表,属于角色私有 |
| Permission | 保留 | 独立实体,可能被其他角色引用 |
| Policy | 保留 | 独立实体,可能被其他角色引用 |
七、测试验证
# 创建角色并关联菜单
POST /role
{
"name": "普通用户305",
"menus": [
{ "id": 14 },
{ "id": 1 },
{ "id": 24 }
]
}
# 更新角色的菜单关联
PATCH /role/25
{
"menus": [
{ "id": 3 },
{ "id": 14 }
]
}
# 删除角色(级联清理关联数据)
DELETE /role/25
bash
数据库验证:
roles表确认角色创建/删除role_menus表确认关联关系正确menus表确认菜单数据未被删除
八、前端界面设计思考
本节的数据结构设计也隐含了前端界面的交互方式:
| 管理对象 | 界面设计 |
|---|---|
| Permission | 单独管理页面,CRUD 操作 |
| Policy | 单独管理页面,CRUD 操作 |
| Role | 角色管理页面,关联 Permission、Policy、Menu |
| Menu | 菜单管理页面,树形结构展示 |
| User | 用户管理页面,关联 Role |
用户 -> 角色 -> 菜单的完整链路:
用户登录 → 获取用户角色 → 查询角色关联的菜单 → 前端动态渲染侧边栏
text
九、总结
| 知识点 | 说明 |
|---|---|
| connect vs connectOrCreate | 菜单关联用 connect,Policy 用 connectOrCreate |
| @@unique 联合约束 | 防止角色重复关联同一菜单 |
| @ValidateIf 条件验证 | 传 ID 时跳过其他字段的验证 |
| deleteMany 清理关联 | 删除角色前先清理所有关联表数据 |
| 事务保障 | 删除关联 + 删除角色在同一事务中 |
| 数据归属原则 | 关联表随角色删除,独立实体保留 |
↑