接入阿里云短信服务
Sync All Branches to GitHub / sync (push) Successful in 2s

This commit is contained in:
hanfuye
2026-01-17 13:53:14 +08:00
parent fe2608f3ea
commit afcf469488
13 changed files with 416 additions and 67 deletions
+6
View File
@@ -76,6 +76,12 @@
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- 阿里云短信认证服务 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dypnsapi20170525</artifactId>
</dependency>
</dependencies>
<build>
@@ -15,7 +15,10 @@ public class RedisKeyConstants {
* 验证码 KEY 前缀
*/
private static final String VERIFICATION_CODE_KEY_PREFIX = "verification_code:";
/**
* 验证码 KEY 过期时间 (分钟)
*/
public static final long VERIFICATION_CODE_EXPIRE_TIME = 3;
/**
* 构建验证码 KEY
*
@@ -2,8 +2,6 @@ package top.crushtj.xiaoyishu.auth.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.lang.String;
import top.crushtj.framework.common.exception.BaseExceptionInterface;
/**
@@ -18,9 +16,10 @@ import top.crushtj.framework.common.exception.BaseExceptionInterface;
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// -------- 通用异常状态码 --------
SYSTEM_ERROR("AUTH-10000", "系统错误"),
PARAM_NOT_VALID("AUTH-10001", "参数错误"),
VERIFICATION_CODE_SEND_FREQUENTLY("AUTH-20000", "请求太频繁,请3分钟后再试");
SYSTEM_ERROR("AUTH-10000", "系统错误"), PARAM_NOT_VALID("AUTH-10001", "参数错误"), // -------- 验证码异常状态码 --------
VERIFICATION_CODE_SEND_FREQUENTLY("AUTH-20000", "请求太频繁,请3分钟后再试"),
SMS_SEND_FAILED("AUTH-20001", "短信发送失败,请稍后再试"),
SMS_SEND_TIMEOUT("AUTH-20002", "短信发送超时,请稍后再试");
/**
* 错误码
@@ -25,6 +25,6 @@ public class SendVerificationCodeReqVO {
* 手机号
*/
@NotBlank(message = "手机号不能为空")
private String phone;
private String phoneNumber;
}
@@ -1,51 +1,102 @@
package top.crushtj.xiaoyishu.auth.service.impl;
import cn.hutool.core.util.RandomUtil;
import top.crushtj.framework.common.exception.BizException;
import top.crushtj.framework.common.response.Response;
import top.crushtj.xiaoyishu.auth.constant.RedisKeyConstants;
import top.crushtj.xiaoyishu.auth.enums.ResponseCodeEnum;
import top.crushtj.xiaoyishu.auth.model.vo.verificationcode.SendVerificationCodeReqVO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import top.crushtj.framework.common.exception.BizException;
import top.crushtj.framework.common.response.Response;
import top.crushtj.framework.common.utils.MaskUtils;
import top.crushtj.xiaoyishu.auth.constant.RedisKeyConstants;
import top.crushtj.xiaoyishu.auth.enums.ResponseCodeEnum;
import top.crushtj.xiaoyishu.auth.model.vo.verificationcode.SendVerificationCodeReqVO;
import top.crushtj.xiaoyishu.auth.service.VerificationCodeService;
import top.crushtj.xiaoyishu.auth.sms.AliyunSmsHelper;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static top.crushtj.xiaoyishu.auth.constant.RedisKeyConstants.VERIFICATION_CODE_EXPIRE_TIME;
/**
* 验证码服务实现类
* 修复点:异步异常捕获、Redis过期时间、验证码日志脱敏、手机号校验、线程任务跟踪
*/
@Service
@Slf4j
public class VerificationCodeServiceImpl implements VerificationCodeService {
// 短信发送超时时间(秒)
private static final int SMS_SEND_TIMEOUT_SECONDS = 5;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource(name = "taskExecutor")
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
@Resource
private AliyunSmsHelper aliyunSmsHelper;
@Override
public Response<?> send(SendVerificationCodeReqVO sendVerificationCodeReqVO) {
// 手机号
String phone = sendVerificationCodeReqVO.getPhone();
// 1. 获取并校验手机号格式
String phoneNumber = sendVerificationCodeReqVO.getPhoneNumber();
// 构建验证码 redis key
String key = RedisKeyConstants.buildVerificationCodeKey(phone);
// 判断是否已发送验证码
// 2. 构建Redis Key并检查发送频率
String key = RedisKeyConstants.buildVerificationCodeKey(phoneNumber);
boolean isSent = redisTemplate.hasKey(key);
if (isSent) {
// 若之前发送的验证码未过期,则提示发送频繁
throw new BizException(ResponseCodeEnum.VERIFICATION_CODE_SEND_FREQUENTLY);
}
// 生成 6 位随机数字验证码
// 3. 生成6位随机验证码
String verificationCode = RandomUtil.randomNumbers(6);
// todo: 调用第三方短信发送服务
// 4. 异步发送短信(用CompletableFuture跟踪任务状态,捕获异常)
CompletableFuture<Boolean> smsSendFuture = CompletableFuture.supplyAsync(() -> {
// 设置线程名称,便于日志排查
Thread.currentThread().setName("sms-send-" + MaskUtils.maskMobile(phoneNumber));
String signName = "速通互联验证码";
String templateCode = "100001";
String templateParam = String.format("{\"code\":\"%s\",\"min\":\"%d\"}", verificationCode, VERIFICATION_CODE_EXPIRE_TIME);
try {
return aliyunSmsHelper.sendMessage(signName, templateCode, phoneNumber, templateParam);
} catch (Exception e) {
log.error("==> 手机号: {}, 短信发送接口调用异常", MaskUtils.maskMobile(phoneNumber), e);
return false;
}
}, threadPoolTaskExecutor);
log.info("==> 手机号: {}, 已发送验证码:【{}】", phone, verificationCode);
// 5. 同步等待短信发送结果(超时控制,避免主线程阻塞过久)
boolean smsSendSuccess;
try {
smsSendSuccess = smsSendFuture.get(SMS_SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
log.error("==> 手机号: {}, 短信发送任务被中断", MaskUtils.maskMobile(phoneNumber), e);
Thread.currentThread().interrupt(); // 恢复中断状态
throw new BizException(ResponseCodeEnum.SMS_SEND_FAILED);
} catch (ExecutionException e) {
log.error("==> 手机号: {}, 短信发送任务执行异常", MaskUtils.maskMobile(phoneNumber), e);
throw new BizException(ResponseCodeEnum.SMS_SEND_FAILED);
} catch (TimeoutException e) {
log.error("==> 手机号: {}, 短信发送任务超时({}秒)", MaskUtils.maskMobile(phoneNumber), SMS_SEND_TIMEOUT_SECONDS, e);
throw new BizException(ResponseCodeEnum.SMS_SEND_TIMEOUT);
}
// 存储验证码到 redis, 并设置过期时间为 3 分钟
redisTemplate.opsForValue().set(key, verificationCode, 3, TimeUnit.MINUTES);
// 6. 短信发送失败则直接抛异常,不存储Redis
if (!smsSendSuccess) {
log.error("==> 手机号: {}, 发送验证码失败(第三方接口返回失败)", MaskUtils.maskMobile(phoneNumber));
throw new BizException(ResponseCodeEnum.SMS_SEND_FAILED);
}
// 7. 短信发送成功后,记录日志(验证码脱敏,仅保留后2位)+ 存储Redis
log.info("==> 手机号: {}, 已发送验证码:【****{}】", MaskUtils.maskMobile(phoneNumber), verificationCode.substring(4));
redisTemplate.opsForValue().set(key, verificationCode, VERIFICATION_CODE_EXPIRE_TIME, TimeUnit.MINUTES);
return Response.success();
}
}
}
@@ -0,0 +1,21 @@
package top.crushtj.xiaoyishu.auth.sms;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* @author ayi
* @version V1.0
* @title AliyunAccessKeyProperties
* @date 2026/01/16 23:31
* @description 阿里云访问密钥配置
*/
@ConfigurationProperties(prefix = "aliyun")
@Component
@Data
public class AliyunAccessKeyProperties {
private String accessKeyId;
private String accessKeySecret;
}
@@ -0,0 +1,42 @@
package top.crushtj.xiaoyishu.auth.sms;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author ayi
* @version V1.0
* @title AliyunSmsClientConfig
* @date 2026/01/16 23:48
* @description 阿里云短信配置
*/
@Slf4j
@Configuration
public class AliyunSmsClientConfig {
@Resource
private AliyunAccessKeyProperties aliyunAccessKeyProperties;
@Bean
public com.aliyun.dypnsapi20170525.Client smsClient() {
try {
com.aliyun.credentials.Client credential = new com.aliyun.credentials.Client();
com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
.setCredential(credential);
// Endpoint 请参考 https://api.aliyun.com/product/Dypnsapi
config.endpoint = "dypnsapi.aliyuncs.com";
config.accessKeyId = aliyunAccessKeyProperties.getAccessKeyId();
config.accessKeySecret = aliyunAccessKeyProperties.getAccessKeySecret();
return new com.aliyun.dypnsapi20170525.Client(config);
} catch (Exception e) {
log.error("初始化阿里云短信发送客户端错误: ", e);
return null;
}
}
}
@@ -0,0 +1,49 @@
package top.crushtj.xiaoyishu.auth.sms;
import com.aliyun.dypnsapi20170525.models.SendSmsVerifyCodeResponse;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import top.crushtj.framework.common.utils.JsonUtils;
import top.crushtj.framework.common.utils.MaskUtils;
/**
* @author ayi
* @version V1.0
* @title AliyunSmsHelper
* @date 2026/01/16 23:36
* @description 阿里云短信工具
*/
@Slf4j
@Component
public class AliyunSmsHelper {
@Resource
private com.aliyun.dypnsapi20170525.Client client;
public boolean sendMessage(String signName, String templateCode, String phoneNumber, String templateParam) {
com.aliyun.dypnsapi20170525.models.SendSmsVerifyCodeRequest sendSmsVerifyCodeRequest = new com.aliyun.dypnsapi20170525.models.SendSmsVerifyCodeRequest()
.setSignName(signName)
.setTemplateCode(templateCode)
.setPhoneNumber(phoneNumber)
.setTemplateParam(templateParam);
com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions();
try {
log.info("==> 开始短信发送, phone: {}, signName: {}, templateCode: {}, templateParam: {}", MaskUtils.maskMobile(phoneNumber), signName, templateCode, templateParam);
// 发送短信
SendSmsVerifyCodeResponse response = client.sendSmsVerifyCodeWithOptions(sendSmsVerifyCodeRequest, runtime);
boolean success = response.getBody().success;
log.info("==> 短信发送状态: {}, response: {}", success, JsonUtils.toJsonString(response));
return success;
} catch (Exception error) {
log.error("==> 短信发送错误: ", error);
return false;
}
}
}
@@ -71,4 +71,8 @@ mybatis-plus:
logging:
level:
top.crushtj.xiaoyishu.auth.domain.mappers: debug
top.crushtj.xiaoyishu.auth.domain.mappers: debug
aliyun: # 接入阿里云(发送短信使用)
accessKeyId:
accessKeySecret:
@@ -1,40 +0,0 @@
package top.crushtj.xiaoyishu.auth;
import com.alibaba.druid.filter.config.ConfigTools;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/**
* @author ayi
* @version V1.0
* @title DruidTest
* @description
* @date 2025/12/01
*/
@SpringBootTest
@Slf4j
public class DruidTest {
/**
* Druid 密码加密
*/
@Test
@SneakyThrows
void testEncodePassword() {
// 你的密码
String password = "HhpxE2HWE4bGTyB5";
String[] arr = ConfigTools.genKeyPair(512);
// 私钥
log.info("privateKey: {}", arr[0]);
// 公钥
log.info("publicKey: {}", arr[1]);
// 通过私钥加密密码
String encodePassword = ConfigTools.encrypt(arr[0], password);
log.info("password: {}", encodePassword);
}
}
@@ -0,0 +1,63 @@
package top.crushtj.xiaoyishu.auth;
import com.alibaba.druid.filter.config.ConfigTools;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.jasypt.encryption.pbe.PooledPBEStringEncryptor;
import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/**
* @author ayi
* @version V1.0
* @title DruidTest
* @description
* @date 2025/12/01
*/
@SpringBootTest
@Slf4j
public class EncryptTest {
//@Autowired
//private StringEncryptor defaultLazyEncryptor;
//private StringEncryptor pooledPbeStringEncryptor;
/**
* Druid 密码加密
*/
@Test
@SneakyThrows
void testEncodePassword() {
// 你的密码
String password = "HhpxE2HWE4bGTyB5";
String[] arr = ConfigTools.genKeyPair(512);
// 私钥
log.info("privateKey: {}", arr[0]);
// 公钥
log.info("publicKey: {}", arr[1]);
// 通过私钥加密密码
String encodePassword = ConfigTools.encrypt(arr[0], password);
log.info("password: {}", encodePassword);
}
@Test
void smsEncode() {
String accessKeyId = manualEncrypt("", "Yu020320.");
System.out.println("accessKeyId" + accessKeyId);
String accessKeySecret = manualEncrypt("", "Yu020320.");
System.out.println("accessKeySecret" + accessKeySecret);
}
private String manualEncrypt(String plainText, String secretKey) {
PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
SimpleStringPBEConfig config = new SimpleStringPBEConfig();
config.setPassword(secretKey); // 密钥
config.setAlgorithm("PBEWithMD5AndDES"); // 算法
config.setPoolSize(1); // 池大小(默认)
encryptor.setConfig(config);
return encryptor.encrypt(plainText);
}
}