MySQL 表的类 Git 版本控制

一、背景

用户要求记录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("回滚成功");
    }


}

四、注意事项

  1. 并发控制:生产环境若并发较高,建议替换表锁为分布式锁(如Redisson),避免锁表阻塞
  2. 大表优化:批量插入时使用分批插入(如每 1000 条一批),避免内存溢出;增量对比时可限制查询字段(仅对比业务字段,忽略 create_time/update_time 等)
  3. SQL注入防护:表名、字段名必须严格校验,禁止直接拼接用户输入
  4. 版本归档:定期归档旧版本(如超过 6 个月的增量快照),合并为新基线,减少回滚时的计算量
  5. 数据备份:回滚前的自动备份建议存储到独立的备份表,而非仅提交版本(防止回滚失败)

五、问题总监

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();
        }
    });
}
相关推荐
EntyIU16 小时前
自己实现mybatisplus的批量插入
java·后端
pany16 小时前
程序员近十年新年愿望,都有哪些变化?
前端·后端·程序员
杨宁山16 小时前
Java 解析 CDR 文件并计算图形面积的完整方案(支持 MultipartFile / 网络文件)@杨宁山
后端
朱昆鹏16 小时前
IDEA Claude Code or Codex GUI 插件【开源自荐】
前端·后端·github
HashTang16 小时前
买了专业屏只当普通屏用?解锁 BenQ RD280U 的“隐藏”开发者模式
前端·javascript·后端
明月_清风17 小时前
从"请求地狱"到"请求天堂":alovajs 如何用 20+ 高级特性拯救前端开发者
前端·后端
掘金者阿豪17 小时前
如何解决 "Required request body is missing" 错误:深度解析与解决方案
后端
William_cl17 小时前
ASP.NET Core 视图组件:从入门到避坑,UI 复用的终极方案
后端·ui·asp.net
小杨同学4917 小时前
C 语言实战:3 次机会密码验证系统(字符串处理 + 边界校验)
后端