feat(api): 实现权限管理系统
- 添加 Menu、Role、Permission 数据模型及关联表 - 实现 PermissionModule 提供菜单、角色、权限 CRUD - 扩展 AuthController 添加获取用户菜单权限接口 - PrismaService 支持新模型的软删除 - 添加数据库种子脚本 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
328
apps/api/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
3
apps/api/src/permission/controllers/index.ts
Normal file
3
apps/api/src/permission/controllers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PermissionController } from './permission.controller';
|
||||
export { RoleController } from './role.controller';
|
||||
export { MenuController } from './menu.controller';
|
||||
65
apps/api/src/permission/controllers/menu.controller.ts
Normal file
65
apps/api/src/permission/controllers/menu.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
66
apps/api/src/permission/controllers/permission.controller.ts
Normal file
66
apps/api/src/permission/controllers/permission.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
58
apps/api/src/permission/controllers/role.controller.ts
Normal file
58
apps/api/src/permission/controllers/role.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
apps/api/src/permission/decorators/index.ts
Normal file
1
apps/api/src/permission/decorators/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PERMISSION_KEY, RequirePermission } from './require-permission.decorator';
|
||||
@@ -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);
|
||||
3
apps/api/src/permission/dto/index.ts
Normal file
3
apps/api/src/permission/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './permission.dto';
|
||||
export * from './role.dto';
|
||||
export * from './menu.dto';
|
||||
242
apps/api/src/permission/dto/menu.dto.ts
Normal file
242
apps/api/src/permission/dto/menu.dto.ts
Normal 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: '父菜单 ID(null 表示移动到顶级)' })
|
||||
@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;
|
||||
}
|
||||
86
apps/api/src/permission/dto/permission.dto.ts
Normal file
86
apps/api/src/permission/dto/permission.dto.ts
Normal 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) {}
|
||||
143
apps/api/src/permission/dto/role.dto.ts
Normal file
143
apps/api/src/permission/dto/role.dto.ts
Normal 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) {}
|
||||
1
apps/api/src/permission/guards/index.ts
Normal file
1
apps/api/src/permission/guards/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PermissionGuard } from './permission.guard';
|
||||
52
apps/api/src/permission/guards/permission.guard.ts
Normal file
52
apps/api/src/permission/guards/permission.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
apps/api/src/permission/index.ts
Normal file
5
apps/api/src/permission/index.ts
Normal 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';
|
||||
12
apps/api/src/permission/permission.module.ts
Normal file
12
apps/api/src/permission/permission.module.ts
Normal 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 {}
|
||||
3
apps/api/src/permission/services/index.ts
Normal file
3
apps/api/src/permission/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PermissionService } from './permission.service';
|
||||
export { RoleService } from './role.service';
|
||||
export { MenuService } from './menu.service';
|
||||
319
apps/api/src/permission/services/menu.service.ts
Normal file
319
apps/api/src/permission/services/menu.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
88
apps/api/src/permission/services/permission.service.ts
Normal file
88
apps/api/src/permission/services/permission.service.ts
Normal 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 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
268
apps/api/src/permission/services/role.service.ts
Normal file
268
apps/api/src/permission/services/role.service.ts
Normal 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))];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user