Browse Source

feat(botp): 转换/反写规则版本号递增与全量快照,新增版本历史与对比接口

- 保存即版本 +1 并落全量快照:新增 convert_rule_ver / reverse_writing_rules_ver,
  快照以 JSON 内联存储规则主表 + 全部子表分录(字段映射、插件分录 / 反写公式分录、业务规则分录)
- 版本号唯一且单调:两张快照表建唯一索引 (rule_id, rule_version);实体 ruleVersion 加
  @Version 并注册 OptimisticLockerInnerInterceptor,用「页面打开时的版本号」做 CAS,
  影响行数为 0 即提示「已被他人修改,请刷新后重试」,避免并发保存互相覆盖或写坏版本号
- 新增版本历史接口:getVersionList(版本号/创建时间/创建人,不返回 JSON 正文)、
  getVersionSnapshot(只读查看指定版本全量快照)、compareVersion(与当前版本或指定版本对比)
- 版本对比只产出新增/删除/修改三类结论,不做逐字 diff;规则主表按字段比对并忽略审计字段,
  子表分录按业务主键配对(分录为「先删后插」,主键每版都变,按主键配会把所有行显示成删改)
- 快照 JSON 分区名对齐任务分派文档附录 A.2:fieldMappings / pluginEntries / formulas / businesses
- 反写规则保存逻辑从 ReverseWritingRulesController 下沉到 Service.saveAll,
  使版本号递增与快照写入同主表、子表处于同一事务
- 新增幂等 DDL 脚本 botp-rule-version-script.sql:缺列时补 rule_version(information_schema 守卫)、
  历史空版本号兜底为 1、建两张快照表,可重复执行
dev-lml
lml 1 week ago
parent
commit
5698a8b72f
  1. 27
      common/common-botp/src/main/java/apelet/common/botp/config/MybatisPlusConfig.java
  2. 21
      common/common-botp/src/main/java/apelet/common/botp/controller/ConvertRuleController.java
  3. 60
      common/common-botp/src/main/java/apelet/common/botp/controller/ReverseWritingRulesController.java
  4. 11
      common/common-botp/src/main/java/apelet/common/botp/dao/ConvertRuleVerMapper.java
  5. 11
      common/common-botp/src/main/java/apelet/common/botp/dao/ReverseWritingRulesVerMapper.java
  6. 147
      common/common-botp/src/main/java/apelet/common/botp/dto/RuleVersionDiffDto.java
  7. 1
      common/common-botp/src/main/java/apelet/common/botp/model/ConvertRule.java
  8. 54
      common/common-botp/src/main/java/apelet/common/botp/model/ConvertRuleVer.java
  9. 2
      common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRules.java
  10. 54
      common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRulesVer.java
  11. 28
      common/common-botp/src/main/java/apelet/common/botp/service/IConvertRuleService.java
  12. 40
      common/common-botp/src/main/java/apelet/common/botp/service/IReverseWritingRulesService.java
  13. 171
      common/common-botp/src/main/java/apelet/common/botp/service/impl/ConvertRuleServiceImpl.java
  14. 212
      common/common-botp/src/main/java/apelet/common/botp/service/impl/ReverseWritingRulesServiceImpl.java
  15. 329
      common/common-botp/src/main/java/apelet/common/botp/util/RuleDiffUtil.java
  16. 160
      common/common-botp/src/main/java/apelet/common/botp/util/RuleSnapshotUtil.java
  17. 50
      common/common-botp/src/main/java/apelet/common/botp/util/RuleVersionUtil.java
  18. 75
      zz-resource/db-scripts/botp-rule-version-script.sql

27
common/common-botp/src/main/java/apelet/common/botp/config/MybatisPlusConfig.java

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
package apelet.common.botp.config;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* BOTP 模块的 MyBatis-Plus 插件配置。
*
* <p>注册乐观锁插件:实体上标注了 {@code @Version} 的字段(转换规则 / 反写规则的 ruleVersion)
* 在执行 {@code updateById} 时会自动带上 {@code WHERE version = 旧值} 并写入 {@code version + 1},
* 更新影响行数为 0 即代表版本已被别人改过。</p>
*
* <p>该插件只对带 {@code @Version} 字段的实体生效,因此不会改变其它模块 / 其它实体的更新行为。</p>
*
*/
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}

21
common/common-botp/src/main/java/apelet/common/botp/controller/ConvertRuleController.java

@ -149,6 +149,27 @@ public class ConvertRuleController { @@ -149,6 +149,27 @@ public class ConvertRuleController {
return ResponseResult.success(convertRuleService.getConvertFieldmappingEntry(id));
}
@GetMapping(value = "/getVersionList")
@Schema(description = "转换规则版本快照列表")
public ResponseResult<?> getVersionList(@RequestParam Long id) {
return ResponseResult.success(convertRuleService.getVersionList(id));
}
@GetMapping(value = "/getVersionSnapshot")
@Schema(description = "转换规则指定版本的全量快照")
public ResponseResult<?> getVersionSnapshot(@RequestParam Long id,
@RequestParam(required = false) Integer ruleVersion) {
return ResponseResult.success(convertRuleService.getVersionSnapshot(id, ruleVersion));
}
@GetMapping(value = "/compareVersion")
@Schema(description = "转换规则版本对比(新增/删除/修改,不做逐字 diff)")
public ResponseResult<?> compareVersion(@RequestParam Long id,
@RequestParam Integer fromVersion,
@RequestParam(required = false) Integer toVersion) {
return ResponseResult.success(convertRuleService.compareVersion(id, fromVersion, toVersion));
}
/**
* 根据源单据查询关联关系
*

60
common/common-botp/src/main/java/apelet/common/botp/controller/ReverseWritingRulesController.java

@ -8,7 +8,6 @@ import apelet.common.botp.service.IReverseWritingFormulaService; @@ -8,7 +8,6 @@ import apelet.common.botp.service.IReverseWritingFormulaService;
import apelet.common.botp.service.IReverseWritingRulesService;
import apelet.common.core.annotation.MyRequestBody;
import apelet.common.core.constant.ErrorCodeEnum;
import apelet.common.core.exception.MyRuntimeException;
import apelet.common.core.object.MyPageData;
import apelet.common.core.object.MyPageParam;
import apelet.common.core.object.ResponseResult;
@ -88,46 +87,12 @@ public class ReverseWritingRulesController { @@ -88,46 +87,12 @@ public class ReverseWritingRulesController {
@PostMapping(value = "/saveOrUpdate")
@Schema(description = "添加主数据和分录数据")
@Transactional(rollbackFor = Exception.class)
public ResponseResult<?> addAll(@RequestBody ReverseWritingRulesDto dto) {
if (dto == null) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED);
}
ReverseWritingRules reverseWritingRules = new ReverseWritingRules();
BeanUtils.copyProperties(dto, reverseWritingRules);
if (reverseWritingRules.getId() != null) {
// 更新走按主键就地更新,避免删除重建导致创建人等审计字段丢失
ReverseWritingRules original = rulesService.getById(reverseWritingRules.getId());
if (original != null) {
reverseWritingRules.setCreateUserId(original.getCreateUserId());
reverseWritingRules.setCreateTime(original.getCreateTime());
}
reverseWritingRules.setUpdateTime(new Date());
if (!rulesService.updateById(reverseWritingRules)) {
throw new MyRuntimeException("反写规则配置数据保存失败!");
}
// 分录按规则id全量覆盖
formulaService.remove(new LambdaQueryWrapper<ReverseWritingFormula>().eq(ReverseWritingFormula::getRulesId, reverseWritingRules.getId()));
businessService.remove(new LambdaQueryWrapper<ReverseWritingBusiness>().eq(ReverseWritingBusiness::getRulesId, reverseWritingRules.getId()));
} else {
reverseWritingRules.setCreateTime(new Date());
boolean save = rulesService.save(reverseWritingRules);
if (!save) {
throw new MyRuntimeException("反写规则配置数据保存失败!");
}
}
//反写公式
List<ReverseWritingFormula> formulaList = dto.getFormulaList();
if (ObjectUtil.isNotEmpty(formulaList)) {
formulaList.forEach(f -> f.setRulesId(reverseWritingRules.getId()));
formulaService.saveBatch(formulaList);
}
// 业务规则
List<ReverseWritingBusiness> businessList = dto.getBusinessList();
if (ObjectUtil.isNotEmpty(businessList)) {
businessList.forEach(f -> f.setRulesId(reverseWritingRules.getId()));
businessService.saveBatch(businessList);
}
// 版本号 +1 与全量快照均在 Service 内完成,与主表/子表写入处于同一事务
rulesService.saveAll(dto);
return ResponseResult.success("保存成功");
}
@ -156,4 +121,25 @@ public class ReverseWritingRulesController { @@ -156,4 +121,25 @@ public class ReverseWritingRulesController {
return ResponseResult.success(businessList);
}
@GetMapping(value = "/getVersionList")
@Schema(description = "反写规则版本快照列表")
public ResponseResult<?> getVersionList(@RequestParam Long id) {
return ResponseResult.success(rulesService.getVersionList(id));
}
@GetMapping(value = "/getVersionSnapshot")
@Schema(description = "反写规则指定版本的全量快照")
public ResponseResult<?> getVersionSnapshot(@RequestParam Long id,
@RequestParam(required = false) Integer ruleVersion) {
return ResponseResult.success(rulesService.getVersionSnapshot(id, ruleVersion));
}
@GetMapping(value = "/compareVersion")
@Schema(description = "反写规则版本对比(新增/删除/修改,不做逐字 diff)")
public ResponseResult<?> compareVersion(@RequestParam Long id,
@RequestParam Integer fromVersion,
@RequestParam(required = false) Integer toVersion) {
return ResponseResult.success(rulesService.compareVersion(id, fromVersion, toVersion));
}
}

11
common/common-botp/src/main/java/apelet/common/botp/dao/ConvertRuleVerMapper.java

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
package apelet.common.botp.dao;
import apelet.common.botp.model.ConvertRuleVer;
import apelet.common.core.base.dao.BaseDaoMapper;
/**
* 转换规则版本快照 Mapper。
*
*/
public interface ConvertRuleVerMapper extends BaseDaoMapper<ConvertRuleVer> {
}

11
common/common-botp/src/main/java/apelet/common/botp/dao/ReverseWritingRulesVerMapper.java

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
package apelet.common.botp.dao;
import apelet.common.botp.model.ReverseWritingRulesVer;
import apelet.common.core.base.dao.BaseDaoMapper;
/**
* 反写规则版本快照 Mapper。
*
*/
public interface ReverseWritingRulesVerMapper extends BaseDaoMapper<ReverseWritingRulesVer> {
}

147
common/common-botp/src/main/java/apelet/common/botp/dto/RuleVersionDiffDto.java

@ -0,0 +1,147 @@ @@ -0,0 +1,147 @@
package apelet.common.botp.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 规则版本对比结果。
*
* <p>对比粒度是「字段 / 分录行」,只给出新增、删除、修改三类结论,不做逐字 diff:
* 规则主表按字段比对;子表分录按业务主键配对后按字段比对,配对不上的分别算新增或删除。</p>
*
* @author admin
* @since 2024-05-17
*/
@Data
@Schema(description = "规则版本对比结果")
public class RuleVersionDiffDto implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 变化类型:新增。
*/
public static final String CHANGE_ADDED = "ADDED";
/**
* 变化类型:删除。
*/
public static final String CHANGE_REMOVED = "REMOVED";
/**
* 变化类型:修改。
*/
public static final String CHANGE_MODIFIED = "MODIFIED";
@Schema(description = "规则Id")
private Long ruleId;
@Schema(description = "对比基准版本号")
private Integer fromVersion;
@Schema(description = "对比目标版本号")
private Integer toVersion;
@Schema(description = "基准版本的快照时间")
private Date fromCreateTime;
@Schema(description = "目标版本的快照时间")
private Date toCreateTime;
@Schema(description = "规则主表字段差异")
private List<FieldDiff> ruleDiff = new ArrayList<>();
/**
* 子表分录差异,key 为快照分区名(fieldMappings / pluginEntries / formulas / businesses)。
*/
@Schema(description = "子表分录差异")
private Map<String, EntryDiff> entries = new LinkedHashMap<>();
@Schema(description = "新增条目数(主表字段 + 各子表分录)")
private int addedCount;
@Schema(description = "删除条目数")
private int removedCount;
@Schema(description = "修改条目数")
private int modifiedCount;
/**
* 单个字段的差异。
*/
@Data
@Schema(description = "字段差异")
public static class FieldDiff implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "字段名")
private String field;
@Schema(description = "变化类型:ADDED / REMOVED / MODIFIED")
private String changeType;
@Schema(description = "基准版本的值")
private Object oldValue;
@Schema(description = "目标版本的值")
private Object newValue;
public static FieldDiff of(String field, String changeType, Object oldValue, Object newValue) {
FieldDiff diff = new FieldDiff();
diff.setField(field);
diff.setChangeType(changeType);
diff.setOldValue(oldValue);
diff.setNewValue(newValue);
return diff;
}
}
/**
* 一类子表分录的差异。
*/
@Data
@Schema(description = "子表分录差异")
public static class EntryDiff implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "新增的分录行")
private List<Map<String, Object>> added = new ArrayList<>();
@Schema(description = "删除的分录行")
private List<Map<String, Object>> removed = new ArrayList<>();
@Schema(description = "修改的分录行")
private List<EntryChange> modified = new ArrayList<>();
}
/**
* 一条被修改的分录行。
*/
@Data
@Schema(description = "被修改的分录行")
public static class EntryChange implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "业务主键(用于把两个版本里的同一行配对上)")
private String key;
@Schema(description = "该行内发生变化的字段")
private List<FieldDiff> changedFields = new ArrayList<>();
@Schema(description = "基准版本的行数据")
private Map<String, Object> oldEntry;
@Schema(description = "目标版本的行数据")
private Map<String, Object> newEntry;
}
}

1
common/common-botp/src/main/java/apelet/common/botp/model/ConvertRule.java

@ -177,6 +177,7 @@ public class ConvertRule extends BaseModel { @@ -177,6 +177,7 @@ public class ConvertRule extends BaseModel {
@Excel(name = "规则版本", width = 15)
@Schema(description = "规则版本")
@Version
private Integer ruleVersion;
@RelationDict(

54
common/common-botp/src/main/java/apelet/common/botp/model/ConvertRuleVer.java

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
package apelet.common.botp.model;
import apelet.common.core.base.model.BaseModel;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* <p>
* 转换规则版本快照
* </p>
*
* <p>转换规则每保存一次(新增或修改),版本号 +1,并在这里追加一条全量快照记录。
* 快照以 JSON 内联存储规则主表及其全部子表分录(字段映射、插件分录),
* 用于历史版本追溯与差异比对。</p>
*
*/
@TableName("convert_rule_ver")
@Schema(description = "ConvertRuleVer 转换规则版本快照")
@Data
public class ConvertRuleVer extends BaseModel {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键")
private Long id;
/**
* 所属转换规则Id。
*/
@Schema(description = "所属转换规则Id")
private Long ruleId;
/**
* 本次保存后的规则版本号。
*/
@Schema(description = "规则版本号")
private Integer ruleVersion;
/**
* 全量快照,JSON 内联存储规则主表与全部子表分录。
*/
@Schema(description = "全量快照JSON")
private String snapshotJson;
/**
* 备注,记录本次快照的触发来源。
*/
@Schema(description = "备注")
private String remark;
}

2
common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRules.java

@ -3,6 +3,7 @@ package apelet.common.botp.model; @@ -3,6 +3,7 @@ package apelet.common.botp.model;
import apelet.common.core.base.model.BaseModel;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@ -74,5 +75,6 @@ public class ReverseWritingRules extends BaseModel implements Serializable { @@ -74,5 +75,6 @@ public class ReverseWritingRules extends BaseModel implements Serializable {
/**
* 规则版本
*/
@Version
private Integer ruleVersion;
}

54
common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRulesVer.java

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
package apelet.common.botp.model;
import apelet.common.core.base.model.BaseModel;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* <p>
* 反写规则版本快照
* </p>
*
* <p>反写规则每保存一次(新增或修改),版本号 +1,并在这里追加一条全量快照记录。
* 快照以 JSON 内联存储规则主表及其全部子表分录(反写公式分录、业务规则分录),
* 用于历史版本追溯与差异比对。</p>
*
*/
@TableName("reverse_writing_rules_ver")
@Schema(description = "ReverseWritingRulesVer 反写规则版本快照")
@Data
public class ReverseWritingRulesVer extends BaseModel {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键")
private Long id;
/**
* 所属反写规则Id。
*/
@Schema(description = "所属反写规则Id")
private Long ruleId;
/**
* 本次保存后的规则版本号。
*/
@Schema(description = "规则版本号")
private Integer ruleVersion;
/**
* 全量快照,JSON 内联存储规则主表与全部子表分录。
*/
@Schema(description = "全量快照JSON")
private String snapshotJson;
/**
* 备注,记录本次快照的触发来源。
*/
@Schema(description = "备注")
private String remark;
}

28
common/common-botp/src/main/java/apelet/common/botp/service/IConvertRuleService.java

@ -2,6 +2,7 @@ package apelet.common.botp.service; @@ -2,6 +2,7 @@ package apelet.common.botp.service;
import apelet.common.botp.dto.ConvertRelationDelDto;
import apelet.common.botp.dto.ConvertRelationListDto;
import apelet.common.botp.dto.RuleVersionDiffDto;
import apelet.common.botp.dto.TransformResultDto;
import apelet.common.botp.model.*;
import apelet.common.core.base.service.IBaseService;
@ -32,6 +33,33 @@ public interface IConvertRuleService extends IBaseService<ConvertRule,Long> { @@ -32,6 +33,33 @@ public interface IConvertRuleService extends IBaseService<ConvertRule,Long> {
List<ConvertPluginEntry> getConvertPluginEntry(Long id);
List<ConvertFieldmappingEntry> getConvertFieldmappingEntry(Long id);
/**
* 查询转换规则的版本快照列表(按版本号倒序,不含快照 JSON 内容)。
*
* @param ruleId 转换规则Id。
* @return 版本快照列表。
*/
List<ConvertRuleVer> getVersionList(Long ruleId);
/**
* 查询转换规则指定版本的完整快照。
*
* @param ruleId 转换规则Id。
* @param ruleVersion 规则版本号;为空时返回最新版本。
* @return 版本快照,不存在时返回 null。
*/
ConvertRuleVer getVersionSnapshot(Long ruleId, Integer ruleVersion);
/**
* 对比转换规则的两个版本快照,产出新增 / 删除 / 修改三类差异(不做逐字 diff)。
*
* @param ruleId 转换规则Id。
* @param fromVersion 基准版本号。
* @param toVersion 目标版本号;为空时取该规则当前版本。
* @return 对比结果。
*/
RuleVersionDiffDto compareVersion(Long ruleId, Integer fromVersion, Integer toVersion);

40
common/common-botp/src/main/java/apelet/common/botp/service/IReverseWritingRulesService.java

@ -1,8 +1,9 @@ @@ -1,8 +1,9 @@
package apelet.common.botp.service;
import apelet.common.botp.dto.ReverseWritingRulesDto;
import apelet.common.botp.dto.RuleVersionDiffDto;
import apelet.common.botp.model.ReverseWritingRules;
import apelet.common.botp.model.ReverseWritingRulesVer;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.List;
@ -15,4 +16,41 @@ public interface IReverseWritingRulesService extends IService<ReverseWritingRule @@ -15,4 +16,41 @@ public interface IReverseWritingRulesService extends IService<ReverseWritingRule
List<ReverseWritingRulesDto> listAll();
List<Map<String,Object>> getTableType();
/**
* 保存反写规则主表及其全部子表分录(公式分录、业务规则分录)。
*
* <p>每保存一次,规则版本号 +1,并落一份包含规则主表与全部子表分录的全量快照。</p>
*
* @param dto 反写规则主表 + 子表分录。
* @return 保存后的规则主表(含最新版本号)。
*/
ReverseWritingRules saveAll(ReverseWritingRulesDto dto);
/**
* 查询反写规则的版本快照列表(按版本号倒序,不含快照 JSON 内容)。
*
* @param ruleId 反写规则Id。
* @return 版本快照列表。
*/
List<ReverseWritingRulesVer> getVersionList(Long ruleId);
/**
* 查询反写规则指定版本的完整快照。
*
* @param ruleId 反写规则Id。
* @param ruleVersion 规则版本号;为空时返回最新版本。
* @return 版本快照,不存在时返回 null。
*/
ReverseWritingRulesVer getVersionSnapshot(Long ruleId, Integer ruleVersion);
/**
* 对比反写规则的两个版本快照,产出新增 / 删除 / 修改三类差异(不做逐字 diff)。
*
* @param ruleId 反写规则Id。
* @param fromVersion 基准版本号。
* @param toVersion 目标版本号;为空时取该规则当前版本。
* @return 对比结果。
*/
RuleVersionDiffDto compareVersion(Long ruleId, Integer fromVersion, Integer toVersion);
}

171
common/common-botp/src/main/java/apelet/common/botp/service/impl/ConvertRuleServiceImpl.java

@ -3,6 +3,7 @@ package apelet.common.botp.service.impl; @@ -3,6 +3,7 @@ package apelet.common.botp.service.impl;
import apelet.common.botp.dao.ConvertFieldmappingEntryMapper;
import apelet.common.botp.dao.ConvertPluginEntryMapper;
import apelet.common.botp.dao.ConvertRuleMapper;
import apelet.common.botp.dao.ConvertRuleVerMapper;
import apelet.common.botp.dto.*;
import apelet.common.botp.model.*;
import apelet.common.botp.model.constant.SplitMergeStrategyEnum;
@ -12,6 +13,9 @@ import apelet.common.botp.service.execute.BotpService; @@ -12,6 +13,9 @@ import apelet.common.botp.service.execute.BotpService;
import apelet.common.botp.service.execute.entity.BillData;
import apelet.common.botp.service.execute.entity.BtpTransformResult;
import apelet.common.botp.util.Constant;
import apelet.common.botp.util.RuleDiffUtil;
import apelet.common.botp.util.RuleSnapshotUtil;
import apelet.common.botp.util.RuleVersionUtil;
import apelet.common.core.base.dao.BaseDaoMapper;
import apelet.common.core.base.service.BaseService;
import apelet.common.core.constant.GlobalDeletedFlag;
@ -65,6 +69,9 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem @@ -65,6 +69,9 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem
private ConvertPluginEntryMapper convertPluginEntryMapper;
@Autowired
private ConvertRuleVerMapper convertRuleVerMapper;
@Autowired
private OnlFormHeadMapper onlFormHeadMapper;
@Autowired
@ -111,13 +118,22 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem @@ -111,13 +118,22 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem
}
//保存前校验(分单策略、字段映射取值方式),不通过直接阻止保存
validateBeforeSave(convertRule);
//乐观锁:以「用户打开页面时看到的版本号」作为比较基准,更新时由 @Version 自动带上
//WHERE rule_version = 基准版本,落库版本号 = 基准版本 + 1;影响行数为 0 说明期间已有人保存过
Integer baseVersion = resolveBaseVersion(id, convertRule.getRuleVersion());
convertRule.setRuleVersion(baseVersion);
//设置主数据基本信息
addOrUpdateBaseData(convertRule,null);
boolean update = this.updateById(convertRule);
if (!this.updateById(convertRule)) {
throw new MyRuntimeException("转换规则已被他人修改(版本号已变化),请刷新页面后重试");
}
//删除分录数据
removeChild(id);
//设置分录数据基本信息,重新添加
return addChild(convertRule,id);
boolean addChild = addChild(convertRule,id);
//落全量快照
saveSnapshot(id, RuleVersionUtil.next(baseVersion), RuleSnapshotUtil.REMARK_UPDATE);
return addChild;
}
@Override
@ -125,6 +141,8 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem @@ -125,6 +141,8 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem
public Boolean addAll(ConvertRule convertRule) {
//保存前校验(分单策略、字段映射取值方式),不通过直接阻止保存
validateBeforeSave(convertRule);
//新增即首版
convertRule.setRuleVersion(RuleVersionUtil.next(null));
//设置主数据基本信息
addOrUpdateBaseData(convertRule,null);
boolean save = this.save(convertRule);
@ -133,7 +151,74 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem @@ -133,7 +151,74 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem
return false;
}
//设置分录数据基本信息,添加
return addChild(convertRule,id);
boolean addChild = addChild(convertRule,id);
//落全量快照
saveSnapshot(id, convertRule.getRuleVersion(), RuleSnapshotUtil.REMARK_CREATE);
return addChild;
}
/**
* 计算本次保存的乐观锁比较基准版本号。
*
* <p>优先使用前端打开页面时带回的 {@code ruleVersion},这样可以发现「在你编辑期间别人也保存过」;
* 前端没有回传版本号时退化为读取库里的当前版本,虽然发现不了编辑期内的覆盖,
* 但并发同时保存时仍然只有一个请求能通过 {@code WHERE rule_version = 基准版本},不会写坏版本号。</p>
*
* @param id 转换规则Id。
* @param clientVersion 前端带回的版本号,可为空。
* @return 有效的基准版本号。
*/
private Integer resolveBaseVersion(Long id, Integer clientVersion) {
if (clientVersion != null && clientVersion >= RuleVersionUtil.INITIAL_VERSION) {
return clientVersion;
}
ConvertRule current = this.getById(id);
if (current == null) {
throw new MyRuntimeException("转换规则不存在或已被删除,id=" + id);
}
return RuleVersionUtil.current(current.getRuleVersion());
}
/**
* 落一份转换规则的全量版本快照。
*
* <p>快照内容取自落库后的真实数据(主表 + 字段映射分录 + 插件分录),以 JSON 内联存储,
* 保证同一版本号下快照与业务表数据完全一致。</p>
*
* @param ruleId 转换规则Id。
* @param ruleVersion 本次保存后的版本号。
* @param remark 快照备注(新增保存 / 修改保存)。
*/
private void saveSnapshot(Long ruleId, Integer ruleVersion, String remark) {
ConvertRule rule = this.getById(ruleId);
if (rule == null) {
throw new MyRuntimeException("转换规则快照生成失败,规则不存在,id=" + ruleId);
}
Map<String, Object> snapshot = RuleSnapshotUtil.newSnapshot(rule);
//字段映射分录
snapshot.put(RuleSnapshotUtil.SECTION_FIELD_MAPPINGS, RuleSnapshotUtil.toColumnMapList(
convertFieldmappingEntryMapper.selectList(new LambdaQueryWrapper<ConvertFieldmappingEntry>()
.eq(ConvertFieldmappingEntry::getParentId, ruleId)
.orderByAsc(ConvertFieldmappingEntry::getSerialNumber))));
//插件分录
snapshot.put(RuleSnapshotUtil.SECTION_PLUGIN_ENTRIES, RuleSnapshotUtil.toColumnMapList(
convertPluginEntryMapper.selectList(new LambdaQueryWrapper<ConvertPluginEntry>()
.eq(ConvertPluginEntry::getParentId, ruleId)
.orderByAsc(ConvertPluginEntry::getSerialNumber))));
ConvertRuleVer ruleVer = new ConvertRuleVer();
ruleVer.setRuleId(ruleId);
ruleVer.setRuleVersion(ruleVersion);
ruleVer.setSnapshotJson(RuleSnapshotUtil.toJson(snapshot));
ruleVer.setRemark(remark);
ruleVer.setCreateTime(new Date());
ruleVer.setUpdateTime(new Date());
TokenData tokenData = TokenData.takeFromRequest();
if (tokenData != null) {
ruleVer.setCreateUserId(tokenData.getUserId());
ruleVer.setUpdateUserId(tokenData.getUserId());
}
convertRuleVerMapper.insert(ruleVer);
}
/**
@ -278,6 +363,86 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem @@ -278,6 +363,86 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem
return convertFieldmappingEntryService.getConvertFieldmappingEntryRelation(id);
}
@Override
public List<ConvertRuleVer> getVersionList(Long ruleId) {
if (ruleId == null) {
return new ArrayList<>();
}
//列表不返回快照正文,避免一次拉回大量 JSON
return convertRuleVerMapper.selectList(new LambdaQueryWrapper<ConvertRuleVer>()
.select(ConvertRuleVer::getId, ConvertRuleVer::getRuleId, ConvertRuleVer::getRuleVersion,
ConvertRuleVer::getRemark, ConvertRuleVer::getCreateUserId,
ConvertRuleVer::getCreateTime, ConvertRuleVer::getUpdateUserId,
ConvertRuleVer::getUpdateTime)
.eq(ConvertRuleVer::getRuleId, ruleId)
.orderByDesc(ConvertRuleVer::getRuleVersion));
}
@Override
public ConvertRuleVer getVersionSnapshot(Long ruleId, Integer ruleVersion) {
if (ruleId == null) {
return null;
}
LambdaQueryWrapper<ConvertRuleVer> queryWrapper = new LambdaQueryWrapper<ConvertRuleVer>()
.eq(ConvertRuleVer::getRuleId, ruleId);
if (ruleVersion == null) {
//未指定版本号时取最新版本
queryWrapper.orderByDesc(ConvertRuleVer::getRuleVersion).last("LIMIT 1");
List<ConvertRuleVer> latestList = convertRuleVerMapper.selectList(queryWrapper);
return latestList.isEmpty() ? null : latestList.get(0);
}
queryWrapper.eq(ConvertRuleVer::getRuleVersion, ruleVersion);
return convertRuleVerMapper.selectOne(queryWrapper);
}
@Override
public RuleVersionDiffDto compareVersion(Long ruleId, Integer fromVersion, Integer toVersion) {
if (ruleId == null) {
throw new MyRuntimeException("转换规则Id不能为空");
}
//目标版本未指定时,默认与「当前版本」对比;当前版本快照缺失时退化为最新快照
Integer targetVersion = toVersion;
if (targetVersion == null) {
ConvertRule current = this.getById(ruleId);
targetVersion = current == null ? null : current.getRuleVersion();
}
ConvertRuleVer toVer = targetVersion == null ? null : getVersionSnapshot(ruleId, targetVersion);
if (toVer == null) {
toVer = getVersionSnapshot(ruleId, null);
}
if (toVer == null) {
throw new MyRuntimeException("转换规则没有任何版本快照,无法对比,规则id=" + ruleId);
}
ConvertRuleVer fromVer = getVersionSnapshot(ruleId, fromVersion);
if (fromVer == null) {
throw new MyRuntimeException("指定的基准版本快照不存在,规则id=" + ruleId + ",版本号=" + fromVersion);
}
RuleVersionDiffDto result = RuleDiffUtil.diff(
RuleDiffUtil.parseSnapshot(fromVer.getSnapshotJson()),
RuleDiffUtil.parseSnapshot(toVer.getSnapshotJson()),
Arrays.asList(RuleSnapshotUtil.SECTION_FIELD_MAPPINGS, RuleSnapshotUtil.SECTION_PLUGIN_ENTRIES),
buildEntryKeyFields());
result.setRuleId(ruleId);
result.setFromVersion(fromVer.getRuleVersion());
result.setToVersion(toVer.getRuleVersion());
result.setFromCreateTime(fromVer.getCreateTime());
result.setToCreateTime(toVer.getCreateTime());
return result;
}
/**
* 各子表分区参与对比的业务主键字段。
*
* @return 分区名 -> 业务主键字段名。
*/
private Map<String, String[]> buildEntryKeyFields() {
Map<String, String[]> keyFields = new LinkedHashMap<>(4);
keyFields.put(RuleSnapshotUtil.SECTION_FIELD_MAPPINGS, RuleDiffUtil.KEY_FIELD_MAPPING);
keyFields.put(RuleSnapshotUtil.SECTION_PLUGIN_ENTRIES, RuleDiffUtil.KEY_PLUGIN_ENTRY);
return keyFields;
}
/**
* 添加子数据方法

212
common/common-botp/src/main/java/apelet/common/botp/service/impl/ReverseWritingRulesServiceImpl.java

@ -2,13 +2,22 @@ package apelet.common.botp.service.impl; @@ -2,13 +2,22 @@ package apelet.common.botp.service.impl;
import apelet.common.botp.dao.ReverseWritingRulesMapper;
import apelet.common.botp.dao.ReverseWritingRulesVerMapper;
import apelet.common.botp.dto.ReverseWritingRulesDto;
import apelet.common.botp.dto.RuleVersionDiffDto;
import apelet.common.botp.model.ReverseWritingBusiness;
import apelet.common.botp.model.ReverseWritingFormula;
import apelet.common.botp.model.ReverseWritingRules;
import apelet.common.botp.model.ReverseWritingRulesVer;
import apelet.common.botp.service.IReverseWritingBusinessService;
import apelet.common.botp.service.IReverseWritingFormulaService;
import apelet.common.botp.service.IReverseWritingRulesService;
import apelet.common.botp.util.RuleDiffUtil;
import apelet.common.botp.util.RuleSnapshotUtil;
import apelet.common.botp.util.RuleVersionUtil;
import apelet.common.core.exception.MyRuntimeException;
import apelet.common.core.object.TokenData;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.BeanUtils;
@ -17,6 +26,9 @@ import org.springframework.stereotype.Service; @@ -17,6 +26,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@ -31,6 +43,8 @@ public class ReverseWritingRulesServiceImpl extends ServiceImpl<ReverseWritingRu @@ -31,6 +43,8 @@ public class ReverseWritingRulesServiceImpl extends ServiceImpl<ReverseWritingRu
IReverseWritingFormulaService formulaService;
@Autowired
ReverseWritingRulesMapper reverseWritingRulesMapper;
@Autowired
ReverseWritingRulesVerMapper reverseWritingRulesVerMapper;
@ -61,4 +75,202 @@ public class ReverseWritingRulesServiceImpl extends ServiceImpl<ReverseWritingRu @@ -61,4 +75,202 @@ public class ReverseWritingRulesServiceImpl extends ServiceImpl<ReverseWritingRu
return reverseWritingRulesMapper.getTableType();
}
@Override
@Transactional(rollbackFor = Exception.class)
public ReverseWritingRules saveAll(ReverseWritingRulesDto dto) {
if (dto == null) {
throw new MyRuntimeException("反写规则配置数据不能为空!");
}
ReverseWritingRules reverseWritingRules = new ReverseWritingRules();
BeanUtils.copyProperties(dto, reverseWritingRules);
String remark;
Integer snapshotVersion;
if (reverseWritingRules.getId() != null) {
// 更新走按主键就地更新,避免删除重建导致创建人等审计字段丢失
ReverseWritingRules original = this.getById(reverseWritingRules.getId());
if (original == null) {
throw new MyRuntimeException("反写规则配置数据不存在或已被删除,id=" + reverseWritingRules.getId());
}
reverseWritingRules.setCreateUserId(original.getCreateUserId());
reverseWritingRules.setCreateTime(original.getCreateTime());
reverseWritingRules.setUpdateTime(new Date());
// 乐观锁:以「用户打开页面时看到的版本号」作为比较基准,更新时由 @Version 自动带上
// WHERE rule_version = 基准版本,落库版本号 = 基准版本 + 1;影响行数为 0 说明期间已有人保存过
Integer baseVersion = resolveBaseVersion(reverseWritingRules.getRuleVersion(), original.getRuleVersion());
reverseWritingRules.setRuleVersion(baseVersion);
if (!this.updateById(reverseWritingRules)) {
throw new MyRuntimeException("反写规则配置数据已被他人修改(版本号已变化),请刷新页面后重试");
}
// 分录按规则id全量覆盖
formulaService.remove(new LambdaQueryWrapper<ReverseWritingFormula>().eq(ReverseWritingFormula::getRulesId, reverseWritingRules.getId()));
businessService.remove(new LambdaQueryWrapper<ReverseWritingBusiness>().eq(ReverseWritingBusiness::getRulesId, reverseWritingRules.getId()));
remark = RuleSnapshotUtil.REMARK_UPDATE;
snapshotVersion = RuleVersionUtil.next(baseVersion);
} else {
reverseWritingRules.setCreateTime(new Date());
// 新增即首版
reverseWritingRules.setRuleVersion(RuleVersionUtil.next(null));
boolean save = this.save(reverseWritingRules);
if (!save) {
throw new MyRuntimeException("反写规则配置数据保存失败!");
}
remark = RuleSnapshotUtil.REMARK_CREATE;
snapshotVersion = reverseWritingRules.getRuleVersion();
}
//反写公式
List<ReverseWritingFormula> formulaList = dto.getFormulaList();
if (ObjectUtil.isNotEmpty(formulaList)) {
formulaList.forEach(f -> f.setRulesId(reverseWritingRules.getId()));
formulaService.saveBatch(formulaList);
}
// 业务规则
List<ReverseWritingBusiness> businessList = dto.getBusinessList();
if (ObjectUtil.isNotEmpty(businessList)) {
businessList.forEach(f -> f.setRulesId(reverseWritingRules.getId()));
businessService.saveBatch(businessList);
}
// 落全量快照
saveSnapshot(reverseWritingRules.getId(), snapshotVersion, remark);
return reverseWritingRules;
}
/**
* 计算本次保存的乐观锁比较基准版本号。
*
* <p>优先使用前端打开页面时带回的 {@code ruleVersion},这样可以发现「在你编辑期间别人也保存过」;
* 前端没有回传版本号时退化为使用库里的当前版本,虽然发现不了编辑期内的覆盖,
* 但并发同时保存时仍然只有一个请求能通过 {@code WHERE rule_version = 基准版本},不会写坏版本号。</p>
*
* @param clientVersion 前端带回的版本号,可为空。
* @param dbVersion 库里的当前版本号,可为空。
* @return 有效的基准版本号。
*/
private Integer resolveBaseVersion(Integer clientVersion, Integer dbVersion) {
if (clientVersion != null && clientVersion >= RuleVersionUtil.INITIAL_VERSION) {
return clientVersion;
}
return RuleVersionUtil.current(dbVersion);
}
/**
* 落一份反写规则的全量版本快照。
*
* <p>快照内容取自落库后的真实数据(主表 + 反写公式分录 + 业务规则分录),以 JSON 内联存储,
* 保证同一版本号下快照与业务表数据完全一致。</p>
*
* @param ruleId 反写规则Id。
* @param ruleVersion 本次保存后的版本号。
* @param remark 快照备注(新增保存 / 修改保存)。
*/
private void saveSnapshot(Long ruleId, Integer ruleVersion, String remark) {
ReverseWritingRules rule = this.getById(ruleId);
if (rule == null) {
throw new MyRuntimeException("反写规则快照生成失败,规则不存在,id=" + ruleId);
}
Map<String, Object> snapshot = RuleSnapshotUtil.newSnapshot(rule);
//反写公式分录
snapshot.put(RuleSnapshotUtil.SECTION_FORMULAS, RuleSnapshotUtil.toColumnMapList(
formulaService.list(new LambdaQueryWrapper<ReverseWritingFormula>()
.eq(ReverseWritingFormula::getRulesId, ruleId)
.orderByAsc(ReverseWritingFormula::getExecOrder))));
//业务规则分录
snapshot.put(RuleSnapshotUtil.SECTION_BUSINESSES, RuleSnapshotUtil.toColumnMapList(
businessService.list(new LambdaQueryWrapper<ReverseWritingBusiness>()
.eq(ReverseWritingBusiness::getRulesId, ruleId))));
ReverseWritingRulesVer ruleVer = new ReverseWritingRulesVer();
ruleVer.setRuleId(ruleId);
ruleVer.setRuleVersion(ruleVersion);
ruleVer.setSnapshotJson(RuleSnapshotUtil.toJson(snapshot));
ruleVer.setRemark(remark);
ruleVer.setCreateTime(new Date());
ruleVer.setUpdateTime(new Date());
TokenData tokenData = TokenData.takeFromRequest();
if (tokenData != null) {
ruleVer.setCreateUserId(tokenData.getUserId());
ruleVer.setUpdateUserId(tokenData.getUserId());
}
reverseWritingRulesVerMapper.insert(ruleVer);
}
@Override
public List<ReverseWritingRulesVer> getVersionList(Long ruleId) {
if (ruleId == null) {
return new ArrayList<>();
}
//列表不返回快照正文,避免一次拉回大量 JSON
return reverseWritingRulesVerMapper.selectList(new LambdaQueryWrapper<ReverseWritingRulesVer>()
.select(ReverseWritingRulesVer::getId, ReverseWritingRulesVer::getRuleId,
ReverseWritingRulesVer::getRuleVersion, ReverseWritingRulesVer::getRemark,
ReverseWritingRulesVer::getCreateUserId, ReverseWritingRulesVer::getCreateTime,
ReverseWritingRulesVer::getUpdateUserId, ReverseWritingRulesVer::getUpdateTime)
.eq(ReverseWritingRulesVer::getRuleId, ruleId)
.orderByDesc(ReverseWritingRulesVer::getRuleVersion));
}
@Override
public ReverseWritingRulesVer getVersionSnapshot(Long ruleId, Integer ruleVersion) {
if (ruleId == null) {
return null;
}
LambdaQueryWrapper<ReverseWritingRulesVer> queryWrapper = new LambdaQueryWrapper<ReverseWritingRulesVer>()
.eq(ReverseWritingRulesVer::getRuleId, ruleId);
if (ruleVersion == null) {
//未指定版本号时取最新版本
queryWrapper.orderByDesc(ReverseWritingRulesVer::getRuleVersion).last("LIMIT 1");
List<ReverseWritingRulesVer> latestList = reverseWritingRulesVerMapper.selectList(queryWrapper);
return latestList.isEmpty() ? null : latestList.get(0);
}
queryWrapper.eq(ReverseWritingRulesVer::getRuleVersion, ruleVersion);
return reverseWritingRulesVerMapper.selectOne(queryWrapper);
}
@Override
public RuleVersionDiffDto compareVersion(Long ruleId, Integer fromVersion, Integer toVersion) {
if (ruleId == null) {
throw new MyRuntimeException("反写规则Id不能为空");
}
//目标版本未指定时,默认与「当前版本」对比;当前版本快照缺失时退化为最新快照
Integer targetVersion = toVersion;
if (targetVersion == null) {
ReverseWritingRules current = this.getById(ruleId);
targetVersion = current == null ? null : current.getRuleVersion();
}
ReverseWritingRulesVer toVer = targetVersion == null ? null : getVersionSnapshot(ruleId, targetVersion);
if (toVer == null) {
toVer = getVersionSnapshot(ruleId, null);
}
if (toVer == null) {
throw new MyRuntimeException("反写规则没有任何版本快照,无法对比,规则id=" + ruleId);
}
ReverseWritingRulesVer fromVer = getVersionSnapshot(ruleId, fromVersion);
if (fromVer == null) {
throw new MyRuntimeException("指定的基准版本快照不存在,规则id=" + ruleId + ",版本号=" + fromVersion);
}
RuleVersionDiffDto result = RuleDiffUtil.diff(
RuleDiffUtil.parseSnapshot(fromVer.getSnapshotJson()),
RuleDiffUtil.parseSnapshot(toVer.getSnapshotJson()),
Arrays.asList(RuleSnapshotUtil.SECTION_FORMULAS, RuleSnapshotUtil.SECTION_BUSINESSES),
buildEntryKeyFields());
result.setRuleId(ruleId);
result.setFromVersion(fromVer.getRuleVersion());
result.setToVersion(toVer.getRuleVersion());
result.setFromCreateTime(fromVer.getCreateTime());
result.setToCreateTime(toVer.getCreateTime());
return result;
}
/**
* 各子表分区参与对比的业务主键字段。
*
* @return 分区名 -> 业务主键字段名。
*/
private Map<String, String[]> buildEntryKeyFields() {
Map<String, String[]> keyFields = new LinkedHashMap<>(4);
keyFields.put(RuleSnapshotUtil.SECTION_FORMULAS, RuleDiffUtil.KEY_FORMULA);
keyFields.put(RuleSnapshotUtil.SECTION_BUSINESSES, RuleDiffUtil.KEY_BUSINESS);
return keyFields;
}
}

329
common/common-botp/src/main/java/apelet/common/botp/util/RuleDiffUtil.java

@ -0,0 +1,329 @@ @@ -0,0 +1,329 @@
package apelet.common.botp.util;
import apelet.common.botp.dto.RuleVersionDiffDto;
import apelet.common.botp.dto.RuleVersionDiffDto.EntryChange;
import apelet.common.botp.dto.RuleVersionDiffDto.EntryDiff;
import apelet.common.botp.dto.RuleVersionDiffDto.FieldDiff;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import java.math.BigDecimal;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 规则版本快照对比工具类。
*
* <p>对比粒度是「字段 / 分录行」,只产出新增、删除、修改三类结论,不做逐字 diff。</p>
*
* <p>两个约定:</p>
* <ol>
* <li>规则主表与子表分录里的审计字段(创建人/创建时间/更新人/更新时间)以及主键、外键不参与对比,
* 因为它们每次保存都会变,展示出来只会淹没真正的配置变更。</li>
* <li>转换规则的子表分录每次保存都是「先删后插」,主键每版都不同,所以子表分录按**业务主键**
* (如字段映射的「源字段+目标字段+目标分录」)配对,而不是按主键配对。</li>
* </ol>
*
* @author admin
* @since 2024-05-17
*/
public final class RuleDiffUtil {
/**
* 规则主表对比时忽略的字段:审计字段、以及版本号本身(版本号在结果的 fromVersion / toVersion 里已单独给出)。
*/
private static final Set<String> RULE_IGNORE_FIELDS = Collections.unmodifiableSet(new HashSet<>(
Arrays.asList("createUserId", "createTime", "updateUserId", "updateTime", "ruleVersion")));
/**
* 子表分录对比时忽略的字段:主键、外键、审计字段、逻辑删除标记。
*/
private static final Set<String> ENTRY_IGNORE_FIELDS = Collections.unmodifiableSet(new HashSet<>(
Arrays.asList("id", "parentId", "rulesId", "createUserId", "createTime",
"updateUserId", "updateTime", "deletedFlag")));
/**
* 字段映射分录的业务主键:源字段 + 目标字段 + 目标分录,一条映射的身份就由这三个字段确定。
*/
public static final String[] KEY_FIELD_MAPPING = {"oldfieldNumber", "newfieldNumber", "entryTarget"};
/**
* 插件分录的业务主键:插件类名。
*/
public static final String[] KEY_PLUGIN_ENTRY = {"className"};
/**
* 反写公式分录的业务主键:反写字段 + 取数的源单字段。
*/
public static final String[] KEY_FORMULA = {"formulaField", "sourceSingleField"};
/**
* 业务规则分录的业务主键:三个分支的条件字段。
*/
public static final String[] KEY_BUSINESS = {"preconditionsField", "establishedField", "notEstablishedField"};
private RuleDiffUtil() {
}
/**
* 解析快照 JSON 字符串。
*
* @param snapshotJson 快照 JSON,允许为空。
* @return 解析后的 Map;入参为空时返回空 Map。
*/
public static Map<String, Object> parseSnapshot(String snapshotJson) {
if (snapshotJson == null || snapshotJson.trim().isEmpty()) {
return new LinkedHashMap<>();
}
JSONObject jsonObject = JSON.parseObject(snapshotJson);
return jsonObject == null ? new LinkedHashMap<>() : new LinkedHashMap<>(jsonObject);
}
/**
* 对比两个版本快照,产出新增 / 删除 / 修改三类差异。
*
* @param fromSnapshot 基准版本快照。
* @param toSnapshot 目标版本快照。
* @param entrySections 需要对比的子表分区名,按顺序输出。
* @param entryKeyFields 各子表分区对应的业务主键字段名。
* @return 差异结果(不含版本号等元信息,由调用方填充)。
*/
public static RuleVersionDiffDto diff(Map<String, Object> fromSnapshot,
Map<String, Object> toSnapshot,
List<String> entrySections,
Map<String, String[]> entryKeyFields) {
RuleVersionDiffDto result = new RuleVersionDiffDto();
List<FieldDiff> ruleDiff = diffFields(asMap(fromSnapshot.get(RuleSnapshotUtil.SECTION_RULE)),
asMap(toSnapshot.get(RuleSnapshotUtil.SECTION_RULE)), RULE_IGNORE_FIELDS);
result.setRuleDiff(ruleDiff);
result.setAddedCount(countByType(ruleDiff, RuleVersionDiffDto.CHANGE_ADDED));
result.setRemovedCount(countByType(ruleDiff, RuleVersionDiffDto.CHANGE_REMOVED));
result.setModifiedCount(countByType(ruleDiff, RuleVersionDiffDto.CHANGE_MODIFIED));
if (entrySections != null) {
for (String section : entrySections) {
String[] keyFields = entryKeyFields == null ? null : entryKeyFields.get(section);
EntryDiff entryDiff = diffEntries(asMapList(fromSnapshot.get(section)),
asMapList(toSnapshot.get(section)),
keyFields == null ? new String[0] : keyFields);
result.getEntries().put(section, entryDiff);
result.setAddedCount(result.getAddedCount() + entryDiff.getAdded().size());
result.setRemovedCount(result.getRemovedCount() + entryDiff.getRemoved().size());
result.setModifiedCount(result.getModifiedCount() + entryDiff.getModified().size());
}
}
return result;
}
/**
* 按字段比对两个 Map,产出新增 / 删除 / 修改三类差异。
*
* @param oldMap 基准数据,允许为空。
* @param newMap 目标数据,允许为空。
* @param ignoreFields 忽略的字段。
* @return 字段差异列表。
*/
public static List<FieldDiff> diffFields(Map<String, Object> oldMap,
Map<String, Object> newMap,
Set<String> ignoreFields) {
List<FieldDiff> diffs = new ArrayList<>();
Set<String> fields = new LinkedHashSet<>();
if (oldMap != null) {
fields.addAll(oldMap.keySet());
}
if (newMap != null) {
fields.addAll(newMap.keySet());
}
for (String field : fields) {
if (ignoreFields != null && ignoreFields.contains(field)) {
continue;
}
boolean inOld = oldMap != null && oldMap.containsKey(field);
boolean inNew = newMap != null && newMap.containsKey(field);
Object oldValue = inOld ? oldMap.get(field) : null;
Object newValue = inNew ? newMap.get(field) : null;
if (inOld && !inNew) {
diffs.add(FieldDiff.of(field, RuleVersionDiffDto.CHANGE_REMOVED, oldValue, null));
} else if (!inOld && inNew) {
diffs.add(FieldDiff.of(field, RuleVersionDiffDto.CHANGE_ADDED, null, newValue));
} else if (!valueEquals(oldValue, newValue)) {
diffs.add(FieldDiff.of(field, RuleVersionDiffDto.CHANGE_MODIFIED, oldValue, newValue));
}
}
return diffs;
}
/**
* 按业务主键比对两组分录行。
*
* <p>业务主键相同的行按出现顺序两两配对,配上的比字段(有差异即「修改」);
* 多出来的分别记「新增」或「删除」。这样即使分录主键每版都变,也能识别出「同一行被改了」。</p>
*
* @param oldEntries 基准版本的分录行。
* @param newEntries 目标版本的分录行。
* @param keyFields 业务主键字段名,为空时所有行归为一组按顺序配对。
* @return 分录差异。
*/
public static EntryDiff diffEntries(List<Map<String, Object>> oldEntries,
List<Map<String, Object>> newEntries,
String[] keyFields) {
EntryDiff entryDiff = new EntryDiff();
Map<String, Deque<Map<String, Object>>> oldGroups = groupByKey(oldEntries, keyFields);
Map<String, Deque<Map<String, Object>>> newGroups = groupByKey(newEntries, keyFields);
Set<String> keys = new LinkedHashSet<>();
keys.addAll(oldGroups.keySet());
keys.addAll(newGroups.keySet());
for (String key : keys) {
Deque<Map<String, Object>> oldQueue = oldGroups.getOrDefault(key, new ArrayDeque<>());
Deque<Map<String, Object>> newQueue = newGroups.getOrDefault(key, new ArrayDeque<>());
while (!oldQueue.isEmpty() && !newQueue.isEmpty()) {
Map<String, Object> oldEntry = oldQueue.poll();
Map<String, Object> newEntry = newQueue.poll();
List<FieldDiff> changedFields = diffFields(oldEntry, newEntry, ENTRY_IGNORE_FIELDS);
if (!changedFields.isEmpty()) {
EntryChange change = new EntryChange();
change.setKey(key);
change.setChangedFields(changedFields);
change.setOldEntry(oldEntry);
change.setNewEntry(newEntry);
entryDiff.getModified().add(change);
}
}
while (!oldQueue.isEmpty()) {
entryDiff.getRemoved().add(oldQueue.poll());
}
while (!newQueue.isEmpty()) {
entryDiff.getAdded().add(newQueue.poll());
}
}
return entryDiff;
}
/**
* 按业务主键把分录行分组,组内保持原始顺序。
*
* @param entries 分录行。
* @param keyFields 业务主键字段名。
* @return 主键 -> 该主键下的分录行队列。
*/
private static Map<String, Deque<Map<String, Object>>> groupByKey(List<Map<String, Object>> entries,
String[] keyFields) {
Map<String, Deque<Map<String, Object>>> groups = new LinkedHashMap<>();
if (entries == null) {
return groups;
}
for (Map<String, Object> entry : entries) {
if (entry == null) {
continue;
}
groups.computeIfAbsent(buildKey(entry, keyFields), k -> new ArrayDeque<>()).add(entry);
}
return groups;
}
/**
* 根据业务主键字段拼出分组用的 key。
*
* @param entry 分录行。
* @param keyFields 业务主键字段名,为空时返回空串(所有行归为一组)。
* @return 分组 key。
*/
private static String buildKey(Map<String, Object> entry, String[] keyFields) {
if (keyFields == null || keyFields.length == 0) {
return "";
}
StringBuilder key = new StringBuilder();
for (String keyField : keyFields) {
if (key.length() > 0) {
key.append('|');
}
Object value = entry.get(keyField);
key.append(value == null ? "\u0000" : String.valueOf(value));
}
return key.toString();
}
/**
* 统计指定变化类型的条数。
*
* @param diffs 字段差异列表。
* @param changeType 变化类型。
* @return 条数。
*/
private static int countByType(List<FieldDiff> diffs, String changeType) {
int count = 0;
for (FieldDiff diff : diffs) {
if (changeType.equals(diff.getChangeType())) {
count++;
}
}
return count;
}
/**
* 比较两个字段值,数字按数值比较,其余按 JSON 文本比较。
*
* @param oldValue 基准值。
* @param newValue 目标值。
* @return 相等返回 true。
*/
private static boolean valueEquals(Object oldValue, Object newValue) {
if (oldValue == null && newValue == null) {
return true;
}
if (oldValue == null || newValue == null) {
return false;
}
if (oldValue instanceof Number && newValue instanceof Number) {
return new BigDecimal(oldValue.toString()).compareTo(new BigDecimal(newValue.toString())) == 0;
}
if (oldValue.equals(newValue)) {
return true;
}
return JSON.toJSONString(oldValue).equals(JSON.toJSONString(newValue));
}
/**
* 把快照分区节点转成 Map。
*
* @param value 分区节点。
* @return Map,类型不符时返回空 Map。
*/
@SuppressWarnings("unchecked")
private static Map<String, Object> asMap(Object value) {
if (value instanceof Map) {
return (Map<String, Object>) value;
}
return new LinkedHashMap<>();
}
/**
* 把快照分区节点转成「Map 列表」。
*
* @param value 分区节点。
* @return Map 列表,类型不符时返回空列表。
*/
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> asMapList(Object value) {
if (value instanceof List) {
List<Map<String, Object>> result = new ArrayList<>();
for (Object item : (List<Object>) value) {
if (item instanceof Map) {
result.add((Map<String, Object>) item);
}
}
return result;
}
return new ArrayList<>();
}
}

160
common/common-botp/src/main/java/apelet/common/botp/util/RuleSnapshotUtil.java

@ -0,0 +1,160 @@ @@ -0,0 +1,160 @@
package apelet.common.botp.util;
import apelet.common.core.exception.MyRuntimeException;
import apelet.common.core.util.MyDateUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.baomidou.mybatisplus.annotation.TableField;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 规则版本快照工具类。
*
* <p>把「规则主表 + 全部子表分录」组装成一个可以直接以 JSON 内联落库的 Map 结构:
* 每个实体只保留真正持久化到数据库的字段(即排除 {@code @TableField(exist = false)} 的关联/字典字段),
* 这样快照里不会出现 upRouteInterfaceMap、deletedFlagMap 这类运行期临时数据,也不会因为
* 某个实体新增了 getter 而污染快照内容。</p>
*
*/
public final class RuleSnapshotUtil {
/**
* 快照中「规则主表」所在的分区名。
*/
public static final String SECTION_RULE = "rule";
/**
* 转换规则快照中「字段映射分录」所在的分区名(契约见任务分派文档附录 A.2 第 5 项)。
*/
public static final String SECTION_FIELD_MAPPINGS = "fieldMappings";
/**
* 转换规则快照中「插件分录」所在的分区名。
*/
public static final String SECTION_PLUGIN_ENTRIES = "pluginEntries";
/**
* 反写规则快照中「反写公式分录」所在的分区名。
*/
public static final String SECTION_FORMULAS = "formulas";
/**
* 反写规则快照中「业务规则分录」所在的分区名。
*/
public static final String SECTION_BUSINESSES = "businesses";
/**
* 快照备注:新增保存。
*/
public static final String REMARK_CREATE = "新增保存";
/**
* 快照备注:修改保存。
*/
public static final String REMARK_UPDATE = "修改保存";
private RuleSnapshotUtil() {
}
/**
* 创建一个新的快照结构,并放入规则主表数据。
*
* @param rule 规则主表实体。
* @return 有序的快照 Map,子表分录由调用方按分区继续 put 进来。
*/
public static Map<String, Object> newSnapshot(Object rule) {
Map<String, Object> snapshot = new LinkedHashMap<>(8);
snapshot.put(SECTION_RULE, toColumnMap(rule));
return snapshot;
}
/**
* 把实体对象转换成「字段名 -> 字段值」的有序 Map,仅包含持久化字段。
*
* @param entity 实体对象,允许为 null。
* @return 字段 Map;入参为 null 时返回 null。
*/
public static Map<String, Object> toColumnMap(Object entity) {
if (entity == null) {
return null;
}
Map<String, Object> columnMap = new LinkedHashMap<>();
// 子类字段在前、基类(审计字段)在后,保证快照的可读性
for (Class<?> clazz = entity.getClass(); clazz != null && clazz != Object.class; clazz = clazz.getSuperclass()) {
for (Field field : clazz.getDeclaredFields()) {
if (isNotPersistent(field)) {
continue;
}
columnMap.put(field.getName(), readField(field, entity));
}
}
return columnMap;
}
/**
* 把实体列表转换成「字段名 -> 字段值」的 Map 列表,仅包含持久化字段。
*
* @param entities 实体列表,允许为 null 或空。
* @return 字段 Map 列表,不会返回 null。
*/
public static List<Map<String, Object>> toColumnMapList(List<?> entities) {
List<Map<String, Object>> columnMapList = new ArrayList<>();
if (entities == null) {
return columnMapList;
}
for (Object entity : entities) {
if (entity != null) {
columnMapList.add(toColumnMap(entity));
}
}
return columnMapList;
}
/**
* 把快照结构序列化成落库用的 JSON 字符串。
*
* @param snapshot 快照结构。
* @return JSON 字符串。
*/
public static String toJson(Map<String, Object> snapshot) {
return JSON.toJSONStringWithDateFormat(snapshot,
MyDateUtil.COMMON_SHORT_DATETIME_FORMAT,
SerializerFeature.DisableCircularReferenceDetect);
}
/**
* 判断字段是否不需要写入快照:静态字段、transient 字段,以及 {@code @TableField(exist = false)} 字段。
*
* @param field 实体字段。
* @return 不需要写入返回 true。
*/
private static boolean isNotPersistent(Field field) {
if (Modifier.isStatic(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) {
return true;
}
TableField tableField = field.getAnnotation(TableField.class);
return tableField != null && !tableField.exist();
}
/**
* 读取字段值。
*
* @param field 实体字段。
* @param entity 实体对象。
* @return 字段值。
*/
private static Object readField(Field field, Object entity) {
try {
field.setAccessible(true);
return field.get(entity);
} catch (IllegalAccessException e) {
throw new MyRuntimeException("读取快照字段失败:" + field.getName(), e);
}
}
}

50
common/common-botp/src/main/java/apelet/common/botp/util/RuleVersionUtil.java

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
package apelet.common.botp.util;
/**
* 规则版本号工具类。
*
* <p>转换规则(convert_rule)与反写规则(reverse_writing_rules)共用同一套版本语义:
* 每保存一次,版本号 +1,并落一份全量快照。</p>
*
*/
public final class RuleVersionUtil {
/**
* 规则首次保存时的版本号。
*/
public static final int INITIAL_VERSION = 1;
private RuleVersionUtil() {
}
/**
* 把库里的版本号规范化成一个可直接使用的「当前版本号」。
*
* <p>历史数据里可能存在版本号为空(早期未维护该字段)或非法值的规则,统一按首版处理。</p>
*
* @param version 库里的版本号,允许为 null。
* @return 有效的当前版本号。
*/
public static int current(Integer version) {
if (version == null || version < INITIAL_VERSION) {
return INITIAL_VERSION;
}
return version;
}
/**
* 计算下一次保存应使用的版本号。
*
* <p>历史数据里可能存在版本号为空(早期未维护该字段)的规则,此时按首次保存处理,直接给
* {@link #INITIAL_VERSION};正常情况一律在原有版本号上 +1。</p>
*
* @param currentVersion 当前版本号,允许为 null。
* @return 下一次保存的版本号。
*/
public static int next(Integer currentVersion) {
if (currentVersion == null || currentVersion < INITIAL_VERSION) {
return INITIAL_VERSION;
}
return currentVersion + 1;
}
}

75
zz-resource/db-scripts/botp-rule-version-script.sql

@ -0,0 +1,75 @@ @@ -0,0 +1,75 @@
-- ----------------------------
-- BOTP 规则版本快照表 + 版本号字段。
-- 转换规则(convert_rule)、反写规则(reverse_writing_rules)每保存一次,版本号 +1,
-- 并追加一条全量快照记录:快照以 JSON 内联存储规则主表及其全部子表分录。
-- 请在 tenant-admin 服务指向的默认数据库执行该脚本;脚本可重复执行。
-- ----------------------------
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 1. 补齐版本号字段(老环境的业务表可能没有 rule_version,MySQL 不支持 ADD COLUMN IF NOT EXISTS,
-- 这里用 information_schema 判断后动态执行,保证脚本可重复执行)
-- ----------------------------
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `convert_rule` ADD COLUMN `rule_version` int NOT NULL DEFAULT 1 COMMENT ''规则版本号''',
'DO 0')
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'convert_rule' AND column_name = 'rule_version');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `reverse_writing_rules` ADD COLUMN `rule_version` int DEFAULT 1 COMMENT ''规则版本号''',
'DO 0')
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'reverse_writing_rules' AND column_name = 'rule_version');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ----------------------------
-- 2. 历史数据版本号兜底:乐观锁需要有非空版本号做比较基准,空值/非法值统一按首版处理。
-- 只影响版本号为空或小于 1 的行。
-- ----------------------------
UPDATE `convert_rule` SET `rule_version` = 1 WHERE `rule_version` IS NULL OR `rule_version` < 1;
UPDATE `reverse_writing_rules` SET `rule_version` = 1 WHERE `rule_version` IS NULL OR `rule_version` < 1;
-- ----------------------------
-- 3. 转换规则版本快照表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `convert_rule_ver` (
`id` bigint NOT NULL COMMENT '主键',
`rule_id` bigint NOT NULL COMMENT '所属转换规则Id(convert_rule.id)',
`rule_version` int NOT NULL COMMENT '规则版本号',
`snapshot_json` longtext COLLATE utf8mb4_bin COMMENT '全量快照JSON:规则主表 + 字段映射分录 + 插件分录',
`remark` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '备注(新增保存/修改保存)',
`create_user_id` varchar(50) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '创建人登录名称',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_user_id` varchar(50) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '更新人登录名称',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_convert_rule_ver_rule_version` (`rule_id`, `rule_version`) USING BTREE,
KEY `idx_convert_rule_ver_rule_id` (`rule_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='转换规则版本快照';
-- ----------------------------
-- 4. 反写规则版本快照表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `reverse_writing_rules_ver` (
`id` bigint NOT NULL COMMENT '主键',
`rule_id` bigint NOT NULL COMMENT '所属反写规则Id(reverse_writing_rules.id)',
`rule_version` int NOT NULL COMMENT '规则版本号',
`snapshot_json` longtext COLLATE utf8mb4_bin COMMENT '全量快照JSON:规则主表 + 反写公式分录 + 业务规则分录',
`remark` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '备注(新增保存/修改保存)',
`create_user_id` varchar(50) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '创建人登录名称',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_user_id` varchar(50) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '更新人登录名称',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_reverse_writing_rules_ver_rule_version` (`rule_id`, `rule_version`) USING BTREE,
KEY `idx_reverse_writing_rules_ver_rule_id` (`rule_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='反写规则版本快照';
SET FOREIGN_KEY_CHECKS = 1;
Loading…
Cancel
Save