NestJS 项目实战-权限管理系统开发(十)

本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。

在线预览地址】账号:test,密码:d.12345

本章节内容: 1. 分页查询用户接口;2. 获取角色树接口;3. 创建用户接口;4. 获取用户详情接口;5. 更新用户信息接口;6. 删除用户接口;7. 重置用户密码接口。

1. 分页查询用户接口

1.1 定义 DTO 对象

新建 /src/user/dto/query-user.dto.ts 文件并添加以下代码:

ts 复制代码
import { BaseQueryDto } from '../../common/dto/base-query.dto';
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, Matches, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';

export class QueryUserDto extends BaseQueryDto {
  @Transform(({ value }) => value === '1')
  @IsOptional()
  @ApiProperty({ required: false, description: '禁用状态' })
  disabled?: boolean;

  @MaxLength(50)
  @Matches(/[a-zA-Z0-9\u4e00-\u9fa5']$/, { message: '关键字格式错误' })
  @IsOptional()
  @ApiProperty({
    required: false,
    description: '用户名/昵称关键字, 不能超过50个字符',
  })
  keyword?: string;
}

@Transform() 装饰器用来将其他格式的数据转换为布尔值。

@IsOptional() 装饰器用来表示该字段是可选的,只有在存在时,才会执行上面的校验。

@ApiProperty() 装饰器用来定义 API 端点的请求参数的属性与描述性信息,以便自动生成 Swagger 文档。

该 DTO 将用来对查询接口的 query 参数进行格式校验与转换。

使用 DTO 文件的好处:

  1. 数据验证 :通过使用类验证器(如 class-validator),可以确保传入的数据符合预期的格式和规则。
  2. 数据转换 :可以使用类转换器(如 class-transformer)将普通的 JavaScript 对象转换为类实例,从而利用类的功能。
  3. 文档生成 :结合 @nestjs/swagger 等工具,可以自动生成 API 文档,方便前后端协作。
  4. 代码可读性和维护性:明确的数据结构定义使代码更易读、更易维护。

1.2 定义接口返回实体

新建 /src/user/entities/user-list.entity.ts 文件并添加以下代码:

ts 复制代码
import { ApiProperty } from '@nestjs/swagger';
import { UserEntity } from './user.entity';

export class UserListItem extends UserEntity {
  @ApiProperty({ description: '用户昵称' })
  nickName: string;

  @ApiProperty({ description: '用户头像' })
  avatar: string;

  @ApiProperty({ description: '角色列表', type: [String] })
  roleNames: string[];
}

export class UserListEntity {
  @ApiProperty({ description: '总数', type: 'number' })
  total: number;

  @ApiProperty({ description: '用户列表', type: [UserListItem] })
  list: UserListItem[];
}

@ApiProperty() 装饰器用来定义 API 端点的响应参数的类型与描述性信息。

@ApiProperty({ description: '角色列表', type: [String] }) 为什么在这里使用了 type: [String] 设置类型,因为复杂数据类型,该装饰器无法直接推断出来,需要我们直接指定。

该实体将用作分页查询用户接口的返回数据结构与 Swagger API 文档的自动生成。

1.3 分页查询用户方法实现

打开 /src/user/user.service.ts 文件并添加以下方法:

ts 复制代码
import { QueryUserDto } from './dto/query-user.dto';
import { Prisma } from '@prisma/client';

  async findAll(queryUserDto: QueryUserDto) {
    const {
      disabled,
      keyword,
      page = 1,
      pageSize = 10,
      beginTime,
      endTime,
      sort = 'desc',
    } = queryUserDto;

    const where: Prisma.UserWhereInput = {
      deleted: false,
      disabled,
      createdAt: {
        gte: beginTime, // 大于等于这个时间
        lte: endTime, // 小于等于这个时间
      },
      OR: [ // 从用户名或用户昵称中根据关键字筛选并忽略大小写,只需任一满足即可
        { userName: { contains: keyword, mode: 'insensitive' } },
        { profile: { nickName: { contains: keyword, mode: 'insensitive' } } },
      ],
    };

    const users = await this.prismaService.$transaction([
      this.prismaService.user.findMany({
        where,
        skip: (page - 1) * pageSize, // 跳过条数
        take: pageSize, // 每页数量
        orderBy: { createdAt: sort },
        select: {
          id: true,
          userName: true,
          disabled: true,
          createdAt: true,
          updatedAt: true,
          profile: { // 关联用户信息表查询
            select: {
              nickName: true,
              avatar: true,
            },
          },
          roleInUser: { // 关联角色用户中间表查询
            select: {
              roles: {
                select: {
                  name: true,
                },
              },
            },
          },
        },
      }),
      this.prismaService.user.count({ where }),
    ]);

    const userList = users[0].map((user) => {
      return {
        id: user.id,
        userName: user.userName,
        disabled: user.disabled,
        createdAt: user.createdAt.toISOString(),
        updatedAt: user.updatedAt.toISOString(),
        nickName: user.profile?.nickName,
        avatar: user.profile?.avatar,
        roleNames: user.roleInUser.map((roleInUser) => roleInUser.roles.name),
      };
    });

    return { list: userList, total: users[1] };
  }

$transaction 用来开启一个事务。通过将两个查询操作(查找用户列表和计算用户总数)包装在一个事务中,可以确保这两个操作看到的是同一个数据库状态。这避免了在高并发环境下可能出现的不一致情况。

Prisma 可以在一个事务中优化多个查询的执行,减少与数据库的往返通信。这种批处理操作通常比单独执行两个查询更高效,尤其是在网络延迟较高或数据库负载大的情况下。

关键字是根据用户名或用户昵称筛选,所以需要将它们放入 OR 条件中。

1.4 添加查询接口

打开 /src/user/user.controller.ts 文件并添加以下代码:

ts 复制代码
  @ApiOperation({
    summary: '获取用户列表',
  })
  @ApiBaseResponse(UserListEntity)
  @Get()
  findAll(@Query() query: QueryUserDto): Promise<UserListEntity> {
    return this.userService.findAll(query);
  }

@ApiOperation({summary: '获取用户列表'}) 用来定义 Swagger API 文档中该接口的描述性信息。

@ApiBaseResponse(UserListEntity) 这个装饰器是前面章节封装的,用来设置 Swagger API 文档中该接口的响应数据结构。

@Get() 用来声明一个 GET 类型的接口。

@Query() 装饰器用来获取接口的查询参数,然后指定使用了 QueryUserDto 类对查询参数进行校验与转换格式(需要 class-transformer 等库并在 main.ts 中开启,前面章节中有讲)。

2. 获取角色树接口

在添加用户时,需要关联角色,所以需要先开发一个获取所有有效角色列表的接口。

2.1 定义实体类

新建 /src/role/entities/role-tree.entity.ts 文件并添加以下内容:

ts 复制代码
import { PickType } from '@nestjs/swagger';
import { RoleEntity } from './role.entity';

export class RoleTreeEntity extends PickType(RoleEntity, ['id', 'name']) {}

PickType 用来从 RoleEntity 实体类中选取需要的属性,这里只需要 idname 信息。

该实体将用作 Swagger API 文档中接口响应数据的类型说明。

2.2 方法实现

打开 /src/role/role.service.ts 文件并添加以下代码:

ts 复制代码
  findRoleTree() {
    return this.prismaService.role.findMany({
      where: {
        deleted: false,
        disabled: false,
      },
      select: {
        id: true,
        name: true,
      },
    });
  }

角色一般不会太多,直接调用 findMany 批量查询所有即可,需要注意,只能查询出未删除且未禁用的角色。

2.3 添加接口

打开 /src/role/role.controller.ts 文件并添加以下代码:

ts 复制代码
  @ApiOperation({ summary: '获取所有有效角色树' })
  @ApiBaseResponse(RoleTreeEntity, 'array')
  @Get('tree')
  findRoleTree() {
    return this.roleService.findRoleTree();
  }

@Get('tree') 用来声明一个 GET 类型的接口并设置接口地址为 tree

注意:该接口要添加在 @Get(':id') 接口的前面,不然将无法匹配。

3. 创建用户接口

3.1 定义 DTO 对象

新建 /src/user/dto/create-user.dto.ts 文件并添加以下代码:

ts 复制代码
import { ApiProperty } from '@nestjs/swagger';
import {
  IsNotEmpty,
  Matches,
  IsBoolean,
  IsNumber,
  IsOptional,
  ArrayNotEmpty,
  ValidateIf,
} from 'class-validator';

export class CreateUserDto {
  @Matches(/^[a-zA-Z][a-zA-Z0-9]{2,10}$/, { message: '用户名格式错误' })
  @IsNotEmpty({ message: '用户名不能为空' })
  @ApiProperty({ description: '用户名', type: 'string' })
  userName: string;

  @Matches(/^[a-zA-Z0-9\u4e00-\u9fa5']{1,50}$/, { message: '昵称格式错误' })
  @ValidateIf((o) => o.nickName !== '')
  @IsOptional()
  @ApiProperty({ description: '用户昵称', type: 'string', required: false })
  nickName?: string;

  @IsBoolean({ message: '禁用状态必须为布尔值' })
  @IsOptional()
  @ApiProperty({ type: 'boolean', required: false, default: false })
  disabled?: boolean;

  @IsNumber({}, { message: '角色id列表必须为数字数组', each: true })
  @ArrayNotEmpty({ message: '角色id列表不能为空' })
  @ValidateIf((o) => o.roles?.length !== 0)
  @IsOptional()
  @ApiProperty({
    description: '角色id列表',
    type: [Number],
    required: false,
  })
  roles?: number[];
}

@ValidateIf((o) => o.nickName !== '') 表示仅在 nickName 不为空时,进行后面的校验(如:Matches)。

该 DTO 将用来对创建用户接口的 body 参数进行格式校验。

3.2 添加创建用户方法

打开 /src/user/user.service.ts 文件并添加以下方法:

ts 复制代码
import bcrypt, { hash } from 'bcrypt';
import { CreateUserDto } from './dto/create-user.dto';

  private generateHashPassword(password: string) {
    return hash(password, getBaseConfig(this.configService).bcryptSaltRounds);
  }

  private getDefaultPassword() {
    return getBaseConfig(this.configService).defaultPassword;
  }
  
  async create(createUserDto: CreateUserDto) {
    const user = await this.prismaService.user.findUnique({
      where: { userName: createUserDto.userName },
      select: { id: true },
    });
    if (user) {
      throw new BadRequestException('用户已存在');
    }

    const password = this.getDefaultPassword();
    const hashedPassword = await this.generateHashPassword(password);

    await this.prismaService.user.create({
      data: {
        userName: createUserDto.userName,
        password: hashedPassword,
        disabled: createUserDto.disabled,
        profile: {
          create: {
            nickName: createUserDto.nickName,
          },
        },
        // 向用户角色关联表中插入相关信息
        roleInUser: createUserDto.roles && {
          createMany: {
            data: createUserDto.roles.map((roleId) => ({
              roleId,
            })),
          },
        },
      },
    });
  }

创建用户时,首先要判断该用户是否已经存在,如果存在则直接返回错误,如不存在,则生成默认登录密码后将该用户插入数据库,如果有设置关联角色,那么还需要向用户角色中间表中查询相应数据。

3.3 添加创建接口

打开 /src/user/user.controller.ts 文件并添加以下代码:

ts 复制代码
  @ApiOperation({
    summary: '创建用户',
  })
  @ApiBaseResponse()
  @Authority('system:user:add')
  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.userService.create(createUserDto);
  }

@ApiBaseResponse() 是前面章节中封装的一个设置 Swagger API 文档接口响应数据结构的装饰器,不传任何参数,则使用默认响应结构。

@Authority() 是前面章节中封装的一个权限装饰器,用来设置接口需要的权限,如果访问用户没有所设置的权限,则无法调用该接口。

@Post() 用来声明一个 POST 类型的接口。@Body() 用来获取请求体参数,然后指定使用 CreateUserDto 类对获取的参数进行格式校验。

这样创建用户接口就开发完成了~

4. 获取用户详情接口

4.1 定义返回实体类

新建 /src/user/entities/user-detail.entity.ts 文件并添加以下代码:

ts 复制代码
import { ApiProperty, PickType } from '@nestjs/swagger';
import { UserEntity } from './user.entity';
import { ProfileEntity } from './profile.entity';

export class UserDetailEntity extends PickType(UserEntity, [
  'userName',
  'disabled',
]) {
  @ApiProperty({ description: '用户昵称', type: 'string' })
  nickName: ProfileEntity['nickName'];

  @ApiProperty({ description: '角色 id 列表', type: [Number] })
  roles: number[];
}

该实体将用作 Swagger API 文档中获取详情接口响应数据的类型说明。

4.2 方法实现

打开 /src/user/user.service.ts 文件并添加以下方法:

ts 复制代码
  async findOne(id: string) {
    const user = await this.prismaService.user.findUnique({
      where: { id, deleted: false },
      select: {
        userName: true,
        disabled: true,
        profile: { // 关联查询用户信息表的昵称
          select: { nickName: true },
        },
        roleInUser: { // 关联查询用户角色表的角色ID
          select: {
            roleId: true,
          },
        },
      },
    });

    if (!user) {
      throw new BadRequestException('用户不存在');
    }

    const profile = user.profile;
    const roles = user.roleInUser.map((role) => role.roleId);
    return {
      userName: user.userName,
      disabled: user.disabled,
      ...profile,
      roles,
    };
  }

直接调用 findUnique 方法查询指定用户的信息即可,需要注意只能查询未删除用户的信息。

4.3 添加接口

打开 /src/user/user.controller.ts 文件并添加以下接口:

ts 复制代码
  @ApiOperation({
    summary: '获取用户详情',
  })
  @ApiBaseResponse(UserDetailEntity)
  @Get(':id')
  findOne(@Param('id') id: string): Promise<UserDetailEntity> {
    return this.userService.findOne(id);
  }

@Get(':id') 定义了一个 Get 请求的动态路由,:id 是一个动态路由参数,例如:/user/10,则 id 为 10。

@Param(id) 用于获取 URL 中的动态路由参数。

5. 更新用户信息接口

5.1 定义 DTO 对象

新建 /src/user/dto/update-user.dto.ts 文件并添加以下代码:

ts 复制代码
import { PickType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';

export class UpdateUserDto extends PickType(CreateUserDto, [
  'disabled',
  'nickName',
  'roles',
]) {}

只需继承 CreateUserDto 类并选择允许修改的属性即可。

5.2 更新方法实现

因为可以修改用户关联的角色,所以需要在关联角色变更时,删除该用户已缓存的权限信息。

那么首先,需要在 user.module.ts 中引入 redis 模块:

ts 复制代码
...
import { RedisModule } from 'src/redis/redis.module';

@Module({
  imports: [RedisModule],
  ...
})
export class UserModule {}

然后,在 user.service.ts 中注册 redis 服务:

ts 复制代码
...
import { RedisService } from 'src/redis/redis.service';

@Injectable()
export class UserService {
  constructor(
    private readonly prismaService: PrismaService,
    private readonly configService: ConfigService,
    private readonly redisService: RedisService, // 注入
  ) {}
  ...
}

最后,在 /src/user/user.service.ts 中添加以下方法:

ts 复制代码
  private async removeUserToken(id: string) {
    const key = this.redisService.generateSSOKey(id);
    const token = await this.redisService.getSSO(key);
    if (token) {
      this.redisService.setBlackList(token);
    }
    this.redisService.delSSO(key);
  }

  async update(id: string, updateUserDto: UpdateUserDto) {
    const user = await this.prismaService.user.findUnique({
      where: { id, deleted: false },
      select: { userName: true },
    });
    if (!user) {
      throw new NotFoundException('用户不存在');
    }

    if (
      user.userName === getBaseConfig(this.configService).defaultAdmin.username
    ) {
      if (updateUserDto.disabled) {
        throw new BadRequestException('不能禁用超级管理员');
      }

      if (updateUserDto.roles) {
        throw new BadRequestException('不能修改超级管理员角色');
      }
    }

    const data: Prisma.UserUpdateInput = {
      disabled: updateUserDto.disabled,
      profile: {
        update: {
          nickName: updateUserDto.nickName,
        },
      },
    };
    
    // 禁用角色后,要清除权限缓存信息与移除 token
    if (updateUserDto.disabled) {
      this.redisService.delUserPermission(id);
      this.removeUserToken(id);
    }
    
    if (updateUserDto.roles || updateUserDto.roles === null) {
      // 清除用户权限缓存
      this.redisService.delUserPermission(id);
      
      // 删除所有关联角色
      data.roleInUser = {
        deleteMany: {},
      };

      if (updateUserDto.roles.length) {
        // 关联新角色
        data.roleInUser.createMany = {
          data: updateUserDto.roles.map((roleId) => ({
            roleId,
          })),
        };
      }
    }

    await this.prismaService.user.update({
      where: { id, deleted: false },
      data,
    });
  }

要注意,超级管理员只允许修改昵称。

禁用用户后,如果该用户已经登录了,则需要将该用户登录的 token 加入黑名单中。

5.3 添加接口

打开 /src/user/user.controller.ts 文件并添加以下代码:

ts 复制代码
  @ApiOperation({
    summary: '更新用户信息',
  })
  @ApiBaseResponse()
  @Authority('system:user:edit')
  @Patch(':id')
  update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.userService.update(id, updateUserDto);
  }

@Patch(':id') 定义了一个 Patch 请求的动态路由,:id 是一个动态路由参数。

因为是部分更新,所以使用 Patch 更合适。

6. 删除用户接口

6.1 删除方法实现

打开 /src/user/user.service.ts 文件并添加以下方法:

ts 复制代码
  async remove(id: string) {
    const user = await this.prismaService.user.findUnique({
      where: { id, deleted: false },
      select: { userName: true },
    });

    if (!user) {
      throw new NotFoundException('用户不存在');
    }
    if (
      user.userName === getBaseConfig(this.configService).defaultAdmin.username
    ) {
      throw new BadRequestException('不能删除超级管理员');
    }
    this.redisService.delUserPermission(id);
    this.removeUserToken(id);
    await this.prismaService.user.update({
      where: { id },
      data: { deleted: true },
    });
  }

要注意:1. 不能删除系统默认超级管理员;2. 用户缓存的权限信息与 token 也要清除。

6.3 添加删除接口

打开 /src/user/user.controller.ts 文件并添加以下代码:

ts 复制代码
  @ApiOperation({
    summary: '删除用户',
  })
  @ApiBaseResponse()
  @Authority('system:user:del')
  @Patch(':id/delete')
  remove(@Param('id') id: string) {
    return this.userService.remove(id);
  }

因为这里只是软删除,仅修改 deleted 字段的状态而已,所以使用 Patch 更合适。

7. 重置用户密码接口

7.1 重置方法实现

打开 /src/user/user.service.ts 文件并添加以下方法:

ts 复制代码
  async resetPassword(id: string) {
    const user = await this.prismaService.user.findUnique({
      where: { id, deleted: false },
      select: { userName: true },
    });
    if (!user) {
      throw new NotFoundException('用户不存在');
    }
    if (
      user.userName === getBaseConfig(this.configService).defaultAdmin.username
    ) {
      throw new BadRequestException('不能重置超级管理员密码');
    }

    const defaultPassword = this.getDefaultPassword();
    const password = await this.generateHashPassword(defaultPassword);

    await this.prismaService.user.update({
      where: { id },
      data: { password },
    });
    
    return { message: `重置密码成功,默认密码为:${defaultPassword}` };
  }

要注意:不能重置系统默认超级管理员的密码。

7.2 添加接口

打开 /src/user/user.controller.ts 文件并添加以下代码:

ts 复制代码
  @ApiOperation({
    summary: '重置密码',
  })
  @ApiBaseResponse()
  @Authority('system:user:reset')
  @Patch(':id/reset')
  resetPassword(@Param('id') id: string) {
    return this.userService.resetPassword(id);
  }

因为这里只是更新 password 字段的值,所以使用 Patch 更合适。

该模块的单元测试代码见代码仓库。

相关推荐
Aska_Lv21 分钟前
mybatis+springboot+MySQL批量插入 1w 条数据——探讨
后端·架构
考虑考虑40 分钟前
UNION和UNION ALL的用法与区别
数据库·后端·mysql
sd21315121 小时前
springboot3 spring security+jwt实现接口权限验证实现
java·后端·spring
m0_748248021 小时前
Spring Boot 集成 MyBatis 全面讲解
spring boot·后端·mybatis
qq_447663051 小时前
《Spring日志整合与注入技术:从入门到精通》
java·开发语言·后端·spring
源码姑娘1 小时前
基于SpringBoot的智慧停车场小程序(源码+论文+部署教程)
spring boot·后端·小程序
Seven971 小时前
【设计模式】使用中介者模式实现松耦合设计
java·后端·设计模式
Seven971 小时前
【设计模式】探索状态模式在现代软件开发中的应用
java·后端·设计模式
Seven971 小时前
【设计模式】从事件驱动到即时更新:掌握观察者模式的核心技巧
java·后端·设计模式
Trae首席推荐官2 小时前
Trae 功能上新:支持 Remote-SSH 和自定义模型配置
前端·后端·trae