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发送动作委派给 {@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