From 4da632bd5c011db50d49b29e7f9e682ca09d19fe Mon Sep 17 00:00:00 2001 From: dongzi Date: Tue, 14 Jul 2026 11:32:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=88=E6=9E=9C=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cache/report/ReportBuilder.java | 33 +- .../cache/report/SkeletonAnnotator.java | 351 ++++++++++++++++++ .../cache/report/ReportBuilderTest.java | 93 +++-- 3 files changed, 439 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java diff --git a/src/main/java/com/codechecker/cache/report/ReportBuilder.java b/src/main/java/com/codechecker/cache/report/ReportBuilder.java index b1dd8de..2952649 100644 --- a/src/main/java/com/codechecker/cache/report/ReportBuilder.java +++ b/src/main/java/com/codechecker/cache/report/ReportBuilder.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.EnumMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。 @@ -53,34 +54,32 @@ public class ReportBuilder { appendKeyLine(sb, kc.getKeyPattern()); String oldJson = nvl(kc.getOldSkeletonJson()); String newJson = nvl(kc.getNewSkeletonJson()); + Set oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails()); + Set newHighlight = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails()); + String oldRendered = oldJson.isEmpty() + ? "" : SkeletonAnnotator.annotateAsCodeBlock(oldJson, oldHighlight); + String newRendered = newJson.isEmpty() + ? "" : SkeletonAnnotator.annotateAsCodeBlock(newJson, newHighlight); if (oldJson.isEmpty() && !newJson.isEmpty()) { - sb.append(" value 新增为 “").append(fontWarning(newJson)).append("”\n\n"); + sb.append(" value 新增为 ").append(newRendered).append("\n\n"); } else if (!oldJson.isEmpty() && newJson.isEmpty()) { - sb.append(" value 原结构 “").append(fontInfo(oldJson)).append("” 已删除写入\n\n"); + sb.append(" value 原结构 ").append(oldRendered).append(" 已删除写入\n\n"); } else { - sb.append(" value 值从 “").append(fontInfo(oldJson)).append("”\n"); - sb.append(" 变更为 “").append(fontWarning(newJson)).append("”\n\n"); + sb.append(" value 值从 ").append(oldRendered).append('\n'); + sb.append(" 变更为 ").append(newRendered).append("\n\n"); } } return sb.toString(); } /** - * Key 行:加粗「Key -->」+ 加粗 key 文本。 - * 避免 key 末尾 * 与 markdown ** 冲突,结尾加零宽空格再闭合。 + * Key 行:Key --> + 橙红色 key(企微 warning)。 + * 末尾加零宽空格,避免 key 中的 * 干扰后续 markdown。 */ private void appendKeyLine(StringBuilder sb, String keyPattern) { - sb.append("- **Key --> ").append(nvl(keyPattern)).append("\u200b**\n"); - } - - /** 企微 markdown:绿色(旧骨架)。 */ - private String fontInfo(String text) { - return "" + text + ""; - } - - /** 企微 markdown:warning 橙红(新骨架)。 */ - private String fontWarning(String text) { - return "" + text + ""; + sb.append("- Key --> ") + .append(nvl(keyPattern)) + .append("\u200b\n"); } /** diff --git a/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java b/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java new file mode 100644 index 0000000..33a3bf4 --- /dev/null +++ b/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java @@ -0,0 +1,351 @@ +package com.codechecker.cache.report; + +import com.codechecker.cache.diff.ChangeType; +import com.codechecker.cache.diff.SchemaChange; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 在骨架 JSON 中为改动字段加企微 warning 标注。 + *
    + *
  • 删除字段 → 标在旧骨架
  • + *
  • 新增字段 / 新增包装层 → 标在新骨架
  • + *
  • 路径迁移 → 旧路径标旧骨架、新路径标新骨架
  • + *
+ */ +final class SkeletonAnnotator { + + private static final String FONT_OPEN = ""; + private static final String FONT_CLOSE = ""; + + private SkeletonAnnotator() { + } + + static Set pathsForOldSkeleton(List details) { + Set paths = new LinkedHashSet<>(); + if (details == null) { + return paths; + } + for (SchemaChange c : details) { + if (c == null || c.getChangeType() == null) { + continue; + } + if (c.getChangeType() == ChangeType.FIELD_REMOVED) { + addIfPresent(paths, c.getFieldPath()); + } else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) { + addIfPresent(paths, c.getOldValue()); + } + } + return paths; + } + + static Set pathsForNewSkeleton(List details) { + Set paths = new LinkedHashSet<>(); + if (details == null) { + return paths; + } + for (SchemaChange c : details) { + if (c == null || c.getChangeType() == null) { + continue; + } + if (c.getChangeType() == ChangeType.FIELD_ADDED + || c.getChangeType() == ChangeType.WRAPPER_ADDED) { + addIfPresent(paths, c.getFieldPath()); + } else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) { + addIfPresent(paths, c.getFieldPath()); + if (c.getNewValue() != null && !c.getNewValue().equals(c.getFieldPath())) { + addIfPresent(paths, c.getNewValue()); + } + } + } + return paths; + } + + /** + * 标注路径对应的 {@code "field":value} 片段,并包成可与代码块混排的企微 Markdown。 + * 企微代码块内不解析 font,故整段用反引号分段包裹,改动项单独用 warning+反引号。 + */ + static String annotateAsCodeBlock(String json, Set highlightPaths) { + if (json == null || json.isEmpty()) { + return "``"; + } + String annotated = annotate(json, highlightPaths); + return toMixedCodeMarkdown(annotated); + } + + static String annotate(String json, Set highlightPaths) { + if (json == null || json.isEmpty() || highlightPaths == null || highlightPaths.isEmpty()) { + return json == null ? "" : json; + } + String result = json; + List sorted = new ArrayList<>(highlightPaths); + sorted.sort(Comparator.comparingInt(String::length).reversed()); + for (String path : sorted) { + result = wrapOnePath(result, path); + } + return result; + } + + /** + * 将已含 {@code } 的字符串拆成:普通段走反引号代码块,改动段走 warning+反引号。 + */ + static String toMixedCodeMarkdown(String annotated) { + if (annotated == null || annotated.isEmpty()) { + return "``"; + } + if (!annotated.contains(FONT_OPEN)) { + return "`" + annotated + "`"; + } + StringBuilder out = new StringBuilder(); + int i = 0; + boolean inCode = false; + while (i < annotated.length()) { + int start = annotated.indexOf(FONT_OPEN, i); + if (start < 0) { + if (!inCode) { + out.append('`'); + inCode = true; + } + out.append(annotated.substring(i)); + out.append('`'); + return out.toString(); + } + if (start > i) { + if (!inCode) { + out.append('`'); + inCode = true; + } + out.append(annotated, i, start); + out.append('`'); + inCode = false; + } else if (inCode) { + out.append('`'); + inCode = false; + } + int contentStart = start + FONT_OPEN.length(); + int end = annotated.indexOf(FONT_CLOSE, contentStart); + if (end < 0) { + if (!inCode) { + out.append('`'); + } + out.append(annotated.substring(start)).append('`'); + return out.toString(); + } + String frag = annotated.substring(contentStart, end); + out.append(FONT_OPEN).append('`').append(frag).append('`').append(FONT_CLOSE); + i = end + FONT_CLOSE.length(); + } + return out.toString(); + } + + private static void addIfPresent(Set paths, String path) { + if (path != null && !path.trim().isEmpty()) { + paths.add(path.trim()); + } + } + + private static String wrapOnePath(String json, String path) { + if (path == null || path.isEmpty()) { + return json; + } + // 已标注过则跳过(避免重复) + String key = lastSegment(path); + if (key.isEmpty()) { + return json; + } + int propStart = findPropertyStart(json, path); + if (propStart < 0) { + return json; + } + // 已在 font 内则跳过 + int fontBefore = json.lastIndexOf(FONT_OPEN, propStart); + int fontCloseBefore = json.lastIndexOf(FONT_CLOSE, propStart); + if (fontBefore > fontCloseBefore) { + return json; + } + String needle = "\"" + key + "\""; + int keyEnd = propStart + needle.length(); + int valueEnd = findValueEnd(json, keyEnd); + if (valueEnd <= propStart) { + return json; + } + String frag = json.substring(propStart, valueEnd); + if (frag.contains(FONT_OPEN)) { + return json; + } + return json.substring(0, propStart) + + FONT_OPEN + frag + FONT_CLOSE + + json.substring(valueEnd); + } + + /** + * 按点分路径定位属性名起始下标(指向开头引号)。 + */ + private static int findPropertyStart(String json, String path) { + List segs = splitPath(path); + if (segs.isEmpty()) { + return -1; + } + int searchFrom = 0; + int scopeEnd = json.length(); + for (int s = 0; s < segs.size(); s++) { + String seg = segs.get(s); + boolean last = s == segs.size() - 1; + String needle = "\"" + seg + "\""; + int idx = indexOfPropertyInScope(json, needle, searchFrom, scopeEnd); + if (idx < 0) { + return -1; + } + if (last) { + return idx; + } + int keyEnd = idx + needle.length(); + int valueStart = skipColon(json, keyEnd); + if (valueStart >= json.length()) { + return -1; + } + char c = json.charAt(valueStart); + if (c == '{') { + searchFrom = valueStart + 1; + scopeEnd = findMatchingBracket(json, valueStart); + if (scopeEnd < 0) { + return -1; + } + } else if (c == '[') { + // 数组:进入第一个元素对象 + int arrEnd = findMatchingBracket(json, valueStart); + int elemObj = json.indexOf('{', valueStart); + if (elemObj < 0 || elemObj >= arrEnd) { + searchFrom = valueStart + 1; + scopeEnd = arrEnd; + } else { + searchFrom = elemObj + 1; + scopeEnd = findMatchingBracket(json, elemObj); + } + } else { + return -1; + } + } + return -1; + } + + private static int indexOfPropertyInScope(String json, String needle, int from, int to) { + int idx = from; + while (idx >= 0 && idx < to) { + idx = json.indexOf(needle, idx); + if (idx < 0 || idx >= to) { + return -1; + } + // 粗略校验:前面应是 { 或 , + int p = idx - 1; + while (p >= from && Character.isWhitespace(json.charAt(p))) { + p--; + } + if (p < from || json.charAt(p) == '{' || json.charAt(p) == ',' || json.charAt(p) == '[') { + return idx; + } + idx += needle.length(); + } + return -1; + } + + private static int skipColon(String json, int afterKey) { + int i = afterKey; + while (i < json.length() && (json.charAt(i) == ':' || Character.isWhitespace(json.charAt(i)))) { + i++; + } + return i; + } + + private static int findMatchingBracket(String json, int openIdx) { + if (openIdx < 0 || openIdx >= json.length()) { + return -1; + } + char open = json.charAt(openIdx); + char close = open == '{' ? '}' : ']'; + int depth = 0; + for (int i = openIdx; i < json.length(); i++) { + char ch = json.charAt(i); + if (ch == '"') { + i++; + while (i < json.length()) { + char x = json.charAt(i++); + if (x == '\\' && i < json.length()) { + i++; + } else if (x == '"') { + break; + } + } + i--; + continue; + } + if (ch == open) { + depth++; + } else if (ch == close) { + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; + } + + private static int findValueEnd(String json, int afterKey) { + int i = skipColon(json, afterKey); + if (i >= json.length()) { + return json.length(); + } + char c = json.charAt(i); + if (c == '"') { + i++; + while (i < json.length()) { + char ch = json.charAt(i++); + if (ch == '\\' && i < json.length()) { + i++; + } else if (ch == '"') { + break; + } + } + return i; + } + if (c == '{' || c == '[') { + int end = findMatchingBracket(json, i); + return end < 0 ? json.length() : end + 1; + } + while (i < json.length() && json.charAt(i) != ',' && json.charAt(i) != '}' && json.charAt(i) != ']') { + i++; + } + return i; + } + + private static List splitPath(String path) { + List segs = new ArrayList<>(); + if (path == null || path.isEmpty()) { + return segs; + } + for (String raw : path.split("\\.")) { + if (raw.isEmpty() || "[]".equals(raw)) { + continue; + } + if (raw.endsWith("[]")) { + String name = raw.substring(0, raw.length() - 2); + if (!name.isEmpty()) { + segs.add(name); + } + } else { + segs.add(raw); + } + } + return segs; + } + + private static String lastSegment(String path) { + List segs = splitPath(path); + return segs.isEmpty() ? "" : segs.get(segs.size() - 1); + } +} diff --git a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java index 23bcd7a..80ffb73 100644 --- a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java +++ b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java @@ -5,6 +5,11 @@ import com.codechecker.cache.diff.SchemaChange; import com.codechecker.cache.diff.Severity; import org.junit.jupiter.api.Test; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -20,33 +25,69 @@ class ReportBuilderTest { report.setModifier("dongzi"); report.setModifyTime("2026-07-13 16:54:17"); - SchemaChange detail = new SchemaChange(ChangeType.WRAPPER_ADDED); - detail.setSeverity(Severity.P0); - detail.setKeyPattern("tenant:db:content:*"); - detail.setWriteLocation("Helper#put:10"); - detail.setFieldPath("vo"); - detail.setMessage("新增包装层 vo"); - report.getChanges().add(detail); + SchemaChange addedMsg = new SchemaChange(ChangeType.FIELD_ADDED); + addedMsg.setSeverity(Severity.P1); + addedMsg.setKeyPattern("saas:period-config:migration:current"); + addedMsg.setFieldPath("message"); + addedMsg.setMessage("新增字段 message"); + + SchemaChange addedErr = new SchemaChange(ChangeType.FIELD_ADDED); + addedErr.setSeverity(Severity.P1); + addedErr.setKeyPattern("saas:period-config:migration:current"); + addedErr.setFieldPath("lastError"); + addedErr.setMessage("新增字段 lastError"); + + report.getChanges().add(addedMsg); + report.getChanges().add(addedErr); KeyStructureChange key = new KeyStructureChange(); - key.setKeyPattern("tenant:db:content:*"); - key.setOldSkeletonJson("{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}"); - key.setNewSkeletonJson("{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]},\"expiresAtMs\":0}"); - key.setSeverity(Severity.P0); + key.setKeyPattern("saas:period-config:migration:current"); + key.setOldSkeletonJson( + "{\"taskId\":\"\",\"status\":\"\",\"progress\":{\"totalTenants\":0}}"); + key.setNewSkeletonJson( + "{\"taskId\":\"\",\"status\":\"\",\"progress\":{\"totalTenants\":0},\"message\":\"\",\"lastError\":\"\"}"); + key.setSeverity(Severity.P1); + key.getFieldDetails().add(addedMsg); + key.getFieldDetails().add(addedErr); report.getKeyChanges().add(key); String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report); assertTrue(md.startsWith("## [缓存结构变更] jnpf-java-cloud")); - assertTrue(md.contains("> **分支**: code/redis_change_detection_v1.0")); - assertTrue(md.contains("> **时间**: 2026-07-13 16:54:17")); - assertTrue(md.contains("- **Key --> tenant:db:content:*\u200b**")); - assertTrue(md.contains("{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}")); - assertTrue(md.contains("{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]},\"expiresAtMs\":0}")); + assertTrue(md.contains("- Key --> saas:period-config:migration:current\u200b")); + assertTrue(md.contains("value 值从")); + assertTrue(md.contains("变更为")); + // 新增字段仅在新骨架中 warning 标注 + assertTrue(md.contains("`\"message\":\"\"`")); + assertTrue(md.contains("`\"lastError\":\"\"`")); + // 旧骨架不应出现这两个字段的 warning + int oldSection = md.indexOf("value 值从"); + int newSection = md.indexOf("变更为"); + String oldPart = md.substring(oldSection, newSection); + assertFalse(oldPart.contains("`\"message\":\"\"`")); assertFalse(md.contains("### P0")); assertFalse(md.contains("位置")); - assertFalse(md.contains("模式")); - assertFalse(md.contains("汇总")); + } + + @Test + void removedFieldsHighlightedOnlyInOldSkeleton() { + SchemaChange removed = new SchemaChange(ChangeType.FIELD_REMOVED); + removed.setFieldPath("lastError"); + removed.setMessage("删除字段 lastError"); + + String oldJson = "{\"taskId\":\"\",\"lastError\":\"\"}"; + String newJson = "{\"taskId\":\"\"}"; + Set oldPaths = SkeletonAnnotator.pathsForOldSkeleton(Collections.singletonList(removed)); + Set newPaths = SkeletonAnnotator.pathsForNewSkeleton(Collections.singletonList(removed)); + + assertEquals(Collections.singleton("lastError"), oldPaths); + assertTrue(newPaths.isEmpty()); + + String oldMd = SkeletonAnnotator.annotateAsCodeBlock(oldJson, oldPaths); + String newMd = SkeletonAnnotator.annotateAsCodeBlock(newJson, newPaths); + assertTrue(oldMd.contains("`\"lastError\":\"\"`")); + assertFalse(newMd.contains("lastError")); + assertTrue(newMd.startsWith("`")); } @Test @@ -60,6 +101,7 @@ class ReportBuilderTest { detail.setSeverity(Severity.P0); detail.setKeyPattern("k1"); detail.setWriteLocation("Foo#bar:1"); + detail.setFieldPath("x"); detail.setMessage("删除字段 x"); report.getChanges().add(detail); @@ -67,6 +109,7 @@ class ReportBuilderTest { key.setKeyPattern("k1"); key.setOldSkeletonJson("{\"x\":\"\"}"); key.setNewSkeletonJson("{}"); + key.getFieldDetails().add(detail); report.getKeyChanges().add(key); String console = new ReportBuilder("[缓存结构变更]").toConsole(report); @@ -75,8 +118,16 @@ class ReportBuilderTest { assertTrue(console.contains("**删除字段**: x")); assertTrue(console.contains("======== 企微 Markdown ========")); assertTrue(console.contains("value 值从")); - assertTrue(console.contains("- **Key --> k1\u200b**")); - assertTrue(console.contains("")); - assertTrue(console.contains("")); + assertTrue(console.contains("`\"x\":\"\"`")); + } + + @Test + void pathAwareNestedHighlight() { + String json = "{\"vo\":{\"dbName\":\"\",\"id\":\"\"},\"expiresAtMs\":0}"; + Set paths = new LinkedHashSet<>(); + paths.add("vo.dbName"); + String out = SkeletonAnnotator.annotate(json, paths); + assertTrue(out.contains("\"dbName\":\"\"")); + assertFalse(out.contains("\"id\":\"\"")); } }