From cab3fea66632c7efb2e0370a1ca15cf4ee4b462e Mon Sep 17 00:00:00 2001 From: dongzi Date: Tue, 14 Jul 2026 15:49:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E4=BA=8C=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E7=9A=84=E4=BC=98=E5=8C=96=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/实施方案.md | 16 +- docs/配置说明.md | 8 +- .../cache/analyze/FileScanner.java | 30 +-- .../cache/analyze/SchemaCheckAnalyzer.java | 22 +- .../detector/RedisWritePointDetector.java | 196 +++++++++++++----- .../cache/key/RedisKeyResolver.java | 35 +++- .../cache/report/ReportBuilder.java | 2 +- .../cache/schema/AnnotationSupport.java | 117 ++++++++--- .../cache/schema/JavaSchemaExtractor.java | 4 + .../codechecker/cache/schema/SourceIndex.java | 22 +- src/main/resources/default-config.yaml | 2 + .../detector/RedisWritePointDetectorTest.java | 101 +++++++++ .../cache/key/RedisKeyResolverTest.java | 68 ++++++ .../cache/report/ReportBuilderTest.java | 6 +- .../cache/schema/JavaSchemaExtractorTest.java | 84 ++++++++ .../resources/fixtures/lock/LockService.txt | 16 ++ .../fixtures/template/TemplateWrite.txt | 12 ++ 17 files changed, 623 insertions(+), 118 deletions(-) create mode 100644 src/test/java/com/codechecker/cache/detector/RedisWritePointDetectorTest.java create mode 100644 src/test/java/com/codechecker/cache/key/RedisKeyResolverTest.java create mode 100644 src/test/java/com/codechecker/cache/schema/JavaSchemaExtractorTest.java create mode 100644 src/test/resources/fixtures/lock/LockService.txt create mode 100644 src/test/resources/fixtures/template/TemplateWrite.txt diff --git a/docs/实施方案.md b/docs/实施方案.md index f0bc344..99da02f 100644 --- a/docs/实施方案.md +++ b/docs/实施方案.md @@ -513,15 +513,15 @@ jobs: - 流水线 push 后能收到企微通知 - `mode=block` 时任意 P0/P1/P2 变更导致 exit 1 -### Phase 2 — 增强(约 1 周) +### Phase 2 — 增强(约 1 周)✅ -| 任务 | 说明 | -|------|------| -| W04/W05 模式 | RedisTemplate 直写对象、Hash 写入 | -| 注解完整支持 | Fastjson/Jackson 注解 | -| Key 推断增强 | `String.format`、常量追溯 | -| 忽略规则完善 | 锁/计数器/token 自动过滤 | -| 多模块性能优化 | 并行解析、缓存索引 | +| 任务 | 说明 | 状态 | +|------|------|------| +| W04/W05 模式 | RedisTemplate 直写对象、Hash 写入 | 已完成 | +| 注解完整支持 | Fastjson/Jackson 注解(含 `@JsonIgnoreProperties`) | 已完成 | +| Key 推断增强 | `String.format`、常量拼接追溯、`buildXxxKey` | 已完成 | +| 忽略规则完善 | 锁/计数器/token/字面量/setIfAbsent 自动过滤 | 已完成 | +| 多模块性能优化 | 并行读文件、索引批量装载、manual_mappings 覆盖 | 已完成 | ### Phase 3 — 运营(约 0.5 周) diff --git a/docs/配置说明.md b/docs/配置说明.md index aaae850..b16225e 100644 --- a/docs/配置说明.md +++ b/docs/配置说明.md @@ -94,8 +94,8 @@ detection: - W01 # redisUtil.insert + JSON.toJSONString - W02 # redisTemplate.opsForValue().set + JSON.toJSONString - W03 # stringRedisTemplate + JsonUtil.getObjectToString - # - W04 # redisTemplate 直写对象(Phase 2) - # - W05 # opsForHash().put(Phase 2) + - W04 # redisTemplate 直写对象 + - W05 # opsForHash().put # 类型推断最低置信度,低于此值仅输出 P2 提示 min_confidence: 0.6 @@ -195,8 +195,8 @@ mode: block | W01 | `redisUtil.insert(key, JSON.toJSONString(x), ttl)` | Phase 1 | | W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)` | Phase 1 | | W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)` | Phase 1 | -| W04 | `redisTemplate.opsForValue().set(key, obj, ...)` | Phase 2 | -| W05 | `redisTemplate.opsForHash().put(key, field, obj)` | Phase 2 | +| W04 | `redisTemplate.opsForValue().set(key, obj, ...)` | Phase 2(已启用) | +| W05 | `redisTemplate.opsForHash().put(key, field, obj)` | Phase 2(已启用) | ### 3.6 manual_mappings diff --git a/src/main/java/com/codechecker/cache/analyze/FileScanner.java b/src/main/java/com/codechecker/cache/analyze/FileScanner.java index ccc4a47..ba18b52 100644 --- a/src/main/java/com/codechecker/cache/analyze/FileScanner.java +++ b/src/main/java/com/codechecker/cache/analyze/FileScanner.java @@ -31,22 +31,24 @@ public class FileScanner { public Map scan() { Map result = new LinkedHashMap<>(); try (Stream stream = Files.walk(repoRoot)) { - stream.filter(Files::isRegularFile) + List javaFiles = stream + .filter(Files::isRegularFile) .filter(p -> p.toString().endsWith(".java")) - .forEach(p -> { + .filter(p -> { String rel = repoRoot.relativize(p).toString().replace('\\', '/'); - if (!rel.contains("/src/main/java/")) { - return; - } - if (!moduleAllowed(rel)) { - return; - } - try { - result.put(rel, new String(Files.readAllBytes(p), StandardCharsets.UTF_8)); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); + return rel.contains("/src/main/java/") && moduleAllowed(rel); + }) + .collect(java.util.stream.Collectors.toList()); + javaFiles.parallelStream().forEach(p -> { + String rel = repoRoot.relativize(p).toString().replace('\\', '/'); + try { + synchronized (result) { + result.put(rel, new String(Files.readAllBytes(p), StandardCharsets.UTF_8)); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); } catch (IOException e) { throw new UncheckedIOException(e); } diff --git a/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java b/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java index 6f14b2c..09c969b 100644 --- a/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java +++ b/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java @@ -111,6 +111,8 @@ public class SchemaCheckAnalyzer { List newWps = detectorNew.detect(path, newContent); List oldWps = oldContent == null ? new ArrayList<>() : detectorOld.detect(path, oldContent); + newWps.forEach(this::applyManualMappings); + oldWps.forEach(this::applyManualMappings); Map oldBySig = new LinkedHashMap<>(); for (WritePoint wp : oldWps) { @@ -399,12 +401,30 @@ public class SchemaCheckAnalyzer { private SourceIndex buildIndex(Iterable contents) { SourceIndex index = new SourceIndex(); + List list = new ArrayList<>(); for (String content : contents) { - index.addSource(content); + list.add(content); } + index.addSources(list); return index; } + private void applyManualMappings(WritePoint wp) { + String location = wp.getEnclosingClass() + "#" + wp.getEnclosingMethod(); + for (CheckerConfig.ManualMapping mapping : config.getManualMappings()) { + if (mapping.getWriterMethod() == null || !mapping.getWriterMethod().equals(location)) { + continue; + } + if (mapping.getKeyPattern() != null && !mapping.getKeyPattern().isEmpty()) { + wp.setResolvedKeyPattern(mapping.getKeyPattern()); + } + if (mapping.getValueType() != null && !mapping.getValueType().isEmpty()) { + wp.setResolvedValueType(mapping.getValueType()); + wp.setConfidence(Math.max(wp.getConfidence(), 1.0)); + } + } + } + private void collectTypeNames(String content, Set out) { if (content == null || content.isEmpty()) { return; diff --git a/src/main/java/com/codechecker/cache/detector/RedisWritePointDetector.java b/src/main/java/com/codechecker/cache/detector/RedisWritePointDetector.java index 8c7e7b4..8212d41 100644 --- a/src/main/java/com/codechecker/cache/detector/RedisWritePointDetector.java +++ b/src/main/java/com/codechecker/cache/detector/RedisWritePointDetector.java @@ -11,10 +11,17 @@ import com.github.javaparser.ast.body.MethodDeclaration; import com.github.javaparser.ast.body.Parameter; import com.github.javaparser.ast.body.TypeDeclaration; 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; @@ -26,13 +33,19 @@ import java.util.Optional; import java.util.Set; /** - * 从单个 Java 源文件中检测 Redis value 写入点(W01~W03:JSON 字符串写入)。 + * 从单个 Java 源文件中检测 Redis value 写入点(W01~W05)。 */ public class RedisWritePointDetector { private static final Set SERIALIZE_METHODS = new HashSet<>(Arrays.asList( "toJSONString", "toJsonString", "getObjectToString", "toJsonStr", "writeValueAsString")); - private static final Set WRITE_METHODS = new HashSet<>(Arrays.asList("set", "insert")); + private static final Set VALUE_WRITE_METHODS = new HashSet<>(Arrays.asList("set", "insert")); + private static final Set SKIP_METHODS = new HashSet<>(Arrays.asList( + "setIfAbsent", "setIfPresent", "increment", "decrement", "delete", "remove", + "expire", "get", "hasKey", "exists", "setnx", "getAndSet", "getAndDelete", + "multiGet", "multiSet", "keys", "scan")); + private static final Set TRIVIAL_VALUE_CALLS = new HashSet<>(Arrays.asList( + "randomUUID", "toString", "valueOf")); private static final Set COLLECTION_SIMPLE = new HashSet<>(Arrays.asList( "List", "ArrayList", "LinkedList", "Set", "HashSet", "Collection")); @@ -59,58 +72,133 @@ public class RedisWritePointDetector { } for (MethodCallExpr mce : cu.findAll(MethodCallExpr.class)) { - String method = mce.getNameAsString(); - if (!WRITE_METHODS.contains(method)) { - continue; + WritePoint wp = tryDetectValueWrite(mce, filePath); + if (wp == null) { + wp = tryDetectHashWrite(mce, filePath); } - String scope = mce.getScope().map(Expression::toString).orElse(""); - if (!isRedisScope(scope)) { - continue; + if (wp != null) { + result.add(wp); } - if (mce.getArguments().size() < 2) { - continue; - } - Expression valueArg = mce.getArgument(1); - Expression serialized = unwrapSerializer(valueArg); - if (serialized == null) { - continue; // 非 JSON 字符串写入(W04/W05 归后续阶段) - } - String pattern = classify(method, valueArg); - if (!enabledPatterns.contains(pattern)) { - continue; - } - - WritePoint wp = new WritePoint(); - wp.setFilePath(filePath); - wp.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0)); - wp.setPattern(pattern); - wp.setKeyExpression(mce.getArgument(0).toString()); - wp.setValueExpression(valueArg.toString()); - - fillEnclosing(mce, wp); - - SourceIndex.IndexedType context = index.get(wp.getEnclosingClass()); - ClassOrInterfaceDeclaration enclosingDecl = mce - .findAncestor(ClassOrInterfaceDeclaration.class).orElse(null); - wp.setResolvedKeyPattern(keyResolver.resolve(mce.getArgument(0), enclosingDecl, context)); - InferredType inferred = inferType(serialized, 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); - } - result.add(wp); } return result; } - private boolean isRedisScope(String scope) { + private WritePoint tryDetectValueWrite(MethodCallExpr mce, String filePath) { + String method = mce.getNameAsString(); + if (!VALUE_WRITE_METHODS.contains(method) || SKIP_METHODS.contains(method)) { + return null; + } + if (!isValueOpsScope(mce)) { + return null; + } + if (mce.getArguments().size() < 2) { + return null; + } + + Expression valueArg = mce.getArgument(1); + Expression serialized = unwrapSerializer(valueArg); + String pattern; + Expression typeExpr; + if (serialized != null) { + pattern = classifySerialized(method, valueArg); + typeExpr = serialized; + } else if (enabledPatterns.contains("W04") && !isTrivialValue(valueArg)) { + pattern = "W04"; + typeExpr = valueArg; + } else { + return null; + } + if (!enabledPatterns.contains(pattern)) { + return null; + } + return buildWritePoint(mce, filePath, pattern, mce.getArgument(0), valueArg, typeExpr); + } + + private WritePoint tryDetectHashWrite(MethodCallExpr mce, String filePath) { + if (!"put".equals(mce.getNameAsString()) || !enabledPatterns.contains("W05")) { + return null; + } + if (!isHashOpsScope(mce) || mce.getArguments().size() < 3) { + return null; + } + Expression valueArg = mce.getArgument(2); + if (isTrivialValue(valueArg)) { + return null; + } + Expression serialized = unwrapSerializer(valueArg); + Expression typeExpr = serialized != null ? serialized : valueArg; + return buildWritePoint(mce, filePath, "W05", mce.getArgument(0), valueArg, typeExpr); + } + + private WritePoint buildWritePoint(MethodCallExpr mce, String filePath, String pattern, + Expression keyArg, Expression valueArg, Expression typeExpr) { + WritePoint wp = new WritePoint(); + wp.setFilePath(filePath); + wp.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0)); + wp.setPattern(pattern); + wp.setKeyExpression(keyArg.toString()); + wp.setValueExpression(valueArg.toString()); + fillEnclosing(mce, wp); + + SourceIndex.IndexedType context = index.get(wp.getEnclosingClass()); + ClassOrInterfaceDeclaration enclosingDecl = mce + .findAncestor(ClassOrInterfaceDeclaration.class).orElse(null); + wp.setResolvedKeyPattern(keyResolver.resolve(keyArg, 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 isValueOpsScope(MethodCallExpr mce) { + String scope = mce.getScope().map(Expression::toString).orElse(""); String lower = scope.toLowerCase(); return lower.contains("redis") || lower.contains("opsforvalue") || lower.contains("boundvalueops"); } + private boolean isHashOpsScope(MethodCallExpr mce) { + String scope = mce.getScope().map(Expression::toString).orElse(""); + String lower = scope.toLowerCase(); + return lower.contains("opsforhash") || lower.contains("boundhashops"); + } + + 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) { + ObjectCreationExpr oce = (ObjectCreationExpr) expr; + String typeName = oce.getType().getNameAsString(); + return "UUID".equals(typeName); + } + return false; + } + private Expression unwrapSerializer(Expression valueArg) { if (valueArg instanceof MethodCallExpr) { MethodCallExpr call = (MethodCallExpr) valueArg; @@ -121,7 +209,7 @@ public class RedisWritePointDetector { return null; } - private String classify(String method, Expression valueArg) { + private String classifySerialized(String method, Expression valueArg) { String serializer = valueArg instanceof MethodCallExpr ? ((MethodCallExpr) valueArg).getNameAsString() : ""; if ("insert".equals(method)) { @@ -145,6 +233,9 @@ public class RedisWritePointDetector { } private InferredType inferType(Expression expr, MethodCallExpr contextCall, SourceIndex.IndexedType context) { + if (expr instanceof CastExpr) { + return resolveTypeNode(((CastExpr) expr).getType(), context); + } if (expr instanceof ObjectCreationExpr) { ClassOrInterfaceType t = ((ObjectCreationExpr) expr).getType(); return resolveTypeNode(t, context); @@ -157,6 +248,18 @@ public class RedisWritePointDetector { } return null; } + if (expr instanceof MethodCallExpr) { + MethodCallExpr call = (MethodCallExpr) expr; + Optional 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; } @@ -164,20 +267,17 @@ public class RedisWritePointDetector { Optional 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 clazz = contextCall.findAncestor(ClassOrInterfaceDeclaration.class); if (clazz.isPresent()) { for (FieldDeclaration field : clazz.get().getFields()) { diff --git a/src/main/java/com/codechecker/cache/key/RedisKeyResolver.java b/src/main/java/com/codechecker/cache/key/RedisKeyResolver.java index e69dd31..baf8d05 100644 --- a/src/main/java/com/codechecker/cache/key/RedisKeyResolver.java +++ b/src/main/java/com/codechecker/cache/key/RedisKeyResolver.java @@ -20,7 +20,7 @@ import java.util.Optional; */ public class RedisKeyResolver { - private static final int MAX_DEPTH = 6; + private static final int MAX_DEPTH = 8; private final SourceIndex index; @@ -52,7 +52,7 @@ public class RedisKeyResolver { } if (expr instanceof NameExpr) { String name = ((NameExpr) expr).getNameAsString(); - String constVal = lookupConstant(enclosingClass, name); + String constVal = lookupConstant(enclosingClass, name, context, depth); if (constVal != null) { return constVal; } @@ -62,12 +62,23 @@ public class RedisKeyResolver { if (expr instanceof FieldAccessExpr) { FieldAccessExpr fae = (FieldAccessExpr) expr; String fieldName = fae.getNameAsString(); + if (fae.getScope() instanceof NameExpr) { + String scope = ((NameExpr) fae.getScope()).getNameAsString(); + String local = lookupConstant(enclosingClass, fieldName, context, depth); + if (local != null && scope.equals(enclosingClass == null ? "" : enclosingClass.getNameAsString())) { + return local; + } + String external = lookupExternalConstant(scope, fieldName, context, depth); + if (external != null) { + return external; + } + } String scope = fae.getScope().toString(); - String external = lookupExternalConstant(scope, fieldName, context); + String external = lookupExternalConstant(scope, fieldName, context, depth); if (external != null) { return external; } - String local = lookupConstant(enclosingClass, fieldName); + String local = lookupConstant(enclosingClass, fieldName, context, depth); return local != null ? local : "*"; } if (expr instanceof MethodCallExpr) { @@ -77,23 +88,26 @@ public class RedisKeyResolver { String fmt = resolveExpr(call.getArgument(0), enclosingClass, context, depth + 1); return fmt.replaceAll("%[-0-9.]*[sdxDX]", "*"); } - // buildCacheKey(...) 等本类方法:解析其 return 表达式 String methodVal = lookupMethodReturn(enclosingClass, name, context, depth); return methodVal != null ? methodVal : "*"; } return "*"; } - private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name) { + private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name, + SourceIndex.IndexedType context, int depth) { if (clazz == null) { return null; } for (FieldDeclaration field : clazz.getFields()) { + if (!field.isStatic()) { + continue; + } for (VariableDeclarator var : field.getVariables()) { if (var.getNameAsString().equals(name)) { Optional init = var.getInitializer(); - if (init.isPresent() && init.get() instanceof StringLiteralExpr) { - return ((StringLiteralExpr) init.get()).asString(); + if (init.isPresent()) { + return resolveExpr(init.get(), clazz, context, depth + 1); } } } @@ -101,7 +115,8 @@ public class RedisKeyResolver { return null; } - private String lookupExternalConstant(String scopeName, String fieldName, SourceIndex.IndexedType context) { + private String lookupExternalConstant(String scopeName, String fieldName, + SourceIndex.IndexedType context, int depth) { String fqn = index.resolveFqn(scopeName, context); if (fqn == null) { return null; @@ -110,7 +125,7 @@ public class RedisKeyResolver { if (type == null) { return null; } - return lookupConstant(type.getDeclaration(), fieldName); + return lookupConstant(type.getDeclaration(), fieldName, type, depth); } private String lookupMethodReturn(ClassOrInterfaceDeclaration clazz, String methodName, diff --git a/src/main/java/com/codechecker/cache/report/ReportBuilder.java b/src/main/java/com/codechecker/cache/report/ReportBuilder.java index 5d18852..af6a0a5 100644 --- a/src/main/java/com/codechecker/cache/report/ReportBuilder.java +++ b/src/main/java/com/codechecker/cache/report/ReportBuilder.java @@ -15,7 +15,7 @@ import java.util.Set; * 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。 *
    *
  • 企微:按 key 展示位置/类型/序列化骨架变更,不含字段明细,不分 P0/P1/P2
  • - *
  • 未解析 key 展示源码表达式 + 灰色「key 未解析」提示
  • + *
  • 未解析 key 展示源码表达式 + 灰色「key 无法解析」提示
  • *
  • 多 key 优先拼成一条;超过企微上限则按 key 拆成多条
  • *
  • CI:先打字段明细,再完整输出企微 Markdown(拆分后的每条)
  • *
diff --git a/src/main/java/com/codechecker/cache/schema/AnnotationSupport.java b/src/main/java/com/codechecker/cache/schema/AnnotationSupport.java index d76cb8a..8f75d89 100644 --- a/src/main/java/com/codechecker/cache/schema/AnnotationSupport.java +++ b/src/main/java/com/codechecker/cache/schema/AnnotationSupport.java @@ -1,12 +1,19 @@ package com.codechecker.cache.schema; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.body.FieldDeclaration; import com.github.javaparser.ast.expr.AnnotationExpr; +import com.github.javaparser.ast.expr.ArrayInitializerExpr; +import com.github.javaparser.ast.expr.BooleanLiteralExpr; +import com.github.javaparser.ast.expr.Expression; import com.github.javaparser.ast.expr.MemberValuePair; import com.github.javaparser.ast.expr.NormalAnnotationExpr; import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; +import java.util.HashSet; +import java.util.Set; + /** * 处理 Fastjson / Jackson 序列化相关注解:字段忽略与字段名映射。 */ @@ -20,17 +27,12 @@ public final class AnnotationSupport { */ public static boolean isSerialized(FieldDeclaration field) { for (AnnotationExpr annotation : field.getAnnotations()) { - String name = annotation.getNameAsString(); - if (name.equals("JsonIgnore")) { + String name = simpleName(annotation); + if (isIgnoreAnnotation(name)) { return false; } - if (name.equals("JSONField") && annotation instanceof NormalAnnotationExpr) { - for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) { - if (pair.getNameAsString().equals("serialize") - && pair.getValue().toString().equals("false")) { - return false; - } - } + if (name.equals("JSONField") && isJsonFieldSerializeDisabled(annotation)) { + return false; } } return true; @@ -41,38 +43,93 @@ public final class AnnotationSupport { */ public static String jsonName(FieldDeclaration field, String defaultName) { for (AnnotationExpr annotation : field.getAnnotations()) { - String name = annotation.getNameAsString(); + String name = simpleName(annotation); if (name.equals("JsonProperty")) { - String v = singleStringValue(annotation); + String v = stringMember(annotation, "value"); if (v != null && !v.isEmpty()) { return v; } } - if (name.equals("JSONField") && annotation instanceof NormalAnnotationExpr) { - for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) { - if (pair.getNameAsString().equals("name") - && pair.getValue() instanceof StringLiteralExpr) { - String v = ((StringLiteralExpr) pair.getValue()).asString(); - if (!v.isEmpty()) { - return v; - } - } + if (name.equals("JSONField")) { + String v = stringMember(annotation, "name"); + if (v != null && !v.isEmpty()) { + return v; } } } return defaultName; } - private static String singleStringValue(AnnotationExpr annotation) { + /** + * 类级别 @JsonIgnoreProperties 声明的忽略字段名。 + */ + public static Set ignoredProperties(ClassOrInterfaceDeclaration type) { + Set ignored = new HashSet<>(); + for (AnnotationExpr annotation : type.getAnnotations()) { + if (!"JsonIgnoreProperties".equals(simpleName(annotation))) { + continue; + } + collectIgnoredNames(annotation, ignored); + } + return ignored; + } + + private static boolean isIgnoreAnnotation(String name) { + return name.equals("JsonIgnore") + || name.equals("Transient") + || name.equals("JsonIgnoreType"); + } + + private static boolean isJsonFieldSerializeDisabled(AnnotationExpr annotation) { + if (annotation instanceof NormalAnnotationExpr) { + for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) { + if (pair.getNameAsString().equals("serialize") + && isFalseLiteral(pair.getValue())) { + return true; + } + } + } + return false; + } + + private static void collectIgnoredNames(AnnotationExpr annotation, Set out) { if (annotation instanceof SingleMemberAnnotationExpr) { - if (((SingleMemberAnnotationExpr) annotation).getMemberValue() instanceof StringLiteralExpr) { - return ((StringLiteralExpr) ((SingleMemberAnnotationExpr) annotation) - .getMemberValue()).asString(); + addStringArray(((SingleMemberAnnotationExpr) annotation).getMemberValue(), out); + return; + } + if (annotation instanceof NormalAnnotationExpr) { + for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) { + if ("value".equals(pair.getNameAsString())) { + addStringArray(pair.getValue(), out); + } + } + } + } + + private static void addStringArray(Expression expr, Set out) { + if (expr instanceof StringLiteralExpr) { + out.add(((StringLiteralExpr) expr).asString()); + return; + } + if (expr instanceof ArrayInitializerExpr) { + for (Expression value : ((ArrayInitializerExpr) expr).getValues()) { + if (value instanceof StringLiteralExpr) { + out.add(((StringLiteralExpr) value).asString()); + } + } + } + } + + private static String stringMember(AnnotationExpr annotation, String member) { + if (annotation instanceof SingleMemberAnnotationExpr) { + Expression value = ((SingleMemberAnnotationExpr) annotation).getMemberValue(); + if (value instanceof StringLiteralExpr) { + return ((StringLiteralExpr) value).asString(); } } if (annotation instanceof NormalAnnotationExpr) { for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) { - if (pair.getNameAsString().equals("value") + if (pair.getNameAsString().equals(member) && pair.getValue() instanceof StringLiteralExpr) { return ((StringLiteralExpr) pair.getValue()).asString(); } @@ -80,4 +137,14 @@ public final class AnnotationSupport { } return null; } + + private static boolean isFalseLiteral(Expression expr) { + return expr instanceof BooleanLiteralExpr && !((BooleanLiteralExpr) expr).getValue(); + } + + private static String simpleName(AnnotationExpr annotation) { + String name = annotation.getNameAsString(); + int dot = name.lastIndexOf('.'); + return dot >= 0 ? name.substring(dot + 1) : name; + } } diff --git a/src/main/java/com/codechecker/cache/schema/JavaSchemaExtractor.java b/src/main/java/com/codechecker/cache/schema/JavaSchemaExtractor.java index 1486f70..5dbbed8 100644 --- a/src/main/java/com/codechecker/cache/schema/JavaSchemaExtractor.java +++ b/src/main/java/com/codechecker/cache/schema/JavaSchemaExtractor.java @@ -79,6 +79,7 @@ public class JavaSchemaExtractor { Set nextAncestors = new LinkedHashSet<>(ancestors); nextAncestors.add(type.getFqn()); + Set classIgnored = AnnotationSupport.ignoredProperties(type.getDeclaration()); for (FieldDeclaration field : collectFields(type, new HashSet<>())) { if (field.isStatic() || field.isTransient()) { continue; @@ -88,6 +89,9 @@ public class JavaSchemaExtractor { } for (VariableDeclarator var : field.getVariables()) { String jsonName = AnnotationSupport.jsonName(field, var.getNameAsString()); + if (classIgnored.contains(jsonName) || classIgnored.contains(var.getNameAsString())) { + continue; + } String path = prefix.isEmpty() ? jsonName : prefix + "." + jsonName; expandType(var.getType(), path, type, schema, nextAncestors, depth); } diff --git a/src/main/java/com/codechecker/cache/schema/SourceIndex.java b/src/main/java/com/codechecker/cache/schema/SourceIndex.java index 5271b88..8c34ae1 100644 --- a/src/main/java/com/codechecker/cache/schema/SourceIndex.java +++ b/src/main/java/com/codechecker/cache/schema/SourceIndex.java @@ -8,9 +8,10 @@ import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.body.TypeDeclaration; import java.util.ArrayList; -import java.util.LinkedHashMap; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * 一次提交快照下的源码类型索引。仅索引本仓库源码(不含依赖 jar),供类型解析与字段展开使用。 @@ -21,9 +22,9 @@ import java.util.Map; public class SourceIndex { /** FQN(以 . 分隔,含内部类) -> 类型信息 */ - private final Map byFqn = new LinkedHashMap<>(); + private final Map byFqn = new ConcurrentHashMap<>(); /** 简单类名 -> FQN 列表(兜底解析) */ - private final Map> bySimpleName = new LinkedHashMap<>(); + private final Map> bySimpleName = new ConcurrentHashMap<>(); static { ParserConfiguration config = new ParserConfiguration() @@ -31,6 +32,20 @@ public class SourceIndex { StaticJavaParser.setConfiguration(config); } + /** + * 批量解析源文件并建立索引。 + *

文件读取可在 {@link com.codechecker.cache.analyze.FileScanner} 中并行; + * AST 解析使用 StaticJavaParser 串行写入,避免其全局配置的线程安全问题。

+ */ + public void addSources(Collection contents) { + if (contents == null || contents.isEmpty()) { + return; + } + for (String content : contents) { + addSource(content); + } + } + /** * 解析并加入一个 Java 源文件内容。解析失败时静默跳过(返回 false)。 */ @@ -65,7 +80,6 @@ public class SourceIndex { byFqn.put(fqn, indexed); bySimpleName.computeIfAbsent(simpleName, k -> new ArrayList<>()).add(fqn); } - // 递归内部类型 for (Object member : type.getMembers()) { if (member instanceof TypeDeclaration) { registerType((TypeDeclaration) member, packageName, imports, fqn); diff --git a/src/main/resources/default-config.yaml b/src/main/resources/default-config.yaml index 57113e3..09ff526 100644 --- a/src/main/resources/default-config.yaml +++ b/src/main/resources/default-config.yaml @@ -42,6 +42,8 @@ detection: - W01 # redisUtil.insert(key, JSON.toJSONString(x), ttl) - W02 # redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...) - W03 # stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...) + - W04 # redisTemplate.opsForValue().set(key, obj, ...) + - W05 # redisTemplate.opsForHash().put(key, field, obj) # 类型推断最低置信度,低于此值降级为 P2 提示 min_confidence: 0.6 # 字段展开最大深度(防止循环引用) diff --git a/src/test/java/com/codechecker/cache/detector/RedisWritePointDetectorTest.java b/src/test/java/com/codechecker/cache/detector/RedisWritePointDetectorTest.java new file mode 100644 index 0000000..086eb19 --- /dev/null +++ b/src/test/java/com/codechecker/cache/detector/RedisWritePointDetectorTest.java @@ -0,0 +1,101 @@ +package com.codechecker.cache.detector; + +import com.codechecker.cache.TestSupport; +import com.codechecker.cache.schema.SourceIndex; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RedisWritePointDetectorTest { + + @Test + void detectsW04DirectObjectWrite() { + String source = "" + + "package demo;\n" + + "import demo.model.DemoVo;\n" + + "public class DemoService {\n" + + " private RedisTemplate redisTemplate;\n" + + " public void save(String key, DemoVo vo) {\n" + + " redisTemplate.opsForValue().set(key, vo, 60, TimeUnit.SECONDS);\n" + + " }\n" + + "}\n"; + String vo = "" + + "package demo.model;\n" + + "public class DemoVo { private String name; }\n"; + + SourceIndex index = new SourceIndex(); + index.addSource(vo); + index.addSource(source); + + Set patterns = new HashSet<>(Arrays.asList("W04")); + List wps = new RedisWritePointDetector(index, patterns) + .detect("DemoService.java", source); + + assertEquals(1, wps.size()); + WritePoint wp = wps.get(0); + assertEquals("W04", wp.getPattern()); + assertEquals("demo.model.DemoVo", wp.getResolvedValueType()); + } + + @Test + void detectsW05HashPut() { + String source = "" + + "package demo;\n" + + "import demo.model.DemoVo;\n" + + "public class DemoService {\n" + + " private RedisTemplate redisTemplate;\n" + + " public void save(String key, String field, DemoVo vo) {\n" + + " redisTemplate.opsForHash().put(key, field, vo);\n" + + " }\n" + + "}\n"; + String vo = "" + + "package demo.model;\n" + + "public class DemoVo { private String name; }\n"; + + SourceIndex index = new SourceIndex(); + index.addSource(vo); + index.addSource(source); + + Set patterns = new HashSet<>(Arrays.asList("W05")); + List wps = new RedisWritePointDetector(index, patterns) + .detect("DemoService.java", source); + + assertEquals(1, wps.size()); + assertEquals("W05", wps.get(0).getPattern()); + assertEquals("demo.model.DemoVo", wps.get(0).getResolvedValueType()); + } + + @Test + void ignoresLockAndLiteralWrites() { + String source = TestSupport.fixture("fixtures/lock/LockService.txt"); + Set patterns = new HashSet<>(Arrays.asList("W01", "W02", "W03", "W04", "W05")); + List wps = new RedisWritePointDetector(new SourceIndex(), patterns) + .detect("LockService.java", source); + assertTrue(wps.isEmpty(), "锁/计数器/token 写入应被忽略"); + } + + @Test + void stillDetectsW01ToW03() { + String helper = TestSupport.fixture("fixtures/tenant/HelperOld.txt"); + String tenantVo = TestSupport.fixture("fixtures/tenant/TenantVO.txt"); + String tenantLink = TestSupport.fixture("fixtures/tenant/TenantLinkModel.txt"); + + SourceIndex index = new SourceIndex(); + index.addSource(tenantVo); + index.addSource(tenantLink); + index.addSource(helper); + + Set patterns = new HashSet<>(Arrays.asList("W01", "W02", "W03")); + List wps = new RedisWritePointDetector(index, patterns) + .detect("Helper.java", helper); + + assertEquals(1, wps.size()); + assertEquals("W01", wps.get(0).getPattern()); + } +} diff --git a/src/test/java/com/codechecker/cache/key/RedisKeyResolverTest.java b/src/test/java/com/codechecker/cache/key/RedisKeyResolverTest.java new file mode 100644 index 0000000..b308907 --- /dev/null +++ b/src/test/java/com/codechecker/cache/key/RedisKeyResolverTest.java @@ -0,0 +1,68 @@ +package com.codechecker.cache.key; + +import com.codechecker.cache.schema.SourceIndex; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.expr.Expression; +import com.github.javaparser.ast.expr.MethodCallExpr; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RedisKeyResolverTest { + + @Test + void resolvesStringFormatWithConstant() { + String source = "" + + "package demo;\n" + + "public class AttendanceService {\n" + + " private static final String ATTENDANCE_BASE_SETTING_CACHE_KEY = " + + "\"fbt:attendance:base_setting:cache:%s\";\n" + + " public void save(String tenantId) {\n" + + " String key = String.format(ATTENDANCE_BASE_SETTING_CACHE_KEY, tenantId);\n" + + " }\n" + + "}\n"; + + SourceIndex index = new SourceIndex(); + index.addSource(source); + CompilationUnit cu = StaticJavaParser.parse(source); + ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration(); + MethodCallExpr formatCall = cu.findAll(MethodCallExpr.class).stream() + .filter(m -> "format".equals(m.getNameAsString())) + .findFirst() + .orElseThrow(IllegalStateException::new); + + String pattern = new RedisKeyResolver(index).resolve( + formatCall, clazz, index.get("demo.AttendanceService")); + assertEquals("fbt:attendance:base_setting:cache:*", pattern); + } + + @Test + void resolvesConstantConcatAndBuildMethod() { + String source = "" + + "package jnpf.util;\n" + + "public class TenantDbContentCacheHelper {\n" + + " private static final String CACHE_KEY_PREFIX = \"tenant:db:content:\";\n" + + " public String buildCacheKey(String encode) {\n" + + " return CACHE_KEY_PREFIX + encode;\n" + + " }\n" + + " public void cache(String encode) {\n" + + " String key = buildCacheKey(encode);\n" + + " }\n" + + "}\n"; + + SourceIndex index = new SourceIndex(); + index.addSource(source); + CompilationUnit cu = StaticJavaParser.parse(source); + ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration(); + MethodCallExpr buildCall = cu.findAll(MethodCallExpr.class).stream() + .filter(m -> "buildCacheKey".equals(m.getNameAsString())) + .findFirst() + .orElseThrow(IllegalStateException::new); + + String pattern = new RedisKeyResolver(index).resolve( + buildCall, clazz, index.get("jnpf.util.TenantDbContentCacheHelper")); + assertEquals("tenant:db:content:*", pattern); + } +} diff --git a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java index 92ed669..882ccf2 100644 --- a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java +++ b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java @@ -59,7 +59,7 @@ class ReportBuilderTest { String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report); assertTrue(md.contains("- Key --> `saas:period-config:migration:current`")); - assertFalse(md.contains("(key 未解析)")); + assertFalse(md.contains("(key 无法解析)")); assertTrue(md.contains("> **位置**: `SaasPeriodConfigMigrationRedisSupport#putCurrent:41`")); assertTrue(md.contains("> **类型**: `MigrationCurrentVo`")); @@ -158,10 +158,10 @@ class ReportBuilderTest { report.getKeyChanges().add(key); String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report); - assertTrue(md.contains("- Key --> `req.getKey()` (key 未解析)")); + assertTrue(md.contains("- Key --> `req.getKey()` (key 无法解析)")); assertTrue(md.contains("> **位置**: `ClockInXxxService#export:128`")); assertTrue(md.contains("> **类型**: `List`")); - assertFalse(md.contains("unknown-key")); + assertFalse(md.contains("`unknown-key`")); } @Test diff --git a/src/test/java/com/codechecker/cache/schema/JavaSchemaExtractorTest.java b/src/test/java/com/codechecker/cache/schema/JavaSchemaExtractorTest.java new file mode 100644 index 0000000..f885ad8 --- /dev/null +++ b/src/test/java/com/codechecker/cache/schema/JavaSchemaExtractorTest.java @@ -0,0 +1,84 @@ +package com.codechecker.cache.schema; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.FieldDeclaration; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JavaSchemaExtractorTest { + + @Test + void honorsJsonIgnoreAndPropertyRename() { + String source = "" + + "package demo;\n" + + "import com.fasterxml.jackson.annotation.JsonIgnore;\n" + + "import com.fasterxml.jackson.annotation.JsonProperty;\n" + + "public class AnnotatedVo {\n" + + " @JsonProperty(\"display_name\")\n" + + " private String name;\n" + + " @JsonIgnore\n" + + " private String secret;\n" + + " private String visible;\n" + + "}\n"; + + SourceIndex index = new SourceIndex(); + index.addSource(source); + TypeSchema schema = new JavaSchemaExtractor(index, 8).extract("demo.AnnotatedVo"); + + assertTrue(schema.getFields().containsKey("display_name")); + assertTrue(schema.getFields().containsKey("visible")); + assertFalse(schema.getFields().containsKey("secret")); + assertFalse(schema.getFields().containsKey("name")); + } + + @Test + void honorsFastjsonFieldAndClassIgnoreProperties() { + String source = "" + + "package demo;\n" + + "import com.alibaba.fastjson.annotation.JSONField;\n" + + "import com.fasterxml.jackson.annotation.JsonIgnoreProperties;\n" + + "@JsonIgnoreProperties({\"password\"})\n" + + "public class FastVo {\n" + + " @JSONField(name = \"user_id\")\n" + + " private String userId;\n" + + " @JSONField(serialize = false)\n" + + " private String token;\n" + + " private String password;\n" + + "}\n"; + + SourceIndex index = new SourceIndex(); + index.addSource(source); + TypeSchema schema = new JavaSchemaExtractor(index, 8).extract("demo.FastVo"); + + assertTrue(schema.getFields().containsKey("user_id")); + assertFalse(schema.getFields().containsKey("token")); + assertFalse(schema.getFields().containsKey("password")); + } + + @Test + void jsonNameHelpersWorkOnFieldAnnotations() { + CompilationUnit cu = StaticJavaParser.parse("" + + "class X {\n" + + " @com.fasterxml.jackson.annotation.JsonProperty(\"alias\")\n" + + " private String field;\n" + + "}"); + FieldDeclaration field = cu.getType(0).asClassOrInterfaceDeclaration().getFields().get(0); + assertEquals("alias", AnnotationSupport.jsonName(field, "field")); + assertTrue(AnnotationSupport.isSerialized(field)); + } + + @Test + void classIgnorePropertiesCollected() { + CompilationUnit cu = StaticJavaParser.parse("" + + "@com.fasterxml.jackson.annotation.JsonIgnoreProperties({\"a\", \"b\"})\n" + + "class X {}"); + ClassOrInterfaceDeclaration type = cu.getType(0).asClassOrInterfaceDeclaration(); + assertTrue(AnnotationSupport.ignoredProperties(type).contains("a")); + assertTrue(AnnotationSupport.ignoredProperties(type).contains("b")); + } +} diff --git a/src/test/resources/fixtures/lock/LockService.txt b/src/test/resources/fixtures/lock/LockService.txt new file mode 100644 index 0000000..06981ec --- /dev/null +++ b/src/test/resources/fixtures/lock/LockService.txt @@ -0,0 +1,16 @@ +package jnpf.lock; + +public class LockService { + + private RedisTemplate redisTemplate; + private RedisUtil redisUtil; + + public void acquire(String bizId) { + redisTemplate.opsForValue().setIfAbsent("order:lock:" + bizId, "1", 30, TimeUnit.SECONDS); + redisTemplate.opsForValue().increment("loginCount:" + bizId); + redisTemplate.delete("temp:" + bizId); + redisUtil.insert("Authorization:" + bizId, "token-abc", 60); + redisTemplate.opsForValue().set("plain:flag", "1", 60, TimeUnit.SECONDS); + redisTemplate.opsForValue().set("uuid:key", UUID.randomUUID().toString(), 60, TimeUnit.SECONDS); + } +} diff --git a/src/test/resources/fixtures/template/TemplateWrite.txt b/src/test/resources/fixtures/template/TemplateWrite.txt new file mode 100644 index 0000000..4d3c689 --- /dev/null +++ b/src/test/resources/fixtures/template/TemplateWrite.txt @@ -0,0 +1,12 @@ +package demo; + +import demo.model.DemoVo; + +public class TemplateService { + + private RedisTemplate redisTemplate; + + public void cache(String key, DemoVo vo) { + redisTemplate.opsForValue().set(key, vo, 3600, TimeUnit.SECONDS); + } +}