VerificationCodeServiceImpl.java

package org.darkroomlibrary.service.impl;

import org.darkroomlibrary.infrastructure.security.VerificationCodeStore;
import org.darkroomlibrary.web.response.ApiResponse;
import org.darkroomlibrary.domain.type.VerificationCodePurpose;
import org.darkroomlibrary.service.VerificationCodeService;
import org.darkroomlibrary.utils.MailUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import jakarta.annotation.Resource;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;

@Slf4j
@Service
public class VerificationCodeServiceImpl implements VerificationCodeService {

    private static final long CODE_EXPIRE_MS = 5 * 60 * 1000;
    private static final long RESEND_INTERVAL_MS = 60 * 1000;
    private static final String DEFAULT_PURPOSE = VerificationCodePurpose.REGISTER.name();

    private final SecureRandom secureRandom = new SecureRandom();

    @Value("${verification-code.daily-max-per-email:10}")
    private int dailyMaxPerEmail;

    @Resource
    private MailUtil mailUtil;

    @Resource
    private VerificationCodeStore verificationCodeStore;

    @Override
    public ApiResponse<String> sendCode(String email) {
        return sendCode(email, DEFAULT_PURPOSE);
    }

    @Override
    public ApiResponse<String> sendCode(String email, String purpose) {
        Optional<VerificationCodePurpose> normalizedPurpose = VerificationCodePurpose.from(purpose);
        if (normalizedPurpose.isEmpty()) {
            return ApiResponse.error("验证码用途不合法");
        }
        String normalizedEmail = normalizeEmail(email);
        if (normalizedEmail == null) {
            return ApiResponse.error("请输入邮箱");
        }

        String sendSlotToken = UUID.randomUUID().toString();
        if (!verificationCodeStore.tryAcquireSendSlot(
                normalizedEmail, sendSlotToken, RESEND_INTERVAL_MS)) {
            return ApiResponse.error("请60秒后再试");
        }

        long sendCount = verificationCodeStore.incrementDailySendCount(normalizedEmail, millisUntilTomorrow());
        if (sendCount > dailyMaxPerEmail) {
            verificationCodeStore.releaseSendSlot(normalizedEmail, sendSlotToken);
            return ApiResponse.error("该邮箱今日验证码发送次数已达上限");
        }

        String code = String.format("%06d", secureRandom.nextInt(1000000));
        String purposeName = normalizedPurpose.get().name();
        verificationCodeStore.putCode(purposeName, normalizedEmail, code, CODE_EXPIRE_MS);
        try {
            mailUtil.sendVerificationCode(normalizedEmail, code);
        } catch (IllegalStateException e) {
            verificationCodeStore.removeCode(purposeName, normalizedEmail);
            verificationCodeStore.releaseSendSlot(normalizedEmail, sendSlotToken);
            log.warn("验证码邮件发送配置缺失: {}", e.getMessage());
            return ApiResponse.error(e.getMessage());
        } catch (Exception e) {
            verificationCodeStore.removeCode(purposeName, normalizedEmail);
            verificationCodeStore.releaseSendSlot(normalizedEmail, sendSlotToken);
            log.error("验证码邮件发送失败: {}", normalizedEmail, e);
            return ApiResponse.error("邮件发送失败,请稍后重试");
        }

        log.info("验证码已发送到: {}", normalizedEmail);
        return ApiResponse.success("验证码已发送,请查收邮箱");
    }

    @Override
    public boolean verify(String email, String code) {
        return verify(email, DEFAULT_PURPOSE, code);
    }

    @Override
    public boolean verify(String email, String purpose, String code) {
        Optional<VerificationCodePurpose> normalizedPurpose = VerificationCodePurpose.from(purpose);
        String normalizedEmail = normalizeEmail(email);
        if (normalizedPurpose.isEmpty() || normalizedEmail == null || code == null) {
            return false;
        }

        String purposeName = normalizedPurpose.get().name();
        return verificationCodeStore.consumeCode(purposeName, normalizedEmail, code);
    }

    @Override
    @Scheduled(fixedRate = 60000)
    public void clearExpired() {
        verificationCodeStore.clearExpired();
    }

    private String normalizeEmail(String email) {
        if (email == null || email.trim().isEmpty()) {
            return null;
        }
        return email.trim().toLowerCase(Locale.ROOT);
    }

    private long millisUntilTomorrow() {
        LocalDateTime tomorrow = LocalDate.now().plusDays(1).atStartOfDay();
        return Math.max(Duration.between(LocalDateTime.now(), tomorrow).toMillis(), 1L);
    }
}