feat:
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped

1、多 key 默认拼成 1 条企微通知
2、UTF-8 超过 4096 字节 → 按 key 拆开,每条带完整抬头,依次发送
3、条间间隔 200ms,降低机器人限频风险
4、CI 控制台会标明「超长已按 key 拆为 N 条」并逐条打印
This commit is contained in:
2026-07-14 14:30:24 +08:00
parent 59326ebe3c
commit e7aa970ef8
4 changed files with 252 additions and 40 deletions

View File

@@ -11,6 +11,7 @@ import picocli.CommandLine.Command;
import picocli.CommandLine.Option; import picocli.CommandLine.Option;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
/** /**
@@ -81,9 +82,11 @@ public class CacheSchemaCheckerMain implements Callable<Integer> {
&& (report.hasChanges() || config.getNotify().isNotifyOnClean()); && (report.hasChanges() || config.getNotify().isNotifyOnClean());
if (shouldNotify && !dryRun) { if (shouldNotify && !dryRun) {
String webhook = config.getNotify().getWebhookUrl(); String webhook = config.getNotify().getWebhookUrl();
String markdown = builder.toMarkdown(report); List<String> messages = builder.toWeComMessages(report);
boolean ok = new WeComNotifier().sendMarkdown(webhook, markdown); int ok = new WeComNotifier().sendMarkdownMessages(webhook, messages);
System.out.println("[cache-schema-checker] 企微通知发送: " + (ok ? "成功" : "失败/跳过")); System.out.println("[cache-schema-checker] 企微通知发送: "
+ ok + "/" + messages.size()
+ (messages.size() > 1 ? "(已按 key 拆分)" : ""));
} }
if (report.isBlocked()) { if (report.isBlocked()) {

View File

@@ -9,6 +9,7 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -46,6 +47,15 @@ public class WeComNotifier {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { 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; return true;
} }
System.err.println("[WeComNotifier] 通知失败, HTTP " + response.statusCode() + ": " + response.body()); System.err.println("[WeComNotifier] 通知失败, HTTP " + response.statusCode() + ": " + response.body());
@@ -55,4 +65,36 @@ public class WeComNotifier {
return false; 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;
}
} }

View File

@@ -3,7 +3,9 @@ package com.codechecker.cache.report;
import com.codechecker.cache.diff.SchemaChange; import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity; import com.codechecker.cache.diff.Severity;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -13,18 +15,73 @@ 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>CI先打字段明细再完整输出企微 Markdown</li> * <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
* <li>CI先打字段明细再完整输出企微 Markdown拆分后的每条</li>
* </ul> * </ul>
*/ */
public class ReportBuilder { public class ReportBuilder {
/** 企微 markdown content 字节上限UTF-8。 */
public static final int WECOM_MARKDOWN_MAX_BYTES = 4096;
private final String titlePrefix; private final String titlePrefix;
public ReportBuilder(String titlePrefix) { public ReportBuilder(String titlePrefix) {
this.titlePrefix = titlePrefix == null ? "[缓存结构变更]" : titlePrefix; this.titlePrefix = titlePrefix == null ? "[缓存结构变更]" : titlePrefix;
} }
/**
* 完整单条 Markdown多 key 全部拼接),供本地预览;发送请用 {@link #toWeComMessages}。
*/
public String toMarkdown(CheckReport report) { 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(); StringBuilder sb = new StringBuilder();
sb.append("## ").append(titlePrefix).append(' ') sb.append("## ").append(titlePrefix).append(' ')
.append(nvl(report.getRepository())).append("\n\n"); .append(nvl(report.getRepository())).append("\n\n");
@@ -33,42 +90,54 @@ public class ReportBuilder {
.append("").append(shortSha(report.getNewSha())).append('\n'); .append("").append(shortSha(report.getNewSha())).append('\n');
sb.append("> **提交人**: ").append(nvl(report.getModifier())).append('\n'); sb.append("> **提交人**: ").append(nvl(report.getModifier())).append('\n');
sb.append("> **时间**: ").append(nvl(report.getModifyTime())).append("\n\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(); List<KeyStructureChange> keys = report.getKeyChanges();
if (keys == null || keys.isEmpty()) { if (keys != null && !keys.isEmpty()) {
// 兼容仅有字段明细时退化展示 List<String> bodies = new ArrayList<>(keys.size());
if (report.getChanges().isEmpty()) { for (KeyStructureChange kc : keys) {
sb.append("未检测到缓存序列化结构变更。\n"); bodies.add(renderKeyBlock(kc));
} else {
for (SchemaChange c : report.getChanges()) {
appendKeyLine(sb, c.getKeyPattern());
if (c.getMessage() != null) {
sb.append(" ").append(c.getMessage()).append('\n');
}
}
} }
return sb.toString(); return bodies;
} }
if (report.getChanges().isEmpty()) {
for (KeyStructureChange kc : keys) { return Collections.emptyList();
appendKeyLine(sb, kc.getKeyPattern()); }
String oldJson = nvl(kc.getOldSkeletonJson()); // 无 key 摘要时:整段字段明细作为一块(仍可超长再整体发,不按字段拆)
String newJson = nvl(kc.getNewSkeletonJson()); StringBuilder sb = new StringBuilder();
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails()); for (SchemaChange c : report.getChanges()) {
Set<String> newHighlight = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails()); appendKeyLine(sb, c.getKeyPattern());
String oldRendered = oldJson.isEmpty() if (c.getMessage() != null) {
? "" : "" + SkeletonAnnotator.annotateForWecom(oldJson, oldHighlight) + ""; sb.append(" ").append(c.getMessage()).append('\n');
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 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(); return sb.toString();
} }
@@ -83,7 +152,7 @@ public class ReportBuilder {
} }
/** /**
* CI 控制台:字段明细 + 完整企微 Markdown。 * CI 控制台:字段明细 + 企微 Markdown(若拆分则逐条打印)
*/ */
public String toConsole(CheckReport report) { public String toConsole(CheckReport report) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
@@ -124,8 +193,22 @@ public class ReportBuilder {
sb.append("(无字段级明细)\n\n"); sb.append("(无字段级明细)\n\n");
} }
sb.append("======== 企微 Markdown ========\n"); List<String> messages = toWeComMessages(report);
sb.append(toMarkdown(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(); return sb.toString();
} }
@@ -160,6 +243,10 @@ public class ReportBuilder {
return trimmed; return trimmed;
} }
static int utf8Bytes(String s) {
return s.getBytes(StandardCharsets.UTF_8).length;
}
private String shortSha(String sha) { private String shortSha(String sha) {
if (sha == null) { if (sha == null) {
return ""; return "";

View File

@@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -55,11 +56,15 @@ class ReportBuilderTest {
String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report); String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report);
// Key颜色 + 行内代码(与 ReportBuilder.appendKeyLine 一致)
assertTrue(md.contains( 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 oldPart = md.substring(oldSection, newSection);
String newPart = md.substring(newSection); String newPart = md.substring(newSection);
@@ -126,6 +131,8 @@ class ReportBuilderTest {
assertTrue(console.contains("======== 字段明细 ========")); assertTrue(console.contains("======== 字段明细 ========"));
assertTrue(console.contains("**删除字段**: x")); assertTrue(console.contains("**删除字段**: x"));
assertTrue(console.contains("<font color=\"warning\">\"x\":\"\"</font>")); assertTrue(console.contains("<font color=\"warning\">\"x\":\"\"</font>"));
assertTrue(console.contains("> **value值由**"));
assertTrue(console.contains("> **变更为:**"));
assertFalse(console.contains("`{\"x\":\"\"}`")); assertFalse(console.contains("`{\"x\":\"\"}`"));
} }
@@ -138,4 +145,77 @@ class ReportBuilderTest {
assertTrue(out.contains("<font color=\"warning\">\"dbName\":\"\"</font>")); assertTrue(out.contains("<font color=\"warning\">\"dbName\":\"\"</font>"));
assertFalse(out.contains("<font color=\"warning\">\"id\":\"\"</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();
}
} }