• Redis实战 - 17 Redis事务和乐观锁实现缓存登录图片验证码功能


    用户登录时会让填写图片验证码,我们的产品需求是实现图片验证功能,验证码最多获取60次,超过60次后获取验证码功能会被锁定,锁定时长为10min,这里将实现的业务功能做一个简单总结;

    1. 项目环境搭建

    创建项目 kapatcher,导入该功能的相关依赖:

    <dependencies>
        
        <dependency>
            <groupId>cn.hutoolgroupId>
            <artifactId>hutool-allartifactId>
            <version>5.7.20version>
        dependency>
        
        <dependency>
            <groupId>com.github.pengglegroupId>
            <artifactId>kaptchaartifactId>
            <version>2.3.2version>
        dependency>
        <dependency>
            <groupId>org.projectlombokgroupId>
            <artifactId>lombokartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-data-redisartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-configuration-processorartifactId>
            <optional>trueoptional>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-testartifactId>
            <scope>testscope>
        dependency>
    dependencies>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    SpringBoot配置文件:

    spring:
      redis:
        host: localhost
        port: 6379
      messages:
        basename: i18n/messages
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在这里插入图片描述

    2. 项目环境公共配置

    1. 生成图片验证码配置类 KaptchaConfig
    @Configuration
    public class KaptchaConfig {
     
        private final static String CODE_LENGTH = "4";
     
        private final static String SESSION_KEY = "handsome_yang";
     
        @Bean
        public DefaultKaptcha defaultKaptcha() {
            DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
            Properties properties = new Properties();
            // 设置边框,合法值:yes , no
            properties.setProperty("kaptcha.border", "yes");
            // 设置边框颜色,合法值: r,g,b (and optional alpha) 或者 white,
            properties.setProperty("kaptcha.border.color", "105,179,90");
            // 设置字体颜色, r,g,b 或者 white,black,blue.
            properties.setProperty("kaptcha.textproducer.font.color", "blue");
            // 设置图片宽度
            properties.setProperty("kaptcha.image.width", "118");
            // 设置图片高度
            properties.setProperty("kaptcha.image.height", "40");
            // 设置字体尺寸
            properties.setProperty("kaptcha.textproducer.font.size", "30");
            // 设置session key
            properties.setProperty("kaptcha.session.key", SESSION_KEY);
            // 设置验证码长度
            properties.setProperty("kaptcha.textproducer.char.length", CODE_LENGTH);
            // 设置字体
            properties.setProperty("kaptcha.textproducer.font.names", "楷体");
            Config config = new Config(properties);
            defaultKaptcha.setConfig(config);
            return defaultKaptcha;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    2. RedisTemplate 配置类 RedisConfig
    @Configuration
    public class RedisConfig {
    
        @Bean
        public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
            RedisTemplate<String, String> template = new RedisTemplate<>();
            template.setConnectionFactory(factory);
            // 设置key的序列化方式
            template.setKeySerializer(new StringRedisSerializer());
            // 设置value的序列化方式
            template.setValueSerializer(RedisSerializer.string());
            template.afterPropertiesSet();
            return template;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    3. 验证码属性配置类 VerifyCodeProperties
    /**
     * 验证码配置属性
     */
    @Component
    @ConfigurationProperties(prefix = "ngsoc.verify-code")
    @Data
    public class VerifyCodeProperties {
    
        /**
         * 验证码锁定时长,单位:秒
         */
        private int lockTimeout = 10 * 60;
    
        /**
         * 验证码过期时长,单位:秒
         */
        private int expireTimeout = 60;
    
        /**
         * 验证码获取次数阈值
         */
        private int limitCount = 60;
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    4. 自定义异常 VerifyCodeLimitException
    public class VerifyCodeLimitException extends RuntimeException {
    
        public VerifyCodeLimitException(String i18eCode){
            super(I18nUtils.tryI18n(i18eCode));
        }
    
        public VerifyCodeLimitException(String i18eCode, Object... args) {
            super(I18nUtils.tryI18n(i18eCode,args));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    5. 自定义异常 VerifyCodeWrongException
    public class VerifyCodeWrongException extends RuntimeException {
    
        public VerifyCodeWrongException(String i18eCode){
            super(I18nUtils.tryI18n(i18eCode));
        }
    
        public VerifyCodeWrongException(String i18eCode, Object... args) {
            super(I18nUtils.tryI18n(i18eCode,args));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    6. 统一异常处理 GlobalExceptionHandler
    @RestControllerAdvice
    @Slf4j
    public class GlobalExceptionHandler {
    
        @ExceptionHandler(VerifyCodeLimitException.class)
        public ApiResponse<Object> handleVerifyCodeLimitException(VerifyCodeLimitException e) {
            log.error(e.getMessage(), e);
            return new ApiResponse<>(-1,"error",e.getMessage());
        }
    
        @ExceptionHandler(VerifyCodeWrongException.class)
        public ApiResponse<Object> handleVerifyCodeWrongException(VerifyCodeWrongException e) {
            log.error(e.getMessage(), e);
            return new ApiResponse<>(-1,"error",e.getMessage());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    7. i18n国际化工具类 I18nUtils
    // @Autowired 自动装配仅在托管类中有效(例如,注释为@ Component,@ Service或在应用程序上下文xml中定义)。
    @Component
    @Slf4j
    public class I18nUtils {
    
        // 如果当前bean不加@Component注解,则messageSource无法注入,始终为null
        private static MessageSource messageSource;
    
        @Autowired
        public void setMessageSource(MessageSource messageSource) {
            I18nUtils.messageSource = messageSource;
        }
    
        /**
         * 解析code对应的信息进行返回,如果对应的code不能被解析则抛出异常NoSuchMessageException
         *
         * @param code 需要进行解析的code,对应资源文件中的一个属性名
         * @param args 当对应code对应的信息不存在时需要返回的默认值
         * @return 国际化翻译值
         */
        public static String i18n(String code, Object... args) {
            return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
        }
    
        /**
         * 解析code对应的信息进行返回,如果对应的code不能被解析则返回默认信息defaultMessage。
         *
         * @param code 需要进行解析的code,对应资源文件中的一个属性名
         * @param defaultMessage 当对应code对应的信息不存在时需要返回的默认值
         * @param args 需要用来替换code对应的信息中包含参数的内容,如:{0},{1,date},{2,time}
         * @return 对应的Locale
         */
        public static String i18nOrDefault(String code, String defaultMessage, Object... args) {
            return messageSource.getMessage(code, args, defaultMessage, LocaleContextHolder.getLocale());
        }
    
        /**
         * 因为i18n方法如果获取不到对应的键值,会抛异常NoSuchMessageException
         * 本方法是对i18n方法的封装。当报错时并不抛出异常,而是返回source
         *
         * @param source 模板
         * @param args   参数
         * @return 返回I18n(正常结束)或者source(抛出异常)
         * @see #i18n(String, Object...)
         */
        public static String tryI18n( String source, @NonNull Object... args) {
            String res;
            try {
                res = i18n(source, args);
            } catch (Exception ignored) {
                res = source;
            }
            return res;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    8. i18n 异常消息

    i18n/messages.properties

    api.verifyCode.acquire.lock=acquire verification code after {0} s
    redis.incr.verify.code.error=redis incr verify code error
    api.verifyCode.wrong=verification code is wrong
    
    • 1
    • 2
    • 3

    i18n/messages_zh_CN.properties

    api.verifyCode.acquire.lock=请在 {0} 秒后获取验证码
    redis.incr.verify.code.error=增加验证码访问次数异常
    api.verifyCode.wrong=验证码错误
    
    • 1
    • 2
    • 3

    3. 生成验证码功能

    1. 响应类-VerifyCodeVo
    @Data
    public class VerifyCodeVo {
    
        /**
         * 访问key
         */
        private String codeKey;
    
        /**
         * 验证码
         */
        private String verifyCode;
    
        /**
         * 验证码过期时间
         */
        private int timeout;
    
        public VerifyCodeVo(String codeKey, String verifyCode) {
            this(codeKey, verifyCode, 0);
        }
    
        public VerifyCodeVo(String codeKey, String verifyCode, int timeout) {
            this.codeKey = codeKey;
            this.verifyCode = verifyCode;
            this.timeout = timeout;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    2. 控制层-获取验证码
    @RestController
    @RequestMapping("/api/v2/auth")
    @Validated
    @Slf4j
    public class AuthController {
    
        @Autowired
        private DefaultKaptcha defaultKaptcha;
    
        @Autowired
        private VerificationCodeService verificationCodeService;
    
       /**
         * 获取验证码
         * @param request
         * @param response
         * @return
         * @throws Exception
         */
        @GetMapping(value = "/verifyCode")
        public ResponseEntity<byte[]> acquireVerifyCode(HttpServletRequest request, HttpServletResponse response) throws Exception {
            // 获取远程客户端的真实IP
            String clientIp ="1.1.1.1";
            
            // 生成验证码
            VerifyCodeVo verifyCodeVo = verificationCodeService.acquire(clientIp);
            
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.IMAGE_JPEG);
            headers.setCacheControl(CacheControl.noCache());
            
            // 将验证码存储在客户端的cookie中
            Cookie cookie = new Cookie("captcha", verifyCodeVo.getAccessKey());
            cookie.setHttpOnly(true);
            cookie.setMaxAge(verifyCodeVo.getTimeout());
            cookie.setPath("/");
            cookie.setSecure(true);
            response.addCookie(cookie);
            
            // 生成验证码图片
            BufferedImage image = defaultKaptcha.createImage(verifyCodeVo.getVerifyCode());
            
            ByteArrayOutputStream byteArrayOutputStream = null;
            try {
                byteArrayOutputStream = new ByteArrayOutputStream();
                ImageIO.write(image, "jpg", byteArrayOutputStream);
            } catch (IOException e) {
                log.error("响应验证码失败:" + e.getMessage());
            }
            return ResponseEntity.ok()
                				 .headers(headers)
                				 .body(byteArrayOutputStream.toByteArray());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    3. 业务层-生成验证码并存储
    @Service
    @Slf4j
    public class RedisVerificationCodeServiceImpl implements VerificationCodeService {
    
        private static final String COUNT_KEY_PREFIX = "ngsoc:portal:imageCode:count:";
        private static final String VALUE_KEY_PREFIX = "ngsoc:portal:imageCode:value:";
    
        public static String generateCountKey(String clientId){
            return COUNT_KEY_PREFIX + clientId;
        }
    
        public static String generateCodeKey(String accessKey){
            return VALUE_KEY_PREFIX + accessKey;
        }
    
        @Autowired
        private DefaultKaptcha defaultKaptcha;
    
        @Autowired
        private RedisTemplate<String, String> redisTemplate;
    
        @Autowired
        private VerifyCodeProperties verifyCodeProperties;
    
        /**
         * 生成验证码并存储
         *
         * @param clientId 客户端ID
         * @return VerifyCodeVo
         */
        @Override
        public VerifyCodeVo acquire(String clientId) {
            // 累计clientId获取验证码次数
            long count = this.incrCount(clientId);
            log.info("client [{}] acquire verify code, times={}", clientId, count);
            // 生成验证码
            String code = defaultKaptcha.createText();
            // 保存验证码到redis
            String key = generateCodeKey( UUID.randomUUID().toString());
            this.saveCode(key,code,verifyCodeProperties.getExpireTimeout());
            return new VerifyCodeVo(key,code,verifyCodeProperties.getExpireTimeout());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    3.1 计算获取验证码的次数

    验证码最多获取60次,超过60次后获取验证码功能会被锁定,锁定时长为10min

    /***
     * 累计clientId获取验证码次数
     *
     * @param clientId 远程客户端的真实IP
     * @return 获取验证码的次数
    */
    private long incrCount(String clientId) {
        
        String key = generateCountKey(clientId);
        String value = redisTemplate.opsForValue().get(key);
        long count = Optional.ofNullable(value).map(Long::parseLong).orElse(0L);
        //count超过验证码获取次数阈值,抛出异常,并导致expire时间后获取验证码
        if(count > verifyCodeProperties.getLimitCount()){
            Long expire = redisTemplate.getExpire(key, TimeUnit.SECONDS);
            int expireTime = Optional.ofNullable(expire).orElse(0L).intValue();
            throw new VerifyCodeLimitException("api.verifyCode.acquire.lock",expireTime);
        }
        
        //使用SessionCallBack接口,从而保证所有的命令都是通过同一个Redis的连接进行操作的
        SessionCallback<Object> callback = new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                //监视key
                operations.watch(key);
                //开启事务
                operations.multi();
                operations.opsForValue().increment(key);
                if (count == 0) {
                    //设置验证码的过期时长
                    operations.expire(key, verifyCodeProperties.getExpireTimeout(), TimeUnit.SECONDS);
                } else if (count == verifyCodeProperties.getLockTimeout()) {
                    // 如果count达到验证码获取次数阈值,设置验证码的锁定时长
                    operations.expire(key, verifyCodeProperties.getLockTimeout(), TimeUnit.SECONDS);
                }
                // 执行事务
                List<Object> resultList = operations.exec();
                Long incr = Optional.ofNullable(resultList)
                    .filter(result -> result.size() > 0)
                    .map(result -> Long.parseLong(result.get(0).toString())).orElse(0L);
                return incr;
            }
        };
        
        long incr;
        try {
            incr = (Long)redisTemplate.execute(callback);
        }catch (RedisException e){
            log.error("redis incr verify code error: ", e);
            // 回滚事务
            redisTemplate.discard();
            throw new VerifyCodeLimitException("redis.incr.verify.code.error");
        }finally {
            // 取消对key的监视
            redisTemplate.unwatch();
        }
        
        if(incr == 0){
            throw new VerifyCodeLimitException("redis.incr.verify.code.error");
        }else if(incr> verifyCodeProperties.getLimitCount()){
            throw new VerifyCodeLimitException("redis.incr.verify.code.error");
        }
        return incr;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    3.2 存储验证码到Redis
    /**
     * 存储验证码
     *
     * @param key  redis的 key
     * @param code 验证码
     * @param expireTimeout 验证码过期时间
    */
    private void saveCode(String key, String code, int expireTimeout) {
        this.redisTemplate.opsForValue().set(key,code,expireTimeout,TimeUnit.SECONDS);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    4. 启动项目测试

    启动项目并访问获取验证码接口:http://localhost:8080/api/v2/auth/verifyCode

    在这里插入图片描述

    查看redis中存储的数据:

    在这里插入图片描述

    4. 登录接口校验验证码

    1. 请求类-LoginRequestVo
    @Data
    public class LoginRequestVo {
        /**
         * 用户名
         */
        private String username;
    
        /**
         * 密码 (密文)
         */
        private String password;
    
        /**
         * 验证码
         */
        private String verifyCode;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    2. 控制层-校验验证码
    @RestController
    @RequestMapping("/api/v2/auth")
    @Validated
    @Slf4j
    public class AuthController {
    
        @Autowired
        private DefaultKaptcha defaultKaptcha;
    
        @Autowired
        private VerificationCodeService verificationCodeService;
    
        @PostMapping(value = "/login")
        public ApiResponse<Object> login(
                LoginRequestVo requestVo,
                @CookieValue(value = "captcha", required = false) String accessKey,
                HttpServletRequest httpRequest,
                HttpServletResponse httpServletResponse) {
            String clientIp ="1.1.1.1";
            if (!verificationCodeService.verify(clientIp, new VerifyCodeVo(accessKey, requestVo.getVerifyCode()))) {
                throw new VerifyCodeWrongException("api.verifyCode.wrong");
            }
            // 省略登录过程....
            return new ApiResponse<>(0,"success");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    3. 业务层-校验redis存储的验证码和用户输入的验证码
    @Service
    @Slf4j
    public class RedisVerificationCodeServiceImpl implements VerificationCodeService {
        
        public static String generateCodeKey(String accessKey){
            return VALUE_KEY_PREFIX + accessKey;
        }
        
        /**
         * 校验验证码
         * 
         * @param clientId  客户端ID
         * @param verifyCodeVo     验证码
         * @return 校验成功与否
         */
        @Override
        public boolean verify(String clientId, VerifyCodeVo verifyCodeVo) {
            String cacheCode = this.findCode(verifyCodeVo.getCodeKey());
            return cacheCode.equalsIgnoreCase(verifyCodeVo.getVerifyCode());
        }
    
        /**
         * 获取验证码
         * @param codeKey 访问key
         * @return 验证码
         */
        private String findCode(String codeKey) {
            String code = this.redisTemplate.opsForValue().get(codeKey);
            if(Objects.isNull(code)){
                throw new VerifyCodeWrongException("api.verifyCode.wrong");
            }
            // 获取验证码后删除redis的key
            this.redisTemplate.delete(codeKey);
            return code;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    4. 启动项目测试

    首先获取一个验证码:
    在这里插入图片描述

    复制响应的Cookie并放在登录接口的请求头中:

    在这里插入图片描述

    登录接口的请求头中放入Cookie信息:

    在这里插入图片描述

    登录接口的请求体信息:

    在这里插入图片描述

  • 相关阅读:
    苹果手机丢了如何通过定位找回?iPhone手机丢失定位找回方法
    vue根据树状结构生成流程图
    编程学习的方向与赛道的选择没有最优解的
    算法与设计分析 | 全排列问题
    我把一个json格式的数据读到dataframe里面了 怎么解析出自己需要的字段呢?
    数据结构 图 并查集 遍历方法 最短路径算法 最小生成树算法 简易代码实现
    基于Python+Pytest+Playwright+BDD的UI自动化测试框架
    一款适用于.Net的高性能文件上传项目
    Golang 常见知识点整理
    遥控车模的电机控制器
  • 原文地址:https://blog.csdn.net/qq_42764468/article/details/127589029