feat: 1、企微通知中隐藏,CI中打印字段明细,另外将整体输出到企微中的md,在CI控制台中打印出来
All checks were successful
Redis序列化结构检查 / redis-schema-check (push) Has been skipped

2、整段截断,具体改动的字段不可截断
      3、不再区分P0\P1\P2
This commit is contained in:
2026-07-14 10:43:38 +08:00
parent ea7bcaff2f
commit 7a8ca7ce0b
8 changed files with 752 additions and 27 deletions

View File

@@ -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<SchemaChange> allChanges = new ArrayList<>();
Map<String, KeyStructureChange> 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<SchemaChange> 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<SchemaChange> finalChanges = postProcess(allChanges);
return buildReport(oldSha, newSha, finalChanges);
// 按最终字段明细重新对齐 key 摘要(过滤被 suppress/dedup 掉的)
Map<String, KeyStructureChange> finalKeys = filterKeyChanges(keyChanges, finalChanges);
return buildReport(oldSha, newSha, finalChanges, finalKeys);
}
private void mergeKeyChange(Map<String, KeyStructureChange> keyChanges, String keyPattern,
List<SchemaChange> changes, String oldSkeleton, String newSkeleton) {
String key = keyPattern == null ? "<unknown>" : 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<String> protectedPaths(List<SchemaChange> changes) {
Set<String> 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<String, KeyStructureChange> filterKeyChanges(
Map<String, KeyStructureChange> keyChanges, List<SchemaChange> finalChanges) {
Set<String> liveKeys = new HashSet<>();
for (SchemaChange c : finalChanges) {
liveKeys.add(c.getKeyPattern() == null ? "<unknown>" : c.getKeyPattern());
}
Map<String, KeyStructureChange> result = new LinkedHashMap<>();
for (Map.Entry<String, KeyStructureChange> e : keyChanges.entrySet()) {
if (liveKeys.contains(e.getKey())) {
KeyStructureChange kc = e.getValue();
// 仅保留最终明细中仍存在的字段变更
List<SchemaChange> retained = new ArrayList<>();
Set<String> finalDedup = new HashSet<>();
for (SchemaChange c : finalChanges) {
String k = c.getKeyPattern() == null ? "<unknown>" : 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<SchemaChange> changes, WritePoint wp, double confidence) {
@@ -208,12 +321,14 @@ public class SchemaCheckAnalyzer {
return false;
}
private CheckReport buildReport(String oldSha, String newSha, List<SchemaChange> changes) {
private CheckReport buildReport(String oldSha, String newSha, List<SchemaChange> changes,
Map<String, KeyStructureChange> 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()) {

View File

@@ -70,13 +70,15 @@ public class RedisSchemaCheckerMain implements Callable<Integer> {
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 ? "成功" : "失败/跳过"));
}

View File

@@ -20,11 +20,12 @@ public class CheckReport {
private String mode;
private final List<SchemaChange> changes = new ArrayList<>();
private final List<KeyStructureChange> 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<KeyStructureChange> getKeyChanges() {
return keyChanges;
}
public boolean isBlocked() {
return blocked;
}

View File

@@ -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<SchemaChange> 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<SchemaChange> 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;
}
}
}

View File

@@ -10,6 +10,10 @@ import java.util.Map;
/**
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
* <ul>
* <li>企微:按 key 展示序列化骨架变更,不含位置/字段明细,不分 P0/P1/P2</li>
* <li>CI先打字段明细再完整输出企微 Markdown</li>
* </ul>
*/
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<KeyStructureChange> 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<Severity, List<SchemaChange>> 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<SchemaChange> 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 "";

View File

@@ -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<String> protectedPaths, int maxLen) {
if (schema == null || schema.isEmpty()) {
return "{}";
}
Set<String> 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<Seg> 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<Seg> parsePath(String path) {
List<Seg> 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<String> 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<String, Node> 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<String> 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<String> protectedPaths, int maxLen) {
List<String> 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<String, Node> children = new LinkedHashMap<>();
Node(JsonType type) {
this.type = type;
}
}
}

View File

@@ -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 值从"));
}
}

View File

@@ -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<String> 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<SchemaChange> changes = new SchemaDiffer().diff(oldSchema, newSchema);
Set<String> 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);
}
}