first commit

This commit is contained in:
2026-06-09 17:57:52 +08:00
parent 518484015e
commit be9ace256d
46 changed files with 5533 additions and 0 deletions

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# ---> Java
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
!.gitea/workflows/code-checker.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
# local env
.env.gitea
gitea-runner/data/
# maven build output提交 .gitea/workflows/code-checker.jar 即可)
.gitea/checker/target/

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.codechecker</groupId>
<artifactId>code-checker</artifactId>
<version>1.0.0</version>
<build>
<finalName>code-checker</finalName>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer>
<mainClass>com.codechecker.CodeCheckMain</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<properties>
<maven.compiler.target>11</maven.compiler.target>
<javaparser.version>3.25.10</javaparser.version>
<maven.compiler.source>11</maven.compiler.source>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

82
pom.xml Normal file
View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.codechecker</groupId>
<artifactId>code-checker</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<javaparser.version>3.25.10</javaparser.version>
</properties>
<dependencies>
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-symbol-solver-core</artifactId>
<version>${javaparser.version}</version>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>2.2</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>4.7.6</version>
</dependency>
</dependencies>
<build>
<finalName>code-checker</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.codechecker.CodeCheckMain</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,178 @@
package com.codechecker;
import com.codechecker.analyzer.ClassChangeAnalyzer;
import com.codechecker.analyzer.DtoNestIndex;
import com.codechecker.analyzer.EndpointIndexBuilder;
import com.codechecker.api.analyzer.ApiChangeAnalyzer;
import com.codechecker.api.analyzer.DtoImpactedApiAnalyzer;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.notify.ApiChangeNotifier;
import com.codechecker.api.scanner.ApiFileChangeScanner;
import com.codechecker.config.AppConfig;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.notify.OverlapNotificationFilter;
import com.codechecker.notify.WeComNotifier;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
/**
* CLI 入口:加载配置 → 扫描 git 变更 → 分析影响 → 输出/发送企微通知。
*/
@Command(name = "code-checker", mixinStandardHelpOptions = true,
description = "检测类变更与 API 变更并发送企业微信通知")
public class CodeCheckMain implements Callable<Integer> {
@Option(names = "--config", required = true, description = "配置文件路径")
private Path config;
@Option(names = "--repo-root", required = true, description = "仓库根目录")
private Path repoRoot;
@Option(names = "--old-sha", required = true, description = "旧提交 SHA")
private String oldSha;
@Option(names = "--new-sha", required = true, description = "新提交 SHA")
private String newSha;
@Option(names = "--modifier", required = true, description = "修改人")
private String modifier;
@Option(names = "--modify-time", required = true, description = "修改时间")
private String modifyTime;
/** 程序入口 */
public static void main(String[] args) {
int exitCode = new CommandLine(new CodeCheckMain()).execute(args);
System.exit(exitCode);
}
/** 主流程:类变更与 API 变更检测,支持 Dto 跟进与重叠通知策略 */
@Override
public Integer call() throws Exception {
AppConfig appConfig = AppConfig.load(config.toAbsolutePath());
if (!appConfig.isMasterEnabled()) {
System.out.println("变更检测已全部关闭checker.enabled=false");
return 0;
}
GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath());
DtoNestIndex nestIndex = DtoNestIndex.build(repoRoot.toAbsolutePath(), appConfig);
List<ClassChangeReport> classReports = List.of();
List<EndpointChangeReport> apiReports = List.of();
if (appConfig.isClassCheckEnabled()) {
classReports = analyzeClassChanges(appConfig, gitScanner, nestIndex);
} else {
System.out.println("类变更检测已关闭class_check.enabled=false");
}
if (appConfig.isApiCheckEnabled()) {
apiReports = analyzeApiChanges(appConfig, gitScanner, classReports, nestIndex);
} else {
System.out.println("API 变更检测已关闭api_check.enabled=false");
}
OverlapNotificationFilter.FilterResult filtered = OverlapNotificationFilter.apply(
classReports, apiReports, appConfig.getDtoOverlapMode(), nestIndex);
int totalSent = sendClassNotifications(appConfig, filtered.classReports())
+ sendApiNotifications(appConfig, filtered.apiReports());
if (totalSent == 0 && appConfig.isOnlyOnChange()) {
System.out.println("无变更,静默退出");
}
return 0;
}
private List<ClassChangeReport> analyzeClassChanges(AppConfig appConfig, GitChangeScanner gitScanner,
DtoNestIndex nestIndex) throws Exception {
System.out.println("=== 类变更检测 ===");
EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder();
Map<String, ApiEndpoint> endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig);
System.out.println("已索引接口数量: " + endpointIndex.size());
ClassChangeAnalyzer analyzer = new ClassChangeAnalyzer(gitScanner);
List<ClassChangeReport> reports = analyzer.analyze(
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex, nestIndex);
System.out.println("检测到需通知的类变更数量: " + reports.size());
return reports;
}
private List<EndpointChangeReport> analyzeApiChanges(AppConfig appConfig, GitChangeScanner gitScanner,
List<ClassChangeReport> classReports,
DtoNestIndex nestIndex) throws Exception {
System.out.println("=== API 变更检测 ===");
ApiFileChangeScanner fileScanner = new ApiFileChangeScanner(gitScanner);
Set<String> changedApiFiles = new LinkedHashSet<>(fileScanner.scanChangedFiles(
repoRoot.toAbsolutePath(), appConfig.getAllApiScanDirs(), oldSha, newSha));
ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner);
List<EndpointChangeReport> reports = new ArrayList<>();
if (!changedApiFiles.isEmpty()) {
reports.addAll(analyzer.analyze(repoRoot.toAbsolutePath(), appConfig, oldSha, newSha));
}
if (appConfig.isDtoApiFollowUpEnabled() && !classReports.isEmpty()) {
DtoImpactedApiAnalyzer dtoAnalyzer = new DtoImpactedApiAnalyzer(gitScanner);
List<EndpointChangeReport> followUpReports = dtoAnalyzer.analyze(
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, classReports, changedApiFiles, nestIndex);
if (!followUpReports.isEmpty()) {
System.out.println("Dto 跟进检测到 API 参数变更数量: " + followUpReports.size());
reports.addAll(followUpReports);
}
}
reports = dedupeApiReports(reports);
System.out.println("检测到需通知的 API 变更数量: " + reports.size());
return reports;
}
private List<EndpointChangeReport> dedupeApiReports(List<EndpointChangeReport> reports) {
Map<String, EndpointChangeReport> merged = new LinkedHashMap<>();
for (EndpointChangeReport report : reports) {
String key = report.getChangeKind() + "|" + report.getHttpMethod() + "|" + report.getUri();
EndpointChangeReport existing = merged.get(key);
if (existing == null) {
merged.put(key, report);
continue;
}
if (report.getChangeKind() == ApiChangeKind.PARAM_CHANGED
&& existing.getChangeKind() == ApiChangeKind.PARAM_CHANGED) {
report.getParameterChanges().forEach(existing::addParameterChange);
}
}
return new ArrayList<>(merged.values());
}
private int sendClassNotifications(AppConfig appConfig, List<ClassChangeReport> reports) {
if (reports.isEmpty()) {
return 0;
}
WeComNotifier notifier = new WeComNotifier();
if (appConfig.isWecomEnabled()) {
return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime);
}
notifier.logAll(reports, modifier, modifyTime);
return reports.size();
}
private int sendApiNotifications(AppConfig appConfig, List<EndpointChangeReport> reports) {
if (reports.isEmpty()) {
return 0;
}
ApiChangeNotifier notifier = new ApiChangeNotifier();
return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime,
appConfig.isWecomEnabled());
}
}

View File

@@ -0,0 +1,131 @@
package com.codechecker.analyzer;
import com.codechecker.config.AppConfig;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.ChangedClassFile;
import com.codechecker.model.ClassChangeKind;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.model.FieldChange;
import com.codechecker.model.FieldInfo;
import com.codechecker.parser.ClassDeclParser;
import com.codechecker.parser.ClassFieldParser;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 编排 git 扫描、字段 diff、影响分析生成待通知的 ClassChangeReport 列表。
*/
public class ClassChangeAnalyzer {
private final GitChangeScanner gitScanner;
private final ClassFieldParser classFieldParser = new ClassFieldParser();
private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine();
private final ImpactAnalyzer impactAnalyzer = new ImpactAnalyzer();
private final ClassDeclParser classDeclParser = new ClassDeclParser();
public ClassChangeAnalyzer(GitChangeScanner gitScanner) {
this.gitScanner = gitScanner;
}
/** 扫描变更文件并逐条分析,无实质变更的 MODIFIED 会被跳过 */
public List<ClassChangeReport> analyze(Path repoRoot, AppConfig config, String oldSha, String newSha,
Map<String, com.codechecker.model.ApiEndpoint> endpointIndex,
DtoNestIndex nestIndex) throws IOException {
List<ChangedClassFile> changedFiles = gitScanner.scanChangedClasses(oldSha, newSha);
List<ClassChangeReport> reports = new ArrayList<>();
for (ChangedClassFile changedFile : changedFiles) {
if (changedFile.getStatus() == ChangedClassFile.ChangeStatus.DELETED) {
reports.add(analyzeDeleted(changedFile, config, repoRoot, oldSha, endpointIndex, nestIndex));
continue;
}
ClassChangeReport report = analyzeModifiedOrRenamed(changedFile, config, repoRoot, oldSha, newSha,
endpointIndex, nestIndex);
if (report != null) {
reports.add(report);
}
}
return reports;
}
/** 处理删除:标记 DELETED 并分析影响(基于旧源码) */
private ClassChangeReport analyzeDeleted(ChangedClassFile changedFile, AppConfig config, Path repoRoot,
String oldSha, Map<String, com.codechecker.model.ApiEndpoint> endpointIndex,
DtoNestIndex nestIndex)
throws IOException {
String path = changedFile.getRelativePath();
String oldSource = gitScanner.readFileAtCommit(oldSha, path);
String classDescription = classDeclParser.extractClassDescription(
oldSource, changedFile.getClassName());
ClassChangeReport report = new ClassChangeReport(
changedFile.getClassName(),
null,
changedFile.getClassType(),
ClassChangeKind.DELETED,
path,
config.isDtoEntityConversionEnabled(),
classDescription
);
impactAnalyzer.analyze(report, endpointIndex, config, repoRoot, oldSource, oldSource, nestIndex);
return report;
}
/** 处理修改/重命名:字段 diff → 判定 changeKind → 影响分析 */
private ClassChangeReport analyzeModifiedOrRenamed(ChangedClassFile changedFile, AppConfig config,
Path repoRoot, String oldSha, String newSha,
Map<String, com.codechecker.model.ApiEndpoint> endpointIndex,
DtoNestIndex nestIndex)
throws IOException {
String oldPath = changedFile.pathForOldCommit();
String newPath = changedFile.getRelativePath();
String oldSource = gitScanner.readFileAtCommit(oldSha, oldPath);
String newSource = gitScanner.readFileAtCommit(newSha, newPath);
if (newSource == null || newSource.isBlank()) {
newSource = gitScanner.readFileAtHead(newPath);
}
String oldFallback = ClassDeclParser.classNameFromPath(oldPath);
String newFallback = ClassDeclParser.classNameFromPath(newPath);
String oldClassName = changedFile.getOldClassName() != null
? changedFile.getOldClassName()
: classDeclParser.resolveClassName(oldSource, oldFallback);
String newClassName = classDeclParser.resolveClassName(newSource, newFallback);
List<FieldInfo> oldFields = classFieldParser.parseFields(oldSource, oldClassName);
List<FieldInfo> newFields = classFieldParser.parseFields(newSource, newClassName);
List<FieldChange> fieldChanges = fieldDiffEngine.diff(oldFields, newFields);
boolean renamed = !oldClassName.equals(newClassName);
ClassChangeKind changeKind;
if (renamed && fieldChanges.isEmpty()) {
changeKind = ClassChangeKind.RENAME_ONLY;
} else if (renamed) {
changeKind = ClassChangeKind.RENAME_AND_FIELDS;
} else if (!fieldChanges.isEmpty()) {
changeKind = ClassChangeKind.FIELDS_ONLY;
} else {
return null;
}
String classDescription = classDeclParser.extractClassDescription(newSource, newClassName);
ClassChangeReport report = new ClassChangeReport(
newClassName,
renamed ? oldClassName : null,
changedFile.getClassType(),
changeKind,
newPath,
config.isDtoEntityConversionEnabled(),
classDescription
);
fieldChanges.forEach(report::addFieldChange);
impactAnalyzer.analyze(report, endpointIndex, config, repoRoot, newSource, oldSource, nestIndex);
return report;
}
}

View File

@@ -0,0 +1,135 @@
package com.codechecker.analyzer;
import com.codechecker.config.AppConfig;
import com.codechecker.model.ClassType;
import com.codechecker.model.FieldInfo;
import com.codechecker.parser.ClassFieldParser;
import com.codechecker.parser.TypeNameUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
/**
* Dto/Vo 嵌套关系索引:反向查找祖先容器(用于影响分析与 API 跟进)。
*/
public class DtoNestIndex {
private static final Set<String> LEAF_TYPES = Set.of(
"String", "Integer", "int", "Long", "long", "Boolean", "boolean", "Double", "double",
"Float", "float", "Short", "short", "Byte", "byte", "Character", "char",
"BigDecimal", "BigInteger", "Date", "LocalDate", "LocalDateTime", "LocalTime",
"Instant", "Timestamp", "Object", "Void", "void"
);
private final int maxDepth;
private final Map<String, Set<String>> ancestorsOf = new LinkedHashMap<>();
private final Map<String, String> sourceByClass = new HashMap<>();
private DtoNestIndex(int maxDepth) {
this.maxDepth = maxDepth;
}
public static DtoNestIndex build(Path repoRoot, AppConfig config) throws IOException {
DtoNestIndex index = new DtoNestIndex(config.getNestMaxDepth());
ClassFieldParser fieldParser = new ClassFieldParser();
for (String dir : config.getModelDirs()) {
Path root = repoRoot.resolve(dir.replace('\\', '/'));
if (!Files.exists(root)) {
continue;
}
try (Stream<Path> paths = Files.walk(root)) {
paths.filter(path -> path.toString().endsWith(".java"))
.forEach(path -> {
String className = path.getFileName().toString().replace(".java", "");
if (ClassType.fromClassName(className) != ClassType.DTO
&& ClassType.fromClassName(className) != ClassType.VO) {
return;
}
try {
String source = Files.readString(path, StandardCharsets.UTF_8);
index.sourceByClass.put(className, source);
} catch (IOException ignored) {
// 跳过无法读取的文件
}
});
}
}
for (Map.Entry<String, String> entry : index.sourceByClass.entrySet()) {
String rootClass = entry.getKey();
List<FieldInfo> fields = fieldParser.parseFields(entry.getValue(), rootClass);
Set<String> visiting = new LinkedHashSet<>();
index.walkNested(rootClass, fields, rootClass, 1, visiting, fieldParser);
}
return index;
}
/** 自身 + 所有祖先 Dto/Vo 类名(用于接口影响匹配) */
public Set<String> expandImpactNames(String className) {
Set<String> names = new LinkedHashSet<>();
if (className != null && !className.isBlank()) {
names.add(className);
names.addAll(ancestorsOf.getOrDefault(className, Set.of()));
}
return names;
}
/** 是否被其他 Dto/Vo 嵌套引用(存在至少一个祖先容器) */
public boolean hasAncestors(String className) {
Set<String> ancestors = ancestorsOf.get(className);
return ancestors != null && !ancestors.isEmpty();
}
/** 嵌套类型的 @RequestBody 根 Dto 祖先(仅 Dto 后缀) */
public Set<String> findRequestBodyRoots(String className) {
Set<String> roots = new LinkedHashSet<>();
if (className != null && className.endsWith("Dto")) {
roots.add(className);
}
for (String ancestor : ancestorsOf.getOrDefault(className, Set.of())) {
if (ancestor.endsWith("Dto")) {
roots.add(ancestor);
}
}
return roots;
}
public int getMaxDepth() {
return maxDepth;
}
private void walkNested(String ownerClass, List<FieldInfo> fields, String rootAncestor,
int depth, Set<String> visiting, ClassFieldParser fieldParser) {
if (depth > maxDepth) {
return;
}
for (FieldInfo field : fields) {
for (String nestedType : TypeNameUtils.peelDirectTypeNames(field.getType())) {
if (isLeafType(nestedType) || nestedType.equals(ownerClass)) {
continue;
}
ancestorsOf.computeIfAbsent(nestedType, k -> new LinkedHashSet<>()).add(rootAncestor);
if (!visiting.add(nestedType)) {
continue;
}
String nestedSource = sourceByClass.get(nestedType);
if (nestedSource != null) {
List<FieldInfo> nestedFields = fieldParser.parseFields(nestedSource, nestedType);
walkNested(nestedType, nestedFields, rootAncestor, depth + 1, visiting, fieldParser);
}
visiting.remove(nestedType);
}
}
}
private boolean isLeafType(String simpleType) {
return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]");
}
}

View File

@@ -0,0 +1,37 @@
package com.codechecker.analyzer;
import com.codechecker.config.AppConfig;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.parser.EndpointParser;
import java.io.IOException;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 预扫描 Controller/Feign 目录,构建 endpointKey → ApiEndpoint 索引。
*/
public class EndpointIndexBuilder {
private final EndpointParser endpointParser = new EndpointParser();
/** 合并 Controller 与 Feign 扫描结果 */
public Map<String, ApiEndpoint> buildIndex(Path repoRoot, AppConfig config) throws IOException {
Map<String, ApiEndpoint> index = new LinkedHashMap<>();
for (String dir : config.getControllerScanDirs()) {
addEndpoints(index, endpointParser.scanControllerDirectory(repoRoot.resolve(dir), dir));
}
for (String dir : config.getFeignScanDirs()) {
addEndpoints(index, endpointParser.scanFeignDirectory(repoRoot.resolve(dir), dir));
}
return index;
}
/** 按 endpointKey 去重写入索引 */
private void addEndpoints(Map<String, ApiEndpoint> index, List<ApiEndpoint> endpoints) {
for (ApiEndpoint endpoint : endpoints) {
index.putIfAbsent(endpoint.endpointKey(), endpoint);
}
}
}

View File

@@ -0,0 +1,166 @@
package com.codechecker.analyzer;
import com.codechecker.model.FieldChange;
import com.codechecker.model.FieldInfo;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 对比新旧字段列表,产出新增/删除/类型修改/重命名(纯注释变更忽略)。
*/
public class FieldDiffEngine {
/**
* 按字段名对比;删除+新增且说明匹配时合并为重命名。
* 输出顺序:按新字段声明顺序,未配对的删除字段置于末尾。
*/
public List<FieldChange> diff(List<FieldInfo> oldFields, List<FieldInfo> newFields) {
Map<String, FieldInfo> oldMap = toMap(oldFields);
Map<String, FieldInfo> newMap = toMap(newFields);
List<FieldChange> modified = new ArrayList<>();
List<FieldInfo> added = new ArrayList<>();
List<FieldInfo> removed = new ArrayList<>();
for (FieldInfo newField : newFields) {
FieldInfo oldField = oldMap.get(newField.getName());
if (oldField == null) {
added.add(newField);
} else if (!oldField.getType().equals(newField.getType())) {
modified.add(FieldChange.modified(oldField, newField, buildTypeDetail(oldField, newField)));
}
// 仅 @Schema / 注释文案变化:不纳入字段变更
}
for (FieldInfo oldField : oldFields) {
if (!newMap.containsKey(oldField.getName())) {
removed.add(oldField);
}
}
List<FieldChange> renamed = pairRenames(removed, added);
return mergeInOrder(newFields, renamed, modified, added, removed);
}
/**
* 将删除+新增配对为字段重命名。
* 优先:说明相同且类型相同;其次:说明相同但类型不同(重命名+改类型)。
*/
private List<FieldChange> pairRenames(List<FieldInfo> removed, List<FieldInfo> added) {
List<FieldChange> renames = new ArrayList<>();
Set<FieldInfo> matchedRemoved = new LinkedHashSet<>();
Set<FieldInfo> matchedAdded = new LinkedHashSet<>();
for (FieldInfo oldField : removed) {
FieldInfo pair = findRenamePair(oldField, added, matchedAdded, true);
if (pair == null) {
pair = findRenamePair(oldField, added, matchedAdded, false);
}
if (pair != null) {
renames.add(FieldChange.renamed(oldField, pair));
matchedRemoved.add(oldField);
matchedAdded.add(pair);
}
}
removed.removeIf(matchedRemoved::contains);
added.removeIf(matchedAdded::contains);
return renames;
}
private FieldInfo findRenamePair(FieldInfo removed, List<FieldInfo> added,
Set<FieldInfo> excluded, boolean requireSameType) {
for (FieldInfo candidate : added) {
if (excluded.contains(candidate)) {
continue;
}
if (!descriptionsMatch(removed, candidate)) {
continue;
}
if (requireSameType && !removed.getType().equals(candidate.getType())) {
continue;
}
if (!requireSameType && removed.getType().equals(candidate.getType())) {
continue;
}
return candidate;
}
return null;
}
/** 说明相同(非空)或双方均为空时视为匹配 */
private boolean descriptionsMatch(FieldInfo oldField, FieldInfo newField) {
String oldDesc = normalizeDescription(oldField.getDescription());
String newDesc = normalizeDescription(newField.getDescription());
if (oldDesc.isEmpty() && newDesc.isEmpty()) {
return true;
}
if (oldDesc.isEmpty() || newDesc.isEmpty()) {
return false;
}
return oldDesc.equals(newDesc);
}
private String normalizeDescription(String description) {
return description == null ? "" : description.trim();
}
/** 按新字段声明顺序合并各变更类型 */
private List<FieldChange> mergeInOrder(List<FieldInfo> newFields, List<FieldChange> renamed,
List<FieldChange> modified, List<FieldInfo> added,
List<FieldInfo> removed) {
Map<String, FieldChange> renamedByNewName = new LinkedHashMap<>();
for (FieldChange change : renamed) {
renamedByNewName.put(change.getFieldName(), change);
}
Map<String, FieldChange> modifiedByName = new LinkedHashMap<>();
for (FieldChange change : modified) {
modifiedByName.put(change.getFieldName(), change);
}
Set<String> emitted = new LinkedHashSet<>();
List<FieldChange> result = new ArrayList<>();
for (FieldInfo newField : newFields) {
String name = newField.getName();
if (renamedByNewName.containsKey(name)) {
result.add(renamedByNewName.get(name));
emitted.add(name);
} else if (modifiedByName.containsKey(name)) {
result.add(modifiedByName.get(name));
emitted.add(name);
} else if (added.stream().anyMatch(f -> f.getName().equals(name))) {
result.add(FieldChange.added(newField));
emitted.add(name);
}
}
for (FieldInfo oldField : removed) {
result.add(FieldChange.removed(oldField));
}
return result;
}
/** 字段列表转 LinkedHashMap保持声明顺序 */
private Map<String, FieldInfo> toMap(List<FieldInfo> fields) {
Map<String, FieldInfo> map = new LinkedHashMap<>();
for (FieldInfo field : fields) {
map.put(field.getName(), field);
}
return map;
}
/** 构造类型变化描述,如 Integer → String */
private String buildTypeDetail(FieldInfo oldField, FieldInfo newField) {
if (oldField.getType().equals(newField.getType())) {
return "";
}
return oldField.getType() + "" + newField.getType();
}
}

View File

@@ -0,0 +1,113 @@
package com.codechecker.analyzer;
import com.codechecker.config.AppConfig;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.model.ClassType;
import java.util.ArrayList;
import com.codechecker.parser.ConversionParser;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 根据变更报告匹配受影响的 HTTP 接口与 Dto→Entity 转换目标。
*/
public class ImpactAnalyzer {
private final ConversionParser conversionParser = new ConversionParser();
/**
* 填充 report 的影响列表新旧类名均参与匹配Entity/Model 不匹配接口。
*/
public void analyze(ClassChangeReport report, Map<String, ApiEndpoint> endpointIndex,
AppConfig config, Path repoRoot, String newSource, String oldSource,
DtoNestIndex nestIndex) throws IOException {
Set<String> matchNames = namesForMatching(report, nestIndex);
if (report.getClassType() != ClassType.ENTITY && report.getClassType() != ClassType.MODEL) {
matchEndpoints(report, endpointIndex, matchNames);
}
report.setObjectRoleLabels(NestedObjectRoleResolver.resolve(report, nestIndex, endpointIndex));
if (!config.isDtoEntityConversionEnabled()) {
return;
}
analyzeConversion(report, config, repoRoot, newSource, oldSource, matchNames);
}
/** 收集新旧类名及嵌套祖先 Dto/Vo用于接口/转换匹配 */
private Set<String> namesForMatching(ClassChangeReport report, DtoNestIndex nestIndex) {
Set<String> names = new LinkedHashSet<>();
names.add(report.getClassName());
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
names.add(report.getOldClassName());
}
if (nestIndex != null
&& (report.getClassType() == ClassType.DTO || report.getClassType() == ClassType.VO)) {
for (String name : new ArrayList<>(names)) {
names.addAll(nestIndex.expandImpactNames(name));
}
}
return names;
}
/** 在接口索引中匹配入参/返回类型 */
private void matchEndpoints(ClassChangeReport report, Map<String, ApiEndpoint> endpointIndex,
Set<String> matchNames) {
List<ApiEndpoint> inputImpacts = new ArrayList<>();
List<ApiEndpoint> frontendImpacts = new ArrayList<>();
for (ApiEndpoint endpoint : endpointIndex.values()) {
if (matchesAnyType(endpoint.getParamTypes(), matchNames)) {
inputImpacts.add(endpoint);
}
if (matchesAnyType(endpoint.getReturnTypes(), matchNames)) {
frontendImpacts.add(endpoint);
}
}
inputImpacts.forEach(report::addInputImpact);
frontendImpacts.forEach(report::addFrontendImpact);
}
/** 扫描 convert 方法与 BeanUtils.copyProperties 关联的 Entity */
private void analyzeConversion(ClassChangeReport report, AppConfig config, Path repoRoot,
String newSource, String oldSource, Set<String> matchNames) throws IOException {
for (String name : matchNames) {
if (newSource != null && !newSource.isBlank()) {
conversionParser.findConvertTargetsInClass(newSource, name)
.forEach(report::addConversionEntity);
}
if (oldSource != null && !oldSource.isBlank() && !oldSource.equals(newSource)) {
conversionParser.findConvertTargetsInClass(oldSource, name)
.forEach(report::addConversionEntity);
}
for (String scanDir : config.getConversionScanDirs()) {
conversionParser.findBeanUtilsTargets(repoRoot.resolve(scanDir), name)
.forEach(report::addConversionEntity);
}
}
}
/** 类型集合中是否包含任一目标类名 */
private boolean matchesAnyType(Collection<String> types, Set<String> classNames) {
if (types == null) {
return false;
}
for (String type : types) {
if (classNames.contains(type)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,54 @@
package com.codechecker.analyzer;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.model.ClassType;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 判定 Dto/Vo 在类变更通知中的对象角色标签(方案 B嵌套 + 可选顶层)。
* <p>
* 仅当存在嵌套祖先时标注;纯顶层不标注;既嵌套又直接作接口根类型时同时标注。
*/
public final class NestedObjectRoleResolver {
private NestedObjectRoleResolver() {
}
public static List<String> resolve(ClassChangeReport report, DtoNestIndex nestIndex,
Map<String, ApiEndpoint> endpointIndex) {
if (report.getClassType() != ClassType.DTO && report.getClassType() != ClassType.VO) {
return List.of();
}
if (nestIndex == null) {
return List.of();
}
String className = report.getClassName();
if (!nestIndex.hasAncestors(className)) {
return List.of();
}
List<String> labels = new ArrayList<>();
labels.add("嵌套对象");
if (isDirectEndpointType(className, endpointIndex)) {
labels.add("顶层对象");
}
return List.copyOf(labels);
}
/** 是否直接出现在接口入参或返回值类型(非仅经祖先传播) */
private static boolean isDirectEndpointType(String className, Map<String, ApiEndpoint> endpointIndex) {
if (className == null || className.isBlank() || endpointIndex == null) {
return false;
}
for (ApiEndpoint endpoint : endpointIndex.values()) {
if (endpoint.getParamTypes().contains(className)
|| endpoint.getReturnTypes().contains(className)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,74 @@
package com.codechecker.api.analyzer;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.model.EndpointSnapshot;
import com.codechecker.api.parser.EndpointSnapshotParser;
import com.codechecker.api.scanner.ApiFileChangeScanner;
import com.codechecker.config.AppConfig;
import com.codechecker.git.GitChangeScanner;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* API 变更分析编排(与 {@link com.codechecker.analyzer.ClassChangeAnalyzer} 平行、互不调用)。
*/
public class ApiChangeAnalyzer {
private final GitChangeScanner gitScanner;
private final ApiFileChangeScanner fileScanner;
public ApiChangeAnalyzer(GitChangeScanner gitScanner) {
this.gitScanner = gitScanner;
this.fileScanner = new ApiFileChangeScanner(gitScanner);
}
public List<EndpointChangeReport> analyze(Path repoRoot, AppConfig config,
String oldSha, String newSha) throws IOException {
List<String> changedFiles = fileScanner.scanChangedFiles(
repoRoot, config.getAllApiScanDirs(), oldSha, newSha);
if (changedFiles.isEmpty()) {
return List.of();
}
EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams());
ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine(
repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha, config.getNestMaxDepth());
EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine);
List<EndpointSnapshot> oldSnapshots = new ArrayList<>();
List<EndpointSnapshot> newSnapshots = new ArrayList<>();
for (String path : changedFiles) {
boolean feign = isFeignPath(path, config);
String oldSource = gitScanner.readFileAtCommit(oldSha, path);
String newSource = gitScanner.readFileAtCommit(newSha, path);
oldSnapshots.addAll(parser.parseSource(oldSource, path, feign));
newSnapshots.addAll(parser.parseSource(newSource, path, feign));
}
return endpointDiffEngine.diff(oldSnapshots, newSnapshots);
}
private List<String> buildSearchDirs(AppConfig config) {
List<String> dirs = new ArrayList<>();
dirs.addAll(config.getModelDirs());
dirs.addAll(config.getAllApiScanDirs());
return dirs;
}
private boolean isFeignPath(String path, AppConfig config) {
String normalized = path.replace('\\', '/');
for (String dir : config.getApiFeignScanDirs()) {
String prefix = dir.replace('\\', '/');
if (!prefix.endsWith("/")) {
prefix = prefix + "/";
}
if (normalized.startsWith(prefix)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,150 @@
package com.codechecker.api.analyzer;
import com.codechecker.analyzer.DtoNestIndex;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.model.EndpointSnapshot;
import com.codechecker.api.model.ParameterChange;
import com.codechecker.api.parser.EndpointSnapshotParser;
import com.codechecker.config.AppConfig;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.model.ClassType;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 类变更Dto/Vo 嵌套字段)后,对受影响的 Controller 继续 API 参数 diff产出 PARAM_CHANGED 报告。
*/
public class DtoImpactedApiAnalyzer {
private final GitChangeScanner gitScanner;
public DtoImpactedApiAnalyzer(GitChangeScanner gitScanner) {
this.gitScanner = gitScanner;
}
public List<EndpointChangeReport> analyze(Path repoRoot, AppConfig config,
String oldSha, String newSha,
List<ClassChangeReport> classReports,
Set<String> alreadyScannedFiles,
DtoNestIndex nestIndex) throws IOException {
Map<String, Set<String>> controllerToDtos = collectImpactedControllers(classReports, alreadyScannedFiles,
nestIndex);
if (controllerToDtos.isEmpty()) {
return List.of();
}
EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams());
ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine(
repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha, config.getNestMaxDepth());
EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine);
List<EndpointSnapshot> oldSnapshots = new ArrayList<>();
List<EndpointSnapshot> newSnapshots = new ArrayList<>();
for (String path : controllerToDtos.keySet()) {
boolean feign = isFeignPath(path, config);
String oldSource = gitScanner.readFileAtCommit(oldSha, path);
String newSource = gitScanner.readFileAtCommit(newSha, path);
oldSnapshots.addAll(parser.parseSource(oldSource, path, feign));
newSnapshots.addAll(parser.parseSource(newSource, path, feign));
}
List<EndpointChangeReport> reports = new ArrayList<>();
for (EndpointChangeReport report : endpointDiffEngine.diff(oldSnapshots, newSnapshots)) {
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED || !report.hasParameterChanges()) {
continue;
}
String relatedDto = findRelatedDto(report, controllerToDtos);
if (relatedDto == null) {
continue;
}
reports.add(EndpointChangeReport.dtoFollowUp(report, relatedDto));
}
return reports;
}
private Map<String, Set<String>> collectImpactedControllers(List<ClassChangeReport> classReports,
Set<String> alreadyScannedFiles,
DtoNestIndex nestIndex) {
Map<String, Set<String>> controllerToDtos = new LinkedHashMap<>();
for (ClassChangeReport report : classReports) {
if (report.getFieldChanges().isEmpty()) {
continue;
}
if (report.getClassType() != ClassType.DTO && report.getClassType() != ClassType.VO) {
continue;
}
Set<String> bodyRoots = resolveBodyRoots(report, nestIndex);
if (bodyRoots.isEmpty()) {
continue;
}
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
String controllerFile = endpoint.getSourceFile();
if (alreadyScannedFiles.contains(controllerFile)) {
continue;
}
controllerToDtos.computeIfAbsent(controllerFile, k -> new LinkedHashSet<>()).addAll(bodyRoots);
}
}
return controllerToDtos;
}
private Set<String> resolveBodyRoots(ClassChangeReport report, DtoNestIndex nestIndex) {
if (nestIndex == null) {
Set<String> names = new LinkedHashSet<>();
if (report.getClassName().endsWith("Dto")) {
names.add(report.getClassName());
}
return names;
}
Set<String> roots = new LinkedHashSet<>();
roots.addAll(nestIndex.findRequestBodyRoots(report.getClassName()));
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
roots.addAll(nestIndex.findRequestBodyRoots(report.getOldClassName()));
}
return roots;
}
private String findRelatedDto(EndpointChangeReport report, Map<String, Set<String>> controllerToDtos) {
Set<String> impactedDtos = controllerToDtos.getOrDefault(report.getSourceFile(), Set.of());
for (ParameterChange change : report.getParameterChanges()) {
if (!"body".equals(change.getSource())) {
continue;
}
String parentDto = change.getParentDto();
if (parentDto != null && impactedDtos.contains(parentDto)) {
return parentDto;
}
}
return null;
}
private List<String> buildSearchDirs(AppConfig config) {
List<String> dirs = new ArrayList<>();
dirs.addAll(config.getModelDirs());
dirs.addAll(config.getAllApiScanDirs());
return dirs;
}
private boolean isFeignPath(String path, AppConfig config) {
String normalized = path.replace('\\', '/');
for (String dir : config.getApiFeignScanDirs()) {
String prefix = dir.replace('\\', '/');
if (!prefix.endsWith("/")) {
prefix = prefix + "/";
}
if (normalized.startsWith(prefix)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,126 @@
package com.codechecker.api.analyzer;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.model.EndpointSnapshot;
import com.codechecker.api.model.ParameterChange;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 接口快照对比:路径 / 方法 / 增删 / 参数(拆分报告,互不混合类型)。
*/
public class EndpointDiffEngine {
private final ParameterDiffEngine parameterDiffEngine;
public EndpointDiffEngine(ParameterDiffEngine parameterDiffEngine) {
this.parameterDiffEngine = parameterDiffEngine;
}
public List<EndpointChangeReport> diff(List<EndpointSnapshot> oldSnapshots,
List<EndpointSnapshot> newSnapshots) throws IOException {
Map<String, EndpointSnapshot> oldMap = indexByFingerprint(oldSnapshots);
Map<String, EndpointSnapshot> newMap = indexByFingerprint(newSnapshots);
List<EndpointChangeReport> reports = new ArrayList<>();
for (String fp : newMap.keySet()) {
if (!oldMap.containsKey(fp)) {
EndpointSnapshot snap = newMap.get(fp);
reports.add(new EndpointChangeReport(
ApiChangeKind.NEW_ENDPOINT,
snap.getHttpMethod(), null,
snap.getUri(), null,
snap.getSourceFile(), snap.getControllerClass(),
snap.getMethodDescription()));
}
}
for (String fp : oldMap.keySet()) {
if (!newMap.containsKey(fp)) {
EndpointSnapshot snap = oldMap.get(fp);
reports.add(new EndpointChangeReport(
ApiChangeKind.REMOVED_ENDPOINT,
snap.getHttpMethod(), null,
snap.getUri(), null,
snap.getSourceFile(), snap.getControllerClass(),
snap.getMethodDescription()));
}
}
for (String fp : oldMap.keySet()) {
if (!newMap.containsKey(fp)) {
continue;
}
EndpointSnapshot oldSnap = oldMap.get(fp);
EndpointSnapshot newSnap = newMap.get(fp);
reports.addAll(diffMatched(oldSnap, newSnap));
}
return reports;
}
private List<EndpointChangeReport> diffMatched(EndpointSnapshot oldSnap,
EndpointSnapshot newSnap) throws IOException {
List<EndpointChangeReport> reports = new ArrayList<>();
boolean pathChanged = !oldSnap.getUri().equals(newSnap.getUri());
boolean methodChanged = !oldSnap.getHttpMethod().equalsIgnoreCase(newSnap.getHttpMethod());
if (pathChanged) {
reports.add(new EndpointChangeReport(
ApiChangeKind.PATH_CHANGED,
newSnap.getHttpMethod(), null,
newSnap.getUri(), oldSnap.getUri(),
newSnap.getSourceFile(), newSnap.getControllerClass(),
preferDescription(newSnap, oldSnap)));
}
if (methodChanged) {
reports.add(new EndpointChangeReport(
ApiChangeKind.METHOD_CHANGED,
newSnap.getHttpMethod(), oldSnap.getHttpMethod(),
newSnap.getUri(), null,
newSnap.getSourceFile(), newSnap.getControllerClass(),
preferDescription(newSnap, oldSnap)));
}
List<ParameterChange> paramChanges = parameterDiffEngine.diff(oldSnap, newSnap);
if (!paramChanges.isEmpty()) {
if (pathChanged || methodChanged) {
EndpointChangeReport paramReport = new EndpointChangeReport(
ApiChangeKind.PARAM_CHANGED,
newSnap.getHttpMethod(), methodChanged ? oldSnap.getHttpMethod() : null,
newSnap.getUri(), pathChanged ? oldSnap.getUri() : null,
newSnap.getSourceFile(), newSnap.getControllerClass(),
preferDescription(newSnap, oldSnap));
paramChanges.forEach(paramReport::addParameterChange);
reports.add(paramReport);
} else {
EndpointChangeReport paramReport = new EndpointChangeReport(
ApiChangeKind.PARAM_CHANGED,
newSnap.getHttpMethod(), null,
newSnap.getUri(), null,
newSnap.getSourceFile(), newSnap.getControllerClass(),
preferDescription(newSnap, oldSnap));
paramChanges.forEach(paramReport::addParameterChange);
reports.add(paramReport);
}
}
return reports;
}
private String preferDescription(EndpointSnapshot primary, EndpointSnapshot fallback) {
if (primary != null && primary.getMethodDescription() != null
&& !primary.getMethodDescription().isBlank()) {
return primary.getMethodDescription();
}
return fallback == null ? "" : fallback.getMethodDescription();
}
private Map<String, EndpointSnapshot> indexByFingerprint(List<EndpointSnapshot> snapshots) {
Map<String, EndpointSnapshot> map = new LinkedHashMap<>();
for (EndpointSnapshot snap : snapshots) {
map.putIfAbsent(snap.getFingerprint(), snap);
}
return map;
}
}

View File

@@ -0,0 +1,234 @@
package com.codechecker.api.analyzer;
import com.codechecker.analyzer.FieldDiffEngine;
import com.codechecker.api.model.EndpointSnapshot;
import com.codechecker.api.model.MethodParameterSnapshot;
import com.codechecker.api.model.ParameterChange;
import com.codechecker.api.parser.NestedDtoFieldParser;
import com.codechecker.api.parser.NestedFieldInfo;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.FieldChange;
import com.codechecker.model.FieldInfo;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 接口入参 diff普通参数 + RequestBody 嵌套 Dto 字段)。
*
* path/query 规则:
* - 形参名+类型相同,仅绑定名变 → 重命名
* - 形参名+绑定名相同,仅类型变 → 类型变更
* - 仅形参名变(绑定名不变)→ 不通知
* - 类型与绑定名同时变,或三者都变 → 先删除后新增
*/
public class ParameterDiffEngine {
private final NestedDtoFieldParser nestedDtoFieldParser;
private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine();
public ParameterDiffEngine(Path repoRoot, List<String> searchDirs,
GitChangeScanner gitScanner, String oldSha, String newSha, int maxDepth) {
this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs, gitScanner, oldSha, newSha, maxDepth);
}
public List<ParameterChange> diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException {
List<ParameterChange> changes = new ArrayList<>();
changes.addAll(diffBodyParams(oldSnap, newSnap));
changes.addAll(diffBindingParams(oldSnap, newSnap));
return changes;
}
private List<ParameterChange> diffBodyParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap)
throws IOException {
Map<String, MethodParameterSnapshot> oldParams = filterBySource(oldSnap, "body");
Map<String, MethodParameterSnapshot> newParams = filterBySource(newSnap, "body");
List<ParameterChange> changes = new ArrayList<>();
for (Map.Entry<String, MethodParameterSnapshot> entry : newParams.entrySet()) {
MethodParameterSnapshot oldParam = oldParams.get(entry.getKey());
MethodParameterSnapshot newParam = entry.getValue();
if (oldParam == null) {
changes.addAll(addedBodyChanges(newParam));
} else {
changes.addAll(diffBodyDto(oldParam, newParam));
}
}
for (Map.Entry<String, MethodParameterSnapshot> entry : oldParams.entrySet()) {
if (!newParams.containsKey(entry.getKey())) {
changes.addAll(removedBodyChanges(entry.getValue()));
}
}
return changes;
}
private List<ParameterChange> diffBindingParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) {
Map<String, MethodParameterSnapshot> oldParams = filterBindingParams(oldSnap);
Map<String, MethodParameterSnapshot> newParams = filterBindingParams(newSnap);
List<ParameterChange> changes = new ArrayList<>();
List<MethodParameterSnapshot> unmatchedOld = new ArrayList<>();
List<MethodParameterSnapshot> unmatchedNew = new ArrayList<>();
for (Map.Entry<String, MethodParameterSnapshot> entry : newParams.entrySet()) {
MethodParameterSnapshot oldParam = oldParams.get(entry.getKey());
MethodParameterSnapshot newParam = entry.getValue();
if (oldParam == null) {
unmatchedNew.add(newParam);
} else if (!oldParam.getType().equals(newParam.getType())) {
changes.add(ParameterChange.modified(
newParam.displayName(),
newParam.getType(),
newParam.getDescription(),
oldParam.getType() + "" + newParam.getType(),
newParam.getSource(),
null, null, null));
}
}
for (Map.Entry<String, MethodParameterSnapshot> entry : oldParams.entrySet()) {
if (!newParams.containsKey(entry.getKey())) {
unmatchedOld.add(entry.getValue());
}
}
pairRenamedBindingParams(unmatchedOld, unmatchedNew, changes);
for (MethodParameterSnapshot removed : unmatchedOld) {
changes.add(ParameterChange.removed(
removed.displayName(), removed.getType(), removed.getDescription(),
removed.getSource(), null, null, null));
}
for (MethodParameterSnapshot added : unmatchedNew) {
changes.add(ParameterChange.added(
added.displayName(), added.getType(), added.getDescription(),
added.getSource(), null, null, null));
}
return changes;
}
/** 形参名+类型相同,仅绑定名变化 → 重命名 */
private void pairRenamedBindingParams(List<MethodParameterSnapshot> unmatchedOld,
List<MethodParameterSnapshot> unmatchedNew,
List<ParameterChange> changes) {
Set<MethodParameterSnapshot> pairedOld = new HashSet<>();
Set<MethodParameterSnapshot> pairedNew = new HashSet<>();
for (MethodParameterSnapshot oldParam : unmatchedOld) {
if (pairedOld.contains(oldParam)) {
continue;
}
for (MethodParameterSnapshot newParam : unmatchedNew) {
if (pairedNew.contains(newParam)) {
continue;
}
if (!oldParam.getSource().equals(newParam.getSource())) {
continue;
}
if (!oldParam.getName().equals(newParam.getName())) {
continue;
}
if (!oldParam.getType().equals(newParam.getType())) {
continue;
}
changes.add(ParameterChange.renamed(
oldParam.getBindingName(),
newParam.getBindingName(),
newParam.getType(),
newParam.getDescription(),
newParam.getSource(),
null, null, null));
pairedOld.add(oldParam);
pairedNew.add(newParam);
break;
}
}
unmatchedOld.removeIf(pairedOld::contains);
unmatchedNew.removeIf(pairedNew::contains);
}
private Map<String, MethodParameterSnapshot> filterBindingParams(EndpointSnapshot snap) {
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
for (MethodParameterSnapshot p : snap.getParameters()) {
if (isBindingParam(p)) {
map.put(p.identityKey(), p);
}
}
return map;
}
private Map<String, MethodParameterSnapshot> filterBySource(EndpointSnapshot snap, String source) {
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
for (MethodParameterSnapshot p : snap.getParameters()) {
if (source.equals(p.getSource())) {
map.put(p.identityKey(), p);
}
}
return map;
}
private boolean isBindingParam(MethodParameterSnapshot param) {
return "path".equals(param.getSource()) || "query".equals(param.getSource());
}
private List<ParameterChange> diffBodyDto(MethodParameterSnapshot oldParam,
MethodParameterSnapshot newParam) throws IOException {
List<NestedFieldInfo> oldFields = nestedDtoFieldParser.parseNestedFieldsAtOldCommit(oldParam.getDtoClassName());
List<NestedFieldInfo> newFields = nestedDtoFieldParser.parseNestedFieldsAtNewCommit(newParam.getDtoClassName());
List<FieldChange> fieldChanges = fieldDiffEngine.diff(toFieldInfo(oldFields), toFieldInfo(newFields));
List<ParameterChange> result = new ArrayList<>();
for (FieldChange fc : fieldChanges) {
result.add(mapFieldChange(fc, newParam.getName(), newParam.getDtoClassName()));
}
return result;
}
private ParameterChange mapFieldChange(FieldChange fc, String bodyParamName, String dtoName) {
String path = fc.getFieldName();
switch (fc.getKind()) {
case ADDED:
return ParameterChange.added(path, fc.getNewType(), fc.getDescription(),
"body", bodyParamName, dtoName, path);
case REMOVED:
return ParameterChange.removed(path, fc.getOldType(), fc.getDescription(),
"body", bodyParamName, dtoName, path);
case RENAMED:
return ParameterChange.renamed(fc.getOldFieldName(), fc.getFieldName(),
fc.getNewType(), fc.getDescription(), "body", bodyParamName, dtoName, path);
case MODIFIED:
default:
return ParameterChange.modified(path, fc.getNewType(), fc.getDescription(),
fc.getDetail(), "body", bodyParamName, dtoName, path);
}
}
private List<ParameterChange> addedBodyChanges(MethodParameterSnapshot param) throws IOException {
List<ParameterChange> list = new ArrayList<>();
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtNewCommit(param.getDtoClassName())) {
list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(),
"body", param.getName(), param.getDtoClassName(), field.getPath()));
}
return list;
}
private List<ParameterChange> removedBodyChanges(MethodParameterSnapshot param) throws IOException {
List<ParameterChange> list = new ArrayList<>();
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtOldCommit(param.getDtoClassName())) {
list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(),
"body", param.getName(), param.getDtoClassName(), field.getPath()));
}
return list;
}
private List<FieldInfo> toFieldInfo(List<NestedFieldInfo> nested) {
List<FieldInfo> result = new ArrayList<>();
for (NestedFieldInfo info : nested) {
result.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription()));
}
return result;
}
}

View File

@@ -0,0 +1,12 @@
package com.codechecker.api.model;
/**
* API 变更类型(与类变更 {@link com.codechecker.model.ClassChangeKind} 独立)。
*/
public enum ApiChangeKind {
NEW_ENDPOINT,
REMOVED_ENDPOINT,
PATH_CHANGED,
METHOD_CHANGED,
PARAM_CHANGED
}

View File

@@ -0,0 +1,112 @@
package com.codechecker.api.model;
import java.util.ArrayList;
import java.util.List;
/**
* 单条 API 变更报告(路径 / 方法 / 参数各自独立,不与其他类型混合)。
*/
public class EndpointChangeReport {
private final ApiChangeKind changeKind;
private final String httpMethod;
private final String oldHttpMethod;
private final String uri;
private final String oldUri;
private final String sourceFile;
private final String controllerClass;
private final String endpointDescription;
private final boolean dtoFollowUp;
private final String relatedDtoClassName;
private final List<ParameterChange> parameterChanges = new ArrayList<>();
public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod,
String uri, String oldUri, String sourceFile, String controllerClass,
String endpointDescription) {
this(changeKind, httpMethod, oldHttpMethod, uri, oldUri, sourceFile, controllerClass,
endpointDescription, false, null);
}
public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod,
String uri, String oldUri, String sourceFile, String controllerClass,
String endpointDescription, boolean dtoFollowUp, String relatedDtoClassName) {
this.changeKind = changeKind;
this.httpMethod = httpMethod;
this.oldHttpMethod = oldHttpMethod;
this.uri = uri;
this.oldUri = oldUri;
this.sourceFile = sourceFile;
this.controllerClass = controllerClass;
this.endpointDescription = endpointDescription == null ? "" : endpointDescription;
this.dtoFollowUp = dtoFollowUp;
this.relatedDtoClassName = relatedDtoClassName;
}
/** 基于已有报告创建 Dto 跟进产生的副本 */
public static EndpointChangeReport dtoFollowUp(EndpointChangeReport source, String relatedDtoClassName) {
EndpointChangeReport copy = new EndpointChangeReport(
source.getChangeKind(),
source.getHttpMethod(),
source.getOldHttpMethod(),
source.getUri(),
source.getOldUri(),
source.getSourceFile(),
source.getControllerClass(),
source.getEndpointDescription(),
true,
relatedDtoClassName);
source.getParameterChanges().forEach(copy::addParameterChange);
return copy;
}
public ApiChangeKind getChangeKind() {
return changeKind;
}
public String getHttpMethod() {
return httpMethod;
}
public String getOldHttpMethod() {
return oldHttpMethod;
}
public String getUri() {
return uri;
}
public String getOldUri() {
return oldUri;
}
public String getSourceFile() {
return sourceFile;
}
public String getControllerClass() {
return controllerClass;
}
public String getEndpointDescription() {
return endpointDescription;
}
public List<ParameterChange> getParameterChanges() {
return parameterChanges;
}
public void addParameterChange(ParameterChange change) {
parameterChanges.add(change);
}
public boolean hasParameterChanges() {
return !parameterChanges.isEmpty();
}
public boolean isDtoFollowUp() {
return dtoFollowUp;
}
public String getRelatedDtoClassName() {
return relatedDtoClassName;
}
}

View File

@@ -0,0 +1,72 @@
package com.codechecker.api.model;
import java.util.ArrayList;
import java.util.List;
/**
* 单个 HTTP/Feign 接口快照。
*/
public class EndpointSnapshot {
private final String fingerprint;
private final String httpMethod;
private final String uri;
private final String sourceFile;
private final String controllerClass;
private final String methodName;
private final String methodDescription;
private final List<MethodParameterSnapshot> parameters;
public EndpointSnapshot(String fingerprint, String httpMethod, String uri, String sourceFile,
String controllerClass, String methodName, String methodDescription,
List<MethodParameterSnapshot> parameters) {
this.fingerprint = fingerprint;
this.httpMethod = httpMethod;
this.uri = uri;
this.sourceFile = sourceFile;
this.controllerClass = controllerClass;
this.methodName = methodName;
this.methodDescription = methodDescription == null ? "" : methodDescription;
this.parameters = parameters == null ? List.of() : new ArrayList<>(parameters);
}
/** 跨 commit 配对同一 Java 方法;不含参数信息,参数 diff 由 ParameterDiffEngine 负责 */
public static String buildFingerprint(String sourceFile, String methodName) {
return sourceFile + "#" + methodName;
}
public String getFingerprint() {
return fingerprint;
}
public String getHttpMethod() {
return httpMethod;
}
public String getUri() {
return uri;
}
public String getSourceFile() {
return sourceFile;
}
public String getControllerClass() {
return controllerClass;
}
public String getMethodName() {
return methodName;
}
public String getMethodDescription() {
return methodDescription;
}
public List<MethodParameterSnapshot> getParameters() {
return parameters;
}
public String endpointKey() {
return httpMethod + " " + uri;
}
}

View File

@@ -0,0 +1,71 @@
package com.codechecker.api.model;
/**
* 接口方法入参快照。
*/
public class MethodParameterSnapshot {
private final String name;
private final String bindingName;
private final String type;
private final String source;
private final boolean required;
private final String description;
private final String dtoClassName;
public MethodParameterSnapshot(String name, String bindingName, String type, String source,
boolean required, String description, String dtoClassName) {
this.name = name;
this.bindingName = bindingName == null || bindingName.isBlank() ? name : bindingName;
this.type = type;
this.source = source;
this.required = required;
this.description = description;
this.dtoClassName = dtoClassName;
}
public String getName() {
return name;
}
/** 对外绑定名(@PathVariable / @RequestParam 的 value/name缺省为形参名 */
public String getBindingName() {
return bindingName;
}
public String getType() {
return type;
}
/** body / path / query / simple */
public String getSource() {
return source;
}
public boolean isRequired() {
return required;
}
public String getDescription() {
return description;
}
public String getDtoClassName() {
return dtoClassName;
}
/** path/query 按绑定名匹配,避免仅 Java 形参重命名误报 */
public String identityKey() {
if ("path".equals(source) || "query".equals(source)) {
return source + ":" + bindingName;
}
return source + ":" + name;
}
/** 通知展示名path/query 展示绑定名 */
public String displayName() {
if ("path".equals(source) || "query".equals(source)) {
return bindingName;
}
return name;
}
}

View File

@@ -0,0 +1,112 @@
package com.codechecker.api.model;
/**
* API 参数或 RequestBody 嵌套字段变更。
*/
public class ParameterChange {
public enum ChangeType {
ADDED, REMOVED, MODIFIED, RENAMED
}
private final ChangeType changeType;
private final String paramName;
private final String oldName;
private final String paramType;
private final String description;
private final String oldDescription;
private final String source;
private final String bodyParamName;
private final String parentDto;
private final String fieldPath;
private final String detail;
private ParameterChange(ChangeType changeType, String paramName, String oldName,
String paramType, String description, String oldDescription,
String source, String bodyParamName, String parentDto,
String fieldPath, String detail) {
this.changeType = changeType;
this.paramName = paramName;
this.oldName = oldName;
this.paramType = paramType;
this.description = description;
this.oldDescription = oldDescription;
this.source = source;
this.bodyParamName = bodyParamName;
this.parentDto = parentDto;
this.fieldPath = fieldPath;
this.detail = detail;
}
public static ParameterChange added(String name, String type, String desc, String source,
String bodyParam, String dto, String fieldPath) {
return new ParameterChange(ChangeType.ADDED, name, null, type, desc, null,
source, bodyParam, dto, fieldPath, null);
}
public static ParameterChange removed(String name, String type, String desc, String source,
String bodyParam, String dto, String fieldPath) {
return new ParameterChange(ChangeType.REMOVED, name, null, type, desc, null,
source, bodyParam, dto, fieldPath, null);
}
public static ParameterChange modified(String name, String type, String desc,
String detail, String source, String bodyParam,
String dto, String fieldPath) {
return new ParameterChange(ChangeType.MODIFIED, name, null, type, desc, null,
source, bodyParam, dto, fieldPath, detail);
}
public static ParameterChange renamed(String oldName, String newName, String type, String desc,
String source, String bodyParam, String dto, String fieldPath) {
return new ParameterChange(ChangeType.RENAMED, newName, oldName, type, desc, null,
source, bodyParam, dto, fieldPath, null);
}
public ChangeType getChangeType() {
return changeType;
}
public String getParamName() {
return paramName;
}
public String getOldName() {
return oldName;
}
public String getParamType() {
return paramType;
}
public String getDescription() {
return description;
}
public String getSource() {
return source;
}
public String getBodyParamName() {
return bodyParamName;
}
public String getParentDto() {
return parentDto;
}
public String getFieldPath() {
return fieldPath;
}
public String getDetail() {
return detail;
}
public boolean isBodyField() {
return "body".equals(source);
}
public String displayName() {
return fieldPath == null || fieldPath.isBlank() ? paramName : fieldPath;
}
}

View File

@@ -0,0 +1,272 @@
package com.codechecker.api.notify;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.model.ParameterChange;
import com.codechecker.common.MarkdownStyles;
import com.codechecker.common.WeComMarkdownSender;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* API 变更通知(路径 / 请求方式 / 参数分类型、分条发送,与类变更通知解耦)。
*/
public class ApiChangeNotifier {
private final WeComMarkdownSender sender = new WeComMarkdownSender();
public int sendAll(String webhookUrl, List<EndpointChangeReport> reports,
String modifier, String modifyTime, boolean wecomEnabled) {
if (reports == null || reports.isEmpty()) {
System.out.println("无 API 变更,不发送通知");
return 0;
}
int sent = 0;
for (EndpointChangeReport report : reports) {
String markdown = buildMarkdown(report, modifier, modifyTime);
if (wecomEnabled) {
if (sender.send(webhookUrl, markdown)) {
sent++;
System.out.println("已发送 API 变更通知: " + report.getChangeKind()
+ " " + report.getHttpMethod() + " " + report.getUri());
}
} else {
sender.logPreview("API 变更 [" + report.getChangeKind() + "]", markdown);
sent++;
}
}
if (sent > 0) {
System.out.println("总共发送 " + sent + " 条 API 变更通知");
}
return sent;
}
public String buildMarkdown(EndpointChangeReport report, String modifier, String modifyTime) {
ApiChangeKind kind = report.getChangeKind();
if (kind == ApiChangeKind.PATH_CHANGED
|| kind == ApiChangeKind.NEW_ENDPOINT
|| kind == ApiChangeKind.REMOVED_ENDPOINT) {
return buildPathMarkdown(report, modifier, modifyTime);
}
if (kind == ApiChangeKind.METHOD_CHANGED) {
return buildMethodMarkdown(report, modifier, modifyTime);
}
return buildParamMarkdown(report, modifier, modifyTime);
}
private String buildPathMarkdown(EndpointChangeReport report, String modifier, String modifyTime) {
String changeLabel;
switch (report.getChangeKind()) {
case NEW_ENDPOINT:
changeLabel = "新增接口";
break;
case REMOVED_ENDPOINT:
changeLabel = "删除接口";
break;
default:
changeLabel = "修改路径";
break;
}
StringBuilder sb = new StringBuilder();
sb.append("# 【API路径变更通知】").append("\n\n");
sb.append(MarkdownStyles.quoteKvBold("变更类型", MarkdownStyles.colorWarning(changeLabel))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("路径",
MarkdownStyles.colorInfo(MarkdownStyles.safe(report.getSourceFile())))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("修改人", MarkdownStyles.colorComment(modifier))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("时间", MarkdownStyles.colorComment(modifyTime))).append("\n");
sb.append("\n## 【URI变更详情】").append("\n\n");
sb.append(MarkdownStyles.quoteKvBold("接口说明", formatEndpointDescription(report))).append("\n");
appendPathUriLines(sb, report, changeLabel);
return sb.toString();
}
private void appendPathUriLines(StringBuilder sb, EndpointChangeReport report, String changeLabel) {
if ("新增接口".equals(changeLabel)) {
sb.append(MarkdownStyles.quoteKvBold("原路径", "`-`")).append("\n");
sb.append(MarkdownStyles.quoteKvBold("新路径",
formatUriWithMethod(report.getHttpMethod(), report.getUri(), true)
+ " " + MarkdownStyles.colorInfo("[新增]"))).append("\n");
} else if ("删除接口".equals(changeLabel)) {
sb.append(MarkdownStyles.quoteKvBold("原路径",
formatUriWithMethod(report.getHttpMethod(), report.getUri(), false)
+ " " + MarkdownStyles.colorWarning("[已删除]"))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("新路径", "`已删除`")).append("\n");
} else {
sb.append(MarkdownStyles.quoteKvBold("原路径",
formatUriWithMethod(report.getHttpMethod(), report.getOldUri(), false)
+ " " + MarkdownStyles.colorWarning("[旧路径]"))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("新路径",
formatUriWithMethod(report.getHttpMethod(), report.getUri(), true)
+ " " + MarkdownStyles.colorInfo("[新路径]"))).append("\n");
}
}
private String buildMethodMarkdown(EndpointChangeReport report, String modifier, String modifyTime) {
StringBuilder sb = new StringBuilder();
sb.append("# 【API请求方式变更通知】").append("\n\n");
sb.append(MarkdownStyles.quoteKvBold("变更类型", MarkdownStyles.colorWarning("修改请求方式"))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("路径",
MarkdownStyles.colorInfo(MarkdownStyles.safe(report.getSourceFile())))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("修改人", MarkdownStyles.colorComment(modifier))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("时间", MarkdownStyles.colorComment(modifyTime))).append("\n");
sb.append("\n## 【请求方式变更详情】").append("\n\n");
sb.append(MarkdownStyles.quoteKvBold("接口说明", formatEndpointDescription(report))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("URI", MarkdownStyles.colorInfo(report.getUri()))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("原请求方式",
MarkdownStyles.colorWarning(report.getOldHttpMethod()))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("新请求方式",
MarkdownStyles.colorInfo(report.getHttpMethod()) + " "
+ MarkdownStyles.colorInfo("[请求方式已变更]"))).append("\n");
return sb.toString();
}
private String buildParamMarkdown(EndpointChangeReport report, String modifier, String modifyTime) {
StringBuilder sb = new StringBuilder();
sb.append("# 【API参数变更通知】").append("\n\n");
sb.append(MarkdownStyles.quoteKvBold("修改人", MarkdownStyles.colorComment(modifier))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("时间", MarkdownStyles.colorComment(modifyTime))).append("\n");
//sb.append(MarkdownStyles.quoteKvBold("变更类型", MarkdownStyles.colorWarning("修改参数"))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("URI",
MarkdownStyles.colorInfo(report.getHttpMethod()) + " "
+ MarkdownStyles.inlineCode(report.getUri()))).append("\n");
sb.append(MarkdownStyles.quoteKvBold("路径",
MarkdownStyles.colorInfo(MarkdownStyles.safe(report.getSourceFile())))).append("\n");
sb.append("\n## 【接口参数变动详情】").append("\n\n");
appendParameterDetails(sb, report);
return sb.toString();
}
private void appendParameterDetails(StringBuilder sb, EndpointChangeReport report) {
List<ParameterChange> bodyChanges = new ArrayList<>();
List<ParameterChange> regularChanges = new ArrayList<>();
for (ParameterChange change : report.getParameterChanges()) {
if (change.isBodyField()) {
bodyChanges.add(change);
} else {
regularChanges.add(change);
}
}
if (!bodyChanges.isEmpty()) {
sb.append("**类对象变更(含嵌套对象字段)**").append("\n\n");
appendBodyGroups(sb, bodyChanges);
sb.append("\n");
}
if (!regularChanges.isEmpty()) {
sb.append("**普通参数变更**").append("\n\n");
sb.append(MarkdownStyles.quoteLine("**共 "
+ MarkdownStyles.colorWarning(String.valueOf(regularChanges.size()))
+ " 项变更**")).append("\n\n");
for (ParameterChange change : regularChanges) {
sb.append(formatParameterLine(change)).append("\n\n");
}
}
if (bodyChanges.isEmpty() && regularChanges.isEmpty()) {
sb.append(MarkdownStyles.quoteLine(MarkdownStyles.colorComment(""))).append("\n");
}
}
private void appendBodyGroups(StringBuilder sb, List<ParameterChange> bodyChanges) {
Map<String, List<ParameterChange>> groups = new LinkedHashMap<>();
for (ParameterChange change : bodyChanges) {
String key = change.getParentDto() == null || change.getParentDto().isBlank()
? (change.getBodyParamName() == null ? "body" : change.getBodyParamName())
: change.getParentDto();
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(change);
}
int total = bodyChanges.size();
sb.append(MarkdownStyles.quoteLine("**共 "
+ MarkdownStyles.colorWarning(String.valueOf(groups.size()))
+ " 个类对象 · "
+ MarkdownStyles.colorWarning(String.valueOf(total))
+ " 项变更**")).append("\n\n");
for (List<ParameterChange> group : groups.values()) {
ParameterChange first = group.get(0);
if (first.getParentDto() != null && !first.getParentDto().isBlank()) {
sb.append("**").append(MarkdownStyles.inlineCode(first.getParentDto())).append("**");
} else if (first.getBodyParamName() != null && !first.getBodyParamName().isBlank()) {
sb.append("**").append(MarkdownStyles.inlineCode(first.getBodyParamName())).append("**");
}
sb.append("\n\n");
for (ParameterChange change : group) {
sb.append(formatParameterLine(change)).append("\n\n");
}
}
}
private String formatParameterLine(ParameterChange change) {
String tag;
switch (change.getChangeType()) {
case ADDED:
tag = MarkdownStyles.colorInfo("[新增]");
break;
case REMOVED:
tag = MarkdownStyles.colorWarning("[删除]");
break;
case RENAMED:
tag = MarkdownStyles.colorWarning("[重命名]");
break;
case MODIFIED:
tag = MarkdownStyles.colorWarning("[类型变更]");
break;
default:
tag = MarkdownStyles.colorWarning("[修改]");
break;
}
String name = MarkdownStyles.inlineCode(MarkdownStyles.safe(change.displayName()));
String desc = change.getDescription() == null || change.getDescription().isBlank()
? MarkdownStyles.colorComment("(无说明)")
: MarkdownStyles.colorComment(change.getDescription());
StringBuilder line = new StringBuilder();
if (change.getChangeType() == ParameterChange.ChangeType.RENAMED) {
line.append(tag).append(" ")
.append(MarkdownStyles.colorComment(MarkdownStyles.safe(change.getOldName()))).append("")
.append(MarkdownStyles.colorInfo(MarkdownStyles.safe(change.getParamName())))
.append(" 说明: ").append(desc);
} else {
line.append(tag).append(" ").append(name).append(" 说明: ").append(desc);
}
appendParameterType(line, change);
return MarkdownStyles.quoteLine(line.toString());
}
private void appendParameterType(StringBuilder line, ParameterChange change) {
String typePart = resolveTypePart(change);
if (!typePart.isBlank()) {
line.append(" 类型: ").append(typePart);
}
}
private String formatUriWithMethod(String httpMethod, String uri, boolean isNew) {
String path = MarkdownStyles.inlineCode(MarkdownStyles.safe(uri));
if (httpMethod == null || httpMethod.isBlank()) {
return path;
}
String methodPart = isNew
? MarkdownStyles.colorInfo(httpMethod.toUpperCase())
: MarkdownStyles.colorWarning(httpMethod.toUpperCase());
return methodPart + " " + path;
}
private String formatEndpointDescription(EndpointChangeReport report) {
String desc = report.getEndpointDescription();
if (desc == null || desc.isBlank()) {
return MarkdownStyles.colorComment("(无说明)");
}
return MarkdownStyles.colorComment(MarkdownStyles.safe(desc));
}
private String resolveTypePart(ParameterChange change) {
if (change.getChangeType() == ParameterChange.ChangeType.MODIFIED
&& change.getDetail() != null && !change.getDetail().isBlank()) {
return MarkdownStyles.formatTypeChange(change.getDetail());
}
if (change.getParamType() != null && !change.getParamType().isBlank()) {
boolean isNew = change.getChangeType() == ParameterChange.ChangeType.ADDED
|| change.getChangeType() == ParameterChange.ChangeType.RENAMED;
return MarkdownStyles.formatSingleType(change.getParamType(), isNew);
}
return "";
}
}

View File

@@ -0,0 +1,313 @@
package com.codechecker.api.parser;
import com.codechecker.api.model.EndpointSnapshot;
import com.codechecker.api.model.MethodParameterSnapshot;
import com.codechecker.parser.TypeNameUtils;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.NodeList;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
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.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
import com.github.javaparser.ast.type.Type;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 解析 Controller / Feign 接口完整快照(含入参明细)。
*/
public class EndpointSnapshotParser {
private static final Set<String> MAPPING_ANNOTATIONS = Set.of(
"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"
);
private static final Map<String, String> MAPPING_DEFAULT_METHOD = Map.of(
"GetMapping", "GET",
"PostMapping", "POST",
"PutMapping", "PUT",
"DeleteMapping", "DELETE",
"PatchMapping", "PATCH"
);
private static final Set<String> FRAMEWORK_PARAM_TYPES = Set.of(
"HttpServletRequest", "HttpServletResponse", "BindingResult", "Principal",
"Authentication", "Model", "ModelMap", "UriComponentsBuilder", "WebRequest",
"NativeWebRequest", "Errors", "Locale"
);
private final boolean excludeFrameworkParams;
public EndpointSnapshotParser(boolean excludeFrameworkParams) {
this.excludeFrameworkParams = excludeFrameworkParams;
}
public List<EndpointSnapshot> parseSource(String source, String sourceFile, boolean feignMode) {
if (source == null || source.isBlank()) {
return List.of();
}
CompilationUnit cu = StaticJavaParser.parse(source);
List<EndpointSnapshot> snapshots = new ArrayList<>();
for (TypeDeclaration<?> type : cu.getTypes()) {
if (!(type instanceof ClassOrInterfaceDeclaration)) {
continue;
}
ClassOrInterfaceDeclaration decl = (ClassOrInterfaceDeclaration) type;
if (feignMode && !isFeignClient(decl)) {
continue;
}
if (!feignMode && !isController(decl)) {
continue;
}
String basePath = feignMode
? joinPaths(extractFeignBasePath(decl), extractTypeLevelPath(decl))
: extractTypeLevelPath(decl);
String className = decl.getNameAsString();
for (MethodDeclaration method : decl.getMethods()) {
if (feignMode && !decl.isInterface()) {
continue;
}
if (!feignMode && decl.isInterface()) {
continue;
}
snapshots.addAll(parseMethod(method, basePath, sourceFile, className));
}
}
return snapshots;
}
private List<EndpointSnapshot> parseMethod(MethodDeclaration method, String basePath,
String sourceFile, String className) {
List<EndpointSnapshot> result = new ArrayList<>();
for (AnnotationExpr annotation : method.getAnnotations()) {
String annName = annotation.getNameAsString();
if (!MAPPING_ANNOTATIONS.contains(annName)) {
continue;
}
List<String> subPaths = readStringArray(annotation, "value", "path");
List<String> httpMethods = extractHttpMethods(annotation, annName);
List<MethodParameterSnapshot> params = extractParameters(method);
String methodDescription = MethodDescriptionExtractor.extract(method);
String fingerprint = EndpointSnapshot.buildFingerprint(sourceFile, method.getNameAsString());
for (String httpMethod : httpMethods) {
for (String subPath : subPaths) {
String uri = joinPaths(basePath, subPath);
result.add(new EndpointSnapshot(fingerprint, httpMethod, uri, sourceFile,
className, method.getNameAsString(), methodDescription, params));
}
}
}
return result;
}
private List<MethodParameterSnapshot> extractParameters(MethodDeclaration method) {
Map<String, String> paramDescriptions = MethodParamJavadocExtractor.extract(method);
List<MethodParameterSnapshot> params = new ArrayList<>();
for (Parameter parameter : method.getParameters()) {
String typeName = TypeNameUtils.typeToString(parameter.getType());
String simple = TypeNameUtils.simpleName(typeName);
if (excludeFrameworkParams && FRAMEWORK_PARAM_TYPES.contains(simple)) {
continue;
}
String source = resolveParamSource(parameter);
String paramName = parameter.getNameAsString();
String bindingName = resolveBindingName(parameter, source, paramName);
boolean required = resolveRequired(parameter, source);
String dtoName = "body".equals(source) ? simple : "";
String description = paramDescriptions.getOrDefault(paramName, "");
params.add(new MethodParameterSnapshot(
paramName,
bindingName,
typeName,
source,
required,
description,
dtoName
));
}
return params;
}
private String resolveBindingName(Parameter parameter, String source, String paramName) {
if (!"path".equals(source) && !"query".equals(source)) {
return paramName;
}
String annName = "path".equals(source) ? "PathVariable" : "RequestParam";
for (AnnotationExpr ann : parameter.getAnnotations()) {
if (!annName.equals(ann.getNameAsString())) {
continue;
}
List<String> bindings = readStringArray(ann, "value", "name");
for (String binding : bindings) {
if (binding != null && !binding.isBlank()) {
return binding;
}
}
}
return paramName;
}
private String resolveParamSource(Parameter parameter) {
for (AnnotationExpr ann : parameter.getAnnotations()) {
String name = ann.getNameAsString();
if ("RequestBody".equals(name)) {
return "body";
}
if ("PathVariable".equals(name)) {
return "path";
}
if ("RequestParam".equals(name)) {
return "query";
}
}
return "simple";
}
private boolean resolveRequired(Parameter parameter, String source) {
if ("query".equals(source)) {
for (AnnotationExpr ann : parameter.getAnnotations()) {
if ("RequestParam".equals(ann.getNameAsString()) && ann.isNormalAnnotationExpr()) {
for (var pair : ann.asNormalAnnotationExpr().getPairs()) {
if ("required".equals(pair.getNameAsString())) {
return !"false".equalsIgnoreCase(pair.getValue().toString().trim());
}
}
}
}
}
return !"query".equals(source);
}
private boolean isController(ClassOrInterfaceDeclaration decl) {
return decl.getAnnotations().stream()
.anyMatch(ann -> {
String n = ann.getNameAsString();
return "RestController".equals(n) || "Controller".equals(n);
});
}
private boolean isFeignClient(ClassOrInterfaceDeclaration decl) {
return decl.isInterface() && decl.getAnnotations().stream()
.anyMatch(ann -> "FeignClient".equals(ann.getNameAsString()));
}
private String extractTypeLevelPath(ClassOrInterfaceDeclaration decl) {
for (AnnotationExpr annotation : decl.getAnnotations()) {
if ("RequestMapping".equals(annotation.getNameAsString())) {
List<String> paths = readStringArray(annotation, "value", "path");
if (!paths.isEmpty()) {
return paths.get(0);
}
}
}
return "";
}
private String extractFeignBasePath(ClassOrInterfaceDeclaration decl) {
for (AnnotationExpr annotation : decl.getAnnotations()) {
if ("FeignClient".equals(annotation.getNameAsString())) {
List<String> paths = readStringArray(annotation, "path");
if (!paths.isEmpty()) {
return paths.get(0);
}
}
}
return "";
}
private List<String> extractHttpMethods(AnnotationExpr annotation, String annName) {
if (!"RequestMapping".equals(annName)) {
return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET"));
}
List<String> methods = readEnumArray(annotation, "method");
return methods.isEmpty() ? List.of("GET") : methods;
}
private String joinPaths(String base, String sub) {
String normalizedBase = normalizePath(base);
String normalizedSub = normalizePath(sub);
if (normalizedBase.isEmpty()) {
return normalizedSub.isEmpty() ? "/" : normalizedSub;
}
if (normalizedSub.isEmpty()) {
return normalizedBase;
}
return (normalizedBase + "/" + normalizedSub.substring(1)).replaceAll("/+", "/");
}
private String normalizePath(String path) {
if (path == null || path.isBlank()) {
return "";
}
String trimmed = path.trim();
if (!trimmed.startsWith("/")) {
trimmed = "/" + trimmed;
}
return trimmed.replaceAll("/+", "/");
}
private List<String> readStringArray(AnnotationExpr annotation, String... keys) {
NodeList<?> values = readArrayValues(annotation, keys);
List<String> result = new ArrayList<>();
for (Object value : values) {
String text = value.toString().replace("\"", "").trim();
if (!text.isBlank()) {
result.add(text);
}
}
if (result.isEmpty()) {
result.add("");
}
return result;
}
private List<String> readEnumArray(AnnotationExpr annotation, String key) {
NodeList<?> values = readArrayValues(annotation, key);
List<String> result = new ArrayList<>();
for (Object value : values) {
String text = value.toString().trim();
if (text.contains(".")) {
text = text.substring(text.lastIndexOf('.') + 1);
}
result.add(text.toUpperCase(Locale.ROOT));
}
return result;
}
private NodeList<?> readArrayValues(AnnotationExpr annotation, String... keys) {
if (annotation.isSingleMemberAnnotationExpr()) {
Expression value = annotation.asSingleMemberAnnotationExpr().getMemberValue();
if (value.isArrayInitializerExpr()) {
return value.asArrayInitializerExpr().getValues();
}
return new NodeList<>(value);
}
if (annotation.isNormalAnnotationExpr()) {
var pairs = annotation.asNormalAnnotationExpr().getPairs();
for (var pair : pairs) {
for (String key : keys) {
if (pair.getNameAsString().equals(key)) {
if (pair.getValue().isArrayInitializerExpr()) {
return pair.getValue().asArrayInitializerExpr().getValues();
}
return new NodeList<>(pair.getValue());
}
}
}
for (var pair : pairs) {
if ("value".equals(pair.getNameAsString())) {
if (pair.getValue().isArrayInitializerExpr()) {
return pair.getValue().asArrayInitializerExpr().getValues();
}
return new NodeList<>(pair.getValue());
}
}
}
return new NodeList<>();
}
}

View File

@@ -0,0 +1,68 @@
package com.codechecker.api.parser;
import com.codechecker.git.GitChangeScanner;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
/**
* 按简单类名在仓库中定位 .java 源文件。
*/
public class JavaSourceLocator {
private final Path repoRoot;
private final List<String> searchDirs;
public JavaSourceLocator(Path repoRoot, List<String> searchDirs) {
this.repoRoot = repoRoot;
this.searchDirs = searchDirs;
}
public Optional<String> readSourceBySimpleName(String simpleClassName) throws IOException {
Optional<Path> path = findFile(simpleClassName);
if (path.isEmpty()) {
return Optional.empty();
}
return Optional.of(Files.readString(path.get()));
}
public Optional<String> readSourceAtCommit(GitChangeScanner gitScanner, String sha,
String simpleClassName) throws IOException {
Optional<String> relativePath = findRelativePath(simpleClassName);
if (relativePath.isEmpty()) {
return Optional.empty();
}
String source = gitScanner.readFileAtCommit(sha, relativePath.get());
if (source == null || source.isBlank()) {
return Optional.empty();
}
return Optional.of(source);
}
public Optional<String> findRelativePath(String simpleClassName) throws IOException {
Optional<Path> path = findFile(simpleClassName);
return path.map(p -> repoRoot.relativize(p).toString().replace('\\', '/'));
}
public Optional<Path> findFile(String simpleClassName) throws IOException {
String fileName = simpleClassName + ".java";
for (String dir : searchDirs) {
Path root = repoRoot.resolve(dir.replace('\\', '/'));
if (!Files.exists(root)) {
continue;
}
try (Stream<Path> walk = Files.walk(root)) {
Optional<Path> found = walk
.filter(p -> p.getFileName().toString().equals(fileName))
.findFirst();
if (found.isPresent()) {
return found;
}
}
}
return Optional.empty();
}
}

View File

@@ -0,0 +1,74 @@
package com.codechecker.api.parser;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.comments.JavadocComment;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
import java.util.Optional;
/**
* 提取接口方法中文说明:@Operation(summary) &gt; @Operation(description) &gt; Javadoc 首段。
*/
public final class MethodDescriptionExtractor {
private MethodDescriptionExtractor() {
}
public static String extract(MethodDeclaration method) {
if (method == null) {
return "";
}
for (AnnotationExpr annotation : method.getAnnotations()) {
if (!"Operation".equals(annotation.getNameAsString())) {
continue;
}
String summary = readAnnotationStringValue(annotation, "summary");
if (!summary.isEmpty()) {
return summary;
}
String description = readAnnotationStringValue(annotation, "description");
if (!description.isEmpty()) {
return description;
}
}
return extractMethodJavadoc(method);
}
private static String extractMethodJavadoc(MethodDeclaration method) {
Optional<JavadocComment> javadoc = method.getJavadocComment();
if (javadoc.isEmpty()) {
return "";
}
String text = javadoc.get().parse().getDescription().toText();
return text == null ? "" : text.trim().replaceAll("\\s+", " ");
}
private static String readAnnotationStringValue(AnnotationExpr annotation, String attributeName) {
if (annotation.isNormalAnnotationExpr()) {
NormalAnnotationExpr normal = annotation.asNormalAnnotationExpr();
for (var pair : normal.getPairs()) {
if (pair.getNameAsString().equals(attributeName)) {
return literalString(pair.getValue());
}
}
return "";
}
if (annotation.isSingleMemberAnnotationExpr()) {
SingleMemberAnnotationExpr single = annotation.asSingleMemberAnnotationExpr();
if ("value".equals(attributeName) || "description".equals(attributeName)
|| "summary".equals(attributeName)) {
return literalString(single.getMemberValue());
}
}
return "";
}
private static String literalString(Expression expression) {
if (expression.isStringLiteralExpr()) {
return expression.asStringLiteralExpr().getValue().trim();
}
return "";
}
}

View File

@@ -0,0 +1,67 @@
package com.codechecker.api.parser;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.comments.JavadocComment;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 从方法 Javadoc 的 @param 标签提取形参说明(按 Java 形参名匹配)。
*/
public final class MethodParamJavadocExtractor {
private static final Pattern PARAM_TAG = Pattern.compile(
"@param\\s+(\\w+)\\s+(.+?)(?=\\s*@param\\s+|\\s*@return\\s+|\\s*@throws\\s+|\\s*@see\\s+|\\s*\\*/|$)",
Pattern.DOTALL);
private MethodParamJavadocExtractor() {
}
public static Map<String, String> extract(MethodDeclaration method) {
Map<String, String> descriptions = new HashMap<>();
if (method == null) {
return descriptions;
}
Optional<JavadocComment> javadoc = method.getJavadocComment();
if (javadoc.isEmpty()) {
return descriptions;
}
String raw = javadoc.get().getContent();
if (raw == null || raw.isBlank()) {
return descriptions;
}
String normalized = raw.replace('\r', '\n');
Matcher matcher = PARAM_TAG.matcher(normalized);
while (matcher.find()) {
String paramName = matcher.group(1).trim();
String desc = cleanDescription(matcher.group(2));
if (!paramName.isBlank()) {
descriptions.put(paramName, desc);
}
}
return descriptions;
}
private static String cleanDescription(String text) {
if (text == null) {
return "";
}
StringBuilder sb = new StringBuilder();
for (String line : text.split("\n")) {
String trimmed = line.trim();
if (trimmed.startsWith("*")) {
trimmed = trimmed.substring(1).trim();
}
if (!trimmed.isEmpty()) {
if (sb.length() > 0) {
sb.append(' ');
}
sb.append(trimmed);
}
}
return sb.toString().trim();
}
}

View File

@@ -0,0 +1,99 @@
package com.codechecker.api.parser;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.FieldInfo;
import com.codechecker.parser.ClassFieldParser;
import com.codechecker.parser.TypeNameUtils;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* 递归展开 Dto/Vo 嵌套字段dot path与类变更字段解析解耦但复用 ClassFieldParser。
*/
public class NestedDtoFieldParser {
private static final Set<String> LEAF_TYPES = Set.of(
"String", "Integer", "int", "Long", "long", "Boolean", "boolean", "Double", "double",
"Float", "float", "Short", "short", "Byte", "byte", "Character", "char",
"BigDecimal", "BigInteger", "Date", "LocalDate", "LocalDateTime", "LocalTime",
"Instant", "Timestamp", "Object", "Void", "void"
);
private final ClassFieldParser classFieldParser = new ClassFieldParser();
private final JavaSourceLocator sourceLocator;
private final GitChangeScanner gitScanner;
private final String oldSha;
private final String newSha;
private final int maxDepth;
public NestedDtoFieldParser(Path repoRoot, List<String> searchDirs,
GitChangeScanner gitScanner, String oldSha, String newSha, int maxDepth) {
this.sourceLocator = new JavaSourceLocator(repoRoot, searchDirs);
this.gitScanner = gitScanner;
this.oldSha = oldSha;
this.newSha = newSha;
this.maxDepth = maxDepth;
}
public List<NestedFieldInfo> parseNestedFieldsAtOldCommit(String dtoClassName) throws IOException {
return parseNestedFields(dtoClassName, oldSha);
}
public List<NestedFieldInfo> parseNestedFieldsAtNewCommit(String dtoClassName) throws IOException {
return parseNestedFields(dtoClassName, newSha);
}
private List<NestedFieldInfo> parseNestedFields(String dtoClassName, String sha) throws IOException {
Set<String> visiting = new HashSet<>();
List<NestedFieldInfo> result = new ArrayList<>();
collectFields(dtoClassName, "", visiting, result, sha, 1);
return result;
}
private void collectFields(String className, String prefix, Set<String> visiting,
List<NestedFieldInfo> out, String sha, int depth) throws IOException {
if (className == null || className.isBlank() || visiting.contains(className) || depth > maxDepth) {
return;
}
visiting.add(className);
Optional<String> source = readSource(className, sha);
if (source.isEmpty()) {
visiting.remove(className);
return;
}
List<FieldInfo> fields = classFieldParser.parseFields(source.get(), className);
for (FieldInfo field : fields) {
String path = prefix.isBlank() ? field.getName() : prefix + "." + field.getName();
Set<String> nestedTypes = TypeNameUtils.peelDirectTypeNames(field.getType());
boolean expanded = false;
for (String nestedType : nestedTypes) {
if (isLeafType(nestedType) || nestedType.equals(className)) {
continue;
}
expanded = true;
// 嵌套字段路径用类型简单类名(如 UserSelfDto.nickName不用成员名userDtos.nickName
collectFields(nestedType, nestedType, visiting, out, sha, depth + 1);
}
if (!expanded) {
out.add(new NestedFieldInfo(path, field.getType(), field.getDescription()));
}
}
visiting.remove(className);
}
private Optional<String> readSource(String className, String sha) throws IOException {
if (sha != null && gitScanner != null) {
return sourceLocator.readSourceAtCommit(gitScanner, sha, className);
}
return sourceLocator.readSourceBySimpleName(className);
}
private boolean isLeafType(String simpleType) {
return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]");
}
}

View File

@@ -0,0 +1,28 @@
package com.codechecker.api.parser;
/**
* DTO 嵌套字段扁平化条目dot path
*/
public class NestedFieldInfo {
private final String path;
private final String type;
private final String description;
public NestedFieldInfo(String path, String type, String description) {
this.path = path;
this.type = type;
this.description = description;
}
public String getPath() {
return path;
}
public String getType() {
return type;
}
public String getDescription() {
return description;
}
}

View File

@@ -0,0 +1,56 @@
package com.codechecker.api.scanner;
import com.codechecker.git.GitChangeScanner;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 扫描 API 相关 Java 文件变更Controller / Feign与类变更扫描解耦。
*/
public class ApiFileChangeScanner {
private final GitChangeScanner gitScanner;
public ApiFileChangeScanner(GitChangeScanner gitScanner) {
this.gitScanner = gitScanner;
}
/** 返回两次提交间变更的 .java 相对路径(位于 scanDirs 下) */
public List<String> scanChangedFiles(Path repoRoot, List<String> scanDirs,
String oldSha, String newSha) throws IOException {
Set<String> changed = new LinkedHashSet<>();
List<String> diffLines = gitScanner.diffNameOnly(oldSha, newSha);
for (String line : diffLines) {
String path = normalize(line);
if (!path.endsWith(".java")) {
continue;
}
if (isUnderScanDirs(path, scanDirs)) {
changed.add(path);
}
}
return new ArrayList<>(changed);
}
private boolean isUnderScanDirs(String relativePath, List<String> scanDirs) {
String normalized = relativePath.replace('\\', '/');
for (String dir : scanDirs) {
String prefix = dir.replace('\\', '/');
if (!prefix.endsWith("/")) {
prefix = prefix + "/";
}
if (normalized.startsWith(prefix)) {
return true;
}
}
return false;
}
private String normalize(String path) {
return path.replace('\\', '/').trim();
}
}

View File

@@ -0,0 +1,62 @@
package com.codechecker.common;
/**
* 企微 Markdown v1 公共样式(类变更 / API 变更通知共用)。
*/
public final class MarkdownStyles {
private MarkdownStyles() {
}
public static String colorInfo(String text) {
return "<font color=\"info\">" + text + "</font>";
}
public static String colorComment(String text) {
return "<font color=\"comment\">" + safe(text) + "</font>";
}
public static String colorWarning(String text) {
return "<font color=\"warning\">" + text + "</font>";
}
public static String quoteKvBold(String key, String value) {
return "> **" + key + ": " + value + "**";
}
public static String quoteKv(String key, String value) {
return "> " + key + ": " + value;
}
public static String quoteLine(String content) {
return "> " + content;
}
public static String inlineCode(String text) {
return "`" + text.replace("`", "'") + "`";
}
/** 类型展示:泛型尖括号不转义 */
public static String formatTypeChange(String detail) {
int arrow = detail.indexOf("");
if (arrow < 0) {
return colorWarning(detail);
}
String oldType = detail.substring(0, arrow).trim();
String newType = detail.substring(arrow + 3).trim();
return colorWarning(oldType) + "" + colorInfo(newType);
}
public static String formatSingleType(String type, boolean isNew) {
if (type == null || type.isBlank()) {
return "";
}
return isNew ? colorInfo(type) : colorWarning(type);
}
public static String safe(String text) {
if (text == null) {
return "";
}
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}

View File

@@ -0,0 +1,75 @@
package com.codechecker.common;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* 企微 Markdown 发送(与具体变更类型解耦)。
*/
public class WeComMarkdownSender {
private static final int MAX_LENGTH = 3800;
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private final OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build();
public boolean send(String webhookUrl, String content) {
return postMarkdown(webhookUrl, truncate(content));
}
public void logPreview(String title, String content) {
System.out.println("========== " + title + " ==========");
System.out.println(content);
System.out.println("========== 结束 ==========");
}
private boolean postMarkdown(String webhookUrl, String content) {
if (webhookUrl == null || webhookUrl.isBlank() || webhookUrl.contains("YOUR_WECOM")) {
System.out.println("[警告] 未配置有效的企业微信 Webhook URL");
System.out.println("--- 通知预览 ---");
System.out.println(content.length() > 1000 ? content.substring(0, 1000) : content);
return false;
}
String payload = "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":"
+ jsonEscape(content) + "}}";
Request request = new Request.Builder()
.url(webhookUrl)
.post(RequestBody.create(payload, JSON))
.build();
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful() && response.body() != null) {
return response.body().string().contains("\"errcode\":0");
}
System.out.println("[错误] 企微返回异常: " + response.code());
return false;
} catch (IOException e) {
System.out.println("[错误] 发送企微消息失败: " + e.getMessage());
return false;
}
}
private String truncate(String text) {
if (text.length() <= MAX_LENGTH) {
return text;
}
return text.substring(0, MAX_LENGTH) + "\n\n... 消息过长,已截断";
}
private String jsonEscape(String text) {
String escaped = text
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "");
return "\"" + escaped + "\"";
}
}

View File

@@ -0,0 +1,239 @@
package com.codechecker.config;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 读取 .gitea/workflows/code-check-config.yaml提供检测开关、扫描目录、企微配置等。
*/
public class AppConfig {
private boolean masterEnabled = true;
private boolean classCheckEnabled = true;
private boolean dtoEntityConversionEnabled = true;
private List<String> modelDirs = new ArrayList<>();
private List<String> controllerScanDirs = new ArrayList<>();
private List<String> feignScanDirs = new ArrayList<>();
private List<String> conversionScanDirs = new ArrayList<>();
private String wecomWebhookUrl = "";
private boolean wecomEnabled = true;
private boolean onlyOnChange = true;
private boolean dtoApiFollowUpEnabled = true;
private int nestMaxDepth = 3;
private boolean apiCheckEnabled = true;
private boolean apiExcludeFrameworkParams = true;
private List<String> apiControllerScanDirs = new ArrayList<>();
private List<String> apiFeignScanDirs = new ArrayList<>();
private DtoOverlapMode dtoOverlapMode = DtoOverlapMode.BOTH;
/** 从 YAML 文件加载配置 */
@SuppressWarnings("unchecked")
public static AppConfig load(Path configPath) throws IOException {
Yaml yaml = new Yaml();
Map<String, Object> root;
try (InputStream in = Files.newInputStream(configPath)) {
root = yaml.load(in);
}
if (root == null) {
root = Map.of();
}
AppConfig config = new AppConfig();
Map<String, Object> checker = mapOrEmpty(root.get("checker"));
config.masterEnabled = boolOrDefault(checker.get("enabled"), true);
Map<String, Object> classCheck = mapOrEmpty(root.get("class_check"));
config.classCheckEnabled = boolOrDefault(classCheck.get("enabled"), true);
Map<String, Object> dtoApiFollowUp = mapOrEmpty(classCheck.get("dto_api_follow_up"));
config.dtoApiFollowUpEnabled = boolOrDefault(dtoApiFollowUp.get("enabled"), true);
Map<String, Object> nestIndex = mapOrEmpty(classCheck.get("nest_index"));
config.nestMaxDepth = intOrDefault(nestIndex.get("max_depth"), 3);
Map<String, Object> conversion = mapOrEmpty(classCheck.get("dto_entity_conversion"));
config.dtoEntityConversionEnabled = boolOrDefault(conversion.get("enabled"), true);
config.modelDirs = stringList(classCheck.get("model_dirs"));
Map<String, Object> endpointScan = mapOrEmpty(classCheck.get("endpoint_scan"));
config.controllerScanDirs = stringList(endpointScan.get("controllers"));
config.feignScanDirs = stringList(endpointScan.get("feign_apis"));
config.conversionScanDirs = stringList(classCheck.get("conversion_scan"));
Map<String, Object> wecom = mapOrEmpty(root.get("wecom"));
config.wecomWebhookUrl = stringOrEmpty(wecom.get("webhook_url"));
config.wecomEnabled = boolOrDefault(wecom.get("enabled"), true);
Map<String, Object> notify = mapOrEmpty(root.get("notify"));
config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true);
config.dtoOverlapMode = DtoOverlapMode.fromString(stringOrEmpty(notify.get("dto_overlap_mode")));
Map<String, Object> apiCheck = mapOrEmpty(root.get("api_check"));
config.apiCheckEnabled = boolOrDefault(apiCheck.get("enabled"), true);
config.apiExcludeFrameworkParams = boolOrDefault(apiCheck.get("exclude_framework_params"), true);
Map<String, Object> apiEndpointScan = mapOrEmpty(apiCheck.get("endpoint_scan"));
config.apiControllerScanDirs = stringList(apiEndpointScan.get("controllers"));
config.apiFeignScanDirs = stringList(apiEndpointScan.get("feign_apis"));
if (config.apiControllerScanDirs.isEmpty()) {
config.apiControllerScanDirs = new ArrayList<>(config.controllerScanDirs);
}
if (config.apiFeignScanDirs.isEmpty()) {
config.apiFeignScanDirs = new ArrayList<>(config.feignScanDirs);
}
return config;
}
/** 安全转为 Map非 Map 则返回空 Map */
@SuppressWarnings("unchecked")
private static Map<String, Object> mapOrEmpty(Object value) {
if (value instanceof Map) {
return (Map<String, Object>) value;
}
return Map.of();
}
/** 安全转为字符串列表 */
@SuppressWarnings("unchecked")
private static List<String> stringList(Object value) {
if (value instanceof List) {
List<?> list = (List<?>) value;
List<String> result = new ArrayList<>();
for (Object item : list) {
if (item != null) {
result.add(item.toString());
}
}
return result;
}
return new ArrayList<>();
}
/** 安全转为 boolean缺省用 defaultValue */
private static boolean boolOrDefault(Object value, boolean defaultValue) {
if (value instanceof Boolean) {
return (Boolean) value;
}
return defaultValue;
}
/** 安全转为 int缺省用 defaultValue */
private static int intOrDefault(Object value, int defaultValue) {
if (value instanceof Number) {
return ((Number) value).intValue();
}
if (value != null) {
try {
return Integer.parseInt(value.toString().trim());
} catch (NumberFormatException ignored) {
// 使用默认值
}
}
return defaultValue;
}
/** 安全转为字符串null 则空串 */
private static String stringOrEmpty(Object value) {
return value == null ? "" : value.toString();
}
/** 变更检测总开关checker.enabled控制 class_check + api_check */
public boolean isMasterEnabled() {
return masterEnabled;
}
/** 类变更检测开关class_check.enabled */
public boolean isClassCheckEnabled() {
return classCheckEnabled;
}
/** Dto→Entity 类转换检测开关 */
public boolean isDtoEntityConversionEnabled() {
return dtoEntityConversionEnabled;
}
/** 模型类目录(预留,当前扫描仍按类名后缀) */
public List<String> getModelDirs() {
return modelDirs;
}
/** Controller 扫描目录 */
public List<String> getControllerScanDirs() {
return controllerScanDirs;
}
/** Feign 接口扫描目录 */
public List<String> getFeignScanDirs() {
return feignScanDirs;
}
/** BeanUtils / convert 扫描目录 */
public List<String> getConversionScanDirs() {
return conversionScanDirs;
}
/** 企微 Webhook 地址 */
public String getWecomWebhookUrl() {
return wecomWebhookUrl;
}
/** 企微通知开关 */
public boolean isWecomEnabled() {
return wecomEnabled;
}
/** 无变更时是否打印提示后退出 */
public boolean isOnlyOnChange() {
return onlyOnChange;
}
/** Dto 类变更后是否继续检测受影响接口的 API 参数变更 */
public boolean isDtoApiFollowUpEnabled() {
return dtoApiFollowUpEnabled;
}
/** Dto/Vo 嵌套展开最大深度(默认 3可按需调至 4、5 */
public int getNestMaxDepth() {
return nestMaxDepth;
}
/** API 变更检测总开关 */
public boolean isApiCheckEnabled() {
return apiCheckEnabled;
}
/** Dto 类变更与 API 参数变更重叠时的通知策略 */
public DtoOverlapMode getDtoOverlapMode() {
return dtoOverlapMode;
}
/** 是否排除 Spring 框架注入参数 */
public boolean isApiExcludeFrameworkParams() {
return apiExcludeFrameworkParams;
}
/** API 检测 Controller 扫描目录 */
public List<String> getApiControllerScanDirs() {
return apiControllerScanDirs;
}
/** API 检测 Feign 扫描目录 */
public List<String> getApiFeignScanDirs() {
return apiFeignScanDirs;
}
/** API 检测所有扫描目录Controller + Feign */
public List<String> getAllApiScanDirs() {
List<String> dirs = new ArrayList<>();
dirs.addAll(apiControllerScanDirs);
dirs.addAll(apiFeignScanDirs);
return dirs;
}
}

View File

@@ -0,0 +1,24 @@
package com.codechecker.config;
/**
* Dto 类变更与 API 参数变更重叠时的通知策略。
*/
public enum DtoOverlapMode {
/** 仅发类变更通知,抑制重叠的 API 参数通知 */
CLASS_ONLY,
/** 仅发 API 参数通知,抑制重叠的类变更通知 */
API_ONLY,
/** 两类通知均发送 */
BOTH;
public static DtoOverlapMode fromString(String value) {
if (value == null || value.isBlank()) {
return BOTH;
}
try {
return DtoOverlapMode.valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
return BOTH;
}
}
}

View File

@@ -0,0 +1,295 @@
package com.codechecker.git;
import com.codechecker.model.ChangedClassFile;
import com.codechecker.model.ClassType;
import com.codechecker.parser.ClassDeclParser;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* 执行 git diff识别 Dto/Vo/Entity/Model 的修改、删除、重命名(含 R* 与同目录 D+A 配对)。
*/
public class GitChangeScanner {
private final Path repoRoot;
private final ClassDeclParser classDeclParser = new ClassDeclParser();
public GitChangeScanner(Path repoRoot) {
this.repoRoot = repoRoot;
}
/** 扫描两次提交间的模型类变更 */
public List<ChangedClassFile> scanChangedClasses(String oldSha, String newSha) throws IOException {
List<String> lines = runGit("diff", "--name-status", oldSha, newSha);
List<ChangedClassFile> deletions = new ArrayList<>();
Map<String, PendingAdd> additionsByParent = new LinkedHashMap<>();
List<ChangedClassFile> result = new ArrayList<>();
for (String line : lines) {
if (line.isBlank()) {
continue;
}
String[] parts = line.split("\t");
if (parts.length < 2) {
continue;
}
String status = parts[0].trim();
if (status.startsWith("R") && parts.length >= 3) {
ChangedClassFile renamed = buildRenamed(parts[1], parts[2], oldSha, newSha);
if (renamed != null) {
result.add(renamed);
}
continue;
}
String path = normalizePath(parts[parts.length - 1]);
if (!path.endsWith(".java")) {
continue;
}
String fallbackName = ClassDeclParser.classNameFromPath(path);
ClassType classType = ClassType.fromClassName(fallbackName);
if (classType == null) {
continue;
}
if (status.equals("A")) {
String newSource = readFileAtCommit(newSha, path);
String className = classDeclParser.resolveClassName(newSource, fallbackName);
additionsByParent.computeIfAbsent(parentDir(path), k -> new PendingAdd())
.add(path, className, classType, newSource);
continue;
}
if (status.equals("D")) {
String oldSource = readFileAtCommit(oldSha, path);
String className = classDeclParser.resolveClassName(oldSource, fallbackName);
deletions.add(new ChangedClassFile(path, ChangedClassFile.ChangeStatus.DELETED, className, classType));
continue;
}
if (status.startsWith("M")) {
ChangedClassFile modified = buildModified(path, oldSha, newSha, fallbackName, classType);
if (modified != null) {
result.add(modified);
}
}
}
pairDeleteAndAdd(deletions, additionsByParent, oldSha, result);
result.addAll(deletions);
return result;
}
/** 处理 git R 状态:路径重命名 */
private ChangedClassFile buildRenamed(String oldPathRaw, String newPathRaw,
String oldSha, String newSha) throws IOException {
String oldPath = normalizePath(oldPathRaw);
String newPath = normalizePath(newPathRaw);
if (!oldPath.endsWith(".java") || !newPath.endsWith(".java")) {
return null;
}
String oldFallback = ClassDeclParser.classNameFromPath(oldPath);
String newFallback = ClassDeclParser.classNameFromPath(newPath);
ClassType classType = ClassType.fromClassName(newFallback);
if (classType == null) {
classType = ClassType.fromClassName(oldFallback);
}
if (classType == null) {
return null;
}
String oldSource = readFileAtCommit(oldSha, oldPath);
String newSource = readFileAtCommit(newSha, newPath);
String oldClassName = classDeclParser.resolveClassName(oldSource, oldFallback);
String newClassName = classDeclParser.resolveClassName(newSource, newFallback);
return new ChangedClassFile(newPath, oldPath, ChangedClassFile.ChangeStatus.RENAMED,
newClassName, oldClassName, classType);
}
/** 处理 M 状态:同路径下对比 AST 类名判断是否重命名 */
private ChangedClassFile buildModified(String path, String oldSha, String newSha,
String fallbackName, ClassType classType) throws IOException {
String oldSource = readFileAtCommit(oldSha, path);
String newSource = readFileAtCommit(newSha, path);
if (newSource == null || newSource.isBlank()) {
newSource = readFileAtHead(path);
}
String oldClassName = classDeclParser.resolveClassName(oldSource, fallbackName);
String newClassName = classDeclParser.resolveClassName(newSource, fallbackName);
if (oldClassName.equals(newClassName)) {
return new ChangedClassFile(path, ChangedClassFile.ChangeStatus.MODIFIED,
newClassName, classType);
}
return new ChangedClassFile(path, path, ChangedClassFile.ChangeStatus.RENAMED,
newClassName, oldClassName, classType);
}
/** 同目录 D+A 配对为 RENAMEDGit 未显式标记 R 时) */
private void pairDeleteAndAdd(List<ChangedClassFile> deletions,
Map<String, PendingAdd> additionsByParent,
String oldSha,
List<ChangedClassFile> result) throws IOException {
List<ChangedClassFile> unpaired = new ArrayList<>();
for (ChangedClassFile deleted : deletions) {
String parent = parentDir(deleted.getRelativePath());
PendingAdd pending = additionsByParent.get(parent);
if (pending == null || pending.isEmpty()) {
unpaired.add(deleted);
continue;
}
PendingAdd.Candidate candidate = pending.poll(deleted.getClassType());
if (candidate == null) {
unpaired.add(deleted);
continue;
}
String oldSource = readFileAtCommit(oldSha, deleted.getRelativePath());
String oldClassName = classDeclParser.resolveClassName(oldSource, deleted.getClassName());
result.add(new ChangedClassFile(candidate.path(), deleted.getRelativePath(),
ChangedClassFile.ChangeStatus.RENAMED,
candidate.className(), oldClassName, deleted.getClassType()));
}
deletions.clear();
deletions.addAll(unpaired);
}
/** 取路径父目录,用于 D+A 配对 */
private static String parentDir(String path) {
int idx = path.lastIndexOf('/');
return idx >= 0 ? path.substring(0, idx) : "";
}
/** 读取指定 commit 下的文件内容 */
public String readFileAtCommit(String commitSha, String relativePath) throws IOException {
List<String> lines = runGit("show", commitSha + ":" + relativePath);
if (lines.isEmpty()) {
return "";
}
if (lines.size() == 1 && lines.get(0).startsWith("fatal:")) {
return "";
}
return String.join("\n", lines);
}
/** 读取工作区 HEAD 文件commit 中缺失时的回退) */
public String readFileAtHead(String relativePath) throws IOException {
Path file = repoRoot.resolve(relativePath);
if (!Files.exists(file)) {
return null;
}
return Files.readString(file, StandardCharsets.UTF_8);
}
/** 两次提交间变更文件路径列表(--name-only */
public List<String> diffNameOnly(String oldSha, String newSha) throws IOException {
return runGit("diff", "--name-only", oldSha, newSha);
}
/** 在 repoRoot 下执行 git 命令并返回 stdout 行 */
private List<String> runGit(String... args) throws IOException {
String[] command = new String[args.length + 3];
command[0] = "git";
command[1] = "-C";
command[2] = repoRoot.toString();
System.arraycopy(args, 0, command, 3, args.length);
ProcessBuilder builder = new ProcessBuilder(command);
builder.redirectErrorStream(true);
Process process = builder.start();
List<String> output = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
output.add(line);
}
}
try {
int exitCode = process.waitFor();
if (exitCode != 0 && !isBenignGitShowFailure(args, output)) {
throw new IOException("git 命令失败: " + String.join(" ", command)
+ "\n" + String.join("\n", output));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("git 命令被中断", e);
}
return output;
}
/** git show 文件不存在等情况视为可忽略 */
private boolean isBenignGitShowFailure(String[] args, List<String> output) {
if (args.length > 0 && "show".equals(args[0])) {
String joined = String.join("\n", output).toLowerCase(Locale.ROOT);
return joined.contains("exists on disk") || joined.contains("bad object")
|| joined.contains("path") && joined.contains("does not exist");
}
return false;
}
/** 统一路径分隔符为 / */
private String normalizePath(String path) {
return path.replace("\\", "/");
}
/** 同目录新增文件缓冲,供 D+A 配对 */
private static final class PendingAdd {
private final Map<ClassType, List<Candidate>> byType = new HashMap<>();
void add(String path, String className, ClassType classType, String source) {
byType.computeIfAbsent(classType, k -> new ArrayList<>())
.add(new Candidate(path, className));
}
boolean isEmpty() {
return byType.values().stream().allMatch(List::isEmpty);
}
/** 按类型取出一个候选新增文件 */
Candidate poll(ClassType classType) {
List<Candidate> list = byType.get(classType);
if (list == null || list.isEmpty()) {
return null;
}
return list.remove(0);
}
private static final class Candidate {
private final String path;
private final String className;
private Candidate(String path, String className) {
this.path = path;
this.className = className;
}
private String path() {
return path;
}
private String className() {
return className;
}
}
}
}

View File

@@ -0,0 +1,57 @@
package com.codechecker.model;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 索引中的 HTTP/Feign 接口方法、URI、入参/返回类型简单名。
*/
public class ApiEndpoint {
private final String httpMethod;
private final String uri;
private final String sourceFile;
private final Set<String> paramTypes;
private final Set<String> returnTypes;
public ApiEndpoint(String httpMethod, String uri, String sourceFile,
Set<String> paramTypes, Set<String> returnTypes) {
this.httpMethod = httpMethod;
this.uri = uri;
this.sourceFile = sourceFile;
this.paramTypes = paramTypes == null ? Set.of() : new LinkedHashSet<>(paramTypes);
this.returnTypes = returnTypes == null ? Set.of() : new LinkedHashSet<>(returnTypes);
}
public String getHttpMethod() {
return httpMethod;
}
public String getUri() {
return uri;
}
/** 定义该接口的 Java 源文件相对路径 */
public String getSourceFile() {
return sourceFile;
}
/** 入参涉及的类型简单名集合 */
public Set<String> getParamTypes() {
return paramTypes;
}
/** 返回值涉及的类型简单名集合(已剥离泛型包装) */
public Set<String> getReturnTypes() {
return returnTypes;
}
/** 去重用键METHOD + URI */
public String endpointKey() {
return httpMethod + " " + uri;
}
/** 通知展示行GET /api/foo */
public String displayLine() {
return httpMethod + " " + uri;
}
}

View File

@@ -0,0 +1,67 @@
package com.codechecker.model;
/**
* Git 扫描得到的单个 Java 模型类变更记录。
*/
public class ChangedClassFile {
/** Git diff 状态:修改 / 删除 / 重命名 */
public enum ChangeStatus {
MODIFIED, DELETED, RENAMED
}
private final String relativePath;
private final String oldRelativePath;
private final ChangeStatus status;
private final String className;
private final String oldClassName;
private final ClassType classType;
/** 修改或删除(无路径变化) */
public ChangedClassFile(String relativePath, ChangeStatus status, String className, ClassType classType) {
this(relativePath, null, status, className, null, classType);
}
/** 重命名或同路径类名变更 */
public ChangedClassFile(String relativePath, String oldRelativePath, ChangeStatus status,
String className, String oldClassName, ClassType classType) {
this.relativePath = relativePath;
this.oldRelativePath = oldRelativePath;
this.status = status;
this.className = className;
this.oldClassName = oldClassName;
this.classType = classType;
}
/** 新提交中的相对路径 */
public String getRelativePath() {
return relativePath;
}
/** 旧提交中的相对路径,未变路径则为 null */
public String getOldRelativePath() {
return oldRelativePath;
}
/** 读取旧版本源码时使用的路径 */
public String pathForOldCommit() {
return oldRelativePath != null ? oldRelativePath : relativePath;
}
public ChangeStatus getStatus() {
return status;
}
/** 当前简单类名 */
public String getClassName() {
return className;
}
/** 重命名前简单类名 */
public String getOldClassName() {
return oldClassName;
}
public ClassType getClassType() {
return classType;
}
}

View File

@@ -0,0 +1,15 @@
package com.codechecker.model;
/**
* 单次类变更的类型,决定通知内容与影响分析策略。
*/
public enum ClassChangeKind {
/** 文件已删除 */
DELETED,
/** 仅字段变更 */
FIELDS_ONLY,
/** 仅类名变更,字段不变 */
RENAME_ONLY,
/** 类名与字段同时变更 */
RENAME_AND_FIELDS
}

View File

@@ -0,0 +1,136 @@
package com.codechecker.model;
import java.util.ArrayList;
import java.util.List;
/**
* 单次类变更的完整报告:变更类型、字段 diff、接口/转换影响,供通知渲染。
*/
public class ClassChangeReport {
private final String className;
private final String oldClassName;
private final ClassType classType;
private final ClassChangeKind changeKind;
private final String sourceFile;
private final String classDescription;
private final List<FieldChange> fieldChanges = new ArrayList<>();
private final List<ApiEndpoint> inputImpactEndpoints = new ArrayList<>();
private final List<String> conversionEntities = new ArrayList<>();
private final List<ApiEndpoint> frontendImpactEndpoints = new ArrayList<>();
private final boolean conversionCheckEnabled;
private List<String> objectRoleLabels = List.of();
public ClassChangeReport(String className, String oldClassName, ClassType classType,
ClassChangeKind changeKind, String sourceFile,
boolean conversionCheckEnabled, String classDescription) {
this.className = className;
this.oldClassName = oldClassName;
this.classType = classType;
this.changeKind = changeKind;
this.sourceFile = sourceFile;
this.conversionCheckEnabled = conversionCheckEnabled;
this.classDescription = classDescription == null ? "" : classDescription.trim();
}
/** 当前(新)简单类名 */
public String getClassName() {
return className;
}
/** 重命名前的简单类名,未重命名则为 null */
public String getOldClassName() {
return oldClassName;
}
/** 是否发生类名变更 */
public boolean isRenamed() {
return oldClassName != null && !oldClassName.equals(className);
}
/** 是否仅类名变更、字段无变化 */
public boolean isRenameOnly() {
return changeKind == ClassChangeKind.RENAME_ONLY;
}
public ClassType getClassType() {
return classType;
}
public ClassChangeKind getChangeKind() {
return changeKind;
}
/** Git 相对路径,通知「文件路径」展示用 */
public String getSourceFile() {
return sourceFile;
}
/** 类级中文说明(@Schema / 类 Javadoc无则空串 */
public String getClassDescription() {
return classDescription;
}
/** 是否整文件删除 */
public boolean isDeleted() {
return changeKind == ClassChangeKind.DELETED;
}
public List<FieldChange> getFieldChanges() {
return fieldChanges;
}
/** 入参引用该类的接口request 影响) */
public List<ApiEndpoint> getInputImpactEndpoints() {
return inputImpactEndpoints;
}
/** Dto→Entity 转换目标类名列表 */
public List<String> getConversionEntities() {
return conversionEntities;
}
/** 返回值引用该类的接口response 影响) */
public List<ApiEndpoint> getFrontendImpactEndpoints() {
return frontendImpactEndpoints;
}
/** 是否启用类转换检测 */
public boolean isConversionCheckEnabled() {
return conversionCheckEnabled;
}
/** 对象角色标签(如「嵌套对象」「顶层对象」),仅 Dto/Vo 且存在嵌套时非空 */
public List<String> getObjectRoleLabels() {
return objectRoleLabels;
}
public void setObjectRoleLabels(List<String> labels) {
this.objectRoleLabels = labels == null || labels.isEmpty() ? List.of() : List.copyOf(labels);
}
/** 追加一条字段变更 */
public void addFieldChange(FieldChange change) {
fieldChanges.add(change);
}
/** 追加 request 影响接口(按 endpointKey 去重) */
public void addInputImpact(ApiEndpoint endpoint) {
if (inputImpactEndpoints.stream().noneMatch(e -> e.endpointKey().equals(endpoint.endpointKey()))) {
inputImpactEndpoints.add(endpoint);
}
}
/** 追加关联 Entity 类名(去重) */
public void addConversionEntity(String entityName) {
if (!conversionEntities.contains(entityName)) {
conversionEntities.add(entityName);
}
}
/** 追加 response 影响接口(按 endpointKey 去重) */
public void addFrontendImpact(ApiEndpoint endpoint) {
if (frontendImpactEndpoints.stream().noneMatch(e -> e.endpointKey().equals(endpoint.endpointKey()))) {
frontendImpactEndpoints.add(endpoint);
}
}
}

View File

@@ -0,0 +1,47 @@
package com.codechecker.model;
/**
* 目标模型类后缀类型,决定通知模版中展示哪些影响段落。
*/
public enum ClassType {
DTO("Dto"),
VO("Vo"),
ENTITY("Entity"),
MODEL("Model");
private final String label;
ClassType(String label) {
this.label = label;
}
/** 通知中展示的类型标签 */
public String getLabel() {
return label;
}
/** 根据简单类名后缀识别类型,不匹配则 null */
public static ClassType fromClassName(String className) {
if (className.endsWith("Dto")) {
return DTO;
}
if (className.endsWith("VO")) {
return VO;
}
if (className.endsWith("Vo")) {
return VO;
}
if (className.endsWith("Entity")) {
return ENTITY;
}
if (className.endsWith("Model")) {
return MODEL;
}
return null;
}
/** 判断类名是否属于当前类型 */
public boolean isTargetSuffix(String className) {
return fromClassName(className) == this;
}
}

View File

@@ -0,0 +1,95 @@
package com.codechecker.model;
/**
* 字段级 diff 结果,用于通知中的 [新增]/[删除]/[修改]/[重命名] 行。
*/
public class FieldChange {
/** 字段变更种类 */
public enum ChangeKind {
ADDED, REMOVED, MODIFIED, RENAMED
}
private final ChangeKind kind;
private final String fieldName;
private final String oldFieldName;
private final String description;
private final String oldType;
private final String newType;
private final String oldDescription;
private final String detail;
private FieldChange(ChangeKind kind, String fieldName, String oldFieldName, String description,
String oldType, String newType, String oldDescription, String detail) {
this.kind = kind;
this.fieldName = fieldName;
this.oldFieldName = oldFieldName;
this.description = description;
this.oldType = oldType;
this.newType = newType;
this.oldDescription = oldDescription;
this.detail = detail;
}
/** 构造新增字段变更 */
public static FieldChange added(FieldInfo field) {
return new FieldChange(ChangeKind.ADDED, field.getName(), null, field.getDescription(),
null, field.getType(), null, null);
}
/** 构造删除字段变更 */
public static FieldChange removed(FieldInfo field) {
return new FieldChange(ChangeKind.REMOVED, field.getName(), null, field.getDescription(),
field.getType(), null, field.getDescription(), null);
}
/** 构造修改字段变更detail 通常为类型变化描述 */
public static FieldChange modified(FieldInfo oldField, FieldInfo newField, String detail) {
return new FieldChange(ChangeKind.MODIFIED, newField.getName(), null, newField.getDescription(),
oldField.getType(), newField.getType(), oldField.getDescription(), detail);
}
/** 构造字段重命名;类型变化时 detail 为 oldType → newType */
public static FieldChange renamed(FieldInfo oldField, FieldInfo newField) {
String typeDetail = oldField.getType().equals(newField.getType())
? null
: oldField.getType() + "" + newField.getType();
return new FieldChange(ChangeKind.RENAMED, newField.getName(), oldField.getName(),
newField.getDescription(), oldField.getType(), newField.getType(),
oldField.getDescription(), typeDetail);
}
public ChangeKind getKind() {
return kind;
}
public String getFieldName() {
return fieldName;
}
/** 重命名前的字段名,仅 RENAMED 时有值 */
public String getOldFieldName() {
return oldFieldName;
}
/** 变更后的字段说明(通知「说明」段) */
public String getDescription() {
return description;
}
public String getOldType() {
return oldType;
}
public String getNewType() {
return newType;
}
public String getOldDescription() {
return oldDescription;
}
/** 结构性变更详情,重命名时为类型变化描述 */
public String getDetail() {
return detail;
}
}

View File

@@ -0,0 +1,52 @@
package com.codechecker.model;
import java.util.Objects;
/**
* 解析后的单个字段名称、类型、业务说明Schema/注释)。
*/
public class FieldInfo {
private final String name;
private final String type;
private final String description;
public FieldInfo(String name, String type, String description) {
this.name = name;
this.type = type;
this.description = description == null ? "" : description;
}
/** 字段名 */
public String getName() {
return name;
}
/** 字段类型(简单名) */
public String getType() {
return type;
}
/** 字段说明文案 */
public String getDescription() {
return description;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof FieldInfo)) {
return false;
}
FieldInfo other = (FieldInfo) o;
return Objects.equals(name, other.name)
&& Objects.equals(type, other.type)
&& Objects.equals(description, other.description);
}
@Override
public int hashCode() {
return Objects.hash(name, type, description);
}
}

View File

@@ -0,0 +1,218 @@
package com.codechecker.notify;
import com.codechecker.analyzer.DtoNestIndex;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.model.ParameterChange;
import com.codechecker.config.DtoOverlapMode;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.model.ClassType;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 按配置过滤 Dto 类变更与 API 参数变更的重叠通知。
*/
public class OverlapNotificationFilter {
public static final class FilterResult {
private final List<ClassChangeReport> classReports;
private final List<EndpointChangeReport> apiReports;
public FilterResult(List<ClassChangeReport> classReports, List<EndpointChangeReport> apiReports) {
this.classReports = classReports;
this.apiReports = apiReports;
}
public List<ClassChangeReport> classReports() {
return classReports;
}
public List<EndpointChangeReport> apiReports() {
return apiReports;
}
}
public static FilterResult apply(List<ClassChangeReport> classReports,
List<EndpointChangeReport> apiReports,
DtoOverlapMode mode,
DtoNestIndex nestIndex) {
if (mode == DtoOverlapMode.BOTH) {
return new FilterResult(classReports, apiReports);
}
Set<OverlapKey> overlapKeys = buildOverlapKeys(classReports, nestIndex);
if (overlapKeys.isEmpty()) {
return new FilterResult(classReports, apiReports);
}
if (mode == DtoOverlapMode.CLASS_ONLY) {
return new FilterResult(classReports, filterApiReports(apiReports, overlapKeys));
}
Set<OverlapKey> apiOverlapKeys = buildApiOverlapKeys(apiReports);
return new FilterResult(filterClassReportsForApiOnly(classReports, apiOverlapKeys, nestIndex), apiReports);
}
/**
* 重叠键使用 @RequestBody 根 Dto如 PunishmentsApprovalDto与 API 参数通知 parentDto 对齐;
* 嵌套子 Dto如 UserSelfDto通过 nestIndex 解析到根 Dto。
*/
private static Set<OverlapKey> buildOverlapKeys(List<ClassChangeReport> classReports,
DtoNestIndex nestIndex) {
Set<OverlapKey> keys = new LinkedHashSet<>();
for (ClassChangeReport report : classReports) {
if (report.getClassType() != ClassType.DTO) {
continue;
}
if (!hasDtoFieldChanges(report)) {
continue;
}
Set<String> bodyRoots = requestBodyRoots(report, nestIndex);
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
for (String rootDto : bodyRoots) {
keys.add(new OverlapKey(rootDto, endpoint.endpointKey()));
}
}
}
return keys;
}
private static Set<String> requestBodyRoots(ClassChangeReport report, DtoNestIndex nestIndex) {
Set<String> roots = new LinkedHashSet<>();
if (nestIndex != null) {
roots.addAll(nestIndex.findRequestBodyRoots(report.getClassName()));
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
roots.addAll(nestIndex.findRequestBodyRoots(report.getOldClassName()));
}
}
if (roots.isEmpty() && report.getClassName().endsWith("Dto")) {
roots.add(report.getClassName());
}
return roots;
}
private static List<EndpointChangeReport> filterApiReports(List<EndpointChangeReport> apiReports,
Set<OverlapKey> overlapKeys) {
List<EndpointChangeReport> kept = new ArrayList<>();
for (EndpointChangeReport report : apiReports) {
if (!matchesOverlap(report, overlapKeys)) {
kept.add(report);
}
}
return kept;
}
private static List<ClassChangeReport> filterClassReportsForApiOnly(List<ClassChangeReport> classReports,
Set<OverlapKey> apiOverlapKeys,
DtoNestIndex nestIndex) {
List<ClassChangeReport> kept = new ArrayList<>();
for (ClassChangeReport report : classReports) {
if (!shouldSuppressClassForApiOnly(report, apiOverlapKeys, nestIndex)) {
kept.add(report);
}
}
return kept;
}
private static Set<OverlapKey> buildApiOverlapKeys(List<EndpointChangeReport> apiReports) {
Set<OverlapKey> keys = new LinkedHashSet<>();
for (EndpointChangeReport report : apiReports) {
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED) {
continue;
}
String endpointKey = report.getHttpMethod() + " " + report.getUri();
for (ParameterChange change : report.getParameterChanges()) {
if (!"body".equals(change.getSource())) {
continue;
}
String parentDto = change.getParentDto();
if (parentDto != null && !parentDto.isBlank()) {
keys.add(new OverlapKey(parentDto, endpointKey));
}
}
if (report.isDtoFollowUp()) {
String relatedDto = report.getRelatedDtoClassName();
if (relatedDto != null && !relatedDto.isBlank()) {
keys.add(new OverlapKey(relatedDto, endpointKey));
}
}
}
return keys;
}
private static boolean shouldSuppressClassForApiOnly(ClassChangeReport report,
Set<OverlapKey> apiOverlapKeys,
DtoNestIndex nestIndex) {
if (report.getClassType() != ClassType.DTO || !hasDtoFieldChanges(report)) {
return false;
}
Set<String> bodyRoots = requestBodyRoots(report, nestIndex);
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
for (String rootDto : bodyRoots) {
if (apiOverlapKeys.contains(new OverlapKey(rootDto, endpoint.endpointKey()))) {
return true;
}
}
}
return false;
}
private static boolean matchesOverlap(EndpointChangeReport report, Set<OverlapKey> overlapKeys) {
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED) {
return false;
}
String endpointKey = report.getHttpMethod() + " " + report.getUri();
for (ParameterChange change : report.getParameterChanges()) {
if (!"body".equals(change.getSource())) {
continue;
}
String parentDto = change.getParentDto();
if (parentDto == null || parentDto.isBlank()) {
continue;
}
if (overlapKeys.contains(new OverlapKey(parentDto, endpointKey))) {
return true;
}
}
if (report.isDtoFollowUp()) {
String relatedDto = report.getRelatedDtoClassName();
if (relatedDto != null && overlapKeys.contains(new OverlapKey(relatedDto, endpointKey))) {
return true;
}
}
return false;
}
private static boolean hasDtoFieldChanges(ClassChangeReport report) {
return !report.getFieldChanges().isEmpty();
}
private static final class OverlapKey {
private final String dtoClassName;
private final String endpointKey;
private OverlapKey(String dtoClassName, String endpointKey) {
this.dtoClassName = dtoClassName;
this.endpointKey = endpointKey;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof OverlapKey)) {
return false;
}
OverlapKey other = (OverlapKey) obj;
return dtoClassName.equals(other.dtoClassName) && endpointKey.equals(other.endpointKey);
}
@Override
public int hashCode() {
return dtoClassName.hashCode() * 31 + endpointKey.hashCode();
}
}
}

View File

@@ -0,0 +1,394 @@
package com.codechecker.notify;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.model.ClassType;
import com.codechecker.model.FieldChange;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 将 ClassChangeReport 渲染为企业微信 Markdown 并发送(或仅日志输出)。
* <p>
* 使用 webhook {@code markdown}v1引用块 + 换行排版,三色 fontinfo/comment/warning
* v1 不支持无序列表,各项以 {@code >标签: 值} 分行展示(冒号后两空格)。
*/
public class WeComNotifier {
private static final int MAX_LENGTH = 3800;
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private final OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build();
/** 逐条发送企微通知,返回成功条数 */
public int sendAll(String webhookUrl, List<ClassChangeReport> reports, String modifier, String modifyTime) {
if (reports == null || reports.isEmpty()) {
System.out.println("无类变更,不发送到企业微信");
return 0;
}
int sent = 0;
for (ClassChangeReport report : reports) {
String markdown = buildMarkdown(report, modifier, modifyTime);
if (postMarkdown(webhookUrl, markdown)) {
sent++;
System.out.println("已发送类变更通知: " + report.getClassName());
}
}
if (sent > 0) {
System.out.println("总共发送 " + sent + " 条类变更通知到企业微信");
}
return sent;
}
/** 企微关闭时打印 Markdown 到控制台 */
public void logAll(List<ClassChangeReport> reports, String modifier, String modifyTime) {
if (reports == null || reports.isEmpty()) {
System.out.println("无类变更,无日志输出");
return;
}
System.out.println("企业微信通知已关闭wecom.enabled=false以下结果仅输出到日志");
for (int i = 0; i < reports.size(); i++) {
ClassChangeReport report = reports.get(i);
System.out.println("========== 类变更 [" + (i + 1) + "/" + reports.size()
+ "]: " + report.getClassName() + " ==========");
System.out.println(buildMarkdown(report, modifier, modifyTime));
System.out.println("========== 结束 ==========");
}
System.out.println("" + reports.size() + " 条类变更结果(未发送到企业微信)");
}
/** 组装完整 Markdown 正文(引用块 + 换行,每项独立一行) */
public String buildMarkdown(ClassChangeReport report, String modifier, String modifyTime) {
StringBuilder sb = new StringBuilder();
sb.append("# 【类变更通知】").append("\n\n");
appendHeader(sb, report, modifier, modifyTime);
sb.append("\n## 【对象变更细节】").append("\n\n");
appendChangeDetails(sb, report);
sb.append("\n## 【影响范围】").append("\n\n");
appendImpactSections(sb, report);
return truncate(sb.toString());
}
/** 变更对象行:类名(绿)+ 可选中文说明 + 嵌套角色标签(灰,整行加粗) */
private String formatChangeTarget(ClassChangeReport report) {
StringBuilder line = new StringBuilder(colorInfo(safe(report.getClassName())));
String description = report.getClassDescription();
if (description != null && !description.isBlank()) {
line.append("").append(colorComment(description)).append("");
}
for (String role : report.getObjectRoleLabels()) {
line.append("").append(colorComment(role)).append("");
}
return line.toString();
}
/** 头部元信息,每项一行引用(加粗) */
private void appendHeader(StringBuilder sb, ClassChangeReport report,
String modifier, String modifyTime) {
sb.append(quoteKvBold("变更对象", formatChangeTarget(report))).append("\n");
sb.append(quoteKvBold("修改人", colorComment(modifier))).append("\n");
sb.append(quoteKvBold("时间", colorComment(modifyTime))).append("\n");
sb.append(quoteKvBold("路径", colorComment(report.getSourceFile()))).append("\n");
}
/** 渲染删除 / 重命名 / 字段变更 */
private void appendChangeDetails(StringBuilder sb, ClassChangeReport report) {
if (report.isDeleted()) {
sb.append(quoteLine(colorWarning("[已删除]") + " "
+ colorComment("该类文件已被移除"))).append("\n");
return;
}
if (report.isRenamed()) {
sb.append(quoteLine(colorWarning("[类名变更]") + " "
+ colorComment(safe(report.getOldClassName())) + ""
+ colorInfo(safe(report.getClassName())))).append("\n");
}
if (report.isRenameOnly()) {
sb.append(quoteLine(colorComment("字段无变化"))).append("\n");
return;
}
if (!report.getFieldChanges().isEmpty()) {
int count = report.getFieldChanges().size();
sb.append(quoteLine("**共 " + colorWarning(String.valueOf(count)) + " 项变更**"))
.append("\n\n");
for (int i = 0; i < report.getFieldChanges().size(); i++) {
if (i > 0) {
sb.append("\n");
}
sb.append(formatFieldChange(report.getFieldChanges().get(i)));
}
sb.append("\n");
}
}
/** 按类类型选择影响段落 */
private void appendImpactSections(StringBuilder sb, ClassChangeReport report) {
appendImpactByType(sb, report);
}
/** Dto/Vo 均展示 request + response二者可能交叉Entity/Model 仅类转换 */
private void appendImpactByType(StringBuilder sb, ClassChangeReport report) {
switch (report.getClassType()) {
case DTO:
case VO:
appendSectionIfNeeded(sb, report, true, true, true);
break;
case ENTITY:
case MODEL:
appendSectionIfNeeded(sb, report, false, false, true);
break;
default:
appendSectionIfNeeded(sb, report, true, true, true);
}
}
/** 按需追加 request / response / 类转换三个小节 */
private void appendSectionIfNeeded(StringBuilder sb, ClassChangeReport report,
boolean showRequest, boolean showResponse, boolean showConversion) {
if (showRequest) {
sb.append("### 影响 request 接口").append("\n");
appendEndpointList(sb, report.getInputImpactEndpoints());
sb.append("\n");
}
if (showResponse) {
sb.append("### 影响 response 接口").append("\n");
appendEndpointList(sb, report.getFrontendImpactEndpoints());
sb.append("\n");
}
if (showConversion && report.isConversionCheckEnabled()) {
sb.append("### 类转换影响").append("\n");
appendConversionList(sb, report);
}
}
/** 渲染关联 Entity每项一行 */
private void appendConversionList(StringBuilder sb, ClassChangeReport report) {
if (report.getConversionEntities().isEmpty()) {
sb.append(quoteLine(colorComment(""))).append("\n");
return;
}
for (String entity : report.getConversionEntities()) {
sb.append(quoteKv("Entity", colorInfo(safe(entity)))).append("\n");
}
}
/** 渲染接口,每项一行 */
private void appendEndpointList(StringBuilder sb, List<ApiEndpoint> endpoints) {
if (endpoints == null || endpoints.isEmpty()) {
sb.append(quoteLine(colorComment(""))).append("\n");
return;
}
for (ApiEndpoint endpoint : endpoints) {
sb.append(formatEndpointLine(endpoint)).append("\n");
}
}
/** 接口行:> POST `/path` */
private String formatEndpointLine(ApiEndpoint endpoint) {
String line = endpoint.displayLine();
int space = line.indexOf(' ');
if (space > 0) {
String method = line.substring(0, space).trim();
String path = line.substring(space).trim();
return quoteLine(colorInfo(method) + " " + inlineCode(path));
}
return quoteLine(inlineCode(safe(line)));
}
/** 单条字段变更:标签、说明、类型合并为一行,字段间空行分隔 */
private String formatFieldChange(FieldChange change) {
String fieldName = inlineCode(safe(change.getFieldName()));
String desc = change.getDescription() == null ? "" : change.getDescription();
String descPart = desc.isBlank()
? colorComment("(无说明)")
: colorComment(desc);
switch (change.getKind()) {
case ADDED: {
StringBuilder line = new StringBuilder();
line.append(tagAdded()).append(" ").append(fieldName)
.append(" 说明: ").append(descPart);
appendFieldType(line, change);
return quoteLine(line.toString());
}
case REMOVED: {
StringBuilder line = new StringBuilder();
line.append(tagRemoved()).append(" ").append(fieldName)
.append(" 说明: ").append(descPart);
appendFieldType(line, change);
return quoteLine(line.toString());
}
case RENAMED: {
StringBuilder renameLine = new StringBuilder();
renameLine.append(tagRenamed()).append(" ")
.append(colorComment(safe(change.getOldFieldName()))).append("")
.append(colorInfo(safe(change.getFieldName())))
.append(" 说明: ").append(descPart);
appendFieldType(renameLine, change);
return quoteLine(renameLine.toString());
}
case MODIFIED:
default: {
StringBuilder line = new StringBuilder();
line.append(tagModified()).append(" ").append(fieldName)
.append(" 说明: ").append(descPart);
appendFieldType(line, change);
return quoteLine(line.toString());
}
}
}
/** 追加字段类型:新增/重命名(仅改名)用 info删除用 warning修改/重命名(改类型)用 old → new */
private void appendFieldType(StringBuilder line, FieldChange change) {
if (change.getKind() == FieldChange.ChangeKind.RENAMED
|| change.getKind() == FieldChange.ChangeKind.MODIFIED) {
String typeDetail = change.getDetail();
if (typeDetail != null && !typeDetail.isBlank()) {
line.append(" 类型: ").append(formatTypeChange(typeDetail));
return;
}
}
String singleType = change.getKind() == FieldChange.ChangeKind.REMOVED
? change.getOldType()
: change.getNewType();
if (singleType == null || singleType.isBlank()) {
return;
}
line.append(" 类型: ");
if (change.getKind() == FieldChange.ChangeKind.REMOVED) {
line.append(colorWarning(singleType));
} else {
line.append(colorInfo(singleType));
}
}
/** 类型变化:旧 warning → 新 info泛型尖括号原样展示不做 HTML 转义 */
private String formatTypeChange(String detail) {
int arrow = detail.indexOf("");
if (arrow < 0) {
return colorWarning(detail);
}
String oldType = detail.substring(0, arrow).trim();
String newType = detail.substring(arrow + 3).trim();
return colorWarning(oldType) + "" + colorInfo(newType);
}
private String tagAdded() {
return colorInfo("[新增]");
}
private String tagRemoved() {
return colorWarning("[删除]");
}
private String tagModified() {
return colorWarning("[修改]");
}
private String tagRenamed() {
return colorWarning("[重命名]");
}
/** 引用行:{@code >标签: 值}(冒号后两空格) */
private String quoteKv(String key, String value) {
return "> " + key + ": " + value;
}
/** 加粗引用行:用于类变更通知头部 */
private String quoteKvBold(String key, String value) {
return "> **" + key + ": " + value + "**";
}
/** 纯引用行 */
private String quoteLine(String content) {
return "> " + content;
}
/** 行内代码 */
private String inlineCode(String text) {
return "`" + text.replace("`", "'") + "`";
}
private String colorInfo(String text) {
return "<font color=\"info\">" + text + "</font>";
}
private String colorComment(String text) {
return "<font color=\"comment\">" + safe(text) + "</font>";
}
private String colorWarning(String text) {
return "<font color=\"warning\">" + text + "</font>";
}
/** 转义 HTML 特殊字符,避免破坏 font 标签 */
private String safe(String text) {
if (text == null) {
return "";
}
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
/** POST 企微 Webhookmarkdown v1 */
private boolean postMarkdown(String webhookUrl, String content) {
if (webhookUrl == null || webhookUrl.isBlank() || webhookUrl.contains("YOUR_WECOM")) {
System.out.println("[警告] 未配置有效的企业微信 Webhook URL");
System.out.println("--- 通知预览 ---");
System.out.println(content.length() > 1000 ? content.substring(0, 1000) : content);
return false;
}
String payload = "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":"
+ jsonEscape(content) + "}}";
Request request = new Request.Builder()
.url(webhookUrl)
.post(RequestBody.create(payload, JSON))
.build();
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful() && response.body() != null) {
String body = response.body().string();
return body.contains("\"errcode\":0");
}
System.out.println("[错误] 企微返回异常: " + response.code()
+ (response.body() != null ? " " + response.body().string() : ""));
return false;
} catch (IOException e) {
System.out.println("[错误] 发送企微消息失败: " + e.getMessage());
return false;
}
}
/** 超长消息截断(企微上限 4096 字节 UTF-8 */
private String truncate(String text) {
if (text.length() <= MAX_LENGTH) {
return text;
}
return text.substring(0, MAX_LENGTH) + "\n\n... 消息过长,已截断";
}
/** JSON 字符串转义 */
private String jsonEscape(String text) {
String escaped = text
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "");
return "\"" + escaped + "\"";
}
}

View File

@@ -0,0 +1,183 @@
package com.codechecker.parser;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.comments.JavadocComment;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
import java.util.Optional;
/**
* 从 Java 源文件路径或 AST 解析类名(简单名 / 全限定名)及类级中文说明。
*/
public class ClassDeclParser {
/**
* 从源码 AST 提取主类名;解析失败或未找到时回退为路径推导的类名。
*/
public String resolveClassName(String source, String fallbackFromPath) {
if (source == null || source.isBlank()) {
return fallbackFromPath;
}
try {
CompilationUnit cu = StaticJavaParser.parse(source);
for (TypeDeclaration<?> type : cu.getTypes()) {
if (type instanceof ClassOrInterfaceDeclaration) {
return type.getNameAsString();
}
}
} catch (Exception ignored) {
// 回退路径类名
}
return fallbackFromPath;
}
/** 从 .java 路径提取文件名(无扩展名)作为类名 */
public static String classNameFromPath(String path) {
String fileName = path.substring(path.lastIndexOf('/') + 1);
if (!fileName.endsWith(".java")) {
return fileName;
}
return fileName.substring(0, fileName.length() - 5);
}
/**
* 全限定类名package + 类名;源码无 package 时从文件路径推断。
*/
public String resolveQualifiedClassName(String source, String relativePath, String fallbackClassName) {
String simpleName = resolveClassName(source, fallbackClassName);
if (source != null && !source.isBlank()) {
try {
CompilationUnit cu = StaticJavaParser.parse(source);
String packageName = cu.getPackageDeclaration()
.map(p -> p.getNameAsString())
.orElse("");
if (!packageName.isBlank()) {
return packageName + "." + simpleName;
}
} catch (Exception ignored) {
// 回退路径推断
}
}
return inferQualifiedFromPath(relativePath, simpleName);
}
/**
* 提取类级中文说明:@Schema(description/title) &gt; 类 Javadoc 首段。
*/
public String extractClassDescription(String source, String expectedClassName) {
if (source == null || source.isBlank()) {
return "";
}
try {
CompilationUnit cu = StaticJavaParser.parse(source);
ClassOrInterfaceDeclaration classDecl = findClass(cu, expectedClassName);
if (classDecl == null) {
return "";
}
String fromSchema = readSchemaDescription(classDecl);
if (!fromSchema.isEmpty()) {
return fromSchema;
}
return extractClassJavadoc(classDecl);
} catch (Exception ignored) {
return "";
}
}
private ClassOrInterfaceDeclaration findClass(CompilationUnit cu, String expectedClassName) {
if (expectedClassName != null && !expectedClassName.isBlank()) {
for (TypeDeclaration<?> type : cu.getTypes()) {
if (type instanceof ClassOrInterfaceDeclaration) {
ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type;
if (classDecl.getNameAsString().equals(expectedClassName)) {
return classDecl;
}
}
}
}
for (TypeDeclaration<?> type : cu.getTypes()) {
if (type instanceof ClassOrInterfaceDeclaration) {
return (ClassOrInterfaceDeclaration) type;
}
}
return null;
}
private String readSchemaDescription(ClassOrInterfaceDeclaration classDecl) {
for (AnnotationExpr annotation : classDecl.getAnnotations()) {
if (!"Schema".equals(annotation.getNameAsString())) {
continue;
}
String description = readAnnotationStringValue(annotation, "description");
if (!description.isEmpty()) {
return description;
}
String title = readAnnotationStringValue(annotation, "title");
if (!title.isEmpty()) {
return title;
}
}
return "";
}
private String readAnnotationStringValue(AnnotationExpr annotation, String attributeName) {
if (annotation.isNormalAnnotationExpr()) {
NormalAnnotationExpr normal = annotation.asNormalAnnotationExpr();
for (var pair : normal.getPairs()) {
if (pair.getNameAsString().equals(attributeName)) {
return literalString(pair.getValue());
}
}
return "";
}
if (annotation.isSingleMemberAnnotationExpr()) {
SingleMemberAnnotationExpr single = annotation.asSingleMemberAnnotationExpr();
if ("value".equals(attributeName) || "description".equals(attributeName)) {
return literalString(single.getMemberValue());
}
}
return "";
}
private String literalString(Expression expression) {
if (expression.isStringLiteralExpr()) {
return expression.asStringLiteralExpr().getValue().trim();
}
return "";
}
private String extractClassJavadoc(ClassOrInterfaceDeclaration classDecl) {
Optional<JavadocComment> javadoc = classDecl.getJavadocComment();
if (javadoc.isEmpty()) {
return "";
}
String text = javadoc.get().parse().getDescription().toText();
return text == null ? "" : text.trim().replaceAll("\\s+", " ");
}
/** 从 src/main/java/ 后的路径推断 package.className */
public static String inferQualifiedFromPath(String relativePath, String className) {
if (relativePath == null || relativePath.isBlank()) {
return className;
}
String normalized = relativePath.replace('\\', '/');
String marker = "src/main/java/";
int idx = normalized.indexOf(marker);
if (idx < 0) {
return className;
}
String subPath = normalized.substring(idx + marker.length());
int lastSlash = subPath.lastIndexOf('/');
if (lastSlash <= 0) {
return className;
}
String packageName = subPath.substring(0, lastSlash).replace('/', '.');
return packageName + "." + className;
}
}

View File

@@ -0,0 +1,135 @@
package com.codechecker.parser;
import com.codechecker.model.FieldInfo;
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 com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.comments.JavadocComment;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 解析模型类字段:名称、类型、业务说明(注解或 Javadoc
*/
public class ClassFieldParser {
/** 解析指定类的实例字段列表 */
public List<FieldInfo> parseFields(String source, String expectedClassName) {
if (source == null || source.isBlank()) {
return List.of();
}
CompilationUnit cu = StaticJavaParser.parse(source);
ClassOrInterfaceDeclaration classDecl = findClass(cu, expectedClassName);
if (classDecl == null) {
return List.of();
}
return parseClassFields(classDecl);
}
/** 按类名查找类声明,找不到则取第一个类 */
private ClassOrInterfaceDeclaration findClass(CompilationUnit cu, String expectedClassName) {
if (expectedClassName != null && !expectedClassName.isBlank()) {
for (TypeDeclaration<?> type : cu.getTypes()) {
if (type instanceof ClassOrInterfaceDeclaration) {
ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type;
if (classDecl.getNameAsString().equals(expectedClassName)) {
return classDecl;
}
}
}
}
for (TypeDeclaration<?> type : cu.getTypes()) {
if (type instanceof ClassOrInterfaceDeclaration) {
return (ClassOrInterfaceDeclaration) type;
}
}
return null;
}
/** 提取非 static final 字段,跳过常量 */
private List<FieldInfo> parseClassFields(ClassOrInterfaceDeclaration classDecl) {
Map<String, FieldInfo> fields = new LinkedHashMap<>();
for (FieldDeclaration fieldDecl : classDecl.getFields()) {
if (fieldDecl.isStatic() && fieldDecl.isFinal()) {
continue;
}
String type = TypeNameUtils.typeToString(fieldDecl.getElementType());
String description = extractFieldLabel(fieldDecl);
for (VariableDeclarator variable : fieldDecl.getVariables()) {
fields.put(variable.getNameAsString(), new FieldInfo(variable.getNameAsString(), type, description));
}
}
return new ArrayList<>(fields.values());
}
/**
* 字段说明:@Schema(description) &gt; @ApiModelProperty &gt; Javadoc均无则空串。
*/
String extractFieldLabel(FieldDeclaration fieldDecl) {
for (AnnotationExpr annotation : fieldDecl.getAnnotations()) {
String annName = annotation.getNameAsString();
if ("Schema".equals(annName)) {
String description = readAnnotationStringValue(annotation, "description");
if (!description.isEmpty()) {
return description;
}
}
if ("ApiModelProperty".equals(annName)) {
String value = readAnnotationStringValue(annotation, "value");
if (!value.isEmpty()) {
return value;
}
}
}
return extractJavadoc(fieldDecl);
}
/** 读取注解中的字符串属性值 */
private String readAnnotationStringValue(AnnotationExpr annotation, String attributeName) {
if (annotation.isNormalAnnotationExpr()) {
NormalAnnotationExpr normal = annotation.asNormalAnnotationExpr();
for (var pair : normal.getPairs()) {
if (pair.getNameAsString().equals(attributeName)) {
return literalString(pair.getValue());
}
}
return "";
}
if (annotation.isSingleMemberAnnotationExpr()) {
SingleMemberAnnotationExpr single = annotation.asSingleMemberAnnotationExpr();
if ("value".equals(attributeName) || "description".equals(attributeName)) {
return literalString(single.getMemberValue());
}
}
return "";
}
/** 提取字符串字面量值 */
private String literalString(Expression expression) {
if (expression.isStringLiteralExpr()) {
return expression.asStringLiteralExpr().getValue().trim();
}
return "";
}
/** 从字段 Javadoc 提取首段描述 */
private String extractJavadoc(FieldDeclaration fieldDecl) {
Optional<JavadocComment> javadoc = fieldDecl.getJavadocComment();
if (javadoc.isEmpty()) {
return "";
}
String text = javadoc.get().parse().getDescription().toText();
return text == null ? "" : text.trim().replaceAll("\\s+", " ");
}
}

View File

@@ -0,0 +1,100 @@
package com.codechecker.parser;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.expr.MethodCallExpr;
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Stream;
/**
* 扫描 Dto→Entity 转换关系convert 方法返回值、BeanUtils.copyProperties 调用。
*/
public class ConversionParser {
/** 在类内查找 convert 方法,收集返回 Entity 的类型名 */
public Set<String> findConvertTargetsInClass(String source, String className) {
Set<String> entities = new LinkedHashSet<>();
if (source == null || source.isBlank()) {
return entities;
}
CompilationUnit cu = StaticJavaParser.parse(source);
for (TypeDeclaration<?> type : cu.getTypes()) {
if (type instanceof ClassOrInterfaceDeclaration) {
ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type;
if (!classDecl.getNameAsString().equals(className)) {
continue;
}
for (MethodDeclaration method : classDecl.getMethods()) {
if (!"convert".equals(method.getNameAsString())) {
continue;
}
String returnType = TypeNameUtils.simpleName(TypeNameUtils.typeToString(method.getType()));
if (returnType.endsWith("Entity")) {
entities.add(returnType);
}
}
}
}
return entities;
}
/** 递归扫描目录,查找 BeanUtils.copyProperties(sourceClass, *Entity) */
public Set<String> findBeanUtilsTargets(Path rootDir, String sourceClassName) throws IOException {
Set<String> entities = new LinkedHashSet<>();
if (!Files.exists(rootDir)) {
return entities;
}
try (Stream<Path> paths = Files.walk(rootDir)) {
paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> {
try {
String source = Files.readString(path, StandardCharsets.UTF_8);
entities.addAll(scanBeanUtilsInSource(source, sourceClassName));
} catch (IOException ignored) {
// 跳过
}
});
}
return entities;
}
/** 在单文件源码中扫描 BeanUtils.copyProperties 调用 */
private Set<String> scanBeanUtilsInSource(String source, String sourceClassName) {
Set<String> entities = new LinkedHashSet<>();
CompilationUnit cu = StaticJavaParser.parse(source);
cu.accept(new VoidVisitorAdapter<Void>() {
@Override
public void visit(MethodCallExpr call, Void arg) {
super.visit(call, arg);
if (!call.getNameAsString().equals("copyProperties")) {
return;
}
if (call.getScope().isEmpty()) {
return;
}
String scope = call.getScope().get().toString();
if (!scope.endsWith("BeanUtils")) {
return;
}
if (call.getArguments().size() < 2) {
return;
}
String firstArg = TypeNameUtils.simpleName(call.getArguments().get(0).toString());
String secondArg = TypeNameUtils.simpleName(call.getArguments().get(1).toString());
if (sourceClassName.equals(firstArg) && secondArg.endsWith("Entity")) {
entities.add(secondArg);
}
}
}, null);
return entities;
}
}

View File

@@ -0,0 +1,301 @@
package com.codechecker.parser;
import com.codechecker.model.ApiEndpoint;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.NodeList;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
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.expr.AnnotationExpr;
import com.github.javaparser.ast.type.Type;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
/**
* 扫描 Controller / Feign 接口,提取 HTTP 方法、URI、入参/返回类型。
*/
public class EndpointParser {
private static final Set<String> MAPPING_ANNOTATIONS = Set.of(
"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"
);
private static final Map<String, String> MAPPING_DEFAULT_METHOD = Map.of(
"GetMapping", "GET",
"PostMapping", "POST",
"PutMapping", "PUT",
"DeleteMapping", "DELETE",
"PatchMapping", "PATCH"
);
/** 扫描 @RestController / @Controller 目录 */
public List<ApiEndpoint> scanControllerDirectory(Path rootDir, String relativePrefix) throws IOException {
return scanDirectory(rootDir, relativePrefix, ScanMode.CONTROLLER);
}
/** 扫描 @FeignClient 接口目录 */
public List<ApiEndpoint> scanFeignDirectory(Path rootDir, String relativePrefix) throws IOException {
return scanDirectory(rootDir, relativePrefix, ScanMode.FEIGN);
}
/** 递归 walk 目录下 .java 并解析 */
private List<ApiEndpoint> scanDirectory(Path rootDir, String relativePrefix, ScanMode mode) throws IOException {
if (!Files.exists(rootDir)) {
return List.of();
}
List<ApiEndpoint> endpoints = new ArrayList<>();
try (Stream<Path> paths = Files.walk(rootDir)) {
paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> {
try {
String source = Files.readString(path, StandardCharsets.UTF_8);
String relativePath = toRelativePath(relativePrefix, rootDir, path);
endpoints.addAll(parseCompilationUnit(source, relativePath, mode));
} catch (IOException ignored) {
// 跳过无法读取的文件
}
});
}
return endpoints;
}
/** 解析单个编译单元,过滤 Controller 或 Feign */
private List<ApiEndpoint> parseCompilationUnit(String source, String relativePath, ScanMode mode) {
CompilationUnit cu = StaticJavaParser.parse(source);
List<ApiEndpoint> endpoints = new ArrayList<>();
for (TypeDeclaration<?> type : cu.getTypes()) {
if (!(type instanceof ClassOrInterfaceDeclaration)) {
continue;
}
ClassOrInterfaceDeclaration declaration = (ClassOrInterfaceDeclaration) type;
if (mode == ScanMode.CONTROLLER && !isController(declaration)) {
continue;
}
if (mode == ScanMode.FEIGN && !isFeignClient(declaration)) {
continue;
}
String basePath = mode == ScanMode.FEIGN
? joinPaths(extractFeignBasePath(declaration), extractTypeLevelPath(declaration))
: extractTypeLevelPath(declaration);
for (MethodDeclaration method : declaration.getMethods()) {
if (mode == ScanMode.FEIGN && declaration.isInterface()) {
endpoints.addAll(parseMethod(method, basePath, relativePath));
} else if (mode == ScanMode.CONTROLLER && !declaration.isInterface()) {
endpoints.addAll(parseMethod(method, basePath, relativePath));
}
}
}
return endpoints;
}
/** 解析方法上的 Mapping 注解,生成 ApiEndpoint */
private List<ApiEndpoint> parseMethod(MethodDeclaration method, String basePath, String sourceFile) {
List<ApiEndpoint> endpoints = new ArrayList<>();
for (AnnotationExpr annotation : method.getAnnotations()) {
String annName = annotation.getNameAsString();
if (!MAPPING_ANNOTATIONS.contains(annName)) {
continue;
}
List<String> subPaths = extractPaths(annotation);
List<String> httpMethods = extractHttpMethods(annotation, annName);
for (String httpMethod : httpMethods) {
for (String subPath : subPaths) {
String uri = joinPaths(basePath, subPath);
Set<String> paramTypes = extractParamTypes(method);
Set<String> returnTypes = TypeNameUtils.peelDirectTypeNames(method.getType());
endpoints.add(new ApiEndpoint(httpMethod, uri, sourceFile, paramTypes, returnTypes));
}
}
}
return endpoints;
}
/** 收集方法入参类型简单名 */
private Set<String> extractParamTypes(MethodDeclaration method) {
Set<String> paramTypes = new LinkedHashSet<>();
for (Parameter parameter : method.getParameters()) {
Type type = parameter.getType();
paramTypes.add(TypeNameUtils.simpleName(TypeNameUtils.typeToString(type)));
paramTypes.addAll(TypeNameUtils.peelDirectTypeNames(type));
}
return paramTypes;
}
/** 是否 Spring Controller */
private boolean isController(ClassOrInterfaceDeclaration declaration) {
return declaration.getAnnotations().stream()
.anyMatch(ann -> {
String name = ann.getNameAsString();
return "RestController".equals(name) || "Controller".equals(name);
});
}
/** 是否 Feign 客户端接口 */
private boolean isFeignClient(ClassOrInterfaceDeclaration declaration) {
return declaration.isInterface() && declaration.getAnnotations().stream()
.anyMatch(ann -> "FeignClient".equals(ann.getNameAsString()));
}
/** 类级 @RequestMapping 路径 */
private String extractTypeLevelPath(ClassOrInterfaceDeclaration declaration) {
for (AnnotationExpr annotation : declaration.getAnnotations()) {
if ("RequestMapping".equals(annotation.getNameAsString())) {
List<String> paths = extractPaths(annotation);
if (!paths.isEmpty()) {
return paths.get(0);
}
}
}
return "";
}
/** @FeignClient(path=...) 基础路径 */
private String extractFeignBasePath(ClassOrInterfaceDeclaration declaration) {
for (AnnotationExpr annotation : declaration.getAnnotations()) {
if ("FeignClient".equals(annotation.getNameAsString())) {
List<String> paths = AnnotationValueReader.readStringArray(annotation, "path");
if (!paths.isEmpty()) {
return paths.get(0);
}
}
}
return "";
}
/** 从 Mapping 注解读取 value/path */
private List<String> extractPaths(AnnotationExpr annotation) {
return AnnotationValueReader.readStringArray(annotation, "value", "path");
}
/** 推断 HTTP 方法RequestMapping 无 method 时默认 GET */
private List<String> extractHttpMethods(AnnotationExpr annotation, String annName) {
if (!"RequestMapping".equals(annName)) {
return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET"));
}
List<String> methods = AnnotationValueReader.readEnumArray(annotation, "method");
if (methods.isEmpty()) {
return List.of("GET");
}
return methods;
}
/** 拼接类级与方法级路径 */
private String joinPaths(String base, String sub) {
String normalizedBase = normalizePath(base);
String normalizedSub = normalizePath(sub);
if (normalizedBase.isEmpty()) {
return normalizedSub.isEmpty() ? "/" : normalizedSub;
}
if (normalizedSub.isEmpty()) {
return normalizedBase;
}
String joined = normalizedBase + "/" + normalizedSub.substring(1);
return joined.replaceAll("/+", "/");
}
/** 规范化 URI 路径 */
private String normalizePath(String path) {
if (path == null || path.isBlank()) {
return "";
}
String trimmed = path.trim();
if (!trimmed.startsWith("/")) {
trimmed = "/" + trimmed;
}
return trimmed.replaceAll("/+", "/");
}
/** 生成相对仓库根的路径 */
private String toRelativePath(String relativePrefix, Path rootDir, Path file) {
String relative = rootDir.relativize(file).toString().replace("\\", "/");
if (relativePrefix == null || relativePrefix.isBlank()) {
return relative;
}
String prefix = relativePrefix.endsWith("/")
? relativePrefix.substring(0, relativePrefix.length() - 1)
: relativePrefix;
return prefix + "/" + relative;
}
private enum ScanMode {
CONTROLLER, FEIGN
}
/** 从注解 AST 读取字符串或枚举数组 */
static final class AnnotationValueReader {
private AnnotationValueReader() {
}
static List<String> readStringArray(AnnotationExpr annotation, String... keys) {
NodeList<?> values = readArrayValues(annotation, keys);
List<String> result = new ArrayList<>();
for (Object value : values) {
String text = value.toString().replace("\"", "").trim();
if (!text.isBlank()) {
result.add(text);
}
}
if (result.isEmpty()) {
result.add("");
}
return result;
}
static List<String> readEnumArray(AnnotationExpr annotation, String key) {
NodeList<?> values = readArrayValues(annotation, key);
List<String> result = new ArrayList<>();
for (Object value : values) {
String text = value.toString().trim();
if (text.contains(".")) {
text = text.substring(text.lastIndexOf('.') + 1);
}
result.add(text.toUpperCase(Locale.ROOT));
}
return result;
}
private static NodeList<?> readArrayValues(AnnotationExpr annotation, String... keys) {
if (annotation.isSingleMemberAnnotationExpr()) {
Expression value = annotation.asSingleMemberAnnotationExpr().getMemberValue();
if (value.isArrayInitializerExpr()) {
return value.asArrayInitializerExpr().getValues();
}
return new NodeList<>(value);
}
if (annotation.isNormalAnnotationExpr()) {
var pairs = annotation.asNormalAnnotationExpr().getPairs();
for (var pair : pairs) {
for (String key : keys) {
if (pair.getNameAsString().equals(key)) {
if (pair.getValue().isArrayInitializerExpr()) {
return pair.getValue().asArrayInitializerExpr().getValues();
}
return new NodeList<>(pair.getValue());
}
}
}
for (var pair : pairs) {
if ("value".equals(pair.getNameAsString())) {
if (pair.getValue().isArrayInitializerExpr()) {
return pair.getValue().asArrayInitializerExpr().getValues();
}
return new NodeList<>(pair.getValue());
}
}
}
return new NodeList<>();
}
}
}

View File

@@ -0,0 +1,117 @@
package com.codechecker.parser;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.type.Type;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Java 类型名工具:转字符串、取简单名、剥离 ActionResult/List 等泛型包装。
*/
public final class TypeNameUtils {
/** 需要向内层继续剥离的包装类型 */
private static final Set<String> WRAPPER_TYPES = Set.of(
"ActionResult", "List", "PageListVO", "Set", "Collection", "Iterable", "Optional"
);
private TypeNameUtils() {
}
/** Type 转无空白字符串 */
public static String typeToString(Type type) {
if (type == null) {
return "Object";
}
return type.toString().replaceAll("\\s+", "");
}
/** 取类型简单名,去掉包名与泛型 */
public static String simpleName(String typeName) {
if (typeName == null || typeName.isBlank()) {
return "";
}
String cleaned = typeName.replaceAll("\\s+", "");
int genericStart = cleaned.indexOf('<');
String base = genericStart >= 0 ? cleaned.substring(0, genericStart) : cleaned;
int dot = base.lastIndexOf('.');
return dot >= 0 ? base.substring(dot + 1) : base;
}
/** 从 Type AST 收集实际业务类型简单名(穿透包装泛型) */
public static Set<String> peelDirectTypeNames(Type type) {
Set<String> result = new LinkedHashSet<>();
collectPeelTargets(type, result);
return result;
}
/** 从类型字符串收集实际业务类型简单名 */
public static Set<String> peelDirectTypeNames(String typeName) {
Set<String> result = new LinkedHashSet<>();
collectPeelTargets(typeName, result);
return result;
}
/** 递归收集:包装类型则进入泛型参数,否则记录简单名 */
private static void collectPeelTargets(Type type, Set<String> result) {
if (type == null) {
return;
}
if (type.isClassOrInterfaceType()) {
ClassOrInterfaceType classType = type.asClassOrInterfaceType();
String name = simpleName(classType.getNameAsString());
if (WRAPPER_TYPES.contains(name) && classType.getTypeArguments().isPresent()) {
for (Type arg : classType.getTypeArguments().get()) {
collectPeelTargets(arg, result);
}
return;
}
result.add(name);
return;
}
result.add(simpleName(typeToString(type)));
}
/** 字符串版递归收集 */
private static void collectPeelTargets(String typeName, Set<String> result) {
String cleaned = typeName.replaceAll("\\s+", "");
int genericStart = cleaned.indexOf('<');
if (genericStart < 0) {
result.add(simpleName(cleaned));
return;
}
String outer = simpleName(cleaned.substring(0, genericStart));
String inner = cleaned.substring(genericStart + 1, cleaned.lastIndexOf('>'));
if (WRAPPER_TYPES.contains(outer)) {
for (String part : splitGenericArgs(inner)) {
collectPeelTargets(part, result);
}
return;
}
result.add(outer);
}
/** 按逗号分割泛型参数,支持嵌套 <> */
private static List<String> splitGenericArgs(String inner) {
List<String> parts = new java.util.ArrayList<>();
int depth = 0;
StringBuilder current = new StringBuilder();
for (char ch : inner.toCharArray()) {
if (ch == '<') {
depth++;
} else if (ch == '>') {
depth--;
} else if (ch == ',' && depth == 0) {
parts.add(current.toString().trim());
current.setLength(0);
continue;
}
current.append(ch);
}
if (current.length() > 0) {
parts.add(current.toString().trim());
}
return parts;
}
}