SpringBoot 实现自动数据变更追踪
在企业级应用中,关键配置、业务数据变更的审计追踪是一个常见需求。无论是金融系统、电商平台还是配置管理,都需要回答几个基本问题:谁改了数据、什么时候改的、改了什么。
背景痛点
传统手工审计的问题
最直接的实现方式是在每个业务方法中手动记录审计日志:
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字段名,用于从实体中提取IDString idField() default "id";// ID参数名,直接从方法参数中获取IDString 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();// 提取实体IDString 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;}// 辅助方法:提取实体IDprivate String extractEntityId(Object[] args, String[] paramNames, Audit audit) {// 优先从方法参数中获取IDif (!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 {@Beanpublic 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 + 注解的组合,我们实现了一个零侵入的数据变更审计系统。这个方案的主要优势:
开发效率提升:无需在每个业务方法中编写审计逻辑
维护成本降低:审计逻辑集中在切面中,便于统一管理
数据质量改善:结构化的审计日志便于查询和分析
技术方案没有银弹,需要根据具体业务场景进行调整。如果您的项目也有数据审计需求,这个方案可以作为参考。
https://github.com/yuboon/java-examples/tree/master/springboot-object-version