|
@@ -38,12 +38,20 @@ public class VerificationCodeService {
|
|
|
|
|
|
|
|
/** Redis Key 前缀 */
|
|
/** Redis Key 前缀 */
|
|
|
private static final String CODE_PREFIX = "sms:code:";
|
|
private static final String CODE_PREFIX = "sms:code:";
|
|
|
- private static final String LIMIT_PREFIX = "sms:limit:";
|
|
|
|
|
|
|
+ private static final String INTERVAL_PREFIX = "sms:interval:";
|
|
|
|
|
+ private static final String DAILY_COUNT_PREFIX = "sms:daily:";
|
|
|
|
|
+ private static final String IP_HOURLY_PREFIX = "sms:ip:";
|
|
|
|
|
|
|
|
- /** 发送间隔限制(秒) */
|
|
|
|
|
|
|
+ /** 最小发送间隔(秒) */
|
|
|
private static final int SEND_INTERVAL = 60;
|
|
private static final int SEND_INTERVAL = 60;
|
|
|
/** 每日发送次数限制 */
|
|
/** 每日发送次数限制 */
|
|
|
private static final int DAILY_LIMIT = 10;
|
|
private static final int DAILY_LIMIT = 10;
|
|
|
|
|
+ /** 每日计数窗口(秒)= 24小时 */
|
|
|
|
|
+ private static final int DAILY_WINDOW = 24 * 60 * 60;
|
|
|
|
|
+ /** 单IP每小时发送次数限制 */
|
|
|
|
|
+ private static final int IP_HOURLY_LIMIT = 20;
|
|
|
|
|
+ /** IP计数窗口(秒)= 1小时 */
|
|
|
|
|
+ private static final int IP_WINDOW = 60 * 60;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 生成并发送验证码
|
|
* 生成并发送验证码
|
|
@@ -51,8 +59,9 @@ public class VerificationCodeService {
|
|
|
*
|
|
*
|
|
|
* @param phone 手机号
|
|
* @param phone 手机号
|
|
|
* @param scene 场景(仅用于日志记录,不影响存储 key)
|
|
* @param scene 场景(仅用于日志记录,不影响存储 key)
|
|
|
|
|
+ * @param ip 客户端IP(用于IP维度限流)
|
|
|
*/
|
|
*/
|
|
|
- public void sendCode(String phone, String scene) {
|
|
|
|
|
|
|
+ public void sendCode(String phone, String scene, String ip) {
|
|
|
// 测试模式:使用固定验证码,跳过真实短信发送
|
|
// 测试模式:使用固定验证码,跳过真实短信发送
|
|
|
log.info("【验证码-测试模式】是否启用 {}", testMode);
|
|
log.info("【验证码-测试模式】是否启用 {}", testMode);
|
|
|
if (testMode) {
|
|
if (testMode) {
|
|
@@ -63,8 +72,8 @@ public class VerificationCodeService {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 检查发送频率限制
|
|
|
|
|
- checkSendLimit(phone);
|
|
|
|
|
|
|
+ // 检查发送频率限制(1分钟间隔 + 每日次数 + IP每小时次数)
|
|
|
|
|
+ checkSendLimit(phone, ip);
|
|
|
|
|
|
|
|
// 生成验证码
|
|
// 生成验证码
|
|
|
String code = generateCode();
|
|
String code = generateCode();
|
|
@@ -73,10 +82,25 @@ public class VerificationCodeService {
|
|
|
String codeKey = CODE_PREFIX + phone;
|
|
String codeKey = CODE_PREFIX + phone;
|
|
|
redisTemplate.opsForValue().set(codeKey, code, expireMinutes, TimeUnit.MINUTES);
|
|
redisTemplate.opsForValue().set(codeKey, code, expireMinutes, TimeUnit.MINUTES);
|
|
|
|
|
|
|
|
- // 记录发送次数
|
|
|
|
|
- String limitKey = LIMIT_PREFIX + phone;
|
|
|
|
|
- redisTemplate.opsForValue().increment(limitKey);
|
|
|
|
|
- redisTemplate.expire(limitKey, SEND_INTERVAL, TimeUnit.SECONDS);
|
|
|
|
|
|
|
+ // 设置1分钟间隔锁
|
|
|
|
|
+ String intervalKey = INTERVAL_PREFIX + phone;
|
|
|
|
|
+ redisTemplate.opsForValue().set(intervalKey, "1", SEND_INTERVAL, TimeUnit.SECONDS);
|
|
|
|
|
+
|
|
|
|
|
+ // 递增每日发送次数
|
|
|
|
|
+ String dailyKey = DAILY_COUNT_PREFIX + phone;
|
|
|
|
|
+ Long count = redisTemplate.opsForValue().increment(dailyKey);
|
|
|
|
|
+ if (count != null && count == 1) {
|
|
|
|
|
+ redisTemplate.expire(dailyKey, DAILY_WINDOW, TimeUnit.SECONDS);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 递增IP每小时发送次数
|
|
|
|
|
+ if (ip != null && !ip.isEmpty()) {
|
|
|
|
|
+ String ipKey = IP_HOURLY_PREFIX + ip;
|
|
|
|
|
+ Long ipCount = redisTemplate.opsForValue().increment(ipKey);
|
|
|
|
|
+ if (ipCount != null && ipCount == 1) {
|
|
|
|
|
+ redisTemplate.expire(ipKey, IP_WINDOW, TimeUnit.SECONDS);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// 通过阿里云短信服务发送验证码
|
|
// 通过阿里云短信服务发送验证码
|
|
|
aliyunSmsClient.sendVerificationCode(phone, code);
|
|
aliyunSmsClient.sendVerificationCode(phone, code);
|
|
@@ -124,15 +148,32 @@ public class VerificationCodeService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 检查发送限制
|
|
|
|
|
|
|
+ * 检查发送限制:1分钟间隔 + 每日次数上限 + IP每小时次数上限
|
|
|
*/
|
|
*/
|
|
|
- private void checkSendLimit(String phone) {
|
|
|
|
|
- String limitKey = LIMIT_PREFIX + phone;
|
|
|
|
|
- Object count = redisTemplate.opsForValue().get(limitKey);
|
|
|
|
|
|
|
+ private void checkSendLimit(String phone, String ip) {
|
|
|
|
|
+ // 1. 检查1分钟间隔锁
|
|
|
|
|
+ String intervalKey = INTERVAL_PREFIX + phone;
|
|
|
|
|
+ if (Boolean.TRUE.equals(redisTemplate.hasKey(intervalKey))) {
|
|
|
|
|
+ throw new BusinessException(ErrorCode.CODE_SEND_TOO_FREQUENT,
|
|
|
|
|
+ "发送过于频繁,请60秒后再试");
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
+ // 2. 检查每日发送次数
|
|
|
|
|
+ String dailyKey = DAILY_COUNT_PREFIX + phone;
|
|
|
|
|
+ Object count = redisTemplate.opsForValue().get(dailyKey);
|
|
|
if (count != null && Integer.parseInt(count.toString()) >= DAILY_LIMIT) {
|
|
if (count != null && Integer.parseInt(count.toString()) >= DAILY_LIMIT) {
|
|
|
throw new BusinessException(ErrorCode.CODE_SEND_TOO_FREQUENT,
|
|
throw new BusinessException(ErrorCode.CODE_SEND_TOO_FREQUENT,
|
|
|
"今日发送次数已达上限");
|
|
"今日发送次数已达上限");
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 检查IP每小时发送次数
|
|
|
|
|
+ if (ip != null && !ip.isEmpty()) {
|
|
|
|
|
+ String ipKey = IP_HOURLY_PREFIX + ip;
|
|
|
|
|
+ Object ipCount = redisTemplate.opsForValue().get(ipKey);
|
|
|
|
|
+ if (ipCount != null && Integer.parseInt(ipCount.toString()) >= IP_HOURLY_LIMIT) {
|
|
|
|
|
+ throw new BusinessException(ErrorCode.CODE_SEND_TOO_FREQUENT,
|
|
|
|
|
+ "该IP发送次数过多,请稍后再试");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|