项目简介
【小苍兰电子书管理系统】是我个人的第2个开源全栈项目,这是后端部分。基于 NestJS 从 0 到 1 实现了后端的三大核心功能模块:注册登录、权限管理、图书管理。前端部分基于 vben admin 框架二次开发,快速搭建页面。篇幅有限,且前端部分为二次开发,本文着重介绍从0到1实现的后端部分
技术栈
后端:NestJS、NestJS CLI、TypeORM、MySQL、fs-extra、adam-zip、compressing、winston 等 前端:Vue Vben Adamin、Vue3、Vite、Ant-Design-Vue、TypeScript 等
功能与实现
基于 Restful API 风格,开发了 39 个接口,涵盖各个功能模块的新增、删除、修改、查询(分页+关键词)。
目录结构:
一、登录注册
登录模块集成 JWT ,利用全局守卫 AuthGuard 验证用户身份信息,结合自定义装饰器忽略公共接口的 token 校验
user.module.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import "reflect-metadata";
import { createLogger } from 'winston';
import * as winston from 'winston';
import { utilities, WinstonModule } from 'nest-winston';
import 'winston-daily-rotate-file'
import { LoggerService, ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception/http-exception.filter';
async function bootstrap() {
// 1、创建实例
const instance = createLogger({
// winston 日志的配置参数
transports: [
// a. 显示到 node 终端中的日志
new winston.transports.Console({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
utilities.format.nestLike(),
),
}),
// b. 记录到服务端硬盘的日志
new winston.transports.DailyRotateFile({
level: 'warn', // 只将 error、warn类型的日志记录在硬盘中
format: winston.format.combine(
winston.format.timestamp(),
winston.format.simple(),
),
dirname: 'logs', // 生产环境下,这将会是个静态目录,专门存放log文件
filename: 'info-%DATE%.log', // 日志文件的名字
datePattern: 'YYYY-MM-DD-HH',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
}),
],
});
const winstonLoggerInstance: LoggerService = WinstonModule.createLogger({
instance,
});
const app = await NestFactory.create(AppModule, {
cors: true, // 开启跨域资源共享
// 2、覆盖 Nest 自带的日志
logger: winstonLoggerInstance,
});
// 全局异常过滤器
app.useGlobalFilters(new HttpExceptionFilter(winstonLoggerInstance));
await app.listen(3000);
}
bootstrap();
app.module.ts
import { Global, Logger,Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { BookModule } from './modules/book/book.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MenuModule } from './modules/menu/menu.module';
import { ContentsModule } from './modules/contents/contents.module';
import { RoleModule } from './modules/role/role.module';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env.develop' }); // 加载指定环境变量
@Global()
@Module({
controllers: [AppController],
providers: [AppService, Logger],
imports: [
UserModule,
AuthModule,
BookModule,
// 配置 TypeOrm
TypeOrmModule.forRoot({
type: "mysql", // 数据库类型
username: `${process.env.TYPEORM_USERNAME}`, // 账号
password: `${process.env.TYPEORM_PASSWORD}`, // 密码
host: `${process.env.TYPEORM_HOST}`, // host
port: Number(process.env.TYPEORM_PORT), //
database: `${process.env.TYPEORM_DATABASE}`, //库名
synchronize: true, // 是否将实体类 Entity 自动同步到数据库中,形成对应的表。上线时,这个属性要关掉
retryDelay: 500, // 重试连接数据库间隔
retryAttempts: 10,// 重试连接数据库的次数
//! 如果为true, 将自动加载实体, forFeature() 方法注册的每个实体都将自动添加到配置对象的实体数组中
autoLoadEntities: true,
}),
MenuModule,
ContentsModule,
RoleModule
],
// 添加 Logger
exports: [Logger]
})
export class AppModule { }
user.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, UseFilters, Request, UseInterceptors, Query, Logger } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
// import { HttpExceptionFilter } from 'src/http-exception/http-exception.filter';
import { PayloadOfRequest, UserSearch } from 'src/types/user';
import { FormattInterceptor } from 'src/formatt-interceptor/formatt.interceptor';
import { Public } from '../auth/public.decorator';
@Controller('user')
// @UseFilters(HttpExceptionFilter)
export class UserController {
constructor(
private readonly userService: UserService,
//! 使用 winston 的日志
private readonly localLogger: Logger
) {
this.localLogger.log('局部的 logger 初始化成功')
}
// 新增用户(自己注册)
@Post('register')
@Public() // 标识为公共接口,不需要校验 token
@UseInterceptors(FormattInterceptor)
register(@Body() data) {
return this.userService.register(data)
}
// 新增用户(管理员新增)
@Post()
@UseInterceptors(FormattInterceptor)
create(@Body() data: CreateUserDto) {
return this.userService.create(data);
}
// 查询所有用户(支持条件查询、分页查询)
@Get()
@UseInterceptors(FormattInterceptor)
findAllUsers(@Query() queryObj: UserSearch) {
return this.userService.findAll(queryObj);
}
// 获取用户资料
// 在 auth.guard.ts 中,已经payload添加到了请求对象上
@Get('info')
@UseInterceptors(FormattInterceptor) // 使用响应拦截器格式化响应数据
getUserInfoByToken(@Request() requestObj: PayloadOfRequest) {
return this.userService.getUserInfoByToken(requestObj.user.username)
}
// 查询单个用户
@Get(':id')
findUser(
// 提取路径参数,并用管道转换为 number 型
@Param('id', ParseIntPipe) id
) {
return this.userService.findOne(id);
}
// 删除指定用户
@Delete(':id')
@UseInterceptors(FormattInterceptor)
remove(@Param('id', ParseIntPipe) id: number) {
return this.userService.remove(id);
}
// 更新用户
@Post(':id')
@UseInterceptors(FormattInterceptor)
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.userService.update(+id, updateUserDto);
}
}
user.service.ts
import { HttpException, HttpStatus, Injectable} from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import * as md5 from 'md5'
import { Like, Repository } from 'typeorm';
@Injectable()
export class UserService {
constructor(
//! 注入 user 表的存储库
@InjectRepository(User)
private readonly userRepository: Repository<User>,
// private readonly formatInterceptor: FormattInterceptor
) {
}
// 新增用户(自己注册)
async register(data) {
const res = await this.userRepository.find({ where: { username: data.username } })
if (res.length !== 0) {
// 禁止注册同名角色
throw new HttpException({
code: HttpStatus.BAD_REQUEST,
errorMSG: '已存在相同用户',
}, HttpStatus.BAD_REQUEST);
}
const newUser = new User()
newUser.username = data.username
newUser.password = md5(data.password).toUpperCase()
newUser.role = JSON.stringify(['role'])
await this.userRepository.save(newUser)
return {
result: '成功',
message: '注册成功'
}
}
// 新增用户(管理员新增)
async create(createUserDto: CreateUserDto) {
// 先创建 user 实例
const newUser = new User()
// 再把传入的对象,添加为 user 实例的属性
newUser.username = createUserDto.username
newUser.password = md5(createUserDto.password).toUpperCase()
newUser.role = createUserDto.role
newUser.avatar = createUserDto.avatar
newUser.nickname = createUserDto.nickname
newUser.active = createUserDto.active // 默认激活
await this.userRepository.save(newUser)
return {
result: '成功',
message: '新增成功'
}
}
// 查询所有用户
async findAll(queryObj) {
let page = Number(queryObj.page) || 1 // 页码
let pageSize = Number(queryObj.pageSize) || 10 // 要多少条数据
// 兜底
if (page <= 0) {
page = 1
}
if (pageSize <= 0) {
pageSize = 10
}
const userID = queryObj.id
const username = queryObj.username
const active = queryObj.active
const searchObj: any = {
where: {
id: userID ? Like(`%${userID}%`) : Like(`%%`),
username: username ? Like(`%${username}%`) : Like(`%%`),
active: active ? Like(`%${active}%`) : Like(`%%`)
},
skip: (page - 1) * pageSize, // 偏移量
take: pageSize, // 每页数据条数
// 排除掉 password
select: ['id', 'role', 'avatar', 'nickname', 'active', 'username'],
}
const userList = await this.userRepository.find(searchObj);
return {
result: userList,
message: '查询成功'
}
}
// 查询单个用户
findOne(id: number): Promise<User> {// 泛型参数 User 就是 user.entity.ts 中类规定的那些属性
return this.userRepository.findOneBy({
id
})
}
// 删除指定用户
async remove(id: number) {
await this.userRepository.delete(id);
return {
result: '删除成功',
message: '删除成功'
};
}
async update(id: number, data: UpdateUserDto) {
const res = await this.userRepository.update(id, data)
return {
result: res,
message: '更新成功'
};
}
// 登录时查询用户是否存在, 用户名是唯一的
findByUsername(username: string): Promise<User> {
return this.userRepository.findOneBy({ username })
}
// 获取用户资料
async getUserInfoByToken(username: string) {
const uesr = await this.userRepository.findOne({
// 排除掉 password
select: ['id', 'role', 'avatar', 'nickname', 'active'],
where: { username }
})
return {
result: uesr
}
}
}
二、权限管理
基于 RBAC 模型,设计并开发了权限管理模块,通过为不同角色分配不同的权限,实现了对用户权限细颗粒度的控制。
主要分为用户管理、角色管理、菜单管理、功能权限管理,通过分表与字段关联查询实现路由级别、菜单级别的权限控制,通过角色守卫 RoleGuard 实现接口级别的权限控制
auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard';
import { UserModule } from '../user/user.module';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants/jwtConstants'
import { TypeOrmModule } from '@nestjs/typeorm';
import { Auth } from './entities/auth.entity';
import { Role_Auth } from './entities/role_auth.entity';
@Module({
controllers: [AuthController],
providers: [
AuthService,
// Nest 将自动把 AuthGuard 绑定为全局守卫
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
imports: [
UserModule,
TypeOrmModule.forFeature([Auth, Role_Auth]),
// 配置 JWT
JwtModule.register({
global: true,
secret: jwtConstants.secret, // 私钥
signOptions: {
// 过期时间
expiresIn: `${72 * 60 * 60}s`
}
})
],
exports: [AuthService]
})
export class AuthModule { }
auth.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, UseFilters, UseInterceptors, Query } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';
import { CreateAuthDto } from './dto/create-auth.dto';
// import { HttpExceptionFilter } from 'src/http-exception/http-exception.filter';
import { UserLogin } from 'src/types/user';
import { FormattInterceptor } from 'src/formatt-interceptor/formatt.interceptor';
import { AuthSearch } from 'src/types/auth';
import { AddNewAuthDto } from './dto/add-new-auth.dto';
@Controller('auth')
// @UseFilters(HttpExceptionFilter) // 控制器级别的拦截器
export class AuthController {
constructor(private readonly authService: AuthService) { }
// 登录
@Post('login')
@Public() // 标识为公共接口,不需要校验 token
@UseInterceptors(FormattInterceptor)
login(@Body() params: UserLogin) {
return this.authService.login(params.username, params.password)
}
// 获取权限列表
@Get()
@UseInterceptors(FormattInterceptor)
findAll(@Query() queryObj: AuthSearch) {
return this.authService.findAll(queryObj);
}
// 新增权限
@Post()
@UseInterceptors(FormattInterceptor)
addNewAuth(@Body() data: AddNewAuthDto) {
return this.authService.addAuthd(data);
}
// 绑定角色和权限
@Post('linkRoleAndAuth')
@UseInterceptors(FormattInterceptor)
linkRoleAndAuth(@Query('roleId') roleId: string, @Query('authId') authId: string) {
return this.authService.linkRoleAndAuth(+roleId, +authId);
}
// 查询roleId对应的功能权限
@Get('linkedAuth/list')
@UseInterceptors(FormattInterceptor)
findAuthByID(@Query('roleId') roleId: string) {
return this.authService.findAuthByID(+roleId);
}
// 编辑权限
@Post(':id')
@UseInterceptors(FormattInterceptor)
update(@Param('id') id: string, @Body() data: AddNewAuthDto) {
return this.authService.update(+id, data);
}
// 删除权限
@Delete(':id')
@UseInterceptors(FormattInterceptor)
remove(@Param('id') id: string) {
return this.authService.remove(+id);
}
}
auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserService } from '../user/user.service';
import * as md5 from 'md5'
import { JwtService } from '@nestjs/jwt';
import { Like, Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Auth } from './entities/auth.entity';
import { AddNewAuthDto } from './dto/add-new-auth.dto';
import { Role_Auth } from './entities/role_auth.entity';
@Injectable()
export class AuthService {
constructor(
// 服务共享
private userService: UserService,
private jwtService: JwtService,
@InjectRepository(Auth)
private readonly authRepository: Repository<Auth>,
@InjectRepository(Role_Auth)
private readonly roleAuthRepository: Repository<Role_Auth>,
) {
//
}
// 登录
async login(username: string, password: string) {
// 先查询用户是否存在
const user = await this.userService.findByUsername(username)
console.log('user', user)
if (user) {
// 比较密码是否正确
const md5Password = md5(password).toUpperCase()
// console.log('md5Password', md5Password)
if (user.password !== md5Password) {
throw new UnauthorizedException()
}
// 登录成功后,返回 token
const payload = {
username: user.username,
userid: user.id
}
const token = await this.jwtService.signAsync(payload)
return {
result: { token },
message: '登录成功'
}
} else {
// 用户不存在
throw new UnauthorizedException()
}
}
// 查找权限
async findAll(queryObj) {
let page = Number(queryObj.page) || 1 // 页码
let pageSize = Number(queryObj.pageSize) || 10 // 要多少条数据
// 兜底
if (page <= 0) {
page = 1
}
if (pageSize <= 0) {
pageSize = 10
}
const key = queryObj.key
const searchObj: any = {
where: {
key: key ? Like(`%${key}%`) : Like(`%%`),
},
skip: (page - 1) * pageSize, // 偏移量
take: pageSize, // 每页数据条数
}
const authList = await this.authRepository.find(searchObj);
return {
result: authList,
message: '查询成功'
}
}
// 新增权限
async addAuthd(data) {
const newAuth = new Auth()
newAuth.key = data.key
newAuth.name = data.name
newAuth.remark = data.remark
const res = await this.authRepository.save(newAuth)
return {
result: res,
message: '新增成功'
}
}
// 更新权限
async update(id: number, data: AddNewAuthDto) {
const res = await this.authRepository.update(id, data)
return {
result: res,
message: '编辑成功'
}
}
// 删除权限
async remove(id: number) {
const res = await this.authRepository.delete(id)
return {
result: res,
message: '删除成功'
}
}
// 绑定角色和权限
async linkRoleAndAuth(roleId: number, authId: number) {
const newBindings = new Role_Auth()
newBindings.roleId = roleId
newBindings.authId = authId
const res = await this.roleAuthRepository.save(newBindings)
return {
result: res,
message: '绑定成功'
}
}
// 解绑角色和权限
async unlinkRoleAndAuth(roleId: number) {
const res = await this.roleAuthRepository.delete({ roleId })
return {
result: res,
message: '解除成功'
}
}
// 查询roleId对应的功能权限
async findAuthByID(roleId) {
const res = await this.roleAuthRepository.find({
where: {
roleId
}
})
return {
result: res,
message: '查询成功'
}
}
async findAuthMarkById(authId) {
const res = await this.authRepository.findBy({
id: authId
})
return res
}
}
auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from './public.decorator';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './constants/jwtConstants'
import { Request } from 'express';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private reflector: Reflector,
private jwtService: JwtService
) {
//
}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 提取当前路由中的元数据 IS_PUBLIC_KEY , 据此判断是否为公共路由
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
// 如果 isPublic 为 true,表示这是公共接口,不需要校验 token,直接放行
if (isPublic) {
return true;
}
// 请求对象
const request = context.switchToHttp().getRequest();
// 提取请求对象中的 token
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
// 校验 token
const payload = await this.jwtService.verifyAsync(token, {
secret: jwtConstants.secret,
});
// 将 payload 添加到请求对象 request 上,在路由中可以方便的获取到它
// payload 长这样: { username: 'tim1', userid: 1, iat: 1726400625, exp: 1726487025 }
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
public.decorator.ts
import { SetMetadata } from '@nestjs/common';
// 自定义的路由元数据
export const IS_PUBLIC_KEY = 'isPublic';
// 用 SetMetadata() 创建自定义装饰器
export const Public = () => {
return SetMetadata(IS_PUBLIC_KEY, true);
}
role.module.ts
import { Module } from '@nestjs/common';
import { RoleService } from './role.service';
import { RoleController } from './role.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Role } from './entities/role.entity';
import { Role_Menu } from './entities/role_menu.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
controllers: [RoleController],
providers: [RoleService],
imports: [TypeOrmModule.forFeature([Role, Role_Menu]), AuthModule]
})
export class RoleModule { }
role.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, UseInterceptors, Query } from '@nestjs/common';
import { RoleService } from './role.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { FormattInterceptor } from 'src/formatt-interceptor/formatt.interceptor';
import { RoleSearch } from 'src/types/role';
@Controller('role')
export class RoleController {
constructor(private readonly roleService: RoleService) { }
// 关联角色与菜单
@Post('/linkedMenu')
@UseInterceptors(FormattInterceptor)
link_Role_Menu(@Query('roleID') roleID, @Query('menuID') menuID) {
return this.roleService.link_Role_Menu(+roleID, +menuID);
}
// 更新关联角色与菜单
@Post('/linkedMenu/update')
@UseInterceptors(FormattInterceptor)
link_Role_Menu_Update(@Query('roleID') roleID, @Query('menuID') menuID) {
return this.roleService.link_Role_Menu_Update(+roleID, +menuID);
}
// 更新关联角色与功能权限
@Post('/linkedAuth/update')
@UseInterceptors(FormattInterceptor)
link_Role_Auth_Update(@Query('roleID') roleID, @Query('menuID') menuID) {
return this.roleService.link_Role_Auth_Update(+roleID, +menuID);
}
// 查询当前角色已绑定的菜单列表
@Get('/linkedMenu/list/:id')
@UseInterceptors(FormattInterceptor)
getListOf_Role_Menu(@Param('id') id) {
return this.roleService.getListOf_Role_Menu(+id);
}
// 删除已存在的角色和菜单的绑定关系
@Delete('/linkedMenu/:id')
@UseInterceptors(FormattInterceptor)
deleteAlreadyExistRoleMenu(@Param('id') id) {
return this.roleService.deleteAlreadyExistRoleMenu(+id);
}
// 删除已存在的角色和功能权限的绑定关系
@Delete('/linkedAuth/:id')
@UseInterceptors(FormattInterceptor)
deleteAlreadyExistRoleAuth(@Param('id') id) {
return this.roleService.deleteAlreadyExistRoleAuth(+id);
}
// 根据角色名,查询功能权限的字段
@Post('getAuthByRole')
@UseInterceptors(FormattInterceptor)
getAuthByRole(@Body('roles') roles: string) {
return this.roleService.getAuthByRole(roles);
}
// 新增角色
@Post()
@UseInterceptors(FormattInterceptor)
create(@Body() data: CreateRoleDto) {
return this.roleService.create(data);
}
// 获取角色, 支持条件查询
@Get()
@UseInterceptors(FormattInterceptor)
findAll(@Query() queryObj: RoleSearch) {
return this.roleService.findAll(queryObj);
}
// 更新角色
@Post(':id')
@UseInterceptors(FormattInterceptor)
update(@Param('id') id: string, @Body() data: UpdateRoleDto) {
const oData = {
name: data.name,
remark: data.remark
}
return this.roleService.update(+id, oData);
}
// 删除角色
@Delete(':id')
@UseInterceptors(FormattInterceptor)
remove(@Param('id') id: string) {
return this.roleService.remove(+id);
}
}
role.service.ts
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { Role } from './entities/role.entity';
import { Like, Repository } from 'typeorm';
import { UpdateRole } from 'src/types/role';
import { Role_Menu } from './entities/role_menu.entity';
import { AuthService } from '../auth/auth.service';
@Injectable()
export class RoleService {
constructor(
//! 注入 Role 表的存储库
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
@InjectRepository(Role_Menu)
private readonly roleMenuRepository: Repository<Role_Menu>,
private readonly authService: AuthService
) {
//
}
async create(data: CreateRoleDto) {
const role = await this.roleRepository.find({ where: { name: data.name } })
if (role.length !== 0) {
// 禁止注册同名角色
throw new HttpException({
code: HttpStatus.BAD_REQUEST,
errorMSG: '已存在相同角色名',
}, HttpStatus.BAD_REQUEST);
}
const newRole = new Role()
newRole.name = data.name
newRole.remark = data.remark
const res = await this.roleRepository.save(newRole)
return {
result: res,
message: "添加成功"
}
}
// 查询角色
async findAll(queryObj) {
let page = Number(queryObj.page) || 1 // 页码
let pageSize = Number(queryObj.pageSize) || 10 // 要多少条数据
// 兜底
if (page <= 0) {
page = 1
}
if (pageSize <= 0) {
pageSize = 10
}
const id = queryObj.id
const name = queryObj.name
const searchObj: any = {
where: {
id: id ? Like(`%${id}%`) : Like(`%%`),
name: name ? Like(`%${name}%`) : Like(`%%`)
},
skip: (page - 1) * pageSize, // 偏移量
take: pageSize, // 每页数据条数
}
const roleList = await this.roleRepository.find(searchObj);
return {
result: roleList,
message: '查询成功'
}
}
async update(id: number, data: UpdateRoleDto) {
const oData = {} as UpdateRole
oData.name = data.name
oData.remark = data.remark
const res = await this.roleRepository.update(id, data)
return {
result: res,
message: "更新成功"
}
}
async remove(id: number) {
// 先删角色与菜单权限的绑定关系,
await this.roleMenuRepository.delete({ roleId: id })
// 再删角色与功能权限的绑定关系,
await this.authService.unlinkRoleAndAuth(id)
// 最后删除角色本身
const res = await this.roleRepository.delete(id)
return {
result: res,
message: "删除成功"
}
}
// 关联角色与菜单
async link_Role_Menu(roleID: number, menuID: number) {
const newBindings = new Role_Menu()
newBindings.roleId = roleID
newBindings.menuId = menuID
const res = await this.roleMenuRepository.save(newBindings)
return {
result: res,
message: '绑定成功'
}
}
// 更新关联角色与菜单
async link_Role_Menu_Update(roleID: number, menuID: number) {
const newBindings = new Role_Menu()
newBindings.roleId = roleID
newBindings.menuId = menuID
const res = await this.roleMenuRepository.save(newBindings)
return {
result: res,
message: '更新成功'
}
}
// 更新关联角色与功能权限
async link_Role_Auth_Update(roleID: number, menuID: number) {
const res = await this.authService.linkRoleAndAuth(roleID, menuID)
return {
result: res,
message: '更新成功'
}
}
// 解除角色与菜单的绑定关系
async deleteAlreadyExistRoleMenu(roleID: number) {
const res = await this.roleMenuRepository.delete({ roleId: roleID })
return {
result: res,
message: '删除成功'
}
}
// 解除角色和功能权限的关系
async deleteAlreadyExistRoleAuth(roleID: number) {
const res = await this.authService.unlinkRoleAndAuth(roleID)
return {
result: res,
message: '删除成功'
}
}
// 查询角色和菜单的绑定关系
async getListOf_Role_Menu(id) {
const res = await this.roleMenuRepository.find({
where: {
roleId: id
}
})
return {
result: res,
message: '查询成功'
}
}
// 根据角色名,查询功能权限的字段
async getAuthByRole(roles: string) {
let arr = JSON.parse(roles)
//!! step 1. 查找角色id
const roleIds = []
for (let i = 0; i < arr.length; i++) {
const resArr = await this.roleRepository.findBy({
name: arr[i]
})
roleIds.push(resArr[0]?.id)
}
//!! step 2. 根据角色id 查询对应的功能权限
let authIds = []
for (let i = 0; i < roleIds.length; i++) {
const arr = await this.authService.findAuthByID(roleIds[i])
if (arr.result.length !== 0) {
let authIdPart = arr.result.map(e => e.authId)
authIds.push(...authIdPart)
}
}
// 多个角色的功能权限可能相同,所以汇聚到一个用户上的功能权限会重复,故去重
authIds = [...new Set(authIds)]
// !! step 3. 根据功能权限id,查询权限标识
let authMarks = []
for (let i = 0; i < authIds.length; i++) {
const arr = await this.authService.findAuthMarkById(authIds[i])
authMarks.push(...arr.map(e => e.key))
}
return {
result: authMarks,
message: '查询成功'
}
}
}
roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../role-enum/role-enum';
import { ROLES_KEY } from '../decorator/role.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) { }
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
console.log('角色守卫的requiredRoles', requiredRoles)
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
三、电子书管理
使用文件拦截器 FileInterceptors 结合装饰器 UploadedFile 、Nginx,实现 .epub 电子书文件的上传与存储,并通过自定义管道 ParseFilePipeBuilder 校验上传文件;
基于 fs-extra 拷贝插件、adam-zip 解压插件等,设计并实现了 ParseEpubBook 类,解决 .epub 文件的解析问题;
基于 compressing 库提供的 Stream 对象,实现了电子书文件的流式下载
book.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe, UseInterceptors, UploadedFile, ParseFilePipeBuilder, Res, UseGuards } from '@nestjs/common';
import { BookService } from './book.service';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';
import { BookSearch } from 'src/types/book';
import { FormattInterceptor } from 'src/formatt-interceptor/formatt.interceptor';
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '../role/role-enum/role-enum';
import { Roles } from '../role/decorator/role.decorator';
import { RolesGuard } from '../role/role-guard/roles.guard';
@Controller('book')
export class BookController {
constructor(private readonly bookService: BookService) { }
// 新增电子书
@Post()
@UseInterceptors(FormattInterceptor)
addNewBook(@Body() data: CreateBookDto) {
return this.bookService.addNewBook(data)
}
// 上传电子书
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadBook(@UploadedFile(new ParseFilePipeBuilder().addFileTypeValidator({ fileType: 'epub' }).build()) file: Express.Multer.File) {
return this.bookService.handleUploadBook(file)
}
// 获取所有书
@Get()
@UseInterceptors(FormattInterceptor) // 使用响应拦截器格式化响应数据
findBookList(@Query() queryObj: BookSearch) {
return this.bookService.findAll(queryObj);
}
// 获取单本书
@Get(':id')
@UseInterceptors(FormattInterceptor) // 使用响应拦截器格式化响应数据
findOneBook(@Param('id', ParseIntPipe) id: number) {
return this.bookService.findOne(id);
}
// 更新电子书
@Post('update/:id')
@UseInterceptors(FormattInterceptor)
update(@Param('id') id: string, @Body() data: UpdateBookDto) {
return this.bookService.update(+id, data);
}
// 删除电子书
@Delete(':id')
@UseInterceptors(FormattInterceptor)
remove(@Param('id') id: string) {
return this.bookService.remove(+id);
}
// 下载电子书
// 举例:只有超级管理员(super)才能下载电子书
@Get('download/:id')
// @Roles(Role.Super) // @Roles 是自定义装饰器,将该路由标记为只有超级管理员特有
// @UseGuards(RolesGuard) // @UseGuards(RolesGuard) 角色守卫,定义只有管理员才有权限调用这个路由
downloadByBinary(@Param('id') id, @Res() res) {
return this.bookService.downloadByBinary(+id, res)
}
}
book.service.ts
import { Injectable } from '@nestjs/common';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';
import { Book } from './entities/book.entity';
import { Like, Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import * as fs from 'fs'
import * as path from 'path'
import { ParseEpubBook } from '../../lib-by-adams/ParseEpubBook';
import { zip } from 'compressing'
@Injectable()
export class BookService {
constructor(
//! 注入 user 表的存储库
@InjectRepository(Book)
private readonly bookRepository: Repository<Book>
) {
}
create(createBookDto: CreateBookDto) {
return 'This action adds a new book';
}
// 查询所有符合筛选条件的书
async findAll(queryObj) {
let page = Number(queryObj.page) || 1 // 页码
let pageSize = Number(queryObj.pageSize) || 10 // 要多少条数据
const name = queryObj.name
const author = queryObj.author
// 兜底
if (page <= 0) {
page = 1
}
if (pageSize <= 0) {
pageSize = 10
}
// 查询条件
const searchObj = {
where: {
title: name ? Like(`%${name}%`) : Like(`%%`), // 根据书名查找, 支持模糊查询
author: author ? Like(`%${author}%`) : Like(`%%`) // 根据作者查找, 支持模糊查询
},
skip: (page - 1) * pageSize, // 偏移量
take: pageSize, // 每页数据条数
}
// 符合当前查询条件的数据总条数
const totalNum = {
where: {
title: name ? Like(`%${name}%`) : Like(`%%`), // 根据书名查找, 支持模糊查询
author: author ? Like(`%${author}%`) : Like(`%%`) // 根据作者查找, 支持模糊查询
}
}
const res = await this.bookRepository.find(searchObj)
const total = await this.bookRepository.count(totalNum)
return {
result: {
res,
total
},
message: '查询成功'
};
}
// 获取单本书
async findOne(id: number) {
// 根据 id 查询单本书
const res = await this.bookRepository.findOne({
where: {
id
}
})
return {
result: res,
message: '查询成功'
};
}
// 上传电子书
async handleUploadBook(file: Express.Multer.File) {
// console.log('file', file);
const originalname = file.originalname // 文件原始名称
const mimetype = file.mimetype // 文件媒体格式
const size = file.size // 文件大小
// Nginx 的静态资源目录
const nginxStaticPath = 'E:/Nginx/html/uploadFile'
// 绝对路径
const absPath = path.resolve(nginxStaticPath, originalname)
// 同步写入文件
fs.writeFileSync(absPath, file.buffer)
// 解析 epub 电子书
const parser = new ParseEpubBook(absPath, file)
const res = await parser.toParse()
return {
code: 0,
result: {
originalname,
mimetype,
size,
path: absPath,
bookInfo_and_content: res
},
message: '上传成功'
}
}
// 新增电子书
async addNewBook(data: CreateBookDto) {
// 1. 创建一个实体对象
const newBook = new Book()
// 2. 向实体对象上添加属性
newBook.fileName = data.fileName
newBook.cover = data.coverPath
newBook.title = data.title
newBook.author = data.author
newBook.publisher = data.publisher
newBook.bookId = data.fileName
newBook.category = data.category
newBook.categoryText = '未知'
newBook.language = data.language
newBook.rootFile = data.rootFile
newBook.originName = data.originName
newBook.filePath = data.filePath
newBook.unzipPath = '未知路径'
newBook.coverPath = data.coverPath
newBook.updateType = 0
// 3. 入表
const res = await this.bookRepository.save(newBook)
return {
result: res,
message: '新增成功'
}
}
// 更新电子书
async update(id: number, data: UpdateBookDto) {
const res = await this.bookRepository.update(id, data)
return {
result: res,
message: '更新成功'
};
}
// 删除电子书
async remove(id: number) {
const res = await this.bookRepository.delete(id)
return {
result: res,
message: '删除成功'
};
}
// 下载电子书(流式下载)
async downloadByBinary(id, res) {
//! step 1. 创建zip压缩的流对象
const stream = new zip.Stream()
// ! step 2. 获取电子书的绝对路径
// 先获取id对应的电子书文件名
const { fileName } = await this.bookRepository.findOne({
where: {
id
}
})
// Nginx 的静态资源目录
const nginxStaticPath = 'E:/Nginx/html/uploadFile'
// 电子书的绝对路径
const fileAbsPath = path.resolve(nginxStaticPath, fileName)
// ! step 3. 将目标文件转换为zip压缩格式个二进制数据
await stream.addEntry(fileAbsPath)
// ! step 4. 响应对象设置响应头
res.setHeader('Content-Type', 'application/octet-stream') // 响应内容
res.setHeader('Content-Disposition', 'attachment;filename=EPUBBOOK') // 附加信息
//! step 5. pipe() 方法,将流文件返回出去
stream.pipe(res)
}
}
ParseEpubBook.ts
import * as fs from 'fs'
import * as path from 'path'
import * as fse from 'fs-extra'
import * as AdmZip from 'adm-zip'
import * as XmlJS from 'xml2js'
export class ParseEpubBook {
constructor(
private readonly bookPath: string,
private readonly file: Express.Multer.File
) {
//
}
async toParse() {
//! step 1. 把epub电子书复制一份,作为读写用的临时文件
// 拷贝得到的文件的路径
const targetPath = path.resolve('E:/Nginx/html/tempEpub', this.file.originalname)
// 拷贝文件
fse.copySync(this.bookPath, targetPath)
//! step 2. 解压拷贝来的 .epub 文件,解压后的文件存在同名的文件夹中
// 文件夹的名字
const unzipEpubDirName = this.file.originalname.replace('.epub', '')
// 生成装解压后的文件的路径
const unzipEpubDirPath = path.resolve('E:/Nginx/html/tempEpub', unzipEpubDirName)
// 根据名字创建文件夹
fse.mkdirpSync(unzipEpubDirPath)
// 解压成很多小文件
this.toUnZip(targetPath, unzipEpubDirPath)
//! step 3. 解析电子书的根文件 container.xml, 获取 content.opf 文件的路径
const content_opf_file_path = await this.parseRootFile(unzipEpubDirPath)
//! step 4. 解析电子书的 content.opf 文件, 得到电子书的关键信息
const bookInfo_And_Content: any = await this.parsContentOpfFile(unzipEpubDirPath, content_opf_file_path)
//! step 5. 在删除临时文件前, 拷贝封面
const coverRealPath = this.copyCoverImage(bookInfo_And_Content)
bookInfo_And_Content.cover = coverRealPath
//! step 6. 解析完成,删除拷贝文件和解压文件
fse.removeSync(targetPath)
fse.removeSync(unzipEpubDirPath)
return bookInfo_And_Content
}
// 解压电子书
toUnZip(originEpubPath, unzipEpubDirPath) {
const zip = new AdmZip(originEpubPath)
// 解压到同名文件夹
zip.extractAllTo(unzipEpubDirPath, true)
}
// 解析根文件 container.xml, 获取 content.opf 文件的路径
parseRootFile(unzipEpubDirPath) {
return new Promise((resolve) => {
// 根文件路径
const containerFilePath = path.resolve(unzipEpubDirPath, 'META-INF/container.xml')
// 读取根文件
const containerXml = fs.readFileSync(containerFilePath, 'utf-8')
const { parseStringPromise } = XmlJS
parseStringPromise(containerXml, { explicitArray: false })
.then((data) => {
resolve(data.container.rootfiles.rootfile['$']['full-path'])
})
})
}
// 解析文件 content.opf, 获取电子书的关键信息
parsContentOpfFile(unzipEpubDirPath, contentPath) {
return new Promise((resolve) => {
// content.opf 路径
const fullPath = path.resolve(unzipEpubDirPath, contentPath)
// 读取content.opf
const contentOpf = fs.readFileSync(fullPath, 'utf-8')
const { parseStringPromise } = XmlJS
parseStringPromise(contentOpf, { explicitArray: false })
.then(async (data) => {
const { metadata } = data.package
// 获取封面的地址
const coverMeta = metadata.meta.find(item => item['$'].name === 'cover')
const coverId = coverMeta['$'].content
const manifest = data.package.manifest.item
const coverRes = manifest.find(item => item['$'].id === coverId)
const dir = path.dirname(fullPath)
const coverImageAbsPath = path.resolve(dir, coverRes['$'].href)
// 解析目录
const contentRes = await this.parseContent(dir, 'toc.ncx', path.dirname(contentPath))
let bookInfo = {
title: metadata['dc:title'] || 'empty', // 书名
creator: metadata['dc:creator'] || 'empty', // 作者
language: metadata['dc:language'] || 'empty', // 语言
publisher: metadata['dc:publisher'] || 'empty', // 出版商
cover: coverImageAbsPath, // 封面图片
rootFile: fullPath // content.opf文件的路径
}
resolve({
...bookInfo,
contentRes
})
})
})
}
// 解析电子书目录
parseContent(dir, contentFilePath, rootDir) {
return new Promise((resolve) => {
// 目录文件路径
const contentPath = path.resolve(dir, contentFilePath)
// 读取目录文件
const contentXml = fs.readFileSync(contentPath, 'utf-8')
// 转换为js文件
const { parseStringPromise } = XmlJS
parseStringPromise(contentXml, { explicitArray: false })
.then((data) => {
const navMap = data.ncx.navMap.navPoint
const navData = navMap.map((nav) => {
const id = nav['$'].id
const playOrder = Number(nav['$'].playOrder)
const text = nav.navLabel.text
const href = nav.content['$'].src
return {
id,
playOrder,
text,
href: `${rootDir}/${href}`
}
})
resolve(navData)
})
})
}
// 拷贝电子书封面,给前端显示用
copyCoverImage(data, tmpDir?) {
const { cover } = data;
// 假设 cover 是封面图片的绝对路径
// 生成封面的目标文件夹路径
const coverDir = path.resolve('E:/Nginx/html/uploadFile', 'cover');
// 确保封面文件夹存在
fse.mkdirpSync(coverDir);
// 提取封面文件名
const coverFileName = path.basename(cover);
// 生成封面的新路径
const coverNewPath = path.resolve(coverDir, coverFileName);
// 拷贝封面图片到目标位置
fse.copySync(cover, coverNewPath);
// 返回封面图片的新路径,以便前端可以访问
// console.log('coverNewPath', coverNewPath)
// 临时拼接, 已适应我本地的nginx
const coverUrl = `http://localhost:88/uploadFile/cover/${coverFileName}`;
return coverUrl;
}
}
四、其他
利用响应拦截器 Interceptors 格式化响应报文,通过全局异常过滤器
ExceptionFilter 捕获错误,并结合 winston 相关插件将异常日志存储为 .log 文件
formatt.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'
import { ReqObj } from 'src/types/formatt';
@Injectable()
export class FormattInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
//! 参数 data 就是每个服务的 return 值,他们被这个响应拦截器包装成标准格式
// 使用 map 操作符包装数据
const doMap = map((data: ReqObj) => {
const theCode = data.code || 0; // 默认值为 0
const theMessage = data.message || '成功'; // 默认值为 '成功'
return {
code: theCode,
result: data.result,
message: theMessage
}
})
return next.handle().pipe(doMap);
}
}
http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, LoggerService } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
// 将错误信息放入日志文件,第1步:接收 winston 日志实例
constructor(private winstonLoggerInstance: LoggerService) {
//
}
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
// 将错误信息放入日志文件,第2步:调用 error 方法,那么借助 winston-daily-rotate-file ,
// 错误信息就会写入日志文件
this.winstonLoggerInstance.error(exception.message)
response
.status(status)
.json({
statusCode: status,
timestamp: new Date().toISOString(),
api: request.url,
});
}
}