From 89fbc6437a7abf111021fcdfd6b976db0a89f950 Mon Sep 17 00:00:00 2001 From: Peanut Date: Sat, 23 May 2026 18:21:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=88=E5=90=8E=E7=AB=AF?= =?UTF-8?q?OpenAPI=E8=A7=A3=E6=9E=90=E5=85=A5=E5=BA=93+=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=88=97=E8=A1=A8/=E8=AF=A6=E6=83=85/=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ApiEndpoint/ApiParam 实体和 Mapper - 新增 DTO 层(分页查询请求、列表项、详情项、参数项、代理测试请求/响应) - 新增 ApiEndpointService 含 OpenAPI JSON 解析、\ 展开(最大10层)、分页查询 - 新增 ApiEndpointSyncRunner 启动时异步同步 - 新增 ApiEndpointController 分页/详情/手动同步接口 - 新增 ApiTestProxyController 代理测试接口(SSRF 防护) - 前端新增接口列表页、详情弹窗(含测试面板、Token 来源选择) - 前端新增菜单和路由 --- .../controller/ApiEndpointController.java | 65 ++++ .../controller/ApiTestProxyController.java | 141 ++++++++ .../dto/request/ApiEndpointListRequest.java | 28 ++ .../dto/request/ApiTestProxyRequest.java | 27 ++ .../response/ApiEndpointDetailResponse.java | 25 ++ .../dto/response/ApiEndpointItemResponse.java | 18 + .../dto/response/ApiParamItemResponse.java | 19 + .../dto/response/ApiTestProxyResponse.java | 18 + .../java/com/emotion/entity/ApiEndpoint.java | 52 +++ .../java/com/emotion/entity/ApiParam.java | 52 +++ .../com/emotion/mapper/ApiEndpointMapper.java | 12 + .../com/emotion/mapper/ApiParamMapper.java | 12 + .../emotion/service/ApiEndpointService.java | 30 ++ .../service/ApiEndpointSyncRunner.java | 36 ++ .../service/impl/ApiEndpointServiceImpl.java | 331 ++++++++++++++++++ web-admin/src/api/endpoint.ts | 92 +++++ web-admin/src/config/menu.ts | 4 + web-admin/src/router/index.ts | 6 + .../views/endpoint/EndpointDetailDialog.vue | 235 +++++++++++++ web-admin/src/views/endpoint/EndpointList.vue | 200 +++++++++++ 20 files changed, 1403 insertions(+) create mode 100644 backend-single/src/main/java/com/emotion/controller/ApiEndpointController.java create mode 100644 backend-single/src/main/java/com/emotion/controller/ApiTestProxyController.java create mode 100644 backend-single/src/main/java/com/emotion/dto/request/ApiEndpointListRequest.java create mode 100644 backend-single/src/main/java/com/emotion/dto/request/ApiTestProxyRequest.java create mode 100644 backend-single/src/main/java/com/emotion/dto/response/ApiEndpointDetailResponse.java create mode 100644 backend-single/src/main/java/com/emotion/dto/response/ApiEndpointItemResponse.java create mode 100644 backend-single/src/main/java/com/emotion/dto/response/ApiParamItemResponse.java create mode 100644 backend-single/src/main/java/com/emotion/dto/response/ApiTestProxyResponse.java create mode 100644 backend-single/src/main/java/com/emotion/entity/ApiEndpoint.java create mode 100644 backend-single/src/main/java/com/emotion/entity/ApiParam.java create mode 100644 backend-single/src/main/java/com/emotion/mapper/ApiEndpointMapper.java create mode 100644 backend-single/src/main/java/com/emotion/mapper/ApiParamMapper.java create mode 100644 backend-single/src/main/java/com/emotion/service/ApiEndpointService.java create mode 100644 backend-single/src/main/java/com/emotion/service/ApiEndpointSyncRunner.java create mode 100644 backend-single/src/main/java/com/emotion/service/impl/ApiEndpointServiceImpl.java create mode 100644 web-admin/src/api/endpoint.ts create mode 100644 web-admin/src/views/endpoint/EndpointDetailDialog.vue create mode 100644 web-admin/src/views/endpoint/EndpointList.vue diff --git a/backend-single/src/main/java/com/emotion/controller/ApiEndpointController.java b/backend-single/src/main/java/com/emotion/controller/ApiEndpointController.java new file mode 100644 index 0000000..09d61c7 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/ApiEndpointController.java @@ -0,0 +1,65 @@ +package com.emotion.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.emotion.common.PageResult; +import com.emotion.common.Result; +import com.emotion.dto.request.ApiEndpointListRequest; +import com.emotion.dto.response.ApiEndpointDetailResponse; +import com.emotion.dto.response.ApiEndpointItemResponse; +import com.emotion.service.ApiEndpointService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.HashMap; +import java.util.Map; + +/** + * 接口端点控制器 + * + * @author Peanut + * @date 2026-05-23 + */ +@RestController +@RequestMapping("/admin/endpoint") +@Tag(name = "接口管理", description = "接口发现、同步和测试") +public class ApiEndpointController { + + @Autowired + private ApiEndpointService apiEndpointService; + + @Operation(summary = "分页查询接口列表", description = "支持关键词、方法、标签过滤") + @PostMapping("/list") + public Result> list(@Valid @RequestBody ApiEndpointListRequest request) { + IPage page = apiEndpointService.getPage(request); + PageResult result = new PageResult<>(page); + return Result.success(result); + } + + @Operation(summary = "查询接口详情", description = "返回接口详情和参数列表") + @GetMapping("/detail") + public Result detail(@RequestParam String operationId) { + ApiEndpointDetailResponse response = apiEndpointService.getDetail(operationId); + if (response == null) { + return Result.notFound("接口不存在"); + } + return Result.success(response); + } + + @Operation(summary = "手动触发同步", description = "异步执行,返回 taskId") + @PostMapping("/sync") + public Result> sync() { + Map result = new HashMap<>(); + result.put("taskId", "sync-" + System.currentTimeMillis()); + new Thread(() -> { + try { + apiEndpointService.syncFromOpenApi(); + } catch (Exception e) { + // 异常已在 service 层记录 + } + }).start(); + return Result.success("同步任务已提交", result); + } +} diff --git a/backend-single/src/main/java/com/emotion/controller/ApiTestProxyController.java b/backend-single/src/main/java/com/emotion/controller/ApiTestProxyController.java new file mode 100644 index 0000000..1957ff7 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/ApiTestProxyController.java @@ -0,0 +1,141 @@ +package com.emotion.controller; + +import com.emotion.common.Result; +import com.emotion.dto.request.ApiTestProxyRequest; +import com.emotion.dto.response.ApiTestProxyResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import javax.validation.Valid; +import java.net.SocketTimeoutException; +import java.util.HashMap; +import java.util.Map; + +/** + * 接口代理测试控制器 + * 仅允许转发到 /api/* 路径,避免 SSRF + * + * @author Peanut + * @date 2026-05-23 + */ +@RestController +@RequestMapping("/admin/endpoint") +@Tag(name = "接口管理", description = "接口测试代理") +public class ApiTestProxyController { + + private static final Logger log = LoggerFactory.getLogger(ApiTestProxyController.class); + private static final int DEFAULT_TIMEOUT = 30; + private static final int MAX_RAW_BODY_LENGTH = 2000; + + @Value("${server.port:19089}") + private int serverPort; + + @Autowired + private RestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Operation(summary = "代理测试请求", description = "转发请求到本地后端并返回响应") + @PostMapping("/test") + public Result test(@Valid @RequestBody ApiTestProxyRequest request) { + if (!request.getPath().startsWith("/api/")) { + return Result.error("仅允许代理 /api/* 路径的请求"); + } + + String url = "http://127.0.0.1:" + serverPort + request.getPath(); + int timeout = request.getTimeoutSeconds() != null ? request.getTimeoutSeconds() : DEFAULT_TIMEOUT; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + if (request.getHeaders() != null) { + for (Map.Entry entry : request.getHeaders().entrySet()) { + headers.set(entry.getKey(), entry.getValue()); + } + } + + // Append query params to URL if present + if (request.getParams() != null && !request.getParams().isEmpty()) { + StringBuilder sb = new StringBuilder(url); + sb.append("?"); + for (Map.Entry entry : request.getParams().entrySet()) { + sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&"); + } + url = sb.substring(0, sb.length() - 1); + } + + Object body = null; + if (request.getBody() != null && !request.getBody().isBlank()) { + try { + body = objectMapper.readValue(request.getBody(), Object.class); + } catch (Exception e) { + return Result.error("请求体 JSON 格式错误: " + e.getMessage()); + } + } + + HttpMethod httpMethod = HttpMethod.valueOf(request.getMethod().toUpperCase()); + HttpEntity entity = new HttpEntity<>(body, headers); + + long startTime = System.currentTimeMillis(); + ApiTestProxyResponse response = new ApiTestProxyResponse(); + + try { + ResponseEntity rawResponse = restTemplate.exchange(url, httpMethod, entity, String.class); + long duration = System.currentTimeMillis() - startTime; + + response.setStatus(rawResponse.getStatusCodeValue()); + response.setDuration(duration); + + try { + response.setBody(objectMapper.readValue(rawResponse.getBody(), Object.class)); + } catch (Exception e) { + String rawBody = rawResponse.getBody(); + if (rawBody != null && rawBody.length() > MAX_RAW_BODY_LENGTH) { + rawBody = rawBody.substring(0, MAX_RAW_BODY_LENGTH) + "\n... (已截断)"; + } + if (rawBody != null) { + rawBody = rawBody.replace("<", "<").replace(">", ">"); + } + response.setRawBody(rawBody); + } + + Map respHeaders = new HashMap<>(); + for (String key : rawResponse.getHeaders().keySet()) { + respHeaders.put(key, rawResponse.getHeaders().getFirst(key)); + } + response.setHeaders(respHeaders); + + return Result.success(response); + + } catch (ResourceAccessException e) { + long duration = System.currentTimeMillis() - startTime; + if (e.getCause() instanceof SocketTimeoutException) { + return Result.error("代理请求超时(" + timeout + "s),目标接口可能响应过慢或不可达"); + } + return Result.error("代理请求失败: " + e.getMessage()); + } catch (HttpClientErrorException | HttpServerErrorException e) { + long duration = System.currentTimeMillis() - startTime; + response.setStatus(e.getStatusCode().value()); + response.setDuration(duration); + String rawBody = e.getResponseBodyAsString(); + if (rawBody.length() > MAX_RAW_BODY_LENGTH) { + rawBody = rawBody.substring(0, MAX_RAW_BODY_LENGTH) + "\n... (已截断)"; + } + response.setRawBody(rawBody.replace("<", "<").replace(">", ">")); + return Result.success(response); + } catch (Exception e) { + return Result.error("代理请求失败: " + e.getMessage()); + } + } +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/ApiEndpointListRequest.java b/backend-single/src/main/java/com/emotion/dto/request/ApiEndpointListRequest.java new file mode 100644 index 0000000..d515910 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/ApiEndpointListRequest.java @@ -0,0 +1,28 @@ +package com.emotion.dto.request; + +import com.emotion.common.BasePageRequest; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 接口端点分页查询请求 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ApiEndpointListRequest extends BasePageRequest { + + /** + * HTTP 方法过滤:GET/POST/PUT/DELETE/PATCH + */ + private String method; + + /** + * 标签过滤 + */ + private String tags; + + /** + * 是否仅显示废弃接口 + */ + private Integer deprecated; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/ApiTestProxyRequest.java b/backend-single/src/main/java/com/emotion/dto/request/ApiTestProxyRequest.java new file mode 100644 index 0000000..3750e30 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/ApiTestProxyRequest.java @@ -0,0 +1,27 @@ +package com.emotion.dto.request; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import java.util.Map; + +/** + * 代理测试请求参数 + */ +@Data +public class ApiTestProxyRequest { + + @NotBlank(message = "请求方法不能为空") + private String method; + + @NotBlank(message = "接口路径不能为空") + private String path; + + private String body; + + private Map headers; + + private Map params; + + private Integer timeoutSeconds; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/ApiEndpointDetailResponse.java b/backend-single/src/main/java/com/emotion/dto/response/ApiEndpointDetailResponse.java new file mode 100644 index 0000000..89da8d8 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/ApiEndpointDetailResponse.java @@ -0,0 +1,25 @@ +package com.emotion.dto.response; + +import lombok.Data; + +import java.util.List; + +/** + * 接口端点详情响应 + */ +@Data +public class ApiEndpointDetailResponse { + + private String id; + private String path; + private String method; + private String operationId; + private String summary; + private String description; + private String tags; + private Integer deprecated; + private String requestSchema; + private String responseSchema; + private String createTime; + private List params; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/ApiEndpointItemResponse.java b/backend-single/src/main/java/com/emotion/dto/response/ApiEndpointItemResponse.java new file mode 100644 index 0000000..82b320b --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/ApiEndpointItemResponse.java @@ -0,0 +1,18 @@ +package com.emotion.dto.response; + +import lombok.Data; + +/** + * 接口端点列表项 + */ +@Data +public class ApiEndpointItemResponse { + + private String id; + private String path; + private String method; + private String summary; + private String tags; + private Integer deprecated; + private String createTime; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/ApiParamItemResponse.java b/backend-single/src/main/java/com/emotion/dto/response/ApiParamItemResponse.java new file mode 100644 index 0000000..4f8f245 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/ApiParamItemResponse.java @@ -0,0 +1,19 @@ +package com.emotion.dto.response; + +import lombok.Data; + +/** + * 接口参数详情项 + */ +@Data +public class ApiParamItemResponse { + + private String paramType; + private String name; + private Integer required; + private String paramTypeDef; + private String description; + private String defaultValue; + private String enumValues; + private String example; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/ApiTestProxyResponse.java b/backend-single/src/main/java/com/emotion/dto/response/ApiTestProxyResponse.java new file mode 100644 index 0000000..f39a354 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/ApiTestProxyResponse.java @@ -0,0 +1,18 @@ +package com.emotion.dto.response; + +import lombok.Data; + +import java.util.Map; + +/** + * 代理测试响应 + */ +@Data +public class ApiTestProxyResponse { + + private int status; + private Object body; + private Map headers; + private long duration; + private String rawBody; +} diff --git a/backend-single/src/main/java/com/emotion/entity/ApiEndpoint.java b/backend-single/src/main/java/com/emotion/entity/ApiEndpoint.java new file mode 100644 index 0000000..e2c77c9 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/ApiEndpoint.java @@ -0,0 +1,52 @@ +package com.emotion.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.emotion.common.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +/** + * 接口端点实体,继承 BaseEntity 字段 + * + * @author Peanut + * @date 2026-05-23 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@TableName("api_endpoint") +public class ApiEndpoint extends BaseEntity { + + @TableField("path") + private String path; + + @TableField("method") + private String method; + + @TableField("operation_id") + private String operationId; + + @TableField("summary") + private String summary; + + @TableField("description") + private String description; + + @TableField("tags") + private String tags; + + @TableField("deprecated") + private Integer deprecated; + + @TableField("request_schema") + private String requestSchema; + + @TableField("response_schema") + private String responseSchema; +} diff --git a/backend-single/src/main/java/com/emotion/entity/ApiParam.java b/backend-single/src/main/java/com/emotion/entity/ApiParam.java new file mode 100644 index 0000000..ac6d1d2 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/ApiParam.java @@ -0,0 +1,52 @@ +package com.emotion.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.emotion.common.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +/** + * 接口参数实体 + * + * @author Peanut + * @date 2026-05-23 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@TableName("api_param") +public class ApiParam extends BaseEntity { + + @TableField("endpoint_id") + private String endpointId; + + @TableField("param_type") + private String paramType; + + @TableField("name") + private String name; + + @TableField("required") + private Integer required; + + @TableField("param_type_def") + private String paramTypeDef; + + @TableField("description") + private String description; + + @TableField("default_value") + private String defaultValue; + + @TableField("enum_values") + private String enumValues; + + @TableField("example") + private String example; +} diff --git a/backend-single/src/main/java/com/emotion/mapper/ApiEndpointMapper.java b/backend-single/src/main/java/com/emotion/mapper/ApiEndpointMapper.java new file mode 100644 index 0000000..7c43b76 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/ApiEndpointMapper.java @@ -0,0 +1,12 @@ +package com.emotion.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.emotion.entity.ApiEndpoint; +import org.apache.ibatis.annotations.Mapper; + +/** + * 接口端点 Mapper + */ +@Mapper +public interface ApiEndpointMapper extends BaseMapper { +} diff --git a/backend-single/src/main/java/com/emotion/mapper/ApiParamMapper.java b/backend-single/src/main/java/com/emotion/mapper/ApiParamMapper.java new file mode 100644 index 0000000..b9052bb --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/ApiParamMapper.java @@ -0,0 +1,12 @@ +package com.emotion.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.emotion.entity.ApiParam; +import org.apache.ibatis.annotations.Mapper; + +/** + * 接口参数 Mapper + */ +@Mapper +public interface ApiParamMapper extends BaseMapper { +} diff --git a/backend-single/src/main/java/com/emotion/service/ApiEndpointService.java b/backend-single/src/main/java/com/emotion/service/ApiEndpointService.java new file mode 100644 index 0000000..2cf848a --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/ApiEndpointService.java @@ -0,0 +1,30 @@ +package com.emotion.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.emotion.dto.request.ApiEndpointListRequest; +import com.emotion.dto.response.ApiEndpointDetailResponse; +import com.emotion.dto.response.ApiEndpointItemResponse; + +/** + * 接口端点服务 + * + * @author Peanut + * @date 2026-05-23 + */ +public interface ApiEndpointService { + + /** + * 分页查询接口列表 + */ + IPage getPage(ApiEndpointListRequest request); + + /** + * 查询接口详情(含参数) + */ + ApiEndpointDetailResponse getDetail(String operationId); + + /** + * 从 OpenAPI spec 同步接口数据 + */ + void syncFromOpenApi(); +} diff --git a/backend-single/src/main/java/com/emotion/service/ApiEndpointSyncRunner.java b/backend-single/src/main/java/com/emotion/service/ApiEndpointSyncRunner.java new file mode 100644 index 0000000..2bc6e34 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/ApiEndpointSyncRunner.java @@ -0,0 +1,36 @@ +package com.emotion.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +/** + * 启动时自动同步接口数据 + * + * @author Peanut + * @date 2026-05-23 + */ +@Component +public class ApiEndpointSyncRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(ApiEndpointSyncRunner.class); + + @Autowired + private ApiEndpointService apiEndpointService; + + @Async + @Override + public void run(ApplicationArguments args) throws Exception { + log.info("启动同步:开始异步同步接口数据"); + try { + apiEndpointService.syncFromOpenApi(); + log.info("启动同步:接口数据同步完成"); + } catch (Exception e) { + log.warn("启动同步:接口数据同步失败: {}", e.getMessage()); + } + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/ApiEndpointServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/ApiEndpointServiceImpl.java new file mode 100644 index 0000000..5a3266a --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/ApiEndpointServiceImpl.java @@ -0,0 +1,331 @@ +package com.emotion.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.emotion.dto.request.ApiEndpointListRequest; +import com.emotion.dto.response.ApiEndpointDetailResponse; +import com.emotion.dto.response.ApiEndpointItemResponse; +import com.emotion.dto.response.ApiParamItemResponse; +import com.emotion.entity.ApiEndpoint; +import com.emotion.entity.ApiParam; +import com.emotion.mapper.ApiEndpointMapper; +import com.emotion.mapper.ApiParamMapper; +import com.emotion.service.ApiEndpointService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * 接口端点服务实现 + * + * @author Peanut + * @date 2026-05-23 + */ +@Service +public class ApiEndpointServiceImpl implements ApiEndpointService { + + private static final Logger log = LoggerFactory.getLogger(ApiEndpointServiceImpl.class); + private static final int MAX_REF_DEPTH = 10; + + @Autowired + private ApiEndpointMapper endpointMapper; + + @Autowired + private ApiParamMapper paramMapper; + + @Value("${server.port:19089}") + private int serverPort; + + @Autowired + private RestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Override + public IPage getPage(ApiEndpointListRequest request) { + Page page = new Page<>(request.getCurrent(), request.getSize()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (StringUtils.hasText(request.getKeyword())) { + wrapper.and(w -> w.like(ApiEndpoint::getPath, request.getKeyword()) + .or().like(ApiEndpoint::getSummary, request.getKeyword()) + .or().like(ApiEndpoint::getOperationId, request.getKeyword())); + } + if (StringUtils.hasText(request.getMethod())) { + wrapper.eq(ApiEndpoint::getMethod, request.getMethod()); + } + if (StringUtils.hasText(request.getTags())) { + wrapper.like(ApiEndpoint::getTags, request.getTags()); + } + if (request.getDeprecated() != null) { + wrapper.eq(ApiEndpoint::getDeprecated, request.getDeprecated()); + } + + wrapper.orderByDesc(ApiEndpoint::getCreateTime); + + IPage result = endpointMapper.selectPage(page, wrapper); + return result.convert(this::toItemResponse); + } + + @Override + public ApiEndpointDetailResponse getDetail(String operationId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ApiEndpoint::getOperationId, operationId); + ApiEndpoint endpoint = endpointMapper.selectOne(wrapper); + if (endpoint == null) return null; + + ApiEndpointDetailResponse response = toDetailResponse(endpoint); + + LambdaQueryWrapper paramWrapper = new LambdaQueryWrapper<>(); + paramWrapper.eq(ApiParam::getEndpointId, endpoint.getId()); + paramWrapper.orderByAsc(ApiParam::getName); + List params = paramMapper.selectList(paramWrapper); + response.setParams(params.stream().map(this::toParamItemResponse).toList()); + + return response; + } + + @Override + @Transactional + public void syncFromOpenApi() { + String url = "http://127.0.0.1:" + serverPort + "/api/v3/api-docs"; + log.info("开始同步接口数据,URL: {}", url); + + String json; + try { + json = restTemplate.getForObject(url, String.class); + } catch (Exception e) { + log.warn("获取 OpenAPI spec 失败: {}", e.getMessage()); + return; + } + + if (json == null || json.isBlank()) { + log.warn("OpenAPI spec 为空"); + return; + } + + try { + JsonNode root = objectMapper.readTree(json); + JsonNode components = root.path("components"); + JsonNode schemas = components.path("schemas"); + + paramMapper.delete(null); + endpointMapper.delete(null); + log.info("已清空旧接口数据"); + + JsonNode paths = root.path("paths"); + Iterator> pathEntries = paths.fields(); + int count = 0; + + while (pathEntries.hasNext()) { + Map.Entry pathEntry = pathEntries.next(); + String path = pathEntry.getKey(); + JsonNode methods = pathEntry.getValue(); + + for (Iterator it = methods.fieldNames(); it.hasNext(); ) { + String method = it.next().toUpperCase(); + // Skip non-HTTP-method keys (e.g., summary, description at path level) + if (!isHttpMethod(method)) continue; + + JsonNode endpointNode = methods.get(method); + + String operationId = endpointNode.path("operationId").asText(); + if (operationId.isEmpty()) continue; + + ApiEndpoint apiEndpoint = ApiEndpoint.builder() + .path(path) + .method(method) + .operationId(operationId) + .summary(endpointNode.path("summary").asText(null)) + .description(endpointNode.path("description").asText(null)) + .deprecated(endpointNode.path("deprecated").asBoolean(false) ? 1 : 0) + .build(); + + JsonNode tagsNode = endpointNode.path("tags"); + if (tagsNode.isArray() && tagsNode.size() > 0) { + List tagList = new ArrayList<>(); + for (JsonNode t : tagsNode) tagList.add(t.asText()); + apiEndpoint.setTags(String.join(",", tagList)); + } + + // Parse parameters + List paramList = new ArrayList<>(); + JsonNode parameters = endpointNode.path("parameters"); + if (parameters.isArray()) { + for (JsonNode param : parameters) { + ApiParam apiParam = parseParam(param, schemas, 0); + if (apiParam != null) { + paramList.add(apiParam); + } + } + } + + // Parse requestBody schema + JsonNode requestBody = endpointNode.path("requestBody"); + if (!requestBody.isMissingNode()) { + JsonNode content = requestBody.path("content"); + if (!content.isMissingNode()) { + apiEndpoint.setRequestSchema(resolveSchema(content, schemas, 0)); + } + } + + // Parse responses schema + JsonNode responses = endpointNode.path("responses"); + if (!responses.isMissingNode()) { + apiEndpoint.setResponseSchema(responses.toString()); + } + + endpointMapper.insert(apiEndpoint); + + for (ApiParam p : paramList) { + p.setEndpointId(apiEndpoint.getId()); + paramMapper.insert(p); + } + + count++; + } + } + + log.info("同步完成,共同步 {} 个接口", count); + } catch (Exception e) { + log.error("同步接口数据失败", e); + throw new RuntimeException("同步接口数据失败", e); + } + } + + private boolean isHttpMethod(String method) { + return method.equals("GET") || method.equals("POST") || method.equals("PUT") + || method.equals("DELETE") || method.equals("PATCH") + || method.equals("HEAD") || method.equals("OPTIONS"); + } + + private ApiParam parseParam(JsonNode paramNode, JsonNode schemas, int depth) { + if (depth > MAX_REF_DEPTH) return null; + + String name = paramNode.path("name").asText(null); + if (name == null) return null; + + JsonNode schemaNode = paramNode.path("schema"); + String typeDef = schemaNode.path("type").asText(null); + + // Parse enum values if present + String enumValues = null; + JsonNode enumArray = schemaNode.path("enum"); + if (enumArray.isArray() && enumArray.size() > 0) { + List values = new ArrayList<>(); + for (JsonNode v : enumArray) values.add(v.asText()); + enumValues = String.join(",", values); + } + + String defaultValue = schemaNode.path("default").asText(null); + + return ApiParam.builder() + .paramType(paramNode.path("in").asText(null)) + .name(name) + .required(paramNode.path("required").asBoolean(false) ? 1 : 0) + .paramTypeDef(typeDef) + .description(paramNode.path("description").asText(null)) + .defaultValue(defaultValue) + .enumValues(enumValues) + .example(paramNode.path("example").asText(null)) + .build(); + } + + private String resolveSchema(JsonNode content, JsonNode schemas, int depth) { + if (depth > MAX_REF_DEPTH) return "{}"; + + JsonNode appJson = content.path("application/json"); + if (appJson.isMissingNode()) return "{}"; + + JsonNode schema = appJson.path("schema"); + return expandRef(schema, schemas, depth); + } + + private String expandRef(JsonNode node, JsonNode schemas, int depth) { + if (depth > MAX_REF_DEPTH) return "{\"$ref\": \"max depth exceeded\"}"; + + if (node.has("$ref")) { + String ref = node.path("$ref").asText(); + if (ref.startsWith("#/components/schemas/")) { + String schemaName = ref.substring("#/components/schemas/".length()); + JsonNode schemaNode = schemas.path(schemaName); + if (!schemaNode.isMissingNode()) { + return expandRef(schemaNode, schemas, depth + 1); + } + } + return "{\"$ref\": \"" + ref + "\"}"; + } + + if (node.has("properties")) { + JsonNode props = node.path("properties"); + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Iterator> it = props.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + if (!first) sb.append(","); + first = false; + sb.append("\"").append(entry.getKey()).append("\":") + .append(expandRef(entry.getValue(), schemas, depth + 1)); + } + sb.append("}"); + return sb.toString(); + } + + return node.toString(); + } + + private ApiEndpointItemResponse toItemResponse(ApiEndpoint e) { + ApiEndpointItemResponse r = new ApiEndpointItemResponse(); + r.setId(e.getId()); + r.setPath(e.getPath()); + r.setMethod(e.getMethod()); + r.setSummary(e.getSummary()); + r.setTags(e.getTags()); + r.setDeprecated(e.getDeprecated()); + r.setCreateTime(e.getCreateTime() != null ? e.getCreateTime().toString() : null); + return r; + } + + private ApiEndpointDetailResponse toDetailResponse(ApiEndpoint e) { + ApiEndpointDetailResponse r = new ApiEndpointDetailResponse(); + r.setId(e.getId()); + r.setPath(e.getPath()); + r.setMethod(e.getMethod()); + r.setOperationId(e.getOperationId()); + r.setSummary(e.getSummary()); + r.setDescription(e.getDescription()); + r.setTags(e.getTags()); + r.setDeprecated(e.getDeprecated()); + r.setRequestSchema(e.getRequestSchema()); + r.setResponseSchema(e.getResponseSchema()); + r.setCreateTime(e.getCreateTime() != null ? e.getCreateTime().toString() : null); + return r; + } + + private ApiParamItemResponse toParamItemResponse(ApiParam p) { + ApiParamItemResponse r = new ApiParamItemResponse(); + r.setParamType(p.getParamType()); + r.setName(p.getName()); + r.setRequired(p.getRequired()); + r.setParamTypeDef(p.getParamTypeDef()); + r.setDescription(p.getDescription()); + r.setDefaultValue(p.getDefaultValue()); + r.setEnumValues(p.getEnumValues()); + r.setExample(p.getExample()); + return r; + } +} diff --git a/web-admin/src/api/endpoint.ts b/web-admin/src/api/endpoint.ts new file mode 100644 index 0000000..04d6b9a --- /dev/null +++ b/web-admin/src/api/endpoint.ts @@ -0,0 +1,92 @@ +import request from '@/utils/request' + +export interface ApiEndpointItem { + id: string + path: string + method: string + summary: string + tags: string + deprecated: number + createTime: string + operationId: string +} + +export interface ApiParamItem { + paramType: string + name: string + required: number + paramTypeDef: string + description: string + defaultValue: string + enumValues: string + example: string +} + +export interface ApiEndpointDetail { + id: string + path: string + method: string + operationId: string + summary: string + description: string + tags: string + deprecated: number + requestSchema: string + responseSchema: string + createTime: string + params: ApiParamItem[] +} + +export interface ApiEndpointListRequest { + current: number + size: number + keyword?: string + method?: string + tags?: string + deprecated?: number +} + +export interface ApiTestProxyRequest { + method: string + path: string + body?: string + headers?: Record + params?: Record + timeoutSeconds?: number +} + +export interface ApiTestProxyResponse { + status: number + body?: any + headers?: Record + duration: number + rawBody?: string +} + +/** + * 分页查询接口列表 + */ +export function getEndpointList(data: ApiEndpointListRequest) { + return request.post('/admin/endpoint/list', data) +} + +/** + * 查询接口详情 + */ +export function getEndpointDetail(operationId: string) { + return request.get('/admin/endpoint/detail', { params: { operationId } }) +} + +/** + * 手动触发同步 + */ +export function syncEndpoints() { + return request.post('/admin/endpoint/sync') +} + +/** + * 代理测试请求 + */ +export function testEndpoint(data: ApiTestProxyRequest) { + return request.post('/admin/endpoint/test', data) +} diff --git a/web-admin/src/config/menu.ts b/web-admin/src/config/menu.ts index f1f2751..dbfadc9 100644 --- a/web-admin/src/config/menu.ts +++ b/web-admin/src/config/menu.ts @@ -45,6 +45,10 @@ export const menuConfig: MenuItem[] = [ { path: '/tools/api-tester', title: 'API接口调用' + }, + { + path: '/endpoint/list', + title: '接口管理' } ] } diff --git a/web-admin/src/router/index.ts b/web-admin/src/router/index.ts index 5a26313..58b4662 100644 --- a/web-admin/src/router/index.ts +++ b/web-admin/src/router/index.ts @@ -107,6 +107,12 @@ const routes: RouteRecordRaw[] = [ name: 'ApiTester', component: () => import('@/views/tools/ApiTester.vue'), meta: { title: 'API接口调用' } + }, + { + path: 'list', + name: 'EndpointList', + component: () => import('@/views/endpoint/EndpointList.vue'), + meta: { title: '接口管理' } } ] }, diff --git a/web-admin/src/views/endpoint/EndpointDetailDialog.vue b/web-admin/src/views/endpoint/EndpointDetailDialog.vue new file mode 100644 index 0000000..66a4192 --- /dev/null +++ b/web-admin/src/views/endpoint/EndpointDetailDialog.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/web-admin/src/views/endpoint/EndpointList.vue b/web-admin/src/views/endpoint/EndpointList.vue new file mode 100644 index 0000000..9c458be --- /dev/null +++ b/web-admin/src/views/endpoint/EndpointList.vue @@ -0,0 +1,200 @@ + + + + +