package com.ruoyi.auth.service; import java.security.SecureRandom; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.auth.config.UniversalCodeProperties; import com.ruoyi.auth.constant.SmsConstants; import com.ruoyi.auth.enums.SmsSceneEnum; import com.ruoyi.auth.service.sms.SmsSender; import com.ruoyi.common.core.constant.CacheConstants; import com.ruoyi.common.core.constant.SecurityConstants; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.core.exception.ServiceException; import com.ruoyi.common.core.text.Convert; import com.ruoyi.common.core.utils.StringUtils; import com.ruoyi.common.redis.service.RedisService; import com.ruoyi.system.api.RemoteAccountUserService; import com.ruoyi.system.api.domain.AccountMember; /** * 短信验证码服务 * * 负责验证码的下发(含频率控制)与校验消费(一次性、错误次数限制)。 * * @author hugray */ @Service public class SmsCodeService { private static final Logger log = LoggerFactory.getLogger(SmsCodeService.class); /** 手机号正则 */ private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); /** 安全随机数生成器(避免在方法内重复创建) */ private static final SecureRandom RANDOM = new SecureRandom(); @Autowired private RedisService redisService; @Autowired private SmsSender smsSender; @Autowired private RemoteAccountUserService remoteAccountUserService; @Autowired private UniversalCodeProperties universalCodeProperties; /** * 下发短信验证码 * * @param phone 手机号 * @param scene 场景编码(register=注册 / login=登录 / reset=重置密码 / 其它=通用下发) */ public void sendCode(String phone, String scene) { // STEP 1: 手机号非空 + 格式校验(Controller 已校验,此处做服务层兜底) if (StringUtils.isEmpty(phone) || !PHONE_PATTERN.matcher(phone).matches()) { throw new ServiceException("请输入正确手机号"); } SmsSceneEnum sceneEnum = SmsSceneEnum.getByCode(scene); // STEP 2: 场景化手机号状态校验:注册需手机号未注册,登录/重置密码需手机号已注册 if (SmsSceneEnum.REGISTER == sceneEnum) { checkPhoneNotRegistered(phone); } else if (SmsSceneEnum.LOGIN == sceneEnum || SmsSceneEnum.RESET == sceneEnum) { checkPhoneRegistered(phone); } // STEP 3: 发送频率控制(同一场景同一手机号 30s 内仅允许发送一次) String limitKey = CacheConstants.SMS_CODE_LIMIT_KEY + sceneEnum.getCode() + ":" + phone; boolean acquired = redisService.setCacheObjectIfAbsent(limitKey, "1", SmsConstants.SEND_INTERVAL_SECONDS, TimeUnit.SECONDS); if (!acquired) { throw new ServiceException("验证码发送过于频繁,请稍后再试"); } // STEP 4: 生成验证码,写入缓存(TTL 5 分钟),同时清零历史错误次数 String code = generateCode(); String codeKey = buildCodeKey(sceneEnum, phone); redisService.setCacheObject(codeKey, code, SmsConstants.CODE_EXPIRE_MINUTES, TimeUnit.MINUTES); redisService.deleteObject(buildErrorKey(sceneEnum, phone)); // STEP 5: 调用短信发送器下发 try { smsSender.send(phone, code); } catch (Exception e) { log.error("[SMS_SEND] send failed, phone={}", maskPhone(phone), e); throw new ServiceException("短信发送失败"); } } /** * 校验并消费验证码(一次性使用) * * @param phone 手机号 * @param scene 场景编码 * @param inputCode 用户输入的验证码 */ public void verifyAndConsume(String phone, String scene, String inputCode) { SmsSceneEnum sceneEnum = SmsSceneEnum.getByCode(scene); // STEP 0: 测试便利——命中通用验证码直接放行(仅 login / register / reset 场景;配置为空即关闭) if (isUniversalCodeHit(sceneEnum, inputCode)) { log.warn("[SMS_VERIFY] universal code hit, phone={}, scene={}", maskPhone(phone), sceneEnum.getCode()); return; } String codeKey = buildCodeKey(sceneEnum, phone); // STEP 1: 读取缓存验证码,不存在视为已失效 Object cacheCode = redisService.getCacheObject(codeKey); if (StringUtils.isNull(cacheCode)) { throw new ServiceException("验证码已失效,请重新获取"); } // STEP 2: 比对失败累加错误次数,达到上限删除验证码 String code = Convert.toStr(cacheCode); if (!code.equals(Convert.toStr(inputCode))) { recordVerifyFailure(sceneEnum, phone, codeKey); throw new ServiceException("验证码错误"); } // STEP 3: 校验通过,删除验证码(一次性使用) redisService.deleteObject(codeKey); redisService.deleteObject(buildErrorKey(sceneEnum, phone)); } /** * 判定是否命中通用验证码(测试便利) * * 仅 login / register / reset 场景生效;命中即直接放行,不读缓存、不计数、不消费。 * * @param sceneEnum 场景枚举 * @param inputCode 用户输入的验证码 * @return 命中返回 true */ private boolean isUniversalCodeHit(SmsSceneEnum sceneEnum, String inputCode) { String universalCode = universalCodeProperties.getUniversalSmsCode(); // 未配置通用验证码时关闭该能力 if (StringUtils.isEmpty(universalCode) || StringUtils.isEmpty(inputCode)) { return false; } // 仅登录 / 注册 / 重置密码场景放行,避免其它场景被通用码绕过 if (SmsSceneEnum.LOGIN != sceneEnum && SmsSceneEnum.REGISTER != sceneEnum && SmsSceneEnum.RESET != sceneEnum) { return false; } return universalCode.equals(inputCode); } /** * 校验手机号是否已被注册 * * @param phone 手机号 */ private void checkPhoneNotRegistered(String phone) { R memberResult = remoteAccountUserService.getMemberByPhone(phone, SecurityConstants.INNER); if (R.FAIL == memberResult.getCode()) { throw new ServiceException(memberResult.getMsg()); } if (StringUtils.isNotNull(memberResult.getData())) { throw new ServiceException("手机号已注册,请前往登录"); } } /** * 校验手机号是否已注册 * * @param phone 手机号 */ private void checkPhoneRegistered(String phone) { R memberResult = remoteAccountUserService.getMemberByPhone(phone, SecurityConstants.INNER); if (R.FAIL == memberResult.getCode()) { throw new ServiceException(memberResult.getMsg()); } if (StringUtils.isNull(memberResult.getData())) { throw new ServiceException("该手机号未注册,请先注册"); } } /** * 记录一次验证码校验失败,达到最大次数时删除验证码 * * @param sceneEnum 场景枚举 * @param phone 手机号 * @param codeKey 验证码缓存键 */ private void recordVerifyFailure(SmsSceneEnum sceneEnum, String phone, String codeKey) { String errorKey = buildErrorKey(sceneEnum, phone); int failCount = Convert.toInt(redisService.getCacheObject(errorKey), 0) + 1; if (failCount >= SmsConstants.MAX_VERIFY_ATTEMPTS) { // 错误次数过多,验证码作废,需重新获取 redisService.deleteObject(codeKey); redisService.deleteObject(errorKey); log.warn("[SMS_VERIFY] too many failures, phone={}", maskPhone(phone)); throw new ServiceException("验证码错误次数过多,请重新获取"); } // 错误计数有效期与验证码保持一致,避免长期占用 long expireSeconds = redisService.getExpire(codeKey); if (expireSeconds <= 0) { expireSeconds = SmsConstants.CODE_EXPIRE_MINUTES * 60; } redisService.setCacheObject(errorKey, failCount, expireSeconds, TimeUnit.SECONDS); } /** * 生成指定长度的数字验证码 * * @return 验证码 */ private String generateCode() { StringBuilder builder = new StringBuilder(SmsConstants.CODE_LENGTH); for (int i = 0; i < SmsConstants.CODE_LENGTH; i++) { builder.append(RANDOM.nextInt(10)); } return builder.toString(); } /** * 构建验证码缓存键 */ private String buildCodeKey(SmsSceneEnum sceneEnum, String phone) { return CacheConstants.SMS_CODE_KEY + sceneEnum.getCode() + ":" + phone; } /** * 构建错误次数缓存键 */ private String buildErrorKey(SmsSceneEnum sceneEnum, String phone) { return CacheConstants.SMS_CODE_ERR_KEY + sceneEnum.getCode() + ":" + phone; } /** * 手机号脱敏,形如 138****0000 */ private String maskPhone(String phone) { if (StringUtils.isEmpty(phone) || phone.length() < 7) { return phone; } return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); } }