From 5698a8b72f2c51a5726d83c7b8c5229522b04e23 Mon Sep 17 00:00:00 2001 From: lml <1297555865@qq.com> Date: Tue, 20 Oct 2026 13:55:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(botp):=20=E8=BD=AC=E6=8D=A2/=E5=8F=8D?= =?UTF-8?q?=E5=86=99=E8=A7=84=E5=88=99=E7=89=88=E6=9C=AC=E5=8F=B7=E9=80=92?= =?UTF-8?q?=E5=A2=9E=E4=B8=8E=E5=85=A8=E9=87=8F=E5=BF=AB=E7=85=A7=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=89=88=E6=9C=AC=E5=8E=86=E5=8F=B2=E4=B8=8E?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保存即版本 +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、建两张快照表,可重复执行 --- .../common/botp/config/MybatisPlusConfig.java | 27 ++ .../botp/controller/ConvertRuleController.java | 21 ++ .../controller/ReverseWritingRulesController.java | 60 ++-- .../common/botp/dao/ConvertRuleVerMapper.java | 11 + .../botp/dao/ReverseWritingRulesVerMapper.java | 11 + .../apelet/common/botp/dto/RuleVersionDiffDto.java | 147 +++++++++ .../java/apelet/common/botp/model/ConvertRule.java | 1 + .../apelet/common/botp/model/ConvertRuleVer.java | 54 ++++ .../common/botp/model/ReverseWritingRules.java | 2 + .../common/botp/model/ReverseWritingRulesVer.java | 54 ++++ .../common/botp/service/IConvertRuleService.java | 28 ++ .../botp/service/IReverseWritingRulesService.java | 40 ++- .../botp/service/impl/ConvertRuleServiceImpl.java | 171 ++++++++++- .../impl/ReverseWritingRulesServiceImpl.java | 212 +++++++++++++ .../java/apelet/common/botp/util/RuleDiffUtil.java | 329 +++++++++++++++++++++ .../apelet/common/botp/util/RuleSnapshotUtil.java | 160 ++++++++++ .../apelet/common/botp/util/RuleVersionUtil.java | 50 ++++ .../db-scripts/botp-rule-version-script.sql | 75 +++++ 18 files changed, 1412 insertions(+), 41 deletions(-) create mode 100644 common/common-botp/src/main/java/apelet/common/botp/config/MybatisPlusConfig.java create mode 100644 common/common-botp/src/main/java/apelet/common/botp/dao/ConvertRuleVerMapper.java create mode 100644 common/common-botp/src/main/java/apelet/common/botp/dao/ReverseWritingRulesVerMapper.java create mode 100644 common/common-botp/src/main/java/apelet/common/botp/dto/RuleVersionDiffDto.java create mode 100644 common/common-botp/src/main/java/apelet/common/botp/model/ConvertRuleVer.java create mode 100644 common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRulesVer.java create mode 100644 common/common-botp/src/main/java/apelet/common/botp/util/RuleDiffUtil.java create mode 100644 common/common-botp/src/main/java/apelet/common/botp/util/RuleSnapshotUtil.java create mode 100644 common/common-botp/src/main/java/apelet/common/botp/util/RuleVersionUtil.java create mode 100644 zz-resource/db-scripts/botp-rule-version-script.sql diff --git a/common/common-botp/src/main/java/apelet/common/botp/config/MybatisPlusConfig.java b/common/common-botp/src/main/java/apelet/common/botp/config/MybatisPlusConfig.java new file mode 100644 index 0000000..17b6ecc --- /dev/null +++ b/common/common-botp/src/main/java/apelet/common/botp/config/MybatisPlusConfig.java @@ -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 插件配置。 + * + *

注册乐观锁插件:实体上标注了 {@code @Version} 的字段(转换规则 / 反写规则的 ruleVersion) + * 在执行 {@code updateById} 时会自动带上 {@code WHERE version = 旧值} 并写入 {@code version + 1}, + * 更新影响行数为 0 即代表版本已被别人改过。

+ * + *

该插件只对带 {@code @Version} 字段的实体生效,因此不会改变其它模块 / 其它实体的更新行为。

+ * + */ +@Configuration +public class MybatisPlusConfig { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); + return interceptor; + } +} diff --git a/common/common-botp/src/main/java/apelet/common/botp/controller/ConvertRuleController.java b/common/common-botp/src/main/java/apelet/common/botp/controller/ConvertRuleController.java index 33db53c..2138c30 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/controller/ConvertRuleController.java +++ b/common/common-botp/src/main/java/apelet/common/botp/controller/ConvertRuleController.java @@ -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)); + } + /** * 根据源单据查询关联关系 * diff --git a/common/common-botp/src/main/java/apelet/common/botp/controller/ReverseWritingRulesController.java b/common/common-botp/src/main/java/apelet/common/botp/controller/ReverseWritingRulesController.java index 103c8aa..902ecbd 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/controller/ReverseWritingRulesController.java +++ b/common/common-botp/src/main/java/apelet/common/botp/controller/ReverseWritingRulesController.java @@ -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 { @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().eq(ReverseWritingFormula::getRulesId, reverseWritingRules.getId())); - businessService.remove(new LambdaQueryWrapper().eq(ReverseWritingBusiness::getRulesId, reverseWritingRules.getId())); - } else { - reverseWritingRules.setCreateTime(new Date()); - boolean save = rulesService.save(reverseWritingRules); - if (!save) { - throw new MyRuntimeException("反写规则配置数据保存失败!"); - } - } - //反写公式 - List formulaList = dto.getFormulaList(); - if (ObjectUtil.isNotEmpty(formulaList)) { - formulaList.forEach(f -> f.setRulesId(reverseWritingRules.getId())); - formulaService.saveBatch(formulaList); - } - // 业务规则 - List 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 { 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)); + } + } diff --git a/common/common-botp/src/main/java/apelet/common/botp/dao/ConvertRuleVerMapper.java b/common/common-botp/src/main/java/apelet/common/botp/dao/ConvertRuleVerMapper.java new file mode 100644 index 0000000..b4be72b --- /dev/null +++ b/common/common-botp/src/main/java/apelet/common/botp/dao/ConvertRuleVerMapper.java @@ -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 { +} diff --git a/common/common-botp/src/main/java/apelet/common/botp/dao/ReverseWritingRulesVerMapper.java b/common/common-botp/src/main/java/apelet/common/botp/dao/ReverseWritingRulesVerMapper.java new file mode 100644 index 0000000..876579c --- /dev/null +++ b/common/common-botp/src/main/java/apelet/common/botp/dao/ReverseWritingRulesVerMapper.java @@ -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 { +} diff --git a/common/common-botp/src/main/java/apelet/common/botp/dto/RuleVersionDiffDto.java b/common/common-botp/src/main/java/apelet/common/botp/dto/RuleVersionDiffDto.java new file mode 100644 index 0000000..5b598ed --- /dev/null +++ b/common/common-botp/src/main/java/apelet/common/botp/dto/RuleVersionDiffDto.java @@ -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; + +/** + * 规则版本对比结果。 + * + *

对比粒度是「字段 / 分录行」,只给出新增、删除、修改三类结论,不做逐字 diff: + * 规则主表按字段比对;子表分录按业务主键配对后按字段比对,配对不上的分别算新增或删除。

+ * + * @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 ruleDiff = new ArrayList<>(); + + /** + * 子表分录差异,key 为快照分区名(fieldMappings / pluginEntries / formulas / businesses)。 + */ + @Schema(description = "子表分录差异") + private Map 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> added = new ArrayList<>(); + + @Schema(description = "删除的分录行") + private List> removed = new ArrayList<>(); + + @Schema(description = "修改的分录行") + private List 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 changedFields = new ArrayList<>(); + + @Schema(description = "基准版本的行数据") + private Map oldEntry; + + @Schema(description = "目标版本的行数据") + private Map newEntry; + } +} diff --git a/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRule.java b/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRule.java index 062ab83..3a98a3a 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRule.java +++ b/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRule.java @@ -177,6 +177,7 @@ public class ConvertRule extends BaseModel { @Excel(name = "规则版本", width = 15) @Schema(description = "规则版本") + @Version private Integer ruleVersion; @RelationDict( diff --git a/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRuleVer.java b/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRuleVer.java new file mode 100644 index 0000000..7873ff2 --- /dev/null +++ b/common/common-botp/src/main/java/apelet/common/botp/model/ConvertRuleVer.java @@ -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; + +/** + *

+ * 转换规则版本快照 + *

+ * + *

转换规则每保存一次(新增或修改),版本号 +1,并在这里追加一条全量快照记录。 + * 快照以 JSON 内联存储规则主表及其全部子表分录(字段映射、插件分录), + * 用于历史版本追溯与差异比对。

+ * + */ +@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; +} diff --git a/common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRules.java b/common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRules.java index 910d4f9..ed5bae5 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRules.java +++ b/common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRules.java @@ -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 { /** * 规则版本 */ + @Version private Integer ruleVersion; } diff --git a/common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRulesVer.java b/common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRulesVer.java new file mode 100644 index 0000000..d02b5f3 --- /dev/null +++ b/common/common-botp/src/main/java/apelet/common/botp/model/ReverseWritingRulesVer.java @@ -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; + +/** + *

+ * 反写规则版本快照 + *

+ * + *

反写规则每保存一次(新增或修改),版本号 +1,并在这里追加一条全量快照记录。 + * 快照以 JSON 内联存储规则主表及其全部子表分录(反写公式分录、业务规则分录), + * 用于历史版本追溯与差异比对。

+ * + */ +@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; +} diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/IConvertRuleService.java b/common/common-botp/src/main/java/apelet/common/botp/service/IConvertRuleService.java index ce975c6..dac7552 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/service/IConvertRuleService.java +++ b/common/common-botp/src/main/java/apelet/common/botp/service/IConvertRuleService.java @@ -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 { List getConvertPluginEntry(Long id); List getConvertFieldmappingEntry(Long id); + /** + * 查询转换规则的版本快照列表(按版本号倒序,不含快照 JSON 内容)。 + * + * @param ruleId 转换规则Id。 + * @return 版本快照列表。 + */ + List 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); + diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/IReverseWritingRulesService.java b/common/common-botp/src/main/java/apelet/common/botp/service/IReverseWritingRulesService.java index 9525315..054f769 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/service/IReverseWritingRulesService.java +++ b/common/common-botp/src/main/java/apelet/common/botp/service/IReverseWritingRulesService.java @@ -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 listAll(); List> getTableType(); + + /** + * 保存反写规则主表及其全部子表分录(公式分录、业务规则分录)。 + * + *

每保存一次,规则版本号 +1,并落一份包含规则主表与全部子表分录的全量快照。

+ * + * @param dto 反写规则主表 + 子表分录。 + * @return 保存后的规则主表(含最新版本号)。 + */ + ReverseWritingRules saveAll(ReverseWritingRulesDto dto); + + /** + * 查询反写规则的版本快照列表(按版本号倒序,不含快照 JSON 内容)。 + * + * @param ruleId 反写规则Id。 + * @return 版本快照列表。 + */ + List 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); } diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/impl/ConvertRuleServiceImpl.java b/common/common-botp/src/main/java/apelet/common/botp/service/impl/ConvertRuleServiceImpl.java index a1c93b4..2dacb5a 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/service/impl/ConvertRuleServiceImpl.java +++ b/common/common-botp/src/main/java/apelet/common/botp/service/impl/ConvertRuleServiceImpl.java @@ -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; 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 implem private ConvertPluginEntryMapper convertPluginEntryMapper; @Autowired + private ConvertRuleVerMapper convertRuleVerMapper; + + @Autowired private OnlFormHeadMapper onlFormHeadMapper; @Autowired @@ -111,13 +118,22 @@ public class ConvertRuleServiceImpl extends BaseService 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 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 implem return false; } //设置分录数据基本信息,添加 - return addChild(convertRule,id); + boolean addChild = addChild(convertRule,id); + //落全量快照 + saveSnapshot(id, convertRule.getRuleVersion(), RuleSnapshotUtil.REMARK_CREATE); + return addChild; + } + + /** + * 计算本次保存的乐观锁比较基准版本号。 + * + *

优先使用前端打开页面时带回的 {@code ruleVersion},这样可以发现「在你编辑期间别人也保存过」; + * 前端没有回传版本号时退化为读取库里的当前版本,虽然发现不了编辑期内的覆盖, + * 但并发同时保存时仍然只有一个请求能通过 {@code WHERE rule_version = 基准版本},不会写坏版本号。

+ * + * @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()); + } + + /** + * 落一份转换规则的全量版本快照。 + * + *

快照内容取自落库后的真实数据(主表 + 字段映射分录 + 插件分录),以 JSON 内联存储, + * 保证同一版本号下快照与业务表数据完全一致。

+ * + * @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 snapshot = RuleSnapshotUtil.newSnapshot(rule); + //字段映射分录 + snapshot.put(RuleSnapshotUtil.SECTION_FIELD_MAPPINGS, RuleSnapshotUtil.toColumnMapList( + convertFieldmappingEntryMapper.selectList(new LambdaQueryWrapper() + .eq(ConvertFieldmappingEntry::getParentId, ruleId) + .orderByAsc(ConvertFieldmappingEntry::getSerialNumber)))); + //插件分录 + snapshot.put(RuleSnapshotUtil.SECTION_PLUGIN_ENTRIES, RuleSnapshotUtil.toColumnMapList( + convertPluginEntryMapper.selectList(new LambdaQueryWrapper() + .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 implem return convertFieldmappingEntryService.getConvertFieldmappingEntryRelation(id); } + @Override + public List getVersionList(Long ruleId) { + if (ruleId == null) { + return new ArrayList<>(); + } + //列表不返回快照正文,避免一次拉回大量 JSON + return convertRuleVerMapper.selectList(new LambdaQueryWrapper() + .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 queryWrapper = new LambdaQueryWrapper() + .eq(ConvertRuleVer::getRuleId, ruleId); + if (ruleVersion == null) { + //未指定版本号时取最新版本 + queryWrapper.orderByDesc(ConvertRuleVer::getRuleVersion).last("LIMIT 1"); + List 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 buildEntryKeyFields() { + Map 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; + } + /** * 添加子数据方法 diff --git a/common/common-botp/src/main/java/apelet/common/botp/service/impl/ReverseWritingRulesServiceImpl.java b/common/common-botp/src/main/java/apelet/common/botp/service/impl/ReverseWritingRulesServiceImpl.java index f569208..bd92ed9 100644 --- a/common/common-botp/src/main/java/apelet/common/botp/service/impl/ReverseWritingRulesServiceImpl.java +++ b/common/common-botp/src/main/java/apelet/common/botp/service/impl/ReverseWritingRulesServiceImpl.java @@ -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; 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().eq(ReverseWritingFormula::getRulesId, reverseWritingRules.getId())); + businessService.remove(new LambdaQueryWrapper().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 formulaList = dto.getFormulaList(); + if (ObjectUtil.isNotEmpty(formulaList)) { + formulaList.forEach(f -> f.setRulesId(reverseWritingRules.getId())); + formulaService.saveBatch(formulaList); + } + // 业务规则 + List businessList = dto.getBusinessList(); + if (ObjectUtil.isNotEmpty(businessList)) { + businessList.forEach(f -> f.setRulesId(reverseWritingRules.getId())); + businessService.saveBatch(businessList); + } + // 落全量快照 + saveSnapshot(reverseWritingRules.getId(), snapshotVersion, remark); + return reverseWritingRules; + } + + /** + * 计算本次保存的乐观锁比较基准版本号。 + * + *

优先使用前端打开页面时带回的 {@code ruleVersion},这样可以发现「在你编辑期间别人也保存过」; + * 前端没有回传版本号时退化为使用库里的当前版本,虽然发现不了编辑期内的覆盖, + * 但并发同时保存时仍然只有一个请求能通过 {@code WHERE rule_version = 基准版本},不会写坏版本号。

+ * + * @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); + } + + /** + * 落一份反写规则的全量版本快照。 + * + *

快照内容取自落库后的真实数据(主表 + 反写公式分录 + 业务规则分录),以 JSON 内联存储, + * 保证同一版本号下快照与业务表数据完全一致。

+ * + * @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 snapshot = RuleSnapshotUtil.newSnapshot(rule); + //反写公式分录 + snapshot.put(RuleSnapshotUtil.SECTION_FORMULAS, RuleSnapshotUtil.toColumnMapList( + formulaService.list(new LambdaQueryWrapper() + .eq(ReverseWritingFormula::getRulesId, ruleId) + .orderByAsc(ReverseWritingFormula::getExecOrder)))); + //业务规则分录 + snapshot.put(RuleSnapshotUtil.SECTION_BUSINESSES, RuleSnapshotUtil.toColumnMapList( + businessService.list(new LambdaQueryWrapper() + .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 getVersionList(Long ruleId) { + if (ruleId == null) { + return new ArrayList<>(); + } + //列表不返回快照正文,避免一次拉回大量 JSON + return reverseWritingRulesVerMapper.selectList(new LambdaQueryWrapper() + .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 queryWrapper = new LambdaQueryWrapper() + .eq(ReverseWritingRulesVer::getRuleId, ruleId); + if (ruleVersion == null) { + //未指定版本号时取最新版本 + queryWrapper.orderByDesc(ReverseWritingRulesVer::getRuleVersion).last("LIMIT 1"); + List 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 buildEntryKeyFields() { + Map keyFields = new LinkedHashMap<>(4); + keyFields.put(RuleSnapshotUtil.SECTION_FORMULAS, RuleDiffUtil.KEY_FORMULA); + keyFields.put(RuleSnapshotUtil.SECTION_BUSINESSES, RuleDiffUtil.KEY_BUSINESS); + return keyFields; + } } diff --git a/common/common-botp/src/main/java/apelet/common/botp/util/RuleDiffUtil.java b/common/common-botp/src/main/java/apelet/common/botp/util/RuleDiffUtil.java new file mode 100644 index 0000000..855d2cb --- /dev/null +++ b/common/common-botp/src/main/java/apelet/common/botp/util/RuleDiffUtil.java @@ -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; + +/** + * 规则版本快照对比工具类。 + * + *

对比粒度是「字段 / 分录行」,只产出新增、删除、修改三类结论,不做逐字 diff。

+ * + *

两个约定:

+ *
    + *
  1. 规则主表与子表分录里的审计字段(创建人/创建时间/更新人/更新时间)以及主键、外键不参与对比, + * 因为它们每次保存都会变,展示出来只会淹没真正的配置变更。
  2. + *
  3. 转换规则的子表分录每次保存都是「先删后插」,主键每版都不同,所以子表分录按**业务主键** + * (如字段映射的「源字段+目标字段+目标分录」)配对,而不是按主键配对。
  4. + *
+ * + * @author admin + * @since 2024-05-17 + */ +public final class RuleDiffUtil { + + /** + * 规则主表对比时忽略的字段:审计字段、以及版本号本身(版本号在结果的 fromVersion / toVersion 里已单独给出)。 + */ + private static final Set RULE_IGNORE_FIELDS = Collections.unmodifiableSet(new HashSet<>( + Arrays.asList("createUserId", "createTime", "updateUserId", "updateTime", "ruleVersion"))); + + /** + * 子表分录对比时忽略的字段:主键、外键、审计字段、逻辑删除标记。 + */ + private static final Set 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 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 fromSnapshot, + Map toSnapshot, + List entrySections, + Map entryKeyFields) { + RuleVersionDiffDto result = new RuleVersionDiffDto(); + List 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 diffFields(Map oldMap, + Map newMap, + Set ignoreFields) { + List diffs = new ArrayList<>(); + Set 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; + } + + /** + * 按业务主键比对两组分录行。 + * + *

业务主键相同的行按出现顺序两两配对,配上的比字段(有差异即「修改」); + * 多出来的分别记「新增」或「删除」。这样即使分录主键每版都变,也能识别出「同一行被改了」。

+ * + * @param oldEntries 基准版本的分录行。 + * @param newEntries 目标版本的分录行。 + * @param keyFields 业务主键字段名,为空时所有行归为一组按顺序配对。 + * @return 分录差异。 + */ + public static EntryDiff diffEntries(List> oldEntries, + List> newEntries, + String[] keyFields) { + EntryDiff entryDiff = new EntryDiff(); + Map>> oldGroups = groupByKey(oldEntries, keyFields); + Map>> newGroups = groupByKey(newEntries, keyFields); + + Set keys = new LinkedHashSet<>(); + keys.addAll(oldGroups.keySet()); + keys.addAll(newGroups.keySet()); + for (String key : keys) { + Deque> oldQueue = oldGroups.getOrDefault(key, new ArrayDeque<>()); + Deque> newQueue = newGroups.getOrDefault(key, new ArrayDeque<>()); + while (!oldQueue.isEmpty() && !newQueue.isEmpty()) { + Map oldEntry = oldQueue.poll(); + Map newEntry = newQueue.poll(); + List 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>> groupByKey(List> entries, + String[] keyFields) { + Map>> groups = new LinkedHashMap<>(); + if (entries == null) { + return groups; + } + for (Map 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 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 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 asMap(Object value) { + if (value instanceof Map) { + return (Map) value; + } + return new LinkedHashMap<>(); + } + + /** + * 把快照分区节点转成「Map 列表」。 + * + * @param value 分区节点。 + * @return Map 列表,类型不符时返回空列表。 + */ + @SuppressWarnings("unchecked") + private static List> asMapList(Object value) { + if (value instanceof List) { + List> result = new ArrayList<>(); + for (Object item : (List) value) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + return new ArrayList<>(); + } +} diff --git a/common/common-botp/src/main/java/apelet/common/botp/util/RuleSnapshotUtil.java b/common/common-botp/src/main/java/apelet/common/botp/util/RuleSnapshotUtil.java new file mode 100644 index 0000000..47ccca8 --- /dev/null +++ b/common/common-botp/src/main/java/apelet/common/botp/util/RuleSnapshotUtil.java @@ -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; + +/** + * 规则版本快照工具类。 + * + *

把「规则主表 + 全部子表分录」组装成一个可以直接以 JSON 内联落库的 Map 结构: + * 每个实体只保留真正持久化到数据库的字段(即排除 {@code @TableField(exist = false)} 的关联/字典字段), + * 这样快照里不会出现 upRouteInterfaceMap、deletedFlagMap 这类运行期临时数据,也不会因为 + * 某个实体新增了 getter 而污染快照内容。

+ * + */ +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 newSnapshot(Object rule) { + Map snapshot = new LinkedHashMap<>(8); + snapshot.put(SECTION_RULE, toColumnMap(rule)); + return snapshot; + } + + /** + * 把实体对象转换成「字段名 -> 字段值」的有序 Map,仅包含持久化字段。 + * + * @param entity 实体对象,允许为 null。 + * @return 字段 Map;入参为 null 时返回 null。 + */ + public static Map toColumnMap(Object entity) { + if (entity == null) { + return null; + } + Map 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> toColumnMapList(List entities) { + List> 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 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); + } + } +} diff --git a/common/common-botp/src/main/java/apelet/common/botp/util/RuleVersionUtil.java b/common/common-botp/src/main/java/apelet/common/botp/util/RuleVersionUtil.java new file mode 100644 index 0000000..a0d8a40 --- /dev/null +++ b/common/common-botp/src/main/java/apelet/common/botp/util/RuleVersionUtil.java @@ -0,0 +1,50 @@ +package apelet.common.botp.util; + +/** + * 规则版本号工具类。 + * + *

转换规则(convert_rule)与反写规则(reverse_writing_rules)共用同一套版本语义: + * 每保存一次,版本号 +1,并落一份全量快照。

+ * + */ +public final class RuleVersionUtil { + + /** + * 规则首次保存时的版本号。 + */ + public static final int INITIAL_VERSION = 1; + + private RuleVersionUtil() { + } + + /** + * 把库里的版本号规范化成一个可直接使用的「当前版本号」。 + * + *

历史数据里可能存在版本号为空(早期未维护该字段)或非法值的规则,统一按首版处理。

+ * + * @param version 库里的版本号,允许为 null。 + * @return 有效的当前版本号。 + */ + public static int current(Integer version) { + if (version == null || version < INITIAL_VERSION) { + return INITIAL_VERSION; + } + return version; + } + + /** + * 计算下一次保存应使用的版本号。 + * + *

历史数据里可能存在版本号为空(早期未维护该字段)的规则,此时按首次保存处理,直接给 + * {@link #INITIAL_VERSION};正常情况一律在原有版本号上 +1。

+ * + * @param currentVersion 当前版本号,允许为 null。 + * @return 下一次保存的版本号。 + */ + public static int next(Integer currentVersion) { + if (currentVersion == null || currentVersion < INITIAL_VERSION) { + return INITIAL_VERSION; + } + return currentVersion + 1; + } +} diff --git a/zz-resource/db-scripts/botp-rule-version-script.sql b/zz-resource/db-scripts/botp-rule-version-script.sql new file mode 100644 index 0000000..dd5e8d5 --- /dev/null +++ b/zz-resource/db-scripts/botp-rule-version-script.sql @@ -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;