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/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)
|
{
|
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 场景;配置为空即关闭)
|
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 场景生效;命中即直接放行,不读缓存、不计数、不消费。
|
*
|
* @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)
|
{
|
return false;
|
}
|
return universalCode.equals(inputCode);
|
}
|
|
/**
|
* 校验手机号是否已被注册
|
*
|
* @param phone 手机号
|
*/
|
private void checkPhoneNotRegistered(String phone)
|
{
|
R<AccountMember> 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<AccountMember> 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);
|
}
|
}
|