feat: 修改字段类型修改不生效的问题
All checks were successful
序列化结构检查 / serialization-schema-check (push) Has been skipped

This commit is contained in:
2026-07-15 13:50:14 +08:00
parent fd5aab5e7f
commit 27dfeb8a4c
7 changed files with 285 additions and 35 deletions

View File

@@ -22,29 +22,41 @@ public class SchemaDiffer {
public List<SchemaChange> diff(TypeSchema oldSchema, TypeSchema newSchema) { public List<SchemaChange> diff(TypeSchema oldSchema, TypeSchema newSchema) {
List<SchemaChange> changes = new ArrayList<>(); List<SchemaChange> changes = new ArrayList<>();
Map<String, JsonType> oldLeaves = leaves(oldSchema); Map<String, FieldSchema> oldAll = fieldsByPath(oldSchema);
Map<String, JsonType> newLeaves = leaves(newSchema); Map<String, FieldSchema> newAll = fieldsByPath(newSchema);
Map<String, FieldSchema> oldLeaves = leaves(oldSchema);
Map<String, FieldSchema> newLeaves = leaves(newSchema);
Set<String> 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<String> removed = new LinkedHashSet<>(oldLeaves.keySet()); Set<String> removed = new LinkedHashSet<>(oldLeaves.keySet());
removed.removeAll(newLeaves.keySet()); removed.removeAll(newLeaves.keySet());
removed.removeAll(typeChangedPaths);
Set<String> added = new LinkedHashSet<>(newLeaves.keySet()); Set<String> added = new LinkedHashSet<>(newLeaves.keySet());
added.removeAll(oldLeaves.keySet()); added.removeAll(oldLeaves.keySet());
added.removeAll(typeChangedPaths);
// 类型变更(同路径)
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);
}
}
}
// 路径迁移检测removed 的路径是某 added 路径的后缀) // 路径迁移检测removed 的路径是某 added 路径的后缀)
List<String[]> moves = new ArrayList<>(); List<String[]> moves = new ArrayList<>();
@@ -99,7 +111,7 @@ public class SchemaDiffer {
} }
SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED); SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED);
c.setFieldPath(r); c.setFieldPath(r);
c.setOldValue(oldLeaves.get(r).name()); c.setOldValue(typeLabel(oldLeaves.get(r)));
c.setMessage("删除字段 " + r); c.setMessage("删除字段 " + r);
changes.add(c); changes.add(c);
} }
@@ -111,7 +123,7 @@ public class SchemaDiffer {
} }
SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED); SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED);
c.setFieldPath(a); c.setFieldPath(a);
c.setNewValue(newLeaves.get(a).name()); c.setNewValue(typeLabel(newLeaves.get(a)));
c.setMessage("新增字段 " + a); c.setMessage("新增字段 " + a);
changes.add(c); changes.add(c);
} }
@@ -119,11 +131,104 @@ public class SchemaDiffer {
return changes; return changes;
} }
private Map<String, JsonType> leaves(TypeSchema schema) { private boolean isTypeChange(FieldSchema oldField, FieldSchema newField) {
Map<String, JsonType> result = new LinkedHashMap<>(); 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<String, FieldSchema> fieldsByPath(TypeSchema schema) {
Map<String, FieldSchema> result = new LinkedHashMap<>();
if (schema == null) {
return result;
}
for (FieldSchema f : schema.getFields().values()) {
result.put(f.getPath(), f);
}
return result;
}
private Map<String, FieldSchema> leaves(TypeSchema schema) {
Map<String, FieldSchema> result = new LinkedHashMap<>();
if (schema == null) {
return result;
}
for (FieldSchema f : schema.getFields().values()) { for (FieldSchema f : schema.getFields().values()) {
if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) { if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) {
result.put(f.getPath(), f.getJsonType()); result.put(f.getPath(), f);
} }
} }
return result; return result;

View File

@@ -128,11 +128,12 @@ public class ReportBuilder {
String oldJson = nvl(kc.getOldSkeletonJson()); String oldJson = nvl(kc.getOldSkeletonJson());
String newJson = nvl(kc.getNewSkeletonJson()); String newJson = nvl(kc.getNewSkeletonJson());
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails()); Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
Set<String> newHighlight = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails()); Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
Set<String> newOrange = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails());
String oldRendered = oldJson.isEmpty() String oldRendered = oldJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateOldForWecom(oldJson, oldHighlight) + ""; ? "" : "" + SkeletonAnnotator.annotateOldForWecom(oldJson, oldHighlight) + "";
String newRendered = newJson.isEmpty() String newRendered = newJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateNewForWecom(newJson, newHighlight) + ""; ? "" : "" + SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, newOrange) + "";
if (oldJson.isEmpty() && !newJson.isEmpty()) { if (oldJson.isEmpty() && !newJson.isEmpty()) {
sb.append(" > **value 新增为:** ").append(newRendered).append("\n\n"); sb.append(" > **value 新增为:** ").append(newRendered).append("\n\n");
} else if (!oldJson.isEmpty() && newJson.isEmpty()) { } else if (!oldJson.isEmpty() && newJson.isEmpty()) {

View File

@@ -15,13 +15,14 @@ import java.util.Set;
* <li>删除字段 → 旧骨架,橙色 {@code warning}</li> * <li>删除字段 → 旧骨架,橙色 {@code warning}</li>
* <li>新增字段 / 新增包装层 → 新骨架,绿色 {@code info}</li> * <li>新增字段 / 新增包装层 → 新骨架,绿色 {@code info}</li>
* <li>路径迁移 → 旧路径橙、新路径绿</li> * <li>路径迁移 → 旧路径橙、新路径绿</li>
* <li>类型变更 → 新旧骨架均为橙色 {@code warning}(提高识别度)</li>
* </ul> * </ul>
*/ */
final class SkeletonAnnotator { final class SkeletonAnnotator {
/** 企微橙:删除 / 旧侧变更 */ /** 企微橙:删除 / 类型变更 / 旧侧变更 */
static final String COLOR_REMOVE = "warning"; static final String COLOR_REMOVE = "warning";
/** 企微绿:新增 / 新侧变更 */ /** 企微绿:新增 / 新侧路径迁移 */
static final String COLOR_ADD = "info"; static final String COLOR_ADD = "info";
private static final String FONT_CLOSE = "</font>"; private static final String FONT_CLOSE = "</font>";
@@ -29,6 +30,7 @@ final class SkeletonAnnotator {
private SkeletonAnnotator() { private SkeletonAnnotator() {
} }
/** 旧骨架应橙色标注的路径:删除、路径迁移旧侧、类型变更。 */
static Set<String> pathsForOldSkeleton(List<SchemaChange> details) { static Set<String> pathsForOldSkeleton(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>(); Set<String> paths = new LinkedHashSet<>();
if (details == null) { if (details == null) {
@@ -38,7 +40,8 @@ final class SkeletonAnnotator {
if (c == null || c.getChangeType() == null) { if (c == null || c.getChangeType() == null) {
continue; continue;
} }
if (c.getChangeType() == ChangeType.FIELD_REMOVED) { if (c.getChangeType() == ChangeType.FIELD_REMOVED
|| c.getChangeType() == ChangeType.TYPE_CHANGED) {
addIfPresent(paths, c.getFieldPath()); addIfPresent(paths, c.getFieldPath());
} else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) { } else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) {
addIfPresent(paths, c.getOldValue()); addIfPresent(paths, c.getOldValue());
@@ -47,6 +50,7 @@ final class SkeletonAnnotator {
return paths; return paths;
} }
/** 新骨架应绿色标注的路径:新增、包装层、路径迁移新侧(不含类型变更)。 */
static Set<String> pathsForNewSkeleton(List<SchemaChange> details) { static Set<String> pathsForNewSkeleton(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>(); Set<String> paths = new LinkedHashSet<>();
if (details == null) { if (details == null) {
@@ -69,18 +73,40 @@ final class SkeletonAnnotator {
return paths; return paths;
} }
/** 新骨架应橙色标注的路径:类型变更(与旧侧同色,便于识别)。 */
static Set<String> pathsForTypeChanged(List<SchemaChange> details) {
Set<String> 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<String> highlightPaths) { static String annotateOldForWecom(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths, COLOR_REMOVE); return annotateForWecom(json, highlightPaths, COLOR_REMOVE);
} }
/** /**
* 标注新骨架改动字段(新增 → 绿色 * 标注新骨架:类型变更先着橙色,其余新增着绿色。
*/ */
static String annotateNewForWecom(String json, Set<String> highlightPaths) { static String annotateNewForWecom(String json, Set<String> greenPaths) {
return annotateForWecom(json, highlightPaths, COLOR_ADD); return annotateNewForWecom(json, greenPaths, null);
}
/**
* 标注新骨架改动字段(类型变更 → 橙色;新增 / 迁移新侧 → 绿色)。
*/
static String annotateNewForWecom(String json, Set<String> greenPaths, Set<String> orangePaths) {
String result = annotateForWecom(json, orangePaths, COLOR_REMOVE);
return annotateForWecom(result, greenPaths, COLOR_ADD);
} }
/** /**

View File

@@ -21,12 +21,11 @@ public class JavaSchemaExtractor {
private static final Set<String> STRING_TYPES = new HashSet<>(Arrays.asList( private static final Set<String> STRING_TYPES = new HashSet<>(Arrays.asList(
"String", "CharSequence", "char", "Character", "UUID", "String", "CharSequence", "char", "Character", "UUID",
"Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp", "Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp"));
"BigDecimal"));
private static final Set<String> NUMBER_TYPES = new HashSet<>(Arrays.asList( private static final Set<String> NUMBER_TYPES = new HashSet<>(Arrays.asList(
"int", "long", "short", "byte", "double", "float", "int", "long", "short", "byte", "double", "float",
"Integer", "Long", "Short", "Byte", "Double", "Float", "Integer", "Long", "Short", "Byte", "Double", "Float",
"Number", "BigInteger", "AtomicInteger", "AtomicLong")); "Number", "BigInteger", "BigDecimal", "AtomicInteger", "AtomicLong"));
private static final Set<String> BOOLEAN_TYPES = new HashSet<>(Arrays.asList( private static final Set<String> BOOLEAN_TYPES = new HashSet<>(Arrays.asList(
"boolean", "Boolean")); "boolean", "Boolean"));
private static final Set<String> COLLECTION_TYPES = new HashSet<>(Arrays.asList( private static final Set<String> COLLECTION_TYPES = new HashSet<>(Arrays.asList(

View File

@@ -51,6 +51,57 @@ class SchemaDifferTest {
assertEquals(1, changes.size()); assertEquals(1, changes.size());
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType()); assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
assertEquals(Severity.P0, changes.get(0).getSeverity()); 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 同为 STRINGjavaType 变化也必须检出(用户真实漏报场景)
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<SchemaChange> 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<SchemaChange> 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<SchemaChange> 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 @Test

View File

@@ -111,6 +111,57 @@ class ReportBuilderTest {
assertFalse(newMd.startsWith("`")); 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<SchemaChange> details = Collections.singletonList(typeChanged);
Set<String> oldPaths = SkeletonAnnotator.pathsForOldSkeleton(details);
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
Set<String> 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("<font color=\"warning\">\"amount\":0</font>"));
assertFalse(oldMd.contains("<font color=\"warning\">\"name\":\"\"</font>"));
assertTrue(newMd.contains("<font color=\"warning\">\"amount\":\"\"</font>"));
assertFalse(newMd.contains("<font color=\"info\">\"amount\""));
assertFalse(newMd.contains("<font color=\"warning\">\"name\":\"\"</font>"));
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("<font color=\"warning\">\"amount\":0</font>"));
assertTrue(newPart.contains("<font color=\"warning\">\"amount\":\"\"</font>"));
assertFalse(newPart.contains("<font color=\"info\">\"amount\""));
}
@Test @Test
void consoleContainsFieldDetailsAndWecomMarkdown() { void consoleContainsFieldDetailsAndWecomMarkdown() {
CheckReport report = new CheckReport(); CheckReport report = new CheckReport();

View File

@@ -81,4 +81,21 @@ class JavaSchemaExtractorTest {
assertTrue(AnnotationSupport.ignoredProperties(type).contains("a")); assertTrue(AnnotationSupport.ignoredProperties(type).contains("a"));
assertTrue(AnnotationSupport.ignoredProperties(type).contains("b")); 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());
}
} }