一、背景
用户要求记录excel表的修改记录,并且对不同版本的excel进行对比展示
开发环境:JDK17 SpringBoot3
二、解决方案
将excel表数据存入数据库,对MySQL 表实现类 Git 版本控制
核心设计逻辑
1. 通用适配
支持任意 MySQL 表的增量快照,通过主键作为唯一标识对比数据(需保证业务表有主键);
2. 版本逻辑
- 首次提交:生成全量基线快照(作为后续增量的基准)
- 非首次提交:对比当前表与上一版本合并后的数据,提取增量变更集;
- 回滚逻辑:基于基线+增量变更集逆向计算,恢复到目标版本;
3. 防风险
全程事务控制,SQL防注入,锁表避免发生变更,回滚前自动备份
三、代码实现
1. 数据库表设计
1.1. 全局版本记录表(存储增量快照核心信息)
sql
CREATE TABLE `dg_table_version` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '全局版本ID',
`table_name` varchar(100) NOT NULL COMMENT '业务表名(如product)',
`table_version_num` int(11) NOT NULL COMMENT '该表的版本号(自增:1、2、3...)',
`snapshot_type` varchar(20) NOT NULL COMMENT '快照类型:FULL(基线)/INCREMENT(增量)',
`snapshot_content` longtext NOT NULL COMMENT '快照内容:FULL=全量JSON;INCREMENT=增量变更集JSON',
`table_structure_hash` char(64) NOT NULL COMMENT '表结构MD5哈希(防结构变更)',
`operator` varchar(100) NOT NULL COMMENT '操作人',
`operate_time` datetime NOT NULL COMMENT '提交时间',
`version_desc` varchar(255) DEFAULT NULL COMMENT '版本说明',
`create_time` datetime DEFAULT NULL,
`create_user` varchar(100) DEFAULT NULL,
`last_update_time` datetime DEFAULT NULL,
`last_update_user` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `dg_table_version_table_name_IDX` (`table_name`, `table_version_num`)
)DEFAULT CHARSET = utf8mb4 ROW_FORMAT = DYNAMIC COMMENT='全局表版本记录表';
1.2. 表结构版本表(可选,管控表结构变更)
sql
CREATE TABLE `dg_table_structure_version` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`table_name` varchar(50) NOT NULL COMMENT '业务表名',
`structure_sql` longtext NOT NULL COMMENT '建表/改表SQL',
`structure_hash` varchar(64) NOT NULL COMMENT '表结构MD5哈希',
`operator` varchar(50) NOT NULL COMMENT '操作人',
`operate_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间',
`create_time` datetime DEFAULT NULL,
`create_user` varchar(100) DEFAULT NULL,
`last_update_time` datetime DEFAULT NULL,
`last_update_user` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_table_hash` (`table_name`, `structure_hash`) COMMENT '表+结构哈希唯一'
) DEFAULT CHARSET = utf8mb4 ROW_FORMAT = DYNAMIC COMMENT = '表结构版本表';
2. 核心概念与实体定义
2.1 枚举类
java
import lombok.Getter;
/**
* 快照类型枚举
*/
@Getter
public enum SnapshotType {
FULL("全量基线"),
INCREMENT("增量变更");
private final String desc;
SnapshotType(String desc) {
this.desc = desc;
}
}
java
import lombok.Getter;
/**
* 数据变更类型枚举
*/
@Getter
public enum ChangeType {
ADD("新增"),
UPDATE("修改"),
DELETE("删除");
private final String desc;
ChangeType(String desc) {
this.desc = desc;
}
}
2.2 增量变更集实体(序列化核心)
java
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* 单条记录的变更信息(修改操作需存储旧值+新值)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RecordChange {
/** 变更类型:ADD/UPDATE/DELETE */
private String changeType;
/** 记录主键值(通用化,适配不同表的主键) */
private Object primaryKey;
/** 新增/修改后的新值(DELETE时为null) */
private Map<String, Object> newData;
/** 修改/删除前的旧值(ADD时为null) */
private Map<String, Object> oldData;
}
java
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 整张表的增量变更集(序列化后存入snapshot_content)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class IncrementSnapshot {
/** 变更记录列表 */
private List<RecordChange> recordChanges;
/** 上一版本号(用于回滚时追溯) */
private Integer lastVersionNum;
}
java
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* 全量基线快照(首次提交用)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class FullSnapshot {
/** 全量表数据(主键→整条记录的Map) */
private Map<String, Map<String, Object>> fullData;
}
2.3 数据库实体类
java
import lombok.Data;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.util.Date;
/**
* @author: ZhongXS02
* @date: 2025-12-31
**/
@Data
public class DgTableVersion {
private Integer id;
private String tableName;
private Integer tableVersionNum;
private String snapshotType;
private String snapshotContent;
private String tableStructureHash;
private String operator;
private Date operateTime;
private String versionDesc;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Schema(name = "创建时间")
private Date createTime;
@Schema(name = "创建用户")
private String createUser;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Schema(name = "最后更新时间")
private Date lastUpdateTime;
@Schema(name = "最后更新用户")
private String lastUpdateUser;
}
java
import lombok.Data;
import java.util.Date;
/**
* @author: ZhongXS02
* @date: 2025-12-31
**/
@Data
public class DgTableStructureVersion {
private Integer id;
private String tableName;
private String structureSql;
private String structureHash;
private String operator;
private Date operateTime;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Schema(name = "创建时间")
private Date createTime;
@Schema(name = "创建用户")
private String createUser;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Schema(name = "最后更新时间")
private Date lastUpdateTime;
@Schema(name = "最后更新用户")
private String lastUpdateUser;
}
3. 核心工具类
3.1 JSON 序列化工具(通用适配)
java
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* JSON 序列化工具
* @author: ZhongXS02
* @date: 2025-12-31
**/
@Component
public class JsonUtil {
private final ObjectMapper objectMapper;
public JsonUtil() {
this.objectMapper = new ObjectMapper();
// 适配LocalDate、Map等类型序列化
objectMapper.registerModule(new JavaTimeModule());
// 空 Bean 序列化时不报错
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS,false);
// 反序列化时忽略未知属性(JSON 有但 Java 类没有的字段)
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
/**
* 对象转字符串
* @param object
* @return
*/
public String toJson(Object object){
try {
return objectMapper.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON序列化失败",e);
}
}
/**
* JSON字符串转对象
* @param json
* @param clazz
* @return
* @param <T>
*/
public <T> T fromJson(String json, Class<T> clazz){
if(!StringUtils.hasLength(json)){
return null;
}
try {
return objectMapper.readValue(json, clazz);
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON反序列化失败",e);
}
}
/**
* JSON字符串转复杂对象
* @param json
* @param typeReference
* @return
* @param <T>
*/
public <T> T fromJson(String json, TypeReference<T> typeReference){
if(!StringUtils.hasLength(json)){
return null;
}
try {
return objectMapper.readValue(json,typeReference);
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON反序列化复杂类型失败",e);
}
}
}
3.2 表结构工具类(哈希值计算,主键获取)
java
import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 表结构工具类 (哈希值计算,主键获取)
* @author: ZhongXS02
* @date: 2025-12-31
**/
@Component
public class TableStructureUtil {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private JsonUtil jsonUtil;
/**
* 获取表的主键字段名
* @param tableName
* @return
*/
public String getPrimaryKeyName(String tableName){
// 检验表名 防止SQL注入
if(!validateTableName(tableName)){
throw new IpsException("表名非法"+tableName);
}
String sql = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE " +
"WHERE TABLE_SCHEMA = (SELECT DATABASE()) AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'";
List<String> pkList = jdbcTemplate.queryForList(sql, String.class, tableName);
if (CollectionUtils.isEmpty(pkList)) {
throw new RuntimeException("表[" + tableName + "]无主键,不支持增量快照");
}
// 仅支持单主键(多主键需扩展)
return pkList.get(0);
}
/**
* 计算表结构的MD5哈希值
* @param tableName
* @return
*/
public String calculateTableStructureHash(String tableName){
if (!validateTableName(tableName)){
throw new IpsException("表名非法"+tableName);
}
String sql = "SHOW CREATE TABLE " + tableName;
Map<String, Object> result = jdbcTemplate.queryForMap(sql);
String createTableSql = (String) result.get("Create Table");
return DigestUtils.md5Hex(createTableSql.getBytes(StandardCharsets.UTF_8));
}
/**
* 检验表名合法性
*/
public boolean validateTableName(String tableName){
return tableName.matches("^[a-zA-Z0-9_]+$");
}
/**
* 查询整张表数据,返回「主键→整条记录」的Map
*/
public Map<String, Map<String, Object>> queryTableData(String tableName) {
String pkName = getPrimaryKeyName(tableName);
String sql = "SELECT * FROM " + tableName;
List<Map<String, Object>> dataList = jdbcTemplate.queryForList(sql);
// 转换为主键→记录的Map
Map<String, Map<String, Object>> dataMap = new HashMap<>();
for (Map<String, Object> row : dataList) {
Object pkValue = row.get(pkName);
dataMap.put(pkValue.toString(), row);
}
return dataMap;
}
}
3.3 增量变更对比工具类(核心:提取增删改)
scss
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 增量变更对比工具类
* @author: ZhongXS02
* @date: 2025-12-31
**/
@Component
public class IncrementCompareUtil {
@Autowired
private TableStructureUtil tableStructureUtil;
public IncrementSnapshot compare(Map<String, Map<String,Object>> oldData,
Map<String, Map<String,Object>> newData,
String tableName,
Integer lastVersionNum){
List<RecordChange> recordChanges=new ArrayList<>();
String pkName=tableStructureUtil.getPrimaryKeyName(tableName);
// 1. 新增:newData有,oldData没有
for(Map.Entry<String,Map<String,Object>> entry:newData.entrySet()){
String pkValue=entry.getKey();
if(!oldData.containsKey(pkValue)){
RecordChange change=new RecordChange();
change.setChangeType("ADD");
change.setPrimaryKey(pkValue);
change.setNewData(entry.getValue());
change.setOldData(null);
recordChanges.add(change);
}
}
// 2. 删除: oldData有,newData无
for(Map.Entry<String,Map<String,Object>> entry:oldData.entrySet()){
String pkValue=entry.getKey();
if(!newData.containsKey(pkValue)){
RecordChange change=new RecordChange();
change.setChangeType("DELETE");
change.setPrimaryKey(pkValue);
change.setNewData(null);
change.setOldData(entry.getValue());
recordChanges.add(change);
}
}
// 3. 修改: 主键都有,内容不同
for(Map.Entry<String,Map<String,Object>> entry:oldData.entrySet()){
String pkValue=entry.getKey();
if(newData.containsKey(pkValue)){
Map<String,Object> oldRow=entry.getValue();
Map<String,Object> newRow=newData.get(pkValue);
if(!oldRow.equals(newRow)){
RecordChange change=new RecordChange();
change.setChangeType("UPDATE");
change.setPrimaryKey(pkValue);
change.setNewData(newRow);
change.setOldData(oldRow);
recordChanges.add(change);
}
}
}
return new IncrementSnapshot(recordChanges, lastVersionNum);
}
}
4. Mapper 层
4.1 TableVersionMapper
java
@Mapper
public interface DgTableVersionMapper {
/**
* 查询指定表的最新版本号
* @param tableName
* @return
*/
@Select("SELECT IFNULL(MAX(table_version_num), 0) FROM dg_table_version WHERE table_name = #{tableName}")
Integer selectMaxVersionNum(@Param("tableName") String tableName);
/**
* 查询指定表 + 版本号的记录
* @param tableName
* @param versionNum
* @return
*/
@Select("SELECT * FROM dg_table_version WHERE table_name = #{tableName} AND table_version_num = #{versionNum}")
DgTableVersion selectByTableAndVersion(@Param("tableName") String tableName, @Param("versionNum") Integer versionNum);
/**
* 查询指定表的所有版本 (按版本号升序)
* @param tableName
* @return
*/
@Select("SELECT * FROM dg_table_version WHERE table_name = #{tableName} ORDER BY table_version_num ASC")
List<DgTableVersion> selectAllByTableName(@Param("tableName") String tableName);
void insert(@Param("tableVersion") DgTableVersion tableVersion);
}
4.2 TableStructureVersionMapper
java
@Mapper
public interface DgTableStructureVersionMapper {
/**
* 查询指定表+哈希的结构记录
*/
@Select("SELECT * FROM dg_table_structure_version WHERE table_name = #{tableName} AND structure_hash = #{hash}")
DgTableStructureVersion selectByTableAndHash(@Param("tableName") String tableName, @Param("hash") String hash);
void insert(@Param("newStruct") DgTableStructureVersion newStruct);
}
5. 核心业务层实现
5.1 表版本服务接口
arduino
public interface DgTableVersionService {
/**
* 提交表版本(自动判断:首次=全量基线,非首次=增量快照)
*/
void commitVersion(String tableName, String versionDesc, String operator);
/**
* 回滚整张表到指定版本
*/
void rollbackToVersion(String tableName, Integer targetVersion, String operator);
/**
* 对比两个版本的差异
*/
String compareVersion(String tableName, Integer v1, Integer v2);
/**
* 查询指定表的所有版本记录
*/
List<DgTableVersion> listTableVersion(String tableName);
}
5.2 表版本服务实现类(核心)
scss
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.*;
import java.util.stream.Collectors;
/**
* @author: ZhongXS02
* @date: 2026-01-05
**/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class DgTableVersionServiceImpl implements DgTableVersionService {
@Autowired
private final DgTableVersionMapper tableVersionMapper;
private final DgTableStructureVersionMapper tableStructureVersionMapper;
private final TableStructureUtil tableStructureUtil;
private final IncrementCompareUtil incrementCompareUtil;
private final JsonUtil jsonUtil;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
public void commitVersion(String tableName, String versionDesc, String operator) {
// 1. 前置校验:表名合法性、表结构变更检查
String currentStructHash = tableStructureUtil.calculateTableStructureHash(tableName);
checkTableStructure(tableName, currentStructHash, operator);
// 2. 获取最新版本号,计算新版本号
Integer lastVersionNumber = tableVersionMapper.selectMaxVersionNum(tableName);
Integer newVersionNumber = lastVersionNumber + 1;
// 3. 锁表:避免提交版本期间数据被修改(MySQL表锁,需谨慎使用,小并发场景)
lockTable(tableName);
try{
// 4. 区分首次提交(全量)和非首次提交(增量)
DgTableVersion tableVersion= new DgTableVersion();
tableVersion.setTableName(tableName);
tableVersion.setTableVersionNum(newVersionNumber);
tableVersion.setTableStructureHash(currentStructHash);
tableVersion.setOperator(operator);
tableVersion.setOperateTime(new Date());
tableVersion.setVersionDesc(versionDesc);
if(lastVersionNumber==0){
// 首次提交:全量基线快照
commitFullSnapshot(tableVersion,tableName);
}else{
// 非首次提交:增量快照
commitIncrementSnapshot(tableVersion, tableName, lastVersionNumber);
}
// 5. 插入版本记录表
tableVersionMapper.insert(tableVersion);
} finally {
// 解锁表
unlockTable();
}
}
@Transactional
@Override
public void rollbackToVersion(String tableName, Integer targetVersion, String operator) {
// 1. 前置校验
Integer maxVersion = tableVersionMapper.selectMaxVersionNum(tableName);
if (targetVersion > maxVersion) {
throw new IpsException("目标版本[" + targetVersion + "]不存在,当前最新版本:" + maxVersion);
}
// 2. 回滚前先提交当前版本
commitVersion(tableName, "回滚前自动备份-目标版本:" + targetVersion, operator);
// 3. 锁表:禁止并发修改
lockTable(tableName);
try{
// 4. 获取目标版本的全量数据
Map<String,Map<String,Object>> targetData=getMergeDataByVersion(tableName, targetVersion);
String pkName=tableStructureUtil.getPrimaryKeyName(tableName);
// 5. 清空当前表
truncateTable(tableName);
// 6. 插入目标版本数据到当前表
batchInsertData(tableName, pkName, targetData);
// 7. 提交回滚后的版本记录
commitVersion(tableName, "回滚到版本[" + targetVersion + "]", operator);
}finally {
unlockTable();
}
}
@Override
public String compareVersion(String tableName, Integer v1, Integer v2) {
// 1. 获取两个版本的全量数据
Map<String, Map<String, Object>> dataV1 = getMergeDataByVersion(tableName, v1);
Map<String, Map<String, Object>> dataV2 = getMergeDataByVersion(tableName, v2);
// 2. 对比差异(复用增量对比工具)
IncrementSnapshot diffSnapshot = incrementCompareUtil.compare(dataV1, dataV2, tableName, v1);
// 3. 构建差异描述
StringBuilder diffDesc = new StringBuilder();
diffDesc.append("===== 版本对比:").append(v1).append(" → ").append(v2).append(" =====\n");
List<RecordChange> recordChanges = diffSnapshot.getRecordChanges();
if (CollectionUtils.isEmpty(recordChanges)) {
diffDesc.append("无差异");
return diffDesc.toString();
}
for (RecordChange change : recordChanges) {
diffDesc.append(change.getChangeType())
.append(" - 主键:").append(change.getPrimaryKey()).append("\n");
if ("UPDATE".equals(change.getChangeType())) {
diffDesc.append(" 旧值:").append(change.getOldData()).append("\n");
diffDesc.append(" 新值:").append(change.getNewData()).append("\n");
} else if ("ADD".equals(change.getChangeType())) {
diffDesc.append(" 新增数据:").append(change.getNewData()).append("\n");
} else if ("DELETE".equals(change.getChangeType())) {
diffDesc.append(" 删除数据:").append(change.getOldData()).append("\n");
}
diffDesc.append("------------------------\n");
}
return diffDesc.toString();
}
@Override
public List<DgTableVersion> listTableVersion(String tableName) {
return tableVersionMapper.selectAllByTableName(tableName);
}
// ====================== 辅助方法 ======================
/**
* 检查表结构变更,若变更则记录新结构版本
*/
private void checkTableStructure(String tableName, String currentHash, String operator) {
DgTableStructureVersion existStruct = tableStructureVersionMapper.selectByTableAndHash(tableName, currentHash);
if (existStruct == null) {
// 表结构变更,记录新结构版本
String createTableSql = jdbcTemplate.queryForMap("SHOW CREATE TABLE " + tableName).get("Create Table").toString();
DgTableStructureVersion newStruct = new DgTableStructureVersion();
newStruct.setTableName(tableName);
newStruct.setStructureSql(createTableSql);
newStruct.setStructureHash(currentHash);
newStruct.setOperator(operator);
newStruct.setOperateTime(new Date());
tableStructureVersionMapper.insert(newStruct);
}
}
/**
* 锁表(MYSQL WRITE锁,阻塞所有读写,小并发场景适用)
*/
private void lockTable(String tableName) {
if (!tableStructureUtil.validateTableName(tableName)) {
throw new RuntimeException("非法表名:" + tableName);
}
jdbcTemplate.execute("LOCK TABLE " + tableName + " WRITE");
}
/**
* 解锁表
*/
private void unlockTable() {
jdbcTemplate.execute("UNLOCK TABLES");
}
/**
* 清空表(安全截断,避免误删)
*/
private void truncateTable(String tableName) {
if (!tableStructureUtil.validateTableName(tableName)) {
throw new RuntimeException("非法表名:" + tableName);
}
jdbcTemplate.execute("TRUNCATE TABLE " + tableName);
}
/**
* 提交全量基线快照(首次提交)
*/
private void commitFullSnapshot(DgTableVersion tableVersion, String tableName) {
// 查询整张表数据
Map<String, Map<String, Object>> fullData = tableStructureUtil.queryTableData(tableName);
// 序列化全量数据
FullSnapshot fullSnapshot = new FullSnapshot(fullData);
String snapshotContent = jsonUtil.toJson(fullSnapshot);
// 设置快照信息
tableVersion.setSnapshotType(SnapshotType.FULL.name());
tableVersion.setSnapshotContent(snapshotContent);
}
/**
* 提交增量快照(非首次提交)
*/
private void commitIncrementSnapshot(DgTableVersion tableVersion, String tableName, Integer lastVersionNum) {
// 1. 获取上一版本的完整数据(基线+所有增量合并)
Map<String, Map<String, Object>> lastVersionData = getMergeDataByVersion(tableName, lastVersionNum);
// 2. 获取当前表的最新数据
Map<String, Map<String, Object>> currentData = tableStructureUtil.queryTableData(tableName);
// 3. 对比提取增量变更集
IncrementSnapshot incrementSnapshot = incrementCompareUtil.compare(lastVersionData, currentData, tableName, lastVersionNum);
// 4. 序列化增量变更集
String snapshotContent = jsonUtil.toJson(incrementSnapshot);
// 5. 设置快照信息
tableVersion.setSnapshotType(SnapshotType.INCREMENT.name());
tableVersion.setSnapshotContent(snapshotContent);
}
/**
* 合并基线+增量,获取指定版本的全量数据
*/
private Map<String, Map<String, Object>> getMergeDataByVersion(String tableName, Integer targetVersion) {
List<DgTableVersion> versionList = tableVersionMapper.selectAllByTableName(tableName);
Map<String, Map<String, Object>> mergeData = new HashMap<>();
for (DgTableVersion version : versionList) {
Integer versionNum = version.getTableVersionNum();
if (versionNum > targetVersion) {
break;
}
String snapshotType = version.getSnapshotType();
String snapshotContent = version.getSnapshotContent();
if (SnapshotType.FULL.name().equals(snapshotType)) {
// 基线版本:直接覆盖mergeData
FullSnapshot fullSnapshot = jsonUtil.fromJson(snapshotContent, FullSnapshot.class);
mergeData = fullSnapshot.getFullData();
} else {
// 增量版本:应用变更集到mergeData
IncrementSnapshot incrementSnapshot = jsonUtil.fromJson(snapshotContent, IncrementSnapshot.class);
applyIncrementToMergeData(mergeData, incrementSnapshot.getRecordChanges());
}
}
return mergeData;
}
/**
* 将增量变更集应用到合并数据中(正向)
*/
private void applyIncrementToMergeData(Map<String, Map<String, Object>> mergeData, List<RecordChange> recordChanges) {
for (RecordChange change : recordChanges) {
Object pkValue = change.getPrimaryKey();
switch (change.getChangeType()) {
case "ADD":
mergeData.put(pkValue.toString(), change.getNewData());
break;
case "UPDATE":
mergeData.put(pkValue.toString(), change.getNewData());
break;
case "DELETE":
mergeData.remove(pkValue.toString());
break;
}
}
}
/**
* 批量插入数据到表中
*/
private void batchInsertData(String tableName, String pkName, Map<String, Map<String, Object>> dataMap) {
if (CollectionUtils.isEmpty(dataMap)) {
return;
}
// 提取第一条记录的字段名(所有记录字段一致)
Map<String, Object> firstRow = dataMap.values().iterator().next();
if (CollectionUtils.isEmpty(firstRow)) { // 新增:字段为空直接返回
return;
}
List<String> columns = new ArrayList<>(firstRow.keySet());
// 修复1:转义保留关键字字段(如desc、enable等)
List<String> escapedColumns = columns.stream()
.map(col -> "`" + col + "`")
.collect(Collectors.toList());
String columnStr = String.join(",", escapedColumns);
// 构建批量插入SQL(优化拼接逻辑)
StringBuilder sqlBuilder = new StringBuilder();
sqlBuilder.append("INSERT INTO ").append(tableName).append(" (").append(columnStr).append(") VALUES ");
List<List<Object>> batchParams = new ArrayList<>();
List<String> valuePlaceholders = new ArrayList<>(); // 存储每一行的?占位符
for (Map<String, Object> row : dataMap.values()) {
List<Object> params = new ArrayList<>();
// 构建单条记录的?占位符
StringBuilder singleValue = new StringBuilder("(");
for (String column : columns) {
singleValue.append("?,");
params.add(row.get(column));
}
// 移除最后一个逗号(更安全的方式)
if (singleValue.length() > 1) {
singleValue.setLength(singleValue.length() - 1);
}
singleValue.append(")");
valuePlaceholders.add(singleValue.toString()); // 加入占位符列表
batchParams.add(params);
}
// 拼接所有占位符(用逗号分隔),避免手动删逗号
sqlBuilder.append(String.join(",", valuePlaceholders));
// 执行批量插入
jdbcTemplate.batchUpdate(sqlBuilder.toString(), new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
List<Object> params = batchParams.get(i);
for (int j = 0; j < params.size(); j++) {
ps.setObject(j + 1, params.get(j));
}
}
@Override
public int getBatchSize() {
return batchParams.size();
}
});
}
}
6. 控制层(暴露 REST 接口)
less
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 表内容版本控制
*
* @author: ZhongXS02
* @date: 2026-01-05
**/
@RestController
@RequestMapping("/v1/dg/tableVersion")
@Slf4j
@RequiredArgsConstructor
public class DgTableVersionController {
private final DgTableVersionService dgTableVersionService;
@Operation(summary = "提交表版本")
@PostMapping("commit")
public ResponseEntity<?> commitVersion(@RequestParam String tableName,
@RequestParam String versionDesc,
@RequestParam(defaultValue = "admin") String operator){
dgTableVersionService.commitVersion(tableName, versionDesc, operator);
return ResponseEntity.ok("提交成功");
}
@Operation(summary = "对比两个版本差异")
@GetMapping("compare")
public ResponseEntity<?> compareVersion(@RequestParam String tableName,
@RequestParam Integer v1,
@RequestParam Integer v2){
String diff = dgTableVersionService.compareVersion(tableName, v1, v2);
return ResponseEntity.ok(diff);
}
@Operation(summary = "回滚到指定版本")
@PostMapping("rollback")
public ResponseEntity<?> rollbackVersion(@RequestParam String tableName,
@RequestParam Integer targetVersion,
@RequestParam(defaultValue = "admin") String operator) {
dgTableVersionService.rollbackToVersion(tableName, targetVersion, operator);
return ResponseEntity.ok("回滚成功");
}
}
四、注意事项
- 并发控制:生产环境若并发较高,建议替换表锁为分布式锁(如Redisson),避免锁表阻塞
- 大表优化:批量插入时使用分批插入(如每 1000 条一批),避免内存溢出;增量对比时可限制查询字段(仅对比业务字段,忽略 create_time/update_time 等)
- SQL注入防护:表名、字段名必须严格校验,禁止直接拼接用户输入
- 版本归档:定期归档旧版本(如超过 6 个月的增量快照),合并为新基线,减少回滚时的计算量
- 数据备份:回滚前的自动备份建议存储到独立的备份表,而非仅提交版本(防止回滚失败)
五、问题总监
1. 新学了@RequiredArgsConstructor + final 注入(构造器注入)
@RequiredArgsConstructor是 Lombok 注解,会自动生成包含所有final字段的构造器,Spring 通过构造器完成注入:
java
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor // Lombok自动生成构造器
public class UserService {
// final修饰的字段会被纳入构造器
private final UserRepository userRepository;
// 业务方法
public void getUser() {
userRepository.findById(1L);
}
}
等价于
kotlin
@Service
public class UserService {
private final UserRepository userRepository;
// 手动编写构造器,Spring通过构造器注入
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
| 维度 | @Autowired(字段注入) | @RequiredArgsConstructor + final(构造器注入) |
|---|---|---|
| 注入方式 | 字段注入,Spring 通过反射直接赋值字段 | 构造器注入,Spring 调用构造器传入依赖 |
| 不可变性 | 字段不能用 final 修饰(赋值时机晚) | 字段必须用 final 修饰,保证依赖不可变 |
| 空指针风险 | 高(依赖未注入时调用会 NPE) | 低(构造时必须传入依赖,否则无法创建对象) |
| 可测试性 | 差(需要 Spring 容器,单元测试需 mockito) | 好(直接通过构造器传入 mock 对象,无需容器) |
| 代码简洁性 | 字段上加注解,无需写构造器 | 类上加注解,字段加 final,无冗余代码 |
| 依赖可见性 | 私有字段,外部无法直接赋值 | 构造器暴露依赖,语义更清晰 |
| Spring 版本兼容 | 全版本支持 | 需 Spring 4.3+(自动识别单构造器无需 @Autowired) |
| 循环依赖处理 | 支持(字段注入是 Spring 后期赋值) | 不支持(构造器注入会触发循环依赖检测) |
2. 主键异常
问题描述:数据库原表主键是BIGINT,用Object接收就是Long,但转为JSON在提取出来就会变成Ingter,这样在对比的时候就会匹配不上
解决方法:
Map<Object, Map<String, Object>> -> Map<String, Map<String, Object>>
将主键 Object 改为 String
3. 关键字异常
问题描述:在回滚表结构时,有个关键字是MySQL保留字段,但没做相对应的处理导致报错
解决方法:加上关键字处理方法 `字段`
scss
/**
* 批量插入数据到表中
*/
private void batchInsertData(String tableName, String pkName, Map<String, Map<String, Object>> dataMap) {
if (CollectionUtils.isEmpty(dataMap)) {
return;
}
// 提取第一条记录的字段名(所有记录字段一致)
Map<String, Object> firstRow = dataMap.values().iterator().next();
if (CollectionUtils.isEmpty(firstRow)) { // 新增:字段为空直接返回
return;
}
List<String> columns = new ArrayList<>(firstRow.keySet());
// 修复1:转义保留关键字字段(如desc、enable等)
List<String> escapedColumns = columns.stream()
.map(col -> "`" + col + "`")
.collect(Collectors.toList());
String columnStr = String.join(",", escapedColumns);
// 构建批量插入SQL(优化拼接逻辑)
StringBuilder sqlBuilder = new StringBuilder();
sqlBuilder.append("INSERT INTO ").append(tableName).append(" (").append(columnStr).append(") VALUES ");
List<List<Object>> batchParams = new ArrayList<>();
List<String> valuePlaceholders = new ArrayList<>(); // 存储每一行的?占位符
for (Map<String, Object> row : dataMap.values()) {
List<Object> params = new ArrayList<>();
// 构建单条记录的?占位符
StringBuilder singleValue = new StringBuilder("(");
for (String column : columns) {
singleValue.append("?,");
params.add(row.get(column));
}
// 移除最后一个逗号(更安全的方式)
if (singleValue.length() > 1) {
singleValue.setLength(singleValue.length() - 1);
}
singleValue.append(")");
valuePlaceholders.add(singleValue.toString()); // 加入占位符列表
batchParams.add(params);
}
// 拼接所有占位符(用逗号分隔),避免手动删逗号
sqlBuilder.append(String.join(",", valuePlaceholders));
// 执行批量插入
jdbcTemplate.batchUpdate(sqlBuilder.toString(), new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
List<Object> params = batchParams.get(i);
for (int j = 0; j < params.size(); j++) {
ps.setObject(j + 1, params.get(j));
}
}
@Override
public int getBatchSize() {
return batchParams.size();
}
});
}