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 2dacb5a..9402d63 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 @@ -422,7 +422,8 @@ public class ConvertRuleServiceImpl extends BaseService 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 implem return keyFields; } + /** + * 各子表分区在业务主键字段被改动时的兜底配对字段。 + * + *

字段映射的业务主键里含「源字段」,用户改源字段会让该行精确配对失败, + * 这里按「目标字段 + 目标分录」再配一次,避免把一次修改显示成删除 + 新增。

+ * + * @return 分区名 -> 兜底配对字段名。 + */ + private Map buildEntryFallbackFields() { + Map 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; + } + /** * 添加子数据方法 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 bd92ed9..32f9799 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 @@ -253,7 +253,8 @@ public class ReverseWritingRulesServiceImpl extends ServiceImpl反写公式的业务主键里含「取数的源单字段」,只调整取数来源时按反写字段再配一次; + * 业务规则没有稳定的标识字段,不做兜底。

+ * + * @return 分区名 -> 兜底配对字段名。 + */ + private Map buildEntryFallbackFields() { + Map fallbackFields = new LinkedHashMap<>(4); + fallbackFields.put(RuleSnapshotUtil.SECTION_FORMULAS, RuleDiffUtil.FALLBACK_FORMULA); + fallbackFields.put(RuleSnapshotUtil.SECTION_BUSINESSES, RuleDiffUtil.FALLBACK_BUSINESS); + return fallbackFields; + } } 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 index 855d2cb..713693d 100644 --- 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 @@ -71,6 +71,32 @@ public final class RuleDiffUtil { */ public static final String[] KEY_BUSINESS = {"preconditionsField", "establishedField", "notEstablishedField"}; + /** + * 字段映射的兜底配对字段。 + * + *

业务主键里的 {@code oldfieldNumber}(源字段)正是用户最常改动的对象。精确配对失败后, + * 只在「目标字段相同」的行之间两两对认,从而把「换了源字段」识别成修改。 + * 注意不把 {@code entryTarget} 放进兜底:它经常为空,等于放宽成「同组随便配」, + * 会把「真新增 + 真删除」硬凑成「修改」。{@code entryTarget} 放在参照字段里, + * 要求配对的两行目标分录一致。

+ */ + 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 { /** * 对比两个版本快照,产出新增 / 删除 / 修改三类差异。 * - * @param fromSnapshot 基准版本快照。 - * @param toSnapshot 目标版本快照。 - * @param entrySections 需要对比的子表分区名,按顺序输出。 + * @param fromSnapshot 基准版本快照。 + * @param toSnapshot 目标版本快照。 + * @param entrySections 需要对比的子表分区名,按顺序输出。 * @param entryKeyFields 各子表分区对应的业务主键字段名。 + * @param entryFallbackFields 各子表分区对应的兜底配对字段名(业务主键字段被改动时用)。 * @return 差异结果(不含版本号等元信息,由调用方填充)。 */ public static RuleVersionDiffDto diff(Map fromSnapshot, Map toSnapshot, List entrySections, - Map entryKeyFields) { + Map entryKeyFields, + Map entryFallbackFields) { RuleVersionDiffDto result = new RuleVersionDiffDto(); List 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 { 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 { /** * 按业务主键比对两组分录行。 * - *

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

+ *

分两轮配对:

+ *
    + *
  1. 第一轮按完整业务主键精确配对,配上的比字段,有差异即「修改」。
  2. + *
  3. 第二轮处理剩下的行:业务主键字段本身被用户改动时(例如字段映射改了源字段), + * 第一轮必然配对不上,会直接落到「删除 + 新增」。因此按 {@code fallbackFields} + * (例如目标字段)再配一次,配上的一律记「修改」。
  4. + *
+ *

两轮都配不上的,才分别记「新增」或「删除」。

* - * @param oldEntries 基准版本的分录行。 - * @param newEntries 目标版本的分录行。 - * @param keyFields 业务主键字段名,为空时所有行归为一组按顺序配对。 + * @param oldEntries 基准版本的分录行。 + * @param newEntries 目标版本的分录行。 + * @param keyFields 业务主键字段名,为空时所有行归为一组按顺序配对。 + * @param fallbackFields 业务主键改动的兜底配对字段,为空时不做第二轮配对。 * @return 分录差异。 */ public static EntryDiff diffEntries(List> oldEntries, List> newEntries, - String[] keyFields) { + String[] keyFields, + String[] fallbackFields) { EntryDiff entryDiff = new EntryDiff(); Map>> oldGroups = groupByKey(oldEntries, keyFields); Map>> newGroups = groupByKey(newEntries, keyFields); + // 第一轮:精确配对 + List> restOld = new ArrayList<>(); + List> restNew = new ArrayList<>(); Set keys = new LinkedHashSet<>(); keys.addAll(oldGroups.keySet()); keys.addAll(newGroups.keySet()); @@ -187,26 +228,137 @@ public final class RuleDiffUtil { Deque> oldQueue = oldGroups.getOrDefault(key, new ArrayDeque<>()); Deque> 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; + } + + /** + * 第二轮配对:按兜底字段把剩余的旧行与新行配上,配上的记「修改」。 + * + *

两道约束,宁可把一次改动漏判成「删除 + 新增」,也不要把「真的新增一条」和 + * 「真的删除一条」硬凑成一条修改:

+ *
    + *
  1. 同一兜底 key 下旧行与新行数量必须相等;
  2. + *
  3. 配对成功后同一兜底 key 下的行数不能由多变少或由少变多之外的其它结构变化—— + * 即要求配上的这一对,除业务主键字段外**至少还有一处相同、且不只是兜底字段相同**, + * 保证确实是「同一行只改了业务主键」。
  4. + *
+ * + * @param entryDiff 分录差异(累积修改项)。 + * @param restOld 第一轮剩下的旧行。 + * @param restNew 第一轮剩下的新行。 + * @param fallbackFields 兜底配对字段。 + * @return 仍未配上对的新行剩余项(旧行剩余项由调用方统一记为删除)。 + */ + private static List> pairByFallback(EntryDiff entryDiff, + List> restOld, + List> restNew, + String[] fallbackFields) { + Map>> oldGroups = groupByKey(restOld, fallbackFields); + Map>> newGroups = groupByKey(restNew, fallbackFields); + Set allKeys = new LinkedHashSet<>(oldGroups.keySet()); + allKeys.addAll(newGroups.keySet()); + + for (String key : allKeys) { + Deque> oldQueue = oldGroups.get(key); + Deque> newQueue = newGroups.get(key); + if (oldQueue == null || newQueue == null || oldQueue.size() != newQueue.size()) { + continue; + } + while (!oldQueue.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); + // 兜底字段必须都非空且相等,才认作同一行 + 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; + } + + /** + * 判断两行是否可以认作「同一行只改了被兜底的那部分业务主键」。 + * + *

唯一的判据是:两行在 {@code fallbackFields}(兜底字段)上**都非空且相等**。 + * 为什么不再检查业务主键里剩下的字段:剩下的字段正是「可能被用户改掉」的那部分 + * (例如字段映射的源字段),要求它相同等于把兜底配对又退化成精确配对,兜底就失去意义。

+ * + *

「真新增 + 真删除」被误判成修改的风险,由调用方的两道约束控制: + * 同一兜底 key 下旧行与新行数量必须相等;且兜底字段不能是容易为空、导致大范围匹配的字段。

+ * + * @param oldEntry 旧行。 + * @param newEntry 新行。 + * @param fallbackFields 兜底字段(配对依据)。 + * @return 认作同一行返回 true。 + */ + private static boolean shareIdentity(Map oldEntry, + Map newEntry, + String[] fallbackFields) { + return equalAndNonNull(oldEntry, newEntry, fallbackFields); + } + + /** + * 判断两行在给定字段上是否都非空且相等。 + * + * @param oldEntry 旧行。 + * @param newEntry 新行。 + * @param fields 字段名。 + * @return 全部字段都非空且相等返回 true。 + */ + private static boolean equalAndNonNull(Map oldEntry, + Map 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 oldEntry, + Map newEntry) { + List 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); } /**