1、多 key 默认拼成 1 条企微通知 2、UTF-8 超过 4096 字节 → 按 key 拆开,每条带完整抬头,依次发送 3、条间间隔 200ms,降低机器人限频风险 4、CI 控制台会标明「超长已按 key 拆为 N 条」并逐条打印
This commit is contained in:
@@ -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<Integer> {
|
||||
&& (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<String> 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()) {
|
||||
|
||||
@@ -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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 / 控制台文本。
|
||||
* <ul>
|
||||
* <li>企微:按 key 展示序列化骨架变更,不含位置/字段明细,不分 P0/P1/P2</li>
|
||||
* <li>CI:先打字段明细,再完整输出企微 Markdown</li>
|
||||
* <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
|
||||
* <li>CI:先打字段明细,再完整输出企微 Markdown(拆分后的每条)</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<String> 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 列表:
|
||||
* <ol>
|
||||
* <li>所有 key 拼成一条,未超长则返回 1 条</li>
|
||||
* <li>超长则按 key 拆分,每条带完整抬头,返回 N 条</li>
|
||||
* </ol>
|
||||
*/
|
||||
public List<String> toWeComMessages(CheckReport report) {
|
||||
String header = buildHeader(report);
|
||||
List<String> 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<String> 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<String> buildKeyBodies(CheckReport report) {
|
||||
List<KeyStructureChange> 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<String> 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<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
|
||||
Set<String> 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<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
|
||||
Set<String> 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<String> 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 "";
|
||||
|
||||
@@ -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 --> <font color=\"warning\">`saas:period-config:migration:current`</font>"));
|
||||
"- <font color=\"warning\">`Key --> saas:period-config:migration:current`</font>"));
|
||||
|
||||
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("<font color=\"warning\">\"x\":\"\"</font>"));
|
||||
assertTrue(console.contains("> **value值由:**"));
|
||||
assertTrue(console.contains("> **变更为:**"));
|
||||
assertFalse(console.contains("`{\"x\":\"\"}`"));
|
||||
}
|
||||
|
||||
@@ -138,4 +145,77 @@ class ReportBuilderTest {
|
||||
assertTrue(out.contains("<font color=\"warning\">\"dbName\":\"\"</font>"));
|
||||
assertFalse(out.contains("<font color=\"warning\">\"id\":\"\"</font>"));
|
||||
}
|
||||
|
||||
@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<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user