springboot防抖 限流 幂等实现 AOP注解实现

概要

在实际应用中,我们会经常用到限流,防抖,幂等等操作,在服务端后台也经常用到

效果图

代码

注解

java 复制代码
import java.lang.annotation.*;

/**
 * 防重复提交注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
    /**
     * 有效期(毫秒), 默认5000秒
     */
    long expire() default 5000;
}

切面(前置通知)

java 复制代码
import com.cc672cc.aop.annotation.NoRepeatSubmit;
import com.cc672cc.common.constants.RedisPreKey;
import com.cc672cc.common.utils.ShiroUtils;
import com.cc672cc.enums.ExceptionEnum;
import com.cc672cc.exceptions.BusinessException;
import com.cc672cc.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.Before;
import org.aspectj.lang.annotation.Aspect;
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.cc672cc.enums.ExceptionEnum.RATE_LIMIT_EXCEPTION;
import static com.cc672cc.enums.ExceptionEnum.SYSTEM_ERROE;

/**
 * 幂等拦截切面(重复提交校验)
 * 核心:基于Redis实现,通过用户ID+请求URI+参数MD5生成唯一标识,防止重复提交
 */
@Aspect
@Component
@Slf4j
public class RepeatSubmitAspect {

    @Autowired
    private IRedisService redisService;

    private static final String MD5 = "MD5";

    @Before("@annotation(noRepeatSubmit)")
    public void before(JoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) {
        // 1. 获取当前请求对象
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            throw new BusinessException(SYSTEM_ERROE.getCode(), "无法获取当前请求上下文");
        }
        HttpServletRequest request = attributes.getRequest();

        // 2. 获取当前登录用户ID(未登录则拒绝)
        Long userId = ShiroUtils.getCurLoginUserId();
        if (userId == null) {
            throw new BusinessException(ExceptionEnum.UNAUTHENTICATED);
        }
        // 3. 构建请求唯一标识(URI + 参数)
        String requestUri = request.getRequestURI();
        String args = Arrays.toString(joinPoint.getArgs());

        // 4. 生成参数MD5哈希(保证唯一且长度固定)
        String paramHash = generateMD5Hash(requestUri + args);
        if (StringUtils.isBlank(paramHash)) {
            throw new BusinessException(SYSTEM_ERROE.getCode(), "参数哈希生成失败");
        }

        // 5. 构建Redis幂等Key
        String redisKey = RedisPreKey.CAHCE_REPEAT_SUBMIT + userId + ":" + paramHash;

        // 6. 幂等校验核心逻辑(SETNX:不存在则设置,存在则抛出异常)
        Boolean exists = redisService.hasKey(redisKey);
        if (Boolean.TRUE.equals(exists)) {
            // 2. 获取剩余过期时间(毫秒),处理null值
            Long expireMillis = redisService.getExpire(redisKey, TimeUnit.MILLISECONDS);
            // 兜底:若返回null/负数,默认0毫秒
            expireMillis = (expireMillis == null || expireMillis < 0) ? 0L : expireMillis;

            // 3. 毫秒转秒,保留1位小数(四舍五入)
            double expireSeconds = expireMillis / 1000.0;
            // 四舍五入保留1位小数(解决原代码Math.round直接取整的问题)
            expireSeconds = Math.round(expireSeconds * 10) / 10.0;

            // 4. 抛限流异常,格式化显示1位小数
            throw new BusinessException(RATE_LIMIT_EXCEPTION.getCode(),
                    String.format("触发限流,请%.1fs后再试", expireSeconds));
        }

        // 7. 设置Redis锁(过期时间 = 注解配置的expire值)
        redisService.set(redisKey, "1", noRepeatSubmit.expire(), TimeUnit.MILLISECONDS);
        log.info("幂等拦截成功,用户ID:{},请求URI:{},RedisKey:{}", userId, requestUri, redisKey);
    }


    /**
     * 生成MD5哈希值
     */
    private String generateMD5Hash(String content) {
        if (StringUtils.isBlank(content)) {
            return "";
        }
        try {
            MessageDigest md = MessageDigest.getInstance(MD5);
            byte[] bytes = md.digest(content.getBytes(StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder();
            for (byte b : bytes) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("MD5算法不存在", e);
        }
    }
}

测试接口层

java 复制代码
public class PortalTestController {

    @GetMapping("/auth-hello")
    @Operation(summary = "认证hello")
    @NoRepeatSubmit(expire = 5000)
    public String authHello(@RequestParam(value = "word", required = false) String word) {
        return "authHello" + ",word=" + word;
    }

}
相关推荐
devlei10 小时前
从源码泄露看AI Agent未来:深度对比Claude Code原生实现与OpenClaw开源方案
android·前端·后端
pshdhx_albert11 小时前
AI agent实现打字机效果
java·http·ai编程
沉鱼.4411 小时前
第十二届题目
java·前端·算法
努力的小郑12 小时前
Canal 不难,难的是用好:从接入到治理
后端·mysql·性能优化
赫瑞12 小时前
数据结构中的排列组合 —— Java实现
java·开发语言·数据结构
Victor35612 小时前
MongoDB(87)如何使用GridFS?
后端
Victor35612 小时前
MongoDB(88)如何进行数据迁移?
后端
小红的布丁13 小时前
单线程 Redis 的高性能之道
redis·后端
GetcharZp13 小时前
Go 语言只能写后端?这款 2D 游戏引擎刷新你的认知!
后端
周末也要写八哥13 小时前
多进程和多线程的特点和区别
java·开发语言·jvm