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
}
@@ -4,6 +4,8 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.ExternalApi;
import org.jeecg.common.aspect.annotation.ExternalApiAuthType;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.openapi.service.ITaskApi;
import org.springframework.web.bind.annotation.GetMapping;
@@ -32,6 +34,7 @@ public class TaskApiController {
* <p>调用方(门户)通过 username 参数传入 sys_user.username(登录账号)。
*/
@Operation(summary = "门户查询待办数", description = "门户查询待办数")
@ExternalApi(name = "门户查询待办数", caller = "门户系统", auth = ExternalApiAuthType.NONE, dataScope = "仅返回待办数量,不涉及业务明细")
@GetMapping("/countTaskforporter")
public Result<Long> countTaskforporter(HttpServletRequest request) {
String username = request.getParameter("username");
@@ -0,0 +1,115 @@
package org.jeecg.modules.system.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.config.extapi.ExternalApiRegistry;
import org.jeecg.config.extapi.model.ExternalApiInfo;
import org.jeecg.config.extapi.model.ExternalApiType;
import org.jeecgframework.poi.excel.def.NormalExcelConstants;
import org.jeecgframework.poi.excel.entity.ExportParams;
import org.jeecgframework.poi.excel.view.JeecgEntityExcelView;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* 外部接口清单(对外提供 / 对外调用)展示与导出
* <p>数据来自 {@link ExternalApiRegistry} 内存注册表(启动时由注解扫描生成)。
*/
@Tag(name = "外部接口清单")
@RestController
@RequestMapping("/sys/extApi")
@Slf4j
public class ExternalApiController {
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Operation(summary = "外部接口清单列表")
@GetMapping("/list")
public Result<List<ExternalApiInfo>> list() {
return Result.OK(ExternalApiRegistry.get());
}
@Operation(summary = "导出外部接口清单Excel")
@GetMapping("/exportXls")
public ModelAndView exportXls(HttpServletRequest request) {
// AutoPoi 导出时会 remove 已处理行(多sheet分页),必须传可变集合
List<ExternalApiInfo> data = new ArrayList<>(ExternalApiRegistry.get());
ModelAndView mv = new ModelAndView(new JeecgEntityExcelView());
String title = "外部接口清单";
mv.addObject(NormalExcelConstants.FILE_NAME, title);
mv.addObject(NormalExcelConstants.CLASS, ExternalApiInfo.class);
LoginUser sysUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
ExportParams exportParams = new ExportParams(title + "报表", "导出人:" + (sysUser == null ? "" : sysUser.getRealname()), title);
mv.addObject(NormalExcelConstants.PARAMS, exportParams);
mv.addObject(NormalExcelConstants.DATA_LIST, data);
return mv;
}
@Operation(summary = "导出外部接口清单Markdown")
@GetMapping("/exportMarkdown")
public void exportMarkdown(HttpServletResponse response) throws IOException {
String content = buildMarkdown();
String fileName = URLEncoder.encode("外部接口清单.md", StandardCharsets.UTF_8.name()).replaceAll("\\+", "%20");
response.setContentType("text/markdown;charset=UTF-8");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName);
OutputStream os = response.getOutputStream();
os.write(content.getBytes(StandardCharsets.UTF_8));
os.flush();
os.close();
}
private String buildMarkdown() {
List<ExternalApiInfo> all = ExternalApiRegistry.get();
StringBuilder sb = new StringBuilder();
sb.append("# 应用系统外部接口清单\n\n");
sb.append("> 生成时间:" + LocalDateTime.now().format(FMT) + " | 认证机制:无=裸调用 / 签名=X-Sign / JWT=登录态\n\n");
sb.append("## 一、对外提供的接口(外部系统调用本系统)\n\n");
sb.append(tableHeader());
all.stream().filter(i -> ExternalApiType.EXPOSE.name().equals(i.getType())).forEach(i -> sb.append(tableRow(i)));
sb.append("\n## 二、对外调用的接口(本系统调用外部系统)\n\n");
sb.append(tableHeader());
all.stream().filter(i -> ExternalApiType.CALL.name().equals(i.getType())).forEach(i -> sb.append(tableRow(i)));
return sb.toString();
}
private String tableHeader() {
return "| 接口名称 | 地址 | HTTP方法 | 调用方系统 | 被调系统 | 数据流向 | 数据范围 | 认证机制 | 代码位置 | 备注 |\n"
+ "|---|---|---|---|---|---|---|---|---|---|\n";
}
private String tableRow(ExternalApiInfo i) {
return String.join(" | ",
cell(i.getName()),
cell(i.getUrl()),
cell(i.getHttpMethod()),
cell(i.getCallerSystem()),
cell(i.getTargetSystem()),
cell(i.getDataFlow()),
cell(i.getDataScope()),
cell(i.getAuth()),
cell(i.getSource()),
cell(i.getRemark())) + "\n";
}
private String cell(String v) {
return StringUtils.hasText(v) ? v.replace("|", "\\|").replace("\n", " ") : " ";
}
}
@@ -0,0 +1,30 @@
-- ============================================
-- 外部接口清单 菜单 + 角色授权(幂等)
-- 页面: src/views/super/apiList/index.vue
-- 后端: /sys/extApi/* (ExternalApiController)
-- ============================================
SET @menu_id = REPLACE(UUID(), '-', '');
INSERT INTO sys_permission
(id, parent_id, name, url, component, is_route, component_name, redirect,
menu_type, perms, perms_type, sort_no, always_show, icon, is_leaf,
keep_alive, hidden, hide_tab, description, create_by, create_time,
update_by, update_time, del_flag, rule_flag, status, internal_or_external)
SELECT
@menu_id, '', '外部接口清单', '/super/apiList', 'super/apiList/index', 1, NULL, NULL,
1, NULL, 1, 100.00, 0, NULL, 1,
0, 0, 0, '应用系统间接口清单(对外提供/对外调用),注解自动采集', 'admin', NOW(),
'admin', NOW(), 0, 0, 1, 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_permission WHERE name = '外部接口清单' AND url = '/super/apiList');
-- 授权给 admin 角色
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids)
SELECT REPLACE(UUID(), '-', ''), 'f6817f48af4fb3af11b9e8bf182f618b', @menu_id, NULL
FROM DUAL
WHERE EXISTS (SELECT 1 FROM sys_permission WHERE id = @menu_id)
AND NOT EXISTS (
SELECT 1 FROM sys_role_permission r
WHERE r.role_id = 'f6817f48af4fb3af11b9e8bf182f618b' AND r.permission_id = @menu_id
);