feat: 一阶段

This commit is contained in:
2026-07-15 16:48:46 +08:00
parent 14f9f1fce7
commit d884aa36ff
16 changed files with 789 additions and 37 deletions

View File

@@ -3,6 +3,7 @@ package com.codechecker.cache.analyze;
import com.codechecker.cache.config.CheckerConfig;
import com.codechecker.cache.detector.CacheReadHint;
import com.codechecker.cache.detector.CacheReadHintDetector;
import com.codechecker.cache.detector.MqWritePointDetector;
import com.codechecker.cache.detector.RedisWritePointDetector;
import com.codechecker.cache.detector.WritePoint;
import com.codechecker.cache.diff.ChangeType;
@@ -92,8 +93,13 @@ public class SchemaCheckAnalyzer {
}
Set<String> patterns = new HashSet<>(config.getDetection().getPatterns());
Set<String> mqPatterns = new HashSet<>(config.getDetection().getMqPatterns());
RedisWritePointDetector detectorNew = new RedisWritePointDetector(newIndex, patterns);
RedisWritePointDetector detectorOld = new RedisWritePointDetector(oldIndex, patterns);
MqWritePointDetector mqDetectorNew = mqPatterns.isEmpty()
? null : new MqWritePointDetector(newIndex, mqPatterns);
MqWritePointDetector mqDetectorOld = mqPatterns.isEmpty()
? null : new MqWritePointDetector(oldIndex, mqPatterns);
JavaSchemaExtractor extractorNew = new JavaSchemaExtractor(newIndex, config.getDetection().getMaxFieldDepth());
JavaSchemaExtractor extractorOld = new JavaSchemaExtractor(oldIndex, config.getDetection().getMaxFieldDepth());
SchemaDiffer differ = new SchemaDiffer();
@@ -110,9 +116,15 @@ public class SchemaCheckAnalyzer {
boolean fileChanged = changedFiles.contains(path);
String oldContent = fileChanged ? oldContents.get(path) : newContent;
List<WritePoint> newWps = detectorNew.detect(path, newContent);
List<WritePoint> newWps = new ArrayList<>(detectorNew.detect(path, newContent));
List<WritePoint> oldWps = oldContent == null
? new ArrayList<>() : detectorOld.detect(path, oldContent);
? new ArrayList<>() : new ArrayList<>(detectorOld.detect(path, oldContent));
if (mqDetectorNew != null) {
newWps.addAll(mqDetectorNew.detect(path, newContent));
}
if (mqDetectorOld != null && oldContent != null) {
oldWps.addAll(mqDetectorOld.detect(path, oldContent));
}
applyReadHints(newWps, path, newContent, newIndex);
if (oldContent != null) {
applyReadHints(oldWps, path, oldContent, oldIndex);
@@ -128,7 +140,7 @@ public class SchemaCheckAnalyzer {
for (WritePoint nw : newWps) {
newSigs.add(nw.signature());
if (isKeyIgnored(nw.getResolvedKeyPattern()) || isWriterIgnored(nw)) {
if (isWritePointIgnored(nw) || isWriterIgnored(nw)) {
continue;
}
WritePoint ow = oldBySig.get(nw.signature());
@@ -151,7 +163,9 @@ public class SchemaCheckAnalyzer {
} else if (fileChanged) {
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
fillFromWritePoint(c, nw);
c.setMessage("新增缓存写入点value 类型: " + displayType(nw));
c.setMessage(nw.isMq()
? "新增 MQ 投递点value 类型: " + displayType(nw)
: "新增缓存写入点value 类型: " + displayType(nw));
allChanges.add(c);
TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray());
mergeKeyChange(keyChanges, nw,
@@ -166,10 +180,12 @@ public class SchemaCheckAnalyzer {
if (fileChanged) {
for (WritePoint ow : oldWps) {
if (!newSigs.contains(ow.signature())
&& !isKeyIgnored(ow.getResolvedKeyPattern()) && !isWriterIgnored(ow)) {
&& !isWritePointIgnored(ow) && !isWriterIgnored(ow)) {
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_REMOVED);
fillFromWritePoint(c, ow);
c.setMessage("删除缓存写入点,原 value 类型: " + displayType(ow));
c.setMessage(ow.isMq()
? "删除 MQ 投递点,原 value 类型: " + displayType(ow)
: "删除缓存写入点,原 value 类型: " + displayType(ow));
allChanges.add(c);
TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray());
mergeKeyChange(keyChanges, ow,
@@ -198,8 +214,12 @@ public class SchemaCheckAnalyzer {
n.setWriteLocation(wp.location());
n.setValueType(displayType(wp));
n.setKeyUnresolved(isUnresolvedKey(wp.getResolvedKeyPattern()));
n.setChannel(wp.getChannel());
return n;
});
if (kc.getChannel() == null || kc.getChannel().isEmpty()) {
kc.setChannel(wp.getChannel());
}
if (kc.getWriteLocation() == null || kc.getWriteLocation().isEmpty()) {
kc.setWriteLocation(wp.location());
}
@@ -229,11 +249,12 @@ public class SchemaCheckAnalyzer {
/** 已解析 key 按模式聚合;未解析按「位置+表达式」拆分,避免串单。 */
private String aggregationKey(WritePoint wp) {
String channel = wp.getChannel() == null ? WritePoint.CHANNEL_REDIS : wp.getChannel();
String pattern = wp.getResolvedKeyPattern();
if (!isUnresolvedKey(pattern)) {
return pattern == null ? "<unknown>" : pattern;
return channel + "|" + (pattern == null ? "<unknown>" : pattern);
}
return "unknown|" + nvl(wp.location()) + "|" + nvl(wp.getKeyExpression());
return channel + "|unknown|" + nvl(wp.location()) + "|" + nvl(wp.getKeyExpression());
}
private boolean isUnresolvedKey(String keyPattern) {
@@ -333,8 +354,14 @@ public class SchemaCheckAnalyzer {
}
private String changeDedupKey(SchemaChange c) {
return c.getChangeType() + "|" + c.getKeyPattern() + "|"
+ c.getWriteLocation() + "|" + c.getFieldPath();
ChangeType t = c.getChangeType();
// 新增/删除投递点:按位置区分
if (t == ChangeType.WRITE_POINT_ADDED || t == ChangeType.WRITE_POINT_REMOVED) {
return t + "|" + c.getKeyPattern() + "|" + c.getWriteLocation();
}
// 同一 destination 上多处 send 同源 VO字段级结构变更只保留一条
return t + "|" + c.getKeyPattern() + "|" + c.getFieldPath()
+ "|" + nvl(c.getOldValue()) + "|" + nvl(c.getNewValue());
}
private void enrich(List<SchemaChange> changes, WritePoint wp, double confidence) {
@@ -358,9 +385,7 @@ public class SchemaCheckAnalyzer {
if (isSuppressed(c)) {
continue;
}
String dedupKey = c.getChangeType() + "|" + c.getKeyPattern() + "|"
+ c.getWriteLocation() + "|" + c.getFieldPath();
if (seen.add(dedupKey)) {
if (seen.add(changeDedupKey(c))) {
result.add(c);
}
}
@@ -448,6 +473,9 @@ public class SchemaCheckAnalyzer {
return;
}
for (WritePoint wp : writePoints) {
if (wp.isMq()) {
continue; // Redis W06 不补强 MQ 投递点MQ-R 为 Phase M2
}
enrichWritePointFromHints(wp, hints);
}
}
@@ -555,6 +583,13 @@ public class SchemaCheckAnalyzer {
return false;
}
private boolean isWritePointIgnored(WritePoint wp) {
if (wp.isMq()) {
return isMqDestinationIgnored(wp.getResolvedKeyPattern());
}
return isKeyIgnored(wp.getResolvedKeyPattern());
}
private boolean isKeyIgnored(String keyPattern) {
if (keyPattern == null) {
return false;
@@ -567,6 +602,18 @@ public class SchemaCheckAnalyzer {
return false;
}
private boolean isMqDestinationIgnored(String destination) {
if (destination == null) {
return false;
}
for (String glob : config.getIgnore().getMqDestinations()) {
if (GlobMatcher.matches(glob, destination)) {
return true;
}
}
return false;
}
private boolean isWriterIgnored(WritePoint wp) {
String sig = wp.getEnclosingClass() + "#" + wp.getEnclosingMethod();
return config.getIgnore().getWriterMethods().contains(sig);

View File

@@ -80,6 +80,8 @@ public class CheckerConfig {
private List<String> keyPatterns = new ArrayList<>();
private List<String> filePatterns = new ArrayList<>();
private List<String> writerMethods = new ArrayList<>();
/** 忽略的 MQ destination 模式topic / topic:tag */
private List<String> mqDestinations = new ArrayList<>();
public List<String> getKeyPatterns() {
return keyPatterns;
@@ -104,14 +106,26 @@ public class CheckerConfig {
public void setWriterMethods(List<String> writerMethods) {
this.writerMethods = writerMethods;
}
public List<String> getMqDestinations() {
return mqDestinations;
}
public void setMqDestinations(List<String> mqDestinations) {
this.mqDestinations = mqDestinations;
}
}
public static class Detection {
private List<String> patterns = new ArrayList<>();
/** MQ 投递检测模式MQ01/MQ02/MQ-K01/MQ-K02… */
private List<String> mqPatterns = new ArrayList<>();
private double minConfidence = 0.6;
private int maxFieldDepth = 8;
/** W06是否启用读侧反序列化类型辅助补强 */
private boolean readHintsEnabled = true;
/** MQ-R读侧 Listener / parse 补强Phase M2M1 默认 false */
private boolean mqReadHintsEnabled = false;
public List<String> getPatterns() {
return patterns;
@@ -121,6 +135,14 @@ public class CheckerConfig {
this.patterns = patterns;
}
public List<String> getMqPatterns() {
return mqPatterns;
}
public void setMqPatterns(List<String> mqPatterns) {
this.mqPatterns = mqPatterns;
}
public double getMinConfidence() {
return minConfidence;
}
@@ -144,6 +166,14 @@ public class CheckerConfig {
public void setReadHintsEnabled(boolean readHintsEnabled) {
this.readHintsEnabled = readHintsEnabled;
}
public boolean isMqReadHintsEnabled() {
return mqReadHintsEnabled;
}
public void setMqReadHintsEnabled(boolean mqReadHintsEnabled) {
this.mqReadHintsEnabled = mqReadHintsEnabled;
}
}
public static class ManualMapping {

View File

@@ -94,13 +94,16 @@ public final class ConfigLoader {
ig.setKeyPatterns(strList(ignore.get("key_patterns")));
ig.setFilePatterns(strList(ignore.get("file_patterns")));
ig.setWriterMethods(strList(ignore.get("writer_methods")));
ig.setMqDestinations(strList(ignore.get("mq_destinations")));
Map<String, Object> detection = asMap(map.get("detection"));
CheckerConfig.Detection d = config.getDetection();
d.setPatterns(strList(detection.get("patterns")));
d.setMqPatterns(strList(detection.get("mq_patterns")));
d.setMinConfidence(dbl(detection, "min_confidence", 0.6));
d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8));
d.setReadHintsEnabled(bool(detection, "read_hints_enabled", true));
d.setMqReadHintsEnabled(bool(detection, "mq_read_hints_enabled", false));
Map<String, Object> severityOverrides = asMap(map.get("severity_overrides"));
Map<String, String> so = new LinkedHashMap<>();

View File

@@ -0,0 +1,329 @@
package com.codechecker.cache.detector;
import com.codechecker.cache.key.RedisKeyResolver;
import com.codechecker.cache.schema.SourceIndex;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.CallableDeclaration;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.Parameter;
import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.expr.BinaryExpr;
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
import com.github.javaparser.ast.expr.CastExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.IntegerLiteralExpr;
import com.github.javaparser.ast.expr.LongLiteralExpr;
import com.github.javaparser.ast.expr.MethodCallExpr;
import com.github.javaparser.ast.expr.NameExpr;
import com.github.javaparser.ast.expr.NullLiteralExpr;
import com.github.javaparser.ast.expr.ObjectCreationExpr;
import com.github.javaparser.ast.expr.StringLiteralExpr;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.type.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* 检测 RocketMQ / Kafka 生产侧投递点Phase M1MQ01/MQ02、MQ-K01/MQ-K02
*/
public class MqWritePointDetector {
private static final Set<String> SERIALIZE_METHODS = new HashSet<>(Arrays.asList(
"toJSONString", "toJsonString", "getObjectToString", "toJsonStr", "writeValueAsString"));
private static final Set<String> ROCKET_SYNC = new HashSet<>(Arrays.asList("syncSend"));
private static final Set<String> ROCKET_ASYNC = new HashSet<>(Arrays.asList(
"asyncSend", "syncSendOrderly", "sendOneWay", "asyncSendOrderly"));
private static final Set<String> TRIVIAL_VALUE_CALLS = new HashSet<>(Arrays.asList(
"randomUUID", "toString", "valueOf"));
private static final Set<String> COLLECTION_SIMPLE = new HashSet<>(Arrays.asList(
"List", "ArrayList", "LinkedList", "Set", "HashSet", "Collection"));
private static final Set<String> IGNORE_PAYLOAD_TYPES = new HashSet<>(Arrays.asList(
"String", "byte", "Byte", "MessageExt", "Message", "ProducerRecord"));
private final SourceIndex index;
private final Set<String> enabledPatterns;
private final RedisKeyResolver keyResolver;
public MqWritePointDetector(SourceIndex index, Set<String> enabledPatterns) {
this.index = index;
this.enabledPatterns = enabledPatterns == null ? new HashSet<>() : enabledPatterns;
this.keyResolver = new RedisKeyResolver(index);
}
public List<WritePoint> detect(String filePath, String content) {
List<WritePoint> result = new ArrayList<>();
if (content == null || content.isEmpty() || enabledPatterns.isEmpty()) {
return result;
}
CompilationUnit cu;
try {
cu = StaticJavaParser.parse(content);
} catch (RuntimeException e) {
return result;
}
for (MethodCallExpr mce : cu.findAll(MethodCallExpr.class)) {
WritePoint wp = tryDetectRocketMq(mce, filePath);
if (wp == null) {
wp = tryDetectKafka(mce, filePath);
}
if (wp != null) {
result.add(wp);
}
}
return result;
}
private WritePoint tryDetectRocketMq(MethodCallExpr mce, String filePath) {
String method = mce.getNameAsString();
String pattern = null;
if (ROCKET_SYNC.contains(method) && enabledPatterns.contains("MQ01")) {
pattern = "MQ01";
} else if (ROCKET_ASYNC.contains(method) && enabledPatterns.contains("MQ02")) {
pattern = "MQ02";
}
if (pattern == null || !isRocketMqScope(mce) || mce.getArguments().size() < 2) {
return null;
}
Expression destArg = mce.getArgument(0);
Expression payloadArg = mce.getArgument(1);
return buildMqWritePoint(mce, filePath, pattern, WritePoint.CHANNEL_ROCKETMQ,
destArg, payloadArg);
}
private WritePoint tryDetectKafka(MethodCallExpr mce, String filePath) {
if (!"send".equals(mce.getNameAsString()) || !isKafkaScope(mce)) {
return null;
}
int argc = mce.getArguments().size();
String pattern;
Expression topicArg;
Expression payloadArg;
if (argc == 2 && enabledPatterns.contains("MQ-K01")) {
pattern = "MQ-K01";
topicArg = mce.getArgument(0);
payloadArg = mce.getArgument(1);
} else if (argc >= 3 && enabledPatterns.contains("MQ-K02")) {
pattern = "MQ-K02";
topicArg = mce.getArgument(0);
payloadArg = mce.getArgument(argc - 1);
} else {
return null;
}
return buildMqWritePoint(mce, filePath, pattern, WritePoint.CHANNEL_KAFKA,
topicArg, payloadArg);
}
private WritePoint buildMqWritePoint(MethodCallExpr mce, String filePath, String pattern,
String channel, Expression destArg, Expression payloadArg) {
Expression serialized = unwrapSerializer(payloadArg);
Expression typeExpr = serialized != null ? serialized : payloadArg;
if (serialized == null && isTrivialValue(payloadArg)) {
return null;
}
if (isBareStringOrBytesPayload(typeExpr, mce)) {
return null;
}
WritePoint wp = new WritePoint();
wp.setFilePath(filePath);
wp.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0));
wp.setPattern(pattern);
wp.setChannel(channel);
wp.setKeyExpression(destArg.toString());
wp.setValueExpression(payloadArg.toString());
fillEnclosing(mce, wp);
SourceIndex.IndexedType context = index.get(wp.getEnclosingClass());
ClassOrInterfaceDeclaration enclosingDecl = mce
.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null);
wp.setResolvedKeyPattern(keyResolver.resolve(destArg, enclosingDecl, context));
InferredType inferred = inferType(typeExpr, mce, context);
if (inferred != null) {
wp.setResolvedValueType(inferred.fqn);
wp.setRootArray(inferred.isArray);
wp.setConfidence(inferred.fqn == null ? 0.4 : 1.0);
} else {
wp.setConfidence(0.4);
}
return wp;
}
private boolean isRocketMqScope(MethodCallExpr mce) {
String scope = mce.getScope().map(Expression::toString).orElse("").toLowerCase();
return scope.contains("rocketmqtemplate") || scope.contains("rocketmq");
}
private boolean isKafkaScope(MethodCallExpr mce) {
String scope = mce.getScope().map(Expression::toString).orElse("").toLowerCase();
return scope.contains("kafkatemplate") || "kafkatemplate".equals(scope);
}
private boolean isBareStringOrBytesPayload(Expression typeExpr, MethodCallExpr contextCall) {
if (typeExpr instanceof StringLiteralExpr) {
return true;
}
if (typeExpr instanceof NameExpr) {
Type declared = findVariableType(((NameExpr) typeExpr).getNameAsString(), contextCall);
if (declared != null) {
String simple = declared.isClassOrInterfaceType()
? declared.asClassOrInterfaceType().getNameAsString()
: declared.asString();
if (IGNORE_PAYLOAD_TYPES.contains(simple) || "byte[]".equals(declared.asString())) {
return true;
}
}
}
return false;
}
private boolean isTrivialValue(Expression expr) {
if (expr instanceof StringLiteralExpr
|| expr instanceof IntegerLiteralExpr
|| expr instanceof LongLiteralExpr
|| expr instanceof BooleanLiteralExpr
|| expr instanceof NullLiteralExpr) {
return true;
}
if (expr instanceof BinaryExpr) {
BinaryExpr be = (BinaryExpr) expr;
if (be.getOperator() == BinaryExpr.Operator.PLUS) {
return isTrivialValue(be.getLeft()) && isTrivialValue(be.getRight());
}
}
if (expr instanceof MethodCallExpr) {
MethodCallExpr call = (MethodCallExpr) expr;
String name = call.getNameAsString();
if (TRIVIAL_VALUE_CALLS.contains(name)) {
return true;
}
if ("valueOf".equals(name) && !call.getArguments().isEmpty()) {
return isTrivialValue(call.getArgument(0));
}
}
if (expr instanceof ObjectCreationExpr) {
return "UUID".equals(((ObjectCreationExpr) expr).getType().getNameAsString());
}
return false;
}
private Expression unwrapSerializer(Expression valueArg) {
if (valueArg instanceof MethodCallExpr) {
MethodCallExpr call = (MethodCallExpr) valueArg;
if (SERIALIZE_METHODS.contains(call.getNameAsString()) && !call.getArguments().isEmpty()) {
return call.getArgument(0);
}
}
return null;
}
private void fillEnclosing(MethodCallExpr mce, WritePoint wp) {
Optional<ClassOrInterfaceDeclaration> clazz = mce.findAncestor(ClassOrInterfaceDeclaration.class);
wp.setEnclosingClass(clazz.map(d -> d.getFullyQualifiedName().orElse(d.getNameAsString()))
.orElse("<unknown>"));
Optional<CallableDeclaration> method = mce.findAncestor(CallableDeclaration.class);
wp.setEnclosingMethod(method.map(CallableDeclaration::getNameAsString).orElse("<unknown>"));
}
private InferredType inferType(Expression expr, MethodCallExpr contextCall,
SourceIndex.IndexedType context) {
if (expr instanceof CastExpr) {
return resolveTypeNode(((CastExpr) expr).getType(), context);
}
if (expr instanceof ObjectCreationExpr) {
return resolveTypeNode(((ObjectCreationExpr) expr).getType(), context);
}
if (expr instanceof NameExpr) {
String name = ((NameExpr) expr).getNameAsString();
Type declared = findVariableType(name, contextCall);
if (declared != null) {
return resolveTypeNode(declared, context);
}
return null;
}
if (expr instanceof MethodCallExpr) {
MethodCallExpr call = (MethodCallExpr) expr;
Optional<ClassOrInterfaceDeclaration> clazz = contextCall
.findAncestor(ClassOrInterfaceDeclaration.class);
if (clazz.isPresent()) {
for (MethodDeclaration md : clazz.get().getMethods()) {
if (md.getNameAsString().equals(call.getNameAsString())) {
return resolveTypeNode(md.getType(), context);
}
}
}
}
return null;
}
private Type findVariableType(String name, MethodCallExpr contextCall) {
Optional<CallableDeclaration> callable = contextCall.findAncestor(CallableDeclaration.class);
if (callable.isPresent()) {
CallableDeclaration<?> decl = callable.get();
for (VariableDeclarator var : decl.findAll(VariableDeclarator.class)) {
if (var.getNameAsString().equals(name)) {
return var.getType();
}
}
for (Parameter p : decl.getParameters()) {
if (p.getNameAsString().equals(name)) {
return p.getType();
}
}
}
Optional<ClassOrInterfaceDeclaration> clazz = contextCall.findAncestor(ClassOrInterfaceDeclaration.class);
if (clazz.isPresent()) {
for (FieldDeclaration field : clazz.get().getFields()) {
for (VariableDeclarator var : field.getVariables()) {
if (var.getNameAsString().equals(name)) {
return var.getType();
}
}
}
}
return null;
}
private InferredType resolveTypeNode(Type type, SourceIndex.IndexedType context) {
if (!(type instanceof ClassOrInterfaceType)) {
return new InferredType(null, false);
}
ClassOrInterfaceType cit = (ClassOrInterfaceType) type;
String simple = cit.getNameAsString();
if (COLLECTION_SIMPLE.contains(simple)) {
Optional<Type> arg = cit.getTypeArguments().filter(a -> !a.isEmpty()).map(a -> a.get(0));
if (arg.isPresent() && arg.get() instanceof ClassOrInterfaceType) {
String elementFqn = resolveFqn((ClassOrInterfaceType) arg.get(), context);
return new InferredType(elementFqn, true);
}
return new InferredType(null, true);
}
return new InferredType(resolveFqn(cit, context), false);
}
private String resolveFqn(ClassOrInterfaceType cit, SourceIndex.IndexedType context) {
String fqn = index.resolveFqn(cit.getNameWithScope(), context);
if (fqn == null) {
fqn = index.resolveFqn(cit.getNameAsString(), context);
}
return fqn;
}
private static final class InferredType {
final String fqn;
final boolean isArray;
InferredType(String fqn, boolean isArray) {
this.fqn = fqn;
this.isArray = isArray;
}
}
}

View File

@@ -1,10 +1,14 @@
package com.codechecker.cache.detector;
/**
* 一个 Redis value 写入点的静态描述。
* 一个 Redis / MQ value 写入(投递)点的静态描述。
*/
public class WritePoint {
public static final String CHANNEL_REDIS = "REDIS";
public static final String CHANNEL_ROCKETMQ = "ROCKETMQ";
public static final String CHANNEL_KAFKA = "KAFKA";
private String filePath;
private int lineNumber;
private String enclosingClass;
@@ -20,6 +24,9 @@ public class WritePoint {
private double confidence = 1.0;
/** REDIS / ROCKETMQ / KAFKA默认 REDIS 兼容现网 */
private String channel = CHANNEL_REDIS;
/** 稳定标识:用于在 old/new 两个版本间配对同一写入点 */
public String signature() {
return enclosingClass + "#" + enclosingMethod + "|" + normalizeKey();
@@ -29,6 +36,10 @@ public class WritePoint {
return keyExpression == null ? "" : keyExpression.replaceAll("\\s+", "");
}
public boolean isMq() {
return CHANNEL_ROCKETMQ.equals(channel) || CHANNEL_KAFKA.equals(channel);
}
public String getFilePath() {
return filePath;
}
@@ -117,6 +128,14 @@ public class WritePoint {
this.confidence = confidence;
}
public String getChannel() {
return channel;
}
public void setChannel(String channel) {
this.channel = channel == null || channel.isEmpty() ? CHANNEL_REDIS : channel;
}
public String location() {
String simpleClass = enclosingClass;
if (simpleClass != null && simpleClass.contains(".")) {

View File

@@ -7,19 +7,21 @@ import java.util.ArrayList;
import java.util.List;
/**
* 按 Redis key或未知 key 时的写入点)聚合后的结构变更摘要。
* 按 Redis key / MQ Topic(或未知时的写入点)聚合后的结构变更摘要。
*/
public class KeyStructureChange {
/** 解析到的 key 模式;未知时为 unknown-key */
/** 解析到的 key / destination 模式;未知时为 unknown-key */
private String keyPattern;
/** 源码中的 key 表达式,如 req.getKey() */
/** 源码中的 key / destination 表达式 */
private String keyExpression;
/** 写入位置 Class#method:line */
private String writeLocation;
/** 展示用 value 类型,如 List&lt;ClockInExportVo&gt; */
private String valueType;
private boolean keyUnresolved;
/** REDIS / ROCKETMQ / KAFKA */
private String channel = "REDIS";
private String oldSkeletonJson;
private String newSkeletonJson;
private Severity severity = Severity.P2;
@@ -65,6 +67,28 @@ public class KeyStructureChange {
this.keyUnresolved = keyUnresolved;
}
public String getChannel() {
return channel;
}
public void setChannel(String channel) {
this.channel = channel == null || channel.isEmpty() ? "REDIS" : channel;
}
public boolean isMq() {
return "ROCKETMQ".equals(channel) || "KAFKA".equals(channel);
}
public String channelDisplay() {
if ("ROCKETMQ".equals(channel)) {
return "RocketMQ";
}
if ("KAFKA".equals(channel)) {
return "Kafka";
}
return "Redis";
}
public String getOldSkeletonJson() {
return oldSkeletonJson;
}
@@ -102,7 +126,7 @@ public class KeyStructureChange {
}
}
/** 通知里 Key 行展示文本:未解析优先用表达式。 */
/** 通知里 Key/Topic 行展示文本:未解析优先用表达式。 */
public String displayKey() {
if (keyUnresolved && keyExpression != null && !keyExpression.trim().isEmpty()) {
return keyExpression.trim();

View File

@@ -8,6 +8,7 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -124,7 +125,10 @@ public class ReportBuilder {
private String renderKeyBlock(KeyStructureChange kc) {
StringBuilder sb = new StringBuilder();
appendKeyLine(sb, kc.displayKey(), kc.getKeyExpression(), kc.isKeyUnresolved());
appendDestinationLine(sb, kc);
if (kc.isMq()) {
sb.append(" > **通道**: ").append(kc.channelDisplay()).append('\n');
}
appendMetaLines(sb, kc.getWriteLocation(), kc.getValueType());
String oldJson = nvl(kc.getOldSkeletonJson());
String newJson = nvl(kc.getNewSkeletonJson());
@@ -142,7 +146,8 @@ public class ReportBuilder {
if (oldJson.isEmpty() && !newJson.isEmpty()) {
sb.append(" > **value 新增为:** ").append(newRendered).append('\n');
} else if (!oldJson.isEmpty() && newJson.isEmpty()) {
sb.append(" > **value 原结构:** ").append(oldRendered).append("(已删除写入)\n");
sb.append(" > **value 原结构:** ").append(oldRendered)
.append(kc.isMq() ? "(已删除投递)\n" : "(已删除写入)\n");
} else {
sb.append(" > **value值由** ").append(oldRendered).append('\n');
sb.append(" > **变更为:** ").append(newRendered).append('\n');
@@ -161,6 +166,7 @@ public class ReportBuilder {
return;
}
List<String> parts = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (SchemaChange c : details) {
if (c == null || c.getChangeType() != ChangeType.TYPE_CHANGED) {
continue;
@@ -171,6 +177,10 @@ public class ReportBuilder {
}
String oldType = displayJavaType(c.getOldValue());
String newType = displayJavaType(c.getNewValue());
String dedup = path + "|" + oldType + "|" + newType;
if (!seen.add(dedup)) {
continue;
}
StringBuilder part = new StringBuilder();
// 字段名用普通文本(避免反引号被企微渲染成色块)
part.append(path);
@@ -213,8 +223,29 @@ public class ReportBuilder {
}
/**
* Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
* 反引号内仍须转义 {@code *},否则企微会把 {@code *:*} 当成斜体吃掉通配符。
* Key / Topic 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
*/
private void appendDestinationLine(StringBuilder sb, KeyStructureChange kc) {
boolean mq = kc.isMq();
String label = mq ? "Topic -->" : "Key -->";
String keyText = kc.isKeyUnresolved() && kc.getKeyExpression() != null
&& !kc.getKeyExpression().trim().isEmpty()
? kc.getKeyExpression().trim()
: nvl(kc.displayKey());
if (keyText.isEmpty()) {
keyText = "unknown-key";
}
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>");
}
sb.append('\n');
}
/**
* Key 行(无 KeyStructureChange 时的退路)。
*/
private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression,
boolean unresolved) {