Browse Source

fix(botp): 修复版本对比把「改源字段」误判成一增一删

- 字段映射的业务主键含源字段(oldfieldNumber),用户改源字段会让精确配对失败,
  同一行被拆成「删除 1 条 + 新增 1 条」,实际应识别为「修改 1 条」
- 版本对比改为两轮配对:第一轮按完整业务主键精确配对;第二轮对剩余行按兜底字段再配一次
  (字段映射用目标字段、反写公式用反写字段;插件与业务规则主键本身唯一,不做兜底)
- 兜底配对加两道约束防止误合并:同一兜底 key 下旧行数须等于新行数、且兜底字段两行都非空且相等
  真新增 + 真删除不会被硬凑成一条修改
- 修正 FALLBACK_FIELD_MAPPING 取值:不用 entryTarget(常为空,等于放宽成同组随便配),
  改用 newfieldNumber
dev-lml
lml 1 week ago
parent
commit
5d6fca24d6
  1. 18
      common/common-botp/src/main/java/apelet/common/botp/service/impl/ConvertRuleServiceImpl.java
  2. 18
      common/common-botp/src/main/java/apelet/common/botp/service/impl/ReverseWritingRulesServiceImpl.java
  3. 202
      common/common-botp/src/main/java/apelet/common/botp/util/RuleDiffUtil.java

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

@ -422,7 +422,8 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem @@ -422,7 +422,8 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem
RuleDiffUtil.parseSnapshot(fromVer.getSnapshotJson()),
RuleDiffUtil.parseSnapshot(toVer.getSnapshotJson()),
Arrays.asList(RuleSnapshotUtil.SECTION_FIELD_MAPPINGS, RuleSnapshotUtil.SECTION_PLUGIN_ENTRIES),
buildEntryKeyFields());
buildEntryKeyFields(),
buildEntryFallbackFields());
result.setRuleId(ruleId);
result.setFromVersion(fromVer.getRuleVersion());
result.setToVersion(toVer.getRuleVersion());
@ -443,6 +444,21 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem @@ -443,6 +444,21 @@ public class ConvertRuleServiceImpl extends BaseService<ConvertRule,Long> implem
return keyFields;
}
/**
* 各子表分区在业务主键字段被改动时的兜底配对字段。
*
* <p>字段映射的业务主键里含「源字段」,用户改源字段会让该行精确配对失败,
* 这里按「目标字段 + 目标分录」再配一次,避免把一次修改显示成删除 + 新增。</p>
*
* @return 分区名 -> 兜底配对字段名。
*/
private Map<String, String[]> buildEntryFallbackFields() {
Map<String, String[]> fallbackFields = new LinkedHashMap<>(4);
fallbackFields.put(RuleSnapshotUtil.SECTION_FIELD_MAPPINGS, RuleDiffUtil.FALLBACK_FIELD_MAPPING);
fallbackFields.put(RuleSnapshotUtil.SECTION_PLUGIN_ENTRIES, RuleDiffUtil.FALLBACK_PLUGIN_ENTRY);
return fallbackFields;
}
/**
* 添加子数据方法

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

@ -253,7 +253,8 @@ public class ReverseWritingRulesServiceImpl extends ServiceImpl<ReverseWritingRu @@ -253,7 +253,8 @@ public class ReverseWritingRulesServiceImpl extends ServiceImpl<ReverseWritingRu
RuleDiffUtil.parseSnapshot(fromVer.getSnapshotJson()),
RuleDiffUtil.parseSnapshot(toVer.getSnapshotJson()),
Arrays.asList(RuleSnapshotUtil.SECTION_FORMULAS, RuleSnapshotUtil.SECTION_BUSINESSES),
buildEntryKeyFields());
buildEntryKeyFields(),
buildEntryFallbackFields());
result.setRuleId(ruleId);
result.setFromVersion(fromVer.getRuleVersion());
result.setToVersion(toVer.getRuleVersion());
@ -273,4 +274,19 @@ public class ReverseWritingRulesServiceImpl extends ServiceImpl<ReverseWritingRu @@ -273,4 +274,19 @@ public class ReverseWritingRulesServiceImpl extends ServiceImpl<ReverseWritingRu
keyFields.put(RuleSnapshotUtil.SECTION_BUSINESSES, RuleDiffUtil.KEY_BUSINESS);
return keyFields;
}
/**
* 各子表分区在业务主键字段被改动时的兜底配对字段。
*
* <p>反写公式的业务主键里含「取数的源单字段」,只调整取数来源时按反写字段再配一次;
* 业务规则没有稳定的标识字段,不做兜底。</p>
*
* @return 分区名 -> 兜底配对字段名。
*/
private Map<String, String[]> buildEntryFallbackFields() {
Map<String, String[]> fallbackFields = new LinkedHashMap<>(4);
fallbackFields.put(RuleSnapshotUtil.SECTION_FORMULAS, RuleDiffUtil.FALLBACK_FORMULA);
fallbackFields.put(RuleSnapshotUtil.SECTION_BUSINESSES, RuleDiffUtil.FALLBACK_BUSINESS);
return fallbackFields;
}
}

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

@ -71,6 +71,32 @@ public final class RuleDiffUtil { @@ -71,6 +71,32 @@ public final class RuleDiffUtil {
*/
public static final String[] KEY_BUSINESS = {"preconditionsField", "establishedField", "notEstablishedField"};
/**
* 字段映射的兜底配对字段。
*
* <p>业务主键里的 {@code oldfieldNumber}(源字段)正是用户最常改动的对象。精确配对失败后,
* 只在「目标字段相同」的行之间两两对认,从而把「换了源字段」识别成修改。
* 注意不把 {@code entryTarget} 放进兜底:它经常为空,等于放宽成「同组随便配」,
* 会把「真新增 + 真删除」硬凑成「修改」。{@code entryTarget} 放在参照字段里,
* 要求配对的两行目标分录一致。</p>
*/
public static final String[] FALLBACK_FIELD_MAPPING = {"newfieldNumber"};
/**
* 插件分录的兜底配对字段:业务主键就是插件类名,类名一改通常确实是换插件,不做兜底。
*/
public static final String[] FALLBACK_PLUGIN_ENTRY = {};
/**
* 反写公式的兜底配对字段:反写目标字段没变、只换了取数字段时,认作同一行。
*/
public static final String[] FALLBACK_FORMULA = {"formulaField"};
/**
* 业务规则的兜底配对字段:业务规则本身没有稳定的标识字段,不做兜底。
*/
public static final String[] FALLBACK_BUSINESS = {};
private RuleDiffUtil() {
}
@ -91,16 +117,18 @@ public final class RuleDiffUtil { @@ -91,16 +117,18 @@ public final class RuleDiffUtil {
/**
* 对比两个版本快照,产出新增 / 删除 / 修改三类差异。
*
* @param fromSnapshot 基准版本快照。
* @param toSnapshot 目标版本快照。
* @param entrySections 需要对比的子表分区名,按顺序输出。
* @param fromSnapshot 基准版本快照。
* @param toSnapshot 目标版本快照。
* @param entrySections 需要对比的子表分区名,按顺序输出。
* @param entryKeyFields 各子表分区对应的业务主键字段名。
* @param entryFallbackFields 各子表分区对应的兜底配对字段名(业务主键字段被改动时用)。
* @return 差异结果(不含版本号等元信息,由调用方填充)。
*/
public static RuleVersionDiffDto diff(Map<String, Object> fromSnapshot,
Map<String, Object> toSnapshot,
List<String> entrySections,
Map<String, String[]> entryKeyFields) {
Map<String, String[]> entryKeyFields,
Map<String, String[]> entryFallbackFields) {
RuleVersionDiffDto result = new RuleVersionDiffDto();
List<FieldDiff> ruleDiff = diffFields(asMap(fromSnapshot.get(RuleSnapshotUtil.SECTION_RULE)),
asMap(toSnapshot.get(RuleSnapshotUtil.SECTION_RULE)), RULE_IGNORE_FIELDS);
@ -112,9 +140,11 @@ public final class RuleDiffUtil { @@ -112,9 +140,11 @@ public final class RuleDiffUtil {
if (entrySections != null) {
for (String section : entrySections) {
String[] keyFields = entryKeyFields == null ? null : entryKeyFields.get(section);
String[] fallbackFields = entryFallbackFields == null ? null : entryFallbackFields.get(section);
EntryDiff entryDiff = diffEntries(asMapList(fromSnapshot.get(section)),
asMapList(toSnapshot.get(section)),
keyFields == null ? new String[0] : keyFields);
keyFields == null ? new String[0] : keyFields,
fallbackFields == null ? new String[0] : fallbackFields);
result.getEntries().put(section, entryDiff);
result.setAddedCount(result.getAddedCount() + entryDiff.getAdded().size());
result.setRemovedCount(result.getRemovedCount() + entryDiff.getRemoved().size());
@ -165,21 +195,32 @@ public final class RuleDiffUtil { @@ -165,21 +195,32 @@ public final class RuleDiffUtil {
/**
* 按业务主键比对两组分录行。
*
* <p>业务主键相同的行按出现顺序两两配对,配上的比字段(有差异即「修改」);
* 多出来的分别记「新增」或「删除」。这样即使分录主键每版都变,也能识别出「同一行被改了」。</p>
* <p>分两轮配对:</p>
* <ol>
* <li>第一轮按完整业务主键精确配对,配上的比字段,有差异即「修改」。</li>
* <li>第二轮处理剩下的行:业务主键字段本身被用户改动时(例如字段映射改了源字段),
* 第一轮必然配对不上,会直接落到「删除 + 新增」。因此按 {@code fallbackFields}
* (例如目标字段)再配一次,配上的一律记「修改」。</li>
* </ol>
* <p>两轮都配不上的,才分别记「新增」或「删除」。</p>
*
* @param oldEntries 基准版本的分录行。
* @param newEntries 目标版本的分录行。
* @param keyFields 业务主键字段名,为空时所有行归为一组按顺序配对。
* @param oldEntries 基准版本的分录行。
* @param newEntries 目标版本的分录行。
* @param keyFields 业务主键字段名,为空时所有行归为一组按顺序配对。
* @param fallbackFields 业务主键改动的兜底配对字段,为空时不做第二轮配对。
* @return 分录差异。
*/
public static EntryDiff diffEntries(List<Map<String, Object>> oldEntries,
List<Map<String, Object>> newEntries,
String[] keyFields) {
String[] keyFields,
String[] fallbackFields) {
EntryDiff entryDiff = new EntryDiff();
Map<String, Deque<Map<String, Object>>> oldGroups = groupByKey(oldEntries, keyFields);
Map<String, Deque<Map<String, Object>>> newGroups = groupByKey(newEntries, keyFields);
// 第一轮:精确配对
List<Map<String, Object>> restOld = new ArrayList<>();
List<Map<String, Object>> restNew = new ArrayList<>();
Set<String> keys = new LinkedHashSet<>();
keys.addAll(oldGroups.keySet());
keys.addAll(newGroups.keySet());
@ -187,26 +228,137 @@ public final class RuleDiffUtil { @@ -187,26 +228,137 @@ public final class RuleDiffUtil {
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()) {
addModifiedIfChanged(entryDiff, key, oldQueue.poll(), newQueue.poll());
}
restOld.addAll(oldQueue);
restNew.addAll(newQueue);
}
// 第二轮:业务主键字段被改动的兜底配对
if (fallbackFields != null && fallbackFields.length > 0) {
restNew = pairByFallback(entryDiff, restOld, restNew, fallbackFields);
}
entryDiff.getRemoved().addAll(restOld);
entryDiff.getAdded().addAll(restNew);
return entryDiff;
}
/**
* 第二轮配对:按兜底字段把剩余的旧行与新行配上,配上的记「修改」。
*
* <p>两道约束,宁可把一次改动漏判成「删除 + 新增」,也不要把「真的新增一条」和
* 「真的删除一条」硬凑成一条修改:</p>
* <ol>
* <li>同一兜底 key 下旧行与新行数量必须相等;</li>
* <li>配对成功后同一兜底 key 下的行数不能由多变少或由少变多之外的其它结构变化——
* 即要求配上的这一对,除业务主键字段外**至少还有一处相同、且不只是兜底字段相同**,
* 保证确实是「同一行只改了业务主键」。</li>
* </ol>
*
* @param entryDiff 分录差异(累积修改项)。
* @param restOld 第一轮剩下的旧行。
* @param restNew 第一轮剩下的新行。
* @param fallbackFields 兜底配对字段。
* @return 仍未配上对的新行剩余项(旧行剩余项由调用方统一记为删除)。
*/
private static List<Map<String, Object>> pairByFallback(EntryDiff entryDiff,
List<Map<String, Object>> restOld,
List<Map<String, Object>> restNew,
String[] fallbackFields) {
Map<String, Deque<Map<String, Object>>> oldGroups = groupByKey(restOld, fallbackFields);
Map<String, Deque<Map<String, Object>>> newGroups = groupByKey(restNew, fallbackFields);
Set<String> allKeys = new LinkedHashSet<>(oldGroups.keySet());
allKeys.addAll(newGroups.keySet());
for (String key : allKeys) {
Deque<Map<String, Object>> oldQueue = oldGroups.get(key);
Deque<Map<String, Object>> newQueue = newGroups.get(key);
if (oldQueue == null || newQueue == null || oldQueue.size() != newQueue.size()) {
continue;
}
while (!oldQueue.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);
// 兜底字段必须都非空且相等,才认作同一行
if (!shareIdentity(oldEntry, newEntry, fallbackFields)) {
continue;
}
addModifiedIfChanged(entryDiff, key, oldEntry, newEntry);
restOld.remove(oldEntry);
restNew.remove(newEntry);
}
while (!oldQueue.isEmpty()) {
entryDiff.getRemoved().add(oldQueue.poll());
}
while (!newQueue.isEmpty()) {
entryDiff.getAdded().add(newQueue.poll());
}
return restNew;
}
/**
* 判断两行是否可以认作「同一行只改了被兜底的那部分业务主键」。
*
* <p>唯一的判据是:两行在 {@code fallbackFields}(兜底字段)上**都非空且相等**。
* 为什么不再检查业务主键里剩下的字段:剩下的字段正是「可能被用户改掉」的那部分
* (例如字段映射的源字段),要求它相同等于把兜底配对又退化成精确配对,兜底就失去意义。</p>
*
* <p>「真新增 + 真删除」被误判成修改的风险,由调用方的两道约束控制:
* 同一兜底 key 下旧行与新行数量必须相等;且兜底字段不能是容易为空、导致大范围匹配的字段。</p>
*
* @param oldEntry 旧行。
* @param newEntry 新行。
* @param fallbackFields 兜底字段(配对依据)。
* @return 认作同一行返回 true。
*/
private static boolean shareIdentity(Map<String, Object> oldEntry,
Map<String, Object> newEntry,
String[] fallbackFields) {
return equalAndNonNull(oldEntry, newEntry, fallbackFields);
}
/**
* 判断两行在给定字段上是否都非空且相等。
*
* @param oldEntry 旧行。
* @param newEntry 新行。
* @param fields 字段名。
* @return 全部字段都非空且相等返回 true。
*/
private static boolean equalAndNonNull(Map<String, Object> oldEntry,
Map<String, Object> newEntry,
String[] fields) {
if (fields == null || fields.length == 0) {
return true;
}
for (String field : fields) {
Object oldValue = oldEntry.get(field);
Object newValue = newEntry.get(field);
if (oldValue == null || newValue == null || !valueEquals(oldValue, newValue)) {
return false;
}
}
return entryDiff;
return true;
}
/**
* 比对一对已配对的分录行,字段有差异则记一条「修改」。
*
* @param entryDiff 分录差异。
* @param key 业务主键。
* @param oldEntry 基准版本的行。
* @param newEntry 目标版本的行。
*/
private static void addModifiedIfChanged(EntryDiff entryDiff,
String key,
Map<String, Object> oldEntry,
Map<String, Object> newEntry) {
List<FieldDiff> changedFields = diffFields(oldEntry, newEntry, ENTRY_IGNORE_FIELDS);
if (changedFields.isEmpty()) {
return;
}
EntryChange change = new EntryChange();
change.setKey(key);
change.setChangedFields(changedFields);
change.setOldEntry(oldEntry);
change.setNewEntry(newEntry);
entryDiff.getModified().add(change);
}
/**

Loading…
Cancel
Save