From 5fe9d1117fd89299efbe27ffdf1eee3bbc0d78de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A5=9E=E7=A0=81-=E6=96=B9=E6=99=93=E8=BE=89?= Date: Mon, 2 Feb 2026 21:43:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BAH5=E7=9F=AD=E4=BF=A1?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E9=98=B2=E5=88=B7=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增配置类 H5SmsProperties,支持以下配置项: - enabled: 是否启用短信发送(测试模式) - code-length: 验证码长度 - code-expire-minutes: 验证码有效期 - send-interval-seconds: 同手机号发送间隔 - daily-limit-per-phone: 手机号每日上限 - minute-limit-per-ip: IP每分钟上限 - daily-limit-per-ip: IP每日上限 - blacklist-minutes: 黑名单封禁时长 - blacklist-trigger-count: 触发黑名单的失败次数 - sms-config-name: 短信配置名称 - login/register-template-id: 短信模板ID 防刷策略: 1. 图形验证码校验 2. 手机号黑名单检查 3. IP黑名单检查 4. 发送间隔限制 5. 手机号每日上限 6. IP每分钟上限 7. IP每日上限 8. 验证码连续失败自动封禁 --- .../src/main/resources/application.yml | 28 +++ .../pangu/h5/config/H5SmsProperties.java | 76 +++++++ .../h5/service/impl/H5AuthServiceImpl.java | 198 +++++++++++++++--- 3 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/config/H5SmsProperties.java diff --git a/backend/ruoyi-admin/src/main/resources/application.yml b/backend/ruoyi-admin/src/main/resources/application.yml index 9c03514..7be111f 100644 --- a/backend/ruoyi-admin/src/main/resources/application.yml +++ b/backend/ruoyi-admin/src/main/resources/application.yml @@ -264,3 +264,31 @@ warm-flow: node-tooltip: true # 默认Authorization,如果有多个token,用逗号分隔 token-name: ${sa-token.token-name},clientid + +--- # H5短信防刷配置 +h5: + sms: + # 是否启用短信发送(false时仅打印日志,用于测试) + enabled: false + # 验证码长度 + code-length: 6 + # 验证码有效期(分钟) + code-expire-minutes: 5 + # 同一手机号发送间隔(秒) + send-interval-seconds: 60 + # 同一手机号每日发送上限 + daily-limit-per-phone: 10 + # 同一IP每分钟发送上限 + minute-limit-per-ip: 5 + # 同一IP每日发送上限 + daily-limit-per-ip: 50 + # 黑名单封禁时长(分钟) + blacklist-minutes: 30 + # 触发黑名单的验证失败次数 + blacklist-trigger-count: 5 + # 阿里云短信配置名称(对应sms.blends下的配置) + sms-config-name: alibaba + # 短信模板ID - 登录 + login-template-id: '' + # 短信模板ID - 注册 + register-template-id: '' diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/config/H5SmsProperties.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/config/H5SmsProperties.java new file mode 100644 index 0000000..5212a0e --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/config/H5SmsProperties.java @@ -0,0 +1,76 @@ +package org.dromara.pangu.h5.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * H5短信配置属性 + * + * @author 湖北新华业务中台研发团队 + */ +@Data +@Component +@ConfigurationProperties(prefix = "h5.sms") +public class H5SmsProperties { + + /** + * 是否启用短信发送(测试时可关闭) + */ + private boolean enabled = true; + + /** + * 验证码长度 + */ + private int codeLength = 6; + + /** + * 验证码有效期(分钟) + */ + private int codeExpireMinutes = 5; + + /** + * 同一手机号发送间隔(秒) + */ + private int sendIntervalSeconds = 60; + + /** + * 同一手机号每日发送上限 + */ + private int dailyLimitPerPhone = 10; + + /** + * 同一IP每分钟发送上限 + */ + private int minuteLimitPerIp = 5; + + /** + * 同一IP每日发送上限 + */ + private int dailyLimitPerIp = 50; + + /** + * 黑名单封禁时长(分钟) + */ + private int blacklistMinutes = 30; + + /** + * 触发黑名单的失败次数(连续验证失败) + */ + private int blacklistTriggerCount = 5; + + /** + * 阿里云短信配置名称 + */ + private String smsConfigName = "alibaba"; + + /** + * 短信模板ID(登录) + */ + private String loginTemplateId = ""; + + /** + * 短信模板ID(注册) + */ + private String registerTemplateId = ""; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java index fc301a2..6866811 100644 --- a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java @@ -12,9 +12,11 @@ import lombok.extern.slf4j.Slf4j; import org.dromara.common.core.constant.Constants; import org.dromara.common.core.constant.GlobalConstants; import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.ServletUtils; import org.dromara.common.core.utils.StringUtils; import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.web.core.WaveAndCircleCaptcha; +import org.dromara.pangu.h5.config.H5SmsProperties; import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto; import org.dromara.pangu.h5.domain.dto.H5RegisterDto; import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto; @@ -34,6 +36,8 @@ import org.springframework.stereotype.Service; import java.awt.*; import java.time.Duration; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.Date; import java.util.LinkedHashMap; @@ -48,6 +52,7 @@ import java.util.LinkedHashMap; public class H5AuthServiceImpl implements H5AuthService { private final PgMemberMapper memberMapper; + private final H5SmsProperties smsProperties; /** * H5会员登录设备标识 @@ -55,13 +60,14 @@ public class H5AuthServiceImpl implements H5AuthService { private static final String H5_DEVICE = "h5"; /** - * 短信验证码Redis前缀 + * Redis Key 前缀 */ private static final String H5_SMS_CODE_KEY = "h5:sms:code:"; - - /** - * RefreshToken Redis前缀 - */ + private static final String H5_SMS_DAILY_PHONE_KEY = "h5:sms:daily:phone:"; + private static final String H5_SMS_DAILY_IP_KEY = "h5:sms:daily:ip:"; + private static final String H5_SMS_MINUTE_IP_KEY = "h5:sms:minute:ip:"; + private static final String H5_SMS_BLACKLIST_KEY = "h5:sms:blacklist:"; + private static final String H5_SMS_FAIL_COUNT_KEY = "h5:sms:fail:"; private static final String H5_REFRESH_TOKEN_KEY = "h5:refresh:token:"; /** @@ -97,7 +103,7 @@ public class H5AuthServiceImpl implements H5AuthService { Expression exp = parser.parseExpression(StringUtils.remove(code, "=")); code = exp.getValue(String.class); - // 存入Redis,5分钟有效 + // 存入Redis RedisUtils.setCacheObject(verifyKey, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); H5CaptchaVo vo = new H5CaptchaVo(); @@ -113,12 +119,58 @@ public class H5AuthServiceImpl implements H5AuthService { String phone = dto.getPhone(); String type = dto.getType(); + String clientIp = getClientIp(); // 校验类型 if (!"login".equals(type) && !"register".equals(type)) { throw new ServiceException("验证码类型错误"); } + // ========== 防刷检查 ========== + + // 1. 检查手机号黑名单 + if (isPhoneBlacklisted(phone)) { + throw new ServiceException("操作过于频繁,请" + smsProperties.getBlacklistMinutes() + "分钟后再试"); + } + + // 2. 检查IP黑名单 + if (isIpBlacklisted(clientIp)) { + throw new ServiceException("操作过于频繁,请稍后再试"); + } + + // 3. 检查发送间隔 + String codeKey = H5_SMS_CODE_KEY + type + ":" + phone; + if (RedisUtils.hasKey(codeKey)) { + Long ttl = RedisUtils.getTimeToLive(codeKey); + long intervalCheck = (smsProperties.getCodeExpireMinutes() * 60L) - smsProperties.getSendIntervalSeconds(); + if (ttl != null && ttl > intervalCheck) { + throw new ServiceException("验证码发送过于频繁,请" + smsProperties.getSendIntervalSeconds() + "秒后再试"); + } + } + + // 4. 检查手机号每日上限 + String dailyPhoneKey = H5_SMS_DAILY_PHONE_KEY + getTodayStr() + ":" + phone; + Integer phoneCount = RedisUtils.getCacheObject(dailyPhoneKey); + if (phoneCount != null && phoneCount >= smsProperties.getDailyLimitPerPhone()) { + throw new ServiceException("今日发送次数已达上限,请明天再试"); + } + + // 5. 检查IP每分钟上限 + String minuteIpKey = H5_SMS_MINUTE_IP_KEY + clientIp; + Integer minuteIpCount = RedisUtils.getCacheObject(minuteIpKey); + if (minuteIpCount != null && minuteIpCount >= smsProperties.getMinuteLimitPerIp()) { + throw new ServiceException("请求过于频繁,请稍后再试"); + } + + // 6. 检查IP每日上限 + String dailyIpKey = H5_SMS_DAILY_IP_KEY + getTodayStr() + ":" + clientIp; + Integer dailyIpCount = RedisUtils.getCacheObject(dailyIpKey); + if (dailyIpCount != null && dailyIpCount >= smsProperties.getDailyLimitPerIp()) { + throw new ServiceException("今日请求次数已达上限"); + } + + // ========== 业务校验 ========== + // 注册时校验手机号是否已注册 if ("register".equals(type)) { PgMember existMember = memberMapper.selectOne( @@ -139,39 +191,46 @@ public class H5AuthServiceImpl implements H5AuthService { } } - // 生成6位验证码 - String code = RandomUtil.randomNumbers(6); - String codeKey = H5_SMS_CODE_KEY + type + ":" + phone; + // ========== 发送短信 ========== - // 检查是否在60秒内重复发送 - if (RedisUtils.hasKey(codeKey)) { - Long ttl = RedisUtils.getTimeToLive(codeKey); - if (ttl != null && ttl > 240) { - throw new ServiceException("验证码发送过于频繁,请稍后再试"); - } - } + // 生成验证码 + String code = RandomUtil.randomNumbers(smsProperties.getCodeLength()); - // 存入Redis,5分钟有效 - RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(5)); + // 存入Redis + RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(smsProperties.getCodeExpireMinutes())); - // 调用阿里云短信发送 - try { - LinkedHashMap map = new LinkedHashMap<>(1); - map.put("code", code); - // 使用配置的阿里云短信 - SmsBlend smsBlend = SmsFactory.getSmsBlend("alibaba"); - // 模板ID需要在配置文件中设置 - SmsResponse smsResponse = smsBlend.sendMessage(phone, map); - if (!smsResponse.isSuccess()) { - log.error("短信发送失败: phone={}, response={}", phone, smsResponse); + // 更新计数器 + incrementCounter(dailyPhoneKey, Duration.ofDays(1)); + incrementCounter(minuteIpKey, Duration.ofMinutes(1)); + incrementCounter(dailyIpKey, Duration.ofDays(1)); + + // 发送短信 + if (smsProperties.isEnabled()) { + try { + LinkedHashMap map = new LinkedHashMap<>(1); + map.put("code", code); + SmsBlend smsBlend = SmsFactory.getSmsBlend(smsProperties.getSmsConfigName()); + String templateId = "login".equals(type) ? smsProperties.getLoginTemplateId() : smsProperties.getRegisterTemplateId(); + SmsResponse smsResponse; + if (StringUtils.isNotBlank(templateId)) { + smsResponse = smsBlend.sendMessage(phone, templateId, map); + } else { + smsResponse = smsBlend.sendMessage(phone, map); + } + if (!smsResponse.isSuccess()) { + log.error("短信发送失败: phone={}, response={}", phone, smsResponse); + throw new ServiceException("短信发送失败,请稍后重试"); + } + log.info("短信发送成功: phone={}, type={}, ip={}", phone, type, clientIp); + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("短信发送异常: phone={}, error={}", phone, e.getMessage()); throw new ServiceException("短信发送失败,请稍后重试"); } - log.info("短信发送成功: phone={}, type={}", phone, type); - } catch (ServiceException e) { - throw e; - } catch (Exception e) { - log.error("短信发送异常: phone={}, error={}", phone, e.getMessage()); - throw new ServiceException("短信发送失败,请稍后重试"); + } else { + // 测试模式,打印验证码到日志 + log.info("【测试模式】短信验证码: phone={}, code={}, type={}", phone, code, type); } } @@ -357,15 +416,86 @@ public class H5AuthServiceImpl implements H5AuthService { */ private void validateSmsCode(String phone, String code, String type) { String codeKey = H5_SMS_CODE_KEY + type + ":" + phone; + String failKey = H5_SMS_FAIL_COUNT_KEY + phone; String smsCode = RedisUtils.getCacheObject(codeKey); + if (smsCode == null) { throw new ServiceException("短信验证码已过期"); } + if (!smsCode.equals(code)) { + // 记录失败次数 + Integer failCount = incrementCounter(failKey, Duration.ofMinutes(smsProperties.getCodeExpireMinutes())); + if (failCount >= smsProperties.getBlacklistTriggerCount()) { + // 加入黑名单 + addToBlacklist(phone); + RedisUtils.deleteObject(failKey); + throw new ServiceException("验证码错误次数过多,请" + smsProperties.getBlacklistMinutes() + "分钟后再试"); + } throw new ServiceException("短信验证码错误"); } + // 验证成功后删除 RedisUtils.deleteObject(codeKey); + RedisUtils.deleteObject(failKey); + } + + /** + * 检查手机号是否在黑名单 + */ + private boolean isPhoneBlacklisted(String phone) { + return RedisUtils.hasKey(H5_SMS_BLACKLIST_KEY + "phone:" + phone); + } + + /** + * 检查IP是否在黑名单 + */ + private boolean isIpBlacklisted(String ip) { + return RedisUtils.hasKey(H5_SMS_BLACKLIST_KEY + "ip:" + ip); + } + + /** + * 将手机号加入黑名单 + */ + private void addToBlacklist(String phone) { + RedisUtils.setCacheObject( + H5_SMS_BLACKLIST_KEY + "phone:" + phone, + "1", + Duration.ofMinutes(smsProperties.getBlacklistMinutes()) + ); + log.warn("手机号加入黑名单: phone={}, minutes={}", phone, smsProperties.getBlacklistMinutes()); + } + + /** + * 增加计数器 + */ + private Integer incrementCounter(String key, Duration duration) { + Integer count = RedisUtils.getCacheObject(key); + if (count == null) { + count = 1; + } else { + count++; + } + RedisUtils.setCacheObject(key, count, duration); + return count; + } + + /** + * 获取今日日期字符串 + */ + private String getTodayStr() { + return LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + } + + /** + * 获取客户端IP + */ + private String getClientIp() { + try { + return ServletUtils.getClientIP(); + } catch (Exception e) { + return "unknown"; + } } /**