feat(api): 实现权限管理系统

- 添加 Menu、Role、Permission 数据模型及关联表
- 实现 PermissionModule 提供菜单、角色、权限 CRUD
- 扩展 AuthController 添加获取用户菜单权限接口
- PrismaService 支持新模型的软删除
- 添加数据库种子脚本

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-17 14:05:09 +08:00
parent ffb16efa62
commit 15d6e6e29e
27 changed files with 1994 additions and 21 deletions

View File

@@ -16,7 +16,8 @@
"db:push": "dotenv -e .env -e .env.local -- prisma db push",
"db:migrate": "dotenv -e .env -e .env.local -- prisma migrate dev",
"db:migrate:reset": "dotenv -e .env -e .env.local -- prisma migrate reset",
"db:studio": "dotenv -e .env -e .env.local -- prisma studio"
"db:studio": "dotenv -e .env -e .env.local -- prisma studio",
"db:seed": "dotenv -e .env -e .env.local -- ts-node prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^10.4.15",

View File

@@ -8,15 +8,128 @@ datasource db {
}
model User {
id String @id @default(cuid(2))
email String
password String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
id String @id @default(cuid(2))
email String
password String
name String?
isSuperAdmin Boolean @default(false) // 超级管理员标记
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
roles UserRole[]
// 复合唯一约束:未删除用户邮箱唯一,已删除用户邮箱可重复
@@unique([email, deletedAt])
@@map("users")
}
// 角色表
model Role {
id String @id @default(cuid(2))
code String @unique // 角色编码: admin, user
name String // 角色名称
description String?
isSystem Boolean @default(false) // 系统内置角色不可删
isEnabled Boolean @default(true)
sort Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
users UserRole[]
permissions RolePermission[]
menus RoleMenu[]
@@map("roles")
}
// 权限表
model Permission {
id String @id @default(cuid(2))
code String @unique // 权限编码: user:create
name String
description String?
resource String // 资源: user
action String // 操作: create
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
roles RolePermission[]
@@index([resource])
@@map("permissions")
}
// 菜单表
model Menu {
id String @id @default(cuid(2))
parentId String?
code String @unique
name String
type String @default("menu") // dir / menu / button
path String?
icon String? // Lucide 图标名
component String?
permission String? // 关联权限编码
isExternal Boolean @default(false)
isHidden Boolean @default(false)
isEnabled Boolean @default(true)
isStatic Boolean @default(true) // 静态/动态菜单
sort Int @default(0)
meta Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
parent Menu? @relation("MenuTree", fields: [parentId], references: [id])
children Menu[] @relation("MenuTree")
roles RoleMenu[]
@@index([parentId])
@@map("menus")
}
// 用户-角色关联表
model UserRole {
id String @id @default(cuid(2))
userId String
roleId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([userId, roleId])
@@map("user_roles")
}
// 角色-权限关联表
model RolePermission {
id String @id @default(cuid(2))
roleId String
permissionId String
createdAt DateTime @default(now())
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@unique([roleId, permissionId])
@@map("role_permissions")
}
// 角色-菜单关联表
model RoleMenu {
id String @id @default(cuid(2))
roleId String
menuId String
createdAt DateTime @default(now())
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
menu Menu @relation(fields: [menuId], references: [id], onDelete: Cascade)
@@unique([roleId, menuId])
@@map("role_menus")
}

328
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,328 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
// 初始权限数据
const permissions = [
// 用户管理权限
{ code: 'user:create', name: '创建用户', resource: 'user', action: 'create' },
{ code: 'user:read', name: '查看用户', resource: 'user', action: 'read' },
{ code: 'user:update', name: '更新用户', resource: 'user', action: 'update' },
{ code: 'user:delete', name: '删除用户', resource: 'user', action: 'delete' },
// 角色管理权限
{ code: 'role:create', name: '创建角色', resource: 'role', action: 'create' },
{ code: 'role:read', name: '查看角色', resource: 'role', action: 'read' },
{ code: 'role:update', name: '更新角色', resource: 'role', action: 'update' },
{ code: 'role:delete', name: '删除角色', resource: 'role', action: 'delete' },
// 权限管理权限
{ code: 'permission:read', name: '查看权限', resource: 'permission', action: 'read' },
// 菜单管理权限
{ code: 'menu:create', name: '创建菜单', resource: 'menu', action: 'create' },
{ code: 'menu:read', name: '查看菜单', resource: 'menu', action: 'read' },
{ code: 'menu:update', name: '更新菜单', resource: 'menu', action: 'update' },
{ code: 'menu:delete', name: '删除菜单', resource: 'menu', action: 'delete' },
];
// 初始角色数据
const roles = [
{
code: 'super_admin',
name: '超级管理员',
description: '拥有系统所有权限',
isSystem: true,
sort: 0,
},
{
code: 'admin',
name: '管理员',
description: '拥有大部分管理权限',
isSystem: true,
sort: 1,
},
{
code: 'user',
name: '普通用户',
description: '基础用户权限',
isSystem: true,
sort: 2,
},
];
// 初始菜单数据
const menus = [
{
code: 'dashboard',
name: '仪表盘',
type: 'menu',
path: '/dashboard',
icon: 'LayoutDashboard',
sort: 0,
isStatic: true,
},
{
code: 'user-management',
name: '用户管理',
type: 'menu',
path: '/users',
icon: 'Users',
permission: 'user:read',
sort: 1,
isStatic: true,
},
{
code: 'system',
name: '系统管理',
type: 'dir',
icon: 'Settings',
sort: 100,
isStatic: true,
},
{
code: 'role-management',
name: '角色管理',
type: 'menu',
path: '/roles',
icon: 'Shield',
permission: 'role:read',
sort: 0,
isStatic: true,
// parentCode: 'system', // 稍后处理
},
{
code: 'menu-management',
name: '菜单管理',
type: 'menu',
path: '/menus',
icon: 'Menu',
permission: 'menu:read',
sort: 1,
isStatic: true,
// parentCode: 'system', // 稍后处理
},
{
code: 'profile',
name: '个人中心',
type: 'menu',
path: '/profile',
icon: 'User',
sort: 200,
isStatic: true,
},
{
code: 'settings',
name: '系统设置',
type: 'menu',
path: '/settings',
icon: 'Settings',
sort: 201,
isStatic: true,
},
];
async function main() {
console.log('开始初始化种子数据...');
// 1. 创建权限
console.log('创建权限...');
for (const permission of permissions) {
await prisma.permission.upsert({
where: { code: permission.code },
update: permission,
create: permission,
});
}
console.log(`已创建 ${permissions.length} 个权限`);
// 2. 创建角色
console.log('创建角色...');
for (const role of roles) {
await prisma.role.upsert({
where: { code: role.code },
update: role,
create: role,
});
}
console.log(`已创建 ${roles.length} 个角色`);
// 3. 创建菜单
console.log('创建菜单...');
// 先创建顶级菜单
const topMenus = menus.filter(
(m) => !['role-management', 'menu-management'].includes(m.code)
);
for (const menu of topMenus) {
await prisma.menu.upsert({
where: { code: menu.code },
update: menu,
create: menu,
});
}
// 获取系统管理菜单的 ID
const systemMenu = await prisma.menu.findUnique({
where: { code: 'system' },
});
// 创建系统管理子菜单
if (systemMenu) {
const subMenus = menus.filter((m) =>
['role-management', 'menu-management'].includes(m.code)
);
for (const menu of subMenus) {
await prisma.menu.upsert({
where: { code: menu.code },
update: { ...menu, parentId: systemMenu.id },
create: { ...menu, parentId: systemMenu.id },
});
}
}
console.log(`已创建 ${menus.length} 个菜单`);
// 4. 为管理员角色分配权限(除超管外的所有权限)
console.log('分配角色权限...');
const adminRole = await prisma.role.findUnique({
where: { code: 'admin' },
});
const allPermissions = await prisma.permission.findMany();
if (adminRole) {
// 清除已有权限
await prisma.rolePermission.deleteMany({
where: { roleId: adminRole.id },
});
// 分配所有权限给管理员
for (const permission of allPermissions) {
await prisma.rolePermission.create({
data: {
roleId: adminRole.id,
permissionId: permission.id,
},
});
}
console.log(`已为管理员角色分配 ${allPermissions.length} 个权限`);
}
// 5. 为普通用户分配基础权限
const userRole = await prisma.role.findUnique({
where: { code: 'user' },
});
const userReadPermission = await prisma.permission.findUnique({
where: { code: 'user:read' },
});
if (userRole && userReadPermission) {
await prisma.rolePermission.deleteMany({
where: { roleId: userRole.id },
});
await prisma.rolePermission.create({
data: {
roleId: userRole.id,
permissionId: userReadPermission.id,
},
});
console.log('已为普通用户角色分配基础权限');
}
// 6. 为角色分配菜单
console.log('分配角色菜单...');
const allMenus = await prisma.menu.findMany();
// 管理员拥有所有菜单
if (adminRole) {
await prisma.roleMenu.deleteMany({
where: { roleId: adminRole.id },
});
for (const menu of allMenus) {
await prisma.roleMenu.create({
data: {
roleId: adminRole.id,
menuId: menu.id,
},
});
}
console.log(`已为管理员角色分配 ${allMenus.length} 个菜单`);
}
// 普通用户只拥有基础菜单
if (userRole) {
const userMenus = allMenus.filter((m) =>
['dashboard', 'profile', 'settings'].includes(m.code)
);
await prisma.roleMenu.deleteMany({
where: { roleId: userRole.id },
});
for (const menu of userMenus) {
await prisma.roleMenu.create({
data: {
roleId: userRole.id,
menuId: menu.id,
},
});
}
console.log(`已为普通用户角色分配 ${userMenus.length} 个菜单`);
}
// 7. 创建超级管理员用户
console.log('创建超级管理员用户...');
const superAdminPassword = await bcrypt.hash('admin123', 10);
// 由于复合唯一约束包含 deletedAt无法使用 upsert改用 findFirst + update/create
let superAdminUser = await prisma.user.findFirst({
where: { email: 'admin@seclusion.dev' },
});
if (superAdminUser) {
superAdminUser = await prisma.user.update({
where: { id: superAdminUser.id },
data: { isSuperAdmin: true },
});
} else {
superAdminUser = await prisma.user.create({
data: {
email: 'admin@seclusion.dev',
password: superAdminPassword,
name: '超级管理员',
isSuperAdmin: true,
},
});
}
// 为超管用户分配超管角色
const superAdminRole = await prisma.role.findUnique({
where: { code: 'super_admin' },
});
if (superAdminRole) {
await prisma.userRole.upsert({
where: {
userId_roleId: {
userId: superAdminUser.id,
roleId: superAdminRole.id,
},
},
update: {},
create: {
userId: superAdminUser.id,
roleId: superAdminRole.id,
},
});
}
console.log('超级管理员用户创建完成');
console.log(' 邮箱: admin@seclusion.dev');
console.log(' 密码: admin123');
console.log('\n种子数据初始化完成');
}
main()
.catch((e) => {
console.error('种子数据初始化失败:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -7,6 +7,7 @@ import { AuthModule } from './auth/auth.module';
import { CaptchaModule } from './common/captcha';
import { CryptoModule } from './common/crypto';
import { RedisModule } from './common/redis';
import { PermissionModule } from './permission';
import { PrismaModule } from './prisma/prisma.module';
import { UserModule } from './user/user.module';
@@ -23,6 +24,7 @@ import { UserModule } from './user/user.module';
CaptchaModule,
AuthModule,
UserModule,
PermissionModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -1,6 +1,6 @@
import { Controller, Post, Body, Get, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiOkResponse } from '@nestjs/swagger';
import type { AuthUser } from '@seclusion/shared';
import type { AuthUserWithPermissions } from '@seclusion/shared';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
@@ -8,10 +8,16 @@ import { Public } from './decorators/public.decorator';
import { RegisterDto, LoginDto, RefreshTokenDto, AuthResponseDto, AuthUserDto, RefreshTokenResponseDto } from './dto/auth.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UserMenusAndPermissionsResponseDto } from '@/permission/dto';
import { MenuService } from '@/permission/services';
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
private readonly menuService: MenuService
) {}
@Post('register')
@Public()
@@ -45,7 +51,23 @@ export class AuthController {
@ApiBearerAuth()
@ApiOperation({ summary: '获取当前用户信息' })
@ApiOkResponse({ type: AuthUserDto, description: '当前用户信息' })
getProfile(@CurrentUser() user: AuthUser) {
return user;
getProfile(@CurrentUser() user: AuthUserWithPermissions) {
return {
id: user.id,
email: user.email,
name: user.name,
};
}
@Get('menus')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '获取当前用户的菜单和权限' })
@ApiOkResponse({ type: UserMenusAndPermissionsResponseDto, description: '用户菜单和权限' })
async getMenusAndPermissions(@CurrentUser() user: AuthUserWithPermissions) {
return this.menuService.getMenusAndPermissionsByRoles(
user.isSuperAdmin,
user.roleIds
);
}
}

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
@@ -7,6 +7,8 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { PermissionModule } from '@/permission';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
@@ -19,6 +21,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
},
}),
}),
forwardRef(() => PermissionModule),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],

View File

@@ -1,7 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import type { TokenPayload } from '@seclusion/shared';
import type { TokenPayload, AuthUserWithPermissions } from '@seclusion/shared';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';
@@ -19,15 +19,57 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
async validate(payload: TokenPayload) {
async validate(payload: TokenPayload): Promise<AuthUserWithPermissions> {
// 查询用户及其角色和权限
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
include: {
roles: {
include: {
role: {
include: {
permissions: {
include: {
permission: {
select: { code: true, isEnabled: true },
},
},
},
},
},
},
},
},
});
if (!user) {
throw new UnauthorizedException('用户不存在');
}
return { id: user.id, email: user.email, name: user.name };
// 提取角色编码和 ID
const roles = user.roles.map((ur) => ur.role.code);
const roleIds = user.roles.map((ur) => ur.roleId);
// 提取权限编码(去重,只包含启用的权限)
const permissionsSet = new Set<string>();
for (const userRole of user.roles) {
if (userRole.role.isEnabled) {
for (const rolePermission of userRole.role.permissions) {
if (rolePermission.permission.isEnabled) {
permissionsSet.add(rolePermission.permission.code);
}
}
}
}
return {
id: user.id,
email: user.email,
name: user.name,
isSuperAdmin: user.isSuperAdmin,
roles,
roleIds,
permissions: [...permissionsSet],
};
}
}

View File

@@ -6,6 +6,9 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { EncryptionInterceptor } from './common/crypto';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { version } = require('../package.json');
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
// 日志级别: 'log' | 'error' | 'warn' | 'debug' | 'verbose'
@@ -46,7 +49,7 @@ async function bootstrap() {
const config = new DocumentBuilder()
.setTitle('Seclusion API')
.setDescription('Seclusion 项目 API 文档')
.setVersion('1.0')
.setVersion(version)
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);

View File

@@ -0,0 +1,3 @@
export { PermissionController } from './permission.controller';
export { RoleController } from './role.controller';
export { MenuController } from './menu.controller';

View File

@@ -0,0 +1,65 @@
import { Controller, Get, Post, Body, Patch, Delete, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse, ApiCreatedResponse } from '@nestjs/swagger';
import { RequirePermission } from '../decorators';
import { CreateMenuDto, UpdateMenuDto, MenuResponseDto, MenuTreeNodeDto } from '../dto';
import { PermissionGuard } from '../guards';
import { MenuService } from '../services';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
@ApiTags('菜单管理')
@Controller('menus')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiBearerAuth()
export class MenuController {
constructor(private readonly menuService: MenuService) {}
@Get()
@RequirePermission('menu:read')
@ApiOperation({ summary: '获取菜单树(仅启用的菜单)' })
@ApiOkResponse({ type: [MenuTreeNodeDto], description: '菜单树' })
findTree() {
return this.menuService.findTree();
}
@Get('full')
@RequirePermission('menu:read')
@ApiOperation({ summary: '获取完整菜单树(包含禁用的菜单)' })
@ApiOkResponse({ type: [MenuTreeNodeDto], description: '完整菜单树' })
findFullTree() {
return this.menuService.findFullTree();
}
@Get(':id')
@RequirePermission('menu:read')
@ApiOperation({ summary: '获取菜单详情' })
@ApiOkResponse({ type: MenuResponseDto, description: '菜单详情' })
findById(@Param('id') id: string) {
return this.menuService.findById(id);
}
@Post()
@RequirePermission('menu:create')
@ApiOperation({ summary: '创建菜单' })
@ApiCreatedResponse({ type: MenuResponseDto, description: '创建成功' })
create(@Body() dto: CreateMenuDto) {
return this.menuService.createMenu(dto);
}
@Patch(':id')
@RequirePermission('menu:update')
@ApiOperation({ summary: '更新菜单' })
@ApiOkResponse({ type: MenuResponseDto, description: '更新成功' })
update(@Param('id') id: string, @Body() dto: UpdateMenuDto) {
return this.menuService.updateMenu(id, dto);
}
@Delete(':id')
@RequirePermission('menu:delete')
@ApiOperation({ summary: '删除菜单' })
@ApiOkResponse({ description: '删除成功' })
delete(@Param('id') id: string) {
return this.menuService.deleteMenu(id);
}
}

View File

@@ -0,0 +1,66 @@
import { Controller, Get, Post, Body, Patch, Delete, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse, ApiCreatedResponse } from '@nestjs/swagger';
import { RequirePermission } from '../decorators';
import { CreatePermissionDto, UpdatePermissionDto, PermissionResponseDto, PaginatedPermissionResponseDto } from '../dto';
import { PermissionGuard } from '../guards';
import { PermissionService } from '../services';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { PaginationQueryDto } from '@/common/crud';
@ApiTags('权限管理')
@Controller('permissions')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiBearerAuth()
export class PermissionController {
constructor(private readonly permissionService: PermissionService) {}
@Get()
@RequirePermission('permission:read')
@ApiOperation({ summary: '获取所有权限(分页)' })
@ApiOkResponse({ type: PaginatedPermissionResponseDto, description: '权限列表' })
findAll(@Query() query: PaginationQueryDto) {
return this.permissionService.findAll(query);
}
@Get('grouped')
@RequirePermission('permission:read')
@ApiOperation({ summary: '按资源分组获取权限' })
@ApiOkResponse({ description: '按资源分组的权限列表' })
findGroupedByResource() {
return this.permissionService.findGroupedByResource();
}
@Get(':id')
@RequirePermission('permission:read')
@ApiOperation({ summary: '获取权限详情' })
@ApiOkResponse({ type: PermissionResponseDto, description: '权限详情' })
findById(@Param('id') id: string) {
return this.permissionService.findById(id);
}
@Post()
@RequirePermission('permission:create')
@ApiOperation({ summary: '创建权限' })
@ApiCreatedResponse({ type: PermissionResponseDto, description: '创建成功' })
create(@Body() dto: CreatePermissionDto) {
return this.permissionService.create(dto);
}
@Patch(':id')
@RequirePermission('permission:update')
@ApiOperation({ summary: '更新权限' })
@ApiOkResponse({ type: PermissionResponseDto, description: '更新成功' })
update(@Param('id') id: string, @Body() dto: UpdatePermissionDto) {
return this.permissionService.update(id, dto);
}
@Delete(':id')
@RequirePermission('permission:delete')
@ApiOperation({ summary: '删除权限' })
@ApiOkResponse({ description: '删除成功' })
delete(@Param('id') id: string) {
return this.permissionService.delete(id);
}
}

View File

@@ -0,0 +1,58 @@
import { Controller, Get, Post, Body, Patch, Delete, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse, ApiCreatedResponse } from '@nestjs/swagger';
import { RequirePermission } from '../decorators';
import { CreateRoleDto, UpdateRoleDto, RoleResponseDto, RoleDetailResponseDto, PaginatedRoleResponseDto } from '../dto';
import { PermissionGuard } from '../guards';
import { RoleService } from '../services';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { PaginationQueryDto } from '@/common/crud';
@ApiTags('角色管理')
@Controller('roles')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiBearerAuth()
export class RoleController {
constructor(private readonly roleService: RoleService) {}
@Get()
@RequirePermission('role:read')
@ApiOperation({ summary: '获取所有角色(分页)' })
@ApiOkResponse({ type: PaginatedRoleResponseDto, description: '角色列表' })
findAll(@Query() query: PaginationQueryDto) {
return this.roleService.findAll(query);
}
@Get(':id')
@RequirePermission('role:read')
@ApiOperation({ summary: '获取角色详情(包含权限和菜单)' })
@ApiOkResponse({ type: RoleDetailResponseDto, description: '角色详情' })
findById(@Param('id') id: string) {
return this.roleService.findByIdWithDetail(id);
}
@Post()
@RequirePermission('role:create')
@ApiOperation({ summary: '创建角色' })
@ApiCreatedResponse({ type: RoleResponseDto, description: '创建成功' })
create(@Body() dto: CreateRoleDto) {
return this.roleService.createRole(dto);
}
@Patch(':id')
@RequirePermission('role:update')
@ApiOperation({ summary: '更新角色' })
@ApiOkResponse({ type: RoleResponseDto, description: '更新成功' })
update(@Param('id') id: string, @Body() dto: UpdateRoleDto) {
return this.roleService.updateRole(id, dto);
}
@Delete(':id')
@RequirePermission('role:delete')
@ApiOperation({ summary: '删除角色' })
@ApiOkResponse({ description: '删除成功' })
delete(@Param('id') id: string) {
return this.roleService.deleteRole(id);
}
}

View File

@@ -0,0 +1 @@
export { PERMISSION_KEY, RequirePermission } from './require-permission.decorator';

View File

@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_KEY = 'permissions';
/**
* 权限装饰器
* 用于标记接口所需的权限,多个权限为 OR 关系
* @param permissions 权限编码列表
*/
export const RequirePermission = (...permissions: string[]) =>
SetMetadata(PERMISSION_KEY, permissions);

View File

@@ -0,0 +1,3 @@
export * from './permission.dto';
export * from './role.dto';
export * from './menu.dto';

View File

@@ -0,0 +1,242 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MenuType, type MenuResponse, type MenuTreeNode, type MenuMeta, type CreateMenuDto as ICreateMenuDto, type UpdateMenuDto as IUpdateMenuDto, type UserMenusAndPermissionsResponse } from '@seclusion/shared';
import { IsString, IsOptional, IsBoolean, IsInt, IsObject, Min, IsIn, Matches } from 'class-validator';
/** 创建菜单 DTO */
export class CreateMenuDto implements ICreateMenuDto {
@ApiPropertyOptional({ example: 'clxxx123', description: '父菜单 ID' })
@IsString()
@IsOptional()
parentId?: string;
@ApiProperty({ example: 'user-management', description: '菜单编码(小写字母、数字和横杠)' })
@IsString()
@Matches(/^[a-z][a-z0-9-]*$/, { message: '菜单编码只能包含小写字母、数字和横杠,且以字母开头' })
code: string;
@ApiProperty({ example: '用户管理', description: '菜单名称' })
@IsString()
name: string;
@ApiPropertyOptional({ example: 'menu', description: '菜单类型', enum: ['dir', 'menu', 'button'] })
@IsIn(['dir', 'menu', 'button'])
@IsOptional()
type?: MenuType;
@ApiPropertyOptional({ example: '/users', description: '路由路径' })
@IsString()
@IsOptional()
path?: string;
@ApiPropertyOptional({ example: 'Users', description: 'Lucide 图标名' })
@IsString()
@IsOptional()
icon?: string;
@ApiPropertyOptional({ example: 'users/index', description: '组件路径' })
@IsString()
@IsOptional()
component?: string;
@ApiPropertyOptional({ example: 'user:read', description: '关联权限编码' })
@IsString()
@IsOptional()
permission?: string;
@ApiPropertyOptional({ example: false, description: '是否外链' })
@IsBoolean()
@IsOptional()
isExternal?: boolean;
@ApiPropertyOptional({ example: false, description: '是否隐藏' })
@IsBoolean()
@IsOptional()
isHidden?: boolean;
@ApiPropertyOptional({ example: true, description: '是否启用' })
@IsBoolean()
@IsOptional()
isEnabled?: boolean;
@ApiPropertyOptional({ example: true, description: '是否静态菜单' })
@IsBoolean()
@IsOptional()
isStatic?: boolean;
@ApiPropertyOptional({ example: 0, description: '排序值' })
@IsInt()
@Min(0)
@IsOptional()
sort?: number;
@ApiPropertyOptional({ example: { keepAlive: true }, description: '元数据' })
@IsObject()
@IsOptional()
meta?: MenuMeta;
}
/** 更新菜单 DTO */
export class UpdateMenuDto implements IUpdateMenuDto {
@ApiPropertyOptional({ example: 'clxxx123', description: '父菜单 IDnull 表示移动到顶级)' })
@IsString()
@IsOptional()
parentId?: string | null;
@ApiPropertyOptional({ example: '用户管理', description: '菜单名称' })
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({ example: 'menu', description: '菜单类型', enum: ['dir', 'menu', 'button'] })
@IsIn(['dir', 'menu', 'button'])
@IsOptional()
type?: MenuType;
@ApiPropertyOptional({ example: '/users', description: '路由路径' })
@IsString()
@IsOptional()
path?: string;
@ApiPropertyOptional({ example: 'Users', description: 'Lucide 图标名' })
@IsString()
@IsOptional()
icon?: string;
@ApiPropertyOptional({ example: 'users/index', description: '组件路径' })
@IsString()
@IsOptional()
component?: string;
@ApiPropertyOptional({ example: 'user:read', description: '关联权限编码' })
@IsString()
@IsOptional()
permission?: string;
@ApiPropertyOptional({ example: false, description: '是否外链' })
@IsBoolean()
@IsOptional()
isExternal?: boolean;
@ApiPropertyOptional({ example: false, description: '是否隐藏' })
@IsBoolean()
@IsOptional()
isHidden?: boolean;
@ApiPropertyOptional({ example: true, description: '是否启用' })
@IsBoolean()
@IsOptional()
isEnabled?: boolean;
@ApiPropertyOptional({ example: 0, description: '排序值' })
@IsInt()
@Min(0)
@IsOptional()
sort?: number;
@ApiPropertyOptional({ example: { keepAlive: true }, description: '元数据' })
@IsObject()
@IsOptional()
meta?: MenuMeta;
}
/** 菜单响应 DTO */
export class MenuResponseDto implements MenuResponse {
@ApiProperty({ example: 'clxxx123', description: '菜单 ID' })
id: string;
@ApiProperty({ example: 'clxxx456', description: '父菜单 ID', nullable: true })
parentId: string | null;
@ApiProperty({ example: 'user-management', description: '菜单编码' })
code: string;
@ApiProperty({ example: '用户管理', description: '菜单名称' })
name: string;
@ApiProperty({ example: 'menu', description: '菜单类型', enum: ['dir', 'menu', 'button'] })
type: MenuType;
@ApiProperty({ example: '/users', description: '路由路径', nullable: true })
path: string | null;
@ApiProperty({ example: 'Users', description: 'Lucide 图标名', nullable: true })
icon: string | null;
@ApiProperty({ example: 'users/index', description: '组件路径', nullable: true })
component: string | null;
@ApiProperty({ example: 'user:read', description: '关联权限编码', nullable: true })
permission: string | null;
@ApiProperty({ example: false, description: '是否外链' })
isExternal: boolean;
@ApiProperty({ example: false, description: '是否隐藏' })
isHidden: boolean;
@ApiProperty({ example: true, description: '是否启用' })
isEnabled: boolean;
@ApiProperty({ example: true, description: '是否静态菜单' })
isStatic: boolean;
@ApiProperty({ example: 0, description: '排序值' })
sort: number;
@ApiProperty({ example: { keepAlive: true }, description: '元数据', nullable: true })
meta: MenuMeta | null;
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '创建时间' })
createdAt: string;
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '更新时间' })
updatedAt: string;
}
/** 菜单树节点 DTO */
export class MenuTreeNodeDto implements MenuTreeNode {
@ApiProperty({ example: 'clxxx123', description: '菜单 ID' })
id: string;
@ApiProperty({ example: 'clxxx456', description: '父菜单 ID', nullable: true })
parentId: string | null;
@ApiProperty({ example: 'user-management', description: '菜单编码' })
code: string;
@ApiProperty({ example: '用户管理', description: '菜单名称' })
name: string;
@ApiProperty({ example: 'menu', description: '菜单类型', enum: ['dir', 'menu', 'button'] })
type: MenuType;
@ApiProperty({ example: '/users', description: '路由路径', nullable: true })
path: string | null;
@ApiProperty({ example: 'Users', description: 'Lucide 图标名', nullable: true })
icon: string | null;
@ApiProperty({ example: 'user:read', description: '关联权限编码', nullable: true })
permission: string | null;
@ApiProperty({ example: false, description: '是否隐藏' })
isHidden: boolean;
@ApiProperty({ example: { keepAlive: true }, description: '元数据', nullable: true })
meta: MenuMeta | null;
@ApiPropertyOptional({ type: [MenuTreeNodeDto], description: '子菜单' })
children?: MenuTreeNodeDto[];
}
/** 用户菜单和权限响应 DTO */
export class UserMenusAndPermissionsResponseDto implements UserMenusAndPermissionsResponse {
@ApiProperty({ type: [MenuTreeNodeDto], description: '用户可访问的菜单树' })
menus: MenuTreeNodeDto[];
@ApiProperty({ example: ['user:read', 'user:create'], description: '用户拥有的权限编码列表', type: [String] })
permissions: string[];
@ApiProperty({ example: false, description: '是否为超级管理员' })
isSuperAdmin: boolean;
}

View File

@@ -0,0 +1,86 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import type { PermissionResponse, CreatePermissionDto as ICreatePermissionDto, UpdatePermissionDto as IUpdatePermissionDto } from '@seclusion/shared';
import { IsString, IsOptional, IsBoolean, Matches } from 'class-validator';
import { createPaginatedResponseDto } from '@/common/crud';
/** 创建权限 DTO */
export class CreatePermissionDto implements ICreatePermissionDto {
@ApiProperty({ example: 'user:create', description: '权限编码(格式:资源:操作)' })
@IsString()
@Matches(/^[a-z]+:[a-z]+$/, { message: '权限编码格式不正确,应为 资源:操作 格式' })
code: string;
@ApiProperty({ example: '创建用户', description: '权限名称' })
@IsString()
name: string;
@ApiPropertyOptional({ example: '允许创建新用户', description: '权限描述' })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ example: 'user', description: '资源名称' })
@IsString()
resource: string;
@ApiProperty({ example: 'create', description: '操作类型' })
@IsString()
action: string;
@ApiPropertyOptional({ example: true, description: '是否启用' })
@IsBoolean()
@IsOptional()
isEnabled?: boolean;
}
/** 更新权限 DTO */
export class UpdatePermissionDto implements IUpdatePermissionDto {
@ApiPropertyOptional({ example: '创建用户', description: '权限名称' })
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({ example: '允许创建新用户', description: '权限描述' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({ example: true, description: '是否启用' })
@IsBoolean()
@IsOptional()
isEnabled?: boolean;
}
/** 权限响应 DTO */
export class PermissionResponseDto implements PermissionResponse {
@ApiProperty({ example: 'clxxx123', description: '权限 ID' })
id: string;
@ApiProperty({ example: 'user:create', description: '权限编码' })
code: string;
@ApiProperty({ example: '创建用户', description: '权限名称' })
name: string;
@ApiProperty({ example: '允许创建新用户', description: '权限描述', nullable: true })
description: string | null;
@ApiProperty({ example: 'user', description: '资源名称' })
resource: string;
@ApiProperty({ example: 'create', description: '操作类型' })
action: string;
@ApiProperty({ example: true, description: '是否启用' })
isEnabled: boolean;
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '创建时间' })
createdAt: string;
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '更新时间' })
updatedAt: string;
}
/** 分页权限响应 DTO */
export class PaginatedPermissionResponseDto extends createPaginatedResponseDto(PermissionResponseDto) {}

View File

@@ -0,0 +1,143 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import type { RoleResponse, RoleDetailResponse, CreateRoleDto as ICreateRoleDto, UpdateRoleDto as IUpdateRoleDto } from '@seclusion/shared';
import { IsString, IsOptional, IsBoolean, IsInt, IsArray, Min, Matches } from 'class-validator';
import { MenuResponseDto } from './menu.dto';
import { PermissionResponseDto } from './permission.dto';
import { createPaginatedResponseDto } from '@/common/crud';
/** 创建角色 DTO */
export class CreateRoleDto implements ICreateRoleDto {
@ApiProperty({ example: 'editor', description: '角色编码(小写字母和下划线)' })
@IsString()
@Matches(/^[a-z][a-z_]*$/, { message: '角色编码只能包含小写字母和下划线,且以字母开头' })
code: string;
@ApiProperty({ example: '编辑员', description: '角色名称' })
@IsString()
name: string;
@ApiPropertyOptional({ example: '负责内容编辑', description: '角色描述' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({ example: true, description: '是否启用' })
@IsBoolean()
@IsOptional()
isEnabled?: boolean;
@ApiPropertyOptional({ example: 10, description: '排序值' })
@IsInt()
@Min(0)
@IsOptional()
sort?: number;
@ApiPropertyOptional({
example: ['clxxx1', 'clxxx2'],
description: '权限 ID 列表',
type: [String],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
permissionIds?: string[];
@ApiPropertyOptional({
example: ['clxxx1', 'clxxx2'],
description: '菜单 ID 列表',
type: [String],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
menuIds?: string[];
}
/** 更新角色 DTO */
export class UpdateRoleDto implements IUpdateRoleDto {
@ApiPropertyOptional({ example: '编辑员', description: '角色名称' })
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({ example: '负责内容编辑', description: '角色描述' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({ example: true, description: '是否启用' })
@IsBoolean()
@IsOptional()
isEnabled?: boolean;
@ApiPropertyOptional({ example: 10, description: '排序值' })
@IsInt()
@Min(0)
@IsOptional()
sort?: number;
@ApiPropertyOptional({
example: ['clxxx1', 'clxxx2'],
description: '权限 ID 列表',
type: [String],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
permissionIds?: string[];
@ApiPropertyOptional({
example: ['clxxx1', 'clxxx2'],
description: '菜单 ID 列表',
type: [String],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
menuIds?: string[];
}
/** 角色响应 DTO */
export class RoleResponseDto implements RoleResponse {
@ApiProperty({ example: 'clxxx123', description: '角色 ID' })
id: string;
@ApiProperty({ example: 'admin', description: '角色编码' })
code: string;
@ApiProperty({ example: '管理员', description: '角色名称' })
name: string;
@ApiProperty({ example: '系统管理员', description: '角色描述', nullable: true })
description: string | null;
@ApiProperty({ example: true, description: '是否系统内置角色' })
isSystem: boolean;
@ApiProperty({ example: true, description: '是否启用' })
isEnabled: boolean;
@ApiProperty({ example: 0, description: '排序值' })
sort: number;
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '创建时间' })
createdAt: string;
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '更新时间' })
updatedAt: string;
}
/** 角色详情响应 DTO包含权限和菜单 */
export class RoleDetailResponseDto extends RoleResponseDto implements RoleDetailResponse {
@ApiProperty({ type: [PermissionResponseDto], description: '权限列表' })
permissions: PermissionResponseDto[];
@ApiProperty({ type: [MenuResponseDto], description: '菜单列表' })
menus: MenuResponseDto[];
}
/** 分页角色响应 DTO */
export class PaginatedRoleResponseDto extends createPaginatedResponseDto(RoleResponseDto) {}

View File

@@ -0,0 +1 @@
export { PermissionGuard } from './permission.guard';

View File

@@ -0,0 +1,52 @@
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthUserWithPermissions } from '@seclusion/shared';
import { PERMISSION_KEY } from '../decorators';
/**
* 权限守卫
* 检查用户是否拥有访问接口所需的权限
*/
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 获取接口所需权限
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(PERMISSION_KEY, [
context.getHandler(),
context.getClass(),
]);
// 如果没有设置权限要求,直接放行
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
// 获取用户信息
const request = context.switchToHttp().getRequest();
const user = request.user as AuthUserWithPermissions | undefined;
if (!user) {
throw new ForbiddenException('未登录');
}
// 超级管理员直接放行
if (user.isSuperAdmin) {
return true;
}
// 检查用户是否拥有所需权限OR 关系)
const hasPermission = requiredPermissions.some((permission) =>
user.permissions.includes(permission)
);
if (!hasPermission) {
throw new ForbiddenException('无访问权限');
}
return true;
}
}

View File

@@ -0,0 +1,5 @@
export { PermissionModule } from './permission.module';
export { RequirePermission, PERMISSION_KEY } from './decorators';
export { PermissionGuard } from './guards';
export { PermissionService, RoleService, MenuService } from './services';
export * from './dto';

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PermissionController, RoleController, MenuController } from './controllers';
import { PermissionGuard } from './guards';
import { PermissionService, RoleService, MenuService } from './services';
@Module({
controllers: [PermissionController, RoleController, MenuController],
providers: [PermissionService, RoleService, MenuService, PermissionGuard],
exports: [PermissionService, RoleService, MenuService, PermissionGuard],
})
export class PermissionModule {}

View File

@@ -0,0 +1,3 @@
export { PermissionService } from './permission.service';
export { RoleService } from './role.service';
export { MenuService } from './menu.service';

View File

@@ -0,0 +1,319 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { Menu, Prisma } from '@prisma/client';
import type {
MenuTreeNode,
MenuMeta,
MenuType,
UserMenusAndPermissionsResponse,
} from '@seclusion/shared';
import { CreateMenuDto, UpdateMenuDto } from '../dto';
import { RoleService } from './role.service';
import { CrudOptions, CrudService } from '@/common/crud';
import { PrismaService } from '@/prisma/prisma.service';
@Injectable()
@CrudOptions({
softDelete: true,
defaultPageSize: 100,
maxPageSize: 500,
defaultSortBy: 'sort',
defaultSortOrder: 'asc',
defaultSelect: {
id: true,
parentId: true,
code: true,
name: true,
type: true,
path: true,
icon: true,
component: true,
permission: true,
isExternal: true,
isHidden: true,
isEnabled: true,
isStatic: true,
sort: true,
meta: true,
createdAt: true,
updatedAt: true,
},
})
export class MenuService extends CrudService<
Menu,
CreateMenuDto,
UpdateMenuDto,
Prisma.MenuWhereInput,
Prisma.MenuWhereUniqueInput
> {
constructor(
prisma: PrismaService,
private readonly roleService: RoleService
) {
super(prisma, 'menu');
}
protected getNotFoundMessage(): string {
return '菜单不存在';
}
protected getDeletedMessage(): string {
return '菜单已删除';
}
protected getDeletedNotFoundMessage(): string {
return '已删除的菜单不存在';
}
/**
* 创建菜单
*/
async createMenu(dto: CreateMenuDto): Promise<Menu> {
// 检查父菜单是否存在
if (dto.parentId) {
const parent = await this.prisma.menu.findUnique({
where: { id: dto.parentId },
});
if (!parent) {
throw new BadRequestException('父菜单不存在');
}
}
return this.create(dto as unknown as CreateMenuDto);
}
/**
* 更新菜单
*/
async updateMenu(id: string, dto: UpdateMenuDto): Promise<Menu> {
// 检查菜单是否存在
const menu = await this.findById(id);
// 如果是静态菜单,限制可修改的字段
if (menu.isStatic) {
// 静态菜单只允许修改名称、图标、排序、隐藏状态
const allowedFields = ['name', 'icon', 'sort', 'isHidden', 'isEnabled', 'meta'];
const updateFields = Object.keys(dto);
const disallowedFields = updateFields.filter((f) => !allowedFields.includes(f));
if (disallowedFields.length > 0) {
throw new BadRequestException(`静态菜单不允许修改以下字段: ${disallowedFields.join(', ')}`);
}
}
// 检查父菜单是否存在
if (dto.parentId) {
const parent = await this.prisma.menu.findUnique({
where: { id: dto.parentId },
});
if (!parent) {
throw new BadRequestException('父菜单不存在');
}
// 防止循环引用
if (dto.parentId === id) {
throw new BadRequestException('不能将菜单设置为自己的子菜单');
}
}
return this.update(id, dto as unknown as UpdateMenuDto);
}
/**
* 删除菜单(静态菜单不可删除)
*/
async deleteMenu(id: string): Promise<{ message: string }> {
const menu = await this.findById(id);
if (menu.isStatic) {
throw new BadRequestException('静态菜单不可删除');
}
// 检查是否有子菜单
const children = await this.prisma.menu.findMany({
where: { parentId: id },
});
if (children.length > 0) {
throw new BadRequestException('请先删除子菜单');
}
return this.delete(id);
}
/**
* 获取菜单树
*/
async findTree(): Promise<MenuTreeNode[]> {
const menus = await this.prisma.menu.findMany({
where: { isEnabled: true },
orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }],
});
return this.buildTree(menus);
}
/**
* 获取完整菜单树(包含禁用的菜单,用于管理)
*/
async findFullTree(): Promise<MenuTreeNode[]> {
const menus = await this.prisma.menu.findMany({
orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }],
});
return this.buildTree(menus);
}
/**
* 根据角色获取菜单和权限
*/
async getMenusAndPermissionsByRoles(
isSuperAdmin: boolean,
roleIds: string[]
): Promise<UserMenusAndPermissionsResponse> {
// 超级管理员拥有所有菜单和权限
if (isSuperAdmin) {
const [menus, permissions] = await Promise.all([
this.findTree(),
this.prisma.permission.findMany({
where: { isEnabled: true },
select: { code: true },
}),
]);
return {
menus,
permissions: permissions.map((p) => p.code),
isSuperAdmin: true,
};
}
// 获取用户角色的权限和菜单
const [permissionCodes, menuIds] = await Promise.all([
this.roleService.getPermissionCodes(roleIds),
this.roleService.getMenuIds(roleIds),
]);
// 获取用户可访问的菜单
const menus = await this.prisma.menu.findMany({
where: {
id: { in: menuIds },
isEnabled: true,
},
orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }],
});
// 构建菜单树,需要补充父级菜单
const menuTree = await this.buildUserMenuTree(menus);
return {
menus: menuTree,
permissions: permissionCodes,
isSuperAdmin: false,
};
}
/**
* 构建菜单树
*/
private buildTree(menus: Menu[]): MenuTreeNode[] {
const menuMap = new Map<string, MenuTreeNode>();
const roots: MenuTreeNode[] = [];
// 先创建所有节点
for (const menu of menus) {
menuMap.set(menu.id, {
id: menu.id,
parentId: menu.parentId,
code: menu.code,
name: menu.name,
type: menu.type as MenuType,
path: menu.path,
icon: menu.icon,
permission: menu.permission,
isHidden: menu.isHidden,
meta: menu.meta as MenuMeta | null,
children: [],
});
}
// 构建树结构
for (const menu of menus) {
const node = menuMap.get(menu.id)!;
if (menu.parentId && menuMap.has(menu.parentId)) {
const parent = menuMap.get(menu.parentId)!;
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
} else {
roots.push(node);
}
}
// 移除空的 children 数组
this.cleanEmptyChildren(roots);
return roots;
}
/**
* 构建用户菜单树(补充父级菜单)
*/
private async buildUserMenuTree(menus: Menu[]): Promise<MenuTreeNode[]> {
// 收集所有需要的父级菜单 ID
const menuIds = new Set(menus.map((m) => m.id));
const parentIds = new Set<string>();
for (const menu of menus) {
if (menu.parentId && !menuIds.has(menu.parentId)) {
parentIds.add(menu.parentId);
}
}
// 递归获取所有父级菜单
let currentParentIds = [...parentIds];
while (currentParentIds.length > 0) {
const parents = await this.prisma.menu.findMany({
where: { id: { in: currentParentIds }, isEnabled: true },
});
const newParentIds: string[] = [];
for (const parent of parents) {
if (!menuIds.has(parent.id)) {
menus.push(parent);
menuIds.add(parent.id);
if (parent.parentId && !menuIds.has(parent.parentId)) {
newParentIds.push(parent.parentId);
}
}
}
currentParentIds = newParentIds;
}
return this.buildTree(menus);
}
/**
* 移除空的 children 数组
*/
private cleanEmptyChildren(nodes: MenuTreeNode[]): void {
for (const node of nodes) {
if (node.children && node.children.length > 0) {
this.cleanEmptyChildren(node.children);
} else {
delete node.children;
}
}
}
/**
* 根据编码查找菜单
*/
async findByCode(code: string): Promise<Menu | null> {
return this.prisma.menu.findUnique({
where: { code },
});
}
}

View File

@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { Permission, Prisma } from '@prisma/client';
import { CreatePermissionDto, UpdatePermissionDto } from '../dto';
import { CrudOptions, CrudService } from '@/common/crud';
import { PrismaService } from '@/prisma/prisma.service';
@Injectable()
@CrudOptions({
softDelete: true,
defaultPageSize: 20,
maxPageSize: 100,
defaultSortBy: 'resource',
defaultSortOrder: 'asc',
defaultSelect: {
id: true,
code: true,
name: true,
description: true,
resource: true,
action: true,
isEnabled: true,
createdAt: true,
updatedAt: true,
},
})
export class PermissionService extends CrudService<
Permission,
CreatePermissionDto,
UpdatePermissionDto,
Prisma.PermissionWhereInput,
Prisma.PermissionWhereUniqueInput
> {
constructor(prisma: PrismaService) {
super(prisma, 'permission');
}
protected getNotFoundMessage(): string {
return '权限不存在';
}
protected getDeletedMessage(): string {
return '权限已删除';
}
protected getDeletedNotFoundMessage(): string {
return '已删除的权限不存在';
}
/**
* 按资源分组获取权限
*/
async findGroupedByResource(): Promise<Record<string, Permission[]>> {
const permissions = await this.prisma.permission.findMany({
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
});
return permissions.reduce(
(acc, permission) => {
if (!acc[permission.resource]) {
acc[permission.resource] = [];
}
acc[permission.resource].push(permission);
return acc;
},
{} as Record<string, Permission[]>
);
}
/**
* 根据编码查找权限
*/
async findByCode(code: string): Promise<Permission | null> {
return this.prisma.permission.findUnique({
where: { code },
});
}
/**
* 根据编码列表查找权限
*/
async findByCodes(codes: string[]): Promise<Permission[]> {
return this.prisma.permission.findMany({
where: { code: { in: codes } },
});
}
}

View File

@@ -0,0 +1,268 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { Role, Prisma } from '@prisma/client';
import type { RoleDetailResponse } from '@seclusion/shared';
import { CreateRoleDto, UpdateRoleDto, RoleDetailResponseDto } from '../dto';
import { CrudOptions, CrudService } from '@/common/crud';
import { PrismaService } from '@/prisma/prisma.service';
@Injectable()
@CrudOptions({
softDelete: true,
defaultPageSize: 20,
maxPageSize: 100,
defaultSortBy: 'sort',
defaultSortOrder: 'asc',
defaultSelect: {
id: true,
code: true,
name: true,
description: true,
isSystem: true,
isEnabled: true,
sort: true,
createdAt: true,
updatedAt: true,
},
})
export class RoleService extends CrudService<
Role,
Prisma.RoleCreateInput,
Prisma.RoleUpdateInput,
Prisma.RoleWhereInput,
Prisma.RoleWhereUniqueInput
> {
constructor(prisma: PrismaService) {
super(prisma, 'role');
}
protected getNotFoundMessage(): string {
return '角色不存在';
}
protected getDeletedMessage(): string {
return '角色已删除';
}
protected getDeletedNotFoundMessage(): string {
return '已删除的角色不存在';
}
/**
* 创建角色(包含权限和菜单分配)
*/
async createRole(dto: CreateRoleDto): Promise<Role> {
const { permissionIds, menuIds, ...roleData } = dto;
// 创建角色
const role = await this.prisma.role.create({
data: roleData,
});
// 分配权限
if (permissionIds?.length) {
await this.prisma.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({
roleId: role.id,
permissionId,
})),
skipDuplicates: true,
});
}
// 分配菜单
if (menuIds?.length) {
await this.prisma.roleMenu.createMany({
data: menuIds.map((menuId) => ({
roleId: role.id,
menuId,
})),
skipDuplicates: true,
});
}
return role;
}
/**
* 更新角色(包含权限和菜单分配)
*/
async updateRole(id: string, dto: UpdateRoleDto): Promise<Role> {
const { permissionIds, menuIds, ...roleData } = dto;
// 检查是否为系统角色
const existingRole = await this.findById(id);
if (existingRole.isSystem && dto.name === undefined && dto.description === undefined) {
// 系统角色只允许修改名称和描述
}
// 更新角色基本信息
const role = await this.prisma.role.update({
where: { id },
data: roleData,
});
// 更新权限
if (permissionIds !== undefined) {
// 删除现有权限
await this.prisma.rolePermission.deleteMany({
where: { roleId: id },
});
// 添加新权限
if (permissionIds.length) {
await this.prisma.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({
roleId: id,
permissionId,
})),
skipDuplicates: true,
});
}
}
// 更新菜单
if (menuIds !== undefined) {
// 删除现有菜单
await this.prisma.roleMenu.deleteMany({
where: { roleId: id },
});
// 添加新菜单
if (menuIds.length) {
await this.prisma.roleMenu.createMany({
data: menuIds.map((menuId) => ({
roleId: id,
menuId,
})),
skipDuplicates: true,
});
}
}
return role;
}
/**
* 删除角色(系统角色不可删除)
*/
async deleteRole(id: string): Promise<{ message: string }> {
const role = await this.findById(id);
if (role.isSystem) {
throw new BadRequestException('系统内置角色不可删除');
}
return this.delete(id);
}
/**
* 获取角色详情(包含权限和菜单)
*/
async findByIdWithDetail(id: string): Promise<RoleDetailResponse> {
const role = await this.prisma.role.findUnique({
where: { id },
include: {
permissions: {
include: {
permission: true,
},
},
menus: {
include: {
menu: true,
},
},
},
});
if (!role) {
throw new BadRequestException(this.getNotFoundMessage());
}
return {
id: role.id,
code: role.code,
name: role.name,
description: role.description,
isSystem: role.isSystem,
isEnabled: role.isEnabled,
sort: role.sort,
createdAt: role.createdAt.toISOString(),
updatedAt: role.updatedAt.toISOString(),
permissions: role.permissions.map((rp) => ({
id: rp.permission.id,
code: rp.permission.code,
name: rp.permission.name,
description: rp.permission.description,
resource: rp.permission.resource,
action: rp.permission.action,
isEnabled: rp.permission.isEnabled,
createdAt: rp.permission.createdAt.toISOString(),
updatedAt: rp.permission.updatedAt.toISOString(),
})),
menus: role.menus.map((rm) => ({
id: rm.menu.id,
parentId: rm.menu.parentId,
code: rm.menu.code,
name: rm.menu.name,
type: rm.menu.type as RoleDetailResponseDto['menus'][0]['type'],
path: rm.menu.path,
icon: rm.menu.icon,
component: rm.menu.component,
permission: rm.menu.permission,
isExternal: rm.menu.isExternal,
isHidden: rm.menu.isHidden,
isEnabled: rm.menu.isEnabled,
isStatic: rm.menu.isStatic,
sort: rm.menu.sort,
meta: rm.menu.meta as RoleDetailResponseDto['menus'][0]['meta'],
createdAt: rm.menu.createdAt.toISOString(),
updatedAt: rm.menu.updatedAt.toISOString(),
})),
};
}
/**
* 根据编码查找角色
*/
async findByCode(code: string): Promise<Role | null> {
return this.prisma.role.findUnique({
where: { code },
});
}
/**
* 获取角色的权限编码列表
*/
async getPermissionCodes(roleIds: string[]): Promise<string[]> {
const rolePermissions = await this.prisma.rolePermission.findMany({
where: { roleId: { in: roleIds } },
include: {
permission: {
select: { code: true, isEnabled: true },
},
},
});
// 只返回启用的权限
return [
...new Set(
rolePermissions.filter((rp) => rp.permission.isEnabled).map((rp) => rp.permission.code)
),
];
}
/**
* 获取角色的菜单 ID 列表
*/
async getMenuIds(roleIds: string[]): Promise<string[]> {
const roleMenus = await this.prisma.roleMenu.findMany({
where: { roleId: { in: roleIds } },
select: { menuId: true },
});
return [...new Set(roleMenus.map((rm) => rm.menuId))];
}
}

View File

@@ -2,7 +2,7 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
// 启用软删除的模型列表
const SOFT_DELETE_MODELS: Prisma.ModelName[] = ['User'];
const SOFT_DELETE_MODELS: Prisma.ModelName[] = ['User', 'Role', 'Permission', 'Menu'];
function isSoftDeleteModel(model: string | undefined): boolean {
return !!model && SOFT_DELETE_MODELS.includes(model as Prisma.ModelName);
@@ -15,13 +15,19 @@ function createSoftDeleteExtension(client: PrismaClient) {
query: {
$allModels: {
async findMany({ model, args, query }) {
if (isSoftDeleteModel(model) && args.where?.deletedAt === undefined) {
if (
isSoftDeleteModel(model) &&
(args.where as Record<string, unknown> | undefined)?.deletedAt === undefined
) {
args.where = { ...args.where, deletedAt: null };
}
return query(args);
},
async findFirst({ model, args, query }) {
if (isSoftDeleteModel(model) && args.where?.deletedAt === undefined) {
if (
isSoftDeleteModel(model) &&
(args.where as Record<string, unknown> | undefined)?.deletedAt === undefined
) {
args.where = { ...args.where, deletedAt: null };
}
return query(args);
@@ -36,7 +42,10 @@ function createSoftDeleteExtension(client: PrismaClient) {
return query(args);
},
async count({ model, args, query }) {
if (isSoftDeleteModel(model) && args.where?.deletedAt === undefined) {
if (
isSoftDeleteModel(model) &&
(args.where as Record<string, unknown> | undefined)?.deletedAt === undefined
) {
args.where = { ...args.where, deletedAt: null };
}
return query(args);
@@ -106,6 +115,30 @@ export class PrismaService implements OnModuleInit, OnModuleDestroy {
return this._client.user;
}
get role() {
return this._client.role;
}
get permission() {
return this._client.permission;
}
get menu() {
return this._client.menu;
}
get userRole() {
return this._client.userRole;
}
get rolePermission() {
return this._client.rolePermission;
}
get roleMenu() {
return this._client.roleMenu;
}
async onModuleInit() {
await this._client.$connect();
}