场景:在很多地方可能都会用到发送验证码、校验验证码的服务;
目的:提供验证码的存储(单机缓存)、校验功能

实现思路

验证码存储、验证工能,分解下来有以下几点:

  1. 由于验证码一般都有有效期:所以可以利用有过期时间的缓存来存储;本章使用 hutool 的 TimedCache 来实现
  2. 验证码再次发送的时候,一般是是页面人工多次点击,那么需要提供剩余的秒数方便页面展示或提示;
  3. 验证码在使用的时候,都需要校验,所以提供校验服务

    代码实现

    引入依赖
    1. implementation 'cn.hutool:hutool-all:5.5.4'
    ```java package cn.mrcode.verify_code;

import java.util.Date; import java.util.Objects;

import cn.hutool.cache.CacheUtil; import cn.hutool.cache.impl.TimedCache; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.RandomUtil; import cn.mrcode.verify_code.exception.VerifyCodeException;

/**

  • 验证码服务:用于存储验证码、校验验证码 的通用服务功能;
    1.  
  • 要使用此服务,可以初始化后使用;
  • 如果有多个场景需要发送验证码,可以多初始化几个实例
  • *
  • @author mrcode
  • @date 2022/01/21 22:21 / public class VerifyCodeService { private int expiredIn = 60 3; private TimedCache timedCache;

    public VerifyCodeService() {

    1. this(null);

    }

    /**

    • @param expiredIn 验证码过期时间;单位秒; 默认为 3 分钟 */ public VerifyCodeService(Integer expiredIn) { if (expiredIn != null) {
      1. this.expiredIn = expiredIn;
      } timedCache = CacheUtil.newTimedCache(1000 * this.expiredIn); timedCache.schedulePrune(500); // 多少毫秒清理一次 }
  1. /**
  2. * 保存一个验证码
  3. *
  4. * @param scene 场景,减少冲突
  5. * @param verifyCode
  6. * @param account 验证码一般是发送给某个邮箱或则短信,这里填写账户,验证的时候会进行关联验证
  7. * @return 返回多少秒后过期
  8. */
  9. public Integer put(String scene, String verifyCode, String account) {
  10. Objects.requireNonNull(account);
  11. Objects.requireNonNull(verifyCode);
  12. final Date expiration = DateUtil.offsetSecond(new Date(), expiredIn);
  13. final VerifyCodeItem item = new VerifyCodeItem(account, verifyCode, expiration);
  14. timedCache.put(buildKey(scene, account), item);
  15. return item.getExpiresIn();
  16. }
  17. /**
  18. * 获取此验证码的过期时间
  19. *
  20. * @param scene
  21. * @param account
  22. * @return 如果返回 null,这表示已经过期,如果返回具体数,这表示该 account 当前还不能再次发送验证码
  23. */
  24. public Integer getExpiration(String scene, String account) {
  25. final String key = buildKey(scene, account);
  26. final VerifyCodeItem verifyCodeItem = timedCache.get(key);
  27. if (verifyCodeItem == null) {
  28. return null;
  29. }
  30. if (verifyCodeItem.getExpiresIn() <= 0) {
  31. timedCache.remove(key);
  32. return null;
  33. }
  34. return verifyCodeItem.getExpiresIn();
  35. }
  36. private String buildKey(String scene, String account) {
  37. return scene + account;
  38. }
  39. /**
  40. * 移除验证码,验证成功后移除
  41. *
  42. * @param verifyCode
  43. */
  44. public void remove(String scene, String verifyCode) {
  45. timedCache.remove(buildKey(scene, verifyCode));
  46. }
  47. /**
  48. * 生成一个 6 位数的数字验证码
  49. *
  50. * @return
  51. */
  52. public static String randomVerifyCode() {
  53. return RandomUtil.randomInt(100000, 999999) + "";
  54. }
  55. /**
  56. * 验证码验证, 没通过验证会抛出异常
  57. *
  58. * @param verifyCode 用户提交的验证码
  59. * @param account 对应的账户
  60. * @param ignoreCase 是否忽略大小写
  61. * @throws VerifyCodeException 验证没通过则抛出异常
  62. */
  63. public void verification(String scene, String verifyCode, String account, boolean ignoreCase) throws VerifyCodeException {
  64. Objects.requireNonNull(account);
  65. Objects.requireNonNull(verifyCode);
  66. final VerifyCodeItem item = timedCache.get(buildKey(scene, account), false);
  67. if (item == null || item.isExpired()) {
  68. throw new VerifyCodeException("验证码不存在或已过期");
  69. }
  70. if (ignoreCase) {
  71. if (!item.getCode().equalsIgnoreCase(verifyCode)) {
  72. throw new VerifyCodeException("验证码不匹配");
  73. }
  74. } else {
  75. if (!item.getCode().equals(verifyCode)) {
  76. throw new VerifyCodeException("验证码不匹配");
  77. }
  78. }
  79. }

}

  1. 缓存中存储验证码的实体
  2. ```java
  3. package cn.mrcode.verify_code;
  4. import java.util.Date;
  5. import lombok.Data;
  6. import lombok.ToString;
  7. /**
  8. * <pre>
  9. * 验证码对象
  10. * </pre>
  11. *
  12. * @author mrcode
  13. * @date 2022/01/21 22:21
  14. */
  15. @Data
  16. @ToString
  17. public class VerifyCodeItem {
  18. /**
  19. * 手机号码
  20. */
  21. private String account;
  22. /**
  23. * 验证码过期时间
  24. */
  25. private Date expiration;
  26. /**
  27. * 验证码
  28. */
  29. private String code;
  30. public VerifyCodeItem(String account, String code, Date expiration) {
  31. this.account = account;
  32. this.expiration = expiration;
  33. this.code = code;
  34. }
  35. /**
  36. * 是否过期
  37. *
  38. * @return
  39. */
  40. public boolean isExpired() {
  41. return expiration != null && expiration.before(new Date());
  42. }
  43. /**
  44. * 获取还有多少秒过期
  45. *
  46. * @return
  47. */
  48. public int getExpiresIn() {
  49. return expiration != null ?
  50. Long.valueOf((expiration.getTime() - System.currentTimeMillis()) / 1000L).intValue() : 0;
  51. }
  52. }

验证异常

  1. package cn.mrcode.verify_code.exception;
  2. import lombok.Getter;
  3. public class VerifyCodeException extends RuntimeException {
  4. @Getter
  5. private int code;
  6. public VerifyCodeException(int code, String message) {
  7. super(message);
  8. }
  9. public VerifyCodeException(String message) {
  10. super(message);
  11. }
  12. }

代码测试

  1. package cn.mrcode.verify_code;
  2. import org.junit.jupiter.api.Test;
  3. import java.util.concurrent.TimeUnit;
  4. import cn.hutool.core.lang.Console;
  5. /**
  6. * @author mrcode
  7. * @date 2022/01/21 22:21
  8. */
  9. public class VerifyCodeServiceTest {
  10. @Test
  11. public void testSendCode() throws InterruptedException {
  12. // 验证码 1 分钟后过期
  13. final VerifyCodeService verifyCodeService = new VerifyCodeService(60);
  14. final String scene = "order";
  15. // 生成一个 6 位数字的验证码
  16. final String randomVerifyCode = VerifyCodeService.randomVerifyCode();
  17. // 假设是手机号,也可以是邮箱之类的
  18. final String phone = "180222";
  19. // 在这之前,可以调用发送短信、邮件逻辑,成功之后,将验证码存储在这里
  20. final Integer expiration = verifyCodeService.put(scene, randomVerifyCode, phone);
  21. Console.log("保存验证码成功:" + expiration);
  22. TimeUnit.SECONDS.sleep(10);
  23. Console.log("{} 验证码有效期:{} 秒", phone, verifyCodeService.getExpiration(scene, phone));
  24. // 验证验证码是否有效, 如果验证失败则 抛出异常 VerifyCodeException
  25. verifyCodeService.verification(scene, "123456", phone, true);
  26. }
  27. }

测试输出

  1. 保存验证码成功:59
  2. 180222 验证码有效期:49
  3. cn.mrcode.verify_code.exception.VerifyCodeException: 验证码不匹配
  4. at cn.mrcode.verify_code.VerifyCodeService.verification(VerifyCodeService.java:120)
  5. at cn.mrcode.verify_code.VerifyCodeServiceTest.testSendCode(VerifyCodeServiceTest.java:33)
  6. at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
  7. at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
  8. at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
  9. at java.lang.reflect.Method.invoke(Method.java:498)
  10. at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688)

如何实例化

由于本工具不依赖其他服务,可以自己 new 一个,或则交给 spring 去管理。

业务方真实使用例子

发送验证码

  1. @ApiOperation(value = "注册验证码发送")
  2. @PostMapping("/register/send-verify-code")
  3. @IgnoreSecurity
  4. public Result registerSendVerifyCode(@RequestParam String phone) {
  5. final Integer expiration = verifyCodeService.getExpiration(REG_SCENE, phone);
  6. if (expiration != null) {
  7. final Result fail = ResultHelper.fail(StrUtil.format("{} 秒,后再尝试获取验证码", expiration));
  8. fail.setData(expiration);
  9. return fail;
  10. }
  11. final String verifyCode = VerifyCodeService.randomVerifyCode();
  12. final SendSmsResult sendSmsResult = smsAliYunService.sendCode(phone, verifyCode);
  13. if (!sendSmsResult.isOk()) {
  14. return ResultHelper.fail("验证码发送失败:" + sendSmsResult.getMessage());
  15. }
  16. log.info("验证码发送成功:{},{}", phone, verifyCode);
  17. return ResultHelper.ok(verifyCodeService.put(REG_SCENE, verifyCode, phone));
  18. }

使用验证码

  1. @ApiOperation(value = "账户注册2", notes = "需要提供验证码")
  2. @IgnoreSecurity
  3. @PostMapping("/register2")
  4. public Result register2(@Validated @RequestBody AccountCreate2Request params) {
  5. final String phone = params.getPhone();
  6. try {
  7. verifyCodeService.verification(REG_SCENE, params.getVerifyCode(), phone, true);
  8. } catch (VerifyCodeException e) {
  9. return ResultHelper.fail(e.getMessage());
  10. }
  11. accountService.register(null, params);
  12. return ResultHelper.ok();
  13. }

拓展

本节使用了单机支持过期的缓存来实现存储和验证码有效期,也可以包装 redis 来实现分布式的功能。

由于这个单机缓存只支持一个过期时间,所以在不同过期时间的场景,就需要 new 多个实例来使用,如果使用 redis 这样的来存储的话,就可以一个实例就支持所有的验证码场景了