掘金 后端 ( ) • 2024-05-05 17:54

为什么会有这篇文章

由于各种不可控因素,最近这几年参与的项目基本都是java8/java11+springboot2.X的项目,springboot3.x已经出来很久了,但是一直没有在正式项目中用过,最近有机会可能会用到springboot3来做个正式项目,所以想搭建一个最基础的脚手架作为新项目的起点。

  1. 这篇文章记录一下过程,作为备忘可以以后查询
  2. 踩到的坑也记录下来,可以供其他用到类似技术的同学参考
  3. 这几年面试了好多java后端小伙伴,发现好多同学都是用别人搭好的框架,对于框架为何这么选型一头雾水,这篇文章希望给这些小伙伴一个思路

涉及知识点

会涵盖以下技术点:

  1. springboot3
  2. spring security6
  3. JWT
  4. MyBatis Plus
  5. liquibase
  6. OpenAPI/springdoc/knife4j
  7. 全局异常处理
  8. lombok
  9. mapstruct
  10. Redis
  11. MyBatisPlus代码生成器

我个人的观点来说,任何一个springboot项目,除了最后一点大部分项目不使用,剩余的技术点是必选项。当然,ORM可能不选用MyBatisPlus,而是采用JPA+SpringData的方式,数据库一致性工具使用flyway。 上述涵盖的东西放在一篇文章中算是比较多了,而且每一个技术点都能写一篇很长的文章,所以我不讨论技术点优劣,不深入解释,甚至有些不能算是最佳实践,只是为一个新项目的起点做出最小的配置,让项目能够跑起来,后续用到的同学再根据自己需要慢慢优化。

新建一个springboot3的项目

可以上网站https://start.spring.io 中建一个项目。 image.png 如上图所示,我选择了Maven作为项目构建工具,选择了最新的稳定版3.2.5,选择了java21,同时勾选了以下的项目依赖: Spring Web Spring Security Lombok MySQL Driver(项目中用postgresql的也很多,但是国内更喜欢用mysql) 当然也可以不使用网站,利用集成开发工具也行。

加入数据库访问

目前应用干不了什么实质工作,我们加入数据库ORM,顺便把properties文件改为yaml格式。 任何项目不可能只有生产环境,一般来说会有各个环境,服务于不同的人员: local:自己开发用,不少项目这个配置文件是被git忽略掉,只有开发者个人使用的 dev:开发人员前后端联调,跟local最大的区别就是这里面配置的网络环境一般是内网IP test:测试人员测试使用 uat:客户验收使用 prod:生产 现在就拆分一下: application.yaml:


spring:
  application:
    name: Springboot3-Springsecurity6-Mybatisplus
  mvc:
    pathmatch:
      matching-strategy: ANT_PATH_MATCHER
  main:
    banner-mode: off

  liquibase:
    change-log: classpath:db/changelog/master.yaml
    enabled: true

# MyBatis配置
#mybatis:
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    banner: off
  #  # 搜索指定包别名
  #  typeAliasesPackage: com.focus.**.domain
  #  # 配置mapper的扫描,找到所有的mapper.xml映射文件
  #  mapperLocations: classpath*:mapper/**/*Mapper.xml
  mapper-locations: classpath*:mapper/**/*Mapper.xml
  type-aliases-package: com.sptan.**.domain

server:
  servlet:
    context-path: /ssmp
  shutdown: graceful

application-local.yaml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/ssmp?useUnicode=true&characterEncoding=UTF-8
    username: root
    password: "1234qweR"

server:
  port: 6666

logging:
  file:
    path: /Users/liupeng/logs/ssmp
  level:
    com.sptan: debug

演示用,只有local的配置文件就够用了,连的本机的测试数据库,希望后续演示一切顺利,端口号比666还要多一个6,主打一个足够6!

建数据库

建一个空的数据库,大家在编码时,建议选utf8mb4,比utf8有更好的兼容性,存储表情符时少很多烦恼。 image.png

引入MyBatis Plus

引入非常简单:

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>${mybatisplus.version}</version>
        </dependency>

我们使用最新版,3.5.5 maven构建文件这时长这样: image.png MyBatis Plus的开发人员做了很多工作,基本上拿来即用,唯一需要配置的就是Mapper文件的位置。 配置文件可以加载springboot启动类上,也可以放在一个专门的配置类上,我们使用后者。 这时发现spring给我们生成的包名太长了,新建工程时没有注意,现在改一下,改成:ssmp,应用名也顺便一块改了。 现在主类这样:

package com.sptan.ssmp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SsmpApplication {

    public static void main(String[] args) {
        SpringApplication.run(SsmpApplication.class, args);
    }

}

MyBatis Plus的配置类:

package com.sptan.ssmp.mybatis;

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

/**
 * Description: MybatisPlusConfig .
 *
 * @author lp
 */
@Configuration
@MapperScan("com.sptan.ssmp.**.mapper")
public class MybatisPlusConfig {

    /**
     * Mybatis plus interceptor mybatis plus interceptor.
     *
     * @return the mybatis plus interceptor
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 分页
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        // 防止全表更新与删除
        interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
        return interceptor;
    }
}

引入liquibase

启动任何一个访问DB的项目,只要客户允许,我是一定要引入liquibase或者flyway这类数据库一致性工具的。 引入liquibase后,建表语句直接放在liquibase脚本中。 初始的SQL脚本如下:

create table sys_admin_user
(
  id             bigint auto_increment comment '主键ID'
  primary key,
  email          varchar(64)  default '' not null comment '邮箱',
  mobile         varchar(64)  default '' not null comment '手机',
  user_name      varchar(64)  default '' not null comment '用户姓名, 不能重复',
  nick_name      varchar(64)  default '' not null comment '用户昵称',
  password       varchar(256) default '' not null comment '密码,密文',
  gender       int(11) default 0 not null comment '1男2女0未知',
  avatar         varchar(256) default '' not null comment '头像地址',
  status         int          default 1  not null comment '启用状态:0->禁用;1->启用',
  sort           int          default 0  not null comment '排序',
  delete_flag    tinyint(1)   default 0  not null comment '1:已删除, 0:正常未删除',
  version        int          default 1  not null comment '版本信息',
  ctime          datetime                not null comment '创建时间',
  utime          datetime                not null comment '最后更新时间',
  cuid           bigint       default 0  not null comment '创建人ID',
  opuid          bigint       default 0  not null comment '更新人ID',
  constraint uni_email
  unique (email, delete_flag)
)
    comment '管理端用户表' row_format = DYNAMIC;

create table sys_dept
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  parent_id   bigint                   null comment '上级部门ID',
  name        varchar(64)   default '' not null comment '角色名称',
  full_path   varchar(8000) default '' not null comment '完整路径',
  status      int           default 1  not null comment '启用状态:0->禁用;1->启用',
  sort        int           default 0  not null comment '排序',
  delete_flag tinyint(1)    default 0  not null comment '1:已删除, 0:正常未删除',
  version     int           default 1  not null comment '版本信息',
  ctime       datetime                 not null comment '创建时间',
  utime       datetime                 not null comment '最后更新时间',
  cuid        bigint        default 0  not null comment '创建人ID',
  opuid       bigint        default 0  not null comment '更新人ID'
)
    comment '部门表' row_format = DYNAMIC;

create table sys_menu
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  parent_id   bigint       default 0  not null comment '父级ID',
  name        varchar(64)  default '' not null comment '菜单或者按钮名称',
  node_type   int          default 2  not null comment '节点类型,1文件夹,2页面,3按钮, 4:子页面或者页面元素',
  icon_url    varchar(256) default '' not null comment '图标地址',
  link_url    varchar(256) default '' not null comment '页面对应的前端地址',
  level       int          default 1  not null comment '层级',
  full_path   varchar(512) default '' not null comment '树id的路径 整个层次上的路径id,逗号分隔',
  status      int          default 1  not null comment '启用状态:0->禁用;1->启用',
  sort        int          default 0  not null comment '排序',
  delete_flag tinyint(1)   default 0  not null comment '1:已删除, 0:正常未删除',
  version     int          default 1  not null comment '版本信息',
  ctime       datetime                not null comment '创建时间',
  utime       datetime                not null comment '最后更新时间',
  cuid        bigint       default 0  not null comment '创建人ID',
  opuid       bigint       default 0  not null comment '更新人ID'
)
    comment '菜单表' row_format = DYNAMIC;

create table sys_role_info
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  code        varchar(64) default '' not null comment '编码,用于处理特殊业务',
  name        varchar(64) default '' not null comment '角色名称',
  role_type   int         default 2  not null comment '0:超级管理员, 1: 管理员 2: 城市合伙人 3:普通用户',
  status      int         default 1  not null comment '启用状态:0->禁用;1->启用',
  sort        int         default 0  not null comment '排序',
  delete_flag tinyint(1)  default 0  not null comment '1:已删除, 0:正常未删除',
  version     int         default 1  not null comment '版本信息',
  ctime       datetime               not null comment '创建时间',
  utime       datetime               not null comment '最后更新时间',
  cuid        bigint      default 0  not null comment '创建人ID',
  opuid       bigint      default 0  not null comment '更新人ID',
  constraint uni_code
  unique (code, delete_flag)
)
    comment '角色表' row_format = DYNAMIC;

create table sys_role_menu_link
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  role_id     bigint     default 0 not null comment '角色ID',
  menu_id     bigint     default 0 not null comment '菜单ID',
  delete_flag tinyint(1) default 0 not null comment '1:已删除, 0:正常未删除',
  version     int        default 1 not null comment '版本信息',
  ctime       datetime             not null comment '创建时间',
  utime       datetime             not null comment '最后更新时间',
  cuid        bigint     default 0 not null comment '创建人ID',
  opuid       bigint     default 0 not null comment '更新人ID'
)
    comment '角色菜单关联表' row_format = DYNAMIC;

create index idx_role
    on sys_role_menu_link (role_id, delete_flag);

create table sys_user_dept_link
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  user_id     bigint     default 0 not null comment '用户ID',
  dept_id     bigint     default 0 not null comment '部门ID',
  delete_flag tinyint(1) default 0 not null comment '1:已删除, 0:正常未删除',
  version     int        default 1 not null comment '版本信息',
  ctime       datetime             not null comment '创建时间',
  utime       datetime             not null comment '最后更新时间',
  cuid        bigint     default 0 not null comment '创建人ID',
  opuid       bigint     default 0 not null comment '更新人ID'
)
    comment '用户部门关联表' row_format = DYNAMIC;

create index idx_dept
    on sys_user_dept_link (dept_id, delete_flag);

create index idx_user
    on sys_user_dept_link (user_id, delete_flag);

create table sys_user_role_link
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  user_id     bigint     default 0 not null comment '用户ID',
  role_id     bigint     default 0 not null comment '角色ID',
  delete_flag tinyint(1) default 0 not null comment '1:已删除, 0:正常未删除',
  version     int        default 1 not null comment '版本信息',
  ctime       datetime             not null comment '创建时间',
  utime       datetime             not null comment '最后更新时间',
  cuid        bigint     default 0 not null comment '创建人ID',
  opuid       bigint     default 0 not null comment '更新人ID'
)
    comment '用户角色关联表' row_format = DYNAMIC;

create index idx_role
    on sys_user_role_link (role_id, delete_flag);

create index idx_user
    on sys_user_role_link (user_id, delete_flag);


这个脚本中包含了用户、部门、角色、菜单等基本信息,可以说是一个管理后台的必须的表。 上面的配置文件application.yaml中已经包含了liquibase的配置信息,它的入口是classpath:db/changelog/master.yaml. 我们看看classpath:db/changelog/master.yaml的内容

databaseChangeLog:
- include:
    file: change-log.yaml
    relativeToChangelogFile: true

很简单,指向了change-log.yaml这个配置文件,看看change-log.yaml:

databaseChangeLog:
  - property:
      name: now
      value: current_timestamp
      dbms: postgresql
  - property:
      name: now
      value: now()
      dbms: h2,mysql
  - property:
      name: now
      value: sysdate
      dbms: oracle
  - property:
      name: autoIncrement
      value: ture
      dbms: mysql,h2,postgresql,oracle
  - changeSet:
      id: init
      author: lp
      runOnChange: true
      changes:
        - sqlFile:
            path: db/changelog/sql/init.sql

也很简单,指定了一些数据库方言,最重要的是changeSet部分,包含了各次数据库变更的SQL历史,在这个SQL历史中,可以包含初始的建表信息、数据库内容的初始化数据,也可以包含后续表结构的变更信息。总之,后续的DDL操作只通过liquibase脚本来完成,而不是提供单独的SQL,一个环境一个环境的去执行。且不说环境多了容易出错,还有多人合作的问题,有的开发改了数据库结构,没有通知大家,可能别不清楚这个改动,对他的修改做了覆盖,环境多,人员多,会出现脚本问题的概率越来越大,使用liquibase,能解决这类不一致问题。 把我们上面建表SQL放在db/changelog/sql/init.sql中即可。 现在程序结构长这样: image.png 数据库是空的 image.png 记得项目还需要因为liquibase的依赖,目前最新的版本是4.27.0 image.png 构建一下,发现有test错误,先不考虑单体测试,把SsmpApplicationTests这个测试类删掉。 备注:这里删除测试类,不是因为单体测试不重要,单体测试非常非常重要,我们删除是因为:

  1. 这里说明脚手架构造,暂时不需要引入单体测试
  2. springboot级别的测试比较慢,我更倾向于service层的单体测试,粒度更小,运行更快

给项目指定profile为local,运行一下。 image.png image.png 没有意外的话,运行成功。 运行后启动log中看到生成了随机的password,这是我们引入spring security的副作用,实际项目中,没有人会用到这种随机生产密码的方式,这个后面提到安全问题时再解决。 从命令行中看到liquibase脚本成功执行了。 我们看看效果: image.png 看到我们的脚本成功创建了表。还有两个表名为databasechange开头的表,这是liquibase运行产生的表,一个记录执行历史,一个起到全局锁控制并发的作用。databasechangelog记录执行历史,可以看看这个表的内容,还是很清晰的,既然liquibase靠着这个表来维护数据一致性,假如我把databasechangelog这个表清空了怎么样呢?感兴趣的可以试试。 注意:不要在生产环境中试!假如你就想在生产环境中试的话,请不要说是跟我学的! 程序员天生喜欢用代码控制一切,利用liquibase,可以用代码来控制数据库的变更,之前无序和不可追踪的数据库变更,变得直观、可追踪、可管理,能够消除大部分数据库结构不一致带来的问题,只要客户允许,推荐所有项目使用类似机制。 表建立好了,不过还没有实质功能。我们下面实现注册一个用户,并用新注册的用户进行登录。

引入安全机制

Spring Security是一套相当复杂的安全框架,不仅复杂,变化还快,快到5.6版引入的新特性,6.1版就被标注了要废弃,说是7.0版本就彻底删除😒。理清楚这个框架比较花时间,用简介的文字介绍更难,详细的说明以后再说,今天只说我们脚手架用到的。

JWT

对于一个普通的前后端分离的web项目,JWT算是事实上的安全标准了。简单、无状态、适用面广。 首先引入jwt相关的库,这个不是唯一的,还有很多其他可用的,大家按需使用即可。

    <properties>
        ......
        <jjwt.version>0.12.5</jjwt.version>
    </properties>
    <dependencies>
        ......

        <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
    </dependencies>

补齐用户表相关的entity、mapper及service

注册用户需要往用户表中添加数据,我们补齐这些基础类。

entity

package com.sptan.ssmp.domain;

import com.baomidou.mybatisplus.annotation.TableName;
import com.sptan.ssmp.mybatis.entity.AbstractFocusBaseEntity;
import lombok.Data;

/**
 * <p>
 * 管理端用户表
 * </p>
 *
 * @author lp
 * @since 2024-05-04
 */
@Data
@TableName("sys_admin_user")
public class AdminUser extends AbstractFocusBaseEntity {

    private static final long serialVersionUID = 1L;

    private String email;

    private String mobile;

    private String userName;

    private String nickName;

    private String password;

    private Integer gender;

    private String avatar;

    private Integer status;

    private Integer sort;

}

建表时应该发现了,我们的表有些共通字段,这些共通字段放在基类中:

package com.sptan.ssmp.mybatis.entity;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * Description: Entity基类 .
 *
 * @author lp
 */
@JsonIgnoreProperties(value = {
    "hibernateLazyInitializer",
    "handler",
    "fieldHandler"
})
@Data
public class AbstractFocusBaseEntity implements Serializable {

    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.AUTO)
    private Long id ;

    @TableField(fill = FieldFill.INSERT)
    private Long cuid;

    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime ctime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long opuid;

    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime utime;

    private Boolean deleteFlag = false;

}

DTO

一般来说,不会直接把entity对象传递给前端,而是转成DTO,隐藏掉敏感或者前端不需要的字段,加上entity中没有但是前端又需要的字段。

通用返回类型

一般来说,项目给前端返回的格式要一致,方便前端的共通处理,我们这里增加一个通用返回类:

package com.sptan.ssmp.dto.core;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Objects;

/**
 * The type Result entity.
 *
 * @param <T> the type parameter
 * @author liupeng
 * @version 1.0
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResultEntity<T> {

    /**
     * The constant SUCCESS.
     */
    public static final int SUCCESS = 200;
    /**
     * The constant BUSINESS_ERROR_CODE.
     */
    public static final int BUSINESS_ERROR_CODE = 400;

    /**
     * The constant BUSINESS_ERROR_CODE_410.
     */
    public static final int BUSINESS_ERROR_CODE_410 = 410;

    /**
     * The constant MSG_SUCCESS.
     */
    public static final String MSG_SUCCESS = "SUCCESS";

    private int code;

    private String message;

    private T data;

    /**
     * Is success boolean.
     *
     * @return the boolean
     */
    public boolean isSuccess() {
        return Objects.equals(SUCCESS, this.getCode());
    }

    /**
     * Ok result entity.
     *
     * @param <T>  the type parameter
     * @param data the data
     * @return the result entity
     */
    public static <T> ResultEntity<T> ok(T data) {
        ResultEntity<T> result = new ResultEntity<>(SUCCESS, MSG_SUCCESS, data);
        return result;
    }

    /**
     * Success result entity.
     *
     * @param <T>  the type parameter
     * @param data the data
     * @return the result entity
     */
    public static <T> ResultEntity<T> success(T data) {
        ResultEntity<T> result = new ResultEntity<>(SUCCESS, MSG_SUCCESS, data);
        return result;
    }

    /**
     * Err result entity.
     *
     * @param <T>     the type parameter
     * @param message the message
     * @return the result entity
     */
    public static <T> ResultEntity<T> error(String message) {
        ResultEntity<T> result = new ResultEntity<>(BUSINESS_ERROR_CODE, message, null);
        return result;
    }

    /**
     * Err result entity.
     *
     * @param <T>       the type parameter
     * @param errorCode the error code
     * @param message   the message
     * @return the result entity
     */
    public static <T> ResultEntity<T> error(int errorCode, String message) {
        ResultEntity<T> result = new ResultEntity<>(errorCode, message, null);
        return result;
    }

    /**
     * 业务要求某些情况下允许数据部分保存并向前端返回错误信息.
     *
     * @param <T>       the type parameter
     * @param errorCode the error code
     * @param message   the message
     * @param data      the data
     * @return the result entity
     */
    public static <T> ResultEntity<T> error(int errorCode, String message, T data) {
        ResultEntity<T> result = new ResultEntity<>(errorCode, message, data);
        return result;
    }
}

用户DTO

package com.sptan.ssmp.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * <p>
 * 管理端用户表.
 * </p>
 *
 * @author lp
 * @since 2024-05-04
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AdminUserDTO {

    private Long id;

    private String email;

    private String mobile;

    private String userName;

    private String nickName;

    private String password;

    private Integer gender;

    private String avatar;

    private Integer status;

    private Integer sort;
}

这个无脑把entity复制过来了,实际项目不要这么搞,password这种字段返给前端就搞笑了。

分页查询条件

package com.sptan.ssmp.dto;

import com.sptan.ssmp.mybatis.page.PageCriteria;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * <p>
 * 管理端用户表查询条件.
 * </p>
 *
 * @author lp
 * @since 2024-05-04
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AdminUserCriteria extends PageCriteria {

    private Long id;

    private String email;

    private String mobile;

    private String userName;

}

分页查询条件基类

package com.sptan.ssmp.mybatis.page;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Data;

import java.io.Serializable;
import java.util.Optional;

/**
 * The type Page req.
 *
 * @author lp
 */
@Data
public class PageCriteria implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * The constant DESC.
     */
    public static final String DESC = "desc";
    /**
     * The constant ASC.
     */
    public static final String ASC = "asc";
    private final static String[] KEYWORDS = {
        "master",
        "truncate",
        "insert",
        "select",
        "delete",
        "update",
        "declare",
        "alter",
        "drop",
        "sleep"
    };

    private Integer pageNumber = 1;

    private Integer pageSize = 10;

    private String sort;

    private String order;

    /**
     * To page page.
     *
     * @param <T> the type parameter
     * @return the page
     */
    public <T> IPage<T> toPage() {
        return PageCriteria.toPage(this);
    }

    /**
     * To page page.
     *
     * @param <T>  the type parameter
     * @param page the page
     * @return the page
     */
    public static <T> IPage<T> toPage(PageCriteria page) {
        Page<T> mybatisPage = null;
        int pageNumber = Optional.ofNullable(page.getPageNumber()).orElse(1);
        int pageSize = Optional.ofNullable(page.getPageSize()).orElse(10);
        String sort = page.getSort();
        String order = page.getOrder();
        sqlinject(sort);
        if (pageNumber < 1) {
            pageNumber = 1;
        }
        if (pageSize < 1) {
            pageSize = 10;
        }
        if (StringUtils.isNotBlank(sort)) {
            boolean isAsc = false;
            if (StringUtils.isBlank(order)) {
                isAsc = false;
            } else {
                if (DESC.equals(order.toLowerCase())) {
                    isAsc = false;
                } else if (ASC.equals(order.toLowerCase())) {
                    isAsc = true;
                }
            }
            mybatisPage = new Page<>(pageNumber, pageSize);
            if (isAsc) {
                mybatisPage.addOrder(OrderItem.asc(camel2Underline(sort)));
            } else {
                mybatisPage.addOrder(OrderItem.desc(camel2Underline(sort)));
            }
        } else {
            mybatisPage = new Page<>(pageNumber, pageSize);
        }
        return mybatisPage;
    }


    /**
     * 驼峰法转下划线.
     *
     * @param str 字符串.
     * @return 返回. string
     */
    public static String camel2Underline(String str) {
        if (StringUtils.isBlank(str)) {
            return "";
        }
        if (str.length() == 1) {
            return str.toLowerCase();
        }
        StringBuilder sb = new StringBuilder();
        for (int i = 1; i < str.length(); i++) {
            if (Character.isUpperCase(str.charAt(i))) {
                sb.append("_" + Character.toLowerCase(str.charAt(i)));
            } else {
                sb.append(str.charAt(i));
            }
        }
        return (str.charAt(0) + sb.toString()).toLowerCase();
    }

    /**
     * 防Mybatis-Plus order by注入.
     *
     * @param param 参数.
     */
    public static void sqlinject(String param) {
        if (StringUtils.isBlank(param)) {
            return;
        }
        // 转换成小写
        param = param.toLowerCase();
        // 判断是否包含非法字符
        for (String keyword : KEYWORDS) {
            if (param.contains(keyword)) {
                throw new RuntimeException(param + "包含非法字符");
            }
        }
    }
}

注册请求对象和注册响应对象

package com.sptan.ssmp.dto.auth;

import lombok.Data;

/**
 * 用户认证请求对象.
 *
 * @author liupeng
 * @date 2024/5/3
 */
@Data
public class AuthRequest {

    private String username;

    private String password;

}
package com.sptan.ssmp.dto.auth;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 用户认证响应对象.
 *
 * @author liupeng
 * @date 2024/5/3
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuthResponse {

    private String token;
}

LoginUser(UserDetails的实现类)

package com.sptan.ssmp.dto.auth;

import com.sptan.ssmp.domain.AdminUser;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

/**
 * 登录用户.
 *
 * @author lp
 * @since 2024-05-04
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginUser implements UserDetails {

    private AdminUser user;

    /**
     * Returns the authorities granted to the user. Cannot return <code>null</code>.
     *
     * @return the authorities, sorted by natural key (never <code>null</code>)
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of();
    }

    /**
     * Returns the password used to authenticate the user.
     *
     * @return the password
     */
    @Override
    public String getPassword() {
        return "";
    }

    /**
     * Returns the username used to authenticate the user. Cannot return
     * <code>null</code>.
     *
     * @return the username (never <code>null</code>)
     */
    @Override
    public String getUsername() {
        return this.user.getUserName();
    }

    /**
     * Indicates whether the user's account has expired. An expired account cannot be
     * authenticated.
     *
     * @return <code>true</code> if the user's account is valid (ie non-expired),
     * <code>false</code> if no longer valid (ie expired)
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * Indicates whether the user is locked or unlocked. A locked user cannot be
     * authenticated.
     *
     * @return <code>true</code> if the user is not locked, <code>false</code> otherwise
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * Indicates whether the user's credentials (password) has expired. Expired
     * credentials prevent authentication.
     *
     * @return <code>true</code> if the user's credentials are valid (ie non-expired),
     * <code>false</code> if no longer valid (ie expired)
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * Indicates whether the user is enabled or disabled. A disabled user cannot be
     * authenticated.
     *
     * @return <code>true</code> if the user is enabled, <code>false</code> otherwise
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}

MapStruct

一般来说,DTO和entity长得很像,大多数字段一致,这时采用MapStruct做类型转换相当合适。 maven中引入

   <properties>
        ......
        <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
    </properties>
    <dependencies>
        ......

        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>${org.mapstruct.version}</version>
        </dependency>
    </dependencies>

增加相关的转换接口: MapStruct接口最合理的包名是mapper,接口名中最合理的后缀是Mapper,不过这个被mybatis占用了,只好用其他的名字代替,这里我使用Converter。

package com.sptan.ssmp.converter;

import com.sptan.ssmp.domain.AdminUser;
import com.sptan.ssmp.dto.AdminUserDTO;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;

import java.util.List;

/**
 * 管理端用户表 Mapstruct转换接口.
 *
 * @author lp
 * @since 2024-05-04
 */
@Mapper(componentModel = "spring")
public interface AdminUserConverter {

    /**
     * The constant INSTANCE.
     */
    AdminUserConverter INSTANCE = Mappers.getMapper(AdminUserConverter.class);


    /**
     * To dto base station dto.
     *
     * @param entity the entity
     * @return the base station dto
     */
    AdminUserDTO toDto(AdminUser entity);

    /**
     * dto to entity.
     *
     * @param entity the entity
     * @return the base station brief dto
     */
    AdminUser toEntity(AdminUserDTO dto);

    /**
     * To dto list.
     *
     * @param entities the entities
     * @return the list
     */
    List<AdminUserDTO> toDtoList(List<AdminUser> entities);

    /**
     * To entity list.
     *
     * @param dtos the dtos
     * @return the list
     */
     List<AdminUser> toEntities(List<AdminUserDTO> dtos);
}

上面这个只是一个例子,大家可以根据自己的需要增删方法。 mapstuct提供了很多配置项,我们采用最常用的spring模式,这个可以配置在各个文件中,考虑到很多接口都是采用相同配置,我们放在配置文件中,其他相同配置的不用单独配置了。 image.png

mapper

package com.sptan.ssmp.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sptan.ssmp.domain.AdminUser;

/**
 * <p>
 * 管理端用户表 Mapper 接口
 * </p>
 *
 * @author lp
 * @since 2024-05-04
 */
public interface AdminUserMapper extends BaseMapper<AdminUser> {

}

没有特殊的SQL,暂时不需要xml文件,这也是MyBatis Plus的一项小福利。

service层

接口

package com.sptan.ssmp.service;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.sptan.ssmp.domain.AdminUser;
import com.sptan.ssmp.dto.AdminUserCriteria;
import com.sptan.ssmp.dto.AdminUserDTO;
import com.sptan.ssmp.dto.auth.AuthRequest;
import com.sptan.ssmp.dto.auth.AuthResponse;
import com.sptan.ssmp.dto.core.ResultEntity;


import java.util.Optional;

/**
 * <p>
 * 管理端用户表 服务类.
 * </p>
 *
 * @author lp
 * @since 2024 -05-04
 */
public interface AdminUserService extends IService<AdminUser> {

    /**
     * 保存.
     *
     * @param dto the dto
     * @return the result entity
     */
    ResultEntity<AdminUserDTO> save(AdminUserDTO dto);

    /**
     * 删除.
     *
     * @param id the id
     * @return the result entity
     */
    ResultEntity<Boolean> delete(Long id);

    /**
     * 查看详情.
     *
     * @param id the id
     * @return the result entity
     */
    ResultEntity<AdminUserDTO> detail(Long id);

    /**
     * 根据条件查询.
     *
     * @param criteria the criteria
     * @return the result entity
     */
    ResultEntity<IPage<AdminUserDTO>> search(AdminUserCriteria criteria);

    /**
     * Gets by user name.
     *
     * @param username the username
     * @return the by user name
     */
    Optional<AdminUser> findByUserName(String username);

    /**
     * Register admin user.
     *
     * @param username the username
     * @param password the password
     * @return the admin user
     */
    ResultEntity<AuthResponse> register(String username, String password);

    /**
     * Login result entity.
     *
     * @param request the request
     * @return the result entity
     */
    ResultEntity<AuthResponse> login(AuthRequest request);
}

实现类

package com.sptan.ssmp.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.sptan.ssmp.config.JwtProvider;
import com.sptan.ssmp.converter.AdminUserConverter;
import com.sptan.ssmp.domain.AdminUser;
import com.sptan.ssmp.dto.AdminUserCriteria;
import com.sptan.ssmp.dto.AdminUserDTO;
import com.sptan.ssmp.dto.auth.AuthRequest;
import com.sptan.ssmp.dto.auth.AuthResponse;
import com.sptan.ssmp.dto.auth.LoginUser;
import com.sptan.ssmp.dto.core.ResultEntity;
import com.sptan.ssmp.mapper.AdminUserMapper;
import com.sptan.ssmp.mybatis.page.PageCriteria;
import com.sptan.ssmp.service.AdminUserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.Objects;
import java.util.Optional;

/**
 * <p>
 * 管理端用户表 服务实现类.
 * </p>
 *
 * @author lp
 * @since 2024-05-04
 */
@Service
@Slf4j
@RequiredArgsConstructor
public class AdminUserServiceImpl extends ServiceImpl<AdminUserMapper, AdminUser> implements AdminUserService {

    /**
     * 密码加密工具类.
     */
    private final PasswordEncoder passwordEncoder;

    /**
     * JWT工具类.
     */
    private final JwtProvider jwtProvider;

    /**
     * Spring Security提供的认证管理器.
     */
    private final AuthenticationManager authenticationManager;

    /**
     * 保存.
     * 传入的参数提供id的话,是编辑用户, 如果id为空,是新增用户.
     *
     * @param dto the dto
     * @return the result entity
     */
    @Override
    public ResultEntity<AdminUserDTO> save(AdminUserDTO dto) {
        AdminUser entity = AdminUserConverter.INSTANCE.toEntity(dto);
        this.saveOrUpdate(entity);
        return ResultEntity.ok(AdminUserConverter.INSTANCE.toDto(entity));
    }

    /**
     * 删除.
     * 这里采用逻辑删除.
     *
     * @param id the id
     * @return the result entity
     */
    @Override
    public ResultEntity<Boolean> delete(Long id) {
        AdminUser entity = getById(id);
        if (entity == null || Objects.equals(true, entity.getDeleteFlag())) {
            return ResultEntity.error("数据不存在");
        }
        entity.setDeleteFlag(true);
        this.saveOrUpdate(entity);
        return ResultEntity.ok(true);
    }

    /**
     * 查看详情.
     *
     * @param id the id
     * @return the result entity
     */
    @Override
    public ResultEntity<AdminUserDTO> detail(Long id) {
        AdminUser entity = baseMapper.selectById(id);
        if (entity == null || Objects.equals(true, entity.getDeleteFlag())) {
            return ResultEntity.error("数据不存在");
        }
        AdminUserDTO dto = AdminUserConverter.INSTANCE.toDto(entity);
        return ResultEntity.ok(dto);
    }

    /**
     * 根据条件分页查询.
     *
     * @param criteria the criteria
     * @return the result entity
     */
    @Override
    public ResultEntity<IPage<AdminUserDTO>> search(AdminUserCriteria criteria) {
        LambdaQueryWrapper<AdminUser> wrapper = getSearchWrapper(criteria);
        IPage<AdminUser> entityPage = this.baseMapper.selectPage(PageCriteria.toPage(criteria), wrapper);
        return ResultEntity.ok(entityPage.convert(AdminUserConverter.INSTANCE::toDto));
    }

    /**
     * Gets by user name.
     *
     * @param username the username
     * @return the by user name
     */
    @Override
    public Optional<AdminUser> findByUserName(String username) {
        AdminUser adminUser = baseMapper.selectOne(new LambdaQueryWrapper<AdminUser>()
            .eq(AdminUser::getDeleteFlag, false)
            .eq(AdminUser::getUserName, username));
        if (adminUser == null) {
            return Optional.empty();
        } else {
            return Optional.of(adminUser);
        }
    }

    /**
     * Register admin user.
     *
     * @param username the username
     * @param password the password
     * @return the admin user
     */
    @Override
    public ResultEntity<AuthResponse> register(String username, String password) {
        LambdaQueryWrapper<AdminUser> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(AdminUser::getUserName, username);
        AdminUser adminUser = baseMapper.selectOne(wrapper);
        if (adminUser != null) {
            return ResultEntity.error("已经被注册");
        }
        AdminUser record = buildAdminUser(username, password);
        this.save(record);
        LoginUser loginUser = LoginUser.builder().user(record).build();
        String token = jwtProvider.generateToken(loginUser);
        AuthResponse registerResponse = AuthResponse.builder()
            .token(token)
            .build();
        return ResultEntity.ok(registerResponse);
    }

    /**
     * Login result entity.
     *
     * @param request the request
     * @return the result entity
     */
    @Override
    public ResultEntity<AuthResponse> login(AuthRequest request) {
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
        var user = findByUserName(request.getUsername())
            .orElseThrow(() -> new IllegalArgumentException("用户名或者密码不对."));
        LoginUser loginUser = LoginUser.builder().user(user).build();
        var jwt = jwtProvider.generateToken(loginUser);
        AuthResponse authResponse = AuthResponse.builder().token(jwt).build();
        return ResultEntity.ok(authResponse);
    }

    private AdminUser buildAdminUser(String username, String password) {
        AdminUser record = new AdminUser();
        record.setUserName(username);
        record.setEmail(username);
        record.setMobile(username);
        record.setPassword(passwordEncoder.encode(password));
        return record;
    }

    /**
     * 获取查询条件.
     * @param criteria
     * @return
     */
    private LambdaQueryWrapper<AdminUser> getSearchWrapper(AdminUserCriteria criteria) {
        LambdaQueryWrapper<AdminUser> wrapper = new LambdaQueryWrapper<>();
        // 罗删除的用户默认不展示出来
        wrapper.eq(AdminUser::getDeleteFlag, false);
        if (criteria == null) {
            return wrapper;
        }
        if (StringUtils.hasText(criteria.getUserName())) {
            // 名字模糊查询
            wrapper.like(AdminUser::getUserName, "%" + criteria.getUserName() + "%");
        }
        return wrapper;
    }
}

出来login和register方法,剩下的都是CRUD的普通方法,这里面还包含了逻辑删除和分页查询的样板代码。 MyBatis Plus提供了全局的逻辑删除字段配置,不过我这里没有使用,感兴趣的可以把AbstractFocusBaseEntity这个基类中设置为逻辑删除字段,设置后,所有使用Wrapper来查询的SQL语句的查询条件中,MyBatis Plus会自动加上delete_flag = false这个分项条件,省去了wrapper.eq(AdminUser::getDeleteFlag, false);这样单独的设置,能省一点代码;当然,根据业务不同,也偶然会有些小坑需要踩一下😊。

AdminUserServiceImpl这个实现类中,有三个实例变量还没有出现,这个是需要配置的。 我们先定义一下org.springframework.security.core.userdetails.UserDetailsService的实现类。

package com.sptan.ssmp.service;

import org.springframework.security.core.userdetails.UserDetailsService;

/**
 * The interface User service.
 */
public interface UserService {

    /**
     * User details service user details service.
     *
     * @return the user details service
     */
    UserDetailsService userDetailsService();

}

实现类:

package com.sptan.ssmp.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sptan.ssmp.domain.AdminUser;
import com.sptan.ssmp.dto.auth.LoginUser;
import com.sptan.ssmp.mapper.AdminUserMapper;
import com.sptan.ssmp.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {


    private final AdminUserMapper adminUserMapper;

    @Override
    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) {
                LambdaQueryWrapper<AdminUser> wrapper = new LambdaQueryWrapper<>();
                wrapper.eq(AdminUser::getUserName, username);
                AdminUser adminUser = adminUserMapper.selectOne(wrapper);
                if (adminUser == null) {
                    throw new UsernameNotFoundException("User not found");
                } else {
                    return LoginUser.builder()
                        .user(adminUser)
                        .build();
                }
            }
        };
    }

}

大多数情况下,我们更愿意实现一个org.springframework.security.core.userdetails.UserDetailsService的实现类,不过我这种方式也是可以的,定义了一个方法,返回了UserDetailsService的实例。

安全配置

定义JWT的工具类

package com.sptan.ssmp.config;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

/**
 * JWT工具类,用于校验token, 生成token等.
 */
@Component
public class JwtProvider {

    @Value("${token.signing.key}")
    private String jwtSigningKey;

    /**
     * Extract user name string.
     *
     * @param token the token
     * @return the string
     */
    public String extractUserName(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    /**
     * Generate token string.
     *
     * @param userDetails the user details
     * @return the string
     */
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    /**
     * Is token valid boolean.
     *
     * @param token       the token
     * @param userDetails the user details
     * @return the boolean
     */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String userName = extractUserName(token);
        return (userName.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolvers) {
        final Claims claims = extractAllClaims(token);
        return claimsResolvers.apply(claims);
    }

    private String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder().setClaims(extraClaims).setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256).compact();
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser().setSigningKey(getSigningKey()).build().parseClaimsJws(token)
                .getBody();
    }

    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSigningKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

别忘了配置文件中增加签名串,一般来说,各个环境中应该采用不一样的key,特别是生产环境一定要采用单独的、保密的key。

token:
  signing:
    key: 413F4428472B4B6250655368566D5970337336763979244226452948404D6351

通用的安全配置

package com.sptan.ssmp.config;

import com.sptan.ssmp.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserService userService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(request -> request
                .requestMatchers("/api/v1/auth/**").permitAll()
                .requestMatchers("/doc.html",
                    "/swagger-resources/configuration/ui",
                    "/swagger*",
                    "/swagger**/**",
                    "/webjars/**",
                    "/favicon.ico",
                    "/**/*.css",
                    "/**/*.js",
                    "/**/*.png",
                    "/**/*.gif",
                    "/v3/**",
                    "/**/*.ttf",
                    "/actuator/**",
                    "/static/**",
                    "/resources/**").permitAll()
                .anyRequest().authenticated())
            .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS))
            .authenticationProvider(authenticationProvider()).addFilterBefore(
                jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userService.userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
        throws Exception {
        return config.getAuthenticationManager();
    }
}

这个类得说明一下:

  1. 过滤器链securityFilterChain定义了基本的安全配置;
  2. http.csrf(AbstractHttpConfigurer::disable)禁用了跨站请求伪造;
  3. 注册和登录的过程中,还没有token,所以把相关接口(我们计划请求前缀为/api/v1/auth/)允许无token访问;
  4. 后面是一堆接口文档相关的,也允许无token访问,这个后面再说明;
  5. 其他的接口需要认证,在实际项目中,需要放过哪些接口,要根据业务需要来判断,比如你提供了一个第三方调用的接口,你们直接使用特殊的请求头或者oauth2之类的其他方式认证,这里也得允许访问,否则对方调用你的接口是,会被spring security拦截了;
  6. 配置session为无状态;
  7. 把JwtAuthenticationFilter加到认证过滤器的前面;

定义方法拦截的过滤器

package com.sptan.ssmp.config;

import com.sptan.ssmp.service.UserService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    private final UserService userService;
    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
            throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;
        if (StringUtils.isEmpty(authHeader) || !StringUtils.startsWith(authHeader, "Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        jwt = authHeader.substring(7);
        userEmail = jwtProvider.extractUserName(jwt);
        if (StringUtils.isNotEmpty(userEmail)
                && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userService.userDetailsService()
                    .loadUserByUsername(userEmail);
            if (jwtProvider.isTokenValid(jwt, userDetails)) {
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                context.setAuthentication(authToken);
                SecurityContextHolder.setContext(context);
            }
        }
        filterChain.doFilter(request, response);
    }
}

每次接口请求(当然除了我们放过去不校验的那几个特殊请求),都会走一遍这个过滤器,这个过滤器从请求头Authorization中拿到token,解析出用户名,到我们数据库里去查询一番,合法的用户放过去,否则认证会失败。

controller层

嗯。。。好像不差什么了。。。 万事俱备只欠东风。我们加个Controller试试。

package com.sptan.ssmp.controller;


import com.sptan.ssmp.dto.auth.AuthRequest;
import com.sptan.ssmp.dto.auth.AuthResponse;
import com.sptan.ssmp.dto.core.ResultEntity;
import com.sptan.ssmp.service.AdminUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {
    private final AdminUserService adminUserService;

    @PostMapping("/signup")
    public ResponseEntity<ResultEntity<AuthResponse>> register(@RequestBody AuthRequest request) {
        return ResponseEntity.ok(adminUserService.register(request.getUsername(), request.getPassword()));
    }

    @PostMapping("/login")
    public ResponseEntity<ResultEntity<AuthResponse>> login(@RequestBody AuthRequest request) {
        return ResponseEntity.ok(adminUserService.login(request));
    }
}

由于还没有添加swagger或者springdoc,我们先用postman跑一下。 image.png 接口失败! 看看控制台: image.png 有些字段不允许为空,但是我们没有设置。 当然可以把字段都设置全来解决问题,但是cuid这种共通字段,每新增一条记录都需要手写代码,不是我们这等懒惰的码农喜欢干的事情,关于这些共通字段的处理,JPA和MyBatisPlus都有自己的处理方案,这种统称为数据审计,名字可能有点奇怪,不过不影响我们使用。

MyBatis Plus的数据审计

package com.sptan.ssmp.mybatis;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

/**
 * Description: 数据填充 .
 *
 * @author lp
 */
@Component("metaObjectHandler")
public class CustomMetaObjectHandler implements MetaObjectHandler {


    @Override
    public void insertFill(MetaObject metaObject) {
        this.setFieldValByName("delete_flag", false, metaObject);
        this.setFieldValByName("cuid", 0L, metaObject);
        this.setFieldValByName("opuid", 0L, metaObject);
        LocalDateTime now = LocalDateTime.now();
        this.setFieldValByName("ctime", now, metaObject);
        this.setFieldValByName("utime", now, metaObject);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.setFieldValByName("opuid", 0L, metaObject);
        this.setFieldValByName("utime", LocalDateTime.now(), metaObject);
    }
}

可以看到这里的数据审计有点问题:cuid和opuid都设置成0了,这肯定不是我们想要的,应该设置成什么更符合我们的业务呢?大家可以先想一想。 再跑一下: image.png

哇,终于成功了。 查看数据库: image.png 再用刚才的用户名密码登录一下: image.png 失败! 没事没事,人生不如意之事十之八九,失败乃人生常态,先冷静一下。 跟踪一下: image.png 发现我们encodedPassword是空串,因为我们定义的LoginUser对象是UserDetails的实现类,看看我们的代码: image.png 这个肯定不对,是我们上一步的马虎导致的。马虎不可怕,知错能改,善莫大焉。 改一下:

    public String getPassword() {
        return this.user.getPassword();
    }

再跑一遍,果然成功了。欧耶✌🏻 image.png

注册和登录都返回了token,我们到https://jwt.io/ 上看看这个token是啥玩意,实际上这个是我们自己生成的,我们肯定是知道了里面包含啥的,假如你还不知道,一定是漏了上面内容😊。 image.png 截图想说明的是,token是基本算是明文,不要把敏感信息放在token里!token中保存的信息一定不能是敏感的,如果安全性要求很高,甚至用户名也不允许放在这里。 反过来想,要是用户名都不允许放在token里,怎么在保证安全的基础上区分出登录用户呢?没有做过的同学先想一想,做过类似工作的同学先坐下,反思一下你为什么要浪费时间看我这篇入门的文章呢?