人人都会AI编程

9.4 自定义注解实现通用切面:操作日志、权限校验、接口限流

更新时间:2026-07-11

在实际项目中,操作日志记录、权限校验、接口限流这三类需求几乎每个系统都会遇到。它们的共同点在于:属于横切关注点,散落在各个方法中会导致大量重复代码。本节通过自定义注解与 AOP 切面的组合,提炼出一套可复用的通用方案,让业务代码只需一个注解即可自动获得这些能力。

9.4.1 整体设计思路

方案的核心步骤分为三部分:

  1. 定义注解:声明元数据,例如操作类型、模块名称、需要的权限标识、限流阈值等。
  2. 编写切面:用 @Aspect 声明切面,根据注解中的元数据执行具体的增强逻辑(记录日志、检查权限、令牌桶限流等)。
  3. 在控制器或服务方法上标注注解:业务方法只需关注自身逻辑,横切功能由切面透明织入。

下面分别给出三种典型场景的实现,示例基于 Spring Boot + AOP,部分依赖于真实可用的中间件(Redis 用于限流)。

9.4.2 操作日志切面

操作日志的需求通常包括:记录操作人、操作时间、操作模块、操作动作、请求参数、耗时等。通过自定义 @OperationLog 注解,可以在任何需要记录的方法上快速启用。

1. 定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
    /** 操作模块,比如 "用户管理" */
    String module() default "";
    /** 操作动作,比如 "新增用户" */
    String action() default "";
    /** 是否记录请求参数 */
    boolean recordParams() default true;
    /** 是否记录响应结果(敏感信息慎用) */
    boolean recordResult() default false;
}

2. 切面实现

@Aspect
@Component
@Slf4j
public class OperationLogAspect {

    @Around("@annotation(operationLog)")
    public Object around(ProceedingJoinPoint point, OperationLog operationLog) throws Throwable {
        // 获取当前登录用户(通常从 SecurityContext 或自定义上下文中获取)
        String operator = getCurrentUsername();

        // 记录开始时间
        long start = System.currentTimeMillis();

        // 获取方法参数(可进行脱敏处理)
        Object[] args = point.getArgs();
        String params = operationLog.recordParams() ? Arrays.toString(args) : "已隐藏";

        // 执行目标方法
        Object result = null;
        Throwable exception = null;
        try {
            result = point.proceed();
            return result;
        } catch (Throwable e) {
            exception = e;
            throw e;
        } finally {
            long cost = System.currentTimeMillis() - start;
            // 只在执行成功或异常时均记录日志(根据实际需求调整)
            if (operationLog.recordResult() && exception == null) {
                log.info("[操作日志] 操作人={} 模块={} 动作={} 参数={} 结果={} 耗时={}ms",
                            operator, operationLog.module(), operationLog.action(), params, result, cost);
            } else {
                log.error("[操作日志] 操作人={} 模块={} 动作={} 参数={} 异常={} 耗时={}ms",
                            operator, operationLog.module(), operationLog.action(), params, exception, cost);
            }
        }
    }

    private String getCurrentUsername() {
        // 例如从 Spring Security 获取
        // Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        // return auth == null ? "anonymous" : auth.getName();
        return "system"; // 示例,实际使用时替换为真实获取方式
    }
}

3. 使用示例

@RestController
@RequestMapping("/user")
public class UserController {

    @PostMapping
    @OperationLog(module = "用户管理", action = "新增用户")
    public R<Void> addUser(@RequestBody @Validated UserDTO user) {
        userService.addUser(user);
        return R.ok();
    }
}

这样,新增用户操作会被自动记录日志,无需在每个方法内重复编写日志代码。

9.4.3 权限校验切面

传统的权限校验通常写在方法开头,使用 if-else 判断用户角色或权限标识,代码耦合度高且容易遗漏。通过 @RequirePermission 注解配合切面,可以实现声明式权限控制。

1. 定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
    /** 需要的权限标识,如 "sys:user:add" */
    String value();
    /** 校验失败时抛出的消息 */
    String message() default "没有访问权限";
}

2. 切面实现

@Aspect
@Component
public class PermissionAspect {

    @Around("@annotation(requirePermission)")
    public Object around(ProceedingJoinPoint point, RequirePermission requirePermission) throws Throwable {
        // 获取当前用户权限集合(从认证信息中获取)
        Set<String> userPermissions = getCurrentUserPermissions();
        String requiredPerm = requirePermission.value();

        if (!userPermissions.contains(requiredPerm)) {
            throw new SecurityException(requirePermission.message());
        }
        return point.proceed();
    }

    private Set<String> getCurrentUserPermissions() {
        // 示例:通常从 SecurityContext 或 Token 中提取权限列表
        return new HashSet<>(Arrays.asList("sys:user:add", "sys:user:delete"));
    }
}

3. 全局异常捕获

当权限不足时,切面抛出 SecurityException,由全局异常处理器统一转换为标准 HTTP 响应:

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(SecurityException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public R<Void> handleSecurityException(SecurityException e) {
        return R.fail(403, e.getMessage());
    }
}

4. 使用示例

@DeleteMapping("/{id}")
@RequirePermission(value = "sys:user:delete", message = "您没有删除用户的权限")
public R<Void> deleteUser(@PathVariable Long id) {
    userService.removeById(id);
    return R.ok();
}

代码语义清晰,安全检查从业务逻辑中完全剥离。

9.4.4 接口限流切面

面对大流量或防刷场景,接口限流必不可少。这里选择基于 滑动窗口算法 + Redis 的实现,通过自定义 @RateLimiter 注解标识需要限流的方法。

1. 定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {
    /** 限流 key,支持 SpEL 表达式,如 "'sms:' + #phone" */
    String key() default "";
    /** 时间窗口,单位秒 */
    int window() default 60;
    /** 窗口内最大请求数 */
    int limit() default 10;
    /** 限流提示信息 */
    String message() default "请求过于频繁,请稍后再试";
}

2. 切面实现

@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class RateLimiterAspect {
    private final StringRedisTemplate redisTemplate;

    @Around("@annotation(rateLimiter)")
    public Object around(ProceedingJoinPoint point, RateLimiter rateLimiter) throws Throwable {
        // 解析 key,支持简单 SpEL 表达式
        String key = parseKey(rateLimiter.key(), point);
        int window = rateLimiter.window();
        int limit = rateLimiter.limit();

        // 滑动窗口限流逻辑(基于 Redis ZSET)
        long now = System.currentTimeMillis();
        long windowStart = now - window * 1000L;

        // 删除窗口外的记录
        redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
        // 统计当前窗口内的请求数
        Long count = redisTemplate.opsForZSet().count(key, windowStart, now);

        if (count != null && count >= limit) {
            log.warn("接口限流触发,key: {}, 当前请求数: {}", key, count);
            throw new RateLimitException(rateLimiter.message());
        }

        // 记录本次请求
        redisTemplate.opsForZSet().add(key, String.valueOf(now), now);
        // 设置 key 的过期时间,避免僵尸数据
        redisTemplate.expire(key, Duration.ofSeconds(window));

        return point.proceed();
    }

    private String parseKey(String keyExpr, ProceedingJoinPoint point) {
        // 简单实现:如果 key 为空,使用方法签名作为默认 key
        if (!StringUtils.hasText(keyExpr)) {
            return point.getSignature().toShortString();
        }
        // 生产中可集成 SpEL 解析器,此处略
        return keyExpr;
    }
}

3. 自定义限流异常

public class RateLimitException extends RuntimeException {
    public RateLimitException(String message) {
        super(message);
    }
}

全局异常处理器照常捕获并返回 429 Too Many Requests 或自定义状态码。

4. 使用示例

@PostMapping("/send-sms")
@RateLimiter(key = "'sms:' + #phone", window = 60, limit = 5, message = "短信发送过于频繁,请1分钟后再试")
public R<Void> sendSms(@RequestParam String phone) {
    smsService.send(phone);
    return R.ok();
}

同一个手机号在 60 秒内最多触发 5 次短信发送,超出即被拦截。

9.4.5 组合与注意事项

这三个切面既可以单独使用,也可以同时标注在同一个方法上。Spring AOP 的执行顺序由切面的优先级决定,可通过 @Order 控制。建议将权限校验放在最前,限流次之,日志记录最后,确保:

  • 没有权限的请求直接拒绝,不占用限流计数;
  • 限流过滤后,只记录真正执行业务的请求日志。

此外,在实现时需注意:

  • 异常处理一致:所有切面抛出的业务异常应由统一的全局异常处理器捕获,返回标准 API 响应。
  • 性能开销:限流切面涉及 Redis 网络调用,对高敏感接口可考虑异步记录日志,但权限校验必须同步执行。
  • 可测试性:切面与业务代码解耦,单元测试时甚至可以单独 Mock 切面逻辑或直接忽略注解。

通过自定义注解与 AOP 的结合,我们将操作日志、权限校验、接口限流拆解为独立、可复用的切面,让业务代码回归纯粹,极大提升了代码的可读性与扩展性。