本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。
该教程主要包含以下内容:
- 用户登录,包含身份验证、无感刷新 token、单点登录;
- 用户、角色、权限的增删改查;
- 接口级别的权限控制,使用装饰器与守卫实现;
- 接口返回数据格式统一,使用拦截器与异常过滤器实现;
- 使用
Winston进行日志管理; Minio的使用,包含文件上传预签名等;- 编写
Swagger API文档; - 数据库设计与
Prisma建模 - 单元测试;
- 生产环境部署,使用
Docker。
主要技术栈:NestJS、TS、PostgreSQL、Prisma、Redis、Minio、Winston、Docker。
【在线预览地址】账号:test,密码:d.12345
本章节内容: 1. 创建 NestJS 项目;2. 使用 docker-compose 运行 PostgreSQL 容器;3. 数据库设计;4. 设置 Prisma。
先决条件
要学习本教程,您需要:
- 安装 NodeJS 18+ 以上版本;
- 安装 Docker;
- 安装 Prisma VSCode 扩展;
- 熟悉 TypeScript;
- 对数据库有基本的了解;
- 对 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 插件 MySQL 与 Database Client JDBC ,并按照下图配置:
安装完插件后,VSCode 左侧将出现一个数据库的 icon ,点击打开后,再点击左上标红的加号,然后填入你的数据库配置。用户名与密码对应 docker-compose.yml 文件中设置的 POSTGRES_USER 与 POSTGRES_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 按钮打开添加界面,找到该模块并添加,如下图:
首先选择 Entity Relation 模块中的第一个图形,添加一个 Table,如下图:
然后修改其内容为用户表内容,如下图:
可以看到users表包含7个字段:
id,用户ID,主键、最大长度100的字符串类型、非空;user_name,用户名,最大长度20的字符串类型、唯一性约束、非空;password,密码,最大长度100的字符串类型、非空;disabled,是否禁用账号,布尔类型、默认值 false;deleted,账号是否删除,布尔类型,默认值 false;created_at,创建时间,时间戳、默认值插入时间;updated_at,修改时间,时间戳、非空。
因为本系统将使用 cuid 作为用户ID,所以 users 表的主键使用了 VARCHAR 类型。你可以根据你的实际需求做出对应调整。
你可能也会好奇 password 为什么使用 VARCHAR(100) 类型,这是因为,密码不会明文保存到数据库中,往往需要加密后再保存,所以长度需要稍长一些。
提示:想要增加表格行数,可以选中某行然后点击鼠标右键,在右键菜单中选中
Duplicate即可,如下图:
同上步骤绘制其他表的 ER 图,完整 ER 图如下:
可以看到一共有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 拆成几个不同的变量?这样做有几点好处:
- 直观,改用户名(或其他部分)只需改对应变量即可,减少出错的可能;
- 统一管理,
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[] 表示 User 和 RoleInUser 之间的多对多关系。只有添加了这个,才能在 RoleInUser 模型中添加 userId 外键字段。
Role 模型中的 roleInUser RoleInUser[] 表示 Role 和 RoleInUser 之间的多对多关系。只有添加了这个,才能在 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.json 的 scripts 中添加该命令 "migrate:dev": "dotenv -e .env.development -- npx prisma migrate dev" ,然后在终端运行以下命令(请保证 PostgreSQL 容器在运行中):
pnpm run migrate:dev
此命令将生成 SQL 文件,并自动执行文件中的 SQL 语句,以在数据库中创建实际表。如下图:
migrations 即为生成的文件夹,migration.sql 则为生成的 SQL 文件。
通过 VSCode 的数据库图形化插件查看创建的表(本文第二节讲了如何配置数据库图形化),如下图:
可以看到成功生成了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.json 的 scripts 中添加 "prisma:seed": "dotenv -e .env.development -- npx prisma db seed" 命令。
最后,运行以下命令执行种子:
pnpm run prisma:seed
现在可以在数据库图形化界面中看到表中已经有数据了,如下图:
see you later~