场景:在很多地方可能都会用到发送验证码、校验验证码的服务;
目的:提供验证码的存储(单机缓存)、校验功能
实现思路
验证码存储、验证工能,分解下来有以下几点:
- 由于验证码一般都有有效期:所以可以利用有过期时间的缓存来存储;本章使用 hutool 的 TimedCache 来实现
- 验证码再次发送的时候,一般是是页面人工多次点击,那么需要提供剩余的秒数方便页面展示或提示;
- 验证码在使用的时候,都需要校验,所以提供校验服务
代码实现
引入依赖
```java package cn.mrcode.verify_code;implementation 'cn.hutool:hutool-all:5.5.4'
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;
/**
- 验证码服务:用于存储验证码、校验验证码 的通用服务功能;
- 要使用此服务,可以初始化后使用;
- 如果有多个场景需要发送验证码,可以多初始化几个实例
- *
- @author mrcode
@date 2022/01/21 22:21 / public class VerifyCodeService { private int expiredIn = 60 3; private TimedCache
timedCache; public VerifyCodeService() {
this(null);
}
/**
- @param expiredIn 验证码过期时间;单位秒; 默认为 3 分钟
*/
public VerifyCodeService(Integer expiredIn) {
if (expiredIn != null) {
} timedCache = CacheUtil.newTimedCache(1000 * this.expiredIn); timedCache.schedulePrune(500); // 多少毫秒清理一次 }this.expiredIn = expiredIn;
/*** 保存一个验证码** @param scene 场景,减少冲突* @param verifyCode* @param account 验证码一般是发送给某个邮箱或则短信,这里填写账户,验证的时候会进行关联验证* @return 返回多少秒后过期*/public Integer put(String scene, String verifyCode, String account) {Objects.requireNonNull(account);Objects.requireNonNull(verifyCode);final Date expiration = DateUtil.offsetSecond(new Date(), expiredIn);final VerifyCodeItem item = new VerifyCodeItem(account, verifyCode, expiration);timedCache.put(buildKey(scene, account), item);return item.getExpiresIn();}/*** 获取此验证码的过期时间** @param scene* @param account* @return 如果返回 null,这表示已经过期,如果返回具体数,这表示该 account 当前还不能再次发送验证码*/public Integer getExpiration(String scene, String account) {final String key = buildKey(scene, account);final VerifyCodeItem verifyCodeItem = timedCache.get(key);if (verifyCodeItem == null) {return null;}if (verifyCodeItem.getExpiresIn() <= 0) {timedCache.remove(key);return null;}return verifyCodeItem.getExpiresIn();}private String buildKey(String scene, String account) {return scene + account;}/*** 移除验证码,验证成功后移除** @param verifyCode*/public void remove(String scene, String verifyCode) {timedCache.remove(buildKey(scene, verifyCode));}/*** 生成一个 6 位数的数字验证码** @return*/public static String randomVerifyCode() {return RandomUtil.randomInt(100000, 999999) + "";}/*** 验证码验证, 没通过验证会抛出异常** @param verifyCode 用户提交的验证码* @param account 对应的账户* @param ignoreCase 是否忽略大小写* @throws VerifyCodeException 验证没通过则抛出异常*/public void verification(String scene, String verifyCode, String account, boolean ignoreCase) throws VerifyCodeException {Objects.requireNonNull(account);Objects.requireNonNull(verifyCode);final VerifyCodeItem item = timedCache.get(buildKey(scene, account), false);if (item == null || item.isExpired()) {throw new VerifyCodeException("验证码不存在或已过期");}if (ignoreCase) {if (!item.getCode().equalsIgnoreCase(verifyCode)) {throw new VerifyCodeException("验证码不匹配");}} else {if (!item.getCode().equals(verifyCode)) {throw new VerifyCodeException("验证码不匹配");}}}
}
缓存中存储验证码的实体```javapackage cn.mrcode.verify_code;import java.util.Date;import lombok.Data;import lombok.ToString;/*** <pre>* 验证码对象* </pre>** @author mrcode* @date 2022/01/21 22:21*/@Data@ToStringpublic class VerifyCodeItem {/*** 手机号码*/private String account;/*** 验证码过期时间*/private Date expiration;/*** 验证码*/private String code;public VerifyCodeItem(String account, String code, Date expiration) {this.account = account;this.expiration = expiration;this.code = code;}/*** 是否过期** @return*/public boolean isExpired() {return expiration != null && expiration.before(new Date());}/*** 获取还有多少秒过期** @return*/public int getExpiresIn() {return expiration != null ?Long.valueOf((expiration.getTime() - System.currentTimeMillis()) / 1000L).intValue() : 0;}}
验证异常
package cn.mrcode.verify_code.exception;import lombok.Getter;public class VerifyCodeException extends RuntimeException {@Getterprivate int code;public VerifyCodeException(int code, String message) {super(message);}public VerifyCodeException(String message) {super(message);}}
代码测试
package cn.mrcode.verify_code;import org.junit.jupiter.api.Test;import java.util.concurrent.TimeUnit;import cn.hutool.core.lang.Console;/*** @author mrcode* @date 2022/01/21 22:21*/public class VerifyCodeServiceTest {@Testpublic void testSendCode() throws InterruptedException {// 验证码 1 分钟后过期final VerifyCodeService verifyCodeService = new VerifyCodeService(60);final String scene = "order";// 生成一个 6 位数字的验证码final String randomVerifyCode = VerifyCodeService.randomVerifyCode();// 假设是手机号,也可以是邮箱之类的final String phone = "180222";// 在这之前,可以调用发送短信、邮件逻辑,成功之后,将验证码存储在这里final Integer expiration = verifyCodeService.put(scene, randomVerifyCode, phone);Console.log("保存验证码成功:" + expiration);TimeUnit.SECONDS.sleep(10);Console.log("{} 验证码有效期:{} 秒", phone, verifyCodeService.getExpiration(scene, phone));// 验证验证码是否有效, 如果验证失败则 抛出异常 VerifyCodeExceptionverifyCodeService.verification(scene, "123456", phone, true);}}
测试输出
保存验证码成功:59180222 验证码有效期:49 秒cn.mrcode.verify_code.exception.VerifyCodeException: 验证码不匹配at cn.mrcode.verify_code.VerifyCodeService.verification(VerifyCodeService.java:120)at cn.mrcode.verify_code.VerifyCodeServiceTest.testSendCode(VerifyCodeServiceTest.java:33)at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)at java.lang.reflect.Method.invoke(Method.java:498)at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688)
如何实例化
由于本工具不依赖其他服务,可以自己 new 一个,或则交给 spring 去管理。
业务方真实使用例子
发送验证码
@ApiOperation(value = "注册验证码发送")@PostMapping("/register/send-verify-code")@IgnoreSecuritypublic Result registerSendVerifyCode(@RequestParam String phone) {final Integer expiration = verifyCodeService.getExpiration(REG_SCENE, phone);if (expiration != null) {final Result fail = ResultHelper.fail(StrUtil.format("{} 秒,后再尝试获取验证码", expiration));fail.setData(expiration);return fail;}final String verifyCode = VerifyCodeService.randomVerifyCode();final SendSmsResult sendSmsResult = smsAliYunService.sendCode(phone, verifyCode);if (!sendSmsResult.isOk()) {return ResultHelper.fail("验证码发送失败:" + sendSmsResult.getMessage());}log.info("验证码发送成功:{},{}", phone, verifyCode);return ResultHelper.ok(verifyCodeService.put(REG_SCENE, verifyCode, phone));}
使用验证码
@ApiOperation(value = "账户注册2", notes = "需要提供验证码")@IgnoreSecurity@PostMapping("/register2")public Result register2(@Validated @RequestBody AccountCreate2Request params) {final String phone = params.getPhone();try {verifyCodeService.verification(REG_SCENE, params.getVerifyCode(), phone, true);} catch (VerifyCodeException e) {return ResultHelper.fail(e.getMessage());}accountService.register(null, params);return ResultHelper.ok();}
拓展
本节使用了单机支持过期的缓存来实现存储和验证码有效期,也可以包装 redis 来实现分布式的功能。
由于这个单机缓存只支持一个过期时间,所以在不同过期时间的场景,就需要 new 多个实例来使用,如果使用 redis 这样的来存储的话,就可以一个实例就支持所有的验证码场景了
