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

@@ -0,0 +1,268 @@
# V1.1 新增写入点展示 — 整改方案
> 版本v1.1
> 日期2026-08-03
> 状态:**已落地**
> 关联:`docs/配置说明.md` §5、`docs/V1.1-非结构写入收敛整改方案.md`(建点收敛已落地,本方案只改**展示层**
> 约束:**不新增配置项**;文案与染色为工具默认行为
---
## 1. 背景与实跑样本
### 1.1 企微原文(问题形态)
```text
- Topic --> duty_im_notice_topic
通道: RocketMQ
位置: DutyDelayQueueImNoticeService#addMq:43
类型: DutyImNotice
value 新增为: “{"objectId":"","content":"","type":0,"pushStatus":0,"tenantId":"","deleteMark":0,"executeTime":0}”
```
含义:对比区间内**首次出现**该 Topic 的投递点(`WRITE_POINT_ADDED`),消息体类型与骨架已正确解析,但展示层体验差。
### 1.2 问题拆解
| # | 现象 | 影响 |
|---|------|------|
| P1 | 文案「value 新增为」语义含糊 | 看不出是「新增投递 / 首次写入」,与字段级「变更为」易混淆 |
| P2 | 新骨架整段无绿色 | 字段级变更有 `<font color="info">`,新增整 Key/Topic 时全是明文,**结构颜色未渲染** |
| P3 | 缺少「变更类型」明示 | 读者需自行推断这是新增写入点还是结构变更 |
| P4 | (次要)删除写入点文案不对称 | 「value 原结构 + 已删除投递/写入」可读,但可与新增侧统一语气 |
Redis 侧新增 Key 走同一 `ReportBuilder#renderKeyBlock` 分支(`oldJson` 空、`newJson` 非空),**同病**,须一并改。
### 1.3 非目标
- 不改检测 / Schema 提取 / Diff 逻辑(建点收敛见另一文档)
- 不新增 YAML 开关
- 不改字段级变更的片段染色规则(`FIELD_ADDED` 等仍按路径染色)
- 不改企微 4096 拆条策略
---
## 2. 根因分析
### 2.1 调用链
```text
SchemaCheckAnalyzer
→ WRITE_POINT_ADDED + newSkeletonJson完整骨架
→ KeyStructureChange.fieldDetails = [WRITE_POINT_ADDED] // 无 fieldPath
ReportBuilder.renderKeyBlock
→ pathsForNewSkeleton(details) // 只认 FIELD_ADDED / FIELD_PATH_MOVED
→ newGreen = ∅
→ annotateNewForWecom(json, ∅) → 原样明文
→ 文案固定「value 新增为:**
```
### 2.2 根因表
| 编号 | 根因 | 证据 |
|------|------|------|
| R1 | 新增写入只有 `ChangeType.WRITE_POINT_ADDED`**无字段路径** | `SchemaCheckAnalyzer` 建点逻辑 |
| R2 | `SkeletonAnnotator.pathsForNewSkeleton` **不处理** `WRITE_POINT_ADDED` | 仅 `FIELD_ADDED` / `FIELD_PATH_MOVED` |
| R3 | `ReportBuilder` 对「旧空新有」写死 `value 新增为`,且 **不区分 Redis / MQ** | `renderKeyBlock` 约 146~147 行 |
| R4 | 无「变更类型」行;控制台 `formatDetailMessage` 对 MQ 新增前缀也不完整 | `ReportBuilder` |
结论:**数据已够用(有完整 newJson + 通道 + 类型),缺口全在报告渲染。**
---
## 3. 整改目标
1. 一眼可读:这是 **新增投递 / 首次写入该 Key**,不是字段 diff。
2. 新骨架在企微中有 **绿色** 渲染(整段 `info`)。
3. Redis / MQ 文案区分清楚与字段级「value值由 / 变更为」不打架。
4. 删除写入点展示与新增侧语气对称;控制台明细前缀对齐。
5. 既有字段级染色回归不变。
---
## 4. 方案设计
### 4.1 判定:何时走「整段新增/删除」展示
`renderKeyBlock` 中:
```text
仅当 fieldDetails 全部为 WRITE_POINT_ADDED或全部为 WRITE_POINT_REMOVED
且不混有 FIELD_* / WRAPPER_* / TYPE_CHANGED 时
→ 走「整段染色 + 专用文案」
否则
→ 保持现有片段染色逻辑
```
实跑新增 Topic/Key 场景几乎总是「仅一条 WRITE_POINT_ADDED」命中上述分支。
### 4.2 文案(定稿)
| 场景 | 通道 | 现行 | 整改后 |
|------|------|------|--------|
| 旧空、新有 | Redis | `value 新增为:` | `首次写入该 Keyvalue 结构为:` |
| 旧空、新有 | MQ | 同上 | `新增投递,消息体结构为:` |
| 旧有、新空 | Redis | `value 原结构:…(已删除写入)` | `原 value 结构:…(写入点已删除)` |
| 旧有、新空 | MQ | `…(已删除投递)` | `原消息体结构:…(投递点已删除)` |
| 旧有、新有 | 共用 | `value值由` / `变更为` | **保持不变** |
建议在 meta位置/类型)之后、骨架之前增加一行:
```markdown
> **变更类型**: 新增写入点
```
删除侧:
```markdown
> **变更类型**: 删除写入点
```
字段级变更块不加此行(避免噪音);仅整段新增/删除分支输出。
### 4.3 颜色渲染(定稿)
| 场景 | 染色 |
|------|------|
| 整段新增(仅 WRITE_POINT_ADDED | 新骨架整段包裹 `<font color="info">…</font>`,再外包中文引号 `“…”` |
| 整段删除(仅 WRITE_POINT_REMOVED | 旧骨架整段包裹 `<font color="warning">…</font>` |
| 字段级变更 | **不改**:仍按路径片段染色 |
实现建议(择一,推荐 A
**A. ReportBuilder 内直接整段 wrap简单**
```text
if (isWritePointAddedOnly(details) && !newJson.isEmpty()) {
newRendered = "“" + fontInfo(newJson) + "”";
}
```
**B. SkeletonAnnotator 增加 `wrapAll(json, color)`**
供 ReportBuilder 调用,便于单测与复用。
本方案采用 **A + 可选抽出 wrap 小方法到 SkeletonAnnotator**,避免改路径匹配算法。
企微颜色约定与现网一致:
| 语义 | color |
|------|-------|
| 新增 / 新结构 | `info`(绿) |
| 删除 / 旧结构 | `warning`(橙) |
### 4.4 目标企微形态(对照样本)
整改后期望接近:
```markdown
- Topic --> `duty_im_notice_topic`
> **通道**: RocketMQ
> **位置**: DutyDelayQueueImNoticeService#addMq:43
> **类型**: DutyImNotice
> **变更类型**: 新增写入点
> **新增投递,消息体结构为:** “<font color="info">{"objectId":"","content":"","type":0,"pushStatus":0,"tenantId":"","deleteMark":0,"executeTime":0}</font>”
```
Redis 新增 Key 对称示例:
```markdown
- Key --> `saas:period-config:migration:current`
> **位置**: …
> **类型**: MigrationCurrentVo
> **变更类型**: 新增写入点
> **首次写入该 Keyvalue 结构为:** “<font color="info">{…}</font>”
```
### 4.5 未解析 Key / Topic顺带小优化可选同批
`keyUnresolved`
- 提示由 `key 无法解析)` / `destination 未解析)`
调整为:`key 无法解析,建议 manual_mappings 补充)`MQ 用 destination 措辞)
- 表达式长度 > 80 时截断为前 77 字符 + `...`(常量即可,**不进配置**
本项为体验增强,可与主改动同 PR若排期紧可二期。
### 4.6 控制台明细
`formatDetailMessage``knownPrefixes` 补齐:
```text
"新增 MQ 投递点,"
"删除 MQ 投递点,原 value 类型: "
```
与 analyzer 现有 `setMessage` 对齐,避免 CI 明细不加粗。
---
## 5. 涉及文件
| 文件 | 变更 |
|------|------|
| `report/ReportBuilder.java` | 文案分支、变更类型行、整段染色、可选未解析截断、prefixes |
| `report/SkeletonAnnotator.java` | 可选:`wrapEntire(json, color)` |
| `report/ReportBuilderTest.java` | 新增 WRITE_POINT_ADDEDMQ/Redis展示断言字段级染色回归 |
| `docs/配置说明.md` §5 | 同步新增/删除写入点文案与整段绿色约定 |
**不改**`SchemaCheckAnalyzer` 建点逻辑、`SkeletonJsonRenderer`、检测器。
---
## 6. 测试计划
| 用例 | 输入要点 | 期望 |
|------|----------|------|
| T1 | 仅 `WRITE_POINT_ADDED` + MQ + 非空 newJson对齐 duty_im 样本) | 含「新增投递,消息体结构为」;含「变更类型: 新增写入点」newJson 外包 `<font color="info">` |
| T2 | 仅 `WRITE_POINT_ADDED` + Redis | 含「首次写入该 Keyvalue 结构为」;整段绿 |
| T3 | 仅 `WRITE_POINT_REMOVED` + 非空 oldJson | 「原…结构」+ 整段 `<font color="warning">` |
| T4 | `FIELD_ADDED` 字段级变更(既有) | 仍为片段绿,**不**整段包 info文案仍为 value值由/变更为 |
| T5 | `toConsole` 含「新增 MQ 投递点」 | 明细前缀加粗正确 |
| T6 | (若做)超长未解析 key 表达式 | 截断 + mapping 提示 |
可用最小化 `CheckReport` / `KeyStructureChange` 构造,不必起 git骨架 JSON 可用样本中的 DutyImNotice 字段串。
---
## 7. 风险与回滚
| 风险 | 缓解 |
|------|------|
| 整段绿色在超长 JSON 下刺眼 | 骨架已有 maxLen新增点通常可接受 |
| 企微对超长 font 标签不友好 | 保持 4096 拆条;单块过长仍按 key 拆 |
| 文案变更影响阅读习惯 | 发布说明给对照表§4.2 |
| 误把字段级变更整段染色 | 严格 `isWritePointAddedOnly` 判定 |
回滚:还原 `ReportBuilder` / `SkeletonAnnotator` 展示逻辑即可,与检测无关。
---
## 8. 与「非结构收敛」的边界
| | 非结构写入收敛(已落地) | 本方案(展示) |
|--|-------------------------|----------------|
| 解决什么 | 不该建的点String/`{}`)不再告警 | **该告的点**怎么读得懂、看得见绿 |
| 样本 | RecordingTodo 原文缓存 | DutyImNotice 新增投递(类型/骨架正确) |
| 落点 | Detector + Analyzer | ReportBuilder+ Annotator |
两者互补:收敛后留下的新增写入点,更需要本方案的文案与整段染色。
---
## 9. 实施检查表
- [x] 认可 §4.2 文案与 §4.3 整段染色(不新增配置)
- [x] 同批做未解析 Key 截断§4.5
- [x] 代码落地 + T1~T6
- [x] 同步 `docs/配置说明.md` §5
- [x] 全量 `mvn test`
---
## 10. 文案对照速查
| 场景 | V1.0 | V1.1(本方案) |
|------|------|----------------|
| MQ 新增投递 | `value 新增为:“{…}”`(无色) | `变更类型: 新增写入点` + `新增投递,消息体结构为:“<font color="info">{…}</font>”` |
| Redis 新增写入 | 同上 | `首次写入该 Keyvalue 结构为:“<font color="info">{…}</font>”` |
| 字段级变更 | `value值由` / `变更为` + 片段染色 | 保持 |

View File

@@ -332,9 +332,11 @@ suppressions:
| 字段删除(标在旧骨架) | 橙色 | `<font color="warning">…</font>` | | 字段删除(标在旧骨架) | 橙色 | `<font color="warning">…</font>` |
| 字段新增 / 包装层(标在新骨架) | 绿色 | `<font color="info">…</font>` | | 字段新增 / 包装层(标在新骨架) | 绿色 | `<font color="info">…</font>` |
| 路径迁移 | 旧橙 / 新绿 | 同上 | | 路径迁移 | 旧橙 / 新绿 | 同上 |
| key 未解析提示 | 色 | `<font color="comment">key 未解析)</font>` | | **新增写入点**(整段新骨架) | 绿色 | 整段 `<font color="info">…</font>` |
| **删除写入点**(整段旧骨架) | 橙色 | 整段 `<font color="warning">…</font>` |
| key 未解析提示 | 灰色 | `<font color="comment">key 无法解析,建议 manual_mappings 补充)</font>` |
### 5.3 示例(已解析 key ### 5.3 示例(字段级变更
```markdown ```markdown
## [序列化结构变更] jnpf-java-cloud ## [序列化结构变更] jnpf-java-cloud
@@ -351,19 +353,36 @@ suppressions:
> **变更为:** “…(仅新增/迁移字段片段带 <font color="info">绿色</font>)…” > **变更为:** “…(仅新增/迁移字段片段带 <font color="info">绿色</font>)…”
``` ```
实际发送时仅对改动属性片段染色:新增 → `info`(绿),删除 → `warning`(橙)。 字段级变更仍对改动属性片段染色:新增 → `info`(绿),删除 → `warning`(橙)。
### 5.4 示例(key 未解析 ### 5.4 示例(新增写入点 / 新增投递
```markdown ```markdown
- Key --> `req.getKey()` <font color="comment">key 未解析)</font> - Topic --> `duty_im_notice_topic`
> **通道**: RocketMQ
> **位置**: DutyDelayQueueImNoticeService#addMq:43
> **类型**: DutyImNotice
> **变更类型**: 新增写入点
> **新增投递,消息体结构为:** “<font color="info">{"objectId":"","content":"",…}</font>”
- Key --> `saas:period-config:migration:current`
> **位置**: SaasPeriodConfigMigrationRedisSupport#putCurrent:41
> **类型**: MigrationCurrentVo
> **变更类型**: 新增写入点
> **首次写入该 Keyvalue 结构为:** “<font color="info">{…}</font>”
```
### 5.5 示例key 未解析)
```markdown
- Key --> `req.getKey()` <font color="comment">key 无法解析,建议 manual_mappings 补充)</font>
> **位置**: `ClockInXxxService#export:128` > **位置**: `ClockInXxxService#export:128`
> **类型**: `List<ClockInExportVo>` > **类型**: `List<ClockInExportVo>`
> **value值由** “{"a":""}” > **value值由** “{"a":""}”
> **变更为:** “…新增字段带绿色高亮…” > **变更为:** “…新增字段带绿色高亮…”
``` ```
未解析时按「写入位置 + key 表达式」拆分聚合,避免多个未知 key 串在一起 未解析时按「写入位置 + key 表达式」拆分聚合;表达式过长(>80会截断
--- ---

View File

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

View File

@@ -176,6 +176,17 @@ final class SkeletonAnnotator {
return annotate(json, highlightPaths, color); 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},勿再整段包代码块 */ /** @deprecated 使用 {@link #annotateForWecom},勿再整段包代码块 */
static String annotateAsCodeBlock(String json, Set<String> highlightPaths) { static String annotateAsCodeBlock(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths); return annotateForWecom(json, highlightPaths);

View File

@@ -362,12 +362,127 @@ class ReportBuilderTest {
report.getKeyChanges().add(key); report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report); 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("> **位置**: ClockInXxxService#export:128"));
assertTrue(md.contains("> **类型**: List<ClockInExportVo>")); assertTrue(md.contains("> **类型**: List<ClockInExportVo>"));
assertFalse(md.contains("`unknown-key`")); 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 @Test
void pathAwareNestedHighlight() { void pathAwareNestedHighlight() {
String json = "{\"vo\":{\"dbName\":\"\",\"id\":\"\"},\"expiresAtMs\":0}"; String json = "{\"vo\":{\"dbName\":\"\",\"id\":\"\"},\"expiresAtMs\":0}";