feat: V1.1 - 写入点展示修复

This commit is contained in:
2026-08-03 13:40:40 +08:00
parent 69d581f034
commit 875c0f3ccb
5 changed files with 490 additions and 33 deletions

View File

@@ -17,7 +17,8 @@ import java.util.Set;
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
* <ul>
* <li>企微:按 key 展示位置/类型/序列化骨架变更;类型变更另附摘要行,不分 P0/P1/P2</li>
* <li>未解析 key 展示源码表达式 + 灰色「key 无法解析」提示</li>
* <li>新增/删除写入点:专用文案 + 骨架整段染色(绿/橙)</li>
* <li>未解析 key 展示截断后的源码表达式 + 灰色 mapping 提示</li>
* <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
* <li>CI先打字段明细再完整输出企微 Markdown拆分后的每条</li>
* </ul>
@@ -26,6 +27,8 @@ public class ReportBuilder {
/** 企微 markdown content 字节上限UTF-8。 */
public static final int WECOM_MARKDOWN_MAX_BYTES = 4096;
/** 未解析 destination 表达式展示最大字符数(超出截断)。 */
static final int UNRESOLVED_KEY_DISPLAY_MAX = 80;
private final String titlePrefix;
@@ -130,33 +133,66 @@ public class ReportBuilder {
sb.append(" > **通道**: ").append(kc.channelDisplay()).append('\n');
}
appendMetaLines(sb, kc.getWriteLocation(), kc.getValueType());
String oldJson = nvl(kc.getOldSkeletonJson());
String newJson = nvl(kc.getNewSkeletonJson());
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(kc.getFieldDetails());
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
Set<String> wrappersAdded = SkeletonAnnotator.pathsForWrapperAdded(kc.getFieldDetails());
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails());
String oldRendered = oldJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateOldForWecom(
oldJson, oldHighlight, typeGray, wrappersRemoved) + "";
String newRendered = newJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateNewForWecom(
newJson, newGreen, typeGray, wrappersAdded) + "";
if (oldJson.isEmpty() && !newJson.isEmpty()) {
sb.append(" > **value 新增为:** ").append(newRendered).append('\n');
} else if (!oldJson.isEmpty() && newJson.isEmpty()) {
sb.append(" > **value 原结构:** ").append(oldRendered)
.append(kc.isMq() ? "(已删除投递)\n" : "(已删除写入)\n");
boolean addedOnly = isOnlyChangeType(kc.getFieldDetails(), ChangeType.WRITE_POINT_ADDED);
boolean removedOnly = isOnlyChangeType(kc.getFieldDetails(), ChangeType.WRITE_POINT_REMOVED);
if (oldJson.isEmpty() && !newJson.isEmpty() && addedOnly) {
sb.append(" > **变更类型**: 新增写入点\n");
String label = kc.isMq() ? "新增投递,消息体结构为" : "首次写入该 Keyvalue 结构为";
sb.append(" > **").append(label).append("** “")
.append(SkeletonAnnotator.wrapEntire(newJson, SkeletonAnnotator.COLOR_ADD))
.append("\n");
} else if (!oldJson.isEmpty() && newJson.isEmpty() && removedOnly) {
sb.append(" > **变更类型**: 删除写入点\n");
String label = kc.isMq() ? "原消息体结构" : "原 value 结构";
String suffix = kc.isMq() ? "(投递点已删除)" : "(写入点已删除)";
sb.append(" > **").append(label).append("** “")
.append(SkeletonAnnotator.wrapEntire(oldJson, SkeletonAnnotator.COLOR_REMOVE))
.append("").append(suffix).append('\n');
} else {
sb.append(" > **value值由** ").append(oldRendered).append('\n');
sb.append(" > **变更为:** ").append(newRendered).append('\n');
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(kc.getFieldDetails());
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
Set<String> wrappersAdded = SkeletonAnnotator.pathsForWrapperAdded(kc.getFieldDetails());
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails());
String oldRendered = oldJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateOldForWecom(
oldJson, oldHighlight, typeGray, wrappersRemoved) + "";
String newRendered = newJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateNewForWecom(
newJson, newGreen, typeGray, wrappersAdded) + "";
if (oldJson.isEmpty() && !newJson.isEmpty()) {
// 兜底:有新骨架但明细不是纯 WRITE_POINT_ADDED
sb.append(" > **首次写入value 结构为:** ").append(newRendered).append('\n');
} else if (!oldJson.isEmpty() && newJson.isEmpty()) {
sb.append(" > **原 value 结构:** ").append(oldRendered)
.append(kc.isMq() ? "(投递点已删除)\n" : "(写入点已删除)\n");
} else {
sb.append(" > **value值由** ").append(oldRendered).append('\n');
sb.append(" > **变更为:** ").append(newRendered).append('\n');
}
appendTypeChangeSummary(sb, kc.getFieldDetails());
}
appendTypeChangeSummary(sb, kc.getFieldDetails());
sb.append('\n');
return sb.toString();
}
/** 明细非空且全部为同一变更类型(用于整段新增/删除展示)。 */
private static boolean isOnlyChangeType(List<SchemaChange> details, ChangeType expected) {
if (details == null || details.isEmpty() || expected == null) {
return false;
}
for (SchemaChange c : details) {
if (c == null || c.getChangeType() != expected) {
return false;
}
}
return true;
}
/**
* 类型变更单独一行摘要,避免仅靠骨架颜色/占位难以识别。
* 例:{@code > **类型变更**: amount <font color="warning">BigDecimal → Integer</font>}
@@ -230,7 +266,7 @@ public class ReportBuilder {
String label = mq ? "Topic -->" : "Key -->";
String keyText = kc.isKeyUnresolved() && kc.getKeyExpression() != null
&& !kc.getKeyExpression().trim().isEmpty()
? kc.getKeyExpression().trim()
? truncateDisplay(kc.getKeyExpression().trim())
: nvl(kc.displayKey());
if (keyText.isEmpty()) {
keyText = "unknown-key";
@@ -238,8 +274,8 @@ public class ReportBuilder {
sb.append("- ").append(label).append(" `").append(escapeWeComCode(keyText)).append('`');
if (kc.isKeyUnresolved()) {
sb.append(mq
? " <font color=\"comment\">destination 未解析)</font>"
: " <font color=\"comment\">key 无法解析)</font>");
? " <font color=\"comment\">destination 未解析,建议 manual_mappings 补充</font>"
: " <font color=\"comment\">key 无法解析,建议 manual_mappings 补充</font>");
}
sb.append('\n');
}
@@ -250,18 +286,25 @@ public class ReportBuilder {
private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression,
boolean unresolved) {
String keyText = unresolved && keyExpression != null && !keyExpression.trim().isEmpty()
? keyExpression.trim()
? truncateDisplay(keyExpression.trim())
: nvl(displayKey);
if (keyText.isEmpty()) {
keyText = "unknown-key";
}
sb.append("- Key --> `").append(escapeWeComCode(keyText)).append('`');
if (unresolved) {
sb.append(" <font color=\"comment\">key 无法解析)</font>");
sb.append(" <font color=\"comment\">key 无法解析,建议 manual_mappings 补充</font>");
}
sb.append('\n');
}
static String truncateDisplay(String text) {
if (text == null || text.length() <= UNRESOLVED_KEY_DISPLAY_MAX) {
return text == null ? "" : text;
}
return text.substring(0, UNRESOLVED_KEY_DISPLAY_MAX - 3) + "...";
}
/**
* 企微 markdown 代码片段内的转义:{@code *} 会触发斜体(即使包在反引号里)。
*/
@@ -361,7 +404,8 @@ public class ReportBuilder {
}
String[] knownPrefixes = {
"删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "删除包装层 ",
"新增缓存写入点,", "删除缓存写入点,原 value 类型: "
"新增缓存写入点,", "删除缓存写入点,原 value 类型: ",
"新增 MQ 投递点,", "删除 MQ 投递点,原 value 类型: "
};
for (String prefix : knownPrefixes) {
if (trimmed.startsWith(prefix)) {

View File

@@ -176,6 +176,17 @@ final class SkeletonAnnotator {
return annotate(json, highlightPaths, color);
}
/**
* 整段骨架染色(新增/删除写入点场景):整 JSON 包一层 font。
*/
static String wrapEntire(String json, String color) {
if (json == null || json.isEmpty()) {
return "";
}
String c = color == null || color.isEmpty() ? COLOR_ADD : color;
return "<font color=\"" + c + "\">" + json + FONT_CLOSE;
}
/** @deprecated 使用 {@link #annotateForWecom},勿再整段包代码块 */
static String annotateAsCodeBlock(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths);

View File

@@ -362,12 +362,127 @@ class ReportBuilderTest {
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("- Key --> `req.getKey()` <font color=\"comment\">key 无法解析)</font>"));
assertTrue(md.contains("- Key --> `req.getKey()` <font color=\"comment\">key 无法解析,建议 manual_mappings 补充</font>"));
assertTrue(md.contains("> **位置**: ClockInXxxService#export:128"));
assertTrue(md.contains("> **类型**: List<ClockInExportVo>"));
assertFalse(md.contains("`unknown-key`"));
}
@Test
void writePointAddedMqShowsFriendlyCopyAndWholeSkeletonGreen() {
String skeleton = "{\"objectId\":\"\",\"content\":\"\",\"type\":0,\"pushStatus\":0,"
+ "\"tenantId\":\"\",\"deleteMark\":0,\"executeTime\":0}";
CheckReport report = baseReport();
SchemaChange added = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
added.setMessage("新增 MQ 投递点value 类型: DutyImNotice");
added.setKeyPattern("duty_im_notice_topic");
report.getChanges().add(added);
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("duty_im_notice_topic");
key.setChannel("ROCKETMQ");
key.setWriteLocation("DutyDelayQueueImNoticeService#addMq:43");
key.setValueType("DutyImNotice");
key.setOldSkeletonJson("");
key.setNewSkeletonJson(skeleton);
key.getFieldDetails().add(added);
report.getKeyChanges().add(key);
String md = new ReportBuilder("【序列化结构变更】").toMarkdown(report);
assertTrue(md.contains("- Topic --> `duty_im_notice_topic`"), md);
assertTrue(md.contains("> **通道**: RocketMQ"), md);
assertTrue(md.contains("> **变更类型**: 新增写入点"), md);
assertTrue(md.contains("> **新增投递,消息体结构为:**"), md);
assertTrue(md.contains("<font color=\"info\">" + skeleton + "</font>"), md);
assertFalse(md.contains("value 新增为"), md);
String console = new ReportBuilder("【序列化结构变更】").toConsole(report);
assertTrue(console.contains("**新增 MQ 投递点**: value 类型: DutyImNotice"), console);
}
@Test
void writePointAddedRedisShowsFriendlyCopyAndWholeSkeletonGreen() {
String skeleton = "{\"taskId\":\"\",\"status\":\"\"}";
CheckReport report = baseReport();
SchemaChange added = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
added.setMessage("新增缓存写入点value 类型: MigrationCurrentVo");
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("saas:period-config:migration:current");
key.setWriteLocation("SaasPeriodConfigMigrationRedisSupport#putCurrent:41");
key.setValueType("MigrationCurrentVo");
key.setOldSkeletonJson("");
key.setNewSkeletonJson(skeleton);
key.getFieldDetails().add(added);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("> **变更类型**: 新增写入点"), md);
assertTrue(md.contains("> **首次写入该 Keyvalue 结构为:**"), md);
assertTrue(md.contains("<font color=\"info\">" + skeleton + "</font>"), md);
assertFalse(md.contains("value 新增为"), md);
}
@Test
void writePointRemovedHighlightsWholeOldSkeletonOrange() {
String skeleton = "{\"objectId\":\"\",\"content\":\"\"}";
CheckReport report = baseReport();
SchemaChange removed = new SchemaChange(ChangeType.WRITE_POINT_REMOVED);
removed.setMessage("删除 MQ 投递点,原 value 类型: DutyImNotice");
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("duty_im_notice_topic");
key.setChannel("ROCKETMQ");
key.setWriteLocation("DutyDelayQueueImNoticeService#addMq:43");
key.setValueType("DutyImNotice");
key.setOldSkeletonJson(skeleton);
key.setNewSkeletonJson("");
key.getFieldDetails().add(removed);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("> **变更类型**: 删除写入点"), md);
assertTrue(md.contains("> **原消息体结构:**"), md);
assertTrue(md.contains("<font color=\"warning\">" + skeleton + "</font>"), md);
assertTrue(md.contains("(投递点已删除)"), md);
}
@Test
void fieldLevelChangeStillUsesFragmentHighlightNotWholeWrap() {
CheckReport report = baseReport();
report.getKeyChanges().add(simpleKey("k1", "{\"a\":\"\"}", "{\"a\":\"\",\"b\":\"\"}", "b"));
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("> **value值由**"), md);
assertTrue(md.contains("> **变更为:**"), md);
assertTrue(md.contains("<font color=\"info\">\"b\":\"\"</font>"), md);
assertFalse(md.contains("<font color=\"info\">{\"a\":\"\",\"b\":\"\"}</font>"), md);
assertFalse(md.contains("变更类型"), md);
}
@Test
void unresolvedLongExpressionTruncated() {
StringBuilder expr = new StringBuilder("prefix.");
while (expr.length() < 100) {
expr.append("getVeryLongMethodName()");
}
CheckReport report = baseReport();
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("unknown-key");
key.setKeyExpression(expr.toString());
key.setKeyUnresolved(true);
key.setOldSkeletonJson("{}");
key.setNewSkeletonJson("{\"a\":\"\"}");
SchemaChange added = new SchemaChange(ChangeType.FIELD_ADDED);
added.setFieldPath("a");
key.getFieldDetails().add(added);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("...`"), "应截断并以 ... 结尾,实际:\n" + md);
assertTrue(md.contains("建议 manual_mappings 补充"), md);
assertFalse(md.contains(expr.toString()), "完整超长表达式不应出现");
}
@Test
void pathAwareNestedHighlight() {
String json = "{\"vo\":{\"dbName\":\"\",\"id\":\"\"},\"expiresAtMs\":0}";