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

51 KiB

接口管理功能实现计划

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 实体

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 实体
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 接口
// 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: 提交
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(分页查询入参)

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(参数详情项)
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(列表项)
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(详情+参数)
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(代理测试入参)
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: 提交
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 接口

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 解析逻辑)
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: 提交
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

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: 提交
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 传递参数。

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: 提交
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

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
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: 提交
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: 编译验证
cd backend-single
mvn clean install -pl :backend-single -am -DskipTests

预期:BUILD SUCCESS

  • Step 2: 提交(如有新增/修改文件)
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 接口文件

// 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.tstools 分组下添加 "接口管理":

// 在 path: '/tools' 的 children 数组中添加:
{
  path: '/endpoint',
  title: '接口管理'
}

具体修改:将 menuConfig 中 tools 的 children 改为:

    children: [
      {
        path: '/tools/api-tester',
        title: 'API接口调用'
      },
      {
        path: '/endpoint',
        title: '接口管理'
      }
    ]
  • Step 3: 修改路由配置

web-admin/src/router/index.ts 中添加路由:

{
  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: 提交
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: 创建列表页

<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: 提交
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: 创建详情弹窗(含测试标签页)

<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: 提交
git add web-admin/src/views/endpoint/EndpointDetailDialog.vue
git commit -m "feat: 添加接口详情弹窗和测试面板"

Task 11: 前端验证

  • Step 1: 启动 web-admin 开发服务器

先检查 5174 端口是否被占用:

netstat -ano | findstr "5174"

如果被占用,先终止进程:

taskkill /F /PID <PID>

然后启动:

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: 检查所有改动
git status
  • Step 2: 提交
git add -A
git commit -m "feat: 完成接口管理功能(后端解析入库+前端列表/详情/测试)"