From 27dfeb8a4c915da93028e4792f8bb2fbce2620a0 Mon Sep 17 00:00:00 2001 From: dongzi Date: Wed, 15 Jul 2026 13:50:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E4=BF=AE=E6=94=B9=E4=B8=8D=E7=94=9F=E6=95=88?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codechecker/cache/diff/SchemaDiffer.java | 151 +++++++++++++++--- .../cache/report/ReportBuilder.java | 5 +- .../cache/report/SkeletonAnnotator.java | 40 ++++- .../cache/schema/JavaSchemaExtractor.java | 5 +- .../cache/diff/SchemaDifferTest.java | 51 ++++++ .../cache/report/ReportBuilderTest.java | 51 ++++++ .../cache/schema/JavaSchemaExtractorTest.java | 17 ++ 7 files changed, 285 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/codechecker/cache/diff/SchemaDiffer.java b/src/main/java/com/codechecker/cache/diff/SchemaDiffer.java index 7ef88a1..ebe1c0e 100644 --- a/src/main/java/com/codechecker/cache/diff/SchemaDiffer.java +++ b/src/main/java/com/codechecker/cache/diff/SchemaDiffer.java @@ -22,29 +22,41 @@ public class SchemaDiffer { public List diff(TypeSchema oldSchema, TypeSchema newSchema) { List changes = new ArrayList<>(); - Map oldLeaves = leaves(oldSchema); - Map newLeaves = leaves(newSchema); + Map oldAll = fieldsByPath(oldSchema); + Map newAll = fieldsByPath(newSchema); + + Map oldLeaves = leaves(oldSchema); + Map newLeaves = leaves(newSchema); + + Set typeChangedPaths = new LinkedHashSet<>(); + + // 类型变更:同路径下 JsonType 或 Java 类型任一变化(覆盖 BigDecimal→String 等同桶变更) + for (String path : oldAll.keySet()) { + FieldSchema oldField = oldAll.get(path); + FieldSchema newField = newAll.get(path); + if (newField == null) { + continue; + } + if (!isTypeChange(oldField, newField)) { + continue; + } + typeChangedPaths.add(path); + String oldLabel = typeLabel(oldField); + String newLabel = typeLabel(newField); + SchemaChange c = new SchemaChange(ChangeType.TYPE_CHANGED); + c.setFieldPath(path); + c.setOldValue(oldLabel); + c.setNewValue(newLabel); + c.setMessage("字段 " + path + " 类型由 " + oldLabel + " 变为 " + newLabel); + changes.add(c); + } Set removed = new LinkedHashSet<>(oldLeaves.keySet()); removed.removeAll(newLeaves.keySet()); + removed.removeAll(typeChangedPaths); Set added = new LinkedHashSet<>(newLeaves.keySet()); added.removeAll(oldLeaves.keySet()); - - // 类型变更(同路径) - for (String path : oldLeaves.keySet()) { - if (newLeaves.containsKey(path)) { - JsonType oldType = oldLeaves.get(path); - JsonType newType = newLeaves.get(path); - if (oldType != newType) { - SchemaChange c = new SchemaChange(ChangeType.TYPE_CHANGED); - c.setFieldPath(path); - c.setOldValue(oldType.name()); - c.setNewValue(newType.name()); - c.setMessage("字段 " + path + " 类型由 " + oldType + " 变为 " + newType); - changes.add(c); - } - } - } + added.removeAll(typeChangedPaths); // 路径迁移检测(removed 的路径是某 added 路径的后缀) List moves = new ArrayList<>(); @@ -99,7 +111,7 @@ public class SchemaDiffer { } SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED); c.setFieldPath(r); - c.setOldValue(oldLeaves.get(r).name()); + c.setOldValue(typeLabel(oldLeaves.get(r))); c.setMessage("删除字段 " + r); changes.add(c); } @@ -111,7 +123,7 @@ public class SchemaDiffer { } SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED); c.setFieldPath(a); - c.setNewValue(newLeaves.get(a).name()); + c.setNewValue(typeLabel(newLeaves.get(a))); c.setMessage("新增字段 " + a); changes.add(c); } @@ -119,11 +131,104 @@ public class SchemaDiffer { return changes; } - private Map leaves(TypeSchema schema) { - Map result = new LinkedHashMap<>(); + private boolean isTypeChange(FieldSchema oldField, FieldSchema newField) { + if (oldField.getJsonType() != newField.getJsonType()) { + return true; + } + String oldJava = normalizeJavaType(oldField.getJavaType()); + String newJava = normalizeJavaType(newField.getJavaType()); + if (oldJava.isEmpty() || newJava.isEmpty()) { + return false; + } + return !oldJava.equals(newJava); + } + + /** + * 展示用类型标签:优先 Java 简单名,并附带 JsonType(便于识别同桶变更)。 + */ + private String typeLabel(FieldSchema field) { + if (field == null) { + return "?"; + } + String java = simpleJavaType(field.getJavaType()); + JsonType json = field.getJsonType(); + if (java.isEmpty() || "?".equals(java)) { + return json == null ? "?" : json.name(); + } + if (json == null) { + return java; + } + return java + "/" + json.name(); + } + + /** + * 归一化后用于相等比较:去掉泛型与包名,基本类型与包装类型视为同一序列化类型。 + */ + static String normalizeJavaType(String javaType) { + String simple = simpleJavaType(javaType); + if (simple.isEmpty() || "?".equals(simple)) { + return ""; + } + switch (simple) { + case "int": + return "Integer"; + case "long": + return "Long"; + case "short": + return "Short"; + case "byte": + return "Byte"; + case "double": + return "Double"; + case "float": + return "Float"; + case "boolean": + return "Boolean"; + case "char": + return "Character"; + default: + return simple; + } + } + + private static String simpleJavaType(String javaType) { + if (javaType == null) { + return ""; + } + String s = javaType.trim(); + if (s.isEmpty()) { + return ""; + } + int lt = s.indexOf('<'); + if (lt > 0) { + s = s.substring(0, lt).trim(); + } + int dot = s.lastIndexOf('.'); + if (dot >= 0 && dot < s.length() - 1) { + s = s.substring(dot + 1); + } + return s; + } + + private Map fieldsByPath(TypeSchema schema) { + Map result = new LinkedHashMap<>(); + if (schema == null) { + return result; + } + for (FieldSchema f : schema.getFields().values()) { + result.put(f.getPath(), f); + } + return result; + } + + private Map leaves(TypeSchema schema) { + Map result = new LinkedHashMap<>(); + if (schema == null) { + return result; + } for (FieldSchema f : schema.getFields().values()) { if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) { - result.put(f.getPath(), f.getJsonType()); + result.put(f.getPath(), f); } } return result; diff --git a/src/main/java/com/codechecker/cache/report/ReportBuilder.java b/src/main/java/com/codechecker/cache/report/ReportBuilder.java index eedfff8..1dc21c7 100644 --- a/src/main/java/com/codechecker/cache/report/ReportBuilder.java +++ b/src/main/java/com/codechecker/cache/report/ReportBuilder.java @@ -128,11 +128,12 @@ public class ReportBuilder { String oldJson = nvl(kc.getOldSkeletonJson()); String newJson = nvl(kc.getNewSkeletonJson()); Set oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails()); - Set newHighlight = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails()); + Set newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails()); + Set newOrange = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails()); String oldRendered = oldJson.isEmpty() ? "" : "“" + SkeletonAnnotator.annotateOldForWecom(oldJson, oldHighlight) + "”"; String newRendered = newJson.isEmpty() - ? "" : "“" + SkeletonAnnotator.annotateNewForWecom(newJson, newHighlight) + "”"; + ? "" : "“" + SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, newOrange) + "”"; if (oldJson.isEmpty() && !newJson.isEmpty()) { sb.append(" > **value 新增为:** ").append(newRendered).append("\n\n"); } else if (!oldJson.isEmpty() && newJson.isEmpty()) { diff --git a/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java b/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java index 59aff21..4ccb811 100644 --- a/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java +++ b/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java @@ -15,13 +15,14 @@ import java.util.Set; *
  • 删除字段 → 旧骨架,橙色 {@code warning}
  • *
  • 新增字段 / 新增包装层 → 新骨架,绿色 {@code info}
  • *
  • 路径迁移 → 旧路径橙、新路径绿
  • + *
  • 类型变更 → 新旧骨架均为橙色 {@code warning}(提高识别度)
  • * */ final class SkeletonAnnotator { - /** 企微橙:删除 / 旧侧变更 */ + /** 企微橙:删除 / 类型变更 / 旧侧变更 */ static final String COLOR_REMOVE = "warning"; - /** 企微绿:新增 / 新侧变更 */ + /** 企微绿:新增 / 新侧路径迁移 */ static final String COLOR_ADD = "info"; private static final String FONT_CLOSE = ""; @@ -29,6 +30,7 @@ final class SkeletonAnnotator { private SkeletonAnnotator() { } + /** 旧骨架应橙色标注的路径:删除、路径迁移旧侧、类型变更。 */ static Set pathsForOldSkeleton(List details) { Set paths = new LinkedHashSet<>(); if (details == null) { @@ -38,7 +40,8 @@ final class SkeletonAnnotator { if (c == null || c.getChangeType() == null) { continue; } - if (c.getChangeType() == ChangeType.FIELD_REMOVED) { + if (c.getChangeType() == ChangeType.FIELD_REMOVED + || c.getChangeType() == ChangeType.TYPE_CHANGED) { addIfPresent(paths, c.getFieldPath()); } else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) { addIfPresent(paths, c.getOldValue()); @@ -47,6 +50,7 @@ final class SkeletonAnnotator { return paths; } + /** 新骨架应绿色标注的路径:新增、包装层、路径迁移新侧(不含类型变更)。 */ static Set pathsForNewSkeleton(List details) { Set paths = new LinkedHashSet<>(); if (details == null) { @@ -69,18 +73,40 @@ final class SkeletonAnnotator { return paths; } + /** 新骨架应橙色标注的路径:类型变更(与旧侧同色,便于识别)。 */ + static Set pathsForTypeChanged(List details) { + Set paths = new LinkedHashSet<>(); + if (details == null) { + return paths; + } + for (SchemaChange c : details) { + if (c != null && c.getChangeType() == ChangeType.TYPE_CHANGED) { + addIfPresent(paths, c.getFieldPath()); + } + } + return paths; + } + /** - * 标注旧骨架改动字段(删除 → 橙色)。 + * 标注旧骨架改动字段(删除 / 类型变更 → 橙色)。 */ static String annotateOldForWecom(String json, Set highlightPaths) { return annotateForWecom(json, highlightPaths, COLOR_REMOVE); } /** - * 标注新骨架改动字段(新增 → 绿色)。 + * 标注新骨架:类型变更先着橙色,其余新增着绿色。 */ - static String annotateNewForWecom(String json, Set highlightPaths) { - return annotateForWecom(json, highlightPaths, COLOR_ADD); + static String annotateNewForWecom(String json, Set greenPaths) { + return annotateNewForWecom(json, greenPaths, null); + } + + /** + * 标注新骨架改动字段(类型变更 → 橙色;新增 / 迁移新侧 → 绿色)。 + */ + static String annotateNewForWecom(String json, Set greenPaths, Set orangePaths) { + String result = annotateForWecom(json, orangePaths, COLOR_REMOVE); + return annotateForWecom(result, greenPaths, COLOR_ADD); } /** diff --git a/src/main/java/com/codechecker/cache/schema/JavaSchemaExtractor.java b/src/main/java/com/codechecker/cache/schema/JavaSchemaExtractor.java index 5dbbed8..44f74e9 100644 --- a/src/main/java/com/codechecker/cache/schema/JavaSchemaExtractor.java +++ b/src/main/java/com/codechecker/cache/schema/JavaSchemaExtractor.java @@ -21,12 +21,11 @@ public class JavaSchemaExtractor { private static final Set STRING_TYPES = new HashSet<>(Arrays.asList( "String", "CharSequence", "char", "Character", "UUID", - "Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp", - "BigDecimal")); + "Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp")); private static final Set NUMBER_TYPES = new HashSet<>(Arrays.asList( "int", "long", "short", "byte", "double", "float", "Integer", "Long", "Short", "Byte", "Double", "Float", - "Number", "BigInteger", "AtomicInteger", "AtomicLong")); + "Number", "BigInteger", "BigDecimal", "AtomicInteger", "AtomicLong")); private static final Set BOOLEAN_TYPES = new HashSet<>(Arrays.asList( "boolean", "Boolean")); private static final Set COLLECTION_TYPES = new HashSet<>(Arrays.asList( diff --git a/src/test/java/com/codechecker/cache/diff/SchemaDifferTest.java b/src/test/java/com/codechecker/cache/diff/SchemaDifferTest.java index e6112aa..74c7e81 100644 --- a/src/test/java/com/codechecker/cache/diff/SchemaDifferTest.java +++ b/src/test/java/com/codechecker/cache/diff/SchemaDifferTest.java @@ -51,6 +51,57 @@ class SchemaDifferTest { assertEquals(1, changes.size()); assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType()); assertEquals(Severity.P0, changes.get(0).getSeverity()); + assertTrue(changes.get(0).getMessage().contains("String")); + assertTrue(changes.get(0).getMessage().contains("Integer")); + } + + @Test + void detectsBigDecimalToStringEvenWhenBothWereStringBucketHistorically() { + // 即便 JsonType 同为 STRING,javaType 变化也必须检出(用户真实漏报场景) + TypeSchema oldS = new TypeSchema("A"); + oldS.add(new FieldSchema("amount", JsonType.STRING, "BigDecimal")); + TypeSchema newS = new TypeSchema("A"); + newS.add(new FieldSchema("amount", JsonType.STRING, "String")); + + List changes = new SchemaDiffer().diff(oldS, newS); + assertEquals(1, changes.size()); + assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType()); + assertEquals("amount", changes.get(0).getFieldPath()); + assertTrue(changes.get(0).getOldValue().contains("BigDecimal")); + assertTrue(changes.get(0).getNewValue().contains("String")); + } + + @Test + void detectsBigDecimalToStringViaJsonTypeAndJavaType() { + TypeSchema oldS = new TypeSchema("A"); + oldS.add(new FieldSchema("amount", JsonType.NUMBER, "BigDecimal")); + TypeSchema newS = new TypeSchema("A"); + newS.add(new FieldSchema("amount", JsonType.STRING, "String")); + + List changes = new SchemaDiffer().diff(oldS, newS); + assertEquals(1, changes.size()); + assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType()); + } + + @Test + void detectsIntegerToLongWithinNumberBucket() { + TypeSchema oldS = new TypeSchema("A"); + oldS.add(new FieldSchema("id", JsonType.NUMBER, "Integer")); + TypeSchema newS = new TypeSchema("A"); + newS.add(new FieldSchema("id", JsonType.NUMBER, "Long")); + + List changes = new SchemaDiffer().diff(oldS, newS); + assertEquals(1, changes.size()); + assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType()); + } + + @Test + void noChangeWhenPrimitiveAndWrapperEquivalent() { + TypeSchema a = new TypeSchema("A"); + a.add(new FieldSchema("n", JsonType.NUMBER, "int")); + TypeSchema b = new TypeSchema("A"); + b.add(new FieldSchema("n", JsonType.NUMBER, "Integer")); + assertTrue(new SchemaDiffer().diff(a, b).isEmpty()); } @Test diff --git a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java index 41ebfee..35b8a1c 100644 --- a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java +++ b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java @@ -111,6 +111,57 @@ class ReportBuilderTest { assertFalse(newMd.startsWith("`")); } + @Test + void typeChangedHighlightedOrangeInBothSkeletons() { + SchemaChange typeChanged = new SchemaChange(ChangeType.TYPE_CHANGED); + typeChanged.setFieldPath("amount"); + typeChanged.setOldValue("BigDecimal/NUMBER"); + typeChanged.setNewValue("String/STRING"); + typeChanged.setMessage("字段 amount 类型由 BigDecimal/NUMBER 变为 String/STRING"); + + String oldJson = "{\"amount\":0,\"name\":\"\"}"; + String newJson = "{\"amount\":\"\",\"name\":\"\"}"; + List details = Collections.singletonList(typeChanged); + + Set oldPaths = SkeletonAnnotator.pathsForOldSkeleton(details); + Set newGreen = SkeletonAnnotator.pathsForNewSkeleton(details); + Set newOrange = SkeletonAnnotator.pathsForTypeChanged(details); + + assertEquals(Collections.singleton("amount"), oldPaths); + assertTrue(newGreen.isEmpty()); + assertEquals(Collections.singleton("amount"), newOrange); + + String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldPaths); + String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, newOrange); + + assertTrue(oldMd.contains("\"amount\":0")); + assertFalse(oldMd.contains("\"name\":\"\"")); + assertTrue(newMd.contains("\"amount\":\"\"")); + assertFalse(newMd.contains("\"amount\"")); + assertFalse(newMd.contains("\"name\":\"\"")); + + CheckReport report = baseReport(); + KeyStructureChange key = new KeyStructureChange(); + key.setKeyPattern("cache:amount"); + key.setWriteLocation("Demo#put:1"); + key.setValueType("AmountVo"); + key.setOldSkeletonJson(oldJson); + key.setNewSkeletonJson(newJson); + key.getFieldDetails().add(typeChanged); + report.getKeyChanges().add(key); + + String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report); + int oldSection = md.indexOf("> **value值由:**"); + int newSection = md.indexOf("> **变更为:**"); + assertTrue(oldSection >= 0); + assertTrue(newSection > oldSection); + String oldPart = md.substring(oldSection, newSection); + String newPart = md.substring(newSection); + assertTrue(oldPart.contains("\"amount\":0")); + assertTrue(newPart.contains("\"amount\":\"\"")); + assertFalse(newPart.contains("\"amount\"")); + } + @Test void consoleContainsFieldDetailsAndWecomMarkdown() { CheckReport report = new CheckReport(); diff --git a/src/test/java/com/codechecker/cache/schema/JavaSchemaExtractorTest.java b/src/test/java/com/codechecker/cache/schema/JavaSchemaExtractorTest.java index f885ad8..21f74b4 100644 --- a/src/test/java/com/codechecker/cache/schema/JavaSchemaExtractorTest.java +++ b/src/test/java/com/codechecker/cache/schema/JavaSchemaExtractorTest.java @@ -81,4 +81,21 @@ class JavaSchemaExtractorTest { assertTrue(AnnotationSupport.ignoredProperties(type).contains("a")); assertTrue(AnnotationSupport.ignoredProperties(type).contains("b")); } + + @Test + void bigDecimalMappedAsNumberNotString() { + String source = "" + + "package demo;\n" + + "import java.math.BigDecimal;\n" + + "public class MoneyVo {\n" + + " private BigDecimal amount;\n" + + " private String currency;\n" + + "}\n"; + SourceIndex index = new SourceIndex(); + index.addSource(source); + TypeSchema schema = new JavaSchemaExtractor(index, 8).extract("demo.MoneyVo"); + assertEquals(JsonType.NUMBER, schema.getFields().get("amount").getJsonType()); + assertEquals("BigDecimal", schema.getFields().get("amount").getJavaType()); + assertEquals(JsonType.STRING, schema.getFields().get("currency").getJsonType()); + } }