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

1,291 阅读7分钟

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

该教程主要包含以下内容:

  1. 用户登录,包含身份验证、无感刷新 token、单点登录;
  2. 用户、角色、权限的增删改查;
  3. 接口级别的权限控制,使用装饰器与守卫实现;
  4. 接口返回数据格式统一,使用拦截器与异常过滤器实现;
  5. 使用 Winston 进行日志管理;
  6. Minio 的使用,包含文件上传预签名等;
  7. 编写 Swagger API 文档;
  8. 数据库设计与 Prisma 建模
  9. 单元测试;
  10. 生产环境部署,使用 Docker

主要技术栈:NestJS、TS、PostgreSQL、Prisma、Redis、Minio、Winston、Docker。

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

本章节内容: 1. 创建 NestJS 项目;2. 使用 docker-compose 运行 PostgreSQL 容器;3. 数据库设计;4. 设置 Prisma。

先决条件

要学习本教程,您需要:

  1. 安装 NodeJS 18+ 以上版本;
  2. 安装 Docker;
  3. 安装 Prisma VSCode 扩展;
  4. 熟悉 TypeScript;
  5. 对数据库有基本的了解;
  6. NestJS 有基本的了解。

最好的学习方式就是勇敢去做,在实践中学习。笔者在第一次写这个项目的时候也碰到了很多问题,有些问题 AI 也无法解决,网上也没找到相关问题,导致一度中断了开发,但最后还是坚持下来了。【第一次写的项目仓库地址

1. 创建 NestJS 项目

首先全局安装 @nestjs/cli

npm install -g @nestjs/cli

然后使用 NestJS CLI 创建一个空项目:

# 将‘xxx’替换为你的项目名称,‘-p‘表示指定包管理器
nest new xxx -p pnpm

使用以下命令运行项目:

pnpm run start:dev

在浏览器中访问 http://localhost:3000/,这时您应该会看到一个空页面,其中包含消息 'Hello World!'。

2. 使用 docker-compose 运行 PostgreSQL 容器

在项目根目录下添加 docker-compose.yml 文件,并添加以下内容:

services:

  postgres:
    image: postgres:latest # 拉取最新的 Postgres 镜像
    container_name: admin # 设置容器名(可选)
    environment: # 环境变量配置
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: ad.postgre
      POSTGRES_DB: admin # 需要创建的数据库
    volumes: # 持久化数据(重启服务不丢失数据),‘postgres_admin’自定义名称
      - postgres_admin:/var/lib/postgresql/data
    ports: # 服务端口号,使用默认的5432即可
      - '5432:5432'

volumes:
  postgres_admin: # 对应上面的volumes名称

在项目根目录新开一个终端窗口并运行 docker-compose up 命令,即可构建并启动 PostgreSQL 容器。

如果想要 PostgreSQL 数据库图形化操作界面,可以安装 VSCode 插件 MySQLDatabase Client JDBC ,并按照下图配置:

postgres.png

安装完插件后,VSCode 左侧将出现一个数据库的 icon ,点击打开后,再点击左上标红的加号,然后填入你的数据库配置。用户名与密码对应 docker-compose.yml 文件中设置的 POSTGRES_USERPOSTGRES_PASSWORD

注意:不要选错数据库了哦,请认准 PostgreSQL

3. 数据库设计

3.1 数据库结构构思

我们要开发的是一个权限管理系统,主要需要有:登录用户管理角色管理权限管理功能。

登录功能,需要用户名与密码,那么用户表必不可少。

如果还需要保存一些用户信息,例如:手机号、邮箱、地址等等,那么最好再添加一个用户信息表来保存这些信息。

因为需要角色管理与权限管理功能,所以我们需要先决定采用哪种权限模型?

目前最常用的是 RBAC 模型,这是一个基于角色的权限访问控制模型。用户通过角色获得权限,角色是权限的集合,用户和角色、角色和权限之间是多对多的关系(RBAC0)。想要了解更多的权限模型可以自行搜索哦。

如果使用 RBAC 模型,那么就还需要权限表角色表角色权限表(中间表,保存角色与权限的多对多关系)、用户角色表(中间表,保存用户与角色的多对多关系)。

那么只需6个数据表即可实现该系统的核心功能。

3.2 绘制 ER 图(可选)

为什么要画 ER 图?在数据库工程中充分利用 ER 关系图,可以保证在数据库创建、管理和维护中产生高质量的数据库设计。通过绘制 ER 图来可视化数据库设计思想,可以更早的识别错误和设计缺陷,也可以更好地理清逻辑。

接下来将使用 VSCode 的 Draw.io Integration 插件来绘制 ER 图,如果你还没有安装,请先安装该插件并启用。

首先在根目录下创建 admin.drawio.png 文件。

我们将使用 Entity Relation 模块来绘制,如果你没有该模块,可以点击 More Shapes 按钮打开添加界面,找到该模块并添加,如下图:

image.png

首先选择 Entity Relation 模块中的第一个图形,添加一个 Table,如下图:

image.png

然后修改其内容为用户表内容,如下图:

image.png

可以看到users表包含7个字段:

  1. id,用户ID,主键、最大长度100的字符串类型、非空;
  2. user_name,用户名,最大长度20的字符串类型、唯一性约束、非空;
  3. password,密码,最大长度100的字符串类型、非空;
  4. disabled,是否禁用账号,布尔类型、默认值 false;
  5. deleted,账号是否删除,布尔类型,默认值 false;
  6. created_at,创建时间,时间戳、默认值插入时间;
  7. updated_at,修改时间,时间戳、非空。

因为本系统将使用 cuid 作为用户ID,所以 users 表的主键使用了 VARCHAR 类型。你可以根据你的实际需求做出对应调整。

你可能也会好奇 password 为什么使用 VARCHAR(100) 类型,这是因为,密码不会明文保存到数据库中,往往需要加密后再保存,所以长度需要稍长一些。

提示:想要增加表格行数,可以选中某行然后点击鼠标右键,在右键菜单中选中 Duplicate 即可,如下图: image.png

同上步骤绘制其他表的 ER 图,完整 ER 图如下:

image.png

可以看到一共有6个数据表,分别是:用户表( users )、用户信息表( profiles )、角色表( roles )、权限表( permissions )、用户角色表( role_in_user )、角色权限表( permission_in_role ),PK 代表主键,FK 代表外键。

Gender 图表示的是 profiles 表中 gender 字段的枚举类型。

MenuType 图表示的是 permissions 表中的 type 字段的枚举类型。

4. 设置 Prisma

4.1 初始化 Prisma

首先在项目中安装 Prisma 作为开发依赖:

pnpm add -D prisma

然后初始化 Prisma

npx prisma init

此命令将在根目录创建一个 prisma 目录与一个 .env 文件。

可以将 admin.drawio.png 文件移入 prisma 目录中。

4.2 设置数据库连接

prisma 文件夹下的 schema.prisma 文件内容保持原样即可。

首先将 .env 文件重命名为 .env.development ,然后修改其内容:

# .env.development
NODE_ENV="development"

# database
DB_USER="admin"
DB_PASSWORD="ad.postgre"
DB_HOST="localhost"
DB_PORT=5432
DB_NAME="admin"
DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=public"

可以看到 DATABASE_URL 为其他变量拼接组合而成,但 env 文件不支持这样的写法,因此要使用 pnpm add -D dotenv-cli 命令安装 dotenv-cli 库。

然后 package.json 中的 scripts 也要做出相应的修改,例如:将 "start:dev": "nest start --watch" => 更改为 "start:dev": "dotenv -e .env.development -- nest start --watch"

现在也可以先不改,因为暂时不会用到环境变量,后面用到时会再讲。

你可能会奇怪为什么要将 DATABASE_URL 拆成几个不同的变量?这样做有几点好处:

  1. 直观,改用户名(或其他部分)只需改对应变量即可,减少出错的可能;
  2. 统一管理,docker-compose.yml 文件中的 postgres 服务的环境变量也可以使用这个配置,无需多处填写相同的环境变量。

提示:可以将 docker-compose.yml 文件的内容修改为以下内容,并使用 docker-compose --env-file .env.development up --build 命令重新构建容器。

services:

  postgres:
    image: postgres:latest
    container_name: admin
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - postgres_admin:/var/lib/postgresql/data
    ports:
      - '${DB_PORT:-5432}:5432'

volumes:
  postgres_admin:

4.3 Prisma 数据建模

首先将 User 模型添加到你的 schema.prisma 文件中:

model User {
  id         String       @id @default(cuid()) @db.VarChar(100)
  userName   String       @unique @db.VarChar(20) @map("user_name")
  password   String       @db.VarChar(100)
  disabled   Boolean      @default(false)
  deleted    Boolean      @default(false)
  createdAt  DateTime     @default(now()) @map("created_at")
  updatedAt  DateTime     @updatedAt @map("updated_at")
  
  @@map("users")
}

在这里创建了一个具有7个字段的 User 模型,每个字段都有一个名称(id、userName等)、一个类型(Sting、Boolean等)和其他可选属性(@id、@unique等)。

id 字段具有一个名为 @id 的特殊属性,此属性表示该字段是模型的主键。@default(cuid()) 属性指示此字段应自动生成 cuid 并分配给任何创建的新记录。@db.VarChar(100) 属性指示使用数据库原生类型 VarChar 并限制最大长度。

userName 字段有一个 @unique 的特殊属性,此属性表示该字段的值不能重复(唯一性约束)。@map 属性可以用来指定数据库真实字段名。

createdAt 字段的 @default(now()) 属性表示自动生成当前时间戳并分配给当前创建的新记录。

updatedAt 字段的 @updatedAt 属性表示修改表记录时,自动更新该字段的时间戳。

@@map 属性用来指定真实数据库名称,可以不使用,那么将会创建名称为 User 的表。

为什么要用 @map@@map 指定数据库里的表、字段真实名称?因为使用小写字母与下划线命名是一种数据库规范。而我们在 NestJS 开发中习惯使用小驼峰命名法,因此需要使用这两个属性映射一下。

然后增加一个 Profile 模型:

model User {
  id         String       @id @default(cuid()) @db.VarChar(100)
  userName   String       @unique @db.VarChar(20) @map("user_name")
  password   String       @db.VarChar(100)
  disabled   Boolean      @default(false)
  deleted    Boolean      @default(false)
  createdAt  DateTime     @default(now()) @map("created_at")
  updatedAt  DateTime     @updatedAt @map("updated_at")

  profile    Profile?

  @@map("users")
}

enum Gender {
  MA
  FE
  OT
}

model Profile {
  id          Int       @id @default(autoincrement()) @db.Integer
  nickName    String?   @db.VarChar(50) @map("nick_name")
  avatar      String?   @db.VarChar(255)
  email       String?   @unique @db.VarChar(50)
  phone       String?   @unique @db.VarChar(20)
  gender      Gender    @default(OT)
  birthday    DateTime?
  description String?   @db.VarChar(150)
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")

  userId String @unique @map("user_id")
  user   User   @relation(fields: [userId], references: [id])

  @@map("profiles")
}

可以发现,User 模型中添加了一行 profile Profile? ,这是为了建立与 Profile 模型的关系(下面建立外键时需要用到),而 ? 表示可选。

enum 表示枚举类型,这里创建了一个性别的枚举,然后在 Profile 模型中的 gender 字段使用了该类型,并设置了默认值 OT

Profile 模型中的 user 是一个特殊字段(实际的表中并不会有这个字段),该字段的 User 表示模型名称,@relation(fields: [userId], references: [id]) 表示 userId 字段依赖于 User 模型的 id 字段,即 userId 字段是外键(注意:需要在 User 模型中添加 profile Profile? 才可以哦)。

最后,向文件中添加剩余的4个模型,文件完整内容如下:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id         String       @id @default(cuid()) @db.VarChar(100)
  userName   String       @unique @db.VarChar(20) @map("user_name")
  password   String       @db.VarChar(100)
  disabled   Boolean      @default(false)
  deleted    Boolean      @default(false)
  createdAt  DateTime     @default(now()) @map("created_at")
  updatedAt  DateTime     @updatedAt @map("updated_at")

  profile    Profile?
  roleInUser RoleInUser[]

  @@map("users")
}

enum Gender {
  MA
  FE
  OT
}

model Profile {
  id          Int       @id @default(autoincrement()) @db.Integer
  nickName    String?   @db.VarChar(50) @map("nick_name")
  avatar      String?   @db.VarChar(255)
  email       String?   @unique @db.VarChar(50)
  phone       String?   @unique @db.VarChar(20)
  gender      Gender    @default(OT)
  birthday    DateTime?
  description String?   @db.VarChar(150)
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")

  userId String @unique @map("user_id")
  user   User   @relation(fields: [userId], references: [id])

  @@map("profiles")
}

model Role {
  id          Int      @id @default(autoincrement()) @db.SmallInt
  name        String   @unique @db.VarChar(50)
  description String?  @db.VarChar(150)
  disabled    Boolean  @default(false)
  deleted     Boolean  @default(false)
  createdAt   DateTime @default(now()) @map("created_at")
  updatedAt   DateTime @updatedAt @map("updated_at")

  roleInUser       RoleInUser[]
  permissionInRole PermissionInRole[]

  @@map("roles")
}

model RoleInUser {
  roleId Int  @map("role_id")
  roles  Role @relation(fields: [roleId], references: [id])

  userId String @map("user_id")
  users  User   @relation(fields: [userId], references: [id])

  @@id([roleId, userId])
  @@map("role_in_user")
}

enum Type {
  DIRECTORY
  MENU
  BUTTON
}

model Permission {
  id               Int                @id @default(autoincrement()) @db.SmallInt
  type             Type               @default(MENU)
  name             String             @unique @db.VarChar(50)
  permission       String?            @unique @db.VarChar(50)
  icon             String?            @db.VarChar(50)
  path             String?            @db.VarChar(50)
  component        String?            @db.VarChar(150)
  sort             Int                @default(0) @db.SmallInt
  redirect         String?            @db.VarChar(100)
  disabled         Boolean            @default(false)
  hidden           Boolean            @default(false)
  cache            Boolean            @default(false)
  props            Boolean            @default(false)
  deleted          Boolean            @default(false)
  createdAt        DateTime           @default(now()) @map("created_at")
  updatedAt        DateTime           @updatedAt @map("updated_at")
  
  pid              Int?               @db.SmallInt
  parent           Permission?        @relation("ParentToChildren", fields: [pid], references: [id])
  children         Permission[]       @relation("ParentToChildren")
  
  permissionInRole PermissionInRole[]

  @@map("permissions")
}

model PermissionInRole {
  permissionId Int        @map("permission_id")
  permissions  Permission @relation(fields: [permissionId], references: [id])
  
  roleId Int  @map("role_id")
  roles  Role @relation(fields: [roleId], references: [id])
  
  @@id([roleId, permissionId])
  @@map("role_in_permission")
}

User 模型中的 roleInUser RoleInUser[] 表示 UserRoleInUser 之间的多对多关系。只有添加了这个,才能在 RoleInUser 模型中添加 userId 外键字段。

Role 模型中的 roleInUser RoleInUser[] 表示 RoleRoleInUser 之间的多对多关系。只有添加了这个,才能在 RoleInUser 模型中添加 roleId 外键字段。

@@id([roleId, userId]) 表示设置联合主键。

pid              Int?               @db.SmallInt
parent           Permission?        @relation("ParentToChildren", fields: [pid], references: [id])
children         Permission[]       @relation("ParentToChildren")

以上代码,将 pid 字段设为了外键,并依赖于自身的 id 字段。

4.4 迁移(生成)数据库

定义 Prisma 架构后,就需要运行迁移命令以在数据库中创建实际表。请先在 package.jsonscripts 中添加该命令 "migrate:dev": "dotenv -e .env.development -- npx prisma migrate dev" ,然后在终端运行以下命令(请保证 PostgreSQL 容器在运行中):

pnpm run migrate:dev

此命令将生成 SQL 文件,并自动执行文件中的 SQL 语句,以在数据库中创建实际表。如下图:

image.png

migrations 即为生成的文件夹,migration.sql 则为生成的 SQL 文件。

通过 VSCode 的数据库图形化插件查看创建的表(本文第二节讲了如何配置数据库图形化),如下图:

image.png

可以看到成功生成了7个表,还有一个 _prisma_migrations 表是 Prisma ORM 用来跟踪和管理数据库迁移的系统表。这个表的主要作用是记录所有已经应用到数据库的迁移历史。

4.5 设定数据库种子

目前,数据库为空。那我们接下来将创建一个种子脚本,该脚本可以使用一些初始数据填充数据库。

首先在 prisma 文件夹下创建一个 seed.ts 文件,并在终端运行以下命令:

pnpm add @prisma/client bcrypt
pnpm add -D @types/bcrypt

Prisma Client 是一种类型安全的数据库客户端,它会根据 Prisma 模型定义生成。

bcrypt 是一个安全的加密算法库,可以用来加密用户密码。

然后在 .env.development 中添加以下内容:

# bcrypt
BCRYPT_SALT_ROUNDS=10

# default admin
DEFAULT_ADMIN_USERNAME="sAdmin"
DEFAULT_ADMIN_PASSWORD="w.1admin"
DEFAULT_ADMIN_ROLE="sAdmin"
DEFAULT_ADMIN_PERMISSION="*:*:*"

在种子文件中将用到以上新添加的环境变量。

最后,在种子文件中,添加以下代码:

import { PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';

const prisma = new PrismaClient();

async function main() {
  const roleName = process.env.DEFAULT_ADMIN_ROLE;
  const role = await prisma.role.upsert({
    create: {
      name: roleName,
      description: '默认超级管理员',
    },
    update: {},
    where: {
      name: roleName,
    },
  });

  await prisma.permission.upsert({
    create: {
      name: '系统管理',
      path: 'system',
      type: 'DIRECTORY',
      icon: 'system',
      permissionInRole: {
        create: {
          roleId: role.id,
        },
      },
      children: {
        create: [
          {
            name: '用户管理',
            path: 'user',
            icon: 'user',
            component: '/system/user/index.vue',
            cache: true,
            permissionInRole: {
              create: {
                roleId: role.id,
              },
            },
            children: {
              create: [
                {
                  name: '添加用户',
                  type: 'BUTTON',
                  permission: 'system:user:add',
                  permissionInRole: {
                    create: {
                      roleId: role.id,
                    },
                  },
                },
                {
                  name: '编辑用户',
                  type: 'BUTTON',
                  permission: 'system:user:edit',
                  permissionInRole: {
                    create: {
                      roleId: role.id,
                    },
                  },
                },
                {
                  name: '删除用户',
                  type: 'BUTTON',
                  permission: 'system:user:del',
                  permissionInRole: {
                    create: {
                      roleId: role.id,
                    },
                  },
                },
              ],
            },
          },
          {
            name: '菜单管理',
            path: 'menu',
            icon: 'menu',
            component: '/system/menu/index.vue',
            cache: true,
            permissionInRole: {
              create: {
                roleId: role.id,
              },
            },
            children: {
              create: [
                {
                  name: '添加菜单',
                  type: 'BUTTON',
                  permission: 'system:menu:add',
                  permissionInRole: {
                    create: {
                      roleId: role.id,
                    },
                  },
                },
                {
                  name: '编辑菜单',
                  type: 'BUTTON',
                  permission: 'system:menu:edit',
                  permissionInRole: {
                    create: {
                      roleId: role.id,
                    },
                  },
                },
                {
                  name: '删除菜单',
                  type: 'BUTTON',
                  permission: 'system:menu:del',
                  permissionInRole: {
                    create: {
                      roleId: role.id,
                    },
                  },
                },
              ],
            },
          },
          {
            name: '角色管理',
            path: 'role',
            icon: 'role',
            component: '/system/role/index.vue',
            cache: true,
            permissionInRole: {
              create: {
                roleId: role.id,
              },
            },
            children: {
              create: [
                {
                  name: '添加角色',
                  type: 'BUTTON',
                  permission: 'system:role:add',
                  permissionInRole: {
                    create: {
                      roleId: role.id,
                    },
                  },
                },
                {
                  name: '编辑角色',
                  type: 'BUTTON',
                  permission: 'system:role:edit',
                  permissionInRole: {
                    create: {
                      roleId: role.id,
                    },
                  },
                },
                {
                  name: '删除角色',
                  type: 'BUTTON',
                  permission: 'system:role:del',
                  permissionInRole: {
                    create: {
                      roleId: role.id,
                    },
                  },
                },
              ],
            },
          },
        ],
      },
    },
    update: {},
    where: {
      name: '系统管理',
    },
  });

  const userName = process.env.DEFAULT_ADMIN_USERNAME;
  // 加密密码
  const password = await hash(
    process.env.DEFAULT_ADMIN_PASSWORD,
    +process.env.BCRYPT_SALT_ROUNDS,
  );
  await prisma.user.upsert({
    create: {
      userName,
      password,
      profile: {
        create: {
          nickName: '超级管理员',
        },
      },
      roleInUser: {
        create: {
          roleId: role.id,
        },
      },
    },
    update: {},
    where: {
      userName,
    },
  });
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

在此脚本中,首先初始化了 Prisma Client 。然后,使用 prisma.upsert() 函数创建角色、权限、用户。upsert 函数仅在没有与 where 条件匹配的记录时创建新记录。

执行脚本之前,请先在 package.json 文件的末尾添加以下内容:

"prisma": {
    "seed": "ts-node prisma/seed.ts"
}

然后在 package.jsonscripts 中添加 "prisma:seed": "dotenv -e .env.development -- npx prisma db seed" 命令。

最后,运行以下命令执行种子:

pnpm run prisma:seed

现在可以在数据库图形化界面中看到表中已经有数据了,如下图:

image.png

see you later~