解释一波逻辑:

  • 前端将手机号作为入参,请求获取短信验证码接口;
  • 后端拿到手机号,根据手机号构建 Redis Key , 如 verification_code:18012349108;
  • 查询 Redis , 判断该 Key 值是否存在;
  • 若已经存在,说明验证码还在有效期内(设置了3分钟有效期),并提示用户请求验证码太过频繁;
  • 若不存在,则生成 6 位随机数字作为验证码;
  • 调用第三方短信发送服务,比如阿里云的,将验证码发送到用户手机上;
  • 同时,将该验证码存储到 Redis 中,过期时间为 3 分钟,用于后续用户点击登录时,判断填写的验证码和缓存中的是否一致,以及判断用户获取验证码是否太过频繁。

接口定义

接口地址:POST /verification/code/sent 入参:

{
	"phone":"18019939108" // 手机号
}

出参:

{
	"success": false,
	"message": "请求太频繁,请3分钟后再试",
	"errorCode": "AUTH-20000",
	"data": null
}

入参VO:

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SendVerificationCodeReqVO {
 
    @NotBlank(message = "手机号不能为空")
    @PhoneNumber
    private String phone;
 
}

这里的 @PhoneNumber自定义注解

代码

用到了一些常用的工具类包:

首先: [[202502222143 Redis 创建 RedisKeyConstants 常量类,用于统一管理 Redis Key|创建 RedisKeyConstants 常量类,用于统一管理 Redis Key]]

之后增加验证码请求次数过多的业务异常:

VERIFICATION_CODE_SEND_FREQUENTLY("USER-20000", "请求太频繁,请3分钟后再试"),

定义 verificationCodeService

public interface VerificationCodeService {  
  
    /**  
     * 接受手机号并发送验证码  
     *  
     * @param sendVerificationCodeReqVO the send verification code req vo  
     * @return the response  
     */    Response<Object> send(SendVerificationCodeReqVO sendVerificationCodeReqVO);  
}

以及实现类:这里用到了 使用进程池优化网络IO

@Service  
@Slf4j  
public class VerificationCodeServiceImpl implements VerificationCodeService {  
  
    @Resource  
    RedissonClient  redissonClient;  
  
    @Override  
    public Response<Object> send(SendVerificationCodeReqVO sendVerificationCodeReqVO) {  
        // 获取手机号  
        String phone = sendVerificationCodeReqVO.getPhone();  
  
        // 构建手机号
        RedisKey        String key = RedisKeyConstants.buildVerificationCodeKey(phone);  
  
        // 判断是否已经发送过,即 redis 中还存留着  
        RBucket<String> bucket = redissonClient.getBucket(key);  
        if(bucket.isExists())  
            throw new BizException(ResponseCodeEnum.VERIFICATION_CODE_SEND_FREQUENTLY);  
  
        // 生成六位随机数字  
        String verificationCode = RandomUtil.randomNumbers(6);  
  
        // TODO 调用发码平台  
  
        log.info("已发送手机号 {} 验证码:{}", phone,verificationCode);  
  
        // 存储到 redis 并设置 3 分钟后过期  
        bucket.set(verificationCode, 3, TimeUnit.MINUTES);  
  
        return Response.success();  
    }  
}

接入阿里api

短信服务注册资格后申请一个测试,模版选用阿里云专用模板即可(免审核)。

<dependency>
  <groupId>com.aliyun</groupId>
  <artifactId>dysmsapi20170525</artifactId>
  <version>3.1.1</version>
</dependency>

记得获取阿里云的 Access Key,它是接入凭证,放在application.yml 即可:

############ AliCloud SMS 配置 ############
aliyun:  
  accessKeyId: LTAI5tGQNtUFU8mT8pnr6avb  
  accessKeySecret: CsjjeEgG8bTeoJczWDxbl66OzcoAZm

接下来创建一些相关类,放在 sms 文件夹下:

  • AliyunAccessKeyProperties 用来接受配置文件中填写的 AccessKey 信息
@ConfigurationProperties(prefix = "aliyun")
@Component
@Data
public class AliyunAccessKeyProperties {
    private String accessKeyId;
    private String accessKeySecret;
}
  • AliyunSmsClientConfig 配置类,用于初始化一个短信发送客户端,注入到 Spring 容器中
  
import com.aliyun.dysmsapi20170525.Client;  
import com.aliyun.teaopenapi.models.Config;  
 
/**  
 * @author Edwin  
 * @date 2/22/2025 11:25 PM  
 * @description: 短信发送客户端  
 */  
@Configuration  
@Slf4j  
public class AliyunSmsClientConfig {  
  
    @Resource  
    private AliyunAccessKeyProperties aliyunAccessKeyProperties;  
  
    @Bean  
    public Client smsClient() {  
        try {  
            Config config = new Config()  
                    // 必填  
                    .setAccessKeyId(aliyunAccessKeyProperties.getAccessKeyId())  
                    // 必填  
                    .setAccessKeySecret(aliyunAccessKeyProperties.getAccessKeySecret());  
  
            // Endpoint 请参考 https://api.aliyun.com/product/Dysmsapi            config.endpoint = "dysmsapi.aliyuncs.com";  
  
            return new Client(config);  
        } catch (Exception e) {  
            log.error("初始化阿里云短信发送客户端错误: ", e);  
            return null;  
        }  
    }  
}
  • AliyunSmsHelper 短信发送工具类
import com.aliyun.dysmsapi20170525.Client;  
  
/**  
 * @author Edwin  
 * @date 2/22/2025 11:28 PM  
 * @description: TODO  
 */  
  
@Component  
@Slf4j  
public class AliyunSmsHelper {  
  
    @Resource  
    private Client client;  
  
    /**  
     * 发送短信  
     * @param signName  
     * @param templateCode  
     * @param phone  
     * @param templateParam  
     * @return  
     */  
    public boolean sendMessage(String signName, String templateCode, String phone, String templateParam) {  
        SendSmsRequest sendSmsRequest = new SendSmsRequest()  
                .setSignName(signName)  
                .setTemplateCode(templateCode)  
                .setPhoneNumbers(phone)  
                .setTemplateParam(templateParam);  
        RuntimeOptions runtime = new RuntimeOptions();  
  
        try {  
            log.info("==> 开始短信发送, phone: {}, signName: {}, templateCode: {}, templateParam: {}", phone, signName, templateCode, templateParam);  
  
            // 发送短信  
            SendSmsResponse response = client.sendSmsWithOptions(sendSmsRequest, runtime);  
  
            log.info("==> 短信发送成功, response: {}", JsonUtil.toJsonString(response));   // JsonUtil 为自定义的
            return true;  
        } catch (Exception error) {  
            log.error("==> 短信发送错误: ", error);  
            return false;  
        }  
    }  
}

之后再使用线程池调用即可:

// 调用第三方短信发送服务
threadPoolTaskExecutor.submit(() -> {
	String signName = "阿里云短信测试";
	String templateCode = "SMS_154950909";
	String templateParam = String.format("{\"code\":\"%s\"}", verificationCode);
	aliyunSmsHelper.sendMessage(signName, templateCode, phone, templateParam);
});