Browse Source
- 保存即版本 +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
18 changed files with 1412 additions and 41 deletions
@ -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; |
||||
} |
||||
} |
||||
@ -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> { |
||||
} |
||||
@ -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> { |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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<>(); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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…
Reference in new issue