基础项目总结

MyBatis-plus

设置逻辑删除

由于数据库中所有表均采用逻辑删除策略,所以查询数据时均需要增加过滤条件is_deleted=0

上述操作虽不难实现,但是每个查询接口都要考虑到,也显得有些繁琐。为简化上述操作,可以使用Mybatis-Plus提供的逻辑删除功能,它可以自动为查询操作增加is_deleted=0过滤条件,并将删除操作转为更新语句。具体配置如下,详细信息可参考官方文档

  • 步骤一:在application.yml中增加如下内容

    mybatis-plus:
      global-config:
        db-config:
          logic-delete-field: isDeleted # 全局逻辑删除的实体字段名(配置后可以忽略不配置步骤二)
          logic-delete-value: 1 # 逻辑已删除值(默认为 1)
          logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
    
  • 步骤二:在实体类中的删除标识字段上增加@TableLogic注解

    @Data
    public class BaseEntity {
    
        @Schema(description = "主键")
        @TableId(value = "id", type = IdType.AUTO)
        private Long id;
    
        @Schema(description = "创建时间")
        @JsonIgnore
        private Date createTime;
    
        @Schema(description = "更新时间")
        @JsonIgnore
        private Date updateTime;
    
        @Schema(description = "逻辑删除")
        @JsonIgnore  // 该字段不参与序列化
        @TableLogic  // 逻辑删除字段
        @TableField("is_deleted")
        private Byte isDeleted;
    }
    

注意

逻辑删除功能只对Mybatis-Plus自动注入的sql起效,也就是说,对于手动在Mapper.xml文件配置的sql不会生效,需要单独考虑。

忽略特定字段

通常情况下接口响应的Json对象中并不需要create_timeupdate_timeis_deleted等字段,这时只需在实体类中的相应字段添加@JsonIgnore注解,该字段就会在序列化时被忽略。

具体配置如下,详细信息可参考Jackson官方文档

@Data
public class BaseEntity {

    @Schema(description = "主键")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @Schema(description = "创建时间")
    @JsonIgnore
    @TableField(value = "create_time")
    private Date createTime;

    @Schema(description = "更新时间")
    @JsonIgnore
    @TableField(value = "update_time")
    private Date updateTime;

    @Schema(description = "逻辑删除")
    @JsonIgnore
    @TableField("is_deleted")
    private Byte isDeleted;

}

字段自动填充

保存或更新数据时,前端通常不会传入isDeletedcreateTimeupdateTime这三个字段,因此我们需要手动赋值。但是数据库中每张表都有上述字段,所以手动去赋值就显得有些繁琐。为简化上述操作,我们可采取以下措施。

  • is_deleted字段:可将数据库中该字段的默认值设置为0。

  • create_timeupdate_time:可使用mybatis-plus的自动填充功能,所谓自动填充,就是通过统一配置,在插入或更新数据时,自动为某些字段赋值,具体配置如下,详细信息可参考官方文档

    • 为相关字段配置触发填充的时机,例如create_time需要在插入数据时填充,而update_time需要在更新数据时填充。具体配置如下,观察@TableField注解中的fill属性。

      @Data
      public class BaseEntity {
      
          @Schema(description = "主键")
          @TableId(value = "id", type = IdType.AUTO)
          private Long id;
      
          @Schema(description = "创建时间")
          @JsonIgnore
          @TableField(value = "create_time", fill = FieldFill.INSERT)
          private Date createTime;
      
          @Schema(description = "更新时间")
          @JsonIgnore
          @TableField(value = "update_time", fill = FieldFill.UPDATE)
          private Date updateTime;
      
          @Schema(description = "逻辑删除")
          @JsonIgnore
          @TableLogic
          @TableField("is_deleted")
          private Byte isDeleted;
      
      }
      
    • 配置自动填充的内容,具体配置如下

      common模块下创建com.atguigu.lease.common.mybatisplus.MybatisMetaObjectHandler类,内容如下:

      @Component
      public class MybatisMetaObjectHandler implements MetaObjectHandler {
          @Override
          public void insertFill(MetaObject metaObject) {
              this.strictInsertFill(metaObject, "createTime", Date.class, new Date());
              this.strictInsertFill(metaObject, "isDeleted", Byte.class, (byte)(0));
          }
      
          @Override
          public void updateFill(MetaObject metaObject) {
              this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
          }
      }
      

    在做完上述配置后,当写入数据时,Mybatis-Plus会自动将实体对象的create_time字段填充为当前时间,当更新数据时,则会自动将实体对象的update_time字段填充为当前时间。

配置转换器

示例:

@Operation(summary = "(根据类型)查询标签列表")
@GetMapping("list")
public Result<List<LabelInfo>> labelList(@RequestParam(required = false) ItemType type) {

    LambdaQueryWrapper<LabelInfo> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(type != null, LabelInfo::getType, type);
    List<LabelInfo> list = service.list(queryWrapper);
    return Result.ok(list);
}

出现问题:上述接口的功能是根据type(公寓/房间),查询标签列表。由于这个type字段在数据库,实体类、前后端交互的过程中有多种不同的形式,因此在请求和响应的过程中,type字段会涉及到多吃类型转换。

解决方案:

public interface BaseEnum {

    Integer getCode();

    String getName();
}

@Component
public class StringToBaseEnumConverterFactory implements ConverterFactory<String, BaseEnum> {
    @Override
    public <T extends BaseEnum> Converter<String, T> getConverter(Class<T> targetType) {
        return new Converter<String, T>() {
            @Override
            public T convert(String source) {

                for (T enumConstant : targetType.getEnumConstants()) {
                    if (enumConstant.getCode().equals(Integer.valueOf(source))) {
                        return enumConstant;
                    }
                }
                throw new IllegalArgumentException("非法的枚举值:" + source);
            }
        };
    }
}

注册上述的转换器

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    @Autowired
    private StringToBaseEnumConverterFactory stringToBaseEnumConverterFactory;

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverterFactory(this.stringToBaseEnumConverterFactory);
    }
}

Mybatis-plus提供了一个通用的处理枚举类类型的TypeHandler,其使用十分简单,只需在ItemType枚举类的code属性上增加一个注解@EnumValue,Mybatis-Plus便可完成从ItemType对象到code属性之间的相互映射

配置分页

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

    /**
     * 新的分页插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

实现方案

  • 基本使用方式

    // 创建分页参数
    Page<User> page = new Page<>(1, 10); // 当前页,每页大小
    
    // 执行分页查询
    Page<User> result = userMapper.selectPage(page, null);
    
    // 获取分页数据
    List<User> records = result.getRecords(); // 当前页数据
    long total = result.getTotal();          // 总记录数
    long pages = result.getPages();          // 总页数
    
  • 带条件的分页查询

    // 创建分页参数
    Page<User> page = new Page<>(1, 10);
    
    // 创建查询条件
    LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
    wrapper.like(User::getName, "张")
           .ge(User::getAge, 18);
    
    // 执行分页查询
    Page<User> result = userMapper.selectPage(page, wrapper);
    
  • 自定义sql分页查询

    Mapper接口方法

    @Mapper
    public interface UserMapper extends BaseMapper<User> {
        // 自定义分页查询
        IPage<User> selectUserPage(IPage<User> page, @Param("state") Integer state);
    }
    

    XML 映射文件:

    <select id="selectUserPage" resultType="com.example.entity.User">
        SELECT * FROM user WHERE state = #{state}
    </select>
    

    service调用:

    Page<User> page = new Page<>(1, 10);
    IPage<User> userPage = userMapper.selectUserPage(page, 1);
    

文件上传

图片上传

参考文档:

https://www.cnblogs.com/gctao/p/18634097

  1. 配置Minio Client

    • 引入依赖

      <dependency>
          <groupId>io.minio</groupId>
          <artifactId>minio</artifactId>
      </dependency>
      
    • xml配置Minio相关参数

      minio:
        endpoint: http://<hostname>:<port>
        access-key: <access-key>
        secret-key: <secret-key>
        bucket-name: <bucket-name>
      
    • 创建相关参数实体类

      @ConfigurationProperties(prefix = "minio")
      @Data
      public class MinioProperties {
      
          private String endpoint;
      
          private String accessKey;
      
          private String secretKey;
          
          private String bucketName;
      }
      
    • 创建配置类

      @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();
          }
      }
      
  2. 开发图片上传接口

    • 编写controller层逻辑

      FileUploadController中增加如下内容

      @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中增加如下内容

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

        @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>)的内容。

        {
          "Statement" : [ {
            "Action" : "s3:GetObject",
            "Effect" : "Allow",
            "Principal" : "*",
            "Resource" : "arn:aws:s3:::<bucket-name>/*"
          } ],
          "Version" : "2012-10-17"
        }
        

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

  3. 异常处理

    • 问题说明:

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

      关闭虚拟机中的Minio服务

      systemctl stop minio
      

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

    • 问题解决思路

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

        Service层代码

        @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层代码

        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();
            }
        }
        
  4. 全局异常处理

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

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

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

      @ControllerAdvice
      public class GlobalExceptionHandler {
      
          @ExceptionHandler(LeaseException.class)
          @ResponseBody
          public Result error(LeaseException e){
              e.printStackTrace();
              return Result.fail(e.getCode(), e.getMessage());
          }
      }
      

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

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

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

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

      注意:

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

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

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

        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);
        }
        
  5. 自定义异常

    @Data
    public class LeaseException extends RuntimeException {
    
        //异常状态码
        private Integer code;
        /**
         * 通过状态码和错误消息创建异常对象
         * @param message
         * @param code
         */
        public LeaseException(String message, Integer code) {
            super(message);
            this.code = code;
        }
    
        /**
         * 根据响应结果枚举对象创建异常对象
         * @param resultCodeEnum
         */
        public LeaseException(ResultCodeEnum resultCodeEnum) {
            super(resultCodeEnum.getMessage());
            this.code = resultCodeEnum.getCode();
        }
    
        @Override
        public String toString() {
            return "LeaseException{" +
                    "code=" + code +
                    ", message=" + this.getMessage() +
                    '}';
        }
    }
    

密码处理

用户的密码通常不会直接以明文的形式保存到数据库中,而是会先经过处理,然后将处理之后得到的'密文'保存到数据库,这样能够降低数据库泄露导致的用户账号安全问题。

密码通常会使用一些简单单向函数处理,常用于处理密码的单向函数(算法)有MD5,SHA-256等,

  • Apache Commons提供了一个工具类DigestUtils,其中就包含上述算法的实现。

    Apache Commons是Apache软件基金会下的一个项目,其致力于提供可重用的开源软件,其中包含了很多易于使用的现成工具。

    使用该工具类需引入commons-codec依赖,在common模块的pom.xml中增加如下内容

    <dependency>
      <groupId>commons-codec</groupId>
      <artifactId>commons-codec</artifactId>
    </dependency>
    
  • 编码

    @Operation(summary = "保存或更新后台用户信息")
    @PostMapping("saveOrUpdate")
    public Result saveOrUpdate(@RequestBody SystemUser systemUser) {
        if(systemUser.getPassword() != null){
            //将密码的明文加密变成密文
            systemUser.setPassword(DigestUtils.md5Hex(systemUser.getPassword()));
        }
        service.saveOrUpdate(systemUser);
        return Result.ok();
    }
    

登录管理

有两种常见的认证方案,分别基于session的认证和基于token的认证:

  • 基于session的认证

    该方案的特点:

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

    该方案的特点

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

token详解

我们所说的Token,通常指JWT(JSON Web TOKEN)。JWT是一种轻量级的安全传输方式,用于在两个实体之间传递信息,通常用于身份验证和信息传递。

JWT是一个字符串,如下图所示,该字符串由三部分组成,三部分由.分隔。三个部分分别被称为

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

各部分的作用如下

  • Header(头部)

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

    {
      "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):编号

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

    {
      "sub": "1234567890",
      "name": "John Doe",
      "iat": 1516239022
    }
    
  • Signature(签名)

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

登录流程

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

  • 验证码生成工具

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

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

    <dependency>
        <groupId>com.github.whvcse</groupId>
        <artifactId>easy-captcha</artifactId>
    </dependency>
    
  • 获取图形验证码

    @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);
    }
    
  • 登录接口

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

      • 引入Maven依赖

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

        <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工具类,内容如下

        public class JwtUtil {
        
            private static long tokenExpiration = 60 * 60 * 1000L;
            private static SecretKey tokenSignKey = Keys.hmacShaKeyFor("M0PKKI6pYGVWWfDZw90a0lTpGYX1d4AQ".getBytes());
            // key 需要有32位
        
            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).
                        compressWith(CompressionCodecs.GZIP).
                        compact();
                return token;
            }
        
            public static Claims parseToken(String token) {
                try {
                    Jws<Claims> claimsJws = Jwts.parserBuilder().
                            setSigningKey(tokenSignKey).
                            build().parseClaimsJws(token);
                    return claimsJws.getBody();
        
                } catch (ExpiredJwtException e) {
                    throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED);
                } catch (JwtException e) {
                    throw new LeaseException(ResultCodeEnum.TOKEN_INVALID);
                }
            }
        }
        
    • 编写Controller层逻辑

      LoginController中增加如下内容

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

      • LoginService中增加如下内容

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

        @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.校验用户是否存在
            LambdaQueryWrapper<SystemUser> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(SystemUser::getUsername, loginVo.getUsername());
            SystemUser systemUser = systemUserMapper.selectOne(queryWrapper);
            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.sha256Hex(loginVo.getPassword()))) {
                throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_ERROR);
            }
        
            //6.创建并返回TOKEN
            return JwtUtil.createToken(systemUser.getId(), systemUser.getUsername());
        }
        
    • 编写HandlerInterceptor

      我们需要为所有受保护的接口增加校验JWT合法性的逻辑,否则,登录功能将没有任何意义。具体实现如下,有关HanderInterceptor的相关内容,可参考官方文档

      • 编写HandlerInterceptor

        web-admin模块中创建com.atguigu.lease.web.admin.custom.interceptor.AuthenticationInterceptor类,内容如下

        @Component
        public class AuthenticationInterceptor implements HandlerInterceptor {
        
            @Override
            public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
                String token = request.getHeader("access_token");
        
                if (token == null) {
                    throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);
                } else {
                    JwtUtil.parseToken(token);
                }
                return true;
            }
        }
        

        注意

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

      • 注册HandlerInterceptor

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

        @Autowired
        private AuthenticationInterceptor authenticationInterceptor;
        
        @Value("${admin.auth.path-patterns.include}")
        private String[] includePathPatterns;
        
        @Value("${admin.auth.path-patterns.exclude}")
        private String[] excludePathPatterns;
        
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(this.authenticationInterceptor).addPathPatterns(includePathPatterns).excludePathPatterns(excludePathPatterns);
        }
        
      • application.yml中增加拦截器匹配和排除的接口路径,如下

        admin:
          auth:
            path-patterns:
              include: /admin/**
              exclude: /admin/login/**
        

过滤器与拦截器

  1. 过滤器与拦截器触发时机不一样,过滤器是在请求进入容器后,但请求进入servlet之前进行预处理的。请求结束返回也是,是在Servlet处理完成后,返回给前端之前。
  2. 拦截器可以获取IOC容器中的各个bean,而过滤器就不行,因为拦截器是spring提供并管理的,spring的功能可以被拦截器使用,在拦截器里注入一个service,可以调用业务逻辑。而过滤器是JavaEE标准,只需依赖servlet api ,不需要依赖spring。
  3. 过滤器的实现基于回调函数。而拦截器(代理模式)的实现基于反射
  4. Filter是依赖于Servlet容器,属于Servlet规范的一部分,而拦截器则是独立存在的,可以在任何情况下使用。
  5. Filter的执行由Servlet容器回调完成,而拦截器通常通过动态代理(反射)的方式来执行。
  6. Filter的生命周由Servlet容器管理,而拦截器则可以通过IoC容器来管理,因此可以通过注入等方式来获取其他Bean的实例,因此使用会更方便。

过滤器与拦截器非常相似,但是它们有很大的区别:

最简单明了的区别就是过滤器可以修改request,而拦截器不能
过滤器需要在servlet容器中实现,拦截器可以适用于javaEE,javaSE等各种环境
拦截器可以调用IOC容器中的各种依赖,而过滤器不能
过滤器只能在请求的前后使用,而拦截器可以详细到每个方法

总体来说:

过滤器就是筛选出你要的东西,比如requeset中你要的那部分

拦截器在做安全方面用的比较多,比如终止一些流程

过滤器(Filter) :可以拿到原始的http请求,但是拿不到你请求的控制器和请求控制器中的方法的信息。

拦截器(Interceptor):可以拿到你请求的控制器和方法,却拿不到请求方法的参数。

切片(Aspect): 可以拿到方法的参数,但是却拿不到http请求和响应的对象

过滤器

两种方式:
1、使用spring boot提供的FilterRegistrationBean注册Filter
2、使用原生servlet注解定义Filter
两种方式的本质都是一样的,都是去FilterRegistrationBean注册自定义Filter

方式一: (使用spring boot提供的FilterRegistrationBean注册Filter )

  1. 先定义Filter:

    package com.corwien.filter;
    import javax.servlet.*;
    import java.io.IOException;
    public class MyFilter implements Filter {
        @Override public void init(FilterConfig filterConfig) throws ServletException {
    
        }
        @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { // do something 处理request 或response
         // doFilter()方法中的servletRequest参数的类型是ServletRequest,需要转换为HttpServletRequest类型方便调用某些方法
          System.out.println("filter1"); // 调用filter链中的下一个filter
    
       HttpServletRequest request = (HttpServletRequest) servletRequest;
            HttpServletResponse response = (HttpServletResponse) servletResponse;
    
            String ip = request.getRemoteAddr();
            String url = request.getRequestURL().toString();
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            Date d = new Date();
            String date = sdf.format(d);
    
            System.out.printf("%s %s 访问了 %s%n", date, ip, url);
    
            filterChain.doFilter(request, response);
        }
        @Override public void destroy() {
    
        }
    }
    
  2. 注册定义Filter

    @Configuration
    public class FilterConfig {
    
        @Bean
        public FilterRegistrationBean registrationBean() {
           ** FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(new** **MyFilter());**
            filterRegistrationBean.addUrlPatterns("/*");
            return filterRegistrationBean;
        }
    }
    
  3. 方式一的1,2步骤可以用下面这段代码代替

    @Configuration public class FilterConfig {
    
        @Bean public **FilterRegistrationBean** registFilter() {
            **FilterRegistrationBean registration** **= new FilterRegistrationBean();
            registration.setFilter(new** **LogCostFilter());**
            registration.addUrlPatterns("/*");
            registration.setName("LogCostFilter");
            registration.setOrder(1); return registration;
        }
    
    }
    public class LogCostFilter implements Filter {
        @Override public void init(FilterConfig filterConfig) throws ServletException {
    
        }
    
        @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { long start = System.currentTimeMillis();
            filterChain.doFilter(servletRequest,servletResponse);
            System.out.println("Execute cost="+(System.currentTimeMillis()-start));
        }
    
        @Override public void destroy() {
    
        }
    

拦截器配置

实现拦截器可以通过继承 HandlerInterceptorAdapter类也可以通过实现HandlerInterceptor这个接口。另外,如果preHandle方法return true,则继续后续处理。

public class LogCostInterceptor implements HandlerInterceptor { long start = System.currentTimeMillis();
    @Override public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
        start = System.currentTimeMillis(); return true;
    }

    @Override public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
        System.out.println("Interceptor cost="+(System.currentTimeMillis()-start));
    }

    @Override public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
    }
}

reHandle是请求执行前执行的,postHandler是请求结束执行的,但只有preHandle方法返回true的时候才会执行,afterCompletion是视图渲染完成后才执行,同样需要preHandle返回true,该方法通常用于清理资源等工作。

拦截器应用场景

拦截器本质上是面向切面编程(AOP),符合横切关注点的功能都可以放在拦截器中来实现,主要的应用场景包括:

  • 登录验证,判断用户是否登录。
  • 权限验证,判断用户是否有权限访问资源,如校验token
  • 日志记录,记录请求操作日志(用户ip,访问时间等),以便统计请求访问量。
  • 处理cookie、本地化、国际化、主题等。
  • 性能监控,监控请求处理时长等。
  • 通用行为:读取cookie得到用户信息并将用户对象放入请求,从而方便后续流程使用,还有如提取Locale、Theme信息等,只要是多个处理器都需要的即可使用拦截器实现)

过滤器应用场景

1)过滤敏感词汇(防止sql注入)
2)设置字符编码
3)URL级别的权限访问控制
4)压缩响应信息

posted @ 2025-06-15 00:01  小郑[努力版]  阅读(29)  评论(0)    收藏  举报