场景:在很多地方可能都会用到发送验证码、校验验证码的服务;
目的:提供验证码的存储(单机缓存)、校验功能
实现思路
验证码存储、验证工能,分解下来有以下几点:
- 由于验证码一般都有有效期:所以可以利用有过期时间的缓存来存储;本章使用 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("验证码不匹配");
}
}
}
}
缓存中存储验证码的实体
```java
package 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
@ToString
public 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 {
@Getter
private 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 {
@Test
public 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));
// 验证验证码是否有效, 如果验证失败则 抛出异常 VerifyCodeException
verifyCodeService.verification(scene, "123456", phone, true);
}
}
测试输出
保存验证码成功:59
180222 验证码有效期: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")
@IgnoreSecurity
public 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 这样的来存储的话,就可以一个实例就支持所有的验证码场景了