feat(extapi): 新增外部接口清单注解扫描与展示导出

新增 @ExternalApi/@ExternalCall 方法级注解,启动时扫描生成内存注册表。
提供 /sys/extApi/list 展示、exportXls(AutoPoi) 与 exportMarkdown 导出;
给 TaskApiController.countTaskforporter 打 @ExternalApi 示范并补菜单 SQL。
This commit is contained in:
wsm
2026-08-11 15:14:38 +08:00
parent 64182840f3
commit 522f269f59
10 changed files with 543 additions and 0 deletions
@@ -0,0 +1,32 @@
package org.jeecg.common.aspect.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 对外提供的接口(供外部系统调用)
* <p>标注在 Controller 方法上;接口地址与 HTTP 方法由框架从 @RequestMapping 自动推导。
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExternalApi {
/** 接口名称/用途 */
String name();
/** 调用方系统(谁在调我) */
String caller() default "";
/** 认证机制 */
ExternalApiAuthType auth() default ExternalApiAuthType.NONE;
/** 数据范围说明(返回/涉及什么数据) */
String dataScope() default "";
/** 备注 */
String remark() default "";
}
@@ -0,0 +1,13 @@
package org.jeecg.common.aspect.annotation;
/**
* 外部接口认证机制
*/
public enum ExternalApiAuthType {
/** 无需认证(裸调用) */
NONE,
/** 共享密钥签名(X-Sign / X-TIMESTAMP */
SIGN,
/** JWT 登录态 */
JWT
}
@@ -0,0 +1,38 @@
package org.jeecg.common.aspect.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 调用外部系统的接口(本系统调用其他系统)
* <p>标注在发起出站调用的方法上(Service、客户端等)。
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExternalCall {
/** 用途 */
String name();
/** 被调用的外部系统 */
String system();
/** 被调接口地址 */
String url();
/** HTTP 方法 */
String httpMethod() default "GET";
/** 对方要求的认证机制 */
ExternalApiAuthType auth() default ExternalApiAuthType.NONE;
/** 交互的数据范围说明 */
String dataScope() default "";
/** 备注 */
String remark() default "";
}
@@ -0,0 +1,30 @@
package org.jeecg.config.extapi;
import org.jeecg.config.extapi.model.ExternalApiInfo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 外部接口清单内存注册表
* <p>启动时由 {@link ExternalApiScanner} 填充,运行时只读。
*/
public class ExternalApiRegistry {
private static final List<ExternalApiInfo> REGISTRY = new ArrayList<>();
private ExternalApiRegistry() {
}
public static synchronized void set(List<ExternalApiInfo> list) {
REGISTRY.clear();
if (list != null) {
REGISTRY.addAll(list);
}
}
public static List<ExternalApiInfo> get() {
return Collections.unmodifiableList(REGISTRY);
}
}
@@ -0,0 +1,216 @@
package org.jeecg.config.extapi;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.aspect.annotation.ExternalApi;
import org.jeecg.common.aspect.annotation.ExternalApiAuthType;
import org.jeecg.common.aspect.annotation.ExternalCall;
import org.jeecg.config.extapi.model.ExternalApiInfo;
import org.jeecg.config.extapi.model.ExternalApiType;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* 外部接口清单扫描器
* <p>启动时扫描两类注解生成内存注册表:
* <ul>
* <li>@ExternalApi:扫描 Controller 方法(接口地址由 @RequestMapping 推导)</li>
* <li>@ExternalCall:扫描全部 Spring Bean 方法(出站调用,地址取自注解)</li>
* </ul>
*/
@Slf4j
@Component
public class ExternalApiScanner implements InitializingBean {
private final RequestMappingHandlerMapping requestMappingHandlerMapping;
private final ApplicationContext applicationContext;
public ExternalApiScanner(RequestMappingHandlerMapping requestMappingHandlerMapping,
ApplicationContext applicationContext) {
this.requestMappingHandlerMapping = requestMappingHandlerMapping;
this.applicationContext = applicationContext;
}
@Override
public void afterPropertiesSet() {
long start = System.currentTimeMillis();
List<ExternalApiInfo> list = new ArrayList<>();
scanExposeApis(list);
scanCallApis(list);
ExternalApiRegistry.set(list);
long cost = System.currentTimeMillis() - start;
log.info("【外部接口清单-扫描器】初始化完成, 对外提供 {} 条, 对外调用 {} 条, 耗时 {} 毫秒",
list.stream().filter(i -> ExternalApiType.EXPOSE.name().equals(i.getType())).count(),
list.stream().filter(i -> ExternalApiType.CALL.name().equals(i.getType())).count(),
cost);
}
/**
* 扫描对外提供接口(Controller 方法)
*/
private void scanExposeApis(List<ExternalApiInfo> list) {
Set<Class<?>> controllers = requestMappingHandlerMapping.getHandlerMethods().values().stream()
.map(HandlerMethod::getBeanType)
.collect(java.util.stream.Collectors.toSet());
for (Class<?> controller : controllers) {
RequestMapping base = controller.getAnnotation(RequestMapping.class);
String[] baseUrl = Objects.nonNull(base) ? base.value() : new String[]{};
for (Method method : controller.getDeclaredMethods()) {
ExternalApi ann = method.getAnnotation(ExternalApi.class);
if (ann == null) {
continue;
}
RequestMappingInfo info = resolveRequestMapping(method);
if (info == null) {
log.warn("【外部接口清单-扫描器】@ExternalApi 方法未找到 HTTP 映射, 跳过, method: {}.{}",
controller.getSimpleName(), method.getName());
continue;
}
for (String url : rebuildUrl(baseUrl, info.uris)) {
ExternalApiInfo item = new ExternalApiInfo();
item.setType(ExternalApiType.EXPOSE.name());
item.setName(ann.name());
item.setUrl(url);
item.setHttpMethod(info.httpMethod);
item.setCallerSystem(ann.caller());
item.setTargetSystem("本系统");
item.setDataFlow("外部系统→本系统(请求入站);本系统→外部系统(数据出站)");
item.setDataScope(ann.dataScope());
item.setAuth(authLabel(ann.auth()));
item.setSource(controller.getName() + "#" + method.getName());
item.setRemark(ann.remark());
list.add(item);
}
}
}
}
/**
* 扫描对外调用接口(全部 Spring Bean 方法)
*/
private void scanCallApis(List<ExternalApiInfo> list) {
Set<String> visited = new HashSet<>();
String[] beanNames = applicationContext.getBeanDefinitionNames();
for (String beanName : beanNames) {
Class<?> type = applicationContext.getType(beanName);
if (type == null || type.getName().startsWith("org.springframework")
|| type.getName().startsWith("org.apache")
|| type.getName().startsWith("java.")
|| type.getName().startsWith("lombok")) {
continue;
}
Class<?> target = type.getName().contains("$$") && type.getSuperclass() != null
? type.getSuperclass() : type;
for (Method method : target.getDeclaredMethods()) {
ExternalCall ann = method.getAnnotation(ExternalCall.class);
if (ann == null) {
continue;
}
String key = target.getName() + "#" + method.getName();
if (!visited.add(key)) {
continue;
}
ExternalApiInfo item = new ExternalApiInfo();
item.setType(ExternalApiType.CALL.name());
item.setName(ann.name());
item.setUrl(ann.url());
item.setHttpMethod(ann.httpMethod());
item.setCallerSystem("本系统");
item.setTargetSystem(ann.system());
item.setDataFlow("本系统→外部系统(数据出站)");
item.setDataScope(ann.dataScope());
item.setAuth(authLabel(ann.auth()));
item.setSource(target.getName() + "#" + method.getName());
item.setRemark(ann.remark());
list.add(item);
}
}
}
/**
* 从方法的 HTTP 映射注解解析 URL 与 HTTP 方法
*/
private RequestMappingInfo resolveRequestMapping(Method method) {
RequestMapping rm = method.getAnnotation(RequestMapping.class);
if (rm != null) {
return new RequestMappingInfo(rm.value(), rm.method().length > 0 ? rm.method()[0].name() : "ALL");
}
GetMapping get = method.getAnnotation(GetMapping.class);
if (get != null) {
return new RequestMappingInfo(get.value(), "GET");
}
PostMapping post = method.getAnnotation(PostMapping.class);
if (post != null) {
return new RequestMappingInfo(post.value(), "POST");
}
PutMapping put = method.getAnnotation(PutMapping.class);
if (put != null) {
return new RequestMappingInfo(put.value(), "PUT");
}
DeleteMapping del = method.getAnnotation(DeleteMapping.class);
if (del != null) {
return new RequestMappingInfo(del.value(), "DELETE");
}
PatchMapping patch = method.getAnnotation(PatchMapping.class);
if (patch != null) {
return new RequestMappingInfo(patch.value(), "PATCH");
}
return null;
}
private List<String> rebuildUrl(String[] bases, String[] uris) {
List<String> urls = new ArrayList<>();
if (bases.length > 0) {
for (String base : bases) {
for (String uri : uris) {
urls.add(prefix(base) + prefix(uri.replaceAll("\\{.*?}", "*")));
}
}
} else {
for (String uri : uris) {
urls.add(prefix(uri.replaceAll("\\{.*?}", "*")));
}
}
return urls;
}
private String prefix(String seg) {
return seg.startsWith("/") ? seg : "/" + seg;
}
private String authLabel(ExternalApiAuthType auth) {
switch (auth) {
case SIGN:
return "签名";
case JWT:
return "JWT";
default:
return "";
}
}
private static class RequestMappingInfo {
private final String[] uris;
private final String httpMethod;
private RequestMappingInfo(String[] uris, String httpMethod) {
this.uris = uris;
this.httpMethod = httpMethod;
}
}
}
@@ -0,0 +1,55 @@
package org.jeecg.config.extapi.model;
import lombok.Data;
import org.jeecgframework.poi.excel.annotation.Excel;
/**
* 外部接口清单条目
*/
@Data
public class ExternalApiInfo {
/** 方向类型(对外提供 / 对外调用) */
@Excel(name = "方向", width = 12)
private String type;
/** 接口名称/用途 */
@Excel(name = "接口名称", width = 28)
private String name;
/** 接口地址 */
@Excel(name = "接口地址", width = 50)
private String url;
/** HTTP 方法 */
@Excel(name = "HTTP方法", width = 10)
private String httpMethod;
/** 调用方系统(谁在调) */
@Excel(name = "调用方系统", width = 20)
private String callerSystem;
/** 被调系统(调谁) */
@Excel(name = "被调系统", width = 20)
private String targetSystem;
/** 数据流向 */
@Excel(name = "数据流向", width = 45)
private String dataFlow;
/** 数据范围 */
@Excel(name = "数据范围", width = 45)
private String dataScope;
/** 认证机制 */
@Excel(name = "认证机制", width = 12)
private String auth;
/** 代码位置(类#方法) */
@Excel(name = "代码位置", width = 70)
private String source;
/** 备注 */
@Excel(name = "备注", width = 30)
private String remark;
}
@@ -0,0 +1,11 @@
package org.jeecg.config.extapi.model;
/**
* 外部接口方向类型
*/
public enum ExternalApiType {
/** 对外提供(外部系统调用本系统) */
EXPOSE,
/** 对外调用(本系统调用外部系统) */
CALL
}