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

项目介绍

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

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

系统架构

1
前端(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依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!-- 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中添加以下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 类结构与初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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方法发现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@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处理核心方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@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 对话处理与工具调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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 系统提示构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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响应发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
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 类结构与核心方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 参数解析与验证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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和方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 解析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 方法调用与结果处理
1
2
3
4
5
6
7
8
9
10
// 准备方法参数
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 智能重试机制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 方法参数准备
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308

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 核心扫描方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 文件系统扫描实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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 完整前端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
<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实现服务器向客户端的实时数据推送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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方法:

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

5.2.3 智能重试机制

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

1
2
3
4
5
6
7
8
9
10
11
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
    查询会员账号为"会员1"的会员信息
  2. 列表查询

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

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

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系统的全过程,包括完整的代码实现和部署说明。通过这个系统,用户可以用自然语言轻松查询数据库,大大降低了数据查询的门槛。

实现效果

image