test
Some checks failed
API Parameter Change Check / api-param-check (push) Failing after 3s

This commit is contained in:
2026-06-03 15:02:21 +08:00
parent 2eacae577c
commit 556f5b8ab6
25 changed files with 2119 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
<?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.aicheck</groupId>
<artifactId>controller-parser</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Controller Parameter AST Parser</name>
<description>基于 JavaParser 解析 Spring Controller 接口参数</description>
<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>
<jackson.version>2.17.2</jackson.version>
</properties>
<dependencies>
<!-- Java AST 解析库 -->
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-core</artifactId>
<version>${javaparser.version}</version>
</dependency>
<!-- JSON 输出,供 Python 主程序读取 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<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.aicheck.ControllerParserMain</mainClass>
</transformer>
</transformers>
<createDependencyReducedPom>false</createDependencyReducedPom>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,86 @@
package com.aicheck;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.ArrayList;
import java.util.List;
/**
* 单个 Controller 接口端点的模型,包含 URI、HTTP 方法及参数列表。
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiEndpoint {
/** HTTP 方法GET / POST / PUT / DELETE / PATCH */
private String httpMethod;
/** 完整 URI 路径,如 /api/users/{id} */
private String uri;
/** 所属 Controller 类名 */
private String controllerClass;
/** Java 方法名 */
private String methodName;
/** 源文件相对路径 */
private String sourceFile;
/** 接口参数列表 */
private List<ApiParameter> parameters = new ArrayList<>();
public String getHttpMethod() {
return httpMethod;
}
public void setHttpMethod(String httpMethod) {
this.httpMethod = httpMethod;
}
public String getUri() {
return uri;
}
public void setUri(String uri) {
this.uri = uri;
}
public String getControllerClass() {
return controllerClass;
}
public void setControllerClass(String controllerClass) {
this.controllerClass = controllerClass;
}
public String getMethodName() {
return methodName;
}
public void setMethodName(String methodName) {
this.methodName = methodName;
}
public String getSourceFile() {
return sourceFile;
}
public void setSourceFile(String sourceFile) {
this.sourceFile = sourceFile;
}
public List<ApiParameter> getParameters() {
return parameters;
}
public void setParameters(List<ApiParameter> parameters) {
this.parameters = parameters;
}
/**
* 生成唯一标识,用于跨版本比对接口是否为同一个。
* 格式HTTP_METHOD + 空格 + URI
*/
public String getEndpointKey() {
return httpMethod + " " + uri;
}
}

View File

@@ -0,0 +1,75 @@
package com.aicheck;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* 单个接口参数的模型,对应 Controller 方法上的一个入参。
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiParameter {
/** 参数名称(@RequestParam / @PathVariable 的 value或字段名 */
private String name;
/** Java 类型,如 String、Long、Boolean */
private String type;
/** 是否必填(来自 required 属性或 @NotNull 等,默认 true */
private boolean required = true;
/** 参数来源query / path / body / header / form */
private String source;
/** 参数说明(来自 @ApiParam、@Parameter 等注解的 description */
private String description;
public ApiParameter() {
}
public ApiParameter(String name, String type, boolean required, String source) {
this.name = name;
this.type = type;
this.required = required;
this.source = source;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public boolean isRequired() {
return required;
}
public void setRequired(boolean required) {
this.required = required;
}
public String getSource() {
return source;
}
public void setSource(String source) {
this.source = source;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}

View File

@@ -0,0 +1,421 @@
package com.aicheck;
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.MethodDeclaration;
import com.github.javaparser.ast.body.Parameter;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.MemberValuePair;
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 基于 JavaParser 的 Spring Controller AST 解析器。
* 扫描指定目录下的 Java 文件,提取带 @RestController / @Controller 注解类中的接口定义。
*/
public class ControllerAstParser {
/** Spring 映射注解 -> HTTP 方法 */
private static final Set<String> MAPPING_ANNOTATIONS = Set.of(
"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"
);
/** 标识 Controller 的类级别注解 */
private static final Set<String> CONTROLLER_ANNOTATIONS = Set.of("RestController", "Controller");
/**
* 解析目录下所有 Java 文件中的 Controller 接口。
*
* @param rootDir 项目根目录或源码目录
* @return 解析出的所有 API 端点列表
*/
public List<ApiEndpoint> parseDirectory(Path rootDir) throws IOException {
List<ApiEndpoint> endpoints = new ArrayList<>();
if (!Files.exists(rootDir)) {
return endpoints;
}
try (Stream<Path> paths = Files.walk(rootDir)) {
List<Path> javaFiles = paths
.filter(p -> p.toString().endsWith(".java"))
.filter(p -> p.toString().contains("Controller"))
.collect(Collectors.toList());
for (Path javaFile : javaFiles) {
endpoints.addAll(parseFile(javaFile, rootDir));
}
}
return endpoints;
}
/**
* 解析单个 Java 源文件。
*
* @param javaFile 源文件路径
* @param rootDir 根目录,用于计算相对路径
*/
public List<ApiEndpoint> parseFile(Path javaFile, Path rootDir) throws IOException {
List<ApiEndpoint> endpoints = new ArrayList<>();
String source = Files.readString(javaFile);
CompilationUnit cu = StaticJavaParser.parse(source);
String relativePath = rootDir.relativize(javaFile).toString().replace("\\", "/");
for (ClassOrInterfaceDeclaration clazz : cu.findAll(ClassOrInterfaceDeclaration.class)) {
if (!isController(clazz)) {
continue;
}
String classBasePath = extractClassBasePath(clazz);
for (MethodDeclaration method : clazz.getMethods()) {
Optional<ApiEndpoint> endpointOpt = parseMethod(method, clazz, classBasePath, relativePath, rootDir);
endpointOpt.ifPresent(endpoints::add);
}
}
return endpoints;
}
/**
* 判断类是否为 Spring Controller含 @RestController 或 @Controller
*/
private boolean isController(ClassOrInterfaceDeclaration clazz) {
return clazz.getAnnotations().stream()
.anyMatch(a -> CONTROLLER_ANNOTATIONS.contains(getSimpleAnnotationName(a)));
}
/**
* 提取类级别 @RequestMapping 的基础路径。
*/
private String extractClassBasePath(ClassOrInterfaceDeclaration clazz) {
for (AnnotationExpr annotation : clazz.getAnnotations()) {
if ("RequestMapping".equals(getSimpleAnnotationName(annotation))) {
return normalizePath(extractAnnotationStringValue(annotation, "value", "path"));
}
}
return "";
}
/**
* 解析单个 Controller 方法,若不含映射注解则返回 empty。
*/
private Optional<ApiEndpoint> parseMethod(
MethodDeclaration method,
ClassOrInterfaceDeclaration clazz,
String classBasePath,
String relativePath,
Path rootDir) {
for (AnnotationExpr annotation : method.getAnnotations()) {
String annName = getSimpleAnnotationName(annotation);
if (!MAPPING_ANNOTATIONS.contains(annName)) {
continue;
}
ApiEndpoint endpoint = new ApiEndpoint();
endpoint.setControllerClass(clazz.getNameAsString());
endpoint.setMethodName(method.getNameAsString());
endpoint.setSourceFile(relativePath);
endpoint.setHttpMethod(resolveHttpMethod(annName, annotation));
endpoint.setUri(joinPaths(classBasePath, normalizePath(extractAnnotationStringValue(annotation, "value", "path"))));
endpoint.setParameters(extractParameters(method, rootDir));
return Optional.of(endpoint);
}
return Optional.empty();
}
/**
* 从映射注解推断 HTTP 方法。
*/
private String resolveHttpMethod(String annName, AnnotationExpr annotation) {
switch (annName) {
case "GetMapping":
return "GET";
case "PostMapping":
return "POST";
case "PutMapping":
return "PUT";
case "DeleteMapping":
return "DELETE";
case "PatchMapping":
return "PATCH";
case "RequestMapping":
String method = extractAnnotationStringValue(annotation, "method");
if (!method.isEmpty()) {
return method.replace("RequestMethod.", "").toUpperCase();
}
return "GET";
default:
return "GET";
}
}
/**
* 提取方法的所有入参(含 @RequestBody DTO 字段展开)。
*/
private List<ApiParameter> extractParameters(MethodDeclaration method, Path rootDir) {
List<ApiParameter> params = new ArrayList<>();
for (Parameter param : method.getParameters()) {
String paramType = param.getType().asString();
// @RequestBody尝试展开 DTO 类字段
if (hasAnnotation(param, "RequestBody")) {
params.addAll(expandDtoFields(paramType, rootDir, "body"));
continue;
}
ApiParameter apiParam = new ApiParameter();
apiParam.setType(paramType);
apiParam.setSource(resolveParameterSource(param));
apiParam.setName(resolveParameterName(param));
apiParam.setRequired(resolveRequired(param));
apiParam.setDescription(extractParamDescription(param));
params.add(apiParam);
}
return params;
}
/**
* 展开 @RequestBody DTO 类的字段为独立参数(便于对比字段增删改)。
*/
private List<ApiParameter> expandDtoFields(String typeName, Path rootDir, String source) {
List<ApiParameter> fields = new ArrayList<>();
Optional<Path> dtoFile = findJavaFileBySimpleName(typeName, rootDir);
if (dtoFile.isEmpty()) {
// 找不到 DTO 源文件时,保留整体类型
ApiParameter body = new ApiParameter();
body.setName(typeName);
body.setType(typeName);
body.setSource(source);
body.setRequired(true);
fields.add(body);
return fields;
}
try {
CompilationUnit cu = StaticJavaParser.parse(dtoFile.get());
for (FieldDeclaration field : cu.findAll(FieldDeclaration.class)) {
if (field.isStatic()) {
continue;
}
for (var variable : field.getVariables()) {
ApiParameter fp = new ApiParameter();
fp.setName(variable.getNameAsString());
fp.setType(field.getElementType().asString());
fp.setSource(source);
fp.setRequired(!hasAnnotation(field, "Nullable"));
fields.add(fp);
}
}
} catch (IOException ignored) {
// 解析失败时退化为整体 body 参数
ApiParameter body = new ApiParameter();
body.setName(typeName);
body.setType(typeName);
body.setSource(source);
fields.add(body);
}
return fields;
}
/**
* 在源码目录中按简单类名查找 Java 文件。
*/
private Optional<Path> findJavaFileBySimpleName(String typeName, Path rootDir) {
String simpleName = typeName.contains(".") ? typeName.substring(typeName.lastIndexOf('.') + 1) : typeName;
simpleName = simpleName.replace(">", "").replace("<", "").trim();
try (Stream<Path> paths = Files.walk(rootDir)) {
final String target = simpleName;
return paths
.filter(p -> p.getFileName().toString().equals(target + ".java"))
.findFirst();
} catch (IOException e) {
return Optional.empty();
}
}
/**
* 判断参数来源query / path / header / form / body。
*/
private String resolveParameterSource(Parameter param) {
if (hasAnnotation(param, "PathVariable")) return "path";
if (hasAnnotation(param, "RequestHeader")) return "header";
if (hasAnnotation(param, "RequestPart")) return "form";
if (hasAnnotation(param, "ModelAttribute")) return "form";
return "query";
}
/**
* 解析参数名称:优先取注解 value/name否则用变量名。
*/
private String resolveParameterName(Parameter param) {
for (String ann : Arrays.asList("RequestParam", "PathVariable", "RequestHeader", "RequestPart")) {
if (hasAnnotation(param, ann)) {
Optional<AnnotationExpr> opt = param.getAnnotationByName(ann);
if (opt.isPresent()) {
String val = extractAnnotationStringValue(opt.get(), "value", "name");
if (!val.isEmpty()) {
return val;
}
}
}
}
return param.getNameAsString();
}
/**
* 解析参数是否必填。
*/
private boolean resolveRequired(Parameter param) {
if (hasAnnotation(param, "RequestParam")) {
Optional<AnnotationExpr> opt = param.getAnnotationByName("RequestParam");
if (opt.isPresent()) {
String required = extractAnnotationMemberValue(opt.get(), "required");
if ("false".equalsIgnoreCase(required)) {
return false;
}
}
}
if (param.getType() instanceof ClassOrInterfaceType) {
ClassOrInterfaceType cit = (ClassOrInterfaceType) param.getType();
if ("Optional".equals(cit.getNameAsString())) {
return false;
}
}
return !hasAnnotation(param, "Nullable");
}
/**
* 提取 @ApiParam / @Parameter 的 description。
*/
private String extractParamDescription(Parameter param) {
for (String ann : Arrays.asList("ApiParam", "Parameter", "Schema")) {
Optional<AnnotationExpr> opt = param.getAnnotationByName(ann);
if (opt.isPresent()) {
return extractAnnotationStringValue(opt.get(), "description", "value");
}
}
return null;
}
private boolean hasAnnotation(Object node, String simpleName) {
if (node instanceof Parameter) {
Parameter p = (Parameter) node;
return p.getAnnotationByName(simpleName).isPresent();
}
if (node instanceof FieldDeclaration) {
FieldDeclaration f = (FieldDeclaration) node;
return f.getAnnotationByName(simpleName).isPresent();
}
return false;
}
/**
* 获取注解的简单名称(去掉包名)。
*/
private String getSimpleAnnotationName(AnnotationExpr annotation) {
String name = annotation.getNameAsString();
int dot = name.lastIndexOf('.');
return dot >= 0 ? name.substring(dot + 1) : name;
}
/**
* 从注解中提取字符串属性,支持 value/path/name 等多个候选 key。
*/
private String extractAnnotationStringValue(AnnotationExpr annotation, String... keys) {
Set<String> keySet = new HashSet<>(Arrays.asList(keys));
if (annotation instanceof SingleMemberAnnotationExpr) {
SingleMemberAnnotationExpr single = (SingleMemberAnnotationExpr) annotation;
return stripQuotes(single.getMemberValue().toString());
}
if (annotation instanceof NormalAnnotationExpr) {
NormalAnnotationExpr normal = (NormalAnnotationExpr) annotation;
for (MemberValuePair pair : normal.getPairs()) {
if (keySet.contains(pair.getNameAsString())) {
return stripQuotes(pair.getValue().toString());
}
}
}
return "";
}
/**
* 提取注解成员的原始字符串值。
*/
private String extractAnnotationMemberValue(AnnotationExpr annotation, String key) {
if (annotation instanceof NormalAnnotationExpr) {
NormalAnnotationExpr normal = (NormalAnnotationExpr) annotation;
for (MemberValuePair pair : normal.getPairs()) {
if (key.equals(pair.getNameAsString())) {
return stripQuotes(pair.getValue().toString());
}
}
}
return "";
}
private String stripQuotes(String value) {
return value.replace("\"", "").replace("'", "").trim();
}
/**
* 拼接类级别与方法级别的路径。
*/
private String joinPaths(String base, String methodPath) {
String b = normalizePath(base);
String m = normalizePath(methodPath);
if (b.isEmpty()) return m.isEmpty() ? "/" : m;
if (m.isEmpty()) return b;
if (b.endsWith("/") && m.startsWith("/")) {
return b + m.substring(1);
}
if (!b.endsWith("/") && !m.startsWith("/")) {
return b + "/" + m;
}
return b + m;
}
/**
* 规范化路径:确保以 / 开头,去除多余斜杠。
*/
private String normalizePath(String path) {
if (path == null || path.isBlank()) {
return "";
}
path = path.trim();
if (!path.startsWith("/")) {
path = "/" + path;
}
return path.replaceAll("/+", "/");
}
}

View File

@@ -0,0 +1,48 @@
package com.aicheck;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
/**
* Java AST 解析器命令行入口。
* 用法java -jar controller-parser.jar <源码目录> [输出JSON文件路径]
*
* 示例:
* java -jar controller-parser.jar ./src/main/java ./endpoints.json
*/
public class ControllerParserMain {
private static final ObjectMapper MAPPER = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
/**
* 程序入口:解析指定目录并输出 JSON。
*
* @param args [0]=源码目录, [1]=可选的输出文件路径(默认 stdout
*/
public static void main(String[] args) throws IOException {
if (args.length < 1) {
System.err.println("用法: java -jar controller-parser.jar <源码目录> [输出JSON路径]");
System.exit(1);
}
Path sourceDir = Paths.get(args[0]).toAbsolutePath().normalize();
ControllerAstParser parser = new ControllerAstParser();
List<ApiEndpoint> endpoints = parser.parseDirectory(sourceDir);
String json = MAPPER.writeValueAsString(endpoints);
if (args.length >= 2) {
Path output = Paths.get(args[1]);
MAPPER.writeValue(output.toFile(), endpoints);
System.out.println("已解析 " + endpoints.size() + " 个接口,输出至: " + output);
} else {
System.out.println(json);
}
}
}

View File

@@ -0,0 +1,5 @@
#Generated by Maven
#Wed Jun 03 11:29:14 GMT+08:00 2026
groupId=com.aicheck
artifactId=controller-parser
version=1.0.0

View File

@@ -0,0 +1,4 @@
com\aicheck\ControllerParserMain.class
com\aicheck\ControllerAstParser.class
com\aicheck\ApiParameter.class
com\aicheck\ApiEndpoint.class

View File

@@ -0,0 +1,4 @@
C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ControllerAstParser.java
C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ApiParameter.java
C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ControllerParserMain.java
C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ApiEndpoint.java