diff --git a/src/main/java/com/codechecker/redis/analyze/SchemaCheckAnalyzer.java b/src/main/java/com/codechecker/redis/analyze/SchemaCheckAnalyzer.java index 41e1c03..7aeaea8 100644 --- a/src/main/java/com/codechecker/redis/analyze/SchemaCheckAnalyzer.java +++ b/src/main/java/com/codechecker/redis/analyze/SchemaCheckAnalyzer.java @@ -10,7 +10,9 @@ import com.codechecker.redis.diff.Severity; import com.codechecker.redis.git.GitDiffScanner; import com.codechecker.redis.git.GitException; import com.codechecker.redis.report.CheckReport; +import com.codechecker.redis.report.KeyStructureChange; import com.codechecker.redis.schema.JavaSchemaExtractor; +import com.codechecker.redis.schema.SkeletonJsonRenderer; import com.codechecker.redis.schema.SourceIndex; import com.codechecker.redis.schema.TypeSchema; import com.github.javaparser.StaticJavaParser; @@ -19,6 +21,7 @@ import com.github.javaparser.ast.body.TypeDeclaration; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -92,8 +95,10 @@ public class SchemaCheckAnalyzer { JavaSchemaExtractor extractorNew = new JavaSchemaExtractor(newIndex, config.getDetection().getMaxFieldDepth()); JavaSchemaExtractor extractorOld = new JavaSchemaExtractor(oldIndex, config.getDetection().getMaxFieldDepth()); SchemaDiffer differ = new SchemaDiffer(); + SkeletonJsonRenderer skeletonRenderer = new SkeletonJsonRenderer(); List allChanges = new ArrayList<>(); + Map keyChanges = new LinkedHashMap<>(); for (String path : candidates) { String newContent = newContents.get(path); @@ -123,16 +128,31 @@ public class SchemaCheckAnalyzer { TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray()); TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray()); List changes = differ.diff(oldSchema, newSchema); + if (changes.isEmpty()) { + continue; + } double confidence = min(nw.getConfidence(), ow.getConfidence(), oldSchema.getConfidence(), newSchema.getConfidence()); enrich(changes, nw, confidence); allChanges.addAll(changes); + mergeKeyChange(keyChanges, nw.getResolvedKeyPattern(), 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("新增 Redis 写入点,value 类型: " + shortType(nw.getResolvedValueType())); allChanges.add(c); + TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray()); + mergeKeyChange(keyChanges, nw.getResolvedKeyPattern(), + Collections.singletonList(c), + "", + skeletonRenderer.render(newSchema, protectedPaths( + Collections.singletonList(c)), + SkeletonJsonRenderer.DEFAULT_MAX_LEN)); } } @@ -145,13 +165,106 @@ public class SchemaCheckAnalyzer { c.setWriteLocation(ow.location()); c.setMessage("删除 Redis 写入点,原 value 类型: " + shortType(ow.getResolvedValueType())); allChanges.add(c); + TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray()); + mergeKeyChange(keyChanges, ow.getResolvedKeyPattern(), + Collections.singletonList(c), + skeletonRenderer.render(oldSchema, protectedPaths( + Collections.singletonList(c)), + SkeletonJsonRenderer.DEFAULT_MAX_LEN), + ""); } } } } List finalChanges = postProcess(allChanges); - return buildReport(oldSha, newSha, finalChanges); + // 按最终字段明细重新对齐 key 摘要(过滤被 suppress/dedup 掉的) + Map finalKeys = filterKeyChanges(keyChanges, finalChanges); + return buildReport(oldSha, newSha, finalChanges, finalKeys); + } + + private void mergeKeyChange(Map keyChanges, String keyPattern, + List changes, String oldSkeleton, String newSkeleton) { + String key = keyPattern == null ? "" : keyPattern; + KeyStructureChange kc = keyChanges.computeIfAbsent(key, k -> { + KeyStructureChange n = new KeyStructureChange(); + n.setKeyPattern(k); + return n; + }); + kc.getFieldDetails().addAll(changes); + for (SchemaChange c : changes) { + kc.raiseSeverity(c.getSeverity()); + } + if (oldSkeleton != null && !oldSkeleton.isEmpty()) { + kc.setOldSkeletonJson(oldSkeleton); + } + if (newSkeleton != null && !newSkeleton.isEmpty()) { + kc.setNewSkeletonJson(newSkeleton); + } + if (kc.getOldSkeletonJson() == null) { + kc.setOldSkeletonJson(oldSkeleton == null ? "" : oldSkeleton); + } + if (kc.getNewSkeletonJson() == null) { + kc.setNewSkeletonJson(newSkeleton == null ? "" : newSkeleton); + } + } + + private Set protectedPaths(List changes) { + Set paths = new LinkedHashSet<>(); + for (SchemaChange c : changes) { + if (c.getFieldPath() != null && !c.getFieldPath().isEmpty()) { + paths.add(c.getFieldPath()); + } + // 路径迁移时 oldValue 为旧路径 + if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED + && c.getOldValue() != null && !c.getOldValue().isEmpty()) { + paths.add(c.getOldValue()); + } + if (c.getChangeType() == ChangeType.WRAPPER_ADDED + && c.getFieldPath() != null) { + paths.add(c.getFieldPath()); + } + } + return paths; + } + + private Map filterKeyChanges( + Map keyChanges, List finalChanges) { + Set liveKeys = new HashSet<>(); + for (SchemaChange c : finalChanges) { + liveKeys.add(c.getKeyPattern() == null ? "" : c.getKeyPattern()); + } + 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); + } + } + 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); + } + } + return result; } private void enrich(List changes, WritePoint wp, double confidence) { @@ -208,12 +321,14 @@ public class SchemaCheckAnalyzer { return false; } - private CheckReport buildReport(String oldSha, String newSha, List changes) { + private CheckReport buildReport(String oldSha, String newSha, List changes, + Map keyChanges) { CheckReport report = new CheckReport(); report.setOldSha(oldSha); report.setNewSha(newSha); report.setMode(config.getMode()); report.getChanges().addAll(changes); + report.getKeyChanges().addAll(keyChanges.values()); boolean blocked = false; if (config.isBlockMode()) { diff --git a/src/main/java/com/codechecker/redis/cli/RedisSchemaCheckerMain.java b/src/main/java/com/codechecker/redis/cli/RedisSchemaCheckerMain.java index 9ef7658..d8320d5 100644 --- a/src/main/java/com/codechecker/redis/cli/RedisSchemaCheckerMain.java +++ b/src/main/java/com/codechecker/redis/cli/RedisSchemaCheckerMain.java @@ -70,13 +70,15 @@ public class RedisSchemaCheckerMain implements Callable { report.setRepository(repository != null ? repository : root.getFileName().toString()); ReportBuilder builder = new ReportBuilder(config.getNotify().getTitlePrefix()); + // CI:字段明细 + 完整企微 Markdown System.out.println(builder.toConsole(report)); boolean shouldNotify = config.getNotify().isEnabled() && (report.hasChanges() || config.getNotify().isNotifyOnClean()); if (shouldNotify && !dryRun) { String webhook = config.getNotify().getWebhookUrl(); - boolean ok = new WeComNotifier().sendMarkdown(webhook, builder.toMarkdown(report)); + String markdown = builder.toMarkdown(report); + boolean ok = new WeComNotifier().sendMarkdown(webhook, markdown); System.out.println("[redis-schema-checker] 企微通知发送: " + (ok ? "成功" : "失败/跳过")); } diff --git a/src/main/java/com/codechecker/redis/report/CheckReport.java b/src/main/java/com/codechecker/redis/report/CheckReport.java index b33373d..7593a65 100644 --- a/src/main/java/com/codechecker/redis/report/CheckReport.java +++ b/src/main/java/com/codechecker/redis/report/CheckReport.java @@ -20,11 +20,12 @@ public class CheckReport { private String mode; private final List changes = new ArrayList<>(); + private final List keyChanges = new ArrayList<>(); private boolean blocked; private int exitCode; public boolean hasChanges() { - return !changes.isEmpty(); + return !changes.isEmpty() || !keyChanges.isEmpty(); } public long count(Severity severity) { @@ -91,6 +92,10 @@ public class CheckReport { return changes; } + public List getKeyChanges() { + return keyChanges; + } + public boolean isBlocked() { return blocked; } diff --git a/src/main/java/com/codechecker/redis/report/KeyStructureChange.java b/src/main/java/com/codechecker/redis/report/KeyStructureChange.java new file mode 100644 index 0000000..ff285c9 --- /dev/null +++ b/src/main/java/com/codechecker/redis/report/KeyStructureChange.java @@ -0,0 +1,65 @@ +package com.codechecker.redis.report; + +import com.codechecker.redis.diff.SchemaChange; +import com.codechecker.redis.diff.Severity; + +import java.util.ArrayList; +import java.util.List; + +/** + * 按 Redis key 聚合后的结构变更摘要(用于企微骨架展示)。 + */ +public class KeyStructureChange { + + private String keyPattern; + private String oldSkeletonJson; + private String newSkeletonJson; + private Severity severity = Severity.P2; + private final List fieldDetails = new ArrayList<>(); + + public String getKeyPattern() { + return keyPattern; + } + + public void setKeyPattern(String keyPattern) { + this.keyPattern = keyPattern; + } + + public String getOldSkeletonJson() { + return oldSkeletonJson; + } + + public void setOldSkeletonJson(String oldSkeletonJson) { + this.oldSkeletonJson = oldSkeletonJson; + } + + public String getNewSkeletonJson() { + return newSkeletonJson; + } + + public void setNewSkeletonJson(String newSkeletonJson) { + this.newSkeletonJson = newSkeletonJson; + } + + public Severity getSeverity() { + return severity; + } + + public void setSeverity(Severity severity) { + this.severity = severity; + } + + public List getFieldDetails() { + return fieldDetails; + } + + public void raiseSeverity(Severity candidate) { + if (candidate == null) { + return; + } + if (severity == null || candidate.ordinal() < severity.ordinal()) { + // P0=0, P1=1, P2=2 → ordinal 更小更严重 + severity = candidate; + } + } +} diff --git a/src/main/java/com/codechecker/redis/report/ReportBuilder.java b/src/main/java/com/codechecker/redis/report/ReportBuilder.java index 9ef39f6..4b026bb 100644 --- a/src/main/java/com/codechecker/redis/report/ReportBuilder.java +++ b/src/main/java/com/codechecker/redis/report/ReportBuilder.java @@ -10,6 +10,10 @@ import java.util.Map; /** * 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。 + *
    + *
  • 企微:按 key 展示序列化骨架变更,不含位置/字段明细,不分 P0/P1/P2
  • + *
  • CI:先打字段明细,再完整输出企微 Markdown
  • + *
*/ public class ReportBuilder { @@ -29,16 +33,60 @@ public class ReportBuilder { sb.append("> **提交人**: ").append(nvl(report.getModifier())).append('\n'); sb.append("> **时间**: ").append(nvl(report.getModifyTime())).append("\n\n"); + List keys = report.getKeyChanges(); + if (keys == null || keys.isEmpty()) { + // 兼容仅有字段明细时退化展示 + if (report.getChanges().isEmpty()) { + sb.append("未检测到 Redis 序列化结构变更。\n"); + } else { + for (SchemaChange c : report.getChanges()) { + sb.append("- `").append(nvl(c.getKeyPattern())).append("`\n"); + if (c.getMessage() != null) { + sb.append(" ").append(c.getMessage()).append('\n'); + } + } + } + return sb.toString(); + } + + for (KeyStructureChange kc : keys) { + sb.append("- `").append(nvl(kc.getKeyPattern())).append("`\n"); + String oldJson = nvl(kc.getOldSkeletonJson()); + String newJson = nvl(kc.getNewSkeletonJson()); + if (oldJson.isEmpty() && !newJson.isEmpty()) { + sb.append(" value 新增为 “").append(newJson).append("”\n\n"); + } else if (!oldJson.isEmpty() && newJson.isEmpty()) { + sb.append(" value 原结构 “").append(oldJson).append("” 已删除写入\n\n"); + } else { + sb.append(" value 值从 “").append(oldJson).append("”\n"); + sb.append(" 变更为 “").append(newJson).append("”\n\n"); + } + } + return sb.toString(); + } + + /** + * CI 控制台:字段明细 + 完整企微 Markdown。 + */ + public String toConsole(CheckReport report) { + StringBuilder sb = new StringBuilder(); + if (!report.hasChanges()) { + sb.append("未检测到 Redis 序列化结构变更。\n"); + return sb.toString(); + } + + sb.append("======== 字段明细 ========\n"); Map> grouped = new EnumMap<>(Severity.class); for (SchemaChange c : report.getChanges()) { grouped.computeIfAbsent(c.getSeverity(), k -> new ArrayList<>()).add(c); } - + boolean anyDetail = false; for (Severity severity : Severity.values()) { List list = grouped.get(severity); if (list == null || list.isEmpty()) { continue; } + anyDetail = true; sb.append("### ").append(severity).append('\n'); for (SchemaChange c : list) { sb.append("- **").append(c.getChangeType().getLabel()).append("**"); @@ -55,12 +103,15 @@ public class ReportBuilder { } sb.append('\n'); } + if (!anyDetail) { + sb.append("(无字段级明细)\n\n"); + } + + sb.append("======== 企微 Markdown ========\n"); + sb.append(toMarkdown(report)); return sb.toString(); } - /** - * 将详情文案中的 key 加粗,如「删除字段 lastError」→「**删除字段**: lastError」。 - */ private String formatDetailMessage(String message) { String trimmed = message.trim(); int colonCn = trimmed.indexOf(':'); @@ -92,13 +143,6 @@ public class ReportBuilder { return trimmed; } - public String toConsole(CheckReport report) { - if (!report.hasChanges()) { - return "未检测到 Redis 序列化结构变更。"; - } - return toMarkdown(report); - } - private String shortSha(String sha) { if (sha == null) { return ""; diff --git a/src/main/java/com/codechecker/redis/schema/SkeletonJsonRenderer.java b/src/main/java/com/codechecker/redis/schema/SkeletonJsonRenderer.java new file mode 100644 index 0000000..9f78dd4 --- /dev/null +++ b/src/main/java/com/codechecker/redis/schema/SkeletonJsonRenderer.java @@ -0,0 +1,375 @@ +package com.codechecker.redis.schema; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 将扁平 {@link TypeSchema} 还原为带占位符的序列化骨架 JSON。 + * 超长时整段压缩/截断,但受保护(改动)字段路径对应片段不被截断。 + */ +public class SkeletonJsonRenderer { + + /** 单侧骨架默认最大长度(企微 markdown 总长约 4096)。 */ + public static final int DEFAULT_MAX_LEN = 1500; + + public String render(TypeSchema schema) { + return render(schema, null, Integer.MAX_VALUE); + } + + /** + * @param protectedPaths 改动相关字段路径(如 vo.dbName、expiresAtMs),压缩时优先保留 + * @param maxLen 输出最大字符数 + */ + public String render(TypeSchema schema, Set protectedPaths, int maxLen) { + if (schema == null || schema.isEmpty()) { + return "{}"; + } + Set protectedSet = protectedPaths == null + ? new LinkedHashSet<>() : new LinkedHashSet<>(protectedPaths); + Node root = buildTree(schema); + String full = write(root, "", protectedSet, false); + if (full.length() <= maxLen) { + return full; + } + String compact = write(root, "", protectedSet, true); + if (compact.length() <= maxLen) { + return compact; + } + return truncatePreserve(compact, protectedSet, maxLen); + } + + private Node buildTree(TypeSchema schema) { + Node root = new Node(JsonType.OBJECT); + for (FieldSchema field : schema.getFields().values()) { + putPath(root, field.getPath(), field.getJsonType()); + } + // 根数组:字段以 [] / [].xxx 记录 + if (root.children.size() == 1 && root.children.containsKey("[]")) { + Node arr = new Node(JsonType.ARRAY); + arr.children.put("[]", root.children.get("[]")); + return arr; + } + return root; + } + + private void putPath(Node root, String path, JsonType type) { + List segs = parsePath(path); + if (segs.isEmpty()) { + return; + } + Node cur = root; + for (int i = 0; i < segs.size(); i++) { + Seg seg = segs.get(i); + boolean last = i == segs.size() - 1; + if (seg.array) { + Node arr = cur.children.computeIfAbsent(seg.name, k -> new Node(JsonType.ARRAY)); + arr.type = JsonType.ARRAY; + Node elem = arr.children.computeIfAbsent("[]", k -> new Node(JsonType.OBJECT)); + if (last) { + if (type == JsonType.OBJECT || type == JsonType.ARRAY || type == JsonType.MAP) { + elem.type = type; + } else { + elem.type = type; + elem.leaf = true; + } + } + cur = elem; + } else { + Node child = cur.children.computeIfAbsent(seg.name, + k -> new Node(last ? type : JsonType.OBJECT)); + if (last) { + child.type = type; + child.leaf = type != JsonType.OBJECT && type != JsonType.ARRAY && type != JsonType.MAP; + } else if (child.type != JsonType.ARRAY) { + child.type = JsonType.OBJECT; + } + cur = child; + } + } + } + + private List parsePath(String path) { + List segs = new ArrayList<>(); + if (path == null || path.isEmpty()) { + return segs; + } + for (String raw : path.split("\\.")) { + if (raw.isEmpty()) { + continue; + } + if ("[]".equals(raw)) { + segs.add(new Seg("[]", false)); + } else if (raw.endsWith("[]")) { + segs.add(new Seg(raw.substring(0, raw.length() - 2), true)); + } else { + segs.add(new Seg(raw, false)); + } + } + return segs; + } + + private String write(Node node, String pathPrefix, Set protectedPaths, boolean compact) { + if (node == null) { + return "null"; + } + if (node.type == JsonType.ARRAY) { + Node elem = node.children.get("[]"); + if (elem == null) { + return "[]"; + } + String elemPath = pathPrefix.isEmpty() ? "[]" : pathPrefix + "[]"; + if (compact && !isProtectedUnder(pathPrefix, protectedPaths) + && !isProtectedUnder(elemPath, protectedPaths)) { + return "[...]"; + } + return "[" + write(elem, elemPath, protectedPaths, compact) + "]"; + } + if (node.leaf || isScalar(node.type)) { + return placeholder(node.type); + } + if (node.type == JsonType.MAP) { + return "{}"; + } + + StringBuilder sb = new StringBuilder(); + sb.append('{'); + boolean first = true; + for (Map.Entry e : node.children.entrySet()) { + String name = e.getKey(); + if (name == null || name.isEmpty()) { + continue; + } + Node child = e.getValue(); + String childPath = pathPrefix.isEmpty() ? name : pathPrefix + "." + name; + // 根数组占位名 [] 不作为 JSON key 输出(由上层 ARRAY 处理) + if ("[]".equals(name) && pathPrefix.isEmpty()) { + continue; + } + + if (!first) { + sb.append(','); + } + first = false; + sb.append('"').append(escape(name)).append("\":"); + + if (compact && !isProtectedUnder(childPath, protectedPaths) && child.type != JsonType.ARRAY) { + sb.append(collapsedValue(child)); + } else if (child.type == JsonType.ARRAY) { + sb.append(write(child, childPath, protectedPaths, compact)); + } else if (child.leaf || isScalar(child.type)) { + sb.append(placeholder(child.type)); + } else { + sb.append(write(child, childPath, protectedPaths, compact)); + } + } + sb.append('}'); + return sb.toString(); + } + + private String collapsedValue(Node child) { + if (child.type == JsonType.ARRAY) { + return "[...]"; + } + if (child.type == JsonType.OBJECT || child.type == JsonType.MAP) { + return "\"...\""; + } + return placeholder(child.type); + } + + private boolean isProtectedUnder(String pathPrefix, Set protectedPaths) { + if (protectedPaths == null || protectedPaths.isEmpty() || pathPrefix == null) { + return false; + } + for (String p : protectedPaths) { + if (p == null || p.isEmpty()) { + continue; + } + if (p.equals(pathPrefix)) { + return true; + } + if (pathPrefix.isEmpty()) { + continue; + } + if (p.startsWith(pathPrefix + ".") || p.startsWith(pathPrefix + "[")) { + return true; + } + } + return false; + } + + private String truncatePreserve(String json, Set protectedPaths, int maxLen) { + List fragments = new ArrayList<>(); + for (String path : protectedPaths) { + String key = lastSegment(path); + if (key.isEmpty() || "[]".equals(key)) { + continue; + } + String needle = "\"" + key + "\""; + int idx = json.indexOf(needle); + if (idx < 0) { + continue; + } + int end = findValueEnd(json, idx + needle.length()); + String frag = json.substring(idx, Math.min(json.length(), end)); + if (!fragments.contains(frag)) { + fragments.add(frag); + } + } + + StringBuilder kept = new StringBuilder(); + for (String f : fragments) { + if (kept.length() > 0 && kept.length() + f.length() + 1 > maxLen) { + // 不截断改动字段:装不下则整段保留已收集的改动片段 + break; + } + if (kept.length() > 0) { + kept.append(','); + } + kept.append(f); + // 单条改动字段允许超过 maxLen(不可截断) + if (fragments.size() == 1 && kept.length() > maxLen) { + return kept.toString(); + } + } + String focus = kept.toString(); + if (focus.length() >= maxLen) { + return focus; + } + int markerLen = 20; + int budget = maxLen - focus.length() - (focus.isEmpty() ? 0 : markerLen); + if (budget < 8) { + return focus.isEmpty() + ? json.substring(0, Math.min(maxLen, json.length())) + : "{...(truncated)," + focus + "}"; + } + String head = json.substring(0, Math.min(budget, json.length())); + if (focus.isEmpty()) { + return head + (json.length() > head.length() ? "..." : ""); + } + return head + "...[改动字段]..." + focus; + } + + private int findValueEnd(String json, int afterKey) { + int i = afterKey; + while (i < json.length() && (json.charAt(i) == ':' || Character.isWhitespace(json.charAt(i)))) { + i++; + } + 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 depth = 0; + for (; i < json.length(); i++) { + char ch = json.charAt(i); + if (ch == '{' || ch == '[') { + depth++; + } else if (ch == '}' || ch == ']') { + depth--; + if (depth == 0) { + return i + 1; + } + } else if (ch == '"') { + i++; + while (i < json.length()) { + char x = json.charAt(i++); + if (x == '\\' && i < json.length()) { + i++; + } else if (x == '"') { + break; + } + } + i--; + } + } + return json.length(); + } + while (i < json.length() && json.charAt(i) != ',' && json.charAt(i) != '}' && json.charAt(i) != ']') { + i++; + } + return i; + } + + private String lastSegment(String path) { + if (path == null || path.isEmpty()) { + return ""; + } + String p = path; + if (p.endsWith("[]")) { + p = p.substring(0, p.length() - 2); + } + int dot = p.lastIndexOf('.'); + String seg = dot >= 0 ? p.substring(dot + 1) : p; + if (seg.endsWith("[]")) { + seg = seg.substring(0, seg.length() - 2); + } + return seg.replace("[]", ""); + } + + private boolean isScalar(JsonType type) { + return type == JsonType.STRING || type == JsonType.NUMBER + || type == JsonType.BOOLEAN || type == JsonType.UNKNOWN; + } + + private String placeholder(JsonType type) { + if (type == null) { + return "null"; + } + switch (type) { + case NUMBER: + return "0"; + case BOOLEAN: + return "false"; + case STRING: + return "\"\""; + case MAP: + case OBJECT: + return "{}"; + case ARRAY: + return "[]"; + case UNKNOWN: + default: + return "null"; + } + } + + private String escape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static final class Seg { + final String name; + final boolean array; + + Seg(String name, boolean array) { + this.name = name; + this.array = array; + } + } + + private static final class Node { + JsonType type; + boolean leaf; + final Map children = new LinkedHashMap<>(); + + Node(JsonType type) { + this.type = type; + } + } +} diff --git a/src/test/java/com/codechecker/redis/report/ReportBuilderTest.java b/src/test/java/com/codechecker/redis/report/ReportBuilderTest.java index 6bbedd5..9203946 100644 --- a/src/test/java/com/codechecker/redis/report/ReportBuilderTest.java +++ b/src/test/java/com/codechecker/redis/report/ReportBuilderTest.java @@ -11,7 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class ReportBuilderTest { @Test - void markdownTemplateWithoutModeAndSummary() { + void wecomMarkdownShowsSkeletonWithoutSeverityAndLocation() { CheckReport report = new CheckReport(); report.setRepository("jnpf-java-cloud"); report.setBranch("code/redis_change_detection_v1.0"); @@ -19,25 +19,61 @@ class ReportBuilderTest { report.setNewSha("67c8a6eb"); report.setModifier("dongzi"); report.setModifyTime("2026-07-13 16:54:17"); - report.setMode("notify"); - SchemaChange change = new SchemaChange(ChangeType.FIELD_REMOVED); - change.setSeverity(Severity.P0); - change.setKeyPattern("saas:period-config:migration:current"); - change.setWriteLocation("SaasPeriodConfigMigrationRedisSupport#putCurrent:41"); - change.setMessage("删除字段 lastError1"); - report.getChanges().add(change); + 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); + + 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); + report.getKeyChanges().add(key); String md = new ReportBuilder("[Redis数据结构变更]").toMarkdown(report); assertTrue(md.startsWith("## [Redis数据结构变更] jnpf-java-cloud")); assertTrue(md.contains("> **分支**: code/redis_change_detection_v1.0")); - assertTrue(md.contains("> **提交**: cedd161c → 67c8a6eb")); - assertTrue(md.contains("> **提交人**: dongzi")); assertTrue(md.contains("> **时间**: 2026-07-13 16:54:17")); - assertTrue(md.contains(" - **位置**: SaasPeriodConfigMigrationRedisSupport#putCurrent:41")); - assertTrue(md.contains(" - **删除字段**: lastError1")); + assertTrue(md.contains("`tenant:db:content:*`")); + assertTrue(md.contains("value 值从 “{\"dbName\":\"\"")); + assertTrue(md.contains("变更为 “{\"vo\":")); + assertFalse(md.contains("### P0")); + assertFalse(md.contains("位置")); assertFalse(md.contains("模式")); assertFalse(md.contains("汇总")); } + + @Test + void consoleContainsFieldDetailsAndWecomMarkdown() { + CheckReport report = new CheckReport(); + report.setRepository("demo"); + report.setOldSha("aaa"); + report.setNewSha("bbb"); + + SchemaChange detail = new SchemaChange(ChangeType.FIELD_REMOVED); + detail.setSeverity(Severity.P0); + detail.setKeyPattern("k1"); + detail.setWriteLocation("Foo#bar:1"); + detail.setMessage("删除字段 x"); + report.getChanges().add(detail); + + KeyStructureChange key = new KeyStructureChange(); + key.setKeyPattern("k1"); + key.setOldSkeletonJson("{\"x\":\"\"}"); + key.setNewSkeletonJson("{}"); + report.getKeyChanges().add(key); + + String console = new ReportBuilder("[Redis数据结构变更]").toConsole(report); + assertTrue(console.contains("======== 字段明细 ========")); + assertTrue(console.contains("**位置**: Foo#bar:1")); + assertTrue(console.contains("**删除字段**: x")); + assertTrue(console.contains("======== 企微 Markdown ========")); + assertTrue(console.contains("value 值从")); + } } diff --git a/src/test/java/com/codechecker/redis/schema/SkeletonJsonRendererTest.java b/src/test/java/com/codechecker/redis/schema/SkeletonJsonRendererTest.java new file mode 100644 index 0000000..321b864 --- /dev/null +++ b/src/test/java/com/codechecker/redis/schema/SkeletonJsonRendererTest.java @@ -0,0 +1,83 @@ +package com.codechecker.redis.schema; + +import com.codechecker.redis.TestSupport; +import com.codechecker.redis.detector.RedisWritePointDetector; +import com.codechecker.redis.detector.WritePoint; +import com.codechecker.redis.diff.SchemaChange; +import com.codechecker.redis.diff.SchemaDiffer; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkeletonJsonRendererTest { + + @Test + void rendersTenantVoThenEnvelopeSkeletons() { + String tenantVo = TestSupport.fixture("fixtures/tenant/TenantVO.txt"); + String tenantLink = TestSupport.fixture("fixtures/tenant/TenantLinkModel.txt"); + String helperOld = TestSupport.fixture("fixtures/tenant/HelperOld.txt"); + String helperNew = TestSupport.fixture("fixtures/tenant/HelperNew.txt"); + + Set patterns = new HashSet<>(); + patterns.add("W01"); + patterns.add("W02"); + patterns.add("W03"); + + SourceIndex oldIndex = new SourceIndex(); + oldIndex.addSource(tenantVo); + oldIndex.addSource(tenantLink); + oldIndex.addSource(helperOld); + + SourceIndex newIndex = new SourceIndex(); + newIndex.addSource(tenantVo); + newIndex.addSource(tenantLink); + newIndex.addSource(helperNew); + + WritePoint oldWp = new RedisWritePointDetector(oldIndex, patterns) + .detect("Helper.java", helperOld).get(0); + WritePoint newWp = new RedisWritePointDetector(newIndex, patterns) + .detect("Helper.java", helperNew).get(0); + + TypeSchema oldSchema = new JavaSchemaExtractor(oldIndex, 8) + .extract(oldWp.getResolvedValueType(), oldWp.isRootArray()); + TypeSchema newSchema = new JavaSchemaExtractor(newIndex, 8) + .extract(newWp.getResolvedValueType(), newWp.isRootArray()); + + List changes = new SchemaDiffer().diff(oldSchema, newSchema); + Set protectedPaths = changes.stream() + .map(SchemaChange::getFieldPath) + .filter(p -> p != null && !p.isEmpty()) + .collect(Collectors.toCollection(HashSet::new)); + changes.stream() + .filter(c -> c.getOldValue() != null && c.getOldValue().contains(".")) + .forEach(c -> protectedPaths.add(c.getOldValue())); + // also plain old paths without dot + changes.forEach(c -> { + if (c.getOldValue() != null && !c.getOldValue().isEmpty() + && c.getOldValue().indexOf(' ') < 0) { + protectedPaths.add(c.getOldValue()); + } + }); + + SkeletonJsonRenderer renderer = new SkeletonJsonRenderer(); + String oldJson = renderer.render(oldSchema); + String newJson = renderer.render(newSchema); + + assertTrue(oldJson.contains("\"dbName\":\"\"")); + assertTrue(oldJson.contains("\"linkList\":[")); + assertTrue(newJson.contains("\"vo\":{")); + assertTrue(newJson.contains("\"expiresAtMs\":0")); + assertTrue(newJson.contains("\"dbName\":\"\"")); + + String truncated = renderer.render(newSchema, protectedPaths, 80); + assertTrue(truncated.contains("expiresAtMs") || truncated.contains("vo"), + "截断后仍应保留改动相关字段: " + truncated); + assertEquals(true, truncated.length() >= 10); + } +}