This commit is contained in:
@@ -2,6 +2,9 @@ package com.aicheck;
|
||||
|
||||
import com.aicheck.analyzer.ClassChangeAnalyzer;
|
||||
import com.aicheck.analyzer.EndpointIndexBuilder;
|
||||
import com.aicheck.api.analyzer.ApiChangeAnalyzer;
|
||||
import com.aicheck.api.model.EndpointChangeReport;
|
||||
import com.aicheck.api.notify.ApiChangeNotifier;
|
||||
import com.aicheck.config.AppConfig;
|
||||
import com.aicheck.git.GitChangeScanner;
|
||||
import com.aicheck.model.ApiEndpoint;
|
||||
@@ -20,7 +23,7 @@ import java.util.concurrent.Callable;
|
||||
* CLI 入口:加载配置 → 扫描 git 变更 → 分析影响 → 输出/发送企微通知。
|
||||
*/
|
||||
@Command(name = "class-checker", mixinStandardHelpOptions = true,
|
||||
description = "检测 Vo/Dto/Entity/Model 类变更并发送企业微信通知")
|
||||
description = "检测类变更与 API 变更并发送企业微信通知")
|
||||
public class ClassCheckMain implements Callable<Integer> {
|
||||
@Option(names = "--config", required = true, description = "配置文件路径")
|
||||
private Path config;
|
||||
@@ -46,16 +49,33 @@ public class ClassCheckMain implements Callable<Integer> {
|
||||
System.exit(exitCode);
|
||||
}
|
||||
|
||||
/** 主流程:索引接口 → 分析变更 → 通知 */
|
||||
/** 主流程:类变更与 API 变更独立检测、分条通知 */
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
AppConfig appConfig = AppConfig.load(config.toAbsolutePath());
|
||||
if (!appConfig.isEnabled()) {
|
||||
GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath());
|
||||
int totalSent = 0;
|
||||
|
||||
if (appConfig.isEnabled()) {
|
||||
totalSent += runClassChangeCheck(appConfig, gitScanner);
|
||||
} else {
|
||||
System.out.println("类变更检测已关闭(class_check.enabled=false)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath());
|
||||
if (appConfig.isApiCheckEnabled()) {
|
||||
totalSent += runApiChangeCheck(appConfig, gitScanner);
|
||||
} else {
|
||||
System.out.println("API 变更检测已关闭(api_check.enabled=false)");
|
||||
}
|
||||
|
||||
if (totalSent == 0 && appConfig.isOnlyOnChange()) {
|
||||
System.out.println("无变更,静默退出");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private int runClassChangeCheck(AppConfig appConfig, GitChangeScanner gitScanner) throws Exception {
|
||||
System.out.println("=== 类变更检测 ===");
|
||||
EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder();
|
||||
Map<String, ApiEndpoint> endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig);
|
||||
System.out.println("已索引接口数量: " + endpointIndex.size());
|
||||
@@ -64,20 +84,28 @@ public class ClassCheckMain implements Callable<Integer> {
|
||||
List<ClassChangeReport> reports = analyzer.analyze(
|
||||
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex);
|
||||
System.out.println("检测到需通知的类变更数量: " + reports.size());
|
||||
|
||||
if (reports.isEmpty()) {
|
||||
if (appConfig.isOnlyOnChange()) {
|
||||
System.out.println("无类变更,静默退出");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
WeComNotifier notifier = new WeComNotifier();
|
||||
if (appConfig.isWecomEnabled()) {
|
||||
notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime);
|
||||
} else {
|
||||
notifier.logAll(reports, modifier, modifyTime);
|
||||
return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime);
|
||||
}
|
||||
return 0;
|
||||
notifier.logAll(reports, modifier, modifyTime);
|
||||
return reports.size();
|
||||
}
|
||||
|
||||
private int runApiChangeCheck(AppConfig appConfig, GitChangeScanner gitScanner) throws Exception {
|
||||
System.out.println("=== API 变更检测 ===");
|
||||
ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner);
|
||||
List<EndpointChangeReport> reports = analyzer.analyze(
|
||||
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha);
|
||||
System.out.println("检测到需通知的 API 变更数量: " + reports.size());
|
||||
if (reports.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
ApiChangeNotifier notifier = new ApiChangeNotifier();
|
||||
return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime,
|
||||
appConfig.isWecomEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.aicheck.api.analyzer;
|
||||
|
||||
import com.aicheck.api.model.EndpointChangeReport;
|
||||
import com.aicheck.api.model.EndpointSnapshot;
|
||||
import com.aicheck.api.parser.EndpointSnapshotParser;
|
||||
import com.aicheck.api.scanner.ApiFileChangeScanner;
|
||||
import com.aicheck.config.AppConfig;
|
||||
import com.aicheck.git.GitChangeScanner;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API 变更分析编排(与 {@link com.aicheck.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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.aicheck.api.analyzer;
|
||||
|
||||
import com.aicheck.api.model.ApiChangeKind;
|
||||
import com.aicheck.api.model.EndpointChangeReport;
|
||||
import com.aicheck.api.model.EndpointSnapshot;
|
||||
import com.aicheck.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.aicheck.api.analyzer;
|
||||
|
||||
import com.aicheck.analyzer.FieldDiffEngine;
|
||||
import com.aicheck.api.model.EndpointSnapshot;
|
||||
import com.aicheck.api.model.MethodParameterSnapshot;
|
||||
import com.aicheck.api.model.ParameterChange;
|
||||
import com.aicheck.api.parser.NestedDtoFieldParser;
|
||||
import com.aicheck.api.parser.NestedFieldInfo;
|
||||
import com.aicheck.model.FieldChange;
|
||||
import com.aicheck.model.FieldInfo;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 接口入参 diff(普通参数 + RequestBody 嵌套 Dto 字段),与类变更 FieldDiffEngine 解耦。
|
||||
*/
|
||||
public class ParameterDiffEngine {
|
||||
private final NestedDtoFieldParser nestedDtoFieldParser;
|
||||
private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine();
|
||||
|
||||
public ParameterDiffEngine(Path repoRoot, List<String> searchDirs) {
|
||||
this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs);
|
||||
}
|
||||
|
||||
public List<ParameterChange> diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException {
|
||||
Map<String, MethodParameterSnapshot> oldParams = toParamMap(oldSnap);
|
||||
Map<String, MethodParameterSnapshot> newParams = toParamMap(newSnap);
|
||||
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(addedChanges(newParam));
|
||||
} else if ("body".equals(newParam.getSource())) {
|
||||
changes.addAll(diffBodyDto(oldParam, newParam));
|
||||
} else if (!oldParam.getType().equals(newParam.getType())) {
|
||||
changes.add(ParameterChange.modified(
|
||||
newParam.getName(),
|
||||
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())) {
|
||||
changes.addAll(removedChanges(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
private List<ParameterChange> diffBodyDto(MethodParameterSnapshot oldParam,
|
||||
MethodParameterSnapshot newParam) throws IOException {
|
||||
List<NestedFieldInfo> oldFields = nestedDtoFieldParser.parseNestedFields(oldParam.getDtoClassName());
|
||||
List<NestedFieldInfo> newFields = nestedDtoFieldParser.parseNestedFields(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> addedChanges(MethodParameterSnapshot param) throws IOException {
|
||||
if ("body".equals(param.getSource())) {
|
||||
List<ParameterChange> list = new ArrayList<>();
|
||||
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) {
|
||||
list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(),
|
||||
"body", param.getName(), param.getDtoClassName(), field.getPath()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
return List.of(ParameterChange.added(param.getName(), param.getType(), param.getDescription(),
|
||||
param.getSource(), null, null, null));
|
||||
}
|
||||
|
||||
private List<ParameterChange> removedChanges(MethodParameterSnapshot param) throws IOException {
|
||||
if ("body".equals(param.getSource())) {
|
||||
List<ParameterChange> list = new ArrayList<>();
|
||||
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) {
|
||||
list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(),
|
||||
"body", param.getName(), param.getDtoClassName(), field.getPath()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
return List.of(ParameterChange.removed(param.getName(), param.getType(), param.getDescription(),
|
||||
param.getSource(), null, null, null));
|
||||
}
|
||||
|
||||
private Map<String, MethodParameterSnapshot> toParamMap(EndpointSnapshot snap) {
|
||||
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
|
||||
for (MethodParameterSnapshot p : snap.getParameters()) {
|
||||
map.put(p.identityKey(), p);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private List<FieldInfo> toFieldInfo(List<NestedFieldInfo> nested) {
|
||||
List<FieldInfo> list = new ArrayList<>();
|
||||
for (NestedFieldInfo info : nested) {
|
||||
list.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.aicheck.api.model;
|
||||
|
||||
/**
|
||||
* API 变更类型(与类变更 {@link com.aicheck.model.ClassChangeKind} 独立)。
|
||||
*/
|
||||
public enum ApiChangeKind {
|
||||
NEW_ENDPOINT,
|
||||
REMOVED_ENDPOINT,
|
||||
PATH_CHANGED,
|
||||
METHOD_CHANGED,
|
||||
PARAM_CHANGED
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.aicheck.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 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 = changeKind;
|
||||
this.httpMethod = httpMethod;
|
||||
this.oldHttpMethod = oldHttpMethod;
|
||||
this.uri = uri;
|
||||
this.oldUri = oldUri;
|
||||
this.sourceFile = sourceFile;
|
||||
this.controllerClass = controllerClass;
|
||||
this.endpointDescription = endpointDescription == null ? "" : endpointDescription;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.aicheck.api.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 单个 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);
|
||||
}
|
||||
|
||||
public static String buildFingerprint(String sourceFile, String methodName,
|
||||
List<MethodParameterSnapshot> parameters) {
|
||||
String sig = parameters.stream()
|
||||
.map(p -> p.getType())
|
||||
.collect(Collectors.joining(","));
|
||||
return sourceFile + "#" + methodName + "#" + sig;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.aicheck.api.model;
|
||||
|
||||
/**
|
||||
* 接口方法入参快照。
|
||||
*/
|
||||
public class MethodParameterSnapshot {
|
||||
private final String name;
|
||||
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 type, String source,
|
||||
boolean required, String description, String dtoClassName) {
|
||||
this.name = name;
|
||||
this.type = type;
|
||||
this.source = source;
|
||||
this.required = required;
|
||||
this.description = description;
|
||||
this.dtoClassName = dtoClassName;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public String identityKey() {
|
||||
return source + ":" + name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.aicheck.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.aicheck.api.notify;
|
||||
|
||||
import com.aicheck.api.model.ApiChangeKind;
|
||||
import com.aicheck.api.model.EndpointChangeReport;
|
||||
import com.aicheck.api.model.ParameterChange;
|
||||
import com.aicheck.common.MarkdownStyles;
|
||||
import com.aicheck.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("原路径",
|
||||
MarkdownStyles.colorWarning(report.getOldUri()) + " " + MarkdownStyles.colorWarning("[旧路径]"))).append("\n");
|
||||
sb.append(MarkdownStyles.quoteKvBold("新路径",
|
||||
MarkdownStyles.colorInfo(report.getUri()) + " " + 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.getBodyParamName() == null ? "body" : change.getBodyParamName())
|
||||
+ "|" + (change.getParentDto() == null ? "" : 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);
|
||||
sb.append("**").append(first.getBodyParamName()).append("**");
|
||||
if (first.getParentDto() != null && !first.getParentDto().isBlank()) {
|
||||
sb.append(" ").append(MarkdownStyles.inlineCode(first.getParentDto()));
|
||||
}
|
||||
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;
|
||||
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();
|
||||
line.append(MarkdownStyles.quoteLine(tag + " " + name + " 说明: " + desc));
|
||||
if (change.getChangeType() == ParameterChange.ChangeType.RENAMED) {
|
||||
line = new StringBuilder();
|
||||
line.append(MarkdownStyles.quoteLine(tag + " "
|
||||
+ MarkdownStyles.colorComment(MarkdownStyles.safe(change.getOldName())) + " → "
|
||||
+ MarkdownStyles.colorInfo(MarkdownStyles.safe(change.getParamName()))
|
||||
+ " 说明: " + desc));
|
||||
}
|
||||
String typePart = resolveTypePart(change);
|
||||
if (!typePart.isBlank()) {
|
||||
return line + "\n" + MarkdownStyles.quoteKv("类型", typePart);
|
||||
}
|
||||
return line.toString();
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.aicheck.api.parser;
|
||||
|
||||
import com.aicheck.api.model.EndpointSnapshot;
|
||||
import com.aicheck.api.model.MethodParameterSnapshot;
|
||||
import com.aicheck.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(), params);
|
||||
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) {
|
||||
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);
|
||||
boolean required = resolveRequired(parameter, source);
|
||||
String dtoName = "body".equals(source) ? simple : "";
|
||||
params.add(new MethodParameterSnapshot(
|
||||
parameter.getNameAsString(),
|
||||
typeName,
|
||||
source,
|
||||
required,
|
||||
"",
|
||||
dtoName
|
||||
));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
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<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.aicheck.api.parser;
|
||||
|
||||
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<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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.aicheck.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) > @Operation(description) > 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 "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.aicheck.api.parser;
|
||||
|
||||
import com.aicheck.model.FieldInfo;
|
||||
import com.aicheck.parser.ClassFieldParser;
|
||||
import com.aicheck.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;
|
||||
|
||||
public NestedDtoFieldParser(Path repoRoot, List<String> searchDirs) {
|
||||
this.sourceLocator = new JavaSourceLocator(repoRoot, searchDirs);
|
||||
}
|
||||
|
||||
public List<NestedFieldInfo> parseNestedFields(String dtoClassName) throws IOException {
|
||||
Set<String> visiting = new HashSet<>();
|
||||
List<NestedFieldInfo> result = new ArrayList<>();
|
||||
collectFields(dtoClassName, "", visiting, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void collectFields(String className, String prefix, Set<String> visiting,
|
||||
List<NestedFieldInfo> out) throws IOException {
|
||||
if (className == null || className.isBlank() || visiting.contains(className)) {
|
||||
return;
|
||||
}
|
||||
visiting.add(className);
|
||||
Optional<String> source = sourceLocator.readSourceBySimpleName(className);
|
||||
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();
|
||||
String simpleType = TypeNameUtils.simpleName(field.getType());
|
||||
if (isLeafType(simpleType)) {
|
||||
out.add(new NestedFieldInfo(path, field.getType(), field.getDescription()));
|
||||
} else {
|
||||
out.add(new NestedFieldInfo(path, field.getType(), field.getDescription()));
|
||||
collectFields(simpleType, path, visiting, out);
|
||||
}
|
||||
}
|
||||
visiting.remove(className);
|
||||
}
|
||||
|
||||
private boolean isLeafType(String simpleType) {
|
||||
return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.aicheck.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.aicheck.api.scanner;
|
||||
|
||||
import com.aicheck.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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.aicheck.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("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.aicheck.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 + "\"";
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ public class AppConfig {
|
||||
private boolean wecomEnabled = true;
|
||||
private boolean onlyOnChange = true;
|
||||
|
||||
private boolean apiCheckEnabled = true;
|
||||
private boolean apiExcludeFrameworkParams = true;
|
||||
private List<String> apiControllerScanDirs = new ArrayList<>();
|
||||
private List<String> apiFeignScanDirs = new ArrayList<>();
|
||||
|
||||
/** 从 YAML 文件加载配置 */
|
||||
@SuppressWarnings("unchecked")
|
||||
public static AppConfig load(Path configPath) throws IOException {
|
||||
@@ -56,6 +61,19 @@ public class AppConfig {
|
||||
Map<String, Object> notify = mapOrEmpty(root.get("notify"));
|
||||
config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -141,4 +159,32 @@ public class AppConfig {
|
||||
public boolean isOnlyOnChange() {
|
||||
return onlyOnChange;
|
||||
}
|
||||
|
||||
/** API 变更检测总开关 */
|
||||
public boolean isApiCheckEnabled() {
|
||||
return apiCheckEnabled;
|
||||
}
|
||||
|
||||
/** 是否排除 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,11 @@ public class GitChangeScanner {
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user