feat: 1、企微通知中隐藏,CI中打印字段明细,另外将整体输出到企微中的md,在CI控制台中打印出来
All checks were successful
Redis序列化结构检查 / redis-schema-check (push) Has been skipped
All checks were successful
Redis序列化结构检查 / redis-schema-check (push) Has been skipped
2、整段截断,具体改动的字段不可截断
3、不再区分P0\P1\P2
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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 ? "成功" : "失败/跳过"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user