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;
    }

}
相关推荐
橙子家6 分钟前
RESTful 接口:它并非标准或协议,而是一种【架构风格】
后端
RuoyiOffice6 分钟前
KubeSphere 容器云实践:从管理 K8s 到建设企业 PaaS,还需要哪些能力
spring boot·kubernetes·kubesphere·jenkins·paas·微服务部署·ruoyi office
杨凯凡8 分钟前
Spring Framework 7 API 版本管理实战:请求头路由、兼容迁移与废弃策略
java·rest api·springframework·spring boot 4·api版本管理
T_Wizard35 分钟前
同一个问题写了 4 遍:官方 SDK / HTTP / Spring AI / LangChain4j,到底该选哪个
java·ai编程
hai_android37 分钟前
AudioTrack 深度解析:从原理到实战
android·java·kotlin
小兔子1 小时前
dict.get 的默认值每次都会求值,哪怕键本来就在
后端
斯维赤1 小时前
限流、熔断、降级,你真的分得清吗?
java·后端·架构
肌肉娃子1 小时前
红队攻防测试报告 — wechat-e.cfd
后端
陈随易1 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
海螺_AIHCC1 小时前
SSE 流式返回中文变问号,问题不在编码
后端