JaVers 版本历史功能完整实现指南
本文档基于 FBI 系统的 JaVers 实现,提供从零开始在 Spring Boot + Vue 3 项目中集成实体版本历史功能的完整指导。涵盖依赖配置、事件驱动架构、Entity/DTO 注解、API 设计、前端 HistoryDialog 组件等全部环节。
目录
- 技术选型与依赖
- [JaVers 配置](#JaVers 配置)
- 核心基础设施(事件驱动架构)
- [实体与 DTO 的 JaVers 注解规范](#实体与 DTO 的 JaVers 注解规范)
- 业务层集成(如何发布事件触发快照)
- [后端 API 设计(查询、详情、对比)](#后端 API 设计(查询、详情、对比))
- 已有数据初始化快照
- [前端 HistoryDialog 组件](#前端 HistoryDialog 组件)
- [前端 API 层](#前端 API 层)
- 页面集成示例
- 完整实施步骤清单
1. 技术选型与依赖
后端(Java)
JaVers 版本 :7.9.0
Maven 依赖 (在父 POM 的 <dependencyManagement> 中声明版本):
xml
<properties>
<javers.version>7.9.0</javers.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.javers</groupId>
<artifactId>javers-spring-boot-starter-sql</artifactId>
<version>${javers.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
在 common 模块中引入(所有业务模块依赖 common):
xml
<dependency>
<groupId>org.javers</groupId>
<artifactId>javers-spring-boot-starter-sql</artifactId>
</dependency>
说明:使用
javers-spring-boot-starter-sql而非javers-spring-boot-starter-mongo。JaVers 的快照数据存储在应用同一 MySQL 数据库中,无需额外数据库。
前端(Vue 3)
无需额外依赖,使用 Element Plus 的 el-table、el-scrollbar、el-checkbox、el-empty 组件即可。
2. JaVers 配置
application.yml
yaml
javers:
spring-data:
enabled: false # 不使用 @JaversSpringDataAuditable 自动审计
sql:
enabled: true
# 显式指定使用应用的 EntityManagerFactory,确保 JaVers 与应用共享事务
entity-manager-bean-name: entityManagerFactory
关键说明:
spring-data.enabled: false:我们不使用@JaversSpringDataAuditable注解。这种自动审计方式会导致每次 Spring Data JPA 保存时都自动提交快照,粒度太粗且无法使用 DTO 包装。sql.enabled: true:使用 SQL 存储(MySQL)。entity-manager-bean-name:必须指定,确保 JaVers 的快照提交在应用的同一事务中。如果 JaVers 提交失败,整个业务操作回滚。
JaVers 自动创建的表
启动应用后,JaVers 会在数据库中自动创建以下表(通过 JPA ddl-auto=update 或 JaVers 内置的 Liquibase 脚本):
| 表名 | 用途 |
|---|---|
javers_commit |
每次快照提交的元数据(作者、时间、提交ID) |
javers_snapshot |
实体在每个版本号下的完整状态(JSON 序列化) |
javers_global_id |
全局对象标识(类型名 + 实体ID 的映射) |
3. 核心基础设施(事件驱动架构)
3.1 设计理念
不使用 JaVers 自带的 @JaversSpringDataAuditable,而是通过自定义 Spring 事件机制手动触发快照提交。这样做的优势:
- 精确控制提交时机:只在业务真正完成时提交,不在中间状态提交
- 支持 DTO 包装:可以提交自定义的 SnapshotDTO(包含关联数据),而非原始 Entity
- 统一事件入口:所有实体的变更通过同一事件通道,方便后续扩展(如通知、日志等)
- 同步事务保证:事件监听器是同步的,快照提交与业务操作在同一事务中
3.2 EntityChangeEvent ------ 事件类
java
package your.project.common.event;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
@Getter
public class EntityChangeEvent extends ApplicationEvent {
public enum ChangeType {
CREATED, // 新增
UPDATED, // 更新
DELETED // 删除
}
private final ChangeType changeType;
private final Object entity; // 要提交给 JaVers 的对象(可以是 Entity 或 DTO)
private final String author; // 操作人标识
public EntityChangeEvent(Object source, ChangeType changeType, Object entity, String author) {
super(source);
this.changeType = changeType;
this.entity = entity;
this.author = author;
}
/** 通过反射获取实体 ID */
public Long getEntityId() {
if (entity != null) {
try {
var method = entity.getClass().getMethod("getId");
return (Long) method.invoke(entity);
} catch (Exception e) {
return null;
}
}
return null;
}
/** 获取实体类型名称 */
public String getEntityType() {
return entity != null ? entity.getClass().getSimpleName() : null;
}
}
3.3 EntityChangeEventPublisher ------ 事件发布器
java
package your.project.common.event;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class EntityChangeEventPublisher {
private static final String DEFAULT_AUTHOR = "system";
private final ApplicationEventPublisher publisher;
/** 获取当前登录用户标识。
* 你需要替换为你们项目中获取当前用户的方法 */
private String getCurrentAuthor() {
// 示例:从 SecurityContext 或自定义 ThreadLocal 获取
// return SecurityContextHolder.getContext().getAuthentication().getName();
return DEFAULT_AUTHOR;
}
/** 发布新增事件 */
public <T> void publishCreated(T entity) {
publishCreated(entity, getCurrentAuthor());
}
public <T> void publishCreated(T entity, String author) {
publisher.publishEvent(new EntityChangeEvent(
this, EntityChangeEvent.ChangeType.CREATED, entity, author));
}
/** 发布更新事件 */
public <T> void publishUpdated(T entity) {
publishUpdated(entity, getCurrentAuthor());
}
public <T> void publishUpdated(T entity, String author) {
publisher.publishEvent(new EntityChangeEvent(
this, EntityChangeEvent.ChangeType.UPDATED, entity, author));
}
/** 发布删除事件 */
public <T> void publishDeleted(T entity) {
publishDeleted(entity, getCurrentAuthor());
}
public <T> void publishDeleted(T entity, String author) {
publisher.publishEvent(new EntityChangeEvent(
this, EntityChangeEvent.ChangeType.DELETED, entity, author));
}
}
3.4 EntityChangeEventListener ------ 事件监听器(核心)
java
package your.project.common.event;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.javers.core.Javers;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class EntityChangeEventListener {
private final Javers javers;
/**
* 同步监听实体变更事件,将实体状态提交到 JaVers。
* 同步执行保证快照提交与业务操作在同一数据库事务中。
*/
@EventListener
public void handleEvent(EntityChangeEvent event) {
try {
switch (event.getChangeType()) {
case CREATED, UPDATED, DELETED:
// 核心调用:将对象提交给 JaVers
javers.commit(event.getAuthor(), event.getEntity());
break;
}
} catch (Exception e) {
log.error("记录实体变更审计失败: {}", event, e);
throw new RuntimeException("记录实体变更审计失败: " + e.getMessage());
}
}
}
重要说明:
- 监听器不加
@Async,是同步执行的。这意味着 JaVers 的commit()在同一个数据库事务中完成。 - 如果
commit()抛出异常,会向上传播导致业务操作回滚。 javers.commit(author, entity)的两个参数:author是操作人标识(可以是用户ID或用户名),entity是待快照的对象(Entity 或 DTO)。
4. 实体与 DTO 的 JaVers 注解规范
4.1 基础实体类(TimeEntity ------ 所有实体的父类)
java
package your.project.common.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.javers.core.metamodel.annotation.DiffIgnore;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Data
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class TimeEntity {
@Column(updatable = false)
@CreatedDate
@DiffIgnore // 创建时间不纳入版本对比
private LocalDateTime createTime;
@Column
@LastModifiedDate
@DiffIgnore // 更新时间不纳入版本对比
private LocalDateTime updateTime;
}
java
package your.project.common.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
@Data
@MappedSuperclass
public abstract class BaseEntity extends TimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.TABLE, generator = "id_generator")
@TableGenerator(name = "id_generator")
private Long id;
}
4.2 直接追踪 Entity(模式A)
适用于简单实体,直接对 Entity 类加 JaVers 注解:
java
package your.project.domain.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.javers.core.metamodel.annotation.PropertyName;
import org.javers.core.metamodel.annotation.TypeName;
import your.project.common.entity.BaseEntity;
@Data
@Entity
@EqualsAndHashCode(callSuper = true)
@Table(name = "your_entity")
@TypeName("YourEntity") // 给 JaVers 看的类型名,建议与类名一致
public class YourEntity extends BaseEntity {
@Column(length = 100)
@PropertyName("名称") // 版本对比时显示的中文名
private String name;
@Column(length = 50)
@PropertyName("状态")
private String status;
@Column(length = 200)
@PropertyName("描述")
private String description;
// 不想纳入版本记录的字段加 @DiffIgnore
@DiffIgnore
@Column(length = 50)
private String internalCode;
}
4.3 使用 SnapshotDTO 追踪(模式B)
适用于需要将关联数据也纳入快照的复杂聚合场景。核心思路:JaVers 提交的不是原始 Entity,而是手动构建的 DTO。
Step 1:定义 DTO(带 JaVers 注解)
java
package your.project.domain.dto;
import lombok.Data;
import org.javers.core.metamodel.annotation.Id;
import org.javers.core.metamodel.annotation.PropertyName;
import org.javers.core.metamodel.annotation.TypeName;
import org.javers.core.metamodel.annotation.DiffIgnore;
import java.time.LocalDateTime;
import java.util.List;
@Data
@TypeName("YourAggregateSnapshotDTO")
public class YourAggregateSnapshotDTO {
@Id // JaVers 的 @Id,不是 JPA 的
@PropertyName("ID")
private Long id;
@PropertyName("名称")
private String name;
@PropertyName("状态")
private String status;
@PropertyName("子项列表")
private List<ChildSnapshotDTO> children;
@DiffIgnore
@PropertyName("创建时间")
private LocalDateTime createTime;
/**
* 工厂方法:从 Entity 构建 DTO
*/
public static YourAggregateSnapshotDTO from(YourEntity entity, List<ChildEntity> children) {
YourAggregateSnapshotDTO dto = new YourAggregateSnapshotDTO();
dto.setId(entity.getId());
dto.setName(entity.getName());
dto.setStatus(entity.getStatus());
dto.setCreateTime(entity.getCreateTime());
dto.setChildren(children.stream()
.map(ChildSnapshotDTO::from)
.toList());
return dto;
}
}
Step 2:子项用 @Value 标注(JaVers 的 ValueObject)
java
package your.project.domain.dto;
import lombok.Value;
import org.javers.core.metamodel.annotation.PropertyName;
@Value // JaVers 的 @Value 注解,表示这是一个值对象,不独立追踪版本
public class ChildSnapshotDTO {
@PropertyName("子项ID")
private Long childId;
@PropertyName("子项名称")
private String childName;
public static ChildSnapshotDTO from(ChildEntity entity) {
return new ChildSnapshotDTO(entity.getId(), entity.getName());
}
}
4.4 JaVers 注解速查表
| 注解 | 作用 | 使用位置 |
|---|---|---|
@TypeName("名称") |
给 JaVers 设置类型名称,影响查询和显示 | Entity / DTO 类 |
@Id |
标记 JaVers 的全局唯一标识字段 | DTO 的 ID 字段(Entity 用 JPA 的 @Id) |
@PropertyName("中文名") |
给字段设置显示名称,前端对比表显示 | 所有追踪的字段 |
@DiffIgnore |
忽略该字段,不纳入变更对比和快照 | createTime、updateTime、内部编码等 |
@Value |
标记为 JaVers 值对象(无独立版本,跟随父对象) | 列表中嵌套的子对象 |
5. 业务层集成(如何发布事件触发快照)
5.1 基本原则
在业务操作完成后,Controller 返回之前,调用 eventPublisher.publishXxx()。
5.2 模式A:直接提交 Entity
java
@Service
@Transactional
@RequiredArgsConstructor
public class YourBiz {
private final YourRepository repository;
private final EntityChangeEventPublisher eventPublisher;
// 新增
public YourEntity create(YourCreateParam param) {
YourEntity entity = new YourEntity();
entity.setName(param.getName());
entity.setStatus(param.getStatus());
entity = repository.save(entity);
// 发布创建事件 → 触发 JaVers 快照
eventPublisher.publishCreated(entity);
return entity;
}
// 更新
public YourEntity update(Long id, YourUpdateParam param) {
YourEntity entity = repository.findById(id)
.orElseThrow(() -> new RuntimeException("数据不存在"));
entity.setName(param.getName());
entity.setStatus(param.getStatus());
entity = repository.save(entity);
// 发布更新事件 → 触发 JaVers 快照
eventPublisher.publishUpdated(entity);
return entity;
}
// 删除
public void delete(Long id) {
YourEntity entity = repository.findById(id)
.orElseThrow(() -> new RuntimeException("数据不存在"));
// 先发布删除事件(记录删除前的最后状态)
eventPublisher.publishDeleted(entity);
repository.delete(entity);
}
}
5.3 模式B:提交 DTO(聚合场景)
java
@Service
@Transactional
@RequiredArgsConstructor
public class YourAggregateBiz {
private final YourEntityRepository entityRepository;
private final ChildEntityRepository childRepository;
private final EntityChangeEventPublisher eventPublisher;
public void update(Long id, YourUpdateParam param) {
YourEntity entity = entityRepository.findById(id)
.orElseThrow(() -> new RuntimeException("数据不存在"));
entity.setName(param.getName());
entity.setStatus(param.getStatus());
entityRepository.save(entity);
// 加载关联数据
List<ChildEntity> children = childRepository.findByParentId(id);
// 构建 DTO 并发布事件
YourAggregateSnapshotDTO dto = YourAggregateSnapshotDTO.from(entity, children);
eventPublisher.publishUpdated(dto);
}
}
5.4 定时任务/系统自动操作中的快照
java
// 对于非用户触发的操作(如定时同步),使用指定作者
eventPublisher.publishCreated(entity, "system_sync");
// 或使用默认(会自动获取当前登录用户,非 Web 上下文中为 "system")
eventPublisher.publishUpdated(entity);
6. 后端 API 设计(查询、详情、对比)
6.1 JaVersHistoryService ------ 通用查询服务
这是所有实体的版本历史查询的唯一实现,所有 Entity 共用。
java
package your.project.domain.service;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.javers.core.Javers;
import org.javers.core.diff.Diff;
import org.javers.core.metamodel.object.CdoSnapshot;
import org.javers.repository.jql.QueryBuilder;
import org.javers.shadow.Shadow;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
@Slf4j
public class JaVersHistoryService {
private final Javers javers;
public JaVersHistoryService(Javers javers) {
this.javers = javers;
}
/**
* 获取实体的历史快照列表(版本元数据,分页)
*
* @param entityId 实体ID(数据库主键)
* @param entityClass 实体类(或 DTO 类)
* @param limit 每页条数
* @param skip 跳过条数
* @return 版本列表(每项包含 version 和 commitMetadata)
*/
public List<Map<String, Object>> getEntitySnapshots(
Long entityId, Class<?> entityClass, int limit, int skip) {
var query = QueryBuilder.byInstanceId(entityId, entityClass)
.skip(skip)
.limit(limit)
.build();
List<CdoSnapshot> snapshots = javers.findSnapshots(query);
return snapshots.stream()
.map(this::convertToMetadata)
.toList();
}
/**
* 获取指定版本的完整快照数据
*
* @param entityId 实体ID
* @param entityClass 实体类
* @param version 版本号
* @return 完整的属性 Map(属性名 → 属性值)
*/
public Map<String, Object> getEntitySnapshotByVersion(
Long entityId, int version, Class<?> entityClass) {
var query = QueryBuilder.byInstanceId(entityId, entityClass)
.withVersion(version)
.build();
List<CdoSnapshot> snapshots = javers.findSnapshots(query);
if (snapshots.isEmpty()) {
throw new RuntimeException(String.format(
"版本号 %d 不存在,实体ID: %d", version, entityId));
}
Map<String, Object> stateMap = new HashMap<>();
snapshots.get(0).getState().forEachProperty(stateMap::put);
return stateMap;
}
/**
* 比较两个版本的差异
*
* @param entityId 实体ID
* @param version1 旧版本号
* @param version2 新版本号
* @param entityClass 实体类
* @return JaVers Diff 对象(包含 changes 列表)
*/
public Diff compareVersions(
Long entityId, int version1, int version2, Class<?> entityClass) {
Object olderVersion = getShadow(entityId, version1, entityClass);
Object newerVersion = getShadow(entityId, version2, entityClass);
return javers.compare(olderVersion, newerVersion);
}
/** 获取指定版本的原始对象 */
private Object getShadow(Long entityId, int version, Class<?> entityClass) {
var query = QueryBuilder.byInstanceId(entityId, entityClass)
.withVersion(version)
.build();
List<Shadow<Object>> shadows = javers.findShadows(query);
if (shadows.isEmpty()) {
throw new RuntimeException(String.format(
"版本号 %d 不存在,实体ID: %d", version, entityId));
}
return shadows.get(0).get();
}
/** 将 CdoSnapshot 转为精简的元数据 Map */
private Map<String, Object> convertToMetadata(CdoSnapshot snapshot) {
Map<String, Object> map = new HashMap<>();
map.put("version", snapshot.getVersion());
Map<String, Object> commitMetadata = new HashMap<>();
String authorId = snapshot.getCommitMetadata().getAuthor();
commitMetadata.put("author", resolveAuthorName(authorId));
commitMetadata.put("authorId", authorId);
commitMetadata.put("commitDate",
snapshot.getCommitMetadata().getCommitDate());
map.put("commitMetadata", commitMetadata);
return map;
}
/**
* 根据作者ID解析显示名称。
* 你需要替换为你们项目的用户查询方式。
*/
private String resolveAuthorName(String authorId) {
if (authorId == null || authorId.isEmpty()) {
return "未知";
}
// 示例:从用户表查询
// return userRepository.findById(Long.parseLong(authorId))
// .map(UserEntity::getDisplayName)
// .orElse(authorId);
return authorId;
}
}
6.2 Controller 示例
每个需要版本历史的 Entity 在 Controller 中添加 3 个接口。以 YourEntity 为例:
java
package your.project.interfaces.web;
import org.javers.core.diff.Diff;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/your-api-path/your-entity")
public class YourEntityController {
@Resource
private JaVersHistoryService jaVersHistoryService;
// ============ 版本历史接口 ============
/** 获取历史快照列表(分页) */
@GetMapping("snapshot-menu/{entityId}")
public List<Map<String, Object>> getSnapshots(
@PathVariable Long entityId,
@RequestParam(defaultValue = "20") int limit,
@RequestParam(defaultValue = "0") int skip) {
return jaVersHistoryService.getEntitySnapshots(
entityId, YourEntity.class, limit, skip);
}
/** 获取指定版本的完整快照 */
@GetMapping("snapshot/{entityId}/{version}")
public Map<String, Object> getSnapshotByVersion(
@PathVariable Long entityId,
@PathVariable int version) {
return jaVersHistoryService.getEntitySnapshotByVersion(
entityId, version, YourEntity.class);
}
/** 比较两个版本 */
@GetMapping("compare/{entityId}")
public Diff compareVersions(
@PathVariable Long entityId,
@RequestParam int version1,
@RequestParam int version2) {
return jaVersHistoryService.compareVersions(
entityId, version1, version2, YourEntity.class);
}
}
接口路径规范 :建议路径格式为 /{业务前缀}/snapshot-menu/{entityId}、/{业务前缀}/snapshot/{entityId}/{version}、/{业务前缀}/compare/{entityId}。
6.3 关键 API 参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
entityId |
Long | 实体在数据库中的主键 ID(对应 JaVers 的 instanceId) |
entityClass |
Class<?> | 传给 JaVers 的类型。直接追踪用 Entity.class,DTO 追踪用 DTO.class |
version |
int | 版本号。JaVers 从 1 开始自增,每次 commit 产生一个新版本 |
limit |
int | 每页条数,默认 20 |
skip |
int | 跳过的条数,用于分页。前端第 N 页的 skip = N * limit |
6.4 Diff 返回结构
compareVersions 返回的 Diff 对象 JSON 序列化后的结构:
json
{
"changes": [
{
"propertyName": "name",
"left": "旧名称",
"right": "新名称"
},
{
"propertyName": "status",
"left": "pending",
"right": "completed"
}
]
}
前端直接使用 changes 数组 ,每项有 propertyName(字段名)、left(旧值)、right(新值)。
7. 已有数据初始化快照
如果系统中已有历史数据(在集成 JaVers 之前就存在的记录),需要做一次性初始化。
java
package your.project.application.biz;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.javers.core.Javers;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
public class JaVersInitBiz {
private final Javers javers;
private final YourEntityRepository yourEntityRepository;
/** 为已有数据创建初始快照 */
@Transactional
public void initYourEntities() {
List<YourEntity> entities = yourEntityRepository.findAll();
log.info("开始初始化 YourEntity 快照,共 {} 条", entities.size());
for (YourEntity entity : entities) {
try {
javers.commit("system_init", entity);
} catch (Exception e) {
log.error("初始化快照失败: entityId={}", entity.getId(), e);
}
}
log.info("初始化 YourEntity 快照完成");
}
}
提供一个后台接口触发:
java
@RestController
@RequestMapping("/admin")
public class AdminController {
@Resource
private JaVersInitBiz jaVersInitBiz;
@PostMapping("/javers/init")
public String initSnapshots(@RequestParam(defaultValue = "all") String type) {
switch (type) {
case "yourEntity":
jaVersInitBiz.initYourEntities();
break;
case "all":
jaVersInitBiz.initYourEntities();
// 可以继续添加其他实体
break;
}
return "初始化完成";
}
}
8. 前端 HistoryDialog 组件
8.1 组件概述
HistoryDialog.vue 是一个通用的版本历史弹窗组件,左右分栏布局:
+----------------------------+--------------------------------------+
| 版本列表 (200px) | 详情/对比区 |
| | |
| [对比] 按钮(需选2个版本) | 对比模式:表格(属性 | 旧值 | 新值) |
| | |
| [x] V3 张三 | 单版本模式:JSON 格式完整快照 |
| 2024-01-03 10:00 | |
| | |
| [x] V2 李四 <-- 高亮 | |
| 2024-01-02 10:00 | |
| | |
| [ ] V1 王五 | |
| 2024-01-01 10:00 | |
| | |
| ...(无限滚动加载更多) | |
+----------------------------+--------------------------------------+
8.2 完整组件代码
vue
<template>
<el-dialog
v-model="dialogVisible"
:title="title"
width="70%"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<div class="version-history-layout">
<!-- 左侧版本列表 -->
<div class="version-list-panel">
<div class="version-list-header">
<span>版本列表</span>
<el-button
type="primary"
size="small"
:disabled="selectedVersions.length !== 2"
@click="handleCompare"
>
对比
</el-button>
</div>
<el-scrollbar ref="scrollbarRef" @scroll="handleScroll">
<div v-if="snapshots.length === 0 && !isLoading" class="no-versions">
<el-empty description="暂无数据" :image-size="60" />
</div>
<div v-else>
<div
class="version-item"
:class="{ active: selectedVersion === item.version }"
v-for="item in snapshots"
:key="item.version"
@click="selectVersion(item.version)"
>
<el-checkbox
:model-value="selectedVersions.includes(item.version)"
@change="handleVersionCheck(item.version)"
:disabled="!selectedVersions.includes(item.version) && selectedVersions.length >= 2"
@click.stop
/>
<div class="version-content">
<div class="version-header">
<span class="version-name">V{{ item.version }}</span>
<span class="version-author">{{ item.commitMetadata?.author ?? '-' }}</span>
</div>
<div class="version-time">{{ formatDateTime(item.commitMetadata?.commitDate) }}</div>
</div>
</div>
<div v-if="isLoading" class="loading-more">
<span>加载中...</span>
</div>
<div v-if="noMoreData && snapshots.length > 0" class="no-more">
没有更多数据了
</div>
</div>
</el-scrollbar>
</div>
<!-- 右侧展示区 -->
<div class="version-detail-panel">
<!-- 对比结果 -->
<div v-if="compareResult" class="compare-container">
<div class="compare-header">
<span class="compare-title">
V{{ compareVersion1 }} → V{{ compareVersion2 }}
</span>
<el-button size="small" type="danger" @click="closeCompare">关闭对比</el-button>
</div>
<el-table :data="compareResult.changes" border stripe max-height="400">
<el-table-column prop="propertyName" label="属性" min-width="150" />
<el-table-column label="旧值" min-width="200">
<template #default="{ row }">
<pre class="compare-value old-value">{{ formatCompareValue(row.left) }}</pre>
</template>
</el-table-column>
<el-table-column label="新值" min-width="200">
<template #default="{ row }">
<pre class="compare-value new-value">{{ formatCompareValue(row.right) }}</pre>
</template>
</el-table-column>
</el-table>
<div class="no-changes" v-if="compareResult.changes.length === 0">
<el-empty description="两个版本没有变更" />
</div>
</div>
<!-- 单版本详情 -->
<div v-else-if="selectedSnapshotData" class="snapshot-detail">
<pre class="json-data">{{ formatJsonData(selectedSnapshotData) }}</pre>
</div>
<div v-else class="no-data">
<el-empty description="请选择一个版本查看详情" />
</div>
</div>
</div>
<template #footer>
<el-button @click="close">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from "vue";
import { ElMessage } from "element-plus";
const emit = defineEmits(["close"]);
// ============ 状态 ============
const title = ref("历史记录");
let getSnapshotsFn = null;
let getSnapshotByVersionFn = null;
let compareVersionsFn = null;
let customValueFormatter = null;
const dialogVisible = ref(false);
const snapshots = ref([]);
const selectedVersion = ref(null);
const selectedVersions = ref([]);
const compareVersion1 = ref(null);
const compareVersion2 = ref(null);
const compareResult = ref(null);
const selectedSnapshotData = ref(null);
const isLoading = ref(false);
const noMoreData = ref(false);
const currentPage = ref(0);
const pageSize = ref(20);
const currentEntityId = ref(null);
const snapshotDataCache = ref(new Map());
const scrollbarRef = ref(null);
// ============ 版本选择 ============
const selectVersion = async (version) => {
selectedVersion.value = version;
if (snapshotDataCache.value.has(version)) {
selectedSnapshotData.value = snapshotDataCache.value.get(version);
return;
}
try {
isLoading.value = true;
const stateData = await getSnapshotByVersionFn(currentEntityId.value, version);
snapshotDataCache.value.set(version, stateData);
selectedSnapshotData.value = stateData;
} catch (error) {
ElMessage.error("加载版本详情失败");
} finally {
isLoading.value = false;
}
};
// ============ 版本对比 ============
const handleVersionCheck = (version) => {
const index = selectedVersions.value.indexOf(version);
if (index > -1) {
selectedVersions.value.splice(index, 1);
} else if (selectedVersions.value.length >= 2) {
ElMessage.warning("最多只能选择两个版本");
} else {
selectedVersions.value.push(version);
}
};
const handleCompare = async () => {
if (selectedVersions.value.length !== 2) return;
const v1 = Math.min(...selectedVersions.value);
const v2 = Math.max(...selectedVersions.value);
compareVersion1.value = v1;
compareVersion2.value = v2;
try {
const res = await compareVersionsFn(currentEntityId.value, v1, v2);
compareResult.value = res;
} catch (error) {
ElMessage.error("版本对比失败");
}
};
const closeCompare = () => {
compareResult.value = null;
compareVersion1.value = null;
compareVersion2.value = null;
};
// ============ 分页加载 ============
const loadSnapshots = async () => {
if (isLoading.value || noMoreData.value) return;
isLoading.value = true;
try {
const skip = currentPage.value * pageSize.value;
const res = await getSnapshotsFn(currentEntityId.value, {
limit: pageSize.value,
skip,
});
if (!res || res.length === 0) {
noMoreData.value = true;
} else {
if (res.length < pageSize.value) noMoreData.value = true;
snapshots.value = currentPage.value === 0
? res
: [...snapshots.value, ...res];
currentPage.value++;
if (currentPage.value === 1 && snapshots.value.length > 0) {
await selectVersion(snapshots.value[0].version);
}
}
} catch {
ElMessage.error("加载历史记录失败");
} finally {
isLoading.value = false;
}
};
const handleScroll = () => {
if (!scrollbarRef.value) return;
const wrap = scrollbarRef.value.$el.querySelector(".el-scrollbar__wrap");
if (!wrap) return;
if (wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 50) {
loadSnapshots();
}
};
// ============ 公开方法 ============
const open = async (entityId, getSnapshots, getSnapshotByVersion, compareVersions, labelMapObj, titleStr, valueFormatter) => {
dialogVisible.value = true;
selectedVersion.value = null;
selectedVersions.value = [];
compareResult.value = null;
selectedSnapshotData.value = null;
currentEntityId.value = entityId;
currentPage.value = 0;
snapshots.value = [];
noMoreData.value = false;
snapshotDataCache.value = new Map();
getSnapshotsFn = getSnapshots;
getSnapshotByVersionFn = getSnapshotByVersion;
compareVersionsFn = compareVersions;
customValueFormatter = valueFormatter || null;
if (titleStr) title.value = titleStr;
await loadSnapshots();
};
const close = () => {
dialogVisible.value = false;
emit("close");
};
defineExpose({ open, close });
// ============ 格式化工具 ============
const formatDateTime = (dateStr) => {
if (!dateStr) return "-";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
const pad = (n) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
const formatCompareValue = (value) => {
if (value === null || value === undefined) return "-";
if (typeof value === "object") return JSON.stringify(value, null, 2);
return String(value);
};
const formatJsonData = (data) => {
if (data === null || data === undefined) return "-";
if (typeof data !== "object") return String(data);
return JSON.stringify(data, null, 2);
};
</script>
<style lang="scss" scoped>
.version-history-layout {
display: flex;
height: 500px;
border: 1px solid #e4e7ed;
border-radius: 4px;
overflow: hidden;
}
.version-list-panel {
width: 200px;
border-right: 1px solid #e4e7ed;
background: #f5f7fa;
display: flex;
flex-direction: column;
flex-shrink: 0;
.version-list-header {
padding: 10px 12px;
font-weight: 500;
color: #303133;
border-bottom: 1px solid #e4e7ed;
background: #ebeef5;
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
}
.no-versions {
padding: 40px 20px;
display: flex;
justify-content: center;
align-items: center;
}
.version-item {
padding: 8px 12px;
border-bottom: 1px solid #ebeef5;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
&:hover { background: #e4e7ed; }
&.active {
background: #409eff;
color: #fff;
.version-time { color: rgba(255, 255, 255, 0.8); }
}
.version-content { flex: 1; min-width: 0; }
.version-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2px;
.version-name { font-weight: 500; font-size: 14px; }
.version-author {
font-size: 12px;
color: #909399;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.version-time { font-size: 12px; color: #909399; }
}
}
.version-detail-panel {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.snapshot-detail .json-data {
margin: 0;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
line-height: 1.6;
color: #303133;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.compare-container {
.compare-header {
margin-bottom: 16px;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.compare-value {
margin: 0;
padding: 4px 8px;
background: #fafafa;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
.old-value { color: #f56c6c; background: #fef0f0; }
.new-value { color: #67c23a; background: #f0f9eb; }
}
.no-data, .no-changes {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.loading-more, .no-more {
text-align: center;
padding: 12px;
color: #909399;
font-size: 13px;
}
</style>
8.3 组件 Props 说明(通过 open() 方法传入)
| 参数 | 类型 | 说明 |
|---|---|---|
entityId |
Number | 实体在数据库中的主键 ID |
getSnapshots |
Function | API函数:(entityId, { limit, skip }) => Promise<Array> |
getSnapshotByVersion |
Function | API函数:(entityId, version) => Promise<Object> |
compareVersions |
Function | API函数:(entityId, version1, version2) => Promise<Object> |
labelMapObj |
Object | 字段名到中文标签的映射,如 { name: "名称" } |
titleStr |
String | 弹窗标题 |
valueFormatter |
Function | 可选,自定义值格式化:(key, value) => formattedString |
9. 前端 API 层
每个实体需要在 API 文件中添加 3 个函数:
javascript
// yourApi.js
import request from "@/utils/request"; // 你们的 HTTP 客户端
const BASE = "/your-api-path/your-entity";
// 获取历史快照列表
export function getYourEntitySnapshots(entityId, params) {
return request.get(`${BASE}/snapshot-menu/${entityId}`, { params });
}
// 获取指定版本完整快照
export function getYourEntitySnapshot(entityId, version) {
return request.get(`${BASE}/snapshot/${entityId}/${version}`);
}
// 比较两个版本
export function compareYourEntityVersions(entityId, version1, version2) {
return request.get(`${BASE}/compare/${entityId}`, {
params: { version1, version2 },
});
}
10. 页面集成示例
在列表页添加"变更历史"按钮
vue
<template>
<div>
<!-- 你的列表 -->
<el-table :data="list">
<el-table-column label="操作">
<template #default="{ row }">
<el-button link type="primary" @click="showHistory(row)">
变更历史
</el-button>
</template>
</el-table-column>
</el-table>
<!-- HistoryDialog -->
<HistoryDialog ref="historyRef" />
</div>
</template>
<script setup>
import { ref } from "vue";
import HistoryDialog from "@/base-components/HistoryDialog.vue";
import {
getYourEntitySnapshots,
getYourEntitySnapshot,
compareYourEntityVersions,
} from "@/api/data/yourApi";
const historyRef = ref(null);
const showHistory = (row) => {
historyRef.value.open(
row.id, // 实体ID
getYourEntitySnapshots, // 快照列表API
getYourEntitySnapshot, // 单版本详情API
compareYourEntityVersions, // 版本对比API
{
// labelMap:字段名 -> 中文名
id: "ID",
name: "名称",
status: "状态",
description: "描述",
createTime: "创建时间",
},
"实体名称-变更历史" // 弹窗标题
);
};
</script>
labelMap 映射规则
- 如果 Entity 使用了
@PropertyName("中文名"),propertyName会是中文名,labelMap可以传空对象{} - 如果 Entity 没使用
@PropertyName,前端拿到的propertyName是 Java 字段名(英文),此时labelMap必须做映射
11. 完整实施步骤清单
按以下顺序实施,每步均可在局部验证:
后端步骤
- Step 1 :在父 POM 添加 JaVers 版本属性,在 common 模块添加
javers-spring-boot-starter-sql依赖 - Step 2 :在
application.yml中添加 JaVers 配置 - Step 3 :启动应用,验证 JaVers 自动创建了
javers_commit、javers_snapshot、javers_global_id三张表 - Step 4 :创建
EntityChangeEvent事件类 - Step 5 :创建
EntityChangeEventPublisher发布器 - Step 6 :创建
EntityChangeEventListener监听器(注入Javers调用commit()) - Step 7 :为实体类添加 JaVers 注解(
@TypeName、@PropertyName、@DiffIgnore),父类TimeEntity的createTime/updateTime加@DiffIgnore - Step 8 :在业务层的增/改/删方法中调用
eventPublisher.publishCreated/Updated/Deleted() - Step 9 :创建
JaVersHistoryService(三个查询方法) - Step 10:在 Controller 中添加 3 个版本历史接口
- Step 11:(可选)为已有数据创建初始化逻辑
前端步骤
- Step 12 :复制
HistoryDialog.vue到项目的公共组件目录 - Step 13:在 API 文件中添加 3 个版本历史请求函数
- Step 14 :在列表页面引入
HistoryDialog,添加"变更历史"按钮,调用open()方法
验证清单
- 创建一条数据 → 查看
javers_snapshot表,应有 V1 记录 - 更新该数据 → 应有 V2 记录,V1 不变
- 再次更新 → 应有 V3 记录
- 前端打开变更历史 → 左侧显示 V3、V2、V1 列表
- 点击某个版本 → 右侧显示完整 JSON 快照
- 勾选两个版本 → 点击对比 → 右侧表格显示属性、旧值、新值
- 滚动到底部 → 自动加载更多版本
常见问题
Q: 为什么不直接用 @JaversSpringDataAuditable?
A: 自动审计虽然简单,但有两个问题:(1) 每次 repository.save() 都触发,无法区分临时保存和最终提交;(2) 只能提交原始 Entity,无法提交聚合 DTO。事件驱动方式更灵活,业务代码明确控制何时提交。
Q: DTO 模式和直接 Entity 模式怎么选?
A: 如果实体只包含自身字段(无关联数据),直接用 Entity 模式。如果需要在快照中包含关联数据(如"项目-详情-迭代"的聚合视图),用 DTO 模式。Entity 模式更简单,DTO 模式更强大。
Q: JaVers 快照会不会让数据库越来越大?
A: 每次 commit 产生一条快照记录(JSON 存储)。对于普通业务实体(几百到几千条,修改频率不高),增长非常缓慢。JaVers 不提供自动清理功能,如需清理可自行按 commitDate 删除历史快照。
Q: 如果 Entity 加了新字段,旧快照中该字段值是什么?
A: 旧快照中没有该字段(因为提交时不包含),前端查询详情时会显示为 undefined。这通常是可接受的行为。如果需要在展示时处理,可以在 valueFormatter 中对缺失字段做默认值处理。