源码更新
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 23s

This commit is contained in:
2026-06-09 15:02:52 +08:00
parent a9e7072688
commit da4611a24c
8 changed files with 294 additions and 72 deletions

View File

@@ -51,7 +51,7 @@ WeComNotifier ────────► ApiChangeNotifier
**方法指纹建议**(用于跨 commit 匹配同一接口):
```
controller源文件 + 方法名 + 参数类型签名
controller源文件 + 方法名 + 参数槽位(如 0:query,1:body不含类型与绑定名
```
仅 URI 匹配在「改路径」场景会失效,需指纹辅助。

View File

@@ -77,7 +77,7 @@
1. 解析旧/新 commit 下同一 Controller 源码 AST
2. 提取每个方法的 `httpMethod` + `uri`(已有 `EndpointParser` 逻辑)
3. 用**方法指纹**(类文件 + 方法名 + 参数类型签名)匹配新旧接口
3. 用**方法指纹**(类文件 + 方法名 + 参数槽位,如 `0:query,1:query`;不含类型与绑定名)匹配新旧接口
4. 指纹相同且 URI 不同 → **修改路径**
5. 仅旧有新无 → **删除**;仅新有旧无 → **新增**

View File

@@ -12,12 +12,20 @@ import com.codechecker.model.FieldInfo;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 接口入参 diff普通参数 + RequestBody 嵌套 Dto 字段),与类变更 FieldDiffEngine 解耦
* 接口入参 diff普通参数 + RequestBody 嵌套 Dto 字段)。
*
* path/query 规则:
* - 形参名+类型相同,仅绑定名变 → 重命名
* - 形参名+绑定名相同,仅类型变 → 类型变更
* - 仅形参名变(绑定名不变)→ 不通知
* - 类型与绑定名同时变,或三者都变 → 先删除后新增
*/
public class ParameterDiffEngine {
private final NestedDtoFieldParser nestedDtoFieldParser;
@@ -28,38 +36,143 @@ public class ParameterDiffEngine {
}
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<>();
changes.addAll(diffBodyParams(oldSnap, newSnap));
changes.addAll(diffBindingParams(oldSnap, newSnap));
return changes;
}
private List<ParameterChange> diffBodyParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap)
throws IOException {
Map<String, MethodParameterSnapshot> oldParams = filterBySource(oldSnap, "body");
Map<String, MethodParameterSnapshot> newParams = filterBySource(newSnap, "body");
List<ParameterChange> changes = new ArrayList<>();
for (Map.Entry<String, MethodParameterSnapshot> entry : newParams.entrySet()) {
MethodParameterSnapshot oldParam = oldParams.get(entry.getKey());
MethodParameterSnapshot newParam = entry.getValue();
if (oldParam == null) {
changes.addAll(addedChanges(newParam));
} else if ("body".equals(newParam.getSource())) {
changes.addAll(addedBodyChanges(newParam));
} else {
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()));
changes.addAll(removedBodyChanges(entry.getValue()));
}
}
return changes;
}
private List<ParameterChange> diffBindingParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) {
Map<String, MethodParameterSnapshot> oldParams = filterBindingParams(oldSnap);
Map<String, MethodParameterSnapshot> newParams = filterBindingParams(newSnap);
List<ParameterChange> changes = new ArrayList<>();
List<MethodParameterSnapshot> unmatchedOld = new ArrayList<>();
List<MethodParameterSnapshot> unmatchedNew = new ArrayList<>();
for (Map.Entry<String, MethodParameterSnapshot> entry : newParams.entrySet()) {
MethodParameterSnapshot oldParam = oldParams.get(entry.getKey());
MethodParameterSnapshot newParam = entry.getValue();
if (oldParam == null) {
unmatchedNew.add(newParam);
} else if (!oldParam.getType().equals(newParam.getType())) {
changes.add(ParameterChange.modified(
newParam.displayName(),
newParam.getType(),
newParam.getDescription(),
oldParam.getType() + "" + newParam.getType(),
newParam.getSource(),
null, null, null));
}
}
for (Map.Entry<String, MethodParameterSnapshot> entry : oldParams.entrySet()) {
if (!newParams.containsKey(entry.getKey())) {
unmatchedOld.add(entry.getValue());
}
}
pairRenamedBindingParams(unmatchedOld, unmatchedNew, changes);
for (MethodParameterSnapshot removed : unmatchedOld) {
changes.add(ParameterChange.removed(
removed.displayName(), removed.getType(), removed.getDescription(),
removed.getSource(), null, null, null));
}
for (MethodParameterSnapshot added : unmatchedNew) {
changes.add(ParameterChange.added(
added.displayName(), added.getType(), added.getDescription(),
added.getSource(), null, null, null));
}
return changes;
}
/** 形参名+类型相同,仅绑定名变化 → 重命名 */
private void pairRenamedBindingParams(List<MethodParameterSnapshot> unmatchedOld,
List<MethodParameterSnapshot> unmatchedNew,
List<ParameterChange> changes) {
Set<MethodParameterSnapshot> pairedOld = new HashSet<>();
Set<MethodParameterSnapshot> pairedNew = new HashSet<>();
for (MethodParameterSnapshot oldParam : unmatchedOld) {
if (pairedOld.contains(oldParam)) {
continue;
}
for (MethodParameterSnapshot newParam : unmatchedNew) {
if (pairedNew.contains(newParam)) {
continue;
}
if (!oldParam.getSource().equals(newParam.getSource())) {
continue;
}
if (!oldParam.getName().equals(newParam.getName())) {
continue;
}
if (!oldParam.getType().equals(newParam.getType())) {
continue;
}
changes.add(ParameterChange.renamed(
oldParam.getBindingName(),
newParam.getBindingName(),
newParam.getType(),
newParam.getDescription(),
newParam.getSource(),
null, null, null));
pairedOld.add(oldParam);
pairedNew.add(newParam);
break;
}
}
unmatchedOld.removeIf(pairedOld::contains);
unmatchedNew.removeIf(pairedNew::contains);
}
private Map<String, MethodParameterSnapshot> filterBindingParams(EndpointSnapshot snap) {
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
for (MethodParameterSnapshot p : snap.getParameters()) {
if (isBindingParam(p)) {
map.put(p.identityKey(), p);
}
}
return map;
}
private Map<String, MethodParameterSnapshot> filterBySource(EndpointSnapshot snap, String source) {
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
for (MethodParameterSnapshot p : snap.getParameters()) {
if (source.equals(p.getSource())) {
map.put(p.identityKey(), p);
}
}
return map;
}
private boolean isBindingParam(MethodParameterSnapshot param) {
return "path".equals(param.getSource()) || "query".equals(param.getSource());
}
private List<ParameterChange> diffBodyDto(MethodParameterSnapshot oldParam,
MethodParameterSnapshot newParam) throws IOException {
List<NestedFieldInfo> oldFields = nestedDtoFieldParser.parseNestedFields(oldParam.getDtoClassName());
@@ -91,45 +204,29 @@ public class ParameterDiffEngine {
}
}
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()));
private List<ParameterChange> addedBodyChanges(MethodParameterSnapshot param) throws IOException {
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;
}
private List<ParameterChange> removedBodyChanges(MethodParameterSnapshot param) throws IOException {
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;
}
private List<FieldInfo> toFieldInfo(List<NestedFieldInfo> nested) {
List<FieldInfo> result = new ArrayList<>();
for (NestedFieldInfo info : nested) {
result.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription()));
}
return result;
}
}

View File

@@ -2,7 +2,6 @@ package com.codechecker.api.model;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 单个 HTTP/Feign 接口快照。
@@ -32,9 +31,13 @@ public class EndpointSnapshot {
public static String buildFingerprint(String sourceFile, String methodName,
List<MethodParameterSnapshot> parameters) {
String sig = parameters.stream()
.map(p -> p.getType())
.collect(Collectors.joining(","));
StringBuilder sig = new StringBuilder();
for (int i = 0; i < parameters.size(); i++) {
if (i > 0) {
sig.append(',');
}
sig.append(parameters.get(i).fingerprintSlotKey(i));
}
return sourceFile + "#" + methodName + "#" + sig;
}

View File

@@ -5,15 +5,17 @@ package com.codechecker.api.model;
*/
public class MethodParameterSnapshot {
private final String name;
private final String bindingName;
private final String type;
private final String source;
private final boolean required;
private final String description;
private final String dtoClassName;
public MethodParameterSnapshot(String name, String type, String source,
public MethodParameterSnapshot(String name, String bindingName, String type, String source,
boolean required, String description, String dtoClassName) {
this.name = name;
this.bindingName = bindingName == null || bindingName.isBlank() ? name : bindingName;
this.type = type;
this.source = source;
this.required = required;
@@ -25,6 +27,11 @@ public class MethodParameterSnapshot {
return name;
}
/** 对外绑定名(@PathVariable / @RequestParam 的 value/name缺省为形参名 */
public String getBindingName() {
return bindingName;
}
public String getType() {
return type;
}
@@ -46,7 +53,24 @@ public class MethodParameterSnapshot {
return dtoClassName;
}
/** 接口指纹槽位:序号 + 参数来源,不含类型与绑定名,避免类型/绑定变更导致误配对 */
public String fingerprintSlotKey(int index) {
return index + ":" + source;
}
/** path/query 按绑定名匹配,避免仅 Java 形参重命名误报 */
public String identityKey() {
if ("path".equals(source) || "query".equals(source)) {
return source + ":" + bindingName;
}
return source + ":" + name;
}
/** 通知展示名path/query 展示绑定名 */
public String displayName() {
if ("path".equals(source) || "query".equals(source)) {
return bindingName;
}
return name;
}
}

View File

@@ -205,6 +205,9 @@ public class ApiChangeNotifier {
case RENAMED:
tag = MarkdownStyles.colorWarning("[重命名]");
break;
case MODIFIED:
tag = MarkdownStyles.colorWarning(change.isBodyField() ? "[修改]" : "[类型变更]");
break;
default:
tag = MarkdownStyles.colorWarning("[修改]");
break;
@@ -214,19 +217,23 @@ public class ApiChangeNotifier {
? 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));
line.append(tag).append(" ")
.append(MarkdownStyles.colorComment(MarkdownStyles.safe(change.getOldName()))).append(" ")
.append(MarkdownStyles.colorInfo(MarkdownStyles.safe(change.getParamName())))
.append(" 说明: ").append(desc);
} else {
line.append(tag).append(" ").append(name).append(" 说明: ").append(desc);
}
appendParameterType(line, change);
return MarkdownStyles.quoteLine(line.toString());
}
private void appendParameterType(StringBuilder line, ParameterChange change) {
String typePart = resolveTypePart(change);
if (!typePart.isBlank()) {
return line + "\n" + MarkdownStyles.quoteKv("类型", typePart);
line.append(" 类型: ").append(typePart);
}
return line.toString();
}
private String formatUriWithMethod(String httpMethod, String uri, boolean isNew) {

View File

@@ -106,6 +106,7 @@ public class EndpointSnapshotParser {
}
private List<MethodParameterSnapshot> extractParameters(MethodDeclaration method) {
Map<String, String> paramDescriptions = MethodParamJavadocExtractor.extract(method);
List<MethodParameterSnapshot> params = new ArrayList<>();
for (Parameter parameter : method.getParameters()) {
String typeName = TypeNameUtils.typeToString(parameter.getType());
@@ -114,20 +115,43 @@ public class EndpointSnapshotParser {
continue;
}
String source = resolveParamSource(parameter);
String paramName = parameter.getNameAsString();
String bindingName = resolveBindingName(parameter, source, paramName);
boolean required = resolveRequired(parameter, source);
String dtoName = "body".equals(source) ? simple : "";
String description = paramDescriptions.getOrDefault(paramName, "");
params.add(new MethodParameterSnapshot(
parameter.getNameAsString(),
paramName,
bindingName,
typeName,
source,
required,
"",
description,
dtoName
));
}
return params;
}
private String resolveBindingName(Parameter parameter, String source, String paramName) {
if (!"path".equals(source) && !"query".equals(source)) {
return paramName;
}
String annName = "path".equals(source) ? "PathVariable" : "RequestParam";
for (AnnotationExpr ann : parameter.getAnnotations()) {
if (!annName.equals(ann.getNameAsString())) {
continue;
}
List<String> bindings = readStringArray(ann, "value", "name");
for (String binding : bindings) {
if (binding != null && !binding.isBlank()) {
return binding;
}
}
}
return paramName;
}
private String resolveParamSource(Parameter parameter) {
for (AnnotationExpr ann : parameter.getAnnotations()) {
String name = ann.getNameAsString();

View File

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