diff --git a/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java b/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java index 287b86f..6f14b2c 100644 --- a/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java +++ b/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java @@ -135,19 +135,18 @@ public class SchemaCheckAnalyzer { oldSchema.getConfidence(), newSchema.getConfidence()); enrich(changes, nw, confidence); allChanges.addAll(changes); - mergeKeyChange(keyChanges, nw.getResolvedKeyPattern(), changes, + mergeKeyChange(keyChanges, nw, changes, skeletonRenderer.render(oldSchema, protectedPaths(changes), SkeletonJsonRenderer.DEFAULT_MAX_LEN), skeletonRenderer.render(newSchema, protectedPaths(changes), SkeletonJsonRenderer.DEFAULT_MAX_LEN)); } else if (fileChanged) { SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_ADDED); - c.setKeyPattern(nw.getResolvedKeyPattern()); - c.setWriteLocation(nw.location()); - c.setMessage("新增缓存写入点,value 类型: " + shortType(nw.getResolvedValueType())); + fillFromWritePoint(c, nw); + c.setMessage("新增缓存写入点,value 类型: " + displayType(nw)); allChanges.add(c); TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray()); - mergeKeyChange(keyChanges, nw.getResolvedKeyPattern(), + mergeKeyChange(keyChanges, nw, Collections.singletonList(c), "", skeletonRenderer.render(newSchema, protectedPaths( @@ -161,12 +160,11 @@ public class SchemaCheckAnalyzer { if (!newSigs.contains(ow.signature()) && !isKeyIgnored(ow.getResolvedKeyPattern()) && !isWriterIgnored(ow)) { SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_REMOVED); - c.setKeyPattern(ow.getResolvedKeyPattern()); - c.setWriteLocation(ow.location()); - c.setMessage("删除缓存写入点,原 value 类型: " + shortType(ow.getResolvedValueType())); + fillFromWritePoint(c, ow); + c.setMessage("删除缓存写入点,原 value 类型: " + displayType(ow)); allChanges.add(c); TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray()); - mergeKeyChange(keyChanges, ow.getResolvedKeyPattern(), + mergeKeyChange(keyChanges, ow, Collections.singletonList(c), skeletonRenderer.render(oldSchema, protectedPaths( Collections.singletonList(c)), @@ -178,19 +176,31 @@ public class SchemaCheckAnalyzer { } List finalChanges = postProcess(allChanges); - // 按最终字段明细重新对齐 key 摘要(过滤被 suppress/dedup 掉的) Map finalKeys = filterKeyChanges(keyChanges, finalChanges); return buildReport(oldSha, newSha, finalChanges, finalKeys); } - private void mergeKeyChange(Map keyChanges, String keyPattern, + private void mergeKeyChange(Map keyChanges, WritePoint wp, List changes, String oldSkeleton, String newSkeleton) { - String key = keyPattern == null ? "" : keyPattern; - KeyStructureChange kc = keyChanges.computeIfAbsent(key, k -> { + String aggKey = aggregationKey(wp); + KeyStructureChange kc = keyChanges.computeIfAbsent(aggKey, k -> { KeyStructureChange n = new KeyStructureChange(); - n.setKeyPattern(k); + n.setKeyPattern(wp.getResolvedKeyPattern()); + n.setKeyExpression(wp.getKeyExpression()); + n.setWriteLocation(wp.location()); + n.setValueType(displayType(wp)); + n.setKeyUnresolved(isUnresolvedKey(wp.getResolvedKeyPattern())); return n; }); + if (kc.getWriteLocation() == null || kc.getWriteLocation().isEmpty()) { + kc.setWriteLocation(wp.location()); + } + if (kc.getValueType() == null || kc.getValueType().isEmpty()) { + kc.setValueType(displayType(wp)); + } + if (kc.getKeyExpression() == null || kc.getKeyExpression().isEmpty()) { + kc.setKeyExpression(wp.getKeyExpression()); + } kc.getFieldDetails().addAll(changes); for (SchemaChange c : changes) { kc.raiseSeverity(c.getSeverity()); @@ -209,6 +219,41 @@ public class SchemaCheckAnalyzer { } } + /** 已解析 key 按模式聚合;未解析按「位置+表达式」拆分,避免串单。 */ + private String aggregationKey(WritePoint wp) { + String pattern = wp.getResolvedKeyPattern(); + if (!isUnresolvedKey(pattern)) { + return pattern == null ? "" : pattern; + } + return "unknown|" + nvl(wp.location()) + "|" + nvl(wp.getKeyExpression()); + } + + private boolean isUnresolvedKey(String keyPattern) { + return keyPattern == null + || keyPattern.isEmpty() + || "unknown-key".equals(keyPattern) + || "".equals(keyPattern); + } + + private String displayType(WritePoint wp) { + String simple = shortType(wp.getResolvedValueType()); + if (wp.isRootArray()) { + return "List<" + simple + ">"; + } + return simple; + } + + private void fillFromWritePoint(SchemaChange c, WritePoint wp) { + c.setKeyPattern(wp.getResolvedKeyPattern()); + c.setKeyExpression(wp.getKeyExpression()); + c.setWriteLocation(wp.location()); + c.setValueType(displayType(wp)); + } + + private String nvl(String s) { + return s == null ? "" : s; + } + private Set protectedPaths(List changes) { Set paths = new LinkedHashSet<>(); for (SchemaChange c : changes) { @@ -230,48 +275,64 @@ public class SchemaCheckAnalyzer { private Map filterKeyChanges( Map keyChanges, List finalChanges) { - Set liveKeys = new HashSet<>(); + Set liveDedup = new HashSet<>(); for (SchemaChange c : finalChanges) { - liveKeys.add(c.getKeyPattern() == null ? "" : c.getKeyPattern()); + liveDedup.add(changeDedupKey(c)); } Map result = new LinkedHashMap<>(); for (Map.Entry e : keyChanges.entrySet()) { - if (liveKeys.contains(e.getKey())) { - KeyStructureChange kc = e.getValue(); - // 仅保留最终明细中仍存在的字段变更 - List retained = new ArrayList<>(); - Set finalDedup = new HashSet<>(); - for (SchemaChange c : finalChanges) { - String k = c.getKeyPattern() == null ? "" : c.getKeyPattern(); - if (!k.equals(e.getKey())) { - continue; - } - String dedupKey = c.getChangeType() + "|" + c.getKeyPattern() + "|" - + c.getWriteLocation() + "|" + c.getFieldPath(); - if (finalDedup.add(dedupKey)) { - retained.add(c); - } + KeyStructureChange kc = e.getValue(); + List retained = new ArrayList<>(); + Set seen = new HashSet<>(); + for (SchemaChange c : kc.getFieldDetails()) { + String dk = changeDedupKey(c); + if (liveDedup.contains(dk) && seen.add(dk)) { + retained.add(c); } - kc.getFieldDetails().clear(); - kc.getFieldDetails().addAll(retained); - Severity max = Severity.P2; - for (SchemaChange c : retained) { - if (c.getSeverity() != null && c.getSeverity().ordinal() < max.ordinal()) { - max = c.getSeverity(); - } - } - kc.setSeverity(max); - result.put(e.getKey(), kc); } + if (retained.isEmpty()) { + continue; + } + kc.getFieldDetails().clear(); + kc.getFieldDetails().addAll(retained); + // 从明细回填通用展示字段(若聚合时未带上) + for (SchemaChange c : retained) { + if ((kc.getWriteLocation() == null || kc.getWriteLocation().isEmpty()) + && c.getWriteLocation() != null) { + kc.setWriteLocation(c.getWriteLocation()); + } + if ((kc.getValueType() == null || kc.getValueType().isEmpty()) + && c.getValueType() != null) { + kc.setValueType(c.getValueType()); + } + if ((kc.getKeyExpression() == null || kc.getKeyExpression().isEmpty()) + && c.getKeyExpression() != null) { + kc.setKeyExpression(c.getKeyExpression()); + } + } + Severity max = Severity.P2; + for (SchemaChange c : retained) { + if (c.getSeverity() != null && c.getSeverity().ordinal() < max.ordinal()) { + max = c.getSeverity(); + } + } + kc.setSeverity(max); + result.put(e.getKey(), kc); } return result; } + private String changeDedupKey(SchemaChange c) { + return c.getChangeType() + "|" + c.getKeyPattern() + "|" + + c.getWriteLocation() + "|" + c.getFieldPath(); + } + private void enrich(List changes, WritePoint wp, double confidence) { boolean lowConfidence = confidence < config.getDetection().getMinConfidence(); + String type = displayType(wp); for (SchemaChange c : changes) { - c.setKeyPattern(wp.getResolvedKeyPattern()); - c.setWriteLocation(wp.location()); + fillFromWritePoint(c, wp); + c.setValueType(type); if (lowConfidence) { c.setSeverity(Severity.P2); c.setMessage(c.getMessage() + "(低置信度,建议人工确认)"); diff --git a/src/main/java/com/codechecker/cache/diff/SchemaChange.java b/src/main/java/com/codechecker/cache/diff/SchemaChange.java index f1540fe..8528115 100644 --- a/src/main/java/com/codechecker/cache/diff/SchemaChange.java +++ b/src/main/java/com/codechecker/cache/diff/SchemaChange.java @@ -8,7 +8,9 @@ public class SchemaChange { private Severity severity; private ChangeType changeType; private String keyPattern; + private String keyExpression; private String writeLocation; + private String valueType; private String fieldPath; private String oldValue; private String newValue; @@ -43,6 +45,14 @@ public class SchemaChange { this.keyPattern = keyPattern; } + public String getKeyExpression() { + return keyExpression; + } + + public void setKeyExpression(String keyExpression) { + this.keyExpression = keyExpression; + } + public String getWriteLocation() { return writeLocation; } @@ -51,6 +61,14 @@ public class SchemaChange { this.writeLocation = writeLocation; } + public String getValueType() { + return valueType; + } + + public void setValueType(String valueType) { + this.valueType = valueType; + } + public String getFieldPath() { return fieldPath; } diff --git a/src/main/java/com/codechecker/cache/report/KeyStructureChange.java b/src/main/java/com/codechecker/cache/report/KeyStructureChange.java index 3301a05..3a598fc 100644 --- a/src/main/java/com/codechecker/cache/report/KeyStructureChange.java +++ b/src/main/java/com/codechecker/cache/report/KeyStructureChange.java @@ -7,11 +7,19 @@ import java.util.ArrayList; import java.util.List; /** - * 按 Redis key 聚合后的结构变更摘要(用于企微骨架展示)。 + * 按 Redis key(或未知 key 时的写入点)聚合后的结构变更摘要。 */ public class KeyStructureChange { + /** 解析到的 key 模式;未知时为 unknown-key */ private String keyPattern; + /** 源码中的 key 表达式,如 req.getKey() */ + private String keyExpression; + /** 写入位置 Class#method:line */ + private String writeLocation; + /** 展示用 value 类型,如 List<ClockInExportVo> */ + private String valueType; + private boolean keyUnresolved; private String oldSkeletonJson; private String newSkeletonJson; private Severity severity = Severity.P2; @@ -25,6 +33,38 @@ public class KeyStructureChange { this.keyPattern = keyPattern; } + public String getKeyExpression() { + return keyExpression; + } + + public void setKeyExpression(String keyExpression) { + this.keyExpression = keyExpression; + } + + public String getWriteLocation() { + return writeLocation; + } + + public void setWriteLocation(String writeLocation) { + this.writeLocation = writeLocation; + } + + public String getValueType() { + return valueType; + } + + public void setValueType(String valueType) { + this.valueType = valueType; + } + + public boolean isKeyUnresolved() { + return keyUnresolved; + } + + public void setKeyUnresolved(boolean keyUnresolved) { + this.keyUnresolved = keyUnresolved; + } + public String getOldSkeletonJson() { return oldSkeletonJson; } @@ -58,8 +98,18 @@ public class KeyStructureChange { return; } if (severity == null || candidate.ordinal() < severity.ordinal()) { - // P0=0, P1=1, P2=2 → ordinal 更小更严重 severity = candidate; } } + + /** 通知里 Key 行展示文本:未解析优先用表达式。 */ + public String displayKey() { + if (keyUnresolved && keyExpression != null && !keyExpression.trim().isEmpty()) { + return keyExpression.trim(); + } + if (keyPattern != null && !keyPattern.trim().isEmpty()) { + return keyPattern.trim(); + } + return keyExpression != null ? keyExpression : "unknown-key"; + } } diff --git a/src/main/java/com/codechecker/cache/report/ReportBuilder.java b/src/main/java/com/codechecker/cache/report/ReportBuilder.java index c5bbe65..aaea7f5 100644 --- a/src/main/java/com/codechecker/cache/report/ReportBuilder.java +++ b/src/main/java/com/codechecker/cache/report/ReportBuilder.java @@ -14,7 +14,8 @@ import java.util.Set; /** * 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。 *
    - *
  • 企微:按 key 展示序列化骨架变更,不含位置/字段明细,不分 P0/P1/P2
  • + *
  • 企微:按 key 展示位置/类型/序列化骨架变更,不含字段明细,不分 P0/P1/P2
  • + *
  • 未解析 key 展示源码表达式 + 灰色「key 未解析」提示
  • *
  • 多 key 优先拼成一条;超过企微上限则按 key 拆成多条
  • *
  • CI:先打字段明细,再完整输出企微 Markdown(拆分后的每条)
  • *
@@ -111,7 +112,8 @@ public class ReportBuilder { // 无 key 摘要时:整段字段明细作为一块(仍可超长再整体发,不按字段拆) StringBuilder sb = new StringBuilder(); for (SchemaChange c : report.getChanges()) { - appendKeyLine(sb, c.getKeyPattern()); + appendKeyLine(sb, c.getKeyPattern(), c.getKeyExpression(), isUnresolvedKey(c.getKeyPattern())); + appendMetaLines(sb, c.getWriteLocation(), c.getValueType()); if (c.getMessage() != null) { sb.append(" ").append(c.getMessage()).append('\n'); } @@ -121,7 +123,8 @@ public class ReportBuilder { private String renderKeyBlock(KeyStructureChange kc) { StringBuilder sb = new StringBuilder(); - appendKeyLine(sb, kc.getKeyPattern()); + appendKeyLine(sb, kc.displayKey(), kc.getKeyExpression(), kc.isKeyUnresolved()); + appendMetaLines(sb, kc.getWriteLocation(), kc.getValueType()); String oldJson = nvl(kc.getOldSkeletonJson()); String newJson = nvl(kc.getNewSkeletonJson()); Set oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails()); @@ -131,9 +134,9 @@ public class ReportBuilder { String newRendered = newJson.isEmpty() ? "" : "“" + SkeletonAnnotator.annotateForWecom(newJson, newHighlight) + "”"; 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()) { - sb.append(" value 原结构 :").append(oldRendered).append(" 已删除写入\n\n"); + sb.append(" > **value 原结构:** ").append(oldRendered).append("(已删除写入)\n\n"); } else { sb.append(" > **value值由:** ").append(oldRendered).append('\n'); sb.append(" > **变更为:** ").append(newRendered).append("\n\n"); @@ -142,13 +145,35 @@ public class ReportBuilder { } /** - * Key 行:企微 warning 颜色 + 行内代码。 - * 不加粗:企微无法稳定嵌套 {@code **} 与反引号,会露出字面量 {@code *}/{@code **}。 + * Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。 + * 反引号仅包裹 key 文本,避免与加粗/颜色嵌套冲突。 */ - private void appendKeyLine(StringBuilder sb, String keyPattern) { - sb.append("- `Key --> ") - .append(nvl(keyPattern)) - .append("`\n"); + private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression, + boolean unresolved) { + String keyText = unresolved && keyExpression != null && !keyExpression.trim().isEmpty() + ? keyExpression.trim() + : nvl(displayKey); + if (keyText.isEmpty()) { + keyText = "unknown-key"; + } + sb.append("- Key --> `").append(keyText).append('`'); + if (unresolved) { + sb.append(" (key 未解析)"); + } + sb.append('\n'); + } + + /** 位置、类型作为每个 Key 块的通用项。 */ + private void appendMetaLines(StringBuilder sb, String writeLocation, String valueType) { + sb.append(" > **位置**: `").append(nvl(writeLocation)).append("`\n"); + sb.append(" > **类型**: `").append(nvl(valueType)).append("`\n"); + } + + private boolean isUnresolvedKey(String keyPattern) { + return keyPattern == null + || keyPattern.isEmpty() + || "unknown-key".equals(keyPattern) + || "".equals(keyPattern); } /** diff --git a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java index 5970efb..92ed669 100644 --- a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java +++ b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java @@ -47,6 +47,8 @@ class ReportBuilderTest { KeyStructureChange key = new KeyStructureChange(); key.setKeyPattern("saas:period-config:migration:current"); + key.setWriteLocation("SaasPeriodConfigMigrationRedisSupport#putCurrent:41"); + key.setValueType("MigrationCurrentVo"); key.setOldSkeletonJson(oldJson); key.setNewSkeletonJson(newJson); key.setSeverity(Severity.P1); @@ -56,9 +58,10 @@ class ReportBuilderTest { String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report); - // Key:颜色 + 行内代码(与 ReportBuilder.appendKeyLine 一致) - assertTrue(md.contains( - "- `Key --> saas:period-config:migration:current`")); + assertTrue(md.contains("- Key --> `saas:period-config:migration:current`")); + assertFalse(md.contains("(key 未解析)")); + assertTrue(md.contains("> **位置**: `SaasPeriodConfigMigrationRedisSupport#putCurrent:41`")); + assertTrue(md.contains("> **类型**: `MigrationCurrentVo`")); int oldSection = md.indexOf("> **value值由:**"); int newSection = md.indexOf("> **变更为:**"); @@ -131,11 +134,36 @@ class ReportBuilderTest { assertTrue(console.contains("======== 字段明细 ========")); assertTrue(console.contains("**删除字段**: x")); assertTrue(console.contains("\"x\":\"\"")); + assertTrue(console.contains("> **位置**: `")); + assertTrue(console.contains("> **类型**: `")); assertTrue(console.contains("> **value值由:**")); assertTrue(console.contains("> **变更为:**")); assertFalse(console.contains("`{\"x\":\"\"}`")); } + @Test + void unresolvedKeyShowsExpressionAndCommentHint() { + CheckReport report = baseReport(); + KeyStructureChange key = new KeyStructureChange(); + key.setKeyPattern("unknown-key"); + key.setKeyExpression("req.getKey()"); + key.setKeyUnresolved(true); + key.setWriteLocation("ClockInXxxService#export:128"); + key.setValueType("List"); + key.setOldSkeletonJson("{\"a\":\"\"}"); + key.setNewSkeletonJson("{\"a\":\"\",\"b\":\"\"}"); + SchemaChange added = new SchemaChange(ChangeType.FIELD_ADDED); + added.setFieldPath("b"); + key.getFieldDetails().add(added); + report.getKeyChanges().add(key); + + String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report); + assertTrue(md.contains("- Key --> `req.getKey()` (key 未解析)")); + assertTrue(md.contains("> **位置**: `ClockInXxxService#export:128`")); + assertTrue(md.contains("> **类型**: `List`")); + assertFalse(md.contains("unknown-key")); + } + @Test void pathAwareNestedHighlight() { String json = "{\"vo\":{\"dbName\":\"\",\"id\":\"\"},\"expiresAtMs\":0}"; @@ -198,6 +226,8 @@ class ReportBuilderTest { String addedField) { KeyStructureChange key = new KeyStructureChange(); key.setKeyPattern(pattern); + key.setWriteLocation("DemoService#write:1"); + key.setValueType("DemoVo"); key.setOldSkeletonJson(oldJson); key.setNewSkeletonJson(newJson); if (addedField != null) {