⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 风象南 「风象南」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

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

背景痛点

传统手工审计的问题

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

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 + 注解的组合,我们实现了一个零侵入的数据变更审计系统。这个方案的主要优势:

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

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

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

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

https://github.com/yuboon/java-examples/tree/master/springboot-object-version

文章目录
  1. 1. 背景痛点
    1. 1.0.1. 传统手工审计的问题
    2. 1.0.2. 实际遇到的问题
  • 2. 需求分析
    1. 2.0.1. 核心需求
    2. 2.0.2. 技术选型考虑
  • 3. 设计思路
    1. 3.0.1. 整体架构
    2. 3.0.2. 核心设计
  • 4. 关键代码实现
    1. 4.0.1. 项目依赖
    2. 4.0.2. 审计注解
    3. 4.0.3. 审计切面
    4. 4.0.4. 业务服务示例
    5. 4.0.5. 审计日志实体
    6. 4.0.6. Javers 配置
  • 5. 应用场景示例
    1. 5.0.1. 场景1:产品信息更新审计
    2. 5.0.2. 场景2:完整操作历史查询
    3. 5.0.3. 场景3:删除操作审计
    4. 5.0.4. 场景4:批量操作审计
  • 6. 总结