swagger+jwt+shiro+redis

一、前言

最近在项目中想整合swagger+jwt+shiro+redis过程中遇到诸多问题和困难,现重新写一个demo并记录解决步骤。
存在的问题:

  1. shiro默认的拦截跳转都是跳转url页面,而前后端分离后,后端并无权干涉页面跳转。
  2. shiro默认的登录拦截校验机制是使用的session。

参考资料:SpringBoot结合JWT+Shiro+Redis实现token无状态登录授权

二、需求及相关说明

需求

  1. 首次通过post请求进行登入;
  2. 登录成功后返回token;
  3. 每次请求,客户端需通过header将token带回服务器做JWT Token的校验;
  4. 服务端负责token生命周期的刷新
  5. 用户权限的校验;

相关说明

Shiro + JWT实现无状态鉴权机制

  1. 首先post用户名与密码到login进行登入,如果成功在请求头Header返回一个加密的Authorization,失败的话直接返回未登录,以后访问都带上这个Authorization即可。

  2. 鉴权流程主要是要重写shiro的入口过滤器BasicHttpAuthenticationFilter,在此基础上进行拦截、token验证授权等操作

关于AccessToken及RefreshToken概念说明

  1. AccessToken:用于接口传输过程中的用户授权标识,客户端每次请求都需携带,出于安全考虑通常有效时长较短。

  2. RefreshToken:与AccessToken为共生关系,一般用于刷新AccessToken,保存于服务端,客户端不可见,有效时长较长。

关于Redis中保存RefreshToken信息(做到JWT的可控性)

  1. 登录认证通过后返回AccessToken信息(在AccessToken中保存当前的时间戳和帐号),同时在Redis中设置一条以帐号为Key,Value为当前时间戳(登录时间)的RefreshToken,现在认证时必须AccessToken没失效以及Redis存在所对应的RefreshToken,且RefreshToken时间戳和AccessToken信息中时间戳一致才算认证通过,这样可以做到JWT的可控性,如果重新登录获取了新的AccessToken,旧的AccessToken就认证不了,因为Redis中所存放的的RefreshToken时间戳信息只会和最新的AccessToken信息中携带的时间戳一致,这样每个用户就只能使用最新的AccessToken认证。

  2. Redis的RefreshToken也可以用来判断用户是否在线,如果删除Redis的某个RefreshToken,那这个RefreshToken所对应的AccessToken之后也无法通过认证了,就相当于控制了用户的登录,可以剔除用户

关于根据RefreshToken自动刷新AccessToken

  1. 本身AccessToken的过期时间为5分钟,RefreshToken过期时间为30分钟,当登录后时间过了5分钟之后,当前AccessToken便会过期失效,再次带上AccessToken访问JWT会抛出TokenExpiredException异常说明Token过期,开始判断是否要进行AccessToken刷新,首先redis查询RefreshToken是否存在,以及时间戳和过期AccessToken所携带的时间戳是否一致,如果存在且一致就进行AccessToken刷新。

  2. 刷新后新的AccessToken过期时间依旧为5分钟,时间戳为当前最新时间戳,同时也设置RefreshToken中的时间戳为当前最新时间戳,刷新过期时间重新为30分钟过期,最终将刷新的AccessToken存放在Response的Header中的Authorization字段返回。

  3. 同时前端进行获取替换,下次用新的AccessToken进行访问即可。

三、实现步骤

1. 依赖导入

<properties>
    <java.version>1.8</java.version>
    <mybatis-plus.version>3.3.2</mybatis-plus.version>
    <shiro.version>1.6.0</shiro.version>
    <swagger.version>2.9.2</swagger.version>
    <jwt.version>3.9.0</jwt.version>
</properties>
<dependencies>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-logging</artifactId>
    </dependency>
    <!--JWT-->
    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>${jwt.version}</version>
    </dependency>

    <!-- MyBatis-Plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>${mybatis-plus.version}</version>
    </dependency>
    <!-- redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!-- lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>

    <!-- shiro -->
    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring</artifactId>
        <version>${shiro.version}</version>
    </dependency>

    <!-- springfox-swagger2 -->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>${swagger.version}</version>
    </dependency>
    <!-- springfox-swagger-ui -->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>${swagger.version}</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

配置文件

server:
  port: 8081
jwt:
 #  AccessToken 过期时间单位分钟
 EXPIRE_TIME: 5
 # RefreshToken 过期时间单位分钟
 REFRESH_EXPIRE_TIME: 30
 # 密钥盐
 TOKEN_SECRET: 50eFa4d9W8ba4d*bb276f^11
swagger:
  enabled: true
spring:
  datasource:
    username: root
    password: maple1234
    url: jdbc:mysql://127.0.0.1:3306/db2020?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
    driver-class-name: com.mysql.cj.jdbc.Driver
  redis:
    host: 127.0.0.1
    port: 6379
mybatis-plus:
  mapper-locations: classpath:com/maplexl/shiroJwtRedis/mapper/xml/*.xml
logging:
  level:
   com.maplexl: debug

2. 先配置swagger

@Configuration
@EnableSwagger2
public class SwaggerConfig {
    //是否开启swagger,根据环境来选择
    @Value(value = "${swagger.enabled}")
    Boolean swaggerEnabled;

    @Bean
    public Docket api() {
        //全局参数
        Parameter token = new ParameterBuilder().name("token")
                .description("用户登陆令牌")
                .parameterType("header")
                .modelRef(new ModelRef("String"))
                //是否必须
                .required(false)
                .build();
        ArrayList<Parameter> parameters = new ArrayList<>();
        parameters.add(token);
        return new Docket(DocumentationType.SWAGGER_2)
                .globalOperationParameters(parameters)
                .apiInfo(apiInfo())
                .enable(swaggerEnabled)
                .groupName("api")
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.maplexl.shiroJwtRedis.controller"))
                .paths(PathSelectors.any())
                .build();
    }


    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("jwt-shiro-redis")
                .description("api")
                // 作者信息
                .contact(new Contact("枫叶", "https://www.cnblogs.com/junlinsky/", "203051919@qq.com"))
                .version("1.0.0")
                .build();
    }
}

3. JWT工具类

主要实现token的签发、验证和数据解析。

@Slf4j
@SuppressWarnings("unused")
public class JwtUtil {

    /**
     * token到期时间,毫秒为单位
     */
    public static long EXPIRE_TIME;
    /**
     * RefreshToken到期时间为,秒为单位
     */
    public static long REFRESH_EXPIRE_TIME;
    /**
     * 密钥盐
     */
    private static String TOKEN_SECRET;

    /**
     * 设置token过期时间及密钥盐
     *
     * @param expireTime        客户端token过期时间
     * @param refreshExpireTime 服务器token过期时间
     * @param tokenSecret       token加密使用的盐值
     */
    public static void setProperties(long expireTime, long refreshExpireTime, String tokenSecret) {
        JwtUtil.EXPIRE_TIME = expireTime;
        JwtUtil.REFRESH_EXPIRE_TIME = refreshExpireTime;
        JwtUtil.TOKEN_SECRET = tokenSecret;
    }

    /**
     * 校验token是否正确
     *
     * @param token 密钥
     * @return 是否正确
     */
    public static boolean verify(String token) {
        //创建token验证器
        JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(TOKEN_SECRET)).build();
        DecodedJWT decodedjwt = jwtVerifier.verify(token);
        log.info("认证通过");
        log.info("userId: [{}]", decodedjwt.getClaim("userId").asLong());
        log.info("过期时间:      [{}]", decodedjwt.getExpiresAt());
        return true;
    }

    /**
     * 获得token中的信息
     *
     * @return token中包含的用户id
     */
    public static long getUserId(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("userId").asLong();
        } catch (JWTDecodeException e) {
            return -1L;
        }
    }

    /**
     * 获取currentTime
     * @param token 密钥
     * @return currentTime
     */
    public static Long getCurrentTime(String token){
        try{
            DecodedJWT decodedjwt =JWT.decode(token);
            return decodedjwt .getClaim("currentTime").asLong();
        }catch (JWTCreationException e){
            return null;
        }
    }
    /**
     * 根据request中的token获取currentTime
     *
     * @param request request
     * @return CurrentTime
     */
    public static Long getCurrentTime(HttpServletRequest request) {
        String accessToken = request.getHeader("token");
        Long currentTime = getCurrentTime(accessToken);
        if (currentTime == null) {
            throw new RuntimeException("未获取到currentTime");
        }
        return currentTime;
    }

    /**
     * 生成签名,5min后过期
     *
     * @param userId 用户名
     * @return 加密的token
     */
    public static String sign(Long userId, Long currentTime) {
        String token = null;
        try {
            Date expireAt = new Date(currentTime + EXPIRE_TIME);
            token = JWT.create()
                    //存放数据
                    .withClaim("userId", userId)
                    .withClaim("currentTime", currentTime)
                    //过期时间
                    .withExpiresAt(expireAt)
                    .sign(Algorithm.HMAC256(TOKEN_SECRET));
        } catch (RuntimeException e) {
            e.printStackTrace();
        }
        return token;
    }


    /**
     * 根据request中的token获取账号
     *
     * @param request request
     * @return 用户的账号
     */
    public static Long getUserId(HttpServletRequest request) {
        String accessToken = request.getHeader("token");
        return getUserId(accessToken);
    }
}

上面的工具类在使用前需需要设置EXPIRE_TIME、TOKEN_SECRET和REFRESH_EXPIRE_TIME

配置类

@Configuration
public class JwtUtilConfig {
    @Value("${jwt.EXPIRE_TIME}")
    private Long expireTime;
    @Value("${jwt.REFRESH_EXPIRE_TIME}")
    private Long refreshExpireTime;
    @Value("${jwt.TOKEN_SECRET}")
    private String tokenSecret;

    @PostConstruct
    public void init() {
        JwtUtil.setProperties(expireTime * 1000 * 60, refreshExpireTime * 60, tokenSecret);
    }
}

4. Redis配置和工具类

RedisConfig

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {

        RedisTemplate<String, Object> template = new RedisTemplate<>();
        // 配置连接工厂
        template.setConnectionFactory(factory);

        //使用Jackson2JsonRedisSerializer来序列化和反序列化redis的value值(默认使用JDK的序列化方式)
        Jackson2JsonRedisSerializer<Object> jacksonSeial = new Jackson2JsonRedisSerializer<>(Object.class);

        ObjectMapper om = new ObjectMapper();
        // 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        // 指定序列化输入的类型,类必须是非final修饰的,final修饰的类,比如String,Integer等会跑出异常
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,ObjectMapper.DefaultTyping.NON_FINAL);
        jacksonSeial.setObjectMapper(om);

        // 值采用json序列化
        template.setValueSerializer(jacksonSeial);
        //使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());

        // 设置hash key 和value序列化模式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(jacksonSeial);
        template.afterPropertiesSet();

        //设置RedisUtil工具类
        RedisUtil.setRedisTemplate(template);
        return template;
    }

    /**
     * 对hash类型的数据操作
     */
    @Bean
    public HashOperations<String, String, Object> hashOperations(RedisTemplate<String, Object> redisTemplate) {
        return redisTemplate.opsForHash();
    }

    /**
     * 对redis字符串类型数据操作
     */
    @Bean
    public ValueOperations<String, Object> valueOperations(RedisTemplate<String, Object> redisTemplate) {
        return redisTemplate.opsForValue();
    }

    /**
     * 对链表类型的数据操作
     */
    @Bean
    public ListOperations<String, Object> listOperations(RedisTemplate<String, Object> redisTemplate) {
        return redisTemplate.opsForList();
    }

    /**
     * 对无序集合类型的数据操作
     */
    @Bean
    public SetOperations<String, Object> setOperations(RedisTemplate<String, Object> redisTemplate) {
        return redisTemplate.opsForSet();
    }

    /**
     * 对有序集合类型的数据操作
     */
    @Bean
    public ZSetOperations<String, Object> zSetOperations(RedisTemplate<String, Object> redisTemplate) {
        return redisTemplate.opsForZSet();
    }

    /**
     * 设置默认缓存管理器
     * @param factory 链接工厂
     */
    @Primary
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory){
        return RedisCacheManager.create(factory);
    }
}

RedisUtil

@SuppressWarnings("unused")
public final class RedisUtil {

    private static RedisTemplate<String, Object> redisTemplate;


    public static void setRedisTemplate(RedisTemplate<String, Object> redisTemplate) {
        RedisUtil.redisTemplate = redisTemplate;
    }

    /**
     * 指定缓存失效时间
     * @param key  键
     * @param time 时间(秒)
     */
    public static void expire(String key, long time) {
        try {
            if (time > 0) {
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 根据key 获取过期时间
     * @param key 键 不能为null
     * @return 时间(秒) 返回0代表为永久有效
     */
    public static Long getExpire(String key) {
        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
    }

    /**
     * 判断key是否存在
     * @param key 键
     * @return true 存在 false不存在
     */
    public static Boolean hasKey(String key) {
        try {
            return redisTemplate.hasKey(key);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    public static boolean hasKey(long key) {
       return hasKey(Long.toString(key));
    }

    /**
     * 删除缓存
     * @param key 可以传一个值 或多个
     */
    @SuppressWarnings("all")
    public static void del(String... key) {
        if (key != null && key.length > 0) {
            if (key.length == 1) {
                redisTemplate.delete(key[0]);
            } else {
                redisTemplate.delete(CollectionUtils.arrayToList(key));
            }
        }
    }

    // ============================String=============================

    /**
     * 普通缓存获取
     * @param key 键
     * @return 值
     */
    public static Object get(String key) {
        return key == null ? null : redisTemplate.opsForValue().get(key);
    }

    public static Object get(long key) {
        return get(Long.toString(key));
    }

    /**
     * 普通缓存放入
     * @param key   键
     * @param value 值
     */

    public static void set(String key, Object value) {
        try {
            redisTemplate.opsForValue().set(key, value);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 普通缓存放入并设置时间
     * @param key   键
     * @param value 值
     * @param time  时间(秒) time要大于0 如果time小于等于0 将设置无限期
     */

    public static void set(String key, Object value, long time) {
        try {
            if (time > 0) {
                redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else {
                set(key, value);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 普通缓存放入并设置时间
     * @param key   键
     * @param value 值
     * @param time  时间(秒) time要大于0 如果time小于等于0 将设置无限期
     */
    public static void set(long key, Object value, long time) {
        set(Long.toString(key),value,time);
    }

    /**
     * 递增
     * @param key   键
     * @param delta 要增加几(大于0)
     */
    public static Long incr(String key, long delta) {
        if (delta < 0) {
            throw new RuntimeException("递增因子必须大于0");
        }
        return redisTemplate.opsForValue().increment(key, delta);
    }

    /**
     * 递减
     * @param key   键
     * @param delta 要减少几(小于0)
     */
    public static Long decr(String key, long delta) {
        if (delta < 0) {
            throw new RuntimeException("递减因子必须大于0");
        }
        return redisTemplate.opsForValue().increment(key, -delta);
    }

    // ================================Map=================================
    /**
     * HashGet
     * @param key  键 不能为null
     * @param item 项 不能为null
     */
    public static Object hget(String key, String item) {
        return redisTemplate.opsForHash().get(key, item);
    }

    /**
     * 获取hashKey对应的所有键值
     * @param key 键
     * @return 对应的多个键值
     */
    public static Map<Object, Object> hmget(String key) {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * HashSet
     * @param key 键
     * @param map 对应多个键值
     */
    public static boolean hmset(String key, Map<String, Object> map) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * HashSet 并设置时间
     * @param key  键
     * @param map  对应多个键值
     * @param time 时间(秒)
     * @return true成功 false失败
     */
    public static boolean hmset(String key, Map<String, Object> map, long time) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 向一张hash表中放入数据,如果不存在将创建
     *
     * @param key   键
     * @param item  项
     * @param value 值
     * @return true 成功 false失败
     */
    public static boolean hset(String key, String item, Object value) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 向一张hash表中放入数据,如果不存在将创建
     *
     * @param key   键
     * @param item  项
     * @param value 值
     * @param time  时间(秒) 注意:如果已存在的hash表有时间,这里将会替换原有的时间
     * @return true 成功 false失败
     */
    public static boolean hset(String key, String item, Object value, long time) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 删除hash表中的值
     *
     * @param key  键 不能为null
     * @param item 项 可以使多个 不能为null
     */
    public static void hdel(String key, Object... item) {
        redisTemplate.opsForHash().delete(key, item);
    }

    /**
     * 判断hash表中是否有该项的值
     *
     * @param key  键 不能为null
     * @param item 项 不能为null
     * @return true 存在 false不存在
     */
    public static boolean hHasKey(String key, String item) {
        return redisTemplate.opsForHash().hasKey(key, item);
    }

    /**
     * hash递增 如果不存在,就会创建一个 并把新增后的值返回
     *
     * @param key  键
     * @param item 项
     * @param by   要增加几(大于0)
     */
    public static double hincr(String key, String item, double by) {
        return redisTemplate.opsForHash().increment(key, item, by);
    }

    /**
     * hash递减
     *
     * @param key  键
     * @param item 项
     * @param by   要减少记(小于0)
     */
    public static double hdecr(String key, String item, double by) {
        return redisTemplate.opsForHash().increment(key, item, -by);
    }

    // ============================set=============================
    /**
     * 根据key获取Set中的所有值
     * @param key 键
     */
    public static Set<Object> sGet(String key) {
        try {
            return redisTemplate.opsForSet().members(key);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 根据value从一个set中查询,是否存在
     *
     * @param key   键
     * @param value 值
     * @return true 存在 false不存在
     */
    public static Boolean sHasKey(String key, Object value) {
        try {
            return redisTemplate.opsForSet().isMember(key, value);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 将数据放入set缓存
     *
     * @param key    键
     * @param values 值 可以是多个
     * @return 成功个数
     */
    public static Long sSet(String key, Object... values) {
        try {
            return redisTemplate.opsForSet().add(key, values);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return 0L;
    }

    /**
     * 将set数据放入缓存
     *
     * @param key    键
     * @param time   时间(秒)
     * @param values 值 可以是多个
     * @return 成功个数
     */
    public static Long sSetAndTime(String key, long time, Object... values) {
        try {
            Long count = redisTemplate.opsForSet().add(key, values);
            if (time > 0) {
                expire(key, time);
            }
            return count;
        } catch (Exception e) {
            e.printStackTrace();
            return 0L;
        }
    }

    /**
     * 获取set缓存的长度
     *
     * @param key 键
     */
    public static Long sGetSetSize(String key) {
        try {
            return redisTemplate.opsForSet().size(key);
        } catch (Exception e) {
            e.printStackTrace();
            return 0L;
        }
    }

    /**
     * 移除值为value的
     *
     * @param key    键
     * @param values 值 可以是多个
     * @return 移除的个数
     */

    public static Long setRemove(String key, Object... values) {
        try {
            return redisTemplate.opsForSet().remove(key, values);
        } catch (Exception e) {
            e.printStackTrace();
            return 0L;
        }
    }
    // ===============================list=================================

    /**
     * 获取list缓存的内容
     *
     * @param key   键
     * @param start 开始
     * @param end   结束 0 到 -1代表所有值
     */
    public static List<Object> lGet(String key, long start, long end) {
        try {
            return redisTemplate.opsForList().range(key, start, end);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 获取list缓存的长度
     *
     * @param key 键
     */
    public static Long lGetListSize(String key) {
        try {
            return redisTemplate.opsForList().size(key);
        } catch (Exception e) {
            e.printStackTrace();
            return 0L;
        }
    }

    /**
     * 通过索引 获取list中的值
     *
     * @param key   键
     * @param index 索引 index>=0时, 0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推
     */
    public static Object lGetIndex(String key, long index) {
        try {
            return redisTemplate.opsForList().index(key, index);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     */
    public static boolean lSet(String key, Object value) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 将list放入缓存
     * @param key   键
     * @param value 值
     * @param time  时间(秒)
     */
    public static Boolean lSet(String key, Object value, long time) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     */
    public static boolean lSet(String key, List<Object> value) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 将list放入缓存
     *
     * @param key   键
     * @param value 值
     * @param time  时间(秒)
     */
    public static boolean lSet(String key, List<Object> value, long time) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 根据索引修改list中的某条数据
     *
     * @param key   键
     * @param index 索引
     * @param value 值
     */

    public static boolean lUpdateIndex(String key, long index, Object value) {
        try {
            redisTemplate.opsForList().set(key, index, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 移除N个值为value
     *
     * @param key   键
     * @param count 移除多少个
     * @param value 值
     * @return 移除的个数
     */

    public static Long lRemove(String key, long count, Object value) {
        try {
            return redisTemplate.opsForList().remove(key, count, value);
        } catch (Exception e) {
            e.printStackTrace();
            return 0L;
        }

    }
}

5. shiro配置

  1. 重写shiro的入口过滤器BasicHttpAuthenticationFilter
    这是本项目最核心的部分,主要做3件事

    1. 判断请求接口是否需要进行登录认证授权,如果需要则该请求就必须在Header中添加token字段存AccessToken,无需授权则直接访问。
    2. 需要授权的接口就调用getSubject(request, response).login(token),将AccessToken提交给shiro中的CustomRealm进行认证。
    3. AccessToken刷新:判断RefreshToken是否过期,未过期就返回新的AccessToken及RefreshToken并让请求继续正常访问。
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
    /**
     * 判断是否允许通过
     *
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        log.info("isAccessAllowed方法");
        try{
            return executeLogin(request,response);
        }catch (Exception e){
            log.info("错误"+e);
            responseError(response,"shiro fail");
            return false;
        }
    }

    /**
     * 是否进行登录请求
     *
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        log.info("isLoginAttempt方法");
        String token=((HttpServletRequest)request).getHeader("token");
        return token != null;
    }

    /**
     * 创建shiro token
     *
     */
    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
        log.info("createToken方法");
        String jwtToken = ((HttpServletRequest)request).getHeader("token");
        if(jwtToken!=null) {
            return new JwtToken(jwtToken);
        }
        return null;
    }

    /**
     * isAccessAllowed为false时调用,验证失败
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) {
        log.info("onAccessDenied");
        this.sendChallenge(request,response);
        responseError(response,"token verify fail");
        return false;
    }



    /**
     * shiro验证成功调用
     */
    @Override
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) {
        log.info("onLoginSuccess:");
        String jwttoken= (String) token.getPrincipal();
        if (jwttoken!=null){
            try{
                if(JwtUtil.verify(jwttoken)){
                    //判断Redis是否存在所对应的RefreshToken
                    long userId = JwtUtil.getUserId(jwttoken);
                    Long currentTime=JwtUtil.getCurrentTime(jwttoken);
                    if (RedisUtil.hasKey(userId)) {
                        Long currentTimeMillisRedis = (Long) RedisUtil.get(userId);
                        return currentTimeMillisRedis.equals(currentTime);
                    }
                }
                return false;
            }catch (Exception e){
                log.info("token验证:"+e.getClass());
                if (e instanceof TokenExpiredException){
                    log.info("TokenExpiredException");
                    return refreshToken(request, response);
                }
            }
        }
        return true;
    }



    /**
     * 拦截器的前置方法,此处进行跨域处理
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest= (HttpServletRequest) request;
        HttpServletResponse httpServletResponse= (HttpServletResponse) response;
        httpServletResponse.setHeader("Access-Control-Allow-Origin",httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods","GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers",httpServletRequest.getHeader("Access-Control-Resquest-Headers"));
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())){
            httpServletResponse.setStatus(HttpStatus.OK.value());
        }

        //如果不带token,不去验证shiro
        if (!isLoginAttempt(request,response)){
            responseError(httpServletResponse,"no token");
            return false;
        }
        return super.preHandle(request,response);
    }


    /**
     * 刷新AccessToken,进行判断RefreshToken是否过期,未过期就返回新的AccessToken且继续正常访问
     */
    private boolean refreshToken(ServletRequest request, ServletResponse response) {
        String token = ((HttpServletRequest)request).getHeader("token");
        long userId = JwtUtil.getUserId(token);
        Long currentTime=JwtUtil.getCurrentTime(token);
        // 判断Redis中RefreshToken是否存在
        if (RedisUtil.hasKey(userId)) {
            // Redis中RefreshToken还存在,获取RefreshToken的时间戳
            Long currentTimeMillisRedis = (Long) RedisUtil.get(userId);
            // 获取当前AccessToken中的时间戳,与RefreshToken的时间戳对比,如果当前时间戳一致,进行AccessToken刷新
            if (currentTimeMillisRedis.equals(currentTime)) {
                // 获取当前最新时间戳
                Long currentTimeMillis =System.currentTimeMillis();
                RedisUtil.set(userId, currentTimeMillis,JwtUtil.REFRESH_EXPIRE_TIME);
                // 刷新AccessToken,设置时间戳为当前最新时间戳
                token = JwtUtil.sign(userId, currentTimeMillis);
                HttpServletResponse httpServletResponse = (HttpServletResponse) response;
                httpServletResponse.setHeader("Authorization", token);
                httpServletResponse.setHeader("Access-Control-Expose-Headers", "Authorization");
                return true;
            }
        }
        return false;
    }

    private void responseError(ServletResponse response, String msg){

        HttpServletResponse httpResponse = (HttpServletResponse) response;
        httpResponse.setStatus(401);
        httpResponse.setCharacterEncoding("UTF-8");
        httpResponse.setContentType("application/json;charset=UTF-8");
        try {
            String rj = new ObjectMapper().writeValueAsString(new Result<>(401,msg));
            httpResponse.getWriter().append(rj);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

JwtFilter需要用到的几个类说明

  1. Result返回数据统一封装类
  2. JwtToken:shiro中Subje.login接收参数为AuthenticationToken,所以我们需要对我们的token进行封装。

这里解析一下该类的执行流程:首先需要授权的请求经过preHandle进行跨域处理后进入isAccessAllowed方法,isAccessAllowed方法直接调用BasicHttpAuthenticationFilter类的父类AuthenticatingFilter中executeLogin方法,executeLogin方法源码如下:

protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        AuthenticationToken token = this.createToken(request, response);
        if (token == null) {
            String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken must be created in order to execute a login attempt.";
            throw new IllegalStateException(msg);
        } else {
            try {
                Subject subject = this.getSubject(request, response);
                subject.login(token);
                return this.onLoginSuccess(token, subject, request, response);
            } catch (AuthenticationException var5) {
                return this.onLoginFailure(token, var5, request, response);
            }
        }
    }

该方法会先调用createToken方法创建token,然后调用this.getSubject(request, response)进行shiro授权但subject.login参数类型为AuthenticationToken和我们的token不一致,因此我们需要重写createToken方法,并创建一个实现了AuthenticationToken接口的JwtToken包装类

JwtToken

public class JwtToken implements AuthenticationToken {
    private String token;

    public JwtToken(String token) {
        this.token = token;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}

executeLogin方法后,授权成功会进入onLoginSuccess方法,该方法进行token的检验,token的检验失败进入onAccessDenied。

2. 自定义Realm

@Component
@Slf4j
public class MyReailm extends AuthorizingRealm {
    @Resource
    UserMapper userMapper;

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 用户授权
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        log.info("用户授权");
        long userId = JwtUtil.getUserId(principalCollection.toString());
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        User user = userMapper.selectById(userId);
        Integer authority = user.getAuthority();
        Set<String> role = new HashSet<>();
        /*
         * 权限说明:2查看权限,3查看和编辑权限,
         * 6查看和授权权限,7查看、编辑、授权权限
         */
        switch (authority) {
            case 2:
                role.add("view");
                break;
            case 3:
                role.add("view");
                role.add("edit");
                break;
            case 6:
                role.add("view");
                role.add("authorization");
                break;
            case 7:
                role.add("view");
                role.add("edit");
                role.add("authorization");
                break;
            default:
                role.add("tourist");
        }
        //设置角色集合
        //设置权限方式与设置角色类似使用info.setStringPermissions();
        info.setRoles(role);
        return info;
    }

    /**
     * 用户身份认证
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        log.info("身份认证");
        String token = (String) authenticationToken.getCredentials();
        long userId = JwtUtil.getUserId(token);
        log.info(userId+"");
        User user = userMapper.selectById(userId);
        if (userId <= 0 || user == null) {
            throw new AuthenticationException("认证失败!");
        }
        return new SimpleAuthenticationInfo(token, token, "MyRealm");
    }
}
  1. Shiro配置ShiroConfig
import com.maplexl.shiroJwtRedis.bean.MyReailm;
import com.maplexl.shiroJwtRedis.filter.JwtFilter;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;

import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * shiro 配置类
 */
@Configuration
public class ShiroConfig {
    @Bean("shiroFilter")
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 过滤器
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        // 配置不会被拦截的链接 顺序判断
        // 登录接口排除
        filterChainDefinitionMap.put("/login", "anon");
        // 注册接口排除
        filterChainDefinitionMap.put("/register", "anon");
        //登出接口排除
        filterChainDefinitionMap.put("/logout", "anon");
        //欢迎页排除
        filterChainDefinitionMap.put("/", "anon");
        filterChainDefinitionMap.put("/**/*.js", "anon");
        filterChainDefinitionMap.put("/**/*.css", "anon");
        filterChainDefinitionMap.put("/**/*.html", "anon");
        filterChainDefinitionMap.put("/**/*.jpg", "anon");
        filterChainDefinitionMap.put("/**/*.png", "anon");
        filterChainDefinitionMap.put("/**/*.ico", "anon");
        //swagger资源排除
        filterChainDefinitionMap.put("/swagger-ui.html/**","anon");
        filterChainDefinitionMap.put("/swagger-resources/**","anon");
        filterChainDefinitionMap.put("/v2/**","anon");

        // 添加自己的过滤器并且取名为jwt
        Map<String, Filter> filterMap = new HashMap<>(1);
        filterMap.put("jwt", new JwtFilter());
        shiroFilterFactoryBean.setFilters(filterMap);
        // 过滤链定义,从上向下顺序执行,一般将放在最为下边
        filterChainDefinitionMap.put("/**", "jwt");

        //未授权界面返回JSON
        shiroFilterFactoryBean.setUnauthorizedUrl("/sys/common/403");
        shiroFilterFactoryBean.setLoginUrl("/sys/common/403");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    @Bean("securityManager")
    public DefaultWebSecurityManager securityManager(MyReailm myRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(myRealm);

        /*
         * 关闭shiro自带的session,详情见文档
         * http://shiro.apache.org/session-management.html#SessionManagement-
         * StatelessApplications%28Sessionless%29
         */
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);

        return securityManager;
    }

    /**
     * 下面的代码是添加注解支持
     */
    @Bean
    @DependsOn("lifecycleBeanPostProcessor")
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }
}

6. 全局异常处理

@ControllerAdvice
@ResponseBody
@SuppressWarnings("unused")
public class GlobalExceptionHandler {


    /**
     * 捕捉所有Shiro异常
     */
    @ExceptionHandler(ShiroException.class)
    public Result<String> handle401(ShiroException e) {
        return new Result<>(401, "无权访问(Unauthorized):" + e.getMessage());
    }

    /**
     * 单独捕捉Shiro(UnauthorizedException)异常 该异常为访问有权限管控的请求而该用户没有所需权限所抛出的异常
     */
    @ExceptionHandler(UnauthorizedException.class)
    public Result<String> handle401(UnauthorizedException e) {
        return new Result<>(401, "无权访问(Unauthorized):当前Subject没有此请求所需权限(" + e.getMessage() + ")");
    }

    /**
     * 单独捕捉Shiro(UnauthenticatedException)异常
     * 该异常为以游客身份访问有权限管控的请求无法对匿名主体进行授权,而授权失败所抛出的异常
     */
    @ExceptionHandler(UnauthenticatedException.class)
    public Result<String> handle401(UnauthenticatedException e) {
        e.printStackTrace();
        return new Result<>(401, "无权访问(Unauthorized):当前Subject是匿名Subject,请先登录(This subject is anonymous.)");
    }

    /**
     * 捕捉校验异常(BindException)
     */
    @ExceptionHandler(BindException.class)
    public Result<Object> validException(BindException e) {
        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
        Map<String, Object> error = this.getValidError(fieldErrors);
        return new Result<>(400, error.get("errorMsg").toString(), error.get("errorList"));
    }


    /**
     * 捕捉404异常
     */
    @ExceptionHandler(NoHandlerFoundException.class)
    public Result<Object> handle(NoHandlerFoundException e) {
        return new Result<>(404, e.getMessage());
    }

    /**
     * 捕捉其他所有异常
     */
    @ExceptionHandler(Exception.class)
    public Result<Object> globalException(HttpServletRequest request, Throwable ex) {
        return new Result<>(500, ex.toString() + ": " + ex.getMessage());
    }


    /**
     * 获取状态码
     */
    private HttpStatus getStatus(HttpServletRequest request) {
        Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code");
        if (statusCode == null) {
            return HttpStatus.INTERNAL_SERVER_ERROR;
        }
        return HttpStatus.valueOf(statusCode);
    }

    /**
     * 获取校验错误信息
     */
    private Map<String, Object> getValidError(List<FieldError> fieldErrors) {
        Map<String, Object> map = new HashMap<>(16);
        List<String> errorList = new ArrayList<>();
        StringBuffer errorMsg = new StringBuffer("校验异常(ValidException):");
        for (FieldError error : fieldErrors) {
            errorList.add(error.getField() + "-" + error.getDefaultMessage());
            errorMsg.append(error.getField()).append("-").append(error.getDefaultMessage()).append(".");
        }
        map.put("errorList", errorList);
        map.put("errorMsg", errorMsg);
        return map;
    }
}

7. 编写业务

  1. LoginController
@RestController
@Slf4j
public class LoginController {
    @Resource
    UserService userService;

    /**
     * 登录接口
     * @param user 用户
     * @param response 响应
     * @return 登录成功则返回token
     */
    @ApiOperation("登录接口")
    @PostMapping("/login")
    public Result<User> login(@RequestBody User user, HttpServletResponse response) {
        String userName = user.getUserName();
        String password = user.getPassword();
        if (StringUtils.isEmpty(userName) || StringUtils.isEmpty(password)) {
            return new Result<>(CommonEnum.INVALID_CHARACTER);
        }
        //查询是否有此用户
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("user_name", userName);
        User dbUser = userService.getOne(wrapper);
        if (dbUser == null) {
            return new Result<>(CommonEnum.NO_SUCH_USER);
        }
        //校验密码
        password = new SimpleHash("MD5", password, dbUser.getSalt(), 32).toString();
        if (password.equals(dbUser.getPassword())) {
            //密码正确
            long timeMillis = System.currentTimeMillis();
            String token = JwtUtil.sign(dbUser.getUserId(), timeMillis);
            dbUser.setPassword(null);
            //token放入redis
            RedisUtil.set(dbUser.getUserId(), timeMillis, JwtUtil.REFRESH_EXPIRE_TIME);
            response.setHeader("Authorization", token);
            response.setHeader("Access-Control-Expose-Headers", "Authorization");
            return new Result<>(CommonEnum.SUCCESS, dbUser);
        }
        return new Result<>(CommonEnum.PASSWORD_ERROR);
    }

    /**
     * 添加用户,即注册
     *
     * @param user 用户信息
     * @return 是否保存成功
     */
    @ApiOperation("注册接口")
    @PostMapping("/register")
    public Result<Boolean> add(@RequestBody User user) {
        //验证用户名是否重复
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("user_name",user.getUserName());
        User dbUser = userService.getOne(wrapper);
        if (dbUser!=null){
            return new Result<>(CommonEnum.USER_EXIST);
        }
        //加密用户的密码
        String salt = UUID.randomUUID().toString().substring(0, 24);
        String password = new SimpleHash("MD5", user.getPassword(), salt, 32).toString();
        user.setSalt(salt);
        user.setPassword(password);
        boolean save = userService.save(user);
        if (save) {
            return new Result<>(CommonEnum.SUCCESS, true);
        }
        return new Result<>(CommonEnum.INVALID_INSERT, false);
    }

    /**
     * 登出
     * @return 是否登出
     */
    @GetMapping("/logout")
    public Result<Boolean> logout(HttpServletRequest request){
        /*
         * 清除redis中的RefreshToken即可
         */
        Long userId = JwtUtil.getUserId(request);
        RedisUtil.del(Long.toString(userId));
        return new Result<>(CommonEnum.SUCCESS);
    }
}
  1. UserController
    用于对用户CRUD
@RestController
public class UserController {
    @Resource
    UserService userService;

    /**
     * 根据id删除用户
     *
     * @param userId 用户id
     * @return 是否删除成功
     */
    @RequiresRoles("edit")
    @DeleteMapping("/user/{id}")
    public Result<Boolean> delete(@PathVariable("id") Long userId) {
        boolean del = userService.removeById(userId);
        if (del) {
            return new Result<>(CommonEnum.SUCCESS, true);
        }
        return new Result<>(CommonEnum.INVALID_DELETE, false);
    }

    /**
     * 修改用户信息
     *
     * @param user 需修改的信息
     * @return 是否成功
     */
    @RequiresRoles("edit")
    @PutMapping("/user")
    public Result<Boolean> update(@RequestBody User user) {
        boolean up = userService.updateById(user);
        if (up) {
            return new Result<>(CommonEnum.SUCCESS, true);
        }
        return new Result<>(CommonEnum.INVALID_UPDATE, false);
    }

    /**
     * 根据用户id查询用户
     *
     * @param userId 用户id
     * @return 用户信息
     */
    @RequiresRoles("view")
    @GetMapping("/user/{id}")
    public Result<User> getById(@PathVariable("id") Long userId) {
        User user = userService.getById(userId);
        if (user != null) {
            return new Result<>(CommonEnum.SUCCESS, user);
        }
        return new Result<>(CommonEnum.NO_SUCH_USER);
    }

    /**
     * 获取用户列表
     *
     * @return 用户列表
     */
    @RequiresRoles("view")
    @GetMapping("/user/list")
    public Result<List<User>> getList() {
        List<User> list = userService.list();
        return new Result<>(CommonEnum.SUCCESS, list);
    }
}

8. 测试

  1. 无token执行

  1. 有token(view身份)

删除操作

  1. 等待5分钟AccessToken过期执行/user/list

可以看到,请求是成功的,并且在响应头里返回了新的token

四、遗留问题解决

在测试时发现userId与数据库的userId不一致,前端显示的userId最后两位是0但数据库不是,明显属于精度丢失。
这是因为JavaScript没有Long而我的id采用雪花算法足足19位超出了JavaScript处理范围。

解决方案如下

配置jackson将Long序列化成字符串

@JsonComponent
public class JsonSerializerManage {

    @Bean
    public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        SimpleModule module = new SimpleModule();
        module.addSerializer(Long.class, ToStringSerializer.instance);
        module.addSerializer(Long.TYPE, ToStringSerializer.instance);
        objectMapper.registerModule(module);
        return objectMapper;
    }
}

最后贴上项目目录结构和git地址


gitEE地址:https://gitee.com/mapleleafred/shiro-jwt-redis
若读者有什么疑问或发现错误欢迎给我留言,我看到后会第一时间回复