脚本修改
Some checks failed
API接口参数变更检测 / api-param-check (push) Has been cancelled

This commit is contained in:
2026-06-03 15:33:24 +08:00
parent 6db621a137
commit 2c20a26af8
15 changed files with 136 additions and 897 deletions

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Set, Tuple
from controller_parser import ApiEndpoint, ApiParameter
from models import ApiEndpoint, ApiParameter
class ChangeType(str, Enum):

View File

@@ -1,114 +1,43 @@
"""
Controller 端点解析模块。
调用 Java AST 解析器 JAR将 Java 源码转为结构化的 API 端点列表。
Controller 端点解析模块(纯 Python无需 Java
"""
import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from typing import Dict, List
@dataclass
class ApiParameter:
"""单个接口参数。"""
name: str
type: str
required: bool = True
source: str = "query"
description: Optional[str] = None
@dataclass
class ApiEndpoint:
"""单个 Controller 接口端点。"""
http_method: str
uri: str
controller_class: str
method_name: str
source_file: str
parameters: List[ApiParameter] = field(default_factory=list)
@property
def endpoint_key(self) -> str:
"""唯一标识HTTP 方法 + URI用于跨版本匹配。"""
return f"{self.http_method} {self.uri}"
def run_java_parser(source_dir: Path, jar_path: Path, output_json: Path) -> List[ApiEndpoint]:
"""
调用 JavaParser JAR 扫描源码目录,返回解析结果。
:param source_dir: Java 源码根目录
:param jar_path: controller-parser JAR 路径
:param output_json: 临时 JSON 输出路径
:return: ApiEndpoint 列表
:raises RuntimeError: Java 进程失败或 JAR 不存在
"""
if not jar_path.exists():
raise RuntimeError(
f"Java 解析器 JAR 不存在: {jar_path}\n"
"请先在 .gitea/java-parser 目录执行: mvn -q package"
)
cmd = ["java", "-jar", str(jar_path), str(source_dir), str(output_json)]
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
if result.returncode != 0:
raise RuntimeError(f"Java 解析器执行失败:\n{result.stderr}")
with open(output_json, "r", encoding="utf-8") as f:
raw = json.load(f)
return [_dict_to_endpoint(item) for item in raw]
def _dict_to_endpoint(data: dict) -> ApiEndpoint:
"""将 JSON 字典转换为 ApiEndpoint 对象。"""
params = [
ApiParameter(
name=p.get("name", ""),
type=p.get("type", ""),
required=p.get("required", True),
source=p.get("source", "query"),
description=p.get("description"),
)
for p in data.get("parameters", [])
]
return ApiEndpoint(
http_method=data.get("httpMethod", "GET"),
uri=data.get("uri", "/"),
controller_class=data.get("controllerClass", ""),
method_name=data.get("methodName", ""),
source_file=data.get("sourceFile", ""),
parameters=params,
)
from models import ApiEndpoint, ApiParameter
def endpoints_to_map(endpoints: List[ApiEndpoint]) -> Dict[str, ApiEndpoint]:
"""
将端点列表转为字典key 为 endpoint_key。
:param endpoints: 端点列表
:return: { "GET /api/users/{id}": ApiEndpoint, ... }
"""
"""端点列表转字典key 为 endpoint_key。"""
return {ep.endpoint_key: ep for ep in endpoints}
def filter_endpoints_by_files(
endpoints: List[ApiEndpoint], changed_files: List[str]
) -> List[ApiEndpoint]:
"""
仅保留源文件在变更列表中的端点(缩小对比范围)。
:param endpoints: 全部端点
:param changed_files: 变更文件相对路径列表
:return: 过滤后的端点
"""
"""仅保留变更文件中的端点。"""
if not changed_files:
return endpoints
changed_set = set(changed_files)
changed_set = {f.replace("\\", "/") for f in changed_files}
return [ep for ep in endpoints if ep.source_file in changed_set]
def parse_endpoints_from_files(
repo_root: Path,
source_subdir: str,
file_paths: List[str],
file_contents: Dict[str, str],
) -> List[ApiEndpoint]:
"""
解析指定 Controller 文件,提取接口参数(仅解析传入文件,不全量扫描)。
:param repo_root: 仓库根
:param source_subdir: 源码目录(相对仓库根)
:param file_paths: 文件路径列表
:param file_contents: 路径 -> 源码内容
:return: ApiEndpoint 列表
"""
from controller_ast_parser import parse_controller_files
return parse_controller_files(repo_root, source_subdir, file_paths, file_contents)

View File

@@ -122,6 +122,20 @@ def get_controller_files_diff(base_sha: str, head_sha: str, changed_files: List[
return ""
def get_file_content_at_commit(commit_sha: str, file_path: str) -> Optional[str]:
"""
读取指定 commit 下某个文件的内容(无需 git worktree更快
:param commit_sha: commit SHA
:param file_path: 相对仓库根目录的文件路径
:return: 文件内容;该 commit 中不存在则返回 None
"""
try:
return run_git(["show", f"{commit_sha}:{file_path}"])
except RuntimeError:
return None
def prepare_worktrees(repo_root: Path) -> tuple:
"""
准备新旧两个版本的代码工作目录,供 AST 解析器分别扫描。

View File

@@ -1,23 +1,12 @@
#!/usr/bin/env python3
"""
AI-Check 主入口 — Controller 层接口参数变更检测
流程(对齐第一版需求):
1. Git 检出新旧代码
2. JavaParser AST 解析 Controller 方法签名与参数
3. 对比增 / 删 / 改 / 重命名
4. 可选LLM 审核参数变更结果
5. (可选)写入日志
6. 企业微信通知URI + 参数变更明细)
用法:
python .gitea/checker/main.py [--config .gitea/config.yaml] [--repo-root .] [推送人] [推送时间]
AI-Check 主入口 — Controller 层接口参数变更检测(纯 Python无 Java 依赖)
"""
import argparse
import sys
import tempfile
from pathlib import Path
from typing import Optional
import yaml
@@ -29,14 +18,14 @@ from comparator import compare_endpoints
from controller_parser import (
endpoints_to_map,
filter_endpoints_by_files,
run_java_parser,
parse_endpoints_from_files,
)
from git_utils import (
get_changed_java_controller_files,
get_controller_files_diff,
get_current_commit,
get_file_content_at_commit,
get_previous_commit_sha,
prepare_worktrees,
)
from llm_reviewer import review_parameter_changes
from notifier import send_parameter_change_notification
@@ -53,20 +42,53 @@ def load_config(config_path: Path) -> dict:
return yaml.safe_load(f) or {}
def parse_endpoints_for_version(
def _read_file_safe(path: Path) -> str:
"""读取文件内容。"""
try:
return path.read_text(encoding="utf-8", errors="ignore")
except OSError as exc:
print(f"[警告] 无法读取 {path}: {exc}")
return ""
def _load_version_contents(
repo_root: Path,
file_paths: list,
commit_sha: Optional[str] = None,
) -> dict:
"""加载文件内容commit_sha 为空则读工作区,否则 git show。"""
contents = {}
for fp in file_paths:
norm = fp.replace("\\", "/")
if commit_sha:
text = get_file_content_at_commit(commit_sha, norm)
if text is not None:
contents[norm] = text
else:
text = _read_file_safe(repo_root / norm)
if text:
contents[norm] = text
return contents
def parse_changed_endpoints(
repo_root: Path,
source_subdir: str,
jar_path: Path,
tmp_dir: Path,
changed_files: list,
old_sha: str,
label: str,
) -> dict:
"""对指定版本源码运行 Java AST 解析,提取 Controller 接口参数"""
source_dir = repo_root / source_subdir
output_json = tmp_dir / f"endpoints_{label}.json"
"""解析变更 Controller 文件在新/旧版本的端点"""
if label == "new":
contents = _load_version_contents(repo_root, changed_files)
else:
contents = _load_version_contents(repo_root, changed_files, commit_sha=old_sha)
print(f"[AST] 扫描 {label} 版本: {source_dir}")
endpoints = run_java_parser(source_dir, jar_path, output_json)
print(f"[AST] {label} 版本共 {len(endpoints)} 个 Controller 接口")
print(f"[AST] 解析 {label} 版本 {len(contents)} 个 Controller 文件")
endpoints = parse_endpoints_from_files(
repo_root, source_subdir, changed_files, contents
)
print(f"[AST] {label} 版本共 {len(endpoints)} 个接口")
return endpoints_to_map(endpoints)
@@ -75,25 +97,27 @@ def main() -> int:
parser = argparse.ArgumentParser(
description="AI-Check: Controller 接口参数变更检测"
)
parser.add_argument("--config", default=".gitea/config.yaml", help="配置文件路径(相对仓库根目录)")
parser.add_argument(
"--config", default=".gitea/config.yaml", help="配置文件路径"
)
parser.add_argument("--repo-root", default=".", help="Git 仓库根目录")
parser.add_argument("push_user", nargs="?", default=None, help="推送人CI 传入)")
parser.add_argument("push_time", nargs="?", default=None, help="推送时间CI 传入)")
parser.add_argument("push_user", nargs="?", default=None, help="推送人")
parser.add_argument("push_time", nargs="?", default=None, help="推送时间")
args = parser.parse_args()
repo_root = Path(args.repo_root).resolve()
config = load_config(repo_root / args.config)
config_path = Path(args.config)
if not config_path.is_absolute():
config_path = repo_root / config_path
config = load_config(config_path)
source_subdir = config.get("source_dir", "src/main/java")
jar_path = repo_root / config.get(
"java_parser_jar", ".gitea/java-parser/target/controller-parser-1.0.0.jar"
)
commit_info = get_current_commit()
push_user = args.push_user or commit_info.author
push_time = args.push_time or commit_info.commit_time
print("Controller 接口参数变更检测")
print("Controller 接口参数变更检测(纯 Python")
print("=" * 40)
print(f"推送人: {push_user}")
print(f"推送时间: {push_time}")
@@ -116,20 +140,12 @@ def main() -> int:
print(f" - {f}")
git_diff = get_controller_files_diff(prev_sha, commit_info.sha, changed_files)
current_dir, prev_dir, _ = prepare_worktrees(repo_root)
reports = []
llm_review = None
with tempfile.TemporaryDirectory(prefix="ai-check-") as tmp:
tmp_dir = Path(tmp)
# 1. AST 解析 + 参数对比
new_map = parse_endpoints_for_version(
current_dir, source_subdir, jar_path, tmp_dir, "new"
new_map = parse_changed_endpoints(
repo_root, source_subdir, changed_files, prev_sha, "new"
)
old_map = parse_endpoints_for_version(
prev_dir, source_subdir, jar_path, tmp_dir, "old"
old_map = parse_changed_endpoints(
repo_root, source_subdir, changed_files, prev_sha, "old"
)
new_filtered = endpoints_to_map(
@@ -142,31 +158,26 @@ def main() -> int:
reports = compare_endpoints(old_filtered, new_filtered)
print(f"[对比] 检测到 {len(reports)} 个接口存在参数变更")
# 2. LLM 审核接口参数变更(非代码审查)
llm_review = None
if reports:
llm_review = review_parameter_changes(
reports, config, changed_files, git_diff
)
if llm_review:
print(f"[LLM] 参数变更审核完成")
print("[LLM] 参数变更审核完成")
# 3. 写日志(开关控制)
persist_change_log(reports, commit_info, config, llm_review)
# 4. 企微通知
notify_cfg = config.get("notify", {})
only_on_change = notify_cfg.get("only_on_change", True)
if only_on_change and not reports:
if notify_cfg.get("only_on_change", True) and not reports:
print("[通知] 无接口参数变更,跳过企微通知。")
return 0
mentioned = notify_cfg.get("mentioned_users", "")
mentioned_list = [u.strip() for u in mentioned.split(",") if u.strip()] or None
wecom_cfg = config.get("wecom", {})
send_parameter_change_notification(
webhook_url=wecom_cfg.get("webhook_url", ""),
webhook_url=config.get("wecom", {}).get("webhook_url", ""),
reports=reports,
push_user=push_user,
push_time=push_time,

View File

@@ -1,3 +1,4 @@
# Python 依赖(CI 主流程
# Python 依赖(纯 Python AST 解析,无需 Java
PyYAML>=6.0.1
requests>=2.31.0
javalang>=0.13.0

View File

@@ -1,13 +1,17 @@
# AI-Check 配置文件
# 业务源码在 ftb 模块下
# ============================================================
# AI-Check 配置文件(位于 .gitea/ 目录,与业务代码解耦)
# ============================================================
# 业务 Java 源码目录(相对仓库根目录)
# 单模块: src/main/java
# 多模块: ftb/src/main/java
source_dir: "ftb/src/main/java"
java_parser_jar: ".gitea/java-parser/target/controller-parser-1.0.0.jar"
# ---------- 企业微信机器人 ----------
wecom:
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81"
# ---------- 豆包 LLM审核接口参数变更----------
llm:
enabled: true
api_key: "2f3f7ee9-a6f7-46b7-a709-a36743a83a04"
@@ -16,6 +20,7 @@ llm:
api_url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
timeout: null
# ---------- 变更日志 ----------
log:
enabled: false
storage: "file"
@@ -28,6 +33,7 @@ log:
database: "YOUR_MYSQL_DATABASE"
table: "api_change_logs"
# ---------- 通知 ----------
notify:
only_on_change: true
mentioned_users: ""

View File

@@ -1,62 +0,0 @@
<?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

@@ -1,86 +0,0 @@
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

@@ -1,75 +0,0 @@
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

@@ -1,421 +0,0 @@
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

@@ -1,48 +0,0 @@
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

@@ -1,5 +0,0 @@
#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

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

View File

@@ -1,4 +0,0 @@
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

View File

@@ -1,6 +1,4 @@
# Gitea Actionspush 后检测 Controller 接口参数变更
# 工具代码与配置均位于 .gitea/ 目录,与业务代码解耦
# 检出方式:使用 gitea.token 直连内网 Git 仓库(避免 actions/checkout 网络问题)
# Gitea ActionsController 接口参数变更检测(纯 Python无 Java 构建)
name: API接口参数变更检测
run-name: ${{ gitea.actor }}的API参数变更检测
@@ -9,7 +7,6 @@ on: [push]
jobs:
api-param-check:
# 排除指定分支(与现有 AI 审查 workflow 保持一致,可按需修改)
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
runs-on: ubuntu-latest
@@ -28,29 +25,15 @@ jobs:
echo "错误: 缺少 .gitea/config.yaml"
exit 1
fi
echo "使用 .gitea/config.yaml"
- name: 安装 Java、Maven 和 Python
- name: 安装 Python 和依赖
run: |
echo "安装运行环境..."
sudo apt-get update
sudo apt-get install -y openjdk-11-jdk maven python3 python3-pip
java -version
mvn -version
python3 --version
echo "环境准备完成"
- name: 安装 Python 依赖
run: |
sudo apt-get install -y python3 python3-pip
python3 -m pip install --break-system-packages -r .gitea/checker/requirements.txt
- name: 构建 AST 解析器
working-directory: .gitea/java-parser
run: mvn -q package -DskipTests
- name: 检测 Controller 接口参数变更
run: |
echo "执行 API 参数变更检测..."
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
python3 .gitea/checker/main.py \
--config .gitea/config.yaml \