feat: 添加接口管理功能(后端OpenAPI解析入库+前端列表/详情/测试)
- 新增 ApiEndpoint/ApiParam 实体和 Mapper - 新增 DTO 层(分页查询请求、列表项、详情项、参数项、代理测试请求/响应) - 新增 ApiEndpointService 含 OpenAPI JSON 解析、\ 展开(最大10层)、分页查询 - 新增 ApiEndpointSyncRunner 启动时异步同步 - 新增 ApiEndpointController 分页/详情/手动同步接口 - 新增 ApiTestProxyController 代理测试接口(SSRF 防护) - 前端新增接口列表页、详情弹窗(含测试面板、Token 来源选择) - 前端新增菜单和路由
This commit is contained in:
@@ -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<PageResult<ApiEndpointItemResponse>> list(@Valid @RequestBody ApiEndpointListRequest request) {
|
||||
IPage<ApiEndpointItemResponse> page = apiEndpointService.getPage(request);
|
||||
PageResult<ApiEndpointItemResponse> result = new PageResult<>(page);
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "查询接口详情", description = "返回接口详情和参数列表")
|
||||
@GetMapping("/detail")
|
||||
public Result<ApiEndpointDetailResponse> 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<Map<String, String>> sync() {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("taskId", "sync-" + System.currentTimeMillis());
|
||||
new Thread(() -> {
|
||||
try {
|
||||
apiEndpointService.syncFromOpenApi();
|
||||
} catch (Exception e) {
|
||||
// 异常已在 service 层记录
|
||||
}
|
||||
}).start();
|
||||
return Result.success("同步任务已提交", result);
|
||||
}
|
||||
}
|
||||
@@ -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<ApiTestProxyResponse> 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<String, String> 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<String, String> 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<Object> entity = new HttpEntity<>(body, headers);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
ApiTestProxyResponse response = new ApiTestProxyResponse();
|
||||
|
||||
try {
|
||||
ResponseEntity<String> 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<String, String> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String, String> headers;
|
||||
|
||||
private Map<String, String> params;
|
||||
|
||||
private Integer timeoutSeconds;
|
||||
}
|
||||
@@ -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<ApiParamItemResponse> params;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String, String> headers;
|
||||
private long duration;
|
||||
private String rawBody;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ApiEndpoint> {
|
||||
}
|
||||
@@ -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<ApiParam> {
|
||||
}
|
||||
@@ -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<ApiEndpointItemResponse> getPage(ApiEndpointListRequest request);
|
||||
|
||||
/**
|
||||
* 查询接口详情(含参数)
|
||||
*/
|
||||
ApiEndpointDetailResponse getDetail(String operationId);
|
||||
|
||||
/**
|
||||
* 从 OpenAPI spec 同步接口数据
|
||||
*/
|
||||
void syncFromOpenApi();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ApiEndpointItemResponse> getPage(ApiEndpointListRequest request) {
|
||||
Page<ApiEndpoint> page = new Page<>(request.getCurrent(), request.getSize());
|
||||
LambdaQueryWrapper<ApiEndpoint> 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<ApiEndpoint> result = endpointMapper.selectPage(page, wrapper);
|
||||
return result.convert(this::toItemResponse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiEndpointDetailResponse getDetail(String operationId) {
|
||||
LambdaQueryWrapper<ApiEndpoint> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(ApiEndpoint::getOperationId, operationId);
|
||||
ApiEndpoint endpoint = endpointMapper.selectOne(wrapper);
|
||||
if (endpoint == null) return null;
|
||||
|
||||
ApiEndpointDetailResponse response = toDetailResponse(endpoint);
|
||||
|
||||
LambdaQueryWrapper<ApiParam> paramWrapper = new LambdaQueryWrapper<>();
|
||||
paramWrapper.eq(ApiParam::getEndpointId, endpoint.getId());
|
||||
paramWrapper.orderByAsc(ApiParam::getName);
|
||||
List<ApiParam> 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<Map.Entry<String, JsonNode>> pathEntries = paths.fields();
|
||||
int count = 0;
|
||||
|
||||
while (pathEntries.hasNext()) {
|
||||
Map.Entry<String, JsonNode> pathEntry = pathEntries.next();
|
||||
String path = pathEntry.getKey();
|
||||
JsonNode methods = pathEntry.getValue();
|
||||
|
||||
for (Iterator<String> 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<String> tagList = new ArrayList<>();
|
||||
for (JsonNode t : tagsNode) tagList.add(t.asText());
|
||||
apiEndpoint.setTags(String.join(",", tagList));
|
||||
}
|
||||
|
||||
// Parse parameters
|
||||
List<ApiParam> 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<String> 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<Map.Entry<String, JsonNode>> it = props.fields(); it.hasNext(); ) {
|
||||
Map.Entry<String, JsonNode> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user