CaptchaServiceImpl.java

package org.darkroomlibrary.service.impl;

import jakarta.annotation.Resource;
import org.darkroomlibrary.infrastructure.cache.CacheService;
import org.darkroomlibrary.service.CaptchaService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.security.SecureRandom;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class CaptchaServiceImpl implements CaptchaService {

    private static final long CAPTCHA_EXPIRE_MS = 5 * 60 * 1000;
    private static final Duration CAPTCHA_TTL = Duration.ofMillis(CAPTCHA_EXPIRE_MS);
    private static final String CAPTCHA_PREFIX = "captcha:";

    @Resource
    private CacheService cacheService;

    private final Map<String, CaptchaEntry> captchaMap = new ConcurrentHashMap<>();
    private final SecureRandom random = new SecureRandom();

    @Override
    public Map<String, String> generate() {
        int a = random.nextInt(20) + 1;
        int b = random.nextInt(20) + 1;
        int op = random.nextInt(3);
        int answer;
        String expression;
        switch (op) {
            case 0:
                answer = a + b;
                expression = a + " + " + b + " = ?";
                break;
            case 1:
                answer = a - b;
                expression = a + " - " + b + " = ?";
                break;
            default:
                answer = a * b;
                expression = a + " × " + b + " = ?";
                break;
        }

        String captchaId = UUID.randomUUID().toString().replace("-", "");
        boolean storedInRedis = cacheService.setString(
                CAPTCHA_PREFIX + captchaId,
                String.valueOf(answer),
                CAPTCHA_TTL
        );
        if (!storedInRedis) {
            captchaMap.put(captchaId, new CaptchaEntry(answer, System.currentTimeMillis() + CAPTCHA_EXPIRE_MS));
        }

        Map<String, String> result = new HashMap<>();
        result.put("captchaId", captchaId);
        result.put("expression", expression);
        return result;
    }

    @Override
    public boolean verify(String captchaId, Integer answer) {
        if (captchaId == null || captchaId.trim().isEmpty() || answer == null) {
            return false;
        }
        String normalizedId = captchaId.trim();
        var redisAnswer = cacheService.getAndDelete(CAPTCHA_PREFIX + normalizedId);
        if (redisAnswer.isPresent()) {
            captchaMap.remove(normalizedId);
            try {
                return Integer.parseInt(redisAnswer.get()) == answer;
            } catch (NumberFormatException ignored) {
                return false;
            }
        }
        CaptchaEntry entry = captchaMap.remove(normalizedId);
        if (entry == null || System.currentTimeMillis() > entry.expireTime) {
            return false;
        }
        return entry.answer == answer;
    }

    @Scheduled(fixedRate = 60000)
    public void clearExpired() {
        long now = System.currentTimeMillis();
        captchaMap.entrySet().removeIf(entry -> now > entry.getValue().expireTime);
    }

    private static class CaptchaEntry {
        private final int answer;
        private final long expireTime;

        private CaptchaEntry(int answer, long expireTime) {
            this.answer = answer;
            this.expireTime = expireTime;
        }
    }
}