Triển khai Phòng trừ Gửi lại, Hạn tốc và Tính đẳng cấp trong Spring Boot bằng AOP

Tổng quan

Trong các ứng dụng thực tế, chúng ta thường cần sử dụng các kỹ thuật hạn tốc, phòng chống gửi lại và đảm bảo tính đẳng cấp, đặc biệt ở phía server backend.

Mã nguồn

Annotation

import java.lang.annotation.*;

/**
 * Annotation phòng chống gửi lại
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AntiDuplicateSubmit {
    /**
     * Thời gian hiệu lực (mili giây), mặc định 5000ms
     */
    long duration() default 5000;
}

Aspect (Tiền xử lý)

import com.example.aop.annotation.AntiDuplicateSubmit;
import com.example.common.constants.RedisKeyPrefix;
import com.example.common.utils.SecurityUtil;
import com.example.enums.ErrorType;
import com.example.exceptions.ServiceException;
import com.example.service.IRedisService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;

import static com.example.enums.ErrorType.RATE_LIMIT_ERROR;
import static com.example.enums.ErrorType.SYSTEM_ERROR;

/**
 * Aspect kiểm tra trùng lặp (phòng chống gửi lại)
 * Cốt lõi: Sử dụng Redis, tạo định danh duy nhất từ ID người dùng + URI + tham số MD5
 */
@Aspect
@Component
@Slf4j
public class DuplicateSubmitAspect {

    @Autowired
    private IRedisService redisService;

    private static final String MD5_ALGORITHM = "MD5";

    @Before("@annotation(antiDuplicateSubmit)")
    public void validateDuplicateSubmit(JoinPoint joinPoint, AntiDuplicateSubmit antiDuplicateSubmit) {
        // 1. Lấy đối tượng request hiện tại
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            throw new ServiceException(SYSTEM_ERROR.getCode(), "Không thể lấy ngữ cảnh request hiện tại");
        }
        HttpServletRequest request = attributes.getRequest();

        // 2. Lấy ID người dùng đang đăng nhập (chưa đăng nhập thì từ chối)
        Long userId = SecurityUtil.getCurrentUserId();
        if (userId == null) {
            throw new ServiceException(ErrorType.UNAUTHORIZED);
        }
        
        // 3. Xây dựng định danh request duy nhất (URI + tham số)
        String requestUri = request.getRequestURI();
        String args = Arrays.toString(joinPoint.getArgs());

        // 4. Tạo hash MD5 của tham số (đảm bảo duy nhất và độ dài cố định)
        String paramHash = createMD5Hash(requestUri + args);
        if (StringUtils.isBlank(paramHash)) {
            throw new ServiceException(SYSTEM_ERROR.getCode(), "Tạo hash tham số thất bại");
        }

        // 5. Xây dựng Redis key cho việc kiểm tra trùng lặp
        String redisKey = RedisKeyPrefix.DUPLICATE_SUBMIT_PREFIX + userId + ":" + paramHash;

        // 6. Logic kiểm tra trùng lặp cốt lõi (SETNX: nếu không tồn tại thì đặt, tồn tại thì ném ngoại lệ)
        Boolean exists = redisService.hasKey(redisKey);
        if (Boolean.TRUE.equals(exists)) {
            // 2. Lấy thời gian hết hạn còn lại (mili giây), xử lý null value
            Long remainingMillis = redisService.getExpire(redisKey, TimeUnit.MILLISECONDS);
            // Fallback: nếu trả về null/số âm, mặc định 0 mili giây
            remainingMillis = (remainingMillis == null || remainingMillis < 0) ? 0L : remainingMillis;

            // 3. Chuyển mili giây thành giây, giữ 1 chữ số thập phân (làm tròn)
            double remainingSeconds = remainingMillis / 1000.0;
            // Làm tròn đến 1 chữ số thập phân (giải quyết vấn đề Math.round làm tròn nguyên)
            remainingSeconds = Math.round(remainingSeconds * 10) / 10.0;

            // 4. Ném ngoại lệ hạn tốc, hiển thị định dạng 1 chữ số thập phân
            throw new ServiceException(RATE_LIMIT_ERROR.getCode(),
                    String.format("Đã đạt giới hạn tốc độ, vui lòng thử lại sau %.1f giây", remainingSeconds));
        }

        // 7. Thiết lập khóa Redis (thời gian hết hạn = giá trị duration trong annotation)
        redisService.set(redisKey, "1", antiDuplicateSubmit.duration(), TimeUnit.MILLISECONDS);
        log.info("Kiểm tra trùng lặp thành công, ID người dùng: {}, URI request: {}, RedisKey: {}", 
                userId, requestUri, redisKey);
    }


    /**
     * Tạo giá trị băm MD5
     */
    private String createMD5Hash(String input) {
        if (StringUtils.isBlank(input)) {
            return "";
        }
        try {
            MessageDigest md = MessageDigest.getInstance(MD5_ALGORITHM);
            byte[] bytes = md.digest(input.getBytes(StandardCharsets.UTF_8));
            StringBuilder hexString = new StringBuilder();
            for (byte b : bytes) {
                hexString.append(String.format("%02x", b));
            }
            return hexString.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("Thuật toán MD5 không tồn tại", e);
        }
    }
}

Controller kiểm thử

public class TestController {

@GetMapping("/api/test")
@Operation(summary = "API kiểm thử")
@AntiDuplicateSubmit(duration = 5000)
public String testEndpoint(@RequestParam(value = "message", required = false) String message) {
return "Kết quả kiểm thử" + ", message=" + message;
}
}

Thẻ: Spring Boot AOP Redis Debounce Rate Limiting

Đăng vào ngày 16 tháng 05 lúc 19:11