package com.mydreamjob.auth; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.sms.SmsCodeSender; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.regex.Pattern; import java.util.concurrent.TimeUnit; /** * 短信验证码服务。 * *

验证码只写入短信验证码表,并通过 {@link SmsCodeSender} 投递; * 任何级别的日志都不得包含验证码或完整手机号。

*/ @Slf4j @Service @RequiredArgsConstructor public class SmsVerificationService { private final SmsVerificationCodeRepository codeRepository; private final RedisTemplate redisTemplate; private final AuditService auditService; private final SmsCodeSender smsCodeSender; @Value("${app.sms.mock.code-length:6}") private int codeLength; @Value("${app.sms.mock.expire-seconds:300}") private int expireSeconds; @Value("${app.sms.mock.max-attempts:5}") private int maxAttempts; @Value("${app.sms.mock.lockout-seconds:300}") private int lockoutSeconds; private static final String RATE_LIMIT_KEY_PREFIX = "sms:rate:"; private static final String LOCKOUT_KEY_PREFIX = "sms:lockout:"; private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]{11}"); private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}"); private static final Set ALLOWED_PURPOSES = Set.of( "login", "password-set", "password-reset", "company-register"); /** * 发送验证码。 * *

发送动作委派给 {@link SmsCodeSender}: * {@code app.sms.mock.enabled=false} 或未配置时使用不可用实现,安全失败并回滚本次验证码落库。

*/ @Transactional public void sendCode(String phone, String purpose, String requestId, String ipAddress, String userAgent) { validatePhone(phone); validatePurpose(purpose); // 检查锁定 String lockoutKey = LOCKOUT_KEY_PREFIX + phone; if (redisTemplate.hasKey(lockoutKey)) { log.warn("SMS send rejected: phone locked out, phone={}", maskPhone(phone)); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); } // 检查频率限制(60秒内最多1次) String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone; if (redisTemplate.hasKey(rateLimitKey)) { log.warn("SMS send rejected: rate limited, phone={}", maskPhone(phone)); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); } // 生成验证码 String code = generateCode(); LocalDateTime now = LocalDateTime.now(); // 保存验证码记录 SmsVerificationCode smsCode = SmsVerificationCode.builder() .phone(phone) .code(code) .purpose(purpose) .attempts(0) .maxAttempts(maxAttempts) .expiredAt(now.plusSeconds(expireSeconds)) .used(false) .build(); codeRepository.save(smsCode); // 设置频率限制 redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS); // 投递验证码:实现方保证不把验证码/完整手机号写入日志。 // 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。 smsCodeSender.sendVerificationCode(phone, purpose, code); // 审计明细同样不得记录完整手机号 auditService.log(null, "SMS_SEND", "PHONE", null, "SMS send accepted; phone=" + maskPhone(phone) + "; purpose=" + purpose, ipAddress, userAgent, requestId); } /** * 手机号脱敏:仅保留前 3 位与后 4 位,用于日志与审计明细。 */ private String maskPhone(String phone) { if (phone == null || phone.length() < 7) { return "***"; } return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); } /** * 验证验证码 * 按手机号+用途查询最新一条未使用记录,原子性标记已使用。 */ @Transactional public boolean verifyCode(String phone, String code, String purpose) { validatePhone(phone); validateCode(code); validatePurpose(purpose); String lockoutKey = LOCKOUT_KEY_PREFIX + phone; if (redisTemplate.hasKey(lockoutKey)) { throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); } // 查询最新一条未使用的验证码(按 createdAt DESC,仅返回第一条) java.util.Optional opt = codeRepository.streamLatestUnusedByPhoneAndPurpose(phone, purpose) .filter(c -> !c.getUsed()) .findFirst(); if (opt.isEmpty()) { throw new BusinessException(ErrorCode.SMS_CODE_INVALID); } SmsVerificationCode smsCode = opt.get(); // 检查过期 LocalDateTime now = LocalDateTime.now(); if (smsCode.getExpiredAt().isBefore(now)) { throw new BusinessException(ErrorCode.SMS_CODE_EXPIRED); } // 检查尝试次数 if (smsCode.getAttempts() >= smsCode.getMaxAttempts()) { redisTemplate.opsForValue().set(lockoutKey, "1", lockoutSeconds, TimeUnit.SECONDS); throw new BusinessException(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED); } // 校验验证码值 if (!code.equals(smsCode.getCode())) { smsCode.setAttempts(smsCode.getAttempts() + 1); codeRepository.save(smsCode); throw new BusinessException(ErrorCode.SMS_CODE_INVALID); } // 条件更新保证同一验证码并发只能被一个事务消费;更新 0 行表示已被其他请求消费。 if (codeRepository.consumeIfValid(smsCode.getId(), now) != 1) { throw new BusinessException(ErrorCode.SMS_CODE_INVALID); } return true; } private String generateCode() { StringBuilder sb = new StringBuilder(codeLength); for (int i = 0; i < codeLength; i++) { sb.append(SECURE_RANDOM.nextInt(10)); } return sb.toString(); } private void validatePhone(String phone) { if (phone == null || !PHONE_PATTERN.matcher(phone).matches()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "phone must contain exactly 11 digits"); } } private void validateCode(String code) { if (code == null || !CODE_PATTERN.matcher(code).matches()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "code must contain exactly 6 digits"); } } private void validatePurpose(String purpose) { if (purpose == null || !ALLOWED_PURPOSES.contains(purpose)) { throw new BusinessException(ErrorCode.BAD_REQUEST, "unsupported sms purpose"); } } }