Spring Boot 注解拦截器实现审计日志功能

引言

在业务系统中,审计日志记录至关重要。系统需要记录用户的操作日志,特别是在用户操作数据库修改、查询、删除重要数据时,系统应追踪操作人的身份、操作的对象、操作的时间等关键数据。这不仅对运维、合规性有帮助,同时也能提高系统的可审计性和安全性。

本篇文章将深入讲解如何在 Spring Boot 中通过注解和拦截器实现审计日志功能。通过自定义注解,可以在不同模块、不同操作上灵活地记录审计信息,包括操作模块、操作对象属性、用户信息和 IP 地址。同时,这一方案具有高度的拓展性,可以适配于不同业务场景。

我们将以电商交易系统为案例进行详细说明,提供表结构设计和完整的代码示例。


1. 项目环境与依赖

在实现审计日志功能之前,我们需要确保项目的环境和依赖配置正确。本例使用的技术栈如下:

  • Spring Boot 2.x
  • Maven
  • JDK 8+
  • MySQL (用于存储审计日志)
  • Lombok (简化 POJO 开发)

1.1 Maven 依赖

首先,在 pom.xml 文件中加入所需的依赖。主要包含 Spring Web、MyBatis 和 Lombok。

<dependencies>
    <!-- Spring Boot Web Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- MyBatis Starter -->
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.4</version>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>

    <!-- MySQL Driver -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Spring Boot DevTools (for development) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

1.2 数据库配置

application.yml 中配置数据库连接信息。

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/ecommerce_db?useSSL=false&serverTimezone=UTC
    username: root
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver

  mybatis:
    mapper-locations: classpath:mapper/*.xml
    type-aliases-package: com.example.ecommerce.model

2. 数据库表结构设计

为了记录审计日志,我们需要设计一个用于存储日志信息的数据库表。这里,我们设计一个 audit_logs 表,用于保存操作模块、操作的对象信息、操作用户、IP 地址等审计数据。

2.1 审计日志表 audit_logs

CREATE TABLE `audit_logs` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `module_name` VARCHAR(255) NOT NULL, -- 操作模块
  `object_id` VARCHAR(255) NOT NULL, -- 操作对象的ID(例如订单ID、用户ID等)
  `object_detail` TEXT, -- 操作对象的详细信息(可选)
  `operation` VARCHAR(255) NOT NULL, -- 操作类型,如创建、修改、删除
  `user_id` BIGINT NOT NULL, -- 操作用户的ID
  `username` VARCHAR(255) NOT NULL, -- 操作用户的名称
  `ip_address` VARCHAR(50), -- 用户的IP地址
  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- 记录时间
);

字段解释:

  • module_name:记录操作发生在哪个模块,比如"订单模块"或"用户模块"。
  • object_id:记录操作对象的主键 ID,如修改的是订单,记录订单 ID。
  • object_detail:操作对象的详细信息,如订单的具体信息,方便后续审计。
  • operation:记录用户的操作类型,如创建、修改、删除等。
  • user_idusername:操作用户的信息。
  • ip_address:用户操作时的 IP 地址。
  • created_at:记录审计日志创建的时间。

3. 自定义注解 @AuditLog

3.1 注解设计

通过自定义注解 @AuditLog,我们可以标记在需要记录日志的地方,比如在 Service 层或 Controller 层。注解的参数可以包括操作模块名、需要记录的对象属性等。

package com.example.ecommerce.annotation;

import java.lang.annotation.*;

/**
 * 用于记录操作审计日志的自定义注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuditLog {

    /**
     * 操作模块名称(如 "订单模块", "用户模块")
     */
    String moduleName();

    /**
     * 操作类型(如 "创建", "修改", "删除")
     */
    String operation();

    /**
     * 指定操作对象的属性(如 "orderId" 或 "userId")
     */
    String objectId() default "id";
}
  • moduleName:指定操作的模块名,便于区分日志来源。
  • operation:操作类型,如创建、修改、删除等。
  • objectId:用于标识操作对象的主键属性。

4. 实现审计日志拦截器

4.1 用户上下文 UserContext

首先我们创建一个用户上下文 UserContext,用来保存当前用户的登录信息和 IP 地址。在实际应用中,用户登录信息一般是通过 JWT 或 Session 获取的,这里为了简化,假设这些信息已经存在。

package com.example.ecommerce.util;

public class UserContext {

    private static final ThreadLocal<Long> userId = new ThreadLocal<>();
    private static final ThreadLocal<String> username = new ThreadLocal<>();
    private static final ThreadLocal<String> ipAddress = new ThreadLocal<>();

    public static void setUserId(Long id) {
        userId.set(id);
    }

    public static Long getUserId() {
        return userId.get();
    }

    public static void setUsername(String name) {
        username.set(name);
    }

    public static String getUsername() {
        return username.get();
    }

    public static void setIpAddress(String ip) {
        ipAddress.set(ip);
    }

    public static String getIpAddress() {
        return ipAddress.get();
    }

    public static void clear() {
        userId.remove();
        username.remove();
        ipAddress.remove();
    }
}

4.2 审计日志拦截器

接下来,我们实现一个 Spring 的 HandlerInterceptor 拦截器,用于拦截带有 @AuditLog 注解的方法,并记录日志。

package com.example.ecommerce.interceptor;

import com.example.ecommerce.annotation.AuditLog;
import com.example.ecommerce.model.AuditLogRecord;
import com.example.ecommerce.service.AuditLogService;
import com.example.ecommerce.util.UserContext;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;

/**
 * 用于记录审计日志的拦截器
 */
@Aspect
@Component
public class AuditLogInterceptor {

    @Autowired
    private AuditLogService auditLogService;

    @Autowired
    private HttpServletRequest request;

    @Around("@annotation(com.example.ecommerce.annotation.AuditLog)")
    public Object logAudit(ProceedingJoinPoint joinPoint) throws Throwable {
        Method method = getTargetMethod(joinPoint);

        if (method == null) {
            return joinPoint.proceed();
        }

        AuditLog auditLog = method.getAnnotation(AuditLog.class);

        if (auditLog != null) {
            // 获取用户信息和 IP 地址
            Long userId = UserContext.getUserId();
            String username = UserContext.getUsername();
            String ipAddress = request.getRemoteAddr();

            // 获取操作对象的ID
            Object[] args = joinPoint.getArgs();
            String objectId = getObjectId(args, auditLog.objectId());

            // 执行目标方法
            Object result = joinPoint.proceed();

            // 创建日志记录
            AuditLogRecord record = new AuditLogRecord();
            record.setModuleName(auditLog.moduleName());
            record.setOperation(auditLog.operation());
            record.setUserId(userId);
            record.setUsername(username);
            record.setIpAddress(ipAddress);
            record.setObjectId(objectId);

            // 保存日志
            auditLogService.saveLog(record);

            return result;
        }

        return joinPoint.proceed();
    }

    private Method getTargetMethod(ProceedingJoinPoint joinPoint) {
        Method method = null;
        try {
            method = joinPoint.getTarget().getClass().getMethod(joinPoint.getSignature().getName(),
                    ((MethodSignature) joinPoint.getSignature()).getParameterTypes());
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
        return method;
    }

    private String getObjectId(Object[] args, String objectIdField) {
        try {
            for (Object arg : args) {
                Field field = arg.getClass().getDeclaredField(objectIdField);
                field.setAccessible(true);
                return String.valueOf(field.get(arg));
            }
        } catch (Exception e) {
            // log the error
        }
        return null;
    }
}

4.3 日志服务

我们需要提供一个 AuditLogService,用来保存日志信息。

package com.example.ecommerce.service;

import com.example.ecommerce.model.AuditLogRecord;
import com.example.ecommerce.mapper.AuditLogMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class AuditLogService {

    @Autowired
    private AuditLogMapper auditLogMapper;

    public void saveLog(AuditLogRecord record) {
        auditLogMapper.insert(record);
    }
}

4.4 日志记录实体

package com.example.ecommerce.model;

import lombok.Data;

@Data
public class AuditLogRecord {

    private Long id;
    private String moduleName;
    private String operation;
    private String objectId;
    private String objectDetail;
    private Long userId;
    private String username;
    private String ipAddress;
    private String createdAt;
}

4.5 Mapper 定义

package com.example.ecommerce.mapper;

import com.example.ecommerce.model.AuditLogRecord;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface AuditLogMapper {

    @Insert("INSERT INTO audit_logs(module_name, operation, object_id, user_id, username, ip_address, created_at) " +
            "VALUES (#{moduleName}, #{operation}, #{objectId}, #{userId}, #{username}, #{ipAddress}, NOW())")
    void insert(AuditLogRecord logRecord);
}

5. 示例使用

OrderService 中,我们可以通过 @AuditLog 注解来记录订单的创建操作。

package com.example.ecommerce.service;

import com.example.ecommerce.annotation.AuditLog;
import com.example.ecommerce.model.Order;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    @AuditLog(moduleName = "订单模块", operation = "创建订单", objectId = "orderId")
    public void createOrder(Order order) {
        // 订单创建逻辑
    }
}

在执行 createOrder 方法时,日志将自动记录到 audit_logs 表中。


6. 注解拦截器实现异步审计日志功能

通过自定义注解 @AuditLog 结合拦截器,实现了审计日志功能,记录用户的操作日志。然而,审计日志功能属于辅助功能,它的执行不应该影响到主流程的性能,尤其是在高并发的场景中,日志记录操作可能会成为性能瓶颈。

进一步优化审计日志的实现,将日志记录功能改为异步处理,从而提高接口的性能和响应速度。


6.1 异步处理的必要性

在实际场景中,审计日志功能仅用于记录用户的操作行为,这类操作通常是写入数据库或记录到日志系统中。虽然日志写入过程本身并不复杂,但如果将日志写入与主业务逻辑串行执行,可能会增加响应时间,特别是在高并发场景下。

通过异步化处理,我们可以将日志的记录放到后台线程中执行,主业务流程无需等待日志记录完成,从而提升接口的性能。


6.2 启用异步支持

首先,在 Spring Boot 的主类上添加 @EnableAsync 注解,启用异步功能。

package com.example.ecommerce;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync
public class EcommerceApplication {

    public static void main(String[] args) {
        SpringApplication.run(EcommerceApplication.class, args);
    }
}

6.3 配置线程池

为了更好地处理异步任务,我们可以自定义一个线程池用于执行异步任务。通过线程池可以更好地控制并发数量以及任务的执行速度。

config 包下创建一个 AsyncConfig 类来配置线程池。

package com.example.ecommerce.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "auditLogExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5); // 核心线程数
        executor.setMaxPoolSize(10); // 最大线程数
        executor.setQueueCapacity(500); // 队列容量
        executor.setThreadNamePrefix("AuditLog-"); // 线程名称前缀
        executor.initialize();
        return executor;
    }
}

在上述配置中,ThreadPoolTaskExecutor 用于处理异步任务,auditLogExecutor 线程池负责异步执行日志记录任务。配置中我们设置了核心线程数为 5,最大线程数为 10,队列容量为 500。可以根据实际需求调整这些参数。


6.4 修改日志服务

接下来,我们将之前的 AuditLogService 进行修改,使其能够异步记录日志。只需要在日志保存方法上加上 @Async 注解,并指定执行的线程池。

package com.example.ecommerce.service;

import com.example.ecommerce.model.AuditLogRecord;
import com.example.ecommerce.mapper.AuditLogMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class AuditLogService {

    @Autowired
    private AuditLogMapper auditLogMapper;

    /**
     * 异步保存审计日志
     */
    @Async("auditLogExecutor")
    public void saveLog(AuditLogRecord record) {
        // 模拟一个较为耗时的日志记录操作
        try {
            Thread.sleep(200); // 模拟耗时操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        // 保存日志记录到数据库
        auditLogMapper.insert(record);
    }
}

saveLog 方法上,添加了 @Async("auditLogExecutor") 注解,表示该方法会在我们之前配置的 auditLogExecutor 线程池中异步执行。当该方法被调用时,Spring 会将其丢到异步线程中执行,而不会阻塞主线程。

6.5 审计日志拦截器保持不变

我们之前的审计日志拦截器实现并不需要修改,拦截器依旧会在标记有 @AuditLog 注解的方法执行前后进行日志记录操作。唯一的不同是 AuditLogService.saveLog 现在是异步执行的,因此不会阻塞业务方法的执行。

package com.example.ecommerce.interceptor;

import com.example.ecommerce.annotation.AuditLog;
import com.example.ecommerce.model.AuditLogRecord;
import com.example.ecommerce.service.AuditLogService;
import com.example.ecommerce.util.UserContext;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;

@Aspect
@Component
public class AuditLogInterceptor {

    @Autowired
    private AuditLogService auditLogService;

    @Autowired
    private HttpServletRequest request;

    @Around("@annotation(com.example.ecommerce.annotation.AuditLog)")
    public Object logAudit(ProceedingJoinPoint joinPoint) throws Throwable {
        Method method = getTargetMethod(joinPoint);

        if (method == null) {
            return joinPoint.proceed();
        }

        AuditLog auditLog = method.getAnnotation(AuditLog.class);

        if (auditLog != null) {
            // 获取用户信息和 IP 地址
            Long userId = UserContext.getUserId();
            String username = UserContext.getUsername();
            String ipAddress = request.getRemoteAddr();

            // 获取操作对象的ID
            Object[] args = joinPoint.getArgs();
            String objectId = getObjectId(args, auditLog.objectId());

            // 执行目标方法
            Object result = joinPoint.proceed();

            // 创建日志记录
            AuditLogRecord record = new AuditLogRecord();
            record.setModuleName(auditLog.moduleName());
            record.setOperation(auditLog.operation());
            record.setUserId(userId);
            record.setUsername(username);
            record.setIpAddress(ipAddress);
            record.setObjectId(objectId);

            // 异步保存日志
            auditLogService.saveLog(record);

            return result;
        }

        return joinPoint.proceed();
    }

    private Method getTargetMethod(ProceedingJoinPoint joinPoint) {
        Method method = null;
        try {
            method = joinPoint.getTarget().getClass().getMethod(joinPoint.getSignature().getName(),
                    ((MethodSignature) joinPoint.getSignature()).getParameterTypes());
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
        return method;
    }

    private String getObjectId(Object[] args, String objectIdField) {
        try {
            for (Object arg : args) {
                Field field = arg.getClass().getDeclaredField(objectIdField);
                field.setAccessible(true);
                return String.valueOf(field.get(arg));
            }
        } catch (Exception e) {
            // log the error
        }
        return null;
    }
}

6.6 示例使用

假设我们有一个订单模块的 OrderService,通过 @AuditLog 注解,我们可以记录订单创建的操作。由于审计日志的记录现在是异步进行的,因此不会影响接口的响应性能。

package com.example.ecommerce.service;

import com.example.ecommerce.annotation.AuditLog;
import com.example.ecommerce.model.Order;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    @AuditLog(moduleName = "订单模块", operation = "创建订单", objectId = "orderId")
    public void createOrder(Order order) {
        // 订单创建逻辑
    }
}

createOrder 方法执行时,日志记录的操作会被异步提交给后台线程处理,从而确保主业务的执行不受影响。即便日志记录出现一些延迟,也不会影响主流程的性能。


6.7 日志输出

假设 OrderService.createOrder() 方法被调用,并且当前用户的 ID 为 1,用户名为 john_doe,操作的 IP 地址为 192.168.1.1,记录的审计日志最终会存储在数据库的 audit_logs 表中。

日志记录的 SQL 如下:

INSERT INTO audit_logs (module_name, operation, object_id, user_id, username, ip_address, created_at)
VALUES ('订单模块', '创建订单', '12345', 1, 'john_doe', '192.168.1.1', NOW());

7. 总结

通过自定义注解和拦截器,我们可以轻松实现审计日志的自动化记录。通过该方案,系统不仅可以动态记录用户的操作,还可以灵活地扩展到不同的模块和业务场景。

通过将审计日志的记录改为异步执行,整个系统的性能得到了显著提升。主流程执行完毕后,无需等待日志写入的

相关推荐
J不A秃V头A5 分钟前
Spring Boot开发编译后读取不到@spring.profiles.active@的问题
spring boot
会说法语的猪10 分钟前
IDEA使用Alt + Enter快捷键自动接受返回值一直有final修饰的问题处理
java·ide·intellij-idea
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ19 分钟前
MyBatis执行完sql后,返回的数值代表的意思
java·开发语言
CodeClimb1 小时前
【华为OD-E卷-寻找密码 100分(python、java、c++、js、c)】
java·python·华为od
爱上语文1 小时前
宠物管理系统:Service层
java·开发语言·宠物
机器视觉知识推荐、就业指导1 小时前
C++设计模式:组合模式(公司架构案例)
c++·后端·设计模式·组合模式
解梦者1 小时前
Spring(七)Spring Cloud----Feign、Zuul和Apollo
spring·spring cloud·feign·apollo·zuul
水w1 小时前
【项目实践】SpringBoot Nacos配置管理 map数据
java·服务器·开发语言·spring boot·nacos
@菜鸟进阶记@1 小时前
SpringBoot核心:自动配置
java·spring boot·后端
长安05111 小时前
面试经典题目:LeetCode134_加油站
c++·算法·面试