【全栈:小苍兰电子书管理系统】从0到1打造一个电子书管理后台(NestJS++MySQL+Vue3+TS)

232 阅读7分钟

项目简介

【小苍兰电子书管理系统】是我个人的第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 个接口,涵盖各个功能模块的新增、删除、修改、查询(分页+关键词)。

目录结构:

image.png

一、登录注册

登录模块集成 JWT ,利用全局守卫 AuthGuard 验证用户身份信息,结合自定义装饰器忽略公共接口的 token 校验

image.png

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 实现接口级别的权限控制

image.png

image.png

image.png

image.png

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 对象,实现了电子书文件的流式下载

image.png

image.png

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,
      });
  }
}