4.4 JaVers 注解速查表 注解作用使用位置TypeName(名称)给 JaVers 设置类型名称影响查询和显示Entity / DTO 类Id标记 JaVers 的全局唯一标识字段DTO 的 ID 字段Entity 用 JPA 的IdPropertyName(中文名)给字段设置显示名称前端对比表显示所有追踪的字段DiffIgnore忽略该字段不纳入变更对比和快照createTime、updateTime、内部编码等Value标记为 JaVers 值对象无独立版本跟随父对象列表中嵌套的子对象5. 业务层集成如何发布事件触发快照5.1 基本原则在业务操作完成后Controller 返回之前调用eventPublisher.publishXxx()。5.2 模式A直接提交 EntityService 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聚合场景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); // 加载关联数据 ListChildEntity children childRepository.findByParentId(id); // 构建 DTO 并发布事件 YourAggregateSnapshotDTO dto YourAggregateSnapshotDTO.from(entity, children); eventPublisher.publishUpdated(dto); } }5.4 定时任务/系统自动操作中的快照// 对于非用户触发的操作如定时同步使用指定作者 eventPublisher.publishCreated(entity, system_sync); // 或使用默认会自动获取当前登录用户非 Web 上下文中为 system eventPublisher.publishUpdated(entity);6. 后端 API 设计查询、详情、对比6.1 JaVersHistoryService —— 通用查询服务这是所有实体的版本历史查询的唯一实现所有 Entity 共用。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 ListMapString, Object getEntitySnapshots( Long entityId, Class? entityClass, int limit, int skip) { var query QueryBuilder.byInstanceId(entityId, entityClass) .skip(skip) .limit(limit) .build(); ListCdoSnapshot snapshots javers.findSnapshots(query); return snapshots.stream() .map(this::convertToMetadata) .toList(); } /** * 获取指定版本的完整快照数据 * * param entityId 实体ID * param entityClass 实体类 * param version 版本号 * return 完整的属性 Map属性名 → 属性值 */ public MapString, Object getEntitySnapshotByVersion( Long entityId, int version, Class? entityClass) { var query QueryBuilder.byInstanceId(entityId, entityClass) .withVersion(version) .build(); ListCdoSnapshot snapshots javers.findSnapshots(query); if (snapshots.isEmpty()) { throw new RuntimeException(String.format( 版本号 %d 不存在实体ID: %d, version, entityId)); } MapString, 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(); ListShadowObject 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 MapString, Object convertToMetadata(CdoSnapshot snapshot) { MapString, Object map new HashMap(); map.put(version, snapshot.getVersion()); MapString, 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为例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 ListMapString, 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 MapString, 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 参数说明参数类型说明entityIdLong实体在数据库中的主键 ID对应 JaVers 的instanceIdentityClassClass?传给 JaVers 的类型。直接追踪用 Entity.classDTO 追踪用 DTO.classversionint版本号。JaVers 从 1 开始自增每次 commit 产生一个新版本limitint每页条数默认 20skipint跳过的条数用于分页。前端第 N 页的 skip N * limit6.4 Diff 返回结构compareVersions返回的Diff对象 JSON 序列化后的结构{ changes: [ { propertyName: name, left: 旧名称, right: 新名称 }, { propertyName: status, left: pending, right: completed } ] }前端直接使用changes数组每项有propertyName字段名、left旧值、right新值。7. 已有数据初始化快照如果系统中已有历史数据在集成 JaVers 之前就存在的记录需要做一次性初始化。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() { ListYourEntity 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 快照完成); } }提供一个后台接口触发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 完整组件代码template el-dialog v-modeldialogVisible :titletitle width70% :close-on-click-modalfalse :destroy-on-closetrue div classversion-history-layout !-- 左侧版本列表 -- div classversion-list-panel div classversion-list-header span版本列表/span el-button typeprimary sizesmall :disabledselectedVersions.length ! 2 clickhandleCompare 对比 /el-button /div el-scrollbar refscrollbarRef scrollhandleScroll div v-ifsnapshots.length 0 !isLoading classno-versions el-empty description暂无数据 :image-size60 / /div div v-else div classversion-item :class{ active: selectedVersion item.version } v-foritem in snapshots :keyitem.version clickselectVersion(item.version) el-checkbox :model-valueselectedVersions.includes(item.version) changehandleVersionCheck(item.version) :disabled!selectedVersions.includes(item.version) selectedVersions.length 2 click.stop / div classversion-content div classversion-header span classversion-nameV{{ item.version }}/span span classversion-author{{ item.commitMetadata?.author ?? - }}/span /div div classversion-time{{ formatDateTime(item.commitMetadata?.commitDate) }}/div /div /div div v-ifisLoading classloading-more span加载中.../span /div div v-ifnoMoreData snapshots.length 0 classno-more 没有更多数据了 /div /div /el-scrollbar /div !-- 右侧展示区 -- div classversion-detail-panel !-- 对比结果 -- div v-ifcompareResult classcompare-container div classcompare-header span classcompare-title V{{ compareVersion1 }} → V{{ compareVersion2 }} /span el-button sizesmall typedanger clickcloseCompare关闭对比/el-button /div el-table :datacompareResult.changes border stripe max-height400 el-table-column proppropertyName label属性 min-width150 / el-table-column label旧值 min-width200 template #default{ row } pre classcompare-value old-value{{ formatCompareValue(row.left) }}/pre /template /el-table-column el-table-column label新值 min-width200 template #default{ row } pre classcompare-value new-value{{ formatCompareValue(row.right) }}/pre /template /el-table-column /el-table div classno-changes v-ifcompareResult.changes.length 0 el-empty description两个版本没有变更 / /div /div !-- 单版本详情 -- div v-else-ifselectedSnapshotData classsnapshot-detail pre classjson-data{{ formatJsonData(selectedSnapshotData) }}/pre /div div v-else classno-data el-empty description请选择一个版本查看详情 / /div /div /div template #footer el-button clickclose关闭/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 () {