教育平台后端-登录功能

287 阅读4分钟

创建项目

nest new project-name

安装包

pnpm i @nestjs/jwt @nestjs/typeorm typeorm mysql2 redis class-transformer class-validator @nestjs/config

src\.env

#redis相关配置
redis_server_host=localhost
redis_server_port=6379
redis_server_db=1

#短信相关配置
sign_name=填写自己的
template_code=填写自己的
access_key=填写自己的
access_key_secret=填写自己的

#mysql相关配置
mysql_server_host=localhost
mysql_server_port=3306
mysql_server_username=root
mysql_server_password=填写自己的
mysql_server_database=填写自己的

#nest服务配置
nest_server_port=3000

#jwt配置
jwt_secret=填写自己的
jwt_access_token_expires_time=30m
jwt_refresh_token_expres_time=7d

nest-cli.json
asssets是指定build时复制的文件,watchAssets是在assets变动之后自动重新复制

{
  "compilerOptions": {
    "deleteOutDir": true,
+    "watchAssets": true,
+    "assets": ["**/*.env"]
  },
}

src\modules\redis\redis.module.ts
使用configService读取变量

import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient } from 'redis';
import { RedisService } from './redis.service';

@Global()
@Module({
  providers: [
    RedisService,
    {
      provide: 'REDIS_CLIENT',
      async useFactory(configService: ConfigService) {
        const client = createClient({
          socket: {
            host: configService.get('redis_server_host'),
            port: configService.get('redis_server_port'),
          },
          database: configService.get('redis_server_db'),
        });
        await client.connect();
        return client;
      },
      inject: [ConfigService],
    },
  ],
  exports: [RedisService],
})
export class RedisModule {}

src\app.module.ts
挂载相关模块

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { RedisModule } from './modules/redis/redis.module';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [
    ConfigModule.forRoot({
      // 是否全局配置
      isGlobal: true,
      // 环境变量文件路径
      envFilePath: 'src/.env',
    }),
    JwtModule.registerAsync({
      // 设置全局的jwt加密密钥
      global: true,
      useFactory(configService: ConfigService) {
        return {
          secret: configService.get('jwt_secret'),
          signOptions: {
            expiresIn: configService.get('jwt_access_token_expires_time'), // 默认 30 分钟
          },
        };
      },
      inject: [ConfigService],
    }),
    TypeOrmModule.forRootAsync({
      useFactory(configService: ConfigService) {
        return {
          // 数据库类型
          type: 'mysql',
          // 数据库主机地址
          host: configService.get('mysql_server_host'),
          // 数据库端口
          port: configService.get('mysql_server_post'),
          // 数据库用户名
          username: configService.get('mysql_server_username'),
          // 数据库密码
          password: configService.get('mysql_server_password'),
          // 数据库名
          database: configService.get('mysql_server_database'),
          // 实体类
          entities: [`${__dirname}/../modules/**/*.entity{.ts,.js}` + ''],
          // 是否自动同步数据库
          synchronize: true,
          // 是否开启日志
          logging: true,
          // 连接包
          connectorPackage: 'mysql2',
          // 是否自动加载实体
          autoLoadEntities: true,
        };
      },
      inject: [ConfigService],
    }),
    RedisModule,
  ],
  controllers: [AppController],
  providers: [
    AppService,
  ],
})
export class AppModule {}

src\main.ts

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import { CustomExceptionFilter } from './custom-exception.filter';
import { FormatResponseInterceptor } from './format-response.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { cors: true });
  const configService = app.get(ConfigService);
  // 全局使用管道,使用ValidationPipe
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(configService.get('nest_server_port'));
}
bootstrap();

格式化

统一格式化响应成功、失败数据格式

格式化响应失败数据格式

生成filter文件

nest g filter custom-exception

src\custom-exception.filter.ts

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
} from '@nestjs/common';
import { Response } from 'express';

// 捕获HttpException异常
@Catch(HttpException)
export class CustomExceptionFilter implements ExceptionFilter {
  // 捕获异常
  catch(exception: HttpException, host: ArgumentsHost) {
    // 获取响应对象
    const response = host.switchToHttp().getResponse<Response>();

    // 设置响应状态码
    response.statusCode = exception.getStatus();
    // 获取响应信息
    const res = exception.getResponse() as { message: string[] };
    // 返回响应
    response
      .json({
        code: exception.getStatus(),
        message: 'fail',
        // 兼容ValidationPipe报错
        data: res?.message?.join ? res?.message?.join(',') : exception.message,
      })
      .end();
  }
}

image.png

格式化响应成功数据格式

生成interceptor文件

nest g interceptor format-response.interceptor

src\format-response.interceptor.ts

import {
  CallHandler,
  ExecutionContext,
  HttpStatus,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Response } from 'express';
import { map, Observable } from 'rxjs';

@Injectable()
export class FormatResponseInterceptor implements NestInterceptor {
  // 拦截上下文和下一个处理程序,返回一个Observable
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 获取响应
    const response = context.switchToHttp().getResponse<Response>();
    // 返回下一个处理程序处理后的数据,并处理响应状态码
    return next.handle().pipe(
      // 映射data,返回一个对象
      map((data) => {
        // 返回状态码200
        response.status(200);
        return {
          code: HttpStatus.OK,
          message: 'success',
          data,
        };
      }),
    );
  }
}

image.png

挂载

src\main.ts
注册自定义异常过滤器、全局拦截器

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import { CustomExceptionFilter } from './custom-exception.filter';
import { FormatResponseInterceptor } from './format-response.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { cors: true });
  const configService = app.get(ConfigService);
  // 添加自定义异常过滤器
  app.useGlobalFilters(new CustomExceptionFilter());
  // 使用全局拦截器,添加FormatResponseInterceptor拦截器
  app.useGlobalInterceptors(new FormatResponseInterceptor());
  // 全局使用管道,使用ValidationPipe
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(configService.get('nest_server_port'));
}
bootstrap();

用户模块

生成用户模块

nest g user modules/user

controller

src\modules\user\user.controller.ts

import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { UserInput } from './dto/user-input.type';
import { UserType } from './dto/user.type';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  async createUser(@Body() params: UserInput): Promise<boolean> {
    return await this.userService.create(params);
  }

  @Get('/:id')
  async find(@Param('id') id: string): Promise<UserType> {
    return await this.userService.find(id);
  }

  @Post('/:id')
  async update(
    @Param('id') id: string,
    @Body() params: UserInput,
  ): Promise<boolean> {
    return await this.userService.update(id, params);
  }

  @Get('/delete')
  async delete(@Param('id') id: string): Promise<boolean> {
    return await this.userService.del(id);
  }
}

service

src\modules\user\user.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DeepPartial } from 'typeorm';
import { User } from './models/user.entity';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User) private readonly userRepository: Repository<User>,
  ) {}

  async create(entity: DeepPartial<User>): Promise<boolean> {
    await this.userRepository.insert(entity);
    return true;
  }

  async find(tel: string): Promise<User> {
    const res = await this.userRepository.findOne({
      where: {
        tel,
      },
    });
    return res;
  }

  async update(id: string, params: any) {
    await this.userRepository.update(id, params);
    return true;
  }

  async del(id: string) {
    await this.userRepository.delete(id);
    return true;
  }
}

module

src\modules\user\user.module.ts
注入Repository

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
import { UserService } from './user.service';
import { UserController } from './user.controller';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UserService],
  exports: [UserService],
  controllers: [UserController],
})
export class UserModule {}

用户表字段

import { IsNotEmpty } from 'class-validator';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity('user')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({
    comment: '昵称',
    default: '',
  })
  @IsNotEmpty()
  name: string;

  @Column({
    comment: '描述',
    default: '',
  })
  desc: string;

  @Column({
    comment: '手机号',
    nullable: true,
  })
  tel: string;

  @Column({
    comment: '头像',
    nullable: true,
  })
  avatar: string;
}

短信服务

使用阿里云的短信服务

image.png 安装对应的包

pnpm i @alicloud/dysmsapi20170525 @alicloud/openapi-client @alicloud/tea-util

controller

生成短信模块

nest g resource modules/auth

src\modules\auth\auth.controller.ts

import {
  Body,
  Post,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UserService } from '../user/user.service';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/auth.dto';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
  ) {}

  @Post('sendCodeMsg')
  async sendCodeMsg(@Body('tel') tel: string) {
    return await this.authService.sendCodeMsg(tel);
  }

service

src\modules\auth\auth.service.ts

  1. 拿到手机号先查询数据库是否存在,不存在就创建
  2. 查询redis中的验证码是否过期,如果没有过期就报错
  3. 拿到返回的验证码保存到redis,设置300秒过期,可以减少读取数据库的频率
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
import OpenApi, * as $OpenApi from '@alicloud/openapi-client';
import Util, * as $Util from '@alicloud/tea-util';
import { getRandomCode } from '@/utils';
import { RedisService } from '../redis/redis.service';
import { LoginDto } from './dto/auth.dto';
import { UserService } from '../user/user.service';
import { LoginUserVo } from './vo/login-user.vo';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AuthService {
  constructor(
    private readonly redisService: RedisService,
    private readonly userService: UserService,
  ) {}
  // 发送短信验证码
 async sendCodeMsg(phone: string): Promise<string> {
    const user = await this.userService.find(phone);
    if (!user) {
      await this.userService.create({ tel: phone });
    }
    const loginMsg = await this.redisService.get(`loginMsg${phone}`);
    if (loginMsg) {
      throw new HttpException('请勿重复发送验证码', HttpStatus.BAD_REQUEST);
    }
    const code = getRandomCode();
    const config = new $OpenApi.Config({
      // 必填,您的 AccessKey ID
      accessKeyId: this.configService.get('access_key'),
      // 必填,您的 AccessKey Secret
      accessKeySecret: this.configService.get('access_key_secret'),
    });
    // Endpoint 请参考 https://api.aliyun.com/product/Dysmsapi
    config.endpoint = `dysmsapi.aliyuncs.com`;
    const client = new Dysmsapi20170525(config);

    // 请确保代码运行环境设置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID 和 ALIBABA_CLOUD_ACCESS_KEY_SECRET。
    // 工程代码泄露可能会导致 AccessKey 泄露,并威胁账号下所有资源的安全性。以下代码示例使用环境变量获取 AccessKey 的方式进行调用,仅供参考,建议使用更安全的 STS 方式,更多鉴权访问方式请参见:https://help.aliyun.com/document_detail/378664.html
    const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
      signName: this.configService.get('sign_name'),
      templateCode: this.configService.get('template_code'),
      phoneNumbers: phone,
      templateParam: `{\"code\":\"${code}\"}`,
    });
    const runtime = new $Util.RuntimeOptions({});
    try {
      // 复制代码运行请自行打印 API 的返回值
      await client.sendSmsWithOptions(sendSmsRequest, runtime);
      this.redisService.set(`loginMsg${phone}`, code, 300);
    } catch (error) {
      // 如有需要,请打印 error
      Util.assertAsString(error.message);
    }
    return code;
  }

src\utils\index.ts
生成四位随机数函数

export const getRandomCode = () => {
  // 返回一个介于1000-9000之间的随机数
  return (Math.floor(Math.random() * 9000) + 1000).toString();
};

module

引入user模块

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';

@Module({
  imports: [UserModule],
  providers: [AuthService],
  controllers: [AuthController],
})
export class AuthModule {}

登录接口

service

src\modules\auth\auth.service.ts

  1. 先判断手机号是否被注册,再判断验证码是否过期、是否与redis中的验证码相同
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
import OpenApi, * as $OpenApi from '@alicloud/openapi-client';
import Util, * as $Util from '@alicloud/tea-util';
import { getRandomCode } from '@/utils';
import { RedisService } from '../redis/redis.service';
import { LoginDto } from './dto/auth.dto';
import { UserService } from '../user/user.service';
import { LoginUserVo } from './vo/login-user.vo';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AuthService {
  constructor(
    private readonly redisService: RedisService,
    private readonly userService: UserService,
    private readonly configService: ConfigService,
  ) {}
  // 登录
  async login(parmas: LoginDto) {
    const { tel, code } = parmas;

    // 调用userService的find方法,根据tel查找用户
    const user = await this.userService.find(tel);
    // 如果没有找到用户,抛出异常
    if (!user) {
      throw new HttpException('手机号未被注册', 400);
    }

    // 调用redisService的get方法,根据tel获取验证码
    const codeMsg = await this.redisService.get(`loginMsg${tel}`);
    // 如果没有找到验证码,抛出异常
    if (!codeMsg) {
      throw new HttpException('验证码已过期', HttpStatus.BAD_REQUEST);
    }
    // 如果验证码不正确,抛出异常
    if (codeMsg !== code) {
      throw new HttpException('验证码不正确', HttpStatus.BAD_REQUEST);
    }
    // 创建LoginUserVo对象
    const vo = new LoginUserVo();
    // 设置vo的userInfo属性
    vo.userInfo = {
      id: user.id,
      desc: user.desc,
      name: user.name,
      tel: user.tel,
      avatar: user.avatar,
    };
    // 返回vo
    return vo;
  }
}

controller

src\modules\auth\auth.controller.ts

login

用户生成accessTokenrefreshToken

  1. 在返回结果添加accessTokenrefreshToken
  2. accessToken中保存了userIdname,过期时间为30分钟
  3. refreshToken中保存了userId,过期时间为7天
import {
  Body,
  Controller,
  Get,
  Post,
  Query,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UserService } from '../user/user.service';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/auth.dto';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly jwtService: JwtService,
    private readonly userService: UserService,
  ) {}

  @Post('login')
  async login(@Body() params: LoginDto) {
    const vo = await this.authService.login(params);
    vo.accessToken = this.jwtService.sign(
      {
        // 用户id
        userId: vo.userInfo.id,
        // 用户名
        name: vo.userInfo.name,
      },
      {
        // 过期时间设置为30分钟
        expiresIn: '30m',
      },
    );
    vo.refreshToken = this.jwtService.sign(
      {
        // 用户id
        userId: vo.userInfo.id,
      },
      {
        // 过期时间设置为7天
        expiresIn: '7d',
      },
    );
    return vo;
  }

}

src\modules\auth\vo\login-user.vo.ts
返回数据类型

interface UserInfo {
  id: string;
  name: string;
  desc: string;
  tel: string;
  avatar: string;
}

export class LoginUserVo {
  userInfo: UserInfo;
  accessToken: string;
  refreshToken: string;
}

src\modules\auth\dto\auth.dto.ts
校验login接口参数

import { IsNotEmpty, IsString } from 'class-validator';

export class LoginDto {
  @IsNotEmpty({
    message: '手机号不能为空',
  })
  @IsString()
  tel: string;
  @IsNotEmpty({
    message: '密码不能为空',
  })
  @IsString()
  code: string;
}

refresh

用于根据refresh_token重新生成accessTokenrefreshToken

  1. 先解析refresh_token拿到用户的id,如果解析失败就返回token失效
  2. 如果没有过期,就再生成accessTokenrefreshToken
@Get('refresh')
  // async 声明函数为异步函数
  async refresh(@Query('refresh_token') refresh_token: string) {
    try {
      // 使用 jwtService 验证 refreshToken
      const data = this.jwtService.verify(refresh_token);
      // 使用 userService 查询用户
      const user = await this.userService.find(data.id);
      // 使用 jwtService 生成 access_token
      const accessToken = this.jwtService.sign(
        {
          userId: user.id,
          name: user.name,
        },
        {
          expiresIn: '30m',
        },
      );

      // 使用 jwtService 生成 refresh_token
      const refreshToken = this.jwtService.sign(
        {
          userId: user.id,
        },
        {
          expiresIn: '7d',
        },
      );

      // 返回 access_token 和 refresh_token
      return {
        accessToken,
        refreshToken,
      };
    } catch (error) {
      // 如果出现错误,抛出 UnauthorizedException
      throw new UnauthorizedException('token 已失效,请重新登录');
    }
  }