Files
happy-life-star/docs/superpowers/plans/2026-05-23-api-endpoint-management.md
T
peanut a4c99b9b0b feat: 接口管理功能 - 分页查询、详情查看、测试代理
- 后端:OpenAPI spec 解析同步、接口分页查询、代理测试(SSRF防护)
- 前端:接口列表页、详情对话框(详情/测试双标签)、Token来源选择
- 服务启动自动同步接口数据,支持手动触发同步
- 测试代理路径修复:自动添加 /api 前缀以匹配后端 SSRF 校验

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:19:08 +08:00

1685 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 接口管理功能实现计划
> **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<ApiEndpoint> {
}
// 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<ApiParam> {
}
```
- [ ] **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<ApiParamItemResponse> 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<String, String> headers;
private java.util.Map<String, String> 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<ApiEndpointItemResponse> 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<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();
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<String> tagList = new ArrayList<>();
for (JsonNode t : tagsNode) tagList.add(t.asText());
apiEndpoint.setTags(String.join(",", tagList));
}
// 解析 parameters
List<ApiParam> 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<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;
}
}
```
- [ ] **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<PageResult<ApiEndpointItemResponse>> list(@Validated @RequestBody ApiEndpointListRequest request) {
IPage<ApiEndpointItemResponse> page = apiEndpointService.getPage(request);
PageResult<ApiEndpointItemResponse> result = new PageResult<>(
page.getRecords(), page.getCurrent(), page.getSize(), page.getTotal()
);
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);
}
}
```
- [ ] **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<String, String> 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<ApiTestProxyResponse> 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<String, String> 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<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);
// 尝试解析 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("<", "&lt;").replace(">", "&gt;");
}
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) {
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("<", "&lt;").replace(">", "&gt;"));
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<string, string>
params?: Record<string, string>
timeoutSeconds?: number
}
export interface ApiTestProxyResponse {
status: number
body?: any
headers?: Record<string, string>
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
<template>
<div class="endpoint-list">
<div class="page-header">
<h2>接口管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleSync" :loading="syncing">
<el-icon><Refresh /></el-icon> 手动同步
</el-button>
</div>
</div>
<!-- 搜索栏 -->
<el-card class="search-card">
<el-form :model="searchForm" inline>
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="路径/简述/operationId"
clearable
style="width: 200px"
@clear="handleSearch"
/>
</el-form-item>
<el-form-item label="方法">
<el-select v-model="searchForm.method" placeholder="全部" clearable style="width: 100px" @change="handleSearch">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
<el-option label="PATCH" value="PATCH" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 接口列表 -->
<el-card class="table-card">
<el-table :data="tableData" v-loading="loading" stripe>
<el-table-column label="方法" width="90" align="center">
<template #default="{ row }">
<el-tag :type="getMethodType(row.method)" size="small">{{ row.method }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="300" show-overflow-tooltip />
<el-table-column prop="summary" label="简述" min-width="200" show-overflow-tooltip />
<el-table-column prop="tags" label="标签" min-width="150" show-overflow-tooltip />
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.deprecated === 1" type="danger" size="small">废弃</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</el-card>
<!-- 详情弹窗 -->
<EndpointDetailDialog v-model="detailVisible" :endpoint="selectedEndpoint" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { getEndpointList, syncEndpoints, type ApiEndpointItem, type ApiEndpointDetail } from '@/api/endpoint'
import EndpointDetailDialog from './EndpointDetailDialog.vue'
const loading = ref(false)
const syncing = ref(false)
const detailVisible = ref(false)
const selectedEndpoint = ref<ApiEndpointDetail | null>(null)
const searchForm = reactive({
keyword: '',
method: '',
tags: ''
})
const tableData = ref<ApiEndpointItem[]>([])
const pagination = reactive({
current: 1,
size: 20,
total: 0
})
const loadData = async () => {
loading.value = true
try {
const res: any = await getEndpointList({
current: pagination.current,
size: pagination.size,
keyword: searchForm.keyword || undefined,
method: searchForm.method || undefined,
tags: searchForm.tags || undefined
})
tableData.value = res.data.records || []
pagination.total = res.data.total || 0
} catch (e: any) {
ElMessage.error('加载失败: ' + e.message)
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const handleReset = () => {
searchForm.keyword = ''
searchForm.method = ''
searchForm.tags = ''
handleSearch()
}
const handleSync = async () => {
syncing.value = true
try {
await syncEndpoints()
ElMessage.success('同步任务已提交')
setTimeout(() => loadData(), 3000)
} catch (e: any) {
ElMessage.error('同步失败: ' + e.message)
} finally {
syncing.value = false
}
}
const showDetail = (row: ApiEndpointItem) => {
selectedEndpoint.value = null
detailVisible.value = true
// 加载详情
import('@/api/endpoint').then(({ getEndpointDetail }) => {
getEndpointDetail(row.operationId || '').then((res: any) => {
selectedEndpoint.value = res.data
}).catch(() => {
ElMessage.error('加载详情失败')
})
})
}
const getMethodType = (method: string): string => {
const types: Record<string, string> = {
GET: 'success',
POST: 'primary',
PUT: 'warning',
DELETE: 'danger',
PATCH: 'info'
}
return types[method] || 'info'
}
onMounted(() => loadData())
</script>
<style scoped lang="scss">
.endpoint-list {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
color: var(--el-text-color-primary);
}
}
.search-card {
margin-bottom: 16px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
}
</style>
```
- [ ] **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
<template>
<el-dialog v-model="visible" :title="'接口详情'" width="800px" destroy-on-close>
<el-tabs v-model="activeTab">
<!-- 详情标签 -->
<el-tab-pane label="详情" name="detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="方法">
<el-tag :type="getMethodType(detail?.method || '')">{{ detail?.method }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="路径">{{ detail?.path }}</el-descriptions-item>
<el-descriptions-item label="Operation ID">{{ detail?.operationId }}</el-descriptions-item>
<el-descriptions-item label="简述">{{ detail?.summary || '-' }}</el-descriptions-item>
<el-descriptions-item label="标签" :span="2">{{ detail?.tags || '-' }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ detail?.description || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 参数列表 -->
<h4 v-if="detail?.params && detail.params.length > 0" style="margin-top: 20px">参数列表</h4>
<el-table :data="detail?.params || []" border size="small" style="margin-top: 8px">
<el-table-column prop="paramType" label="类型" width="80" />
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="required" label="必填" width="60" align="center">
<template #default="{ row }">
<el-tag v-if="row.required === 1" type="danger" size="small"></el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="paramTypeDef" label="数据类型" width="100" />
<el-table-column prop="description" label="描述" min-width="150" />
<el-table-column prop="example" label="示例" min-width="120" />
</el-table>
</el-tab-pane>
<!-- 测试标签 -->
<el-tab-pane label="测试" name="test">
<!-- Token 来源 -->
<el-card class="token-card" style="margin-bottom: 16px">
<template #header>Token 来源</template>
<el-radio-group v-model="tokenSource">
<el-radio value="admin">使用当前管理员Token</el-radio>
<el-radio value="manual">手动输入</el-radio>
</el-radio-group>
<el-input
v-if="tokenSource === 'manual'"
v-model="manualToken"
placeholder="Bearer eyJhbGci..."
style="margin-top: 8px"
/>
</el-card>
<!-- 参数表单 -->
<el-card style="margin-bottom: 16px">
<template #header>参数配置</template>
<el-form :model="testForm" label-width="80px">
<el-form-item label="请求路径">
<el-input v-model="testForm.path" disabled />
</el-form-item>
<el-form-item label="请求方法">
<el-input v-model="testForm.method" disabled />
</el-form-item>
<el-form-item v-for="param in (detail?.params || [])" :key="param.name" :label="param.name">
<el-input v-model="testForm.params[param.name]" :placeholder="param.description || param.example || ''" />
</el-form-item>
<el-form-item label="请求体">
<el-input v-model="testForm.body" type="textarea" :rows="5" placeholder="JSON 格式" />
</el-form-item>
</el-form>
</el-card>
<el-button type="primary" @click="handleTest" :loading="testing">发送请求</el-button>
<!-- 响应结果 -->
<el-card v-if="testResult" style="margin-top: 16px">
<template #header>
<div style="display: flex; justify-content: space-between">
<span>响应结果</span>
<div>
<el-tag :type="testResult.status >= 200 && testResult.status < 300 ? 'success' : 'danger'">
{{ testResult.status }}
</el-tag>
<el-tag type="info" style="margin-left: 8px">{{ testResult.duration }}ms</el-tag>
</div>
</div>
</template>
<pre class="response-body">{{ testResult.display }}</pre>
</el-card>
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { testEndpoint, type ApiEndpointDetail } from '@/api/endpoint'
const props = defineProps<{
modelValue: boolean
endpoint: ApiEndpointDetail | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val)
})
const activeTab = ref('detail')
const tokenSource = ref('admin')
const manualToken = ref('')
const testing = ref(false)
const testResult = ref<{ status: number; duration: number; display: string } | null>(null)
const testForm = ref({
path: '',
method: '',
params: {} as Record<string, string>,
body: ''
})
watch(() => props.endpoint, (ep) => {
if (ep) {
testForm.value.path = ep.path
testForm.value.method = ep.method
testForm.value.params = {}
testForm.value.body = ''
testResult.value = null
activeTab.value = 'detail'
}
}, { immediate: true })
const getMethodType = (method: string): string => {
const types: Record<string, string> = {
GET: 'success',
POST: 'primary',
PUT: 'warning',
DELETE: 'danger',
PATCH: 'info'
}
return types[method] || 'info'
}
const handleTest = async () => {
testing.value = true
try {
// 构建 headers
const headers: Record<string, string> = {}
const token = tokenSource.value === 'admin'
? localStorage.getItem('adminToken')
: manualToken.value
if (token) {
headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`
}
// 构建查询参数(从表单中提取非空值)
const queryParams: Record<string, string> = {}
for (const [key, value] of Object.entries(testForm.value.params)) {
if (value) queryParams[key] = value
}
const res: any = await testEndpoint({
method: testForm.value.method,
path: testForm.value.path,
body: testForm.value.body || undefined,
headers,
params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
timeoutSeconds: 30
})
if (res.code === 200 && res.data) {
const data = res.data
testResult.value = {
status: data.status,
duration: data.duration,
display: data.rawBody || JSON.stringify(data.body, null, 2)
}
} else {
testResult.value = {
status: 0,
duration: 0,
display: res.message || '请求失败'
}
}
} catch (e: any) {
testResult.value = {
status: 0,
duration: 0,
display: e.message || '请求失败'
}
} finally {
testing.value = false
}
}
</script>
<style scoped lang="scss">
.response-body {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 4px;
max-height: 400px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Menlo', 'Monaco', monospace;
font-size: 13px;
}
</style>
```
- [ ] **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 <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: 完成接口管理功能(后端解析入库+前端列表/详情/测试)"
```