# 接口管理功能实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 web-admin 新增接口管理菜单,后端启动时自动解析 OpenAPI JSON 入库,前端支持分页查询、详情查看、接口测试调用 **Architecture:** 后端 ApplicationRunner 异步解析 `/api/v3/api-docs` 写入数据库,Controller 提供分页查询/详情/同步/代理测试接口,前端从数据库查询接口数据并生成测试表单 **Tech Stack:** Spring Boot 2.7, MyBatis-Plus, Jackson, OpenAPI 3, Vue 3, Element Plus, Axios --- ### Task 1: 数据库实体 + Mapper 层 **Files:** - Create: `backend-single/src/main/java/com/emotion/entity/ApiEndpoint.java` - Create: `backend-single/src/main/java/com/emotion/entity/ApiParam.java` - Create: `backend-single/src/main/java/com/emotion/mapper/ApiEndpointMapper.java` - Create: `backend-single/src/main/java/com/emotion/mapper/ApiParamMapper.java` - [ ] **Step 1: 创建 ApiEndpoint 实体** ```java package com.emotion.entity; import com.baomidou.mybatisplus.annotation.TableName; import com.emotion.common.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; import lombok.experimental.SuperBuilder; /** * 接口端点实体,继承 BaseEntity 字段 */ @Data @EqualsAndHashCode(callSuper = true) @SuperBuilder @NoArgsConstructor @AllArgsConstructor @TableName("api_endpoint") public class ApiEndpoint extends BaseEntity { @com.baomidou.mybatisplus.annotation.TableField("path") private String path; @com.baomidou.mybatisplus.annotation.TableField("method") private String method; @com.baomidou.mybatisplus.annotation.TableField("operation_id") private String operationId; @com.baomidou.mybatisplus.annotation.TableField("summary") private String summary; @com.baomidou.mybatisplus.annotation.TableField("description") private String description; @com.baomidou.mybatisplus.annotation.TableField("tags") private String tags; @com.baomidou.mybatisplus.annotation.TableField("deprecated") private Integer deprecated; @com.baomidou.mybatisplus.annotation.TableField("request_schema") private String requestSchema; @com.baomidou.mybatisplus.annotation.TableField("response_schema") private String responseSchema; } ``` - [ ] **Step 2: 创建 ApiParam 实体** ```java package com.emotion.entity; import com.baomidou.mybatisplus.annotation.TableName; import com.emotion.common.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; import lombok.experimental.SuperBuilder; /** * 接口参数实体 */ @Data @EqualsAndHashCode(callSuper = true) @SuperBuilder @NoArgsConstructor @AllArgsConstructor @TableName("api_param") public class ApiParam extends BaseEntity { @com.baomidou.mybatisplus.annotation.TableField("endpoint_id") private String endpointId; @com.baomidou.mybatisplus.annotation.TableField("param_type") private String paramType; @com.baomidou.mybatisplus.annotation.TableField("name") private String name; @com.baomidou.mybatisplus.annotation.TableField("required") private Integer required; @com.baomidou.mybatisplus.annotation.TableField("param_type_def") private String paramTypeDef; @com.baomidou.mybatisplus.annotation.TableField("description") private String description; @com.baomidou.mybatisplus.annotation.TableField("default_value") private String defaultValue; @com.baomidou.mybatisplus.annotation.TableField("enum_values") private String enumValues; @com.baomidou.mybatisplus.annotation.TableField("example") private String example; } ``` - [ ] **Step 3: 创建 Mapper 接口** ```java // ApiEndpointMapper.java package com.emotion.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.emotion.entity.ApiEndpoint; import org.apache.ibatis.annotations.Mapper; @Mapper public interface ApiEndpointMapper extends BaseMapper { } // ApiParamMapper.java package com.emotion.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.emotion.entity.ApiParam; import org.apache.ibatis.annotations.Mapper; @Mapper public interface ApiParamMapper extends BaseMapper { } ``` - [ ] **Step 4: 提交** ```bash git add backend-single/src/main/java/com/emotion/entity/ApiEndpoint.java git add backend-single/src/main/java/com/emotion/entity/ApiParam.java git add backend-single/src/main/java/com/emotion/mapper/ApiEndpointMapper.java git add backend-single/src/main/java/com/emotion/mapper/ApiParamMapper.java git commit -m "feat: 添加接口端点实体和Mapper" ``` --- ### Task 2: DTO 层 — Request/Response **Files:** - Create: `backend-single/src/main/java/com/emotion/dto/request/ApiEndpointListRequest.java` - Create: `backend-single/src/main/java/com/emotion/dto/response/ApiEndpointItemResponse.java` - Create: `backend-single/src/main/java/com/emotion/dto/response/ApiEndpointDetailResponse.java` - Create: `backend-single/src/main/java/com/emotion/dto/response/ApiParamItemResponse.java` - Create: `backend-single/src/main/java/com/emotion/dto/request/ApiTestProxyRequest.java` - [ ] **Step 1: 创建 ApiEndpointListRequest(分页查询入参)** ```java package com.emotion.dto.request; 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; } ``` - [ ] **Step 2: 创建 ApiParamItemResponse(参数详情项)** ```java 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; } ``` - [ ] **Step 3: 创建 ApiEndpointItemResponse(列表项)** ```java 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; } ``` - [ ] **Step 4: 创建 ApiEndpointDetailResponse(详情+参数)** ```java 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; } ``` - [ ] **Step 5: 创建 ApiTestProxyRequest(代理测试入参)** ```java package com.emotion.dto.request; import lombok.Data; import javax.validation.constraints.NotBlank; /** * 代理测试请求参数 */ @Data public class ApiTestProxyRequest { @NotBlank(message = "请求方法不能为空") private String method; @NotBlank(message = "接口路径不能为空") private String path; private String body; private java.util.Map headers; private java.util.Map params; private Integer timeoutSeconds; } ``` - [ ] **Step 6: 提交** ```bash git add backend-single/src/main/java/com/emotion/dto/request/ApiEndpointListRequest.java git add backend-single/src/main/java/com/emotion/dto/response/ApiEndpointItemResponse.java git add backend-single/src/main/java/com/emotion/dto/response/ApiEndpointDetailResponse.java git add backend-single/src/main/java/com/emotion/dto/response/ApiParamItemResponse.java git add backend-single/src/main/java/com/emotion/dto/request/ApiTestProxyRequest.java git commit -m "feat: 添加接口管理DTO" ``` --- ### Task 3: Service 层 — 核心解析与同步逻辑 **Files:** - Create: `backend-single/src/main/java/com/emotion/service/ApiEndpointService.java` - Create: `backend-single/src/main/java/com/emotion/service/impl/ApiEndpointServiceImpl.java` - [ ] **Step 1: 创建 Service 接口** ```java package com.emotion.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.emotion.common.PageResult; import com.emotion.dto.request.ApiEndpointListRequest; import com.emotion.dto.response.ApiEndpointDetailResponse; import com.emotion.dto.response.ApiEndpointItemResponse; /** * 接口端点服务 */ public interface ApiEndpointService { /** * 分页查询接口列表 */ IPage getPage(ApiEndpointListRequest request); /** * 查询接口详情(含参数) */ ApiEndpointDetailResponse getDetail(String operationId); /** * 从 OpenAPI spec 同步接口数据 */ void syncFromOpenApi(); } ``` - [ ] **Step 2: 创建 ServiceImpl(含 OpenAPI 解析逻辑)** ```java 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.common.PageResult; 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; /** * 接口端点服务实现 */ @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(); JsonNode endpoint = methods.get(method); String operationId = endpoint.path("operationId").asText(); if (operationId.isEmpty()) continue; ApiEndpoint apiEndpoint = ApiEndpoint.builder() .path(path) .method(method) .operationId(operationId) .summary(endpoint.path("summary").asText(null)) .description(endpoint.path("description").asText(null)) .deprecated(endpoint.path("deprecated").asBoolean(false) ? 1 : 0) .build(); // 解析 tags JsonNode tagsNode = endpoint.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)); } // 解析 parameters List paramList = new ArrayList<>(); JsonNode parameters = endpoint.path("parameters"); if (parameters.isArray()) { for (JsonNode param : parameters) { ApiParam apiParam = parseParam(param, schemas, 0); if (apiParam != null) { // 延迟设置 endpointId paramList.add(apiParam); } } } // 解析 requestBody schema JsonNode requestBody = endpoint.path("requestBody"); if (!requestBody.isMissingNode()) { JsonNode content = requestBody.path("content"); if (!content.isMissingNode()) { apiEndpoint.setRequestSchema(resolveSchema(content, schemas, 0)); } } // 解析 responses schema JsonNode responses = endpoint.path("responses"); if (!responses.isMissingNode()) { apiEndpoint.setResponseSchema(responses.toString()); } endpointMapper.insert(apiEndpoint); // 设置 endpointId 并保存参数 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 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; return ApiParam.builder() .paramType(paramNode.path("in").asText(null)) .name(name) .required(paramNode.path("required").asBoolean(false) ? 1 : 0) .paramTypeDef(paramNode.path("schema").path("type").asText(null)) .description(paramNode.path("description").asText(null)) .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(); // 解析 #/components/schemas/Xxx 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; } } ``` - [ ] **Step 3: 提交** ```bash git add backend-single/src/main/java/com/emotion/service/ApiEndpointService.java git add backend-single/src/main/java/com/emotion/service/impl/ApiEndpointServiceImpl.java git commit -m "feat: 添加接口端点Service层" ``` --- ### Task 4: ApplicationRunner — 启动自动同步 **Files:** - Create: `backend-single/src/main/java/com/emotion/service/ApiEndpointSyncRunner.java` - [ ] **Step 1: 创建 SyncRunner** ```java 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; /** * 启动时自动同步接口数据 */ @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("ApplicationRunner: 开始异步同步接口数据"); try { apiEndpointService.syncFromOpenApi(); log.info("ApplicationRunner: 接口数据同步完成"); } catch (Exception e) { log.warn("ApplicationRunner: 接口数据同步失败: {}", e.getMessage()); } } } ``` - [ ] **Step 2: 提交** ```bash git add backend-single/src/main/java/com/emotion/service/ApiEndpointSyncRunner.java git commit -m "feat: 添加启动自动同步接口数据" ``` --- ### Task 5: Controller 层 — 分页查询 + 详情 + 手动同步 **Files:** - Create: `backend-single/src/main/java/com/emotion/controller/ApiEndpointController.java` - [ ] **Step 1: 创建 Controller** 注意:路由前缀为 `/admin/endpoint`,无 `/api` 前缀。`@RequestParam` 传递参数。 ```java 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.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; /** * 接口端点控制器 */ @RestController @RequestMapping("/admin/endpoint") @Tag(name = "接口管理", description = "接口发现、同步和测试") public class ApiEndpointController { @Autowired private ApiEndpointService apiEndpointService; @Operation(summary = "分页查询接口列表", description = "支持关键词、方法、标签过滤") @PostMapping("/list") public Result> list(@Validated @RequestBody ApiEndpointListRequest request) { IPage page = apiEndpointService.getPage(request); PageResult result = new PageResult<>( page.getRecords(), page.getCurrent(), page.getSize(), page.getTotal() ); 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); } } ``` - [ ] **Step 2: 提交** ```bash git add backend-single/src/main/java/com/emotion/controller/ApiEndpointController.java git commit -m "feat: 添加接口管理Controller" ``` --- ### Task 6: Controller 层 — 代理测试接口 **Files:** - Create: `backend-single/src/main/java/com/emotion/controller/ApiTestProxyController.java` - Create: `backend-single/src/main/java/com/emotion/dto/response/ApiTestProxyResponse.java` - [ ] **Step 1: 创建 ApiTestProxyResponse** ```java 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; } ``` - [ ] **Step 2: 创建 ApiTestProxyController** ```java package com.emotion.controller; import com.emotion.common.Result; import com.emotion.dto.request.ApiTestProxyRequest; import com.emotion.dto.response.ApiTestProxyResponse; 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.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.RestTemplate; import org.springframework.web.client.ResourceAccessException; import java.util.HashMap; import java.util.Map; /** * 接口代理测试控制器 * 仅允许转发到 /api/* 路径,避免 SSRF */ @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; @Operation(summary = "代理测试请求", description = "转发请求到本地后端并返回响应") @PostMapping("/test") public Result test(@Validated @RequestBody ApiTestProxyRequest request) { // SSRF 防护:仅允许 /api/* 路径 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()); } } // 构建请求体 Object body = null; if (request.getBody() != null && !request.getBody().isBlank()) { try { body = com.fasterxml.jackson.databind.ObjectMapper.class .getDeclaredMethod("readValue", String.class, Class.class) .invoke(new com.fasterxml.jackson.databind.ObjectMapper(), 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); // 尝试解析 JSON try { com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); response.setBody(mapper.readValue(rawResponse.getBody(), Object.class)); } catch (Exception e) { // 非 JSON 响应 String rawBody = rawResponse.getBody(); if (rawBody != null && rawBody.length() > MAX_RAW_BODY_LENGTH) { rawBody = rawBody.substring(0, MAX_RAW_BODY_LENGTH) + "\n... (已截断)"; } // HTML 转义 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) { if (e.getCause() instanceof java.net.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()); } } } ``` - [ ] **Step 3: 提交** ```bash git add backend-single/src/main/java/com/emotion/controller/ApiTestProxyController.java git add backend-single/src/main/java/com/emotion/dto/response/ApiTestProxyResponse.java git commit -m "feat: 添加接口代理测试Controller" ``` --- ### Task 7: 编译验证后端 - [ ] **Step 1: 编译验证** ```bash cd backend-single mvn clean install -pl :backend-single -am -DskipTests ``` 预期:BUILD SUCCESS - [ ] **Step 2: 提交(如有新增/修改文件)** ```bash git add -A git commit -m "chore: 编译验证通过" ``` --- ### Task 8: 前端 — API 层 + 菜单 + 路由 **Files:** - Create: `web-admin/src/api/endpoint.ts` - Modify: `web-admin/src/config/menu.ts` — 添加 "接口管理" 子菜单 - Modify: `web-admin/src/router/index.ts` — 添加路由 - [ ] **Step 1: 创建 API 接口文件** ```typescript // web-admin/src/api/endpoint.ts import request from '@/utils/request' export interface ApiEndpointItem { id: string path: string method: string summary: string tags: string deprecated: number createTime: 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) } ``` - [ ] **Step 2: 修改菜单配置** 在 `web-admin/src/config/menu.ts` 的 `tools` 分组下添加 "接口管理": ```typescript // 在 path: '/tools' 的 children 数组中添加: { path: '/endpoint', title: '接口管理' } ``` 具体修改:将 menuConfig 中 tools 的 children 改为: ```typescript children: [ { path: '/tools/api-tester', title: 'API接口调用' }, { path: '/endpoint', title: '接口管理' } ] ``` - [ ] **Step 3: 修改路由配置** 在 `web-admin/src/router/index.ts` 中添加路由: ```typescript { path: '/endpoint', component: Layout, redirect: '/endpoint/list', meta: { title: '接口管理', icon: 'Tools' }, children: [ { path: 'list', name: 'EndpointList', component: () => import('@/views/endpoint/EndpointList.vue'), meta: { title: '接口管理' } } ] } ``` 放在 `/tools` 路由之后,404 路由之前。 - [ ] **Step 4: 提交** ```bash git add web-admin/src/api/endpoint.ts git add web-admin/src/config/menu.ts git add web-admin/src/router/index.ts git commit -m "feat: 添加接口管理前端路由和菜单" ``` --- ### Task 9: 前端 — 接口列表页 **Files:** - Create: `web-admin/src/views/endpoint/EndpointList.vue` - [ ] **Step 1: 创建列表页** ```vue ``` - [ ] **Step 2: 提交** ```bash git add web-admin/src/views/endpoint/EndpointList.vue git commit -m "feat: 添加接口列表页" ``` --- ### Task 10: 前端 — 详情弹窗 + 测试面板 **Files:** - Create: `web-admin/src/views/endpoint/EndpointDetailDialog.vue` - [ ] **Step 1: 创建详情弹窗(含测试标签页)** ```vue ``` - [ ] **Step 2: 提交** ```bash git add web-admin/src/views/endpoint/EndpointDetailDialog.vue git commit -m "feat: 添加接口详情弹窗和测试面板" ``` --- ### Task 11: 前端验证 - [ ] **Step 1: 启动 web-admin 开发服务器** 先检查 5174 端口是否被占用: ```powershell netstat -ano | findstr "5174" ``` 如果被占用,先终止进程: ```powershell taskkill /F /PID ``` 然后启动: ```bash cd web-admin npm run dev ``` - [ ] **Step 2: 浏览器验证** 访问管理后台 `http://localhost:5174/emotion-museum-admin/`,登录后: 1. 导航到 **开发工具 > 接口管理** 2. 确认页面正常加载,无 console 错误 3. 点击"手动同步"按钮,等待数据加载 4. 确认接口列表展示正常 5. 点击任意接口的"详情"按钮 6. 确认详情标签页展示接口信息和参数列表 7. 切换到"测试"标签页 8. 确认 Token 来源选择、参数表单正常 9. 选择一个简单 GET 接口发送测试请求 10. 确认响应结果正常展示 --- ### Task 12: 提交完成 - [ ] **Step 1: 检查所有改动** ```bash git status ``` - [ ] **Step 2: 提交** ```bash git add -A git commit -m "feat: 完成接口管理功能(后端解析入库+前端列表/详情/测试)" ```