从零开始搭建基于Spring Boot的MCP(Model Context Protocol)系统完整教程

项目介绍

本教程将详细介绍如何从零开始搭建一个基于Spring Boot的MCP(Model Context Protocol)系统,该系统能够让AI模型(如通义千问)直接调用MyBatis数据库查询方法,实现自然语言查询数据库的功能。

要求dao层必须在com.dao这个路径下面,如果不是请自行更改代码,(详见包扫描工具)

系统架构

前端(Vue) -> Controller -> Service -> AI模型 -> 工具类 -> MyBatis -> 数据库

核心功能

  1. 自然语言查询:用户可以用自然语言描述查询需求
  2. AI理解与转换:AI模型将自然语言转换为具体的数据库查询
  3. 动态方法调用:系统动态调用MyBatis Mapper方法执行查询
  4. 流式响应:通过SSE(Server-Sent Events)实现流式响应
  5. 智能重试:当单条查询返回多条记录时,自动重试列表查询

1. 环境准备

1.1 技术栈

  • 后端:Spring Boot 2.2.2 + MyBatis Plus + 通义千问AI SDK
  • 前端:Vue.js + Element UI
  • 数据库:MySQL
  • Java版本:JDK 1.8

1.2 创建Spring Boot项目

使用Spring Initializr创建项目,或直接使用本教程提供的pom.xml配置。

2. Maven依赖配置

以下是实现MCP系统所需的关键Maven依赖:

<!-- Spring Boot Web支持 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- MyBatis集成 -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.1</version>
</dependency>

<!-- MyBatis增强工具 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus</artifactId>
    <version>2.3</version>
</dependency>

<!-- 通义千问AI SDK -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>dashscope-sdk-java</artifactId>
    <version>2.21.11</version>
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<!-- MySQL驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

<!-- Lombok简化代码 -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

关键依赖说明

  1. dashscope-sdk-java:通义千问AI SDK,用于与AI模型交互,是MCP系统的核心组件
  2. mybatis-spring-boot-starter:提供MyBatis集成,用于数据库操作
  3. mybatis-plus:MyBatis增强工具,简化CRUD操作
  4. spring-boot-starter-web:提供Web MVC支持,处理HTTP请求
  5. mysql-connector-java:MySQL数据库驱动
  6. lombok:通过注解简化Java代码,减少样板代码

3. 后端实现

3.1 配置文件

application.yml中添加以下配置:

server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/your_database?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=GMT%2B8
    username: your_username
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis-plus:
  mapper-locations: classpath*:mapper/*.xml
  type-aliases-package: com.entity

# AI配置
ai-key: your_ai_api_key

3.2 Controller层实现

创建DeepSeekController.java

package com.controller;

import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationParam;
import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.common.Role;
import com.service.DeepSeekService;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.Resource;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Author reisen7
 * Date 2025/4/9 1:12
 * Description 
 */

@RestController
@RequestMapping("/deepSeek")
@RequiredArgsConstructor
public class DeepSeekController {


    private final DeepSeekService deepSeekService;

    @GetMapping(value = "/query", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter getBotContent(@RequestParam String question) {
        // 直接调用Service层处理业务逻辑
        return deepSeekService.processQuestion(question);
    }

}

3.3 Service接口定义

创建DeepSeekService.java接口:

package com.service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationParam;
import com.alibaba.dashscope.aigc.generation.GenerationResult;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import io.reactivex.Flowable;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.alibaba.dashscope.common.Message;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.lang.System;
import java.util.Arrays;

/**
 * Author reisen7
 * Date 2025/4/9 0:04
 * Description
 */

public interface DeepSeekService {

    /**
     * 处理AI问答请求并通过SSE流式返回结果
     * @param question 用户问题
     * @return SseEmitter 对象
     */
    SseEmitter processQuestion(String question);
}

3.4 Service实现类

创建DeepSeekServiceImpl.java实现类,这是整个MCP系统的核心。下面我们逐步讲解其中的关键方法:

3.4.1 类结构与初始化
@Service
public class DeepSeekServiceImpl implements DeepSeekService, ApplicationContextAware {
    
    private static final Logger logger = LoggerFactory.getLogger(DeepSeekServiceImpl.class);
    private static final String MYBATIS_TOOL_NAME = "query_with_mybatis";
    
    @Value("${ai-key}")
    private String aiKey;
    
    @Resource
    private SqlSessionFactory sqlSessionFactory;
    
    private ApplicationContext applicationContext;
    private List<String> availableMethodsCache;
    
    private final ExecutorService executor = Executors.newFixedThreadPool(5);
    private final AtomicInteger eventIdGenerator = new AtomicInteger(0);
}

这个类实现了两个接口:DeepSeekService提供业务方法,ApplicationContextAware用于获取Spring上下文。关键属性包括:

  • aiKey:AI模型的API密钥
  • sqlSessionFactory:MyBatis会话工厂,用于数据库操作
  • availableMethodsCache:缓存所有可用的MyBatis方法
  • executor:线程池,用于异步处理请求
3.4.2 Mapper方法发现
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
    this.applicationContext = applicationContext;
    this.availableMethodsCache = discoverMapperMethods();
    // ... 日志记录
}

@SuppressWarnings("rawtypes")
private List<String> discoverMapperMethods() {
    // 1. 查找所有带@Mapper注解的Bean
    Map<String, Object> mappers = new HashMap<>();
    try {
        mappers.putAll(applicationContext.getBeansWithAnnotation(org.apache.ibatis.annotations.Mapper.class));
    } catch (BeansException e) {
        logger.info("没有找到带有 @Mapper 注解的 Beans。");
    }
    
    // 2. 如果没有找到,尝试查找BaseMapper的实现类
    if (mappers.isEmpty()) {
        // ... 查找BaseMapper实现类的逻辑
    }
    
    // 3. 遍历所有Mapper,提取方法信息
    for (Object mapperProxy : mappers.values()) {
        // ... 提取方法并添加智能标记
    }
    
    // 4. 如果Spring上下文中没有找到,回退到文件系统扫描
    if (methodNames.isEmpty()) {
        return PackageScanner.getAvailableMapperMethods("com.dao");
    }
    
    return methodNames.stream().distinct().collect(Collectors.toList());
}

这个方法在应用启动时被调用,用于发现所有可用的MyBatis Mapper方法。它会:

  1. 首先尝试从Spring上下文中查找带@Mapper注解的Bean
  2. 如果没有找到,则查找实现了BaseMapper接口的类
  3. 遍历所有找到的Mapper,提取其中的方法信息
  4. 为方法添加智能标记,如"✅推荐-返回列表"、"⚠️单条记录"等
  5. 如果Spring上下文中没有找到任何Mapper,则回退到文件系统扫描模式
3.4.3 SSE处理核心方法
@Override
public SseEmitter processQuestion(String question) {
    // 1. 创建SseEmitter对象,设置超时时间
    SseEmitter emitter = new SseEmitter(600_000L);
    
    // 2. 设置回调
    emitter.onCompletion(() -> logger.info("SSE连接已正常完成"));
    emitter.onError(e -> { /* 错误处理 */ });
    emitter.onTimeout(() -> { /* 超时处理 */ });
    
    // 3. 异步处理请求
    executor.execute(() -> {
        try {
            List<Message> messages = new ArrayList<>();
            messages.add(buildSystemMessage());
            messages.add(buildUserMessage(question));
            processConversation(messages, emitter);
        } catch (Exception e) {
            logger.error("处理请求时发生错误", e);
            sendSseResponse(emitter, "处理失败: " + e.getMessage());
        } finally {
            completeEmitter(emitter);
        }
    });
    
    return emitter;
}

这是处理用户请求的核心方法,它:

  1. 创建一个SseEmitter对象,设置10分钟的超时时间
  2. 设置连接完成、错误和超时的回调处理
  3. 使用线程池异步处理请求,避免阻塞主线程
  4. 构建消息列表,包括系统提示和用户问题
  5. 调用processConversation方法处理对话
3.4.4 对话处理与工具调用
private void processConversation(List<Message> messages, SseEmitter emitter) throws Exception {
    // 1. 调用AI模型
    Generation gen = new Generation();
    GenerationParam param = buildGenerationParam(messages);
    GenerationResult result = gen.call(param);
    Message assistantMessage = result.getOutput().getChoices().get(0).getMessage();
    messages.add(assistantMessage);
    
    // 2. 检查是否有工具调用
    if (assistantMessage.getToolCalls() != null && !assistantMessage.getToolCalls().isEmpty()) {
        sendSseResponse(emitter, "正在查询数据库,请稍候...");
        
        // 3. 执行工具调用
        for(com.alibaba.dashscope.tools.ToolCallBase toolCall : assistantMessage.getToolCalls()) {
            if (toolCall instanceof ToolCallFunction) {
                ToolCallFunction functionCall = (ToolCallFunction) toolCall;
                if (MYBATIS_TOOL_NAME.equals(functionCall.getFunction().getName())) {
                    String toolCallResult = executeMyBatisTool(functionCall);
                    Message toolMessage = Message.builder()
                            .role(Role.TOOL.getValue())
                            .content(toolCallResult)
                            .toolCallId(functionCall.getId())
                            .build();
                    
                    logger.info("查询方法: {}, 结果: {}", functionCall.getFunction().getName(), toolCallResult);
                    messages.add(toolMessage);
                }
            }
        }
        
        // 4. 递归处理,继续与AI对话
        processConversation(messages, emitter);
    } else {
        // 5. 没有工具调用,直接返回AI回答
        logger.info("最终回答: {}", assistantMessage.getContent());
        streamAssistantResponse(assistantMessage.getContent(), emitter);
    }
}

这个方法是MCP系统的核心,它实现了与AI模型的对话和工具调用:

  1. 调用AI模型,获取回复
  2. 检查AI的回复中是否包含工具调用
  3. 如果有工具调用,执行相应的工具(这里是MyBatis查询)
  4. 将工具执行结果添加到消息列表中
  5. 递归调用自身,继续与AI对话,直到AI不再调用工具
  6. 如果没有工具调用,将AI的最终回答发送给前端
3.4.5 系统提示构建
private Message buildSystemMessage() {
    String systemPrompt = "你是一个全能的AI助手,尤其擅长根据用户的问题,查询数据库并给出精准的回答。你可以调用工具 " + MYBATIS_TOOL_NAME + " 来执行MyBatis的Mapper方法。\n\n"
            + "重要提示:\n"
            + "1. 优先使用返回List的方法,如selectListView、selectListVO等\n"
            + "2. 避免使用返回单条记录的方法,如selectView、selectVO、selectOne等\n"
            + "3. 如果必须使用单条记录方法,请确保查询条件足够精确(如通过主键ID查询)\n"
            + "4. 对于模糊查询或可能返回多条记录的情况,必须使用List方法\n\n"
            + "可用方法列表:\n"
            + this.availableMethodsCache.stream()
                .map(method -> {
                    if (method.contains("selectView") && !method.contains("selectListView")) {
                        return method + " ⚠️单条记录";
                    } else if (method.contains("selectListView") || method.contains("selectListVO")) {
                        return method + " ✅推荐";
                    }
                    return method;
                })
                .collect(Collectors.joining("\n"))
            + "\n\n请根据用户的问题选择最合适的方法进行调用。";

    return Message.builder().role(Role.SYSTEM.getValue()).content(systemPrompt).build();
}

这个方法构建了系统提示,指导AI如何使用MyBatis工具:

  1. 定义AI的角色和能力
  2. 提供重要提示,指导AI选择合适的查询方法
  3. 列出所有可用的MyBatis方法,并添加智能标记
  4. 要求AI根据用户问题选择最合适的查询方法
3.4.6 SSE响应发送
private void sendSseResponse(SseEmitter emitter, String data) {
    try {
        int eventId = eventIdGenerator.incrementAndGet();
        // 将换行符替换为 <br>,确保 SSE 传输安全且前端 v-html 能正确渲染
        String safeData = data.replace("\n", "<br>");
        emitter.send(SseEmitter.event()
                .id(String.valueOf(eventId))
                .name("message")
                .data(safeData, MediaType.TEXT_PLAIN));
    } catch (IOException e) {
        logger.debug("向客户端发送SSE事件失败,连接可能已关闭: {}", e.getMessage());
        completeEmitter(emitter);
    }
}

这个方法负责向客户端发送SSE响应:

  1. 生成唯一的事件ID
  2. 将换行符替换为<br>标签,确保SSE传输安全且前端能正确渲染
  3. 构建SSE事件并发送给客户端
  4. 处理发送失败的情况,完成连接

通过这些关键方法的组合,DeepSeekServiceImpl实现了完整的MCP系统功能,包括AI对话、工具调用、数据库查询和流式响应。

3.4.7 完整代码
package com.service.impl;

import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationParam;
import com.alibaba.dashscope.aigc.generation.GenerationResult;
import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.tools.FunctionDefinition;
import com.alibaba.dashscope.tools.ToolCallFunction;
import com.alibaba.dashscope.tools.ToolFunction;
import com.baomidou.mybatisplus.mapper.BaseMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.service.DeepSeekService;
import com.utils.MyBatisToolCall;
import com.utils.PackageScanner;
import org.apache.ibatis.session.SqlSessionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.Resource;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

@Service
public class DeepSeekServiceImpl implements DeepSeekService, ApplicationContextAware {

    private static final Logger logger = LoggerFactory.getLogger(DeepSeekServiceImpl.class);

    @Value("${ai-key}")
    private String aiKey;

    @Resource
    private SqlSessionFactory sqlSessionFactory;

    private ApplicationContext applicationContext;
    private List<String> availableMethodsCache;

    private final ExecutorService executor = Executors.newFixedThreadPool(5);
    private final AtomicInteger eventIdGenerator = new AtomicInteger(0);

    private static final String MYBATIS_TOOL_NAME = "query_with_mybatis";

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
        this.availableMethodsCache = discoverMapperMethods();
        if (availableMethodsCache != null && !availableMethodsCache.isEmpty()) {
            logger.info("成功发现并缓存了 {} 个MyBatis Mapper方法。", availableMethodsCache.size());
        } else {
            logger.warn("未能发现任何MyBatis Mapper方法。");
        }
    }

    @SuppressWarnings("rawtypes")
    private List<String> discoverMapperMethods() {
        List<String> methodNames = new ArrayList<>();
        Map<String, Object> mappers = new HashMap<>();
        try {
            mappers.putAll(applicationContext.getBeansWithAnnotation(org.apache.ibatis.annotations.Mapper.class));
        } catch (BeansException e) {
            logger.info("没有找到带有 @Mapper 注解的 Beans。");
        }

        if (mappers.isEmpty()) {
            logger.info("尝试查找所有实现 BaseMapper 的 Beans。");
            try {
                Map<String, BaseMapper> baseMappers = applicationContext.getBeansOfType(BaseMapper.class);
                mappers.putAll(baseMappers);
            } catch (Exception e) {
                logger.warn("获取 BaseMapper 类型的 Beans 失败。", e);
            }
        }

        if (!mappers.isEmpty()) {
            for (Object mapperProxy : mappers.values()) {
                Class<?>[] interfaces = mapperProxy.getClass().getInterfaces();
                for (Class<?> mapperInterface : interfaces) {
                    if (mapperInterface.getName().startsWith("com.dao")) {

                        for (Method method : mapperInterface.getDeclaredMethods()) {
                            if (!method.isDefault() && method.getDeclaringClass() != Object.class) {
                                String fullMethodName = mapperInterface.getName() + "." + method.getName();
                                String displayName = fullMethodName;
                                
                                // 添加智能标记
                                if (method.getName().contains("selectList")) {
                                    displayName += " ✅推荐-返回列表";
                                } else if (method.getName().contains("selectView") || method.getName().contains("selectVO")) {
                                    displayName += " ⚠️单条记录";
                                } else if (method.getName().contains("selectOne")) {
                                    displayName += " ❗单条记录-慎用";
                                }
                                
                                methodNames.add(displayName);
                            }
                        }
                    }
                }
            }
        }

        if (methodNames.isEmpty()) {
            logger.warn("未能通过 Spring 上下文发现任何 Mapper 方法,将回退到文件系统扫描模式。");
            return PackageScanner.getAvailableMapperMethods("com.dao");
        }

        return methodNames.stream().distinct().collect(Collectors.toList());
    }

    @Override
    public SseEmitter processQuestion(String question) {
        SseEmitter emitter = new SseEmitter(600_000L);

        emitter.onCompletion(() -> logger.info("SSE连接已正常完成"));
        emitter.onError(e -> {
            if (e instanceof IOException && e.getMessage().contains("中止了一个已建立的连接")) {
                logger.debug("客户端主动中断连接");
            } else {
                logger.error("SSE连接发生错误", e);
            }
        });
        emitter.onTimeout(() -> {
            try {
                sendSseResponse(emitter, "请求处理超时,请稍后重试");
            } catch (Exception ex) {
                logger.debug("超时响应发送失败(连接可能已关闭)");
            } finally {
                completeEmitter(emitter);
            }
        });

        executor.execute(() -> {
            try {
                List<Message> messages = new ArrayList<>();
                messages.add(buildSystemMessage());
                messages.add(buildUserMessage(question));
                processConversation(messages, emitter);
            } catch (Exception e) {
                logger.error("处理请求时发生错误", e);
                sendSseResponse(emitter, "处理失败: " + e.getMessage());
            } finally {
                completeEmitter(emitter);
            }
        });

        return emitter;
    }

    private void processConversation(List<Message> messages, SseEmitter emitter) throws Exception {
        Generation gen = new Generation();
        GenerationParam param = buildGenerationParam(messages);

        GenerationResult result = gen.call(param);
        Message assistantMessage = result.getOutput().getChoices().get(0).getMessage();
        messages.add(assistantMessage);

        if (assistantMessage.getToolCalls() != null && !assistantMessage.getToolCalls().isEmpty()) {
            sendSseResponse(emitter, "正在查询数据库,请稍候...");
            for(com.alibaba.dashscope.tools.ToolCallBase toolCall : assistantMessage.getToolCalls()) {
                 if (toolCall instanceof ToolCallFunction) {
                    ToolCallFunction functionCall = (ToolCallFunction) toolCall;
                    if (MYBATIS_TOOL_NAME.equals(functionCall.getFunction().getName())) {
                        String toolCallResult = executeMyBatisTool(functionCall);
                        Message toolMessage = Message.builder()
                                .role(Role.TOOL.getValue())
                                .content(toolCallResult)
                                .toolCallId(functionCall.getId())
                                .build();

                        logger.info("查询方法: {}, 结果: {}", functionCall.getFunction().getName(), toolCallResult);
                        messages.add(toolMessage);
                    }
                }
            }
            processConversation(messages, emitter);
        } else {
            logger.info("最终回答: {}", assistantMessage.getContent());
            streamAssistantResponse(assistantMessage.getContent(), emitter);
        }
    }

    private Message buildSystemMessage() {
        String systemPrompt = "你是一个全能的AI助手,尤其擅长根据用户的问题,查询数据库并给出精准的回答。你可以调用工具 " + MYBATIS_TOOL_NAME + " 来执行MyBatis的Mapper方法。\n\n"
                + "重要提示:\n"
                + "1. 优先使用返回List的方法,如selectListView、selectListVO等\n"
                + "2. 避免使用返回单条记录的方法,如selectView、selectVO、selectOne等\n"
                + "3. 如果必须使用单条记录方法,请确保查询条件足够精确(如通过主键ID查询)\n"
                + "4. 对于模糊查询或可能返回多条记录的情况,必须使用List方法\n\n"
                + "5. 返回结果必须使用标准Markdown格式:\n"
                + "   - 列表项用『- 内容』表示,每个字段占一行\n"
                + "   - 需要加粗的字段名用『**字段名**』表示(如**会员账号**)\n"
                + "   - 换行用\\n(不要用<br>)\n"
                + "   - 不要包含任何HTML标签\n"
                + "6. 请结合用户的需求,选择最合适的方法进行调用,显示关键的数据即可,避免返回大量冗余信息。\n"
                + "7. 根据用户的需求,合理的返回所需的数据,你不是单纯的数据库查询助手,你必须体现的比较智能。\n"
                + "可用方法列表:\n"
                + this.availableMethodsCache.stream()
                    .map(method -> {
                        if (method.contains("selectView") && !method.contains("selectListView")) {
                            return method + " ⚠️单条记录";
                        } else if (method.contains("selectListView") || method.contains("selectListVO")) {
                            return method + " ✅推荐";
                        }
                        return method;
                    })
                    .collect(Collectors.joining("\n"))
                + "\n\n请根据用户的问题选择最合适的方法进行调用。";

        return Message.builder().role(Role.SYSTEM.getValue()).content(systemPrompt).build();
    }

    private Message buildUserMessage(String question) {
        return Message.builder().role(Role.USER.getValue()).content(question).build();
    }

    private GenerationParam buildGenerationParam(List<Message> messages) throws NoApiKeyException {
        FunctionDefinition functionDefinition = FunctionDefinition.builder()
                .name(MYBATIS_TOOL_NAME)
                .description("执行一个MyBatis Mapper接口中的方法来查询数据库。")
                .build();
        ToolFunction myBatisTool = ToolFunction.builder()
                .function(functionDefinition)
                .build();

        return GenerationParam.builder()
                .model("qwen-flash")
                .apiKey(aiKey)
                .messages(messages)
                .resultFormat(GenerationParam.ResultFormat.MESSAGE)
                .tools(Collections.singletonList(myBatisTool))
                .build();
    }

    private String executeMyBatisTool(ToolCallFunction functionCall) {
        try {
            String arguments = functionCall.getFunction().getArguments();
            logger.info("AI准备调用MyBatis方法,参数: {}", arguments);

            String result = MyBatisToolCall.execute(sqlSessionFactory, arguments);
            logger.info("MyBatis方法执行完成,返回结果长度: {}", result != null ? result.length() : 0);

            return result;
        } catch (Exception e) {
            logger.error("执行MyBatis工具时出错,参数: {}", functionCall.getFunction().getArguments(), e);
            return "{\"error\": \"" + e.getMessage() + "\"}";
        }
    }

    private void streamAssistantResponse(String content, SseEmitter emitter) {
        if (content != null && !content.isEmpty()) {
            sendSseResponse(emitter, content);
        }
    }

    private void sendSseResponse(SseEmitter emitter, String data) {
        try {
            int eventId = eventIdGenerator.incrementAndGet();
            // 只替换多余的空行,保留必要的换行结构
            String safeData = data.replaceAll("\n{2,}", "\n").replace("\n", "[NEWLINE]");
            // 对特殊字符进行转义
            safeData = safeData.replace("&", "&amp;")
                    .replace("<", "&lt;")
                    .replace(">", "&gt;")
                    .replace("\"", "&quot;")
                    .replace("'", "&#039;");

            emitter.send(SseEmitter.event()
                    .id(String.valueOf(eventId))
                    .name("message")
                    .data(safeData, MediaType.TEXT_HTML)); // 改为TEXT_HTML更合适
        } catch (IOException e) {
            logger.debug("向客户端发送SSE事件失败,连接可能已关闭: {}", e.getMessage());
            completeEmitter(emitter);
        }
    }

    private void completeEmitter(SseEmitter emitter) {
        try {
            emitter.complete();
        } catch (Exception e) {
            logger.debug("完成SSE Emitter时出错(可能已经完成或关闭): {}", e.getMessage());
        }
    }
}

3.5 工具类实现

3.5.1 MyBatis工具类

MyBatisToolCall是MCP系统的核心工具类,负责动态执行MyBatis Mapper方法。下面我们分步讲解其关键方法:

3.5.1.1 类结构与核心方法
public class MyBatisToolCall {
    private static final Logger logger = LoggerFactory.getLogger(MyBatisToolCall.class);
    private static final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 执行MyBatis Mapper方法的核心方法
     * @param sqlSessionFactory MyBatis会话工厂
     * @param arguments AI模型提供的JSON格式参数
     * @return JSON格式的查询结果
     */
    public static String execute(SqlSessionFactory sqlSessionFactory, String arguments) {
        // 方法实现...
    }
}

这个类提供了静态方法execute,接收SqlSessionFactory和JSON格式的参数,返回JSON格式的查询结果。

3.5.1.2 参数解析与验证
public static String execute(SqlSessionFactory sqlSessionFactory, String arguments) {
    String methodNameWithMapper = null;
    JsonNode methodArgsNode = null;
    
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
        // 1. 解析JSON参数
        JsonNode argsNode = objectMapper.readTree(arguments);

        // 2. 支持两种参数格式
        if (argsNode.has("methodName")) {
            // 格式1: {"methodName": "HuiyuanDao.selectListView", "args": {...}}
            methodNameWithMapper = argsNode.get("methodName").asText();
            methodArgsNode = argsNode.get("args");
        } else if (argsNode.has("mapper") && argsNode.has("method")) {
            // 格式2: {"mapper": "HuiyuanDao", "method": "selectListView", "params": {...}}
            methodNameWithMapper = argsNode.get("mapper").asText() + "." + argsNode.get("method").asText();
            if (argsNode.has("params")) {
                methodArgsNode = argsNode.get("params");
            }
        } else {
            return "{\"error\": \"Invalid arguments format...\"}";
        }
        
        // 3. 验证方法名格式
        int lastDotIndex = methodNameWithMapper.lastIndexOf('.');
        if (lastDotIndex == -1) {
            return "{\"error\": \"Invalid methodName format...\"}";
        }
        
        // 后续处理...
    }
}

execute方法首先解析AI模型提供的JSON参数,支持两种格式:

  1. methodName格式:直接指定完整的方法名
  2. mapper/method格式:分别指定Mapper名和方法名
3.5.1.3 动态查找Mapper和方法
// 解析Mapper名和方法名
String mapperIdentifier = methodNameWithMapper.substring(0, lastDotIndex);
String methodName = methodNameWithMapper.substring(lastDotIndex + 1);

// 查找目标Mapper类
Configuration configuration = sqlSessionFactory.getConfiguration();
Class<?> targetMapper = null;
for (Class<?> mapperClass : configuration.getMapperRegistry().getMappers()) {
    if (mapperClass.getName().equals(mapperIdentifier) ||
        mapperClass.getSimpleName().equals(mapperIdentifier)) {
        targetMapper = mapperClass;
        break;
    }
}

if (targetMapper == null) {
    return "{\"error\": \"Mapper '" + mapperIdentifier + "' not found.\"}";
}

// 查找目标方法 - 优先选择参数最少的方法
Method targetMethod = null;
Method[] methods = targetMapper.getDeclaredMethods();
int minParamCount = Integer.MAX_VALUE;

for (Method method : methods) {
    if (method.getName().equals(methodName)) {
        int paramCount = method.getParameterCount();
        // 优先选择参数最少的方法,避免选择包含Pagination的方法
        if (paramCount < minParamCount) {
            minParamCount = paramCount;
            targetMethod = method;
        }
    }
}

这段代码实现了动态查找Mapper类和方法的功能:

  1. 从方法名字符串中解析出Mapper名和方法名
  2. 在MyBatis配置中查找匹配的Mapper类
  3. 在Mapper类中查找匹配的方法,优先选择参数最少的方法
3.5.1.4 方法调用与结果处理
// 准备方法参数
Object[] methodArgs = prepareMethodArguments(targetMethod, methodArgsNode);

// 获取Mapper实例并调用方法
Object mapper = sqlSession.getMapper(targetMapper);
Object result = targetMethod.invoke(mapper, methodArgs);

// 序列化结果并返回
String jsonResult = objectMapper.writeValueAsString(result);
return jsonResult;

最后,准备好方法参数后,通过反射调用目标方法,并将结果序列化为JSON格式返回。

3.5.1.5 智能重试机制
private static String retryWithListMethod(SqlSessionFactory sqlSessionFactory,
                                          String originalMethodNameWithMapper,
                                          JsonNode originalArgsNode) throws Exception {
    // 1. 智能转换方法名
    String listMethodName = null;
    if (originalMethodNameWithMapper.endsWith("selectView")) {
        listMethodName = originalMethodNameWithMapper.replace("selectView", "selectListView");
    } else if (originalMethodNameWithMapper.endsWith("selectVO")) {
        listMethodName = originalMethodNameWithMapper.replace("selectVO", "selectListVO");
    } else if (originalMethodNameWithMapper.endsWith("selectOne")) {
        listMethodName = originalMethodNameWithMapper.replace("selectOne", "selectList");
    }
    // ... 其他转换逻辑
    
    // 2. 添加查询限制,避免返回过多数据
    for (int i = 0; i < methodArgs.length; i++) {
        if (methodArgs[i] instanceof EntityWrapper) {
            EntityWrapper<?> ew = (EntityWrapper<?>) methodArgs[i];
            ew.last("LIMIT 50"); // 限制最多50条记录
            methodArgs[i] = ew;
            break;
        }
    }
    
    // 3. 执行列表查询方法
    Object mapper = sqlSession.getMapper(targetMapper);
    Object result = targetMethod.invoke(mapper, methodArgs);
    
    return objectMapper.writeValueAsString(result);
}

当单条查询方法返回多条记录时,系统会自动重试对应的列表查询方法:

  1. 智能转换方法名(如selectView -> selectListView
  2. 添加查询限制,避免返回过多数据
  3. 执行列表查询方法并返回结果
3.5.1.6 方法参数准备
private static Object[] prepareMethodArguments(Method method, JsonNode argsNode) {
    Parameter[] parameters = method.getParameters();
    Object[] preparedArgs = new Object[parameters.length];

    for (int i = 0; i < parameters.length; i++) {
        Parameter param = parameters[i];
        Class<?> paramType = param.getType();
        
        // 1. 跳过分页参数
        if (paramType == com.baomidou.mybatisplus.plugins.pagination.Pagination.class) {
            preparedArgs[i] = null;
            continue;
        }
        
        // 2. 创建EntityWrapper
        EntityWrapper<?> ew = new EntityWrapper<>();
        
        // 3. 处理查询条件
        if (argsNode != null && argsNode.isObject()) {
            argsNode.fields().forEachRemaining(entry -> {
                String key = entry.getKey();
                String value = entry.getValue().asText();
                ew.eq(key, value);
            });
        }
        
        preparedArgs[i] = ew;
    }
    
    return preparedArgs;
}

prepareMethodArguments方法负责准备方法调用所需的参数:

  1. 跳过分页参数(我们不使用分页)
  2. 为每个参数创建EntityWrapper对象
  3. 将JSON中的查询条件添加到EntityWrapper
3.5.1.7 完整代码

package com.utils;

import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.baomidou.mybatisplus.mapper.Wrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.exceptions.TooManyResultsException;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;

/**
 * @author reisen7
 * @date 2025-10-03 14:17
 * @description mybatis工具类,用于执行mybatis mapper方法
 */

public class MyBatisToolCall {

    private static final Logger logger = LoggerFactory.getLogger(MyBatisToolCall.class);
    private static final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * Executes a MyBatis Mapper method based on the arguments provided by the language model.
     *
     * @param sqlSessionFactory The SqlSessionFactory to create a SqlSession.
     * @param arguments         A JSON string from the model, containing "methodName" and "args".
     * @return A JSON string representing the result of the method execution.
     */
    public static String execute(SqlSessionFactory sqlSessionFactory, String arguments) {
        String methodNameWithMapper = null;
        JsonNode methodArgsNode = null;
        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            JsonNode argsNode = objectMapper.readTree(arguments);

            if (argsNode.has("methodName")) {
                methodNameWithMapper = argsNode.get("methodName").asText();
                methodArgsNode = argsNode.get("args");
            } else if (argsNode.has("mapper") && argsNode.has("method")) {
                methodNameWithMapper = argsNode.get("mapper").asText() + "." + argsNode.get("method").asText();
                // 对于mapper/method格式,params可能是直接的条件对象
                if (argsNode.has("params")) {
                    methodArgsNode = argsNode.get("params");
                    if (methodArgsNode.isObject() && methodArgsNode.size() > 0) {
                        logger.info("检测到查询条件: {}", methodArgsNode);
                    }
                } else {
                    methodArgsNode = null;
                }
            } else {
                logger.error("Invalid arguments format. 'methodName' or 'mapper'/'method' is missing: {}", arguments);
                return "{\"error\": \"Invalid arguments format. 'methodName' or 'mapper'/'method' is missing.\"}";
            }

            // methodNameWithMapper 的格式可能是 "HuiyuanDao.selectListView" 或 "com.dao.HuiyuanDao.selectListView"
            int lastDotIndex = methodNameWithMapper.lastIndexOf('.');
            if (lastDotIndex == -1) {
                return "{\"error\": \"Invalid methodName format. Expected 'MapperName.methodName'.\"}";
            }
            
            String mapperIdentifier = methodNameWithMapper.substring(0, lastDotIndex);
            String methodName = methodNameWithMapper.substring(lastDotIndex + 1);

            // Find the target mapper class
            Configuration configuration = sqlSessionFactory.getConfiguration();
            Class<?> targetMapper = null;
            // 尝试通过全限定名或简单名称匹配
            for (Class<?> mapperClass : configuration.getMapperRegistry().getMappers()) {
                if (mapperClass.getName().equals(mapperIdentifier) || mapperClass.getSimpleName().equals(mapperIdentifier)) {
                    targetMapper = mapperClass;
                    break;
                }
            }

            if (targetMapper == null) {
                return "{\"error\": \"Mapper '" + mapperIdentifier + "' not found.\"}";
            }

            // Find the target method - 优先选择参数最少的方法
            Method targetMethod = null;
            Method[] methods = targetMapper.getDeclaredMethods();
            int minParamCount = Integer.MAX_VALUE;
            
            for (Method method : methods) {
                if (method.getName().equals(methodName)) {
                    int paramCount = method.getParameterCount();
                    // 优先选择参数最少的方法,避免选择包含Pagination的方法
                    if (paramCount < minParamCount) {
                        minParamCount = paramCount;
                        targetMethod = method;
                    }
                }
            }

            if (targetMethod == null) {
                return "{\"error\": \"Method '" + methodName + "' not found in mapper '" + targetMapper.getSimpleName() + "'.\"}";
            }

            // Prepare arguments for method invocation
            Object[] methodArgs = prepareMethodArguments(targetMethod, methodArgsNode);

            // Get the mapper instance and invoke the method
            Object mapper = sqlSession.getMapper(targetMapper);
            Object result = targetMethod.invoke(mapper, methodArgs);

            logger.info("MyBatis方法 {} 执行成功,返回结果类型: {}", methodNameWithMapper,
                       result != null ? result.getClass().getSimpleName() : "null");

            // Serialize and return the result
            String jsonResult = objectMapper.writeValueAsString(result);
            logger.debug("MyBatis方法 {} 返回JSON数据长度: {}", methodNameWithMapper, jsonResult.length());
            return jsonResult;

        } catch (InvocationTargetException e) {
            // Check for TooManyResultsException and retry with a list method
            if (e.getTargetException() instanceof TooManyResultsException) {
                logger.warn("方法 {} 返回多条记录,准备重试列表查询: {}", methodNameWithMapper, e.getTargetException().getMessage());
                try {
                    return retryWithListMethod(sqlSessionFactory, methodNameWithMapper, methodArgsNode);
                } catch (Exception retryException) {
                    logger.error("重试列表查询失败: {}", retryException.getMessage(), retryException);
                    return "{\"error\": \"查询返回了多条记录,重试失败: " + retryException.getMessage() + "\"}";
                }
            }
            logger.error("执行MyBatis方法 {} 失败: {}", methodNameWithMapper, e.getTargetException().getMessage(), e);
            return "{\"error\": \"执行失败: " + e.getTargetException().getMessage() + "\"}";
        } catch (Exception e) {
            logger.error("执行MyBatis方法 {} 发生错误: {}", methodNameWithMapper, e.getMessage(), e);
            return "{\"error\": \"执行错误: " + e.getMessage() + "\"}";
        }
    }

    private static String retryWithListMethod(SqlSessionFactory sqlSessionFactory, String originalMethodNameWithMapper, JsonNode originalArgsNode) throws Exception {
        String listMethodName = null;
        
        // 更智能的方法名转换
        if (originalMethodNameWithMapper.endsWith("selectView")) {
            listMethodName = originalMethodNameWithMapper.replace("selectView", "selectListView");
        } else if (originalMethodNameWithMapper.endsWith("selectVO")) {
            listMethodName = originalMethodNameWithMapper.replace("selectVO", "selectListVO");
        } else if (originalMethodNameWithMapper.endsWith("selectOne")) {
            listMethodName = originalMethodNameWithMapper.replace("selectOne", "selectList");
        } else if (originalMethodNameWithMapper.endsWith("View")) {
            listMethodName = originalMethodNameWithMapper.replace("View", "ListView");
        } else if (originalMethodNameWithMapper.endsWith("One")) {
            listMethodName = originalMethodNameWithMapper.replace("One", "List");
        } else {
            return "{\"error\": \"查询返回了多条记录,但未找到对应的列表查询方法。请使用返回List的方法进行查询。\"}";
        }

        logger.info("重试列表查询 - 原始方法: {} -> 列表方法: {}", originalMethodNameWithMapper, listMethodName);

        try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
            int lastDotIndex = listMethodName.lastIndexOf('.');
            String mapperIdentifier = listMethodName.substring(0, lastDotIndex);
            String methodName = listMethodName.substring(lastDotIndex + 1);

            Configuration configuration = sqlSessionFactory.getConfiguration();
            Class<?> targetMapper = null;
            for (Class<?> mapperClass : configuration.getMapperRegistry().getMappers()) {
                if (mapperClass.getName().equals(mapperIdentifier) || mapperClass.getSimpleName().equals(mapperIdentifier)) {
                    targetMapper = mapperClass;
                    break;
                }
            }

            if (targetMapper == null) {
                logger.error("重试失败:未找到Mapper '{}'", mapperIdentifier);
                return "{\"error\": \"重试失败:未找到Mapper '" + mapperIdentifier + "'\"}";
            }

            Method targetMethod = null;
            for (Method method : targetMapper.getDeclaredMethods()) {
                if (method.getName().equals(methodName)) {
                    targetMethod = method;
                    break;
                }
            }

            if (targetMethod == null) {
                logger.error("重试失败:在Mapper '{}' 中未找到方法 '{}'", targetMapper.getSimpleName(), methodName);
                return "{\"error\": \"重试失败:在Mapper '" + targetMapper.getSimpleName() + "' 中未找到方法 '" + methodName + "'\"}";
            }

            // 使用原始参数,确保查询条件不丢失
            Object[] methodArgs = prepareMethodArguments(targetMethod, originalArgsNode);
            logger.info("重试方法参数: {}", objectMapper.writeValueAsString(methodArgs));
            
            // 添加合理的限制,避免返回过多数据
            for (int i = 0; i < methodArgs.length; i++) {
                if (methodArgs[i] instanceof EntityWrapper) {
                    EntityWrapper<?> ew = (EntityWrapper<?>) methodArgs[i];
                    ew.last("LIMIT 50"); // 限制最多50条记录
                    methodArgs[i] = ew;
                    logger.info("添加查询限制: LIMIT 50");
                    break;
                } else if (methodArgs[i] == null && i < targetMethod.getParameterCount()) {
                    Class<?> paramType = targetMethod.getParameters()[i].getType();
                    if (Wrapper.class.isAssignableFrom(paramType)) {
                        EntityWrapper<?> ew = new EntityWrapper<>();
                        ew.last("LIMIT 50");
                        methodArgs[i] = ew;
                        logger.info("创建新Wrapper并添加限制: LIMIT 50");
                    }
                }
            }

            Object mapper = sqlSession.getMapper(targetMapper);
            Object result = targetMethod.invoke(mapper, methodArgs);
            
            logger.info("重试方法 {} 执行成功,返回结果类型: {}", listMethodName,
                       result != null ? result.getClass().getSimpleName() : "null");

            return objectMapper.writeValueAsString(result);
        }
    }

    /**
     * Prepares the arguments for method invocation based on the method's parameters and the JSON from the model.
     */
    private static Object[] prepareMethodArguments(Method method, JsonNode argsNode) {
        Parameter[] parameters = method.getParameters();
        Object[] preparedArgs = new Object[parameters.length];

        logger.info("准备方法参数 - 方法: {}.{},参数数量: {}",
                   method.getDeclaringClass().getSimpleName(), method.getName(), parameters.length);

        for (int i = 0; i < parameters.length; i++) {
            Parameter param = parameters[i];
            String paramName = param.getName(); // Fallback
            if (param.isAnnotationPresent(Param.class)) {
                paramName = param.getAnnotation(Param.class).value();
            }
            Class<?> paramType = param.getType();

            // 检查是否为分页参数
            if (paramType == com.baomidou.mybatisplus.plugins.pagination.Pagination.class) {
                preparedArgs[i] = null;
                logger.info("参数[{}] {}: 跳过分页参数", i, paramName);
                continue;
            }

            EntityWrapper<?> ew = new EntityWrapper<>();

            if (argsNode != null) {
                // 处理直接条件对象
                if (argsNode.isObject() && !argsNode.has(paramName)) {
                    argsNode.fields().forEachRemaining(entry -> {
                        String key = entry.getKey();
                        String value = entry.getValue().asText();
                        ew.eq(key, value);
                        logger.info("Wrapper条件: {} = {}", key, value);
                    });
                    preparedArgs[i] = ew;
                } else if (argsNode.has(paramName)) {
                    JsonNode argValueNode = argsNode.get(paramName);
                    try {
                        logger.info("参数[{}] {}: {} -> {}", i, paramName, argValueNode, paramType.getSimpleName());
                        
                        if (paramType.isAssignableFrom(EntityWrapper.class) || paramType.isAssignableFrom(Wrapper.class)) {
                            if (argValueNode.isObject() && argValueNode.size() > 0) {
                                argValueNode.fields().forEachRemaining(entry -> {
                                    ew.eq(entry.getKey(), entry.getValue().asText());
                                    logger.info("Wrapper条件: {} = {}", entry.getKey(), entry.getValue().asText());
                                });
                                preparedArgs[i] = ew;
                            } else {
                                preparedArgs[i] = ew;
                                logger.info("创建空Wrapper参数");
                            }
                        } else {
                            preparedArgs[i] = objectMapper.treeToValue(argValueNode, paramType);
                            logger.info("参数[{}]映射成功: {} -> {}", i, argValueNode, paramType.getSimpleName());
                        }
                    } catch (Exception e) {
                        logger.error("参数[{}]映射失败: {} -> {}", i, paramName, paramType.getSimpleName(), e);
                        preparedArgs[i] = null;
                    }
                } else {
                    preparedArgs[i] = ew;
                    logger.info("参数[{}]使用默认空Wrapper", i);
                }
            } else {
                preparedArgs[i] = ew;
                logger.info("参数[{}]使用默认空Wrapper", i);
            }
        }
        
        // 打印所有准备好的参数
        for (int i = 0; i < preparedArgs.length; i++) {
            logger.info("最终参数[{}]: {} (类型: {})", i,
                       preparedArgs[i] != null ? preparedArgs[i].toString() : "null",
                       preparedArgs[i] != null ? preparedArgs[i].getClass().getSimpleName() : "null");
        }
        
        return preparedArgs;
    }
}


3.5.2 包扫描工具类

PackageScanner是一个辅助工具类,用于在Spring上下文中找不到Mapper时,通过文件系统扫描来发现Mapper方法。

3.5.2.1 核心扫描方法
public class PackageScanner {
    private static final Logger logger = LoggerFactory.getLogger(PackageScanner.class);

    /**
     * 扫描指定包中的所有Mapper接口方法
     * @param basePackage 要扫描的包名,如"com.dao"
     * @return 方法名列表,如["com.dao.UserDao.selectById"]
     */
    public static List<String> getAvailableMapperMethods(String basePackage) {
        List<String> methodNames = new ArrayList<>();
        try {
            // 1. 查找包中的所有接口
            List<Class<?>> mapperInterfaces = findMapperInterfaces(basePackage);
            
            // 2. 提取每个接口的方法
            for (Class<?> mapperClass : mapperInterfaces) {
                for (Method method : mapperClass.getDeclaredMethods()) {
                    // 只添加接口本身的公共方法
                    if (!method.isDefault() && method.getDeclaringClass() != Object.class) {
                        methodNames.add(mapperClass.getName() + "." + method.getName());
                    }
                }
            }
        } catch (Exception e) {
            logger.error("扫描包失败: {}", basePackage, e);
        }
        return methodNames;
    }
}

这个方法的主要功能是:

  1. 查找指定包中的所有接口类
  2. 提取每个接口中的方法
  3. 返回完整的方法名列表
3.5.2.2 文件系统扫描实现
private static List<Class<?>> findMapperInterfaces(String basePackage)
    throws IOException, ClassNotFoundException, URISyntaxException {
    
    List<Class<?>> classes = new ArrayList<>();
    String path = basePackage.replace('.', '/');
    ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
    Enumeration<URL> resources = classLoader.getResources(path);

    while (resources.hasMoreElements()) {
        URL resource = resources.nextElement();
        if ("file".equals(resource.getProtocol())) {
            File directory = new File(resource.toURI());
            if (directory.exists() && directory.isDirectory()) {
                File[] files = directory.listFiles();
                if (files != null) {
                    for (File file : files) {
                        if (file.isFile() && file.getName().endsWith(".class")) {
                            // 将文件名转换为类名
                            String className = basePackage + '.' +
                                file.getName().substring(0, file.getName().length() - 6);
                            classes.add(Class.forName(className));
                        }
                    }
                }
            }
        }
    }
    return classes;
}

findMapperInterfaces方法实现了文件系统扫描功能:

  1. 将包名转换为文件系统路径
  2. 获取类加载器中的所有资源
  3. 遍历文件系统中的.class文件
  4. 将文件名转换为类名并加载类

这个工具类作为备用机制,当Spring上下文中找不到Mapper时,可以通过文件系统扫描来发现Mapper方法,确保系统的健壮性。

3.5.2.3 完整代码
package com.utils;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;

/**
 * @author reisen7
 * @date 2025-10-03 14:18
 * @description 包扫描工具类,用于扫描指定包下的所有类
 */

public class PackageScanner {

    private static final Logger logger = LoggerFactory.getLogger(PackageScanner.class);

    /**
     * Scans the specified package for interface classes and returns a list of their method names.
     * This is a fallback mechanism.
     *
     * @param basePackage The package to scan, e.g., "com.dao".
     * @return A list of fully qualified method names, e.g., "com.dao.UserDao.selectById".
     */
    public static List<String> getAvailableMapperMethods(String basePackage) {
        List<String> methodNames = new ArrayList<>();
        try {
            List<Class<?>> mapperInterfaces = findMapperInterfaces(basePackage);
            for (Class<?> mapperClass : mapperInterfaces) {
                for (Method method : mapperClass.getDeclaredMethods()) {
                    // Ensure we only add public methods of the interface itself
                    if (!method.isDefault() && method.getDeclaringClass() != Object.class) {
                        methodNames.add(mapperClass.getName() + "." + method.getName());
                    }
                }
            }
        } catch (IOException | ClassNotFoundException | URISyntaxException e) {
            logger.error("Could not scan package for mapper methods: {}", basePackage, e);
        }
        return methodNames;
    }

    /**
     * Finds all classes within a given package.
     *
     * @param basePackage The base package to search.
     * @return A list of Class objects found in the package.
     */
    private static List<Class<?>> findMapperInterfaces(String basePackage) throws IOException, ClassNotFoundException, URISyntaxException {
        List<Class<?>> classes = new ArrayList<>();
        String path = basePackage.replace('.', '/');
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        Enumeration<URL> resources = classLoader.getResources(path);

        while (resources.hasMoreElements()) {
            URL resource = resources.nextElement();
            if ("file".equals(resource.getProtocol())) {
                File directory = new File(resource.toURI());
                if (directory.exists() && directory.isDirectory()) {
                    File[] files = directory.listFiles();
                    if (files != null) {
                        for (File file : files) {
                            if (file.isFile() && file.getName().endsWith(".class")) {
                                String className = basePackage + '.' + file.getName().substring(0, file.getName().length() - 6);
                                classes.add(Class.forName(className));
                            }
                        }
                    }
                }
            }
        }
        return classes;
    }
}


4. 前端实现

4.1 前端组件说明

前端使用Vue.js实现,主要组件为Model.vue,负责与后端SSE接口交互并展示AI回复。

4.2 完整前端代码

<template>
    <div class="doubao-chat-container" v-show="show" @mousemove="modelMove" @mouseup="cancelMove">
        <div class="doubao-chat-window">
            <div class="doubao-header" @mousedown="setStartingPoint">
                <div class="doubao-logo"></div>
                <div class="doubao-title">{{ title }}</div>
                <div class="doubao-close" @click.stop="cancel">×</div>
            </div>
            <div class="doubao-messages" ref="box">
                <div v-for="(item, i) in list" :key="i"
                    :class="['message', item.id == 2 ? 'bot-message' : 'user-message']">
                    <div class="message-content">
                        <div class="message-text">
                            <!-- 机器人消息:显示逐字更新的 displayText -->
                            <template v-if="item.id === 2">
                                <span v-html="item.displayText || ''"></span>
                                <!-- 显示打字指示器 -->
                                <span
                                    v-if="item.isTyping && item.displayText && item.displayText.length < item.content.length"
                                    class="typing-cursor">|</span>
                            </template>
                            <!-- 用户消息:仍显示原始 content -->
                            <template v-else>
                                {{ item.content }}
                            </template>
                        </div>
                    </div>
                </div>
                <!-- 全局打字指示器 -->
                <div v-if="isTyping" class="typing-indicator">
                    <div class="dot"></div>
                    <div class="dot"></div>
                    <div class="dot"></div>
                </div>
            </div>
            <div class="doubao-input-area">
                <input type="text" v-model="wordone" class="doubao-input" placeholder="输入消息..." @keyup.enter="sendmsg">
                <button class="doubao-send-btn" :disabled="isButtonDisabled" @click="sendmsg">
                    <svg viewBox="0 0 24 24" width="20" height="20">
                        <path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"></path>
                    </svg>
                </button>
            </div>
        </div>
    </div>
</template>
<script>
export default {
    props: {
        show: {
            type: Boolean,
            default: false
        },
        title: {
            type: String,
            default: 'deepseek'
        },
    },
    data() {
        return {
            x: 0,
            y: 0,
            node: null,
            isCanMove: false,
            isButtonDisabled: false,
            isTyping: false, // 全局打字状态
            list: [],
            wordone: '',
            eventSource: null,
            currentBotMessage: null, // 当前正在接收的机器人消息
            typingTimer: null // 逐字显示定时器
        }
    },
    mounted() {
        this.node = document.querySelector('.model-container')
    },
    methods: {
        sendmsg() {
            if (this.isButtonDisabled || !this.wordone.trim()) {
                return;
            }

            // 重置状态
            this.resetTypingState();

            // 添加用户消息
            this.list.push({
                id: 1,
                name: 'user',
                content: this.wordone.trim()
            });
            this.scrollToBottom();

            // 开始获取机器人回复
            this.getBotContent();

            this.isButtonDisabled = true;
            this.isTyping = true; // 显示打字指示器
            this.wordone = '';
        },

        getBotContent() {
            // 清理旧的连接
            this.cleanupEventSource();

            // 创建新的机器人消息对象(只创建一个)
            this.currentBotMessage = {
                id: 2,
                name: 'bot',
                content: '',
                displayText: '',
                isTyping: true,
                isCompleted: false
            };
            this.list.push(this.currentBotMessage);

            // 初始化SSE连接
            const encodedQuestion = encodeURIComponent(this.wordone);
            this.eventSource = new EventSource(`http://localhost:8080/api/deepSeek/query?question=${encodedQuestion}`);

            this.eventSource.onmessage = (event) => {
                const sseContent = event.data.trim();
                if (!sseContent) return;

                // 累积内容到当前机器人消息
                if (this.currentBotMessage) {
                    this.currentBotMessage.content += sseContent;

                    // 如果还没有开始逐字显示,则开始
                    if (!this.typingTimer) {
                        this.startTyping();
                    }
                }
            };

            this.eventSource.onerror = (error) => {
                console.error('SSE错误:', error);
                //this.handleError();
            };

            this.eventSource.onclose = () => {
                console.log('SSE连接关闭');
                //this.handleMessageComplete();
            };
        },

        startTyping() {
            if (!this.currentBotMessage || this.typingTimer) {
                return;
            }

            this.typingTimer = setInterval(() => {
                if (!this.currentBotMessage) {
                    this.resetTypingState();
                    return;
                }

                const fullContent = this.currentBotMessage.content;
                const currentDisplayLength = this.currentBotMessage.displayText.length;

                if (currentDisplayLength < fullContent.length) {
                    // 逐字显示
                    this.currentBotMessage.displayText = fullContent.slice(0, currentDisplayLength + 1);
                    this.scrollToBottom();
                } else {
                    // 显示完成
                    this.currentBotMessage.isTyping = false;
                    this.currentBotMessage.isCompleted = true;
                    this.resetTypingState();
                }
            }, 30); // 调整打字速度(毫秒)
        },

        handleMessageComplete() {
            // 确保所有内容都显示完毕
            if (this.currentBotMessage) {
                this.currentBotMessage.displayText = this.currentBotMessage.content;
                this.currentBotMessage.isTyping = false;
                this.currentBotMessage.isCompleted = true;
            }
            this.resetTypingState();
            this.scrollToBottom();
        },

        handleError() {
            if (this.currentBotMessage) {
                this.currentBotMessage.content = '抱歉,发生了错误。请稍后重试。';
                this.currentBotMessage.displayText = '抱歉,发生了错误。请稍后重试。';
                this.currentBotMessage.isTyping = false;
                this.currentBotMessage.isCompleted = true;
            }
            this.resetTypingState();
            this.scrollToBottom();
        },

        resetTypingState() {
            // 清理定时器
            if (this.typingTimer) {
                clearInterval(this.typingTimer);
                this.typingTimer = null;
            }

            // 重置状态
            this.isButtonDisabled = false;
            this.isTyping = false;

            // 清理EventSource
            this.cleanupEventSource();
        },

        cleanupEventSource() {
            if (this.eventSource) {
                this.eventSource.close();
                this.eventSource = null;
            }
        },

        scrollToBottom() {
            this.$nextTick(() => {
                const div = this.$refs.box;
                if (div) {
                    div.scrollTop = div.scrollHeight;
                }
            });
        },

        cancel() {
            this.resetTypingState();
            this.currentBotMessage = null;
            this.$emit('cancel');
        },

        submit() {
            this.$emit('submit');
        },

        setStartingPoint(e) {
            this.x = e.clientX - this.node.offsetLeft;
            this.y = e.clientY - this.node.offsetTop;
            this.isCanMove = true;
        },

        modelMove(e) {
            if (this.isCanMove) {
                this.node.style.left = e.clientX - this.x + 'px';
                this.node.style.top = e.clientY - this.y + 'px';
            }
        },

        cancelMove() {
            this.isCanMove = false;
        },
    },

    beforeDestroy() {
        // 组件销毁时清理资源
        this.resetTypingState();
    }
}
</script>
<style scoped>
.doubao-chat-container {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.5);
    z-index: 1000;
    display: flex;
    justify-content: center;
    align-items: center;
}

.doubao-chat-window {
    width: 400px;
    height: 600px;
    background: #fff;
    border-radius: 16px;
    box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
    display: flex;
    flex-direction: column;
    overflow: hidden;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

.doubao-header {
    height: 60px;
    background: linear-gradient(135deg, #6e8efb, #a777e3);
    color: white;
    display: flex;
    align-items: center;
    padding: 0 20px;
    cursor: move;
    position: relative;
}

.doubao-logo {
    width: 32px;
    height: 32px;
    background-color: white;
    border-radius: 50%;
    margin-right: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: bold;
    color: #6e8efb;
}

.doubao-title {
    font-size: 16px;
    font-weight: 600;
    flex: 1;
}

.doubao-close {
    font-size: 24px;
    cursor: pointer;
    width: 32px;
    height: 32px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    transition: background 0.2s;
}

.doubao-close:hover {
    background: rgba(255, 255, 255, 0.2);
}

.doubao-messages {
    flex: 1;
    padding: 20px;
    overflow-y: auto;
    background: #f5f7fb;
    display: flex;
    flex-direction: column;
}

.message {
    max-width: 80%;
    margin-bottom: 16px;
    display: flex;
}

.user-message {
    align-self: flex-end;
}

.bot-message {
    align-self: flex-start;
}

.message-content {
    padding: 12px 16px;
    border-radius: 18px;
    line-height: 1.4;
    font-size: 14px;
    position: relative;
}

.user-message .message-content {
    background: #6e8efb;
    color: white;
    border-top-right-radius: 4px;
}

.bot-message .message-content {
    background: white;
    color: #333;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
    border-top-left-radius: 4px;
}

.typing-cursor {
    animation: blink 1s infinite;
    color: #6e8efb;
    font-weight: bold;
}

@keyframes blink {

    0%,
    50% {
        opacity: 1;
    }

    51%,
    100% {
        opacity: 0;
    }
}

.doubao-input-area {
    padding: 16px;
    border-top: 1px solid #e5e5e5;
    display: flex;
    align-items: center;
    background: white;
}

.doubao-input {
    flex: 1;
    height: 40px;
    border: 1px solid #e5e5e5;
    border-radius: 20px;
    padding: 0 16px;
    font-size: 14px;
    outline: none;
    transition: border 0.2s;
}

.doubao-input:focus {
    border-color: #6e8efb;
}

.doubao-send-btn {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    background: #6e8efb;
    color: white;
    border: none;
    margin-left: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    transition: background 0.2s;
}

.doubao-send-btn:disabled {
    background: #cccccc;
    cursor: not-allowed;
}

.doubao-send-btn:not(:disabled):hover {
    background: #5a7df4;
}

.typing-indicator {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 8px 16px;
    background: white;
    border-radius: 18px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
    align-self: flex-start;
    margin-top: 8px;
}

.dot {
    width: 8px;
    height: 8px;
    background: #a777e3;
    border-radius: 50%;
    margin: 0 4px;
    animation: bounce 1.4s infinite ease-in-out;
}

.dot:nth-child(1) {
    animation-delay: 0s;
}

.dot:nth-child(2) {
    animation-delay: 0.2s;
}

.dot:nth-child(3) {
    animation-delay: 0.4s;
}

@keyframes bounce {

    0%,
    60%,
    100% {
        transform: translateY(0);
    }

    30% {
        transform: translateY(-5px);
    }
}

/* 滚动条样式 */
.doubao-messages::-webkit-scrollbar {
    width: 6px;
}

.doubao-messages::-webkit-scrollbar-track {
    background: #f1f1f1;
}

.doubao-messages::-webkit-scrollbar-thumb {
    background: #c1c1c1;
    border-radius: 3px;
}

.doubao-messages::-webkit-scrollbar-thumb:hover {
    background: #a8a8a8;
}
</style>

5. 系统工作流程

5.1 整体流程

  1. 用户输入:用户在前端输入自然语言查询
  2. SSE连接:前端建立与后端的SSE连接
  3. AI处理:后端将用户问题发送给AI模型
  4. 工具调用:AI模型决定调用MyBatis工具查询数据库
  5. 动态执行MyBatisToolCall动态执行Mapper方法
  6. 结果返回:查询结果返回给AI模型
  7. 格式化输出:AI模型将结果格式化为自然语言回答
  8. 流式响应:通过SSE将回答流式返回给前端
  9. 逐字显示:前端逐字显示AI回答

5.2 关键技术点

5.2.1 SSE流式响应

使用Spring Boot的SseEmitter实现服务器向客户端的实时数据推送:

private void sendSseResponse(SseEmitter emitter, String data) {
    try {
        int eventId = eventIdGenerator.incrementAndGet();
        // 将换行符替换为 <br>,确保 SSE 传输安全且前端 v-html 能正确渲染
        String safeData = data.replace("\n", "<br>");
        emitter.send(SseEmitter.event()
                .id(String.valueOf(eventId))
                .name("message")
                .data(safeData, MediaType.TEXT_PLAIN));
    } catch (IOException e) {
        logger.debug("向客户端发送SSE事件失败,连接可能已关闭: {}", e.getMessage());
        completeEmitter(emitter);
    }
}
5.2.2 动态方法调用

通过反射动态调用MyBatis Mapper方法:

// Get the mapper instance and invoke the method
Object mapper = sqlSession.getMapper(targetMapper);
Object result = targetMethod.invoke(mapper, methodArgs);
5.2.3 智能重试机制

当单条查询返回多条记录时,自动重试列表查询:

private static String retryWithListMethod(SqlSessionFactory sqlSessionFactory, String originalMethodNameWithMapper, JsonNode originalArgsNode) throws Exception {
    String listMethodName = null;
    
    // 更智能的方法名转换
    if (originalMethodNameWithMapper.endsWith("selectView")) {
        listMethodName = originalMethodNameWithMapper.replace("selectView", "selectListView");
    } else if (originalMethodNameWithMapper.endsWith("selectVO")) {
        listMethodName = originalMethodNameWithMapper.replace("selectVO", "selectListVO");
    }
    // ... 其他转换逻辑
}

6. 部署与测试

6.1 部署步骤

  1. 配置数据库:修改application.yml中的数据库连接信息
  2. 配置AI密钥:在application.yml中设置ai-key

6.2 测试用例

  1. 简单查询

    查询会员账号为"会员1"的会员信息
    
  2. 列表查询

    查询所有男性会员的信息
    
  3. 模糊查询

    查询姓名包含"张"的会员
    

7. 总结与展望

7.1 系统优势

  1. 自然语言交互:用户无需编写SQL,用自然语言即可查询数据库
  2. 智能方法选择:AI能够根据查询需求选择最合适的Mapper方法
  3. 自动错误处理:系统具有智能重试机制,提高查询成功率
  4. 实时响应:通过SSE实现流式响应,提升用户体验

7.2 扩展方向

  1. 多数据源支持:扩展支持多种数据库类型
  2. 权限控制:添加用户权限验证,确保数据安全
  3. 查询优化:添加查询缓存和性能优化
  4. 更多AI模型:支持多种AI模型,如GPT、Claude等

7.3 注意事项

  1. 安全性:确保AI密钥和数据库连接信息的安全
  2. 性能监控:监控系统性能,及时处理异常情况
  3. 日志管理:合理配置日志级别,便于问题排查

本教程详细介绍了从零开始搭建MCP系统的全过程,包括完整的代码实现和部署说明。通过这个系统,用户可以用自然语言轻松查询数据库,大大降低了数据查询的门槛。

实现效果

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐