0%

尚硅谷尚庭公寓-02

尚硅谷尚庭公寓-02

MinIo实现图片上传功能

  • 配置Minio Client

    • 引入Minio Maven依赖

      common模块pom.xml文件增加如下内容:

      1
      2
      3
      4
      <dependency>
      <groupId>io.minio</groupId>
      <artifactId>minio</artifactId>
      </dependency>
    • 配置Minio相关参数

      application.yml中配置Minio的endpointaccessKeysecretKeybucketName等参数

      1
      2
      3
      4
      5
      minio:
      endpoint: http://<hostname>:<port>
      access-key: <access-key>
      secret-key: <secret-key>
      bucket-name: <bucket-name>

      注意:上述<hostname><port>等信息需根据实际情况进行修改。

    • common模块中创建com.atguigu.lease.common.minio.MinioProperties,内容如下

      1
      2
      3
      4
      5
      6
      7
      8
      @ConfigurationProperties(prefix = "minio")
      @Data
      public class MinioProperties {
      private String endpoint;
      private String accessKey;
      private String secretKey;
      private String bucketName;
      }
    • common模块中创建com.atguigu.lease.common.minio.MinioConfiguration,内容如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @Configuration
      @EnableConfigurationProperties(MinioProperties.class)
      public class MinioConfiguration {

      @Autowired
      private MinioProperties properties;

      @Bean
      public MinioClient minioClient() {
      return MinioClient.builder().endpoint(properties.getEndpoint()).credentials(properties.getAccessKey(), properties.getSecretKey()).build();
      }
      }
  • 开发图片上传接口

    • 编写Controller层逻辑

      FileUploadController中增加如下内容

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      @Tag(name = "文件管理")
      @RequestMapping("/admin/file")
      @RestController
      public class FileUploadController {

      @Autowired
      private FileService service;

      @Operation(summary = "上传文件")
      @PostMapping("upload")
      public Result<String> upload(@RequestParam MultipartFile file) {

      String url = service.upload(file);
      return Result.ok(url);
      }
      }

      说明:MultipartFile是Spring框架中用于处理文件上传的类,它包含了上传文件的信息(如文件名、文件内容等)。

    • 编写Service层逻辑

      • FileService中增加如下内容

        1
        String upload(MultipartFile file);
      • FileServiceImpl中增加如下内容

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        @Autowired
        private MinioProperties properties;

        @Autowired
        private MinioClient client;

        @Override
        public String upload(MultipartFile file) {

        try {
        boolean bucketExists = client.bucketExists(BucketExistsArgs.builder().bucket(properties.getBucketName()).build());
        if (!bucketExists) {
        client.makeBucket(MakeBucketArgs.builder().bucket(properties.getBucketName()).build());
        client.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(properties.getBucketName()).config(createBucketPolicyConfig(properties.getBucketName())).build());
        }

        String filename = new SimpleDateFormat("yyyyMMdd").format(new Date()) + "/" + UUID.randomUUID() + "-" + file.getOriginalFilename();
        client.putObject(PutObjectArgs.builder().
        bucket(properties.getBucketName()).
        object(filename).
        stream(file.getInputStream(), file.getSize(), -1).
        contentType(file.getContentType()).build());

        return String.join("/", properties.getEndpoint(), properties.getBucketName(), filename);

        } catch (Exception e) {
        e.printStackTrace();
        }
        return null;
        }

        private String createBucketPolicyConfig(String bucketName) {

        return """
        {
        "Statement" : [ {
        "Action" : "s3:GetObject",
        "Effect" : "Allow",
        "Principal" : "*",
        "Resource" : "arn:aws:s3:::%s/*"
        } ],
        "Version" : "2012-10-17"
        }
        """.formatted(bucketName);
        }

        注意

        上述createBucketPolicyConfig方法的作用是生成用于描述指定bucket访问权限的JSON字符串。最终生成的字符串格式如下,其表示,允许(Allow)所有人(*)获取(s3:GetObject)指定桶(<bucket-name>)的内容。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        {
        "Statement" : [ {
        "Action" : "s3:GetObject",
        "Effect" : "Allow",
        "Principal" : "*",
        "Resource" : "arn:aws:s3:::<bucket-name>/*"
        } ],
        "Version" : "2012-10-17"
        }

        由于公寓、房间的图片为公开信息,所以将其设置为所有人可访问。

      • 异常处理

        • 问题说明

          上述代码只是对MinioClient方法抛出的各种异常进行了捕获,然后打印了异常信息,目前这种处理逻辑,无论Minio是否发生异常,前端在上传文件时,总是会受到成功的响应信息。可按照以下步骤进行操作,查看具体现象

          关闭虚拟机中的Minio服务

          1
          systemctl stop minio

          启动项目,并上传文件,观察接收的响应信息

        • 问题解决思路

          为保证前端能够接收到正常的错误提示信息,应该将Service方法的异常抛出到Controller方法中,然后在Controller方法中对异常进行捕获并处理。具体操作如下

          Service层代码

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          21
          22
          23
          24
          25
          26
          27
          28
          29
          30
          @Override
          public String upload(MultipartFile file) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException{

          boolean bucketExists = minioClient.bucketExists(
          BucketExistsArgs.builder()
          .bucket(properties.getBucketName())
          .build());
          if (!bucketExists) {
          minioClient.makeBucket(
          MakeBucketArgs.builder()
          .bucket(properties.getBucketName())
          .build());
          minioClient.setBucketPolicy(
          SetBucketPolicyArgs.builder()
          .bucket(properties.getBucketName())
          .config(createBucketPolicyConfig(properties.getBucketName()))
          .build());
          }
          String filename = new SimpleDateFormat("yyyyMMdd").format(new Date()) +
          "/" + UUID.randomUUID() + "-" + file.getOriginalFilename();
          minioClient.putObject(
          PutObjectArgs.builder()
          .bucket(properties.getBucketName())
          .stream(file.getInputStream(), file.getSize(), -1)
          .object(filename)
          .contentType(file.getContentType())
          .build());

          return String.join("/",properties.getEndpoint(),properties.getBucketName(),filename);
          }

          Controller层代码

          1
          2
          3
          4
          5
          6
          7
          8
          9
          public Result<String> upload(@RequestParam MultipartFile file) {
          try {
          String url = service.upload(file);
          return Result.ok(url);
          } catch (Exception e) {
          e.printStackTrace();
          return Result.fail();
          }
          }
        • 全局异常处理

          按照上述写法,所有的Controller层方法均需要增加try-catch逻辑,使用Spring MVC提供的全局异常处理功能,可以将所有处理异常的逻辑集中起来,进而统一处理所有异常,使代码更容易维护。

          具体用法如下,详细信息可参考官方文档

          common模块中创建com.atguigu.lease.common.exception.GlobalExceptionHandler类,内容如下

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          @ControllerAdvice
          public class GlobalExceptionHandler {

          @ExceptionHandler(Exception.class)
          @ResponseBody
          public Result error(Exception e){
          e.printStackTrace();
          return Result.fail();
          }
          }

          上述代码中的关键注解的作用如下

          @ControllerAdvice用于声明处理全局Controller方法异常的类

          @ExceptionHandler用于声明处理异常的方法,value属性用于声明该方法处理的异常类型

          @ResponseBody表示将方法的返回值作为HTTP的响应体

          注意:

          全局异常处理功能由SpringMVC提供,因此需要在common模块pom.xml中引入如下依赖

          1
          2
          3
          4
          5
          <!--spring-web-->
          <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
          </dependency>
        • 修改Controller层代码

          由于前文的GlobalExceptionHandler会处理所有Controller方法抛出的异常,因此Controller层就无序关注异常的处理逻辑了,因此Controller层代码可做出如下调整。

          1
          2
          3
          4
          5
          public Result<String> upload(@RequestParam MultipartFile file) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
          String url = service.upload(file);
          return Result.ok(url);
          }

Spring Boot定时任务

  • 启用Spring Boot定时任务

    在SpringBoot启动类上增加@EnableScheduling注解,如下

    1
    2
    3
    4
    5
    6
    7
    @SpringBootApplication
    @EnableScheduling
    public class AdminWebApplication {
    public static void main(String[] args) {
    SpringApplication.run(AdminWebApplication.class, args);
    }
    }
  • 编写定时逻辑

    web-admin模块下创建com.atguigu.lease.web.admin.schedule.ScheduledTasks类,内容如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Component
    public class ScheduledTasks {

    @Autowired
    private LeaseAgreementService leaseAgreementService;

    @Scheduled(cron = "0 0 0 * * *")
    public void checkLeaseStatus() {
    LambdaUpdateWrapper<LeaseAgreement> updateWrapper = new LambdaUpdateWrapper<>();
    Date now = new Date();
    updateWrapper.le(LeaseAgreement::getLeaseEndDate, now);
    updateWrapper.eq(LeaseAgreement::getStatus, LeaseStatus.SIGNED);
    updateWrapper.in(LeaseAgreement::getStatus, LeaseStatus.SIGNED, LeaseStatus.WITHDRAWING);
    leaseAgreementService.update(updateWrapper);
    }
    }

    知识点:

    SpringBoot中的cron表达式语法如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ┌───────────── second (0-59)
    │ ┌───────────── minute (0 - 59)
    │ │ ┌───────────── hour (0 - 23)
    │ │ │ ┌───────────── day of the month (1 - 31)
    │ │ │ │ ┌───────────── month (1 - 12) (or JAN-DEC)
    │ │ │ │ │ ┌───────────── day of the week (0 - 7)
    │ │ │ │ │ │ (0 or 7 is Sunday, or MON-SUN)
    │ │ │ │ │ │
    * * * * * *

密码加密处理

用户的密码通常不会直接以明文的形式保存到数据库中,而是会先经过处理,然后将处理之后得到的”密文”保存到数据库,这样能够降低数据库泄漏导致的用户账号安全问题。
密码通常会使用一些单向函数进行处理,如下图所示

常用于处理密码的单向函数(算法)有MD5、SHA-256等,Apache Commons提供了一个工具类DigestUtils,其中就包含上述算法的实现。

Apache Commons是Apache软件基金会下的一个项目,其致力于提供可重用的开源软件,其中包含了很多易于使用的现成工具。
使用该工具类需引入commons-codec依赖,在common模块的pom.xml中增加如下内容

1
2
3
4
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>

Mybatis-Plus update strategy

使用Mybatis-Plus提供的更新方法时,若实体中的字段为null,默认情况下,最终生成的update语句中,不会包含该字段。若想改变默认行为,可做以下配置。

  • 全局配置
    application.yml中配置如下参数

    1
    2
    3
    4
    mybatis-plus:
    global-config:
    db-config:
    update-strategy: <strategy>

    :上述<strategy>可选值有:ignorenot_nullnot_emptynever,默认值为not_null

    • ignore:忽略空值判断,不管字段是否为空,都会进行更新

    • not_null:进行非空判断,字段非空才会进行判断

    • not_empty:进行非空判断,并进行非空串(””)判断,主要针对字符串类型

    • never:从不进行更新,不管该字段为何值,都不更新

  • 局部配置
    在实体类中的具体字段通过@TableField注解进行配置,如下:

    1
    2
    3
    @Schema(description = "密码")
    @TableField(value = "password", updateStrategy = FieldStrategy.NOT_EMPTY)
    private String password;

登录功能的实现

背景知识

1. 认证方案概述

有两种常见的认证方案,分别是基于Session的认证和基于Token的认证,下面逐一进行介绍

  • 基于Session

    基于Session的认证流程如下图所示

    该方案的特点

    • 登录用户信息保存在服务端内存中,若访问量增加,单台节点压力会较大
    • 随用户规模增大,若后台升级为集群,则需要解决集群中各服务器登录状态共享的问题。
  • 基于Token

    基于Token的认证流程如下图所示

    该方案的特点

    • 登录状态保存在客户端,服务器没有存储开销
    • 客户端发起的每个请求自身均携带登录状态,所以即使后台为集群,也不会面临登录状态共享的问题。

2. Token详解

本项目采用基于Token的登录方案,下面详细介绍Token这一概念。
我们所说的Token,通常指JWT(JSON Web TOKEN)。JWT是一种轻量级的安全传输方式,用于在两个实体之间传递信息,通常用于身份验证和信息传递。
JWT是一个字符串,如下图所示,该字符串由三部分组成,三部分由.分隔。三个部分分别被称为

  • header(头部)
  • payload(负载)
  • signature(签名)

各部分的作用如下

  • Header(头部)

    Header部分是由一个JSON对象经过base64url编码得到的,这个JSON对象用于保存JWT 的类型(typ)、签名算法(alg)等元信息,例如

    1
    2
    3
    4
    {
    "alg": "HS256",
    "typ": "JWT"
    }
  • Payload(负载)

    也称为 Claims(声明),也是由一个JSON对象经过base64url编码得到的,用于保存要传递的具体信息。JWT规范定义了7个官方字段,如下:

    • iss (issuer):签发人
    • exp (expiration time):过期时间
    • sub (subject):主题
    • aud (audience):受众
    • nbf (Not Before):生效时间
    • iat (Issued At):签发时间
    • jti (JWT ID):编号

    除此之外,我们还可以自定义任何字段,例如

    1
    2
    3
    4
    5
    {
    "sub": "1234567890",
    "name": "John Doe",
    "iat": 1516239022
    }
  • Signature(签名)

    由头部、负载和秘钥一起经过(header中指定的签名算法)计算得到的一个字符串,用于防止消息被篡改。

登录流程

后台管理系统的登录流程如下图所示

根据上述登录流程,可分析出,登录管理共需三个接口,分别是获取图形验证码登录获取登录用户个人信息,除此之外,我们还需为所有受保护的接口增加验证JWT合法性的逻辑,这一功能可通过HandlerInterceptor来实现。

接口开发

首先在LoginController中注入LoginService,如下

1
2
3
4
5
6
7
8
@Tag(name = "后台管理系统登录管理")
@RestController
@RequestMapping("/admin")
public class LoginController {

@Autowired
private LoginService service;
}

1. 获取图形验证码

  • 查看响应的数据结构

    查看web-admin模块下的com.atguigu.lease.web.admin.vo.login.CaptchaVo,内容如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Data
    @Schema(description = "图像验证码")
    @AllArgsConstructor
    public class CaptchaVo {

    @Schema(description="验证码图片信息")
    private String image;

    @Schema(description="验证码key")
    private String key;
    }
  • 配置所需依赖

    • 验证码生成工具

      本项目使用开源的验证码生成工具EasyCaptcha,其支持多种类型的验证码,例如gif、中文、算术等,并且简单易用,具体内容可参考其官方文档

      common模块的pom.xml文件中增加如下内容

      1
      2
      3
      4
      <dependency>
      <groupId>com.github.whvcse</groupId>
      <artifactId>easy-captcha</artifactId>
      </dependency>
    • Redis

      common模块的pom.xml中增加如下内容

      1
      2
      3
      4
      <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>

      application.yml中增加如下配置

      1
      2
      3
      4
      5
      6
      spring:
      data:
      redis:
      host: <hostname>
      port: <port>
      database: 0

      注意:上述hostnameport需根据实际情况进行修改

  • 编写Controller层逻辑

    LoginController中增加如下内容

    1
    2
    3
    4
    5
    6
    @Operation(summary = "获取图形验证码")
    @GetMapping("login/captcha")
    public Result<CaptchaVo> getCaptcha() {
    CaptchaVo captcha = service.getCaptcha();
    return Result.ok(captcha);
    }
  • 编写Service层逻辑

    • LoginService中增加如下内容

      1
      CaptchaVo getCaptcha();
    • LoginServiceImpl中增加如下内容

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @Autowired
      private StringRedisTemplate redisTemplate;

      @Override
      public CaptchaVo getCaptcha() {
      SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
      specCaptcha.setCharType(Captcha.TYPE_DEFAULT);

      String code = specCaptcha.text().toLowerCase();
      String key = RedisConstant.ADMIN_LOGIN_PREFIX + UUID.randomUUID();
      String image = specCaptcha.toBase64();
      redisTemplate.opsForValue().set(key, code, RedisConstant.ADMIN_LOGIN_CAPTCHA_TTL_SEC, TimeUnit.SECONDS);

      return new CaptchaVo(image, key);
      }

      知识点

      • 本项目Reids中的key需遵循以下命名规范:项目名:功能模块名:其他,例如admin:login:123456

      • spring-boot-starter-data-redis已经完成了StringRedisTemplate的自动配置,我们直接注入即可。

      • 为方便管理,可以将Reids相关的一些值定义为常量,例如key的前缀、TTL时长,内容如下。大家可将这些常量统一定义在common模块下的com.atguigu.lease.common.constant.RedisConstant类中

        1
        2
        3
        4
        5
        6
        7
        8
        public class RedisConstant {
        public static final String ADMIN_LOGIN_PREFIX = "admin:login:";
        public static final Integer ADMIN_LOGIN_CAPTCHA_TTL_SEC = 60;
        public static final String APP_LOGIN_PREFIX = "app:login:";
        public static final Integer APP_LOGIN_CODE_RESEND_TIME_SEC = 60;
        public static final Integer APP_LOGIN_CODE_TTL_SEC = 60 * 10;
        public static final String APP_ROOM_PREFIX = "app:room:";
        }

2. 登录接口

  • 登录校验逻辑

    用户登录的校验逻辑分为三个主要步骤,分别是校验验证码校验用户状态校验密码,具体逻辑如下

    • 前端发送usernamepasswordcaptchaKeycaptchaCode请求登录。
    • 判断captchaCode是否为空,若为空,则直接响应验证码为空;若不为空进行下一步判断。
    • 根据captchaKey从Redis中查询之前保存的code,若查询出来的code为空,则直接响应验证码已过期;若不为空进行下一步判断。
    • 比较captchaCodecode,若不相同,则直接响应验证码不正确;若相同则进行下一步判断。
    • 根据username查询数据库,若查询结果为空,则直接响应账号不存在;若不为空则进行下一步判断。
    • 查看用户状态,判断是否被禁用,若禁用,则直接响应账号被禁;若未被禁用,则进行下一步判断。
    • 比对password和数据库中查询的密码,若不一致,则直接响应账号或密码错误,若一致则进行入最后一步。
    • 创建JWT,并响应给浏览器。
  • 接口逻辑实现

    • 查看请求数据结构

      查看web-admin模块下的com.atguigu.lease.web.admin.vo.login.LoginVo,具体内容如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      @Data
      @Schema(description = "后台管理系统登录信息")
      public class LoginVo {

      @Schema(description="用户名")
      private String username;

      @Schema(description="密码")
      private String password;

      @Schema(description="验证码key")
      private String captchaKey;

      @Schema(description="验证码code")
      private String captchaCode;
      }
    • 配置所需依赖

      登录接口需要为登录成功的用户创建并返回JWT,本项目使用开源的JWT工具Java-JWT,配置如下,具体内容可参考官方文档

      • 引入Maven依赖

        common模块的pom.xml文件中增加如下内容

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        </dependency>

        <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <scope>runtime</scope>
        </dependency>

        <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <scope>runtime</scope>
        </dependency>
      • 创建JWT工具类

        common模块下创建com.atguigu.lease.common.utils.JwtUtil工具类,内容如下

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        public class JwtUtil {

        private static long tokenExpiration = 60 * 60 * 1000L;
        private static SecretKey tokenSignKey = Keys.hmacShaKeyFor("M0PKKI6pYGVWWfDZw90a0lTpGYX1d4AQ".getBytes());

        public static String createToken(Long userId, String username) {
        String token = Jwts.builder().
        setSubject("USER_INFO").
        setExpiration(new Date(System.currentTimeMillis() + tokenExpiration)).
        claim("userId", userId).
        claim("username", username).
        signWith(tokenSignKey).
        compact();
        return token;
        }
        }
    • 编写Controller层逻辑

      LoginController中增加如下内容

      1
      2
      3
      4
      5
      6
      @Operation(summary = "登录")
      @PostMapping("login")
      public Result<String> login(@RequestBody LoginVo loginVo) {
      String token = service.login(loginVo);
      return Result.ok(token);
      }
    • 编写Service层逻辑

      • LoginService中增加如下内容

        1
        String login(LoginVo loginVo);
      • LoginServiceImpl中增加如下内容

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        @Override
        public String login(LoginVo loginVo) {
        //1.判断是否输入了验证码
        if (!StringUtils.hasText(loginVo.getCaptchaCode())) {
        throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_NOT_FOUND);
        }

        //2.校验验证码
        String code = redisTemplate.opsForValue().get(loginVo.getCaptchaKey());
        if (code == null) {
        throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_EXPIRED);
        }

        if (!code.equals(loginVo.getCaptchaCode().toLowerCase())) {
        throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_ERROR);
        }

        //3.校验用户是否存在
        SystemUser systemUser = systemUserMapper.selectOneByUsername(loginVo.getUsername());

        if (systemUser == null) {
        throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_NOT_EXIST_ERROR);
        }

        //4.校验用户是否被禁
        if (systemUser.getStatus() == BaseStatus.DISABLE) {
        throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_DISABLED_ERROR);
        }

        //5.校验用户密码
        if (!systemUser.getPassword().equals(DigestUtils.md5Hex(loginVo.getPassword()))) {
        throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_ERROR);
        }

        //6.创建并返回TOKEN
        return JwtUtil.createToken(systemUser.getId(), systemUser.getUsername());
        }
    • 编写Mapper层逻辑

      • LoginMapper中增加如下内容

        1
        SystemUser selectOneByUsername(String username);
      • LoginMapper.xml中增加如下内容

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        <select id="selectOneByUsername" resultType="com.atguigu.lease.model.entity.SystemUser">
        select id,
        username,
        password,
        name,
        type,
        phone,
        avatar_url,
        additional_info,
        post_id,
        status
        from system_user
        where is_deleted = 0
        and username = #{username}
        </select>
    • 编写HandlerInterceptor

      我们需要为所有受保护的接口增加校验JWT合法性的逻辑。具体实现如下

      • JwtUtil中增加parseToken方法,内容如下

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        public static Claims parseToken(String token){

        if (token==null){
        throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);
        }

        try{
        JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(secretKey).build();
        return jwtParser.parseClaimsJws(token).getBody();
        }catch (ExpiredJwtException e){
        throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED);
        }catch (JwtException e){
        throw new LeaseException(ResultCodeEnum.TOKEN_INVALID);
        }
        }
      • 编写HandlerInterceptor

        web-admin模块中创建com.atguigu.lease.web.admin.custom.interceptor.AuthenticationInterceptor类,内容如下,有关HanderInterceptor的相关内容,可参考官方文档

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        @Component
        public class AuthenticationInterceptor implements HandlerInterceptor {

        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("access-token");
        JwtUtil.parseToken(token);
        return true;
        }
        }

        注意

        我们约定,前端登录后,后续请求都将JWT,放置于HTTP请求的Header中,其Header的key为access-token

      • 注册HandlerInterceptor

        web-admin模块com.atguigu.lease.web.admin.custom.config.WebMvcConfiguration中增加如下内容

        1
        2
        3
        4
        5
        6
        7
        @Autowired
        private AuthenticationInterceptor authenticationInterceptor;

        @Override
        public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(this.authenticationInterceptor).addPathPatterns("/admin/**").excludePathPatterns("/admin/login/**");
        }
    • Knife4j配置

      在增加上述拦截器后,为方便继续调试其他接口,可以获取一个长期有效的Token,将其配置到Knife4j的全局参数中,如下图所示。

      注意:每个接口分组需要单独配置

      刷新页面,任选一个接口进行调试,会发现发送请求时会自动携带该header,如下图所示

3.获取登录用户个人信息

  • 查看请求和响应的数据结构

    • 响应的数据结构

      查看web-admin模块下的com.atguigu.lease.web.admin.vo.system.user.SystemUserInfoVo,内容如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      @Schema(description = "员工基本信息")
      @Data
      public class SystemUserInfoVo {

      @Schema(description = "用户姓名")
      private String name;

      @Schema(description = "用户头像")
      private String avatarUrl;
      }
    • 请求的数据结构

      按理说,前端若想获取当前登录用户的个人信息,需要传递当前用户的id到后端进行查询。但是由于请求中携带的JWT中就包含了当前登录用户的id,故请求个人信息时,就无需再传递id

  • 修改JwtUtil中的parseToken方法

    由于需要从Jwt中获取用户id,因此需要为parseToken 方法增加返回值,如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public static Claims parseToken(String token){

    if (token==null){
    throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);
    }

    try{
    JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(secretKey).build();
    return jwtParser.parseClaimsJws(token).getBody();
    }catch (ExpiredJwtException e){
    throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED);
    }catch (JwtException e){
    throw new LeaseException(ResultCodeEnum.TOKEN_INVALID);
    }
    }
  • 编写ThreadLocal工具类

    理论上我们可以在Controller方法中,使用@RequestHeader获取JWT,然后在进行解析,如下

    1
    2
    3
    4
    5
    6
    7
    8
    @Operation(summary = "获取登陆用户个人信息")
    @GetMapping("info")
    public Result<SystemUserInfoVo> info(@RequestHeader("access-token") String token) {
    Claims claims = JwtUtil.parseToken(token);
    Long userId = claims.get("userId", Long.class);
    SystemUserInfoVo userInfo = service.getLoginUserInfo(userId);
    return Result.ok(userInfo);
    }

    上述代码的逻辑没有任何问题,但是这样做,JWT会被重复解析两次(一次在拦截器中,一次在该方法中)。为避免重复解析,通常会在拦截器将Token解析完毕后,将结果保存至ThreadLocal中,这样一来,我们便可以在整个请求的处理流程中进行访问了。

    ThreadLocal概述

    ThreadLocal的主要作用是为每个使用它的线程提供一个独立的变量副本,使每个线程都可以操作自己的变量,而不会互相干扰,其用法如下图所示。

    common模块中创建com.atguigu.lease.common.login.LoginUserHolder工具类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class LoginUserHolder {
    public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();

    public static void setLoginUser(LoginUser loginUser) {
    threadLocal.set(loginUser);
    }

    public static LoginUser getLoginUser() {
    return threadLocal.get();
    }

    public static void clear() {
    threadLocal.remove();
    }
    }

    同时在common模块中创建com.atguigu.lease.common.login.LoginUser

    1
    2
    3
    4
    5
    6
    7
    @Data
    @AllArgsConstructor
    public class LoginUser {

    private Long userId;
    private String username;
    }
  • 修改AuthenticationInterceptor拦截器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Component
    public class AuthenticationInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

    String token = request.getHeader("access-token");

    Claims claims = JwtUtil.parseToken(token);
    Long userId = claims.get("userId", Long.class);
    String username = claims.get("username", String.class);
    LoginUserHolder.setLoginUser(new LoginUser(userId, username));

    return true;

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    LoginUserHolder.clear();
    }
    }
  • 编写Controller层逻辑

    LoginController中增加如下内容

    1
    2
    3
    4
    5
    6
    @Operation(summary = "获取登陆用户个人信息")
    @GetMapping("info")
    public Result<SystemUserInfoVo> info() {
    SystemUserInfoVo userInfo = service.getLoginUserInfo(LoginUserHolder.getLoginUser().getUserId());
    return Result.ok(userInfo);
    }
  • 编写Service层逻辑

    LoginService中增加如下内容

    ```java
    @Override
    public SystemUserInfoVo getLoginUserInfo(Long userId) {

      SystemUser systemUser = systemUserMapper.selectById(userId);
      SystemUserInfoVo systemUserInfoVo = new SystemUserInfoVo();
      systemUserInfoVo.setName(systemUser.getName());
      systemUserInfoVo.setAvatarUrl(systemUser.getAvatarUrl());
      return systemUserInfoVo;
    

    }