From 9461fc53f3b8711c30c04603a5eb07c06fa35992 Mon Sep 17 00:00:00 2001 From: liuyi Date: Mon, 30 Jun 2025 13:12:21 +0800 Subject: [PATCH] add rbac module --- src/modules/database/base/subscriber.ts | 4 +- src/modules/rbac/controllers/index.ts | 1 - src/modules/rbac/controllers/manager/index.ts | 1 + .../{ => manager}/permission.controller.ts | 0 src/modules/user/controllers/manager/index.ts | 1 + .../controllers/manager/user.controller.ts | 102 ++++++++++++++++++ .../user/controllers/user.controller.ts | 76 ++----------- src/modules/user/dtos/user.dto.ts | 66 +++++++++++- .../user/repositories/user.repository.ts | 5 +- src/modules/user/routes.ts | 13 ++- src/modules/user/services/user.service.ts | 75 ++++++++++++- .../user/subscribers/user.subscriber.ts | 20 +++- 12 files changed, 286 insertions(+), 78 deletions(-) rename src/modules/rbac/controllers/{ => manager}/permission.controller.ts (100%) create mode 100644 src/modules/user/controllers/manager/index.ts create mode 100644 src/modules/user/controllers/manager/user.controller.ts diff --git a/src/modules/database/base/subscriber.ts b/src/modules/database/base/subscriber.ts index 5faca23..5636aa9 100644 --- a/src/modules/database/base/subscriber.ts +++ b/src/modules/database/base/subscriber.ts @@ -18,6 +18,8 @@ import { UpdateEvent, } from 'typeorm'; +import { LoadEvent } from 'typeorm/subscriber/event/LoadEvent'; + import { Configure } from '@/modules/config/configure'; import { app } from '@/modules/core/helpers/app'; @@ -72,7 +74,7 @@ export abstract class BaseSubscriber return this.entity; } - async afterLoad(entity: any) { + async afterLoad(entity: any, event?: LoadEvent) { if ('parent' in entity && isNil(entity.depth)) { entity.depth = 0; } diff --git a/src/modules/rbac/controllers/index.ts b/src/modules/rbac/controllers/index.ts index 315007d..fa5a3e4 100644 --- a/src/modules/rbac/controllers/index.ts +++ b/src/modules/rbac/controllers/index.ts @@ -1,2 +1 @@ -export * from './permission.controller'; export * from './role.controller'; diff --git a/src/modules/rbac/controllers/manager/index.ts b/src/modules/rbac/controllers/manager/index.ts index fa5a3e4..bfa8b6e 100644 --- a/src/modules/rbac/controllers/manager/index.ts +++ b/src/modules/rbac/controllers/manager/index.ts @@ -1 +1,2 @@ export * from './role.controller'; +export * from './permission.controller'; diff --git a/src/modules/rbac/controllers/permission.controller.ts b/src/modules/rbac/controllers/manager/permission.controller.ts similarity index 100% rename from src/modules/rbac/controllers/permission.controller.ts rename to src/modules/rbac/controllers/manager/permission.controller.ts diff --git a/src/modules/user/controllers/manager/index.ts b/src/modules/user/controllers/manager/index.ts new file mode 100644 index 0000000..edd3705 --- /dev/null +++ b/src/modules/user/controllers/manager/index.ts @@ -0,0 +1 @@ +export * from './user.controller'; diff --git a/src/modules/user/controllers/manager/user.controller.ts b/src/modules/user/controllers/manager/user.controller.ts new file mode 100644 index 0000000..0735b14 --- /dev/null +++ b/src/modules/user/controllers/manager/user.controller.ts @@ -0,0 +1,102 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + SerializeOptions, +} from '@nestjs/common'; + +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; + +import { DeleteWithTrashDto, RestoreDto } from '@/modules/content/dtos/delete.with.trash.dto'; +import { PermissionAction } from '@/modules/rbac/constants'; +import { Permission } from '@/modules/rbac/decorators/permission.decorator'; +import { PermissionChecker } from '@/modules/rbac/types'; +import { Depends } from '@/modules/restful/decorators/depend.decorator'; +import { CreateUserDto, FrontedQueryUserDto, UpdateUserDto } from '@/modules/user/dtos/user.dto'; +import { UserEntity } from '@/modules/user/entities'; +import { UserService } from '@/modules/user/services'; +import { UserModule } from '@/modules/user/user.module'; + +const permission: PermissionChecker = async (ab) => + ab.can(PermissionAction.MANAGE, UserEntity.name); + +@ApiTags('用户管理') +@Depends(UserModule) +@ApiBearerAuth() +@Controller('users') +export class UserController { + constructor(protected service: UserService) {} + + /** + * 用户列表 + */ + @Get() + @Permission(permission) + @SerializeOptions({ groups: ['user-list'] }) + async list(@Query() options: FrontedQueryUserDto) { + return this.service.list(options); + } + + /** + * 获取用户信息 + * @param id + */ + @Get(':id') + @Permission(permission) + @SerializeOptions({ groups: ['user-detail'] }) + async detail(@Param('id', new ParseUUIDPipe()) id: string) { + return this.service.detail(id); + } + + /** + * 新增用户 + * @param data + */ + @Post() + @Permission(permission) + @SerializeOptions({ groups: ['user-detail'] }) + async store(@Body() data: CreateUserDto) { + return this.service.create(data); + } + + /** + * 更新用户 + * @param data + */ + @Patch() + @Permission(permission) + @SerializeOptions({ groups: ['user-detail'] }) + async update(@Body() data: UpdateUserDto) { + return this.service.update(data); + } + + /** + * 批量删除用户 + * @param data + */ + @Delete() + @Permission(permission) + @SerializeOptions({ groups: ['user-list'] }) + async delete(@Body() data: DeleteWithTrashDto) { + const { ids, trash } = data; + return this.service.delete(ids, trash); + } + + /** + * 批量恢复用户 + * @param data + */ + @Patch('restore') + @Permission(permission) + @SerializeOptions({ groups: ['user-list'] }) + async restore(@Body() data: RestoreDto) { + const { ids } = data; + return this.service.restore(ids); + } +} diff --git a/src/modules/user/controllers/user.controller.ts b/src/modules/user/controllers/user.controller.ts index 76ceaf0..5b04dfe 100644 --- a/src/modules/user/controllers/user.controller.ts +++ b/src/modules/user/controllers/user.controller.ts @@ -1,26 +1,18 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - ParseUUIDPipe, - Patch, - Post, - SerializeOptions, -} from '@nestjs/common'; +import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; -import { DeleteWithTrashDto, RestoreDto } from '@/modules/content/dtos/delete.with.trash.dto'; +import { IsNull, Not } from 'typeorm'; + +import { SelectTrashMode } from '@/modules/database/constants'; import { Depends } from '@/modules/restful/decorators/depend.decorator'; +import { UserService } from '@/modules/user/services'; import { UserModule } from '@/modules/user/user.module'; import { Guest } from '../decorators/guest.decorator'; -import { CreateUserDto, UpdateUserDto } from '../dtos/user.dto'; -import { UserService } from '../services/user.service'; +import { FrontedQueryUserDto } from '../dtos/user.dto'; -@ApiTags('用户管理') +@ApiTags('用户查询') @Depends(UserModule) @Controller('users') export class UserController { @@ -32,8 +24,8 @@ export class UserController { @Get() @Guest() @SerializeOptions({ groups: ['user-list'] }) - async list() { - return this.service.list(); + async list(@Query() options: FrontedQueryUserDto) { + return this.service.list({ ...options, trashed: SelectTrashMode.NONE }); } /** @@ -44,52 +36,6 @@ export class UserController { @Guest() @SerializeOptions({ groups: ['user-detail'] }) async detail(@Param('id', new ParseUUIDPipe()) id: string) { - return this.service.detail(id); - } - - /** - * 新增用户 - * @param data - */ - @Post() - @ApiBearerAuth() - @SerializeOptions({ groups: ['user-detail'] }) - async store(@Body() data: CreateUserDto) { - return this.service.create(data); - } - - /** - * 更新用户 - * @param data - */ - @Patch() - @ApiBearerAuth() - @SerializeOptions({ groups: ['user-detail'] }) - async update(@Body() data: UpdateUserDto) { - return this.service.update(data); - } - - /** - * 批量删除用户 - * @param data - */ - @Delete() - @ApiBearerAuth() - @SerializeOptions({ groups: ['user-list'] }) - async delete(@Body() data: DeleteWithTrashDto) { - const { ids, trash } = data; - return this.service.delete(ids, trash); - } - - /** - * 批量恢复用户 - * @param data - */ - @Patch('restore') - @ApiBearerAuth() - @SerializeOptions({ groups: ['user-list'] }) - async restore(@Body() data: RestoreDto) { - const { ids } = data; - return this.service.restore(ids); + return this.service.detail(id, async (qb) => qb.andWhere({ deletedAt: Not(IsNull()) })); } } diff --git a/src/modules/user/dtos/user.dto.ts b/src/modules/user/dtos/user.dto.ts index 09f5ae8..eafce0a 100644 --- a/src/modules/user/dtos/user.dto.ts +++ b/src/modules/user/dtos/user.dto.ts @@ -1,8 +1,10 @@ -import { PartialType, PickType } from '@nestjs/swagger'; +import { OmitType, PartialType, PickType } from '@nestjs/swagger'; -import { IsDefined, IsEnum, IsUUID } from 'class-validator'; +import { IsDefined, IsEnum, IsOptional, IsUUID } from 'class-validator'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; +import { IsDataExist } from '@/modules/database/constraints'; +import { PermissionEntity, RoleEntity } from '@/modules/rbac/entities'; import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto'; import { UserOrderType, UserValidateGroup } from '@/modules/user/constants'; import { UserCommonDto } from '@/modules/user/dtos/user.common.dto'; @@ -17,7 +19,39 @@ export class CreateUserDto extends PickType(UserCommonDto, [ 'email', 'password', 'phone', -]) {} +]) { + /** + * 用户关联的角色ID列表 + */ + @IsDataExist(RoleEntity, { + each: true, + always: true, + message: '角色不存在', + }) + @IsUUID(undefined, { + each: true, + always: true, + message: '角色ID格式不正确', + }) + @IsOptional({ always: true }) + roles?: string[]; + + /** + * 用户直接关联的权限ID列表 + */ + @IsDataExist(PermissionEntity, { + each: true, + always: true, + message: '权限不存在', + }) + @IsUUID(undefined, { + each: true, + always: true, + message: '权限ID格式不正确', + }) + @IsOptional({ always: true }) + permissions?: string[]; +} /** * 更新用户 @@ -36,9 +70,35 @@ export class UpdateUserDto extends PartialType(CreateUserDto) { * 查询用户列表的Query数据验证 */ export class QueryUserDto extends PaginateWithTrashedDto { + /** + * 角色ID:根据角色来过滤用户 + */ + @IsDataExist(RoleEntity, { + message: '角色不存在', + }) + @IsUUID(undefined, { message: '角色ID格式错误' }) + @IsOptional() + role?: string; + + /** + * 权限ID:根据权限来过滤用户(权限包含用户关联的所有角色的权限以及直接关联的权限) + */ + @IsDataExist(PermissionEntity, { + message: '权限不存在', + }) + @IsUUID(undefined, { message: '权限ID格式错误' }) + @IsOptional() + permission?: string; + /** * 排序规则:可指定用户列表的排序规则,默认为按创建时间降序排序 */ @IsEnum(UserOrderType) orderBy?: UserOrderType; } + +/** + * 客户端查询用户 + */ +@DtoValidation({ type: 'query' }) +export class FrontedQueryUserDto extends OmitType(QueryUserDto, ['trashed']) {} diff --git a/src/modules/user/repositories/user.repository.ts b/src/modules/user/repositories/user.repository.ts index b3d3bd0..cd80797 100644 --- a/src/modules/user/repositories/user.repository.ts +++ b/src/modules/user/repositories/user.repository.ts @@ -7,6 +7,9 @@ export class UserRepository extends BaseRepository { protected _qbName: string = 'user'; buildBaseQuery() { - return this.createQueryBuilder(this.qbName).orderBy(`${this.qbName}.createdAt`, 'DESC'); + return this.createQueryBuilder(this.qbName) + .orderBy(`${this.qbName}.createdAt`, 'DESC') + .leftJoinAndSelect(`${this.qbName}.roles`, 'roles') + .leftJoinAndSelect(`${this.qbName}.permissions`, 'permissions'); } } diff --git a/src/modules/user/routes.ts b/src/modules/user/routes.ts index df0ebae..f54e7e8 100644 --- a/src/modules/user/routes.ts +++ b/src/modules/user/routes.ts @@ -1,9 +1,10 @@ import { RouteOption, TagOption } from '../restful/types'; import * as controllers from './controllers'; +import * as managerControllers from './controllers/manager'; export function createUserApi() { - const routes: Record<'app', RouteOption[]> = { + const routes: Record<'app' | 'manager', RouteOption[]> = { app: [ { name: 'app.user', @@ -11,13 +12,21 @@ export function createUserApi() { controllers: Object.values(controllers), }, ], + manager: [ + { + name: 'app.user', + path: 'manager', + controllers: Object.values(managerControllers), + }, + ], }; - const tags: Record<'app', (string | TagOption)[]> = { + const tags: Record<'app' | 'manager', (string | TagOption)[]> = { app: [ { name: '用户管理', description: '对用户进行CRUD操作' }, { name: '账户操作', description: '注册登录、查看修改账户信息、修改密码等' }, ], + manager: [{ name: '用户管理', description: '管理用户信息' }], }; return { routes, tags }; diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 9d71930..c07421d 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { isNil } from 'lodash'; +import { isArray, isNil } from 'lodash'; import { DataSource, EntityNotFoundError, SelectQueryBuilder } from 'typeorm'; import { Configure } from '@/modules/config/configure'; @@ -8,6 +8,8 @@ import { BaseService } from '@/modules/database/base/service'; import { QueryHook } from '@/modules/database/types'; +import { SystemRoles } from '@/modules/rbac/constants'; +import { RoleRepository } from '@/modules/rbac/repositories'; import { UserRepository } from '@/modules/user/repositories'; import { CreateUserDto, QueryUserDto, UpdateUserDto } from '../dtos/user.dto'; @@ -21,26 +23,68 @@ export class UserService extends BaseService { protected configure: Configure, protected dataSource: DataSource, protected userRepository: UserRepository, + protected roleRepository: RoleRepository, ) { super(userRepository); } /** * 创建用户 + * @param roles + * @param permissions * @param data */ - async create(data: CreateUserDto): Promise { + async create({ roles, permissions, ...data }: CreateUserDto): Promise { const user = await this.userRepository.save(data, { reload: true }); + if (isArray(roles) && roles.length > 0) { + await this.userRepository + .createQueryBuilder('user') + .relation('roles') + .of(user) + .add(roles); + } + if (isArray(permissions) && permissions.length > 0) { + await this.userRepository + .createQueryBuilder('user') + .relation('permissions') + .of(user) + .add(permissions); + } + await this.addUserRole(await this.detail(user.id)); return this.detail(user.id); } /** * 更新用户 + * @param roles + * @param permissions * @param data */ - async update(data: UpdateUserDto): Promise { + async update({ roles, permissions, ...data }: UpdateUserDto): Promise { const updated = await this.userRepository.save(data, { reload: true }); - return this.detail(updated.id); + const user = await this.detail(updated.id); + if ( + (isNil(roles) || roles.length <= 0) && + (isNil(permissions) || permissions.length <= 0) + ) { + return user; + } + if (isArray(roles) && roles.length > 0) { + await this.userRepository + .createQueryBuilder('user') + .relation('roles') + .of(user) + .addAndRemove(roles, user.roles ?? []); + } + if (isArray(permissions) && permissions.length > 0) { + await this.userRepository + .createQueryBuilder('user') + .relation('permissions') + .of(user) + .addAndRemove(permissions, user.permissions ?? []); + } + await this.addUserRole(await this.detail(user.id)); + return this.detail(user.id); } /** @@ -87,9 +131,32 @@ export class UserService extends BaseService { ) { const { orderBy } = options; const qb = await super.buildListQB(queryBuilder, options, callback); + if (!isNil(options.role)) { + qb.andWhere('roles.id IN (:...roles', { roles: [options.role] }); + } + if (!isNil(options.permission)) { + qb.andWhere('permissions.id IN (:...permissions', { + permissions: [options.permission], + }); + } if (!isNil(orderBy)) { qb.orderBy(`${this.repository.qbName}.${orderBy}`, 'ASC'); } return qb; } + + protected async addUserRole(user: UserEntity) { + const roleRelation = this.userRepository.createQueryBuilder().relation('roles').of(user); + const roleNames = (user.roles ?? []).map((role) => role.name); + const noneUserRole = roleNames.length <= 0 || !roleNames.includes(SystemRoles.USER); + if (noneUserRole) { + const userRole = await this.roleRepository.findOne({ + relations: ['users'], + where: { name: SystemRoles.USER }, + }); + if (!isNil(userRole)) { + await roleRelation.add(userRole); + } + } + } } diff --git a/src/modules/user/subscribers/user.subscriber.ts b/src/modules/user/subscribers/user.subscriber.ts index f9487f9..a9b8b36 100644 --- a/src/modules/user/subscribers/user.subscriber.ts +++ b/src/modules/user/subscribers/user.subscriber.ts @@ -1,8 +1,9 @@ import { randomBytes } from 'node:crypto'; -import { EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm'; +import { EventSubscriber, InsertEvent, LoadEvent, UpdateEvent } from 'typeorm'; import { BaseSubscriber } from '@/modules/database/base/subscriber'; +import { RoleEntity } from '@/modules/rbac/entities'; import { UserEntity } from '@/modules/user/entities/user.entity'; import { encrypt } from '@/modules/user/utils'; @@ -36,4 +37,21 @@ export class UserSubscriber extends BaseSubscriber { event.entity.password = await encrypt(this.configure, event.entity.password); } } + + async afterLoad(user: UserEntity, event: LoadEvent): Promise { + let permissions = user.permissions ?? []; + for (const role of user.roles ?? []) { + const roleEntity = await this.getManage(event).findOneOrFail(RoleEntity, { + relations: ['permissions'], + where: { id: role.id }, + }); + permissions = [...permissions, ...(roleEntity.permissions ?? [])]; + } + user.permissions = permissions.reduce((o, n) => { + if (o.find(({ name }) => name === n.name)) { + return o; + } + return [...o, n]; + }, []); + } }