SpringBoot 实现自动数据变更追踪

企业应用开发与运维数据库

在企业级应用中,关键配置、业务数据变更的审计追踪是一个常见需求。无论是金融系统、电商平台还是配置管理,都需要回答几个基本问题:谁改了数据、什么时候改的、改了什么。

picture.image

背景痛点

传统手工审计的问题

最直接的实现方式是在每个业务方法中手动记录审计日志:


 
 
 
 
   
public void updatePrice(Long productId, BigDecimal newPrice) {  
    Product old = productRepository.findById(productId).get();  
    productRepository.updatePrice(productId, newPrice);  
  
    // 手动记录变更  
    auditService.save("价格从 " + old.getPrice() + " 改为 " + newPrice);  
}

这种做法在项目初期还能应付,但随着业务复杂度增加,会暴露出几个明显问题:

代码重复 :每个需要审计的方法都要写类似逻辑

维护困难 :业务字段变更时,审计逻辑需要同步修改

格式不统一 :不同开发者写的审计格式可能不一致

查询不便 :字符串拼接的日志难以进行结构化查询

业务代码污染 :审计逻辑与业务逻辑耦合在一起

实际遇到的问题

  • • 产品价格改错了,查了半天日志才找到是谁改的
  • • 配置被误删了,想恢复时发现没有详细的变更记录
  • • 审计要求越来越严格,手工记录的日志格式不规范
需求分析

基于实际需求,审计功能应具备以下特性:

核心需求

1. 零侵入性 :业务代码不需要关心审计逻辑

2. 自动化 :通过配置或注解就能启用审计功能

3. 精确记录 :字段级别的变更追踪

4. 结构化存储 :便于查询和分析的格式

5. 完整信息 :包含操作人、时间、操作类型等元数据

技术选型考虑

本方案选择使用 Javers 作为核心组件,主要考虑:

  • • 专业的对象差异比对算法
  • • Spring Boot 集成简单
  • • 支持多种存储后端
  • • JSON 输出友好
设计思路

整体架构

我们采用 AOP + 注解的设计模式:


 
 
 
 
   
┌─────────────────┐  
│   Controller    │  
└─────────┬───────┘  
          │ AOP 拦截  
┌─────────▼───────┐  
│     Service     │ ← 业务逻辑保持不变  
└─────────┬───────┘  
          │  
┌─────────▼───────┐  
│   AuditAspect   │ ← 统一处理审计逻辑  
└─────────┬───────┘  
          │  
┌─────────▼───────┐  
│   Javers Core   │ ← 对象差异比对  
└─────────┬───────┘  
          │  
┌─────────▼───────┐  
│  Audit Storage  │ ← 结构化存储  
└─────────────────┘

核心设计

1. 注解驱动 :通过 @Audit 注解标记需要审计的方法

2. 切面拦截 :AOP 自动拦截带注解的方法

3. 差异比对 :使用 Javers 比较对象变更

4. 统一存储 :审计日志统一存储和查询

关键代码实现

项目依赖


 
 
 
 
   
<dependencies>  
    <dependency>  
        <groupId>org.springframework.boot</groupId>  
        <artifactId>spring-boot-starter-web</artifactId>  
    </dependency>  
    <dependency>  
        <groupId>org.springframework.boot</groupId>  
        <artifactId>spring-boot-starter-aop</artifactId>  
    </dependency>  
    <dependency>  
        <groupId>org.javers</groupId>  
        <artifactId>javers-core</artifactId>  
        <version>7.3.1</version>  
    </dependency>  
    <dependency>  
        <groupId>org.projectlombok</groupId>  
        <artifactId>lombok</artifactId>  
        <optional>true</optional>  
    </dependency>  
</dependencies>

审计注解


 
 
 
 
   
@Target(ElementType.METHOD)  
@Retention(RetentionPolicy.RUNTIME)  
public @interface Audit {  
    // ID字段名,用于从实体中提取ID  
    String idField() default "id";  
  
    // ID参数名,直接从方法参数中获取ID  
    String idParam() default "";  
  
    // 操作类型,根据方法名自动推断  
    ActionType action() default ActionType.AUTO;  
  
    // 操作人参数名  
    String actorParam() default "";  
  
    // 实体参数位置  
    int entityIndex() default 0;  
  
    enum ActionType {  
        CREATE, UPDATE, DELETE, AUTO  
    }  
}

审计切面


 
 
 
 
   
@Slf4j  
@Aspect  
@Component  
@RequiredArgsConstructor  
public class AuditAspect {  
  
    private final Javers javers;  
  
    // 内存存储审计日志(生产环境建议使用数据库)  
    private final List<AuditLog> auditTimeline = new CopyOnWriteArrayList<>();  
    private final Map<String, List<AuditLog>> auditByEntity = new ConcurrentHashMap<>();  
    private final AtomicLong auditSequence = new AtomicLong(0);  
  
    // 数据快照存储  
    private final Map<String, Object> dataStore = new ConcurrentHashMap<>();  
  
    @Around("@annotation(auditAnnotation)")  
    public Object auditMethod(ProceedingJoinPoint joinPoint, Audit auditAnnotation) throws Throwable {  
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();  
        Method method = signature.getMethod();  
        String[] paramNames = signature.getParameterNames();  
        Object[] args = joinPoint.getArgs();  
  
        // 提取实体ID  
        String entityId = extractEntityId(args, paramNames, auditAnnotation);  
        if (entityId == null) {  
            log.warn("无法提取实体ID,跳过审计: {}", method.getName());  
            return joinPoint.proceed();  
        }  
  
        // 提取实体对象  
        Object entity = null;  
        if (auditAnnotation.entityIndex() >= 0 && auditAnnotation.entityIndex() < args.length) {  
            entity = args[auditAnnotation.entityIndex()];  
        }  
  
        // 提取操作人  
        String actor = extractActor(args, paramNames, auditAnnotation);  
  
        // 确定操作类型  
        Audit.ActionType actionType = determineActionType(auditAnnotation, method.getName());  
  
        // 执行前快照  
        Object beforeSnapshot = dataStore.get(buildKey(entityId));  
  
        // 执行原方法  
        Object result = joinPoint.proceed();  
  
        // 执行后快照  
        Object afterSnapshot = determineAfterSnapshot(entity, actionType);  
  
        // 比较差异并记录审计日志  
        Diff diff = javers.compare(beforeSnapshot, afterSnapshot);  
        if (diff.hasChanges() || beforeSnapshot == null || actionType == Audit.ActionType.DELETE) {  
            recordAudit(  
                entity != null ? entity.getClass().getSimpleName() : "Unknown",  
                entityId,  
                actionType.name(),  
                actor,  
                javers.getJsonConverter().toJson(diff)  
            );  
        }  
  
        // 更新数据存储  
        if (actionType != Audit.ActionType.DELETE) {  
            dataStore.put(buildKey(entityId), afterSnapshot);  
        } else {  
            dataStore.remove(buildKey(entityId));  
        }  
  
        return result;  
    }  
  
    // 辅助方法:提取实体ID  
    private String extractEntityId(Object[] args, String[] paramNames, Audit audit) {  
        // 优先从方法参数中获取ID  
        if (!audit.idParam().isEmpty() && paramNames != null) {  
            for (int i = 0; i < paramNames.length; i++) {  
                if (audit.idParam().equals(paramNames[i])) {  
                    Object idValue = args[i];  
                    return idValue != null ? idValue.toString() : null;  
                }  
            }  
        }  
        return null;  
    }  
  
    // 其他辅助方法...  
}

业务服务示例


 
 
 
 
   
@Service  
public class ProductService {  
  
    private final Map<String, Product> products = new ConcurrentHashMap<>();  
  
    @Audit(  
            action = Audit.ActionType.CREATE,  
            idParam = "id",  
            actorParam = "actor",  
            entityIndex = 1  
    )  
    public Product create(String id, ProductRequest request, String actor) {  
        Product newProduct = new Product(id, request.name(), request.price(), request.description());  
        return products.put(id, newProduct);  
    }  
  
    @Audit(  
            action = Audit.ActionType.UPDATE,  
            idParam = "id",  
            actorParam = "actor",  
            entityIndex = 1  
    )  
    public Product update(String id, ProductRequest request, String actor) {  
        Product existingProduct = products.get(id);  
        if (existingProduct == null) {  
            throw new IllegalArgumentException("产品不存在: " + id);  
        }  
  
        Product updatedProduct = new Product(id, request.name(), request.price(), request.description());  
        return products.put(id, updatedProduct);  
    }  
  
    @Audit(  
            action = Audit.ActionType.DELETE,  
            idParam = "id",  
            actorParam = "actor"  
    )  
    public boolean delete(String id, String actor) {  
        return products.remove(id) != null;  
    }  
  
    @Audit(  
            idParam = "id",  
            actorParam = "actor",  
            entityIndex = 1  
    )  
    public Product upsert(String id, ProductRequest request, String actor) {  
        Product newProduct = new Product(id, request.name(), request.price(), request.description());  
        return products.put(id, newProduct);  
    }  
}

审计日志实体


 
 
 
 
   
public record AuditLog(  
        String id,  
        String entityType,  
        String entityId,  
        String action,  
        String actor,  
        Instant occurredAt,  
        String diffJson  
) {}

Javers 配置


 
 
 
 
   
@Configuration  
public class JaversConfig {  
  
    @Bean  
    public Javers javers() {  
        return JaversBuilder.javers()  
                .withPrettyPrint(true)  
                .build();  
    }  
}
应用场景示例

场景1:产品信息更新审计

操作请求


 
 
 
 
   
PUT /api/products/prod-001  
Content-Type: application/json  
X-User: 张三  
  
{  
  "name": "iPhone 15",  
  "price": 99.99,  
  "description": "最新款手机"  
}

审计日志结构


 
 
 
 
   
{  
  "id": "1",  
  "entityType": "Product",  
  "entityId": "prod-001",  
  "action": "UPDATE",  
  "actor": "张三",  
  "occurredAt": "2025-10-12T10:30:00Z",  
  "diffJson": "{\"changes\":[{\"field\":\"price\",\"oldValue\":100.00,\"newValue\":99.99}]}"  
}

diffJson 的具体内容


 
 
 
 
   
{  
  "changes": [  
    {  
      "changeType": "ValueChange",  
      "globalId": {  
        "valueObject": "com.example.objectversion.dto.ProductRequest"  
      },  
      "property": "price",  
      "propertyChangeType": "PROPERTY\_VALUE\_CHANGED",  
      "left": 100.00,  
      "right": 99.99  
    },  
    {  
      "changeType": "ValueChange",  
      "globalId": {  
        "valueObject": "com.example.objectversion.dto.ProductRequest"  
      },  
      "property": "description",  
      "propertyChangeType": "PROPERTY\_VALUE\_CHANGED",  
      "left": null,  
      "right": "最新款手机"  
    }  
  ]  
}

场景2:完整操作历史查询


 
 
 
 
   
GET /api/products/prod-001/audits

响应结果


 
 
 
 
   
[  
  {  
    "id": "1",  
    "entityType": "Product",  
    "entityId": "prod-001",  
    "action": "CREATE",  
    "actor": "system",  
    "occurredAt": "2025-10-10T08:00:00Z",  
    "diffJson": "{\"changes\":[{\"field\":\"name\",\"oldValue\":null,\"newValue\":\"iPhone 15\"},{\"field\":\"price\",\"oldValue\":null,\"newValue\":100.00}]}"  
  },  
  {  
    "id": "2",  
    "entityType": "Product",  
    "entityId": "prod-001",  
    "action": "UPDATE",  
    "actor": "张三",  
    "occurredAt": "2025-10-12T10:30:00Z",  
    "diffJson": "{\"changes\":[{\"field\":\"price\",\"oldValue\":100.00,\"newValue\":99.99}]}"  
  }  
]

场景3:删除操作审计

删除请求


 
 
 
 
   
DELETE /api/products/prod-001  
X-User: 李四

审计日志


 
 
 
 
   
{  
  "id": "3",  
  "entityType": "Product",  
  "entityId": "prod-001",  
  "action": "DELETE",  
  "actor": "李四",  
  "occurredAt": "2025-10-13T15:45:00Z",  
  "diffJson": "{\"changes\":[]}"  
}

场景4:批量操作审计

创建多个产品


 
 
 
 
   
// 执行多次创建操作  
productService.create("prod-002", new ProductRequest("手机壳", 29.99, "透明保护壳"), "王五");  
productService.create("prod-003", new ProductRequest("充电器", 59.99, "快充充电器"), "王五");

审计日志


 
 
 
 
   
[  
  {  
    "id": "4",  
    "entityType": "Product",  
    "entityId": "prod-002",  
    "action": "CREATE",  
    "actor": "王五",  
    "occurredAt": "2025-10-13T16:00:00Z",  
    "diffJson": "{\"changes\":[{\"field\":\"name\",\"oldValue\":null,\"newValue\":\"手机壳\"},{\"field\":\"price\",\"oldValue\":null,\"newValue\":29.99}]}"  
  },  
  {  
    "id": "5",  
    "entityType": "Product",  
    "entityId": "prod-003",  
    "action": "CREATE",  
    "actor": "王五",  
    "occurredAt": "2025-10-13T16:01:00Z",  
    "diffJson": "{\"changes\":[{\"field\":\"name\",\"oldValue\":null,\"newValue\":\"充电器\"},{\"field\":\"price\",\"oldValue\":null,\"newValue\":59.99}]}"  
  }  
]
总结

通过 Javers + AOP + 注解的组合,我们实现了一个零侵入的数据变更审计系统。这个方案的主要优势:

开发效率提升 :无需在每个业务方法中编写审计逻辑

维护成本降低 :审计逻辑集中在切面中,便于统一管理

数据质量改善 :结构化的审计日志便于查询和分析

技术方案没有银弹,需要根据具体业务场景进行调整。如果您的项目也有数据审计需求,这个方案可以作为参考。

最后欢迎加入苏三的星球,你将获得:智能天气播报AI Agent、SaaS点餐系统(DDD+多租户)、100万QPS短链系统(超过并发)、复杂的商城微服务系统(分布式)、苏三AI项目、刷题吧小程序、秒杀系统、商城系统、码猿简历网站、代码生成工具等10个项目的源代码、开发教程和技术答疑。 系统设计、性能优化、技术选型、底层原理、Spring源码解读、工作经验分享、痛点问题、面试八股文等多个优质专栏。

还有1V1免费修改简历、技术答疑、职业规划、送书活动、技术交流。

扫描下方二维码,可以加入星球:

picture.image

数量有限,先到先得。 目前星球已经更新了6100+篇优质内容,还在持续爆肝中.....

星球已经被官方推荐了3次,收到了小伙伴们的一致好评。戳我加入学习,已有2000+小伙伴加入学习。

0
0
0
0
关于作者
关于作者

文章

0

获赞

0

收藏

0

相关资源
字节跳动 NoSQL 的实践与探索
随着 NoSQL 的蓬勃发展越来越多的数据存储在了 NoSQL 系统中,并且 NoSQL 和 RDBMS 的界限越来越模糊,各种不同的专用 NoSQL 系统不停涌现,各具特色,形态不一。本次主要分享字节跳动内部和火山引擎 NoSQL 的实践,希望能够给大家一定的启发。
相关产品
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论