From e7aa970ef87bfde3af6a77007c19077d87760bb8 Mon Sep 17 00:00:00 2001 From: dongzi Date: Tue, 14 Jul 2026 14:30:24 +0800 Subject: [PATCH] feat: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1、多 key 默认拼成 1 条企微通知 2、UTF-8 超过 4096 字节 → 按 key 拆开,每条带完整抬头,依次发送 3、条间间隔 200ms,降低机器人限频风险 4、CI 控制台会标明「超长已按 key 拆为 N 条」并逐条打印 --- .../cache/cli/CacheSchemaCheckerMain.java | 9 +- .../cache/notify/WeComNotifier.java | 42 +++++ .../cache/report/ReportBuilder.java | 155 ++++++++++++++---- .../cache/report/ReportBuilderTest.java | 86 +++++++++- 4 files changed, 252 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/codechecker/cache/cli/CacheSchemaCheckerMain.java b/src/main/java/com/codechecker/cache/cli/CacheSchemaCheckerMain.java index ca36351..60ab7d7 100644 --- a/src/main/java/com/codechecker/cache/cli/CacheSchemaCheckerMain.java +++ b/src/main/java/com/codechecker/cache/cli/CacheSchemaCheckerMain.java @@ -11,6 +11,7 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Option; import java.nio.file.Path; +import java.util.List; import java.util.concurrent.Callable; /** @@ -81,9 +82,11 @@ public class CacheSchemaCheckerMain implements Callable { && (report.hasChanges() || config.getNotify().isNotifyOnClean()); if (shouldNotify && !dryRun) { String webhook = config.getNotify().getWebhookUrl(); - String markdown = builder.toMarkdown(report); - boolean ok = new WeComNotifier().sendMarkdown(webhook, markdown); - System.out.println("[cache-schema-checker] 企微通知发送: " + (ok ? "成功" : "失败/跳过")); + List messages = builder.toWeComMessages(report); + int ok = new WeComNotifier().sendMarkdownMessages(webhook, messages); + System.out.println("[cache-schema-checker] 企微通知发送: " + + ok + "/" + messages.size() + + (messages.size() > 1 ? "(已按 key 拆分)" : "")); } if (report.isBlocked()) { diff --git a/src/main/java/com/codechecker/cache/notify/WeComNotifier.java b/src/main/java/com/codechecker/cache/notify/WeComNotifier.java index 2c6a359..e18224c 100644 --- a/src/main/java/com/codechecker/cache/notify/WeComNotifier.java +++ b/src/main/java/com/codechecker/cache/notify/WeComNotifier.java @@ -9,6 +9,7 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -46,6 +47,15 @@ public class WeComNotifier { HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { + // 企微成功时 body 一般为 {"errcode":0,...} + if (response.body() != null && response.body().contains("\"errcode\":0")) { + return true; + } + if (response.body() != null && response.body().contains("errcode") + && !response.body().contains("\"errcode\":0")) { + System.err.println("[WeComNotifier] 通知失败: " + response.body()); + return false; + } return true; } System.err.println("[WeComNotifier] 通知失败, HTTP " + response.statusCode() + ": " + response.body()); @@ -55,4 +65,36 @@ public class WeComNotifier { return false; } } + + /** + * 按序发送多条 Markdown(用于超长按 key 拆分后的通知)。 + * + * @return 成功条数 + */ + public int sendMarkdownMessages(String webhookUrl, List markdownMessages) { + if (markdownMessages == null || markdownMessages.isEmpty()) { + return 0; + } + int ok = 0; + for (int i = 0; i < markdownMessages.size(); i++) { + String content = markdownMessages.get(i); + boolean success = sendMarkdown(webhookUrl, content); + if (success) { + ok++; + } + System.out.println("[WeComNotifier] 第 " + (i + 1) + "/" + markdownMessages.size() + + " 条发送" + (success ? "成功" : "失败") + + " (" + content.getBytes(StandardCharsets.UTF_8).length + " bytes)"); + // 避免触发机器人频率限制(约 20 条/分钟) + if (i < markdownMessages.size() - 1) { + try { + Thread.sleep(200L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + return ok; + } } diff --git a/src/main/java/com/codechecker/cache/report/ReportBuilder.java b/src/main/java/com/codechecker/cache/report/ReportBuilder.java index ca48eeb..c5bbe65 100644 --- a/src/main/java/com/codechecker/cache/report/ReportBuilder.java +++ b/src/main/java/com/codechecker/cache/report/ReportBuilder.java @@ -3,7 +3,9 @@ package com.codechecker.cache.report; import com.codechecker.cache.diff.SchemaChange; import com.codechecker.cache.diff.Severity; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; import java.util.EnumMap; import java.util.List; import java.util.Map; @@ -13,18 +15,73 @@ import java.util.Set; * 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。 *
    *
  • 企微:按 key 展示序列化骨架变更,不含位置/字段明细,不分 P0/P1/P2
  • - *
  • CI:先打字段明细,再完整输出企微 Markdown
  • + *
  • 多 key 优先拼成一条;超过企微上限则按 key 拆成多条
  • + *
  • CI:先打字段明细,再完整输出企微 Markdown(拆分后的每条)
  • *
*/ public class ReportBuilder { + /** 企微 markdown content 字节上限(UTF-8)。 */ + public static final int WECOM_MARKDOWN_MAX_BYTES = 4096; + private final String titlePrefix; public ReportBuilder(String titlePrefix) { this.titlePrefix = titlePrefix == null ? "[缓存结构变更]" : titlePrefix; } + /** + * 完整单条 Markdown(多 key 全部拼接),供本地预览;发送请用 {@link #toWeComMessages}。 + */ public String toMarkdown(CheckReport report) { + List messages = toWeComMessages(report); + if (messages.isEmpty()) { + return ""; + } + if (messages.size() == 1) { + return messages.get(0); + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < messages.size(); i++) { + if (i > 0) { + sb.append("\n---\n"); + } + sb.append(messages.get(i)); + } + return sb.toString(); + } + + /** + * 生成待发送的企微 Markdown 列表: + *
    + *
  1. 所有 key 拼成一条,未超长则返回 1 条
  2. + *
  3. 超长则按 key 拆分,每条带完整抬头,返回 N 条
  4. + *
+ */ + public List toWeComMessages(CheckReport report) { + String header = buildHeader(report); + List bodies = buildKeyBodies(report); + if (bodies.isEmpty()) { + return Collections.singletonList(header + "未检测到缓存序列化结构变更。\n"); + } + + StringBuilder combined = new StringBuilder(header); + for (String body : bodies) { + combined.append(body); + } + String allInOne = combined.toString(); + if (utf8Bytes(allInOne) <= WECOM_MARKDOWN_MAX_BYTES) { + return Collections.singletonList(allInOne); + } + + List split = new ArrayList<>(bodies.size()); + for (String body : bodies) { + split.add(header + body); + } + return split; + } + + private String buildHeader(CheckReport report) { StringBuilder sb = new StringBuilder(); sb.append("## ").append(titlePrefix).append(' ') .append(nvl(report.getRepository())).append("\n\n"); @@ -33,42 +90,54 @@ public class ReportBuilder { .append(" → ").append(shortSha(report.getNewSha())).append('\n'); sb.append("> **提交人**: ").append(nvl(report.getModifier())).append('\n'); sb.append("> **时间**: ").append(nvl(report.getModifyTime())).append("\n\n"); + return sb.toString(); + } + /** + * 每个 key 一段正文(不含抬头)。无 keyChanges 时退化为字段明细段落列表(仍尽量合并)。 + */ + private List buildKeyBodies(CheckReport report) { List keys = report.getKeyChanges(); - if (keys == null || keys.isEmpty()) { - // 兼容仅有字段明细时退化展示 - if (report.getChanges().isEmpty()) { - sb.append("未检测到缓存序列化结构变更。\n"); - } else { - for (SchemaChange c : report.getChanges()) { - appendKeyLine(sb, c.getKeyPattern()); - if (c.getMessage() != null) { - sb.append(" ").append(c.getMessage()).append('\n'); - } - } + if (keys != null && !keys.isEmpty()) { + List bodies = new ArrayList<>(keys.size()); + for (KeyStructureChange kc : keys) { + bodies.add(renderKeyBlock(kc)); } - return sb.toString(); + return bodies; } - - for (KeyStructureChange kc : keys) { - appendKeyLine(sb, kc.getKeyPattern()); - String oldJson = nvl(kc.getOldSkeletonJson()); - String newJson = nvl(kc.getNewSkeletonJson()); - Set oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails()); - Set newHighlight = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails()); - String oldRendered = oldJson.isEmpty() - ? "" : "“" + SkeletonAnnotator.annotateForWecom(oldJson, oldHighlight) + "”"; - String newRendered = newJson.isEmpty() - ? "" : "“" + SkeletonAnnotator.annotateForWecom(newJson, newHighlight) + "”"; - if (oldJson.isEmpty() && !newJson.isEmpty()) { - sb.append(" value 新增为 :").append(newRendered).append("\n\n"); - } else if (!oldJson.isEmpty() && newJson.isEmpty()) { - sb.append(" value 原结构 :").append(oldRendered).append(" 已删除写入\n\n"); - } else { - sb.append(" value 值从 :").append(oldRendered).append('\n'); - sb.append(" 变更为 :").append(newRendered).append("\n\n"); + if (report.getChanges().isEmpty()) { + return Collections.emptyList(); + } + // 无 key 摘要时:整段字段明细作为一块(仍可超长再整体发,不按字段拆) + StringBuilder sb = new StringBuilder(); + for (SchemaChange c : report.getChanges()) { + appendKeyLine(sb, c.getKeyPattern()); + if (c.getMessage() != null) { + sb.append(" ").append(c.getMessage()).append('\n'); } } + return Collections.singletonList(sb.toString()); + } + + private String renderKeyBlock(KeyStructureChange kc) { + StringBuilder sb = new StringBuilder(); + appendKeyLine(sb, kc.getKeyPattern()); + String oldJson = nvl(kc.getOldSkeletonJson()); + String newJson = nvl(kc.getNewSkeletonJson()); + Set oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails()); + Set newHighlight = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails()); + String oldRendered = oldJson.isEmpty() + ? "" : "“" + SkeletonAnnotator.annotateForWecom(oldJson, oldHighlight) + "”"; + String newRendered = newJson.isEmpty() + ? "" : "“" + SkeletonAnnotator.annotateForWecom(newJson, newHighlight) + "”"; + if (oldJson.isEmpty() && !newJson.isEmpty()) { + sb.append(" value 新增为 :").append(newRendered).append("\n\n"); + } else if (!oldJson.isEmpty() && newJson.isEmpty()) { + sb.append(" value 原结构 :").append(oldRendered).append(" 已删除写入\n\n"); + } else { + sb.append(" > **value值由:** ").append(oldRendered).append('\n'); + sb.append(" > **变更为:** ").append(newRendered).append("\n\n"); + } return sb.toString(); } @@ -83,7 +152,7 @@ public class ReportBuilder { } /** - * CI 控制台:字段明细 + 完整企微 Markdown。 + * CI 控制台:字段明细 + 企微 Markdown(若拆分则逐条打印)。 */ public String toConsole(CheckReport report) { StringBuilder sb = new StringBuilder(); @@ -124,8 +193,22 @@ public class ReportBuilder { sb.append("(无字段级明细)\n\n"); } - sb.append("======== 企微 Markdown ========\n"); - sb.append(toMarkdown(report)); + List messages = toWeComMessages(report); + if (messages.size() == 1) { + sb.append("======== 企微 Markdown ========\n"); + sb.append(messages.get(0)); + } else { + sb.append("======== 企微 Markdown(超长已按 key 拆为 ") + .append(messages.size()).append(" 条)========\n"); + for (int i = 0; i < messages.size(); i++) { + sb.append("--- 第 ").append(i + 1).append('/').append(messages.size()) + .append(" 条 (").append(utf8Bytes(messages.get(i))).append(" bytes) ---\n"); + sb.append(messages.get(i)); + if (i < messages.size() - 1) { + sb.append('\n'); + } + } + } return sb.toString(); } @@ -160,6 +243,10 @@ public class ReportBuilder { return trimmed; } + static int utf8Bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8).length; + } + private String shortSha(String sha) { if (sha == null) { return ""; diff --git a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java index 0e6e522..5970efb 100644 --- a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java +++ b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import java.util.Collections; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -55,11 +56,15 @@ class ReportBuilderTest { String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report); + // Key:颜色 + 行内代码(与 ReportBuilder.appendKeyLine 一致) assertTrue(md.contains( - "- Key --> `saas:period-config:migration:current`")); + "- `Key --> saas:period-config:migration:current`")); + + int oldSection = md.indexOf("> **value值由:**"); + int newSection = md.indexOf("> **变更为:**"); + assertTrue(oldSection >= 0, "应包含「value值由」段落,实际 markdown:\n" + md); + assertTrue(newSection > oldSection, "应包含「变更为」段落且在旧值之后,实际 markdown:\n" + md); - int oldSection = md.indexOf("value 值从"); - int newSection = md.indexOf("变更为"); String oldPart = md.substring(oldSection, newSection); String newPart = md.substring(newSection); @@ -126,6 +131,8 @@ class ReportBuilderTest { assertTrue(console.contains("======== 字段明细 ========")); assertTrue(console.contains("**删除字段**: x")); assertTrue(console.contains("\"x\":\"\"")); + assertTrue(console.contains("> **value值由:**")); + assertTrue(console.contains("> **变更为:**")); assertFalse(console.contains("`{\"x\":\"\"}`")); } @@ -138,4 +145,77 @@ class ReportBuilderTest { assertTrue(out.contains("\"dbName\":\"\"")); assertFalse(out.contains("\"id\":\"\"")); } + + @Test + void multipleSmallKeysStayInOneWeComMessage() { + CheckReport report = baseReport(); + report.getKeyChanges().add(simpleKey("k-a", "{\"a\":\"\"}", "{\"a\":\"\",\"x\":\"\"}", "x")); + report.getKeyChanges().add(simpleKey("k-b", "{\"b\":\"\"}", "{\"b\":\"\",\"y\":\"\"}", "y")); + + List messages = new ReportBuilder("[缓存结构变更]").toWeComMessages(report); + assertEquals(1, messages.size()); + assertTrue(messages.get(0).contains("k-a")); + assertTrue(messages.get(0).contains("k-b")); + assertTrue(ReportBuilder.utf8Bytes(messages.get(0)) <= ReportBuilder.WECOM_MARKDOWN_MAX_BYTES); + } + + @Test + void oversizedMultiKeysSplitOneMessagePerKey() { + CheckReport report = baseReport(); + // 两侧骨架各约 1.8KB,两条拼在一起超 4096;单条仍应落在上限内 + String fat = fatJson(1800); + report.getKeyChanges().add(simpleKey("fat-key-1", fat, fat + "1", null)); + report.getKeyChanges().add(simpleKey("fat-key-2", fat, fat + "2", null)); + + List messages = new ReportBuilder("[缓存结构变更]").toWeComMessages(report); + assertEquals(2, messages.size(), "超长应按 key 拆成 2 条"); + assertTrue(messages.get(0).contains("fat-key-1")); + assertFalse(messages.get(0).contains("fat-key-2")); + assertTrue(messages.get(1).contains("fat-key-2")); + assertFalse(messages.get(1).contains("fat-key-1")); + assertTrue(ReportBuilder.utf8Bytes(messages.get(0)) <= ReportBuilder.WECOM_MARKDOWN_MAX_BYTES); + assertTrue(ReportBuilder.utf8Bytes(messages.get(1)) <= ReportBuilder.WECOM_MARKDOWN_MAX_BYTES); + // 抬头在每条中重复 + assertTrue(messages.get(0).contains("## [缓存结构变更] jnpf-java-cloud")); + assertTrue(messages.get(1).contains("## [缓存结构变更] jnpf-java-cloud")); + + String console = new ReportBuilder("[缓存结构变更]").toConsole(report); + assertTrue(console.contains("超长已按 key 拆为 2 条")); + } + + private static CheckReport baseReport() { + CheckReport report = new CheckReport(); + report.setRepository("jnpf-java-cloud"); + report.setBranch("feature/x"); + report.setOldSha("aaa11111"); + report.setNewSha("bbb22222"); + report.setModifier("dongzi"); + report.setModifyTime("2026-07-14 12:00:00"); + return report; + } + + private static KeyStructureChange simpleKey(String pattern, String oldJson, String newJson, + String addedField) { + KeyStructureChange key = new KeyStructureChange(); + key.setKeyPattern(pattern); + key.setOldSkeletonJson(oldJson); + key.setNewSkeletonJson(newJson); + if (addedField != null) { + SchemaChange added = new SchemaChange(ChangeType.FIELD_ADDED); + added.setFieldPath(addedField); + added.setKeyPattern(pattern); + key.getFieldDetails().add(added); + // 保证 hasChanges / 控制台字段明细有数据 + } + return key; + } + + private static String fatJson(int minChars) { + StringBuilder sb = new StringBuilder("{\"pad\":\""); + while (sb.length() < minChars) { + sb.append('x'); + } + sb.append("\"}"); + return sb.toString(); + } }