接入阿里云短信服务
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
+8
View File
@@ -48,6 +48,7 @@
<guava.version>33.0.0-jre</guava.version>
<hutool.version>5.8.26</hutool.version>
<commons-lang3.version>3.12.0</commons-lang3.version>
<dypnsapi.version>2.0.0</dypnsapi.version>
</properties>
<!-- 统一依赖管理 -->
@@ -168,6 +169,13 @@
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
<!--阿里云短信认证服务-->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dypnsapi20170525</artifactId>
<version>${dypnsapi.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
+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,50 +1,101 @@
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;
}
}
}
@@ -72,3 +72,7 @@ mybatis-plus:
logging:
level:
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);
}
}
@@ -0,0 +1,143 @@
package top.crushtj.framework.common.utils;
import org.apache.commons.lang3.StringUtils;
/**
* @author ayi
* @version V1.0
* @title MaskUtils
* @date 2026/01/17 13:22
* @description 数据脱敏工具类
*/
public class MaskUtils {
// ===================== 通用脱敏方法(基础) =====================
/**
* 通用脱敏方法:保留前prefixLen位,后suffixLen位,中间用*填充
* @param content 原始字符串
* @param prefixLen 保留前缀长度
* @param suffixLen 保留后缀长度
* @return 脱敏后的字符串
*/
public static String maskGeneral(String content, int prefixLen, int suffixLen) {
// 空值处理:null/空串直接返回,避免NPE
if (StringUtils.isBlank(content)) {
return content;
}
int contentLen = content.length();
// 若长度小于等于前缀+后缀,直接返回原字符串(避免过度脱敏)
if (contentLen <= prefixLen + suffixLen) {
return content;
}
// 拼接前缀 + 中间* + 后缀
StringBuilder sb = new StringBuilder();
// 添加前缀
sb.append(content.substring(0, prefixLen));
// 添加中间掩码(长度=总长度-前缀-后缀)
sb.append("*".repeat(Math.max(0, contentLen - prefixLen - suffixLen)));
// 添加后缀
sb.append(content.substring(contentLen - suffixLen));
return sb.toString();
}
// ===================== 常用敏感字段脱敏方法(业务封装) =====================
/**
* 手机号脱敏:保留前3位,后4位,中间4位掩码(如:138****1234
* @param mobile 手机号(支持11位常规手机号)
* @return 脱敏后的手机号
*/
public static String maskMobile(String mobile) {
// 先校验手机号格式(简单校验,可根据业务调整)
if (StringUtils.isBlank(mobile) || !mobile.matches("^1[3-9]\\d{9}$")) {
return mobile;
}
return maskGeneral(mobile, 3, 4);
}
/**
* 邮箱脱敏:保留前缀前3位,@及域名完整,中间掩码(如:123****@qq.com
* @param email 邮箱地址
* @return 脱敏后的邮箱
*/
public static String maskEmail(String email) {
if (StringUtils.isBlank(email) || !email.contains("@")) {
return email;
}
String[] parts = email.split("@");
String prefix = parts[0];
String domain = parts[1];
// 前缀长度<=3时不脱敏,否则保留前3位
String maskedPrefix = prefix.length() <= 3 ? prefix : maskGeneral(prefix, 3, 0);
return maskedPrefix + "@" + domain;
}
/**
* 姓名脱敏:
* - 单字名:直接返回(如:李 → 李)
* - 两字名:保留姓,名掩码(如:李白 → 李*)
* - 三字及以上:保留姓和最后一个字,中间掩码(如:李世民 → 李*民)
* @param name 姓名
* @return 脱敏后的姓名
*/
public static String maskName(String name) {
if (StringUtils.isBlank(name)) {
return name;
}
int nameLen = name.length();
if (nameLen == 1) {
return name;
} else if (nameLen == 2) {
return name.substring(0, 1) + "*";
} else {
// 姓 + 中间* + 最后一个字
return name.substring(0, 1) +
"*".repeat(nameLen - 2) +
name.substring(nameLen - 1);
}
}
/**
* 身份证号脱敏:保留前3位,后4位,中间掩码(如:110***********1234
* @param idCard 身份证号(支持15位/18位)
* @return 脱敏后的身份证号
*/
public static String maskIdCard(String idCard) {
if (StringUtils.isBlank(idCard) || (idCard.length() != 15 && idCard.length() != 18)) {
return idCard;
}
return maskGeneral(idCard, 3, 4);
}
/**
* 银行卡号脱敏:保留前6位,后4位,中间掩码(如:622260**********1234
* @param bankCard 银行卡号(常规16/19位)
* @return 脱敏后的银行卡号
*/
public static String maskBankCard(String bankCard) {
if (StringUtils.isBlank(bankCard) || bankCard.length() < 10) {
return bankCard;
}
return maskGeneral(bankCard, 6, 4);
}
// ===================== 测试用例(可直接运行验证) =====================
public static void main(String[] args) {
// 测试手机号脱敏
System.out.println("手机号脱敏:" + maskMobile("13812345678")); // 输出:138****5678
// 测试邮箱脱敏
System.out.println("邮箱脱敏:" + maskEmail("1234567890@qq.com")); // 输出:123****@qq.com
// 测试姓名脱敏
System.out.println("单字名:" + maskName("")); // 输出:李
System.out.println("两字名:" + maskName("李白")); // 输出:李*
System.out.println("三字名:" + maskName("李世民")); // 输出:李*民
// 测试身份证脱敏
System.out.println("身份证脱敏:" + maskIdCard("110101199001011234")); // 输出:110***********1234
// 测试银行卡脱敏
System.out.println("银行卡脱敏:" + maskBankCard("6222600000000001234")); // 输出:622260**********1234
// 测试空值/异常值
System.out.println("空手机号:" + maskMobile(null)); // 输出:null
System.out.println("无效邮箱:" + maskEmail("123456")); // 输出:123456
}
}