Compare commits

..

9 Commits

Author SHA1 Message Date
fdab553ba1 feat(chat): 实现联网搜索与对话管理功能
- 新增 OkHttp 客户端配置及依赖
- 添加 SearXNG 搜索引擎集成配置
- 创建基础分页查询类 BasePageQuery
- 实现网络搜索增强顾问 NetworkSearchAdvisor
- 增加聊天历史消息和对话的分页查询接口
- 添加对话摘要重命名与删除功能
- 配置 MyBatis Plus 分页插件支持
- 引入 Jsoup用于网页内容解析
- 新增 Hutool 工具库依赖
- 实现搜索结果内容抓取服务
- 添加搜索结果 DTO 和相关服务接口
- 扩展响应码枚举支持对话不存在情况
- 新增多个 VO 类用于请求和响应数据传输
2025-11-03 22:08:26 +08:00
59eb69747b feat(ai): 实现对话记忆与消息持久化功能
- 移除 Cassandra 相关配置及依赖
- 新增 CustomChatMemoryAdvisor 实现对话记忆管理
-重命名并扩展 CustomStreamLoggerAdvisor 为 CustomStreamLoggerAndMessage2DBAdvisor,增加消息入库逻辑
- 在 ChatController 中集成新的 Advisor 并注入相关依赖
- 使用 TransactionTemplate 管理消息存储事务
-限制记忆消息数量为最新 50 条
- 支持将用户消息与 AI 回答同步写入数据库
2025-11-03 16:31:19 +08:00
f3f320f390 feat(chat): 实现新的对话接口和相关功能模块
- 添加了 AI 对话请求 VO 类 (AiChatReqVO),支持模型名称、温度等参数
- 新增 AI 响应实体类 (AiResponse)用于封装返回结果
- 创建 API 操作日志注解 (@ApiOperationLog) 和切面类 (ApiOperationLogAspect)
- 配置数据源使用 P6Spy 驱动并优化 HikariCP 连接池设置
- 更新 DashScope 模型配置,调整默认模型为 qwen-plus 及温度值
- 引入全局异常处理机制,包括基础异常接口和业务异常类- 新增对话控制器 (ChatController) 支持新建对话及流式交互- 创建对话及相关消息的数据访问对象 (ChatDO, ChatMessageDO) 和映射器
- 实现聊天服务接口及其实现类,支持创建新对话记录
- 添加自定义流式日志顾问 (CustomStreamLoggerAdvisor) 用于调试输出
- 删除旧版控制器和相关模型类,移除冗余配置项
- 增加日期常量工具类统一时间格式管理
- 修改 .gitignore 忽略 /logs/ 目录避免日志文件被提交
2025-11-02 21:24:03 +08:00
594adcc48d feat(ai): 新增多模态与结构化输出功能支持
- 引入 Cassandra作为聊天记忆存储后端
- 配置 DashScope 多模态模型支持图文输入- 新增结构化输出控制器,支持 Bean、Map、List 等格式转换
- 添加文生图接口,集成阿里百炼图像生成能力
- 更新应用配置以支持多模态及持久化聊天记录
- 升级依赖项,引入 DashScope SDK 和 Cassandra 支持库
- 创建 ActorFilmography 和 Book 数据模型用于结构化响应
- 调整 ChatClient 配置以适配新的多模态与记忆逻辑
2025-10-27 22:11:08 +08:00
d12334fe36 feat(ai): 实现基于模板的智能代码生成功能
- 新增 PromptTemplateController 控制器,支持多种提示词模板方式
- 支持流式输出智能生成的代码内容- 提供系统角色与用户角色组合的提示词构建方式
- 新增 code-assistant.st 模板文件用于代码生成场景
- 扩展 DashscopeAIController,增加对话记忆功能
- 支持通过 chatId 维护多轮对话上下文- 引入 Spring AI 相关依赖以支持提示词模板和消息管理
2025-10-23 22:42:26 +08:00
5bfa65bc0b feat(ai): 集成阿里云DashScope AI模型支持
- 新增DashScope AI配置项,支持qwen-plus模型
- 添加AIResponse数据模型用于流式响应
- 实现普通对话和流式对话两个接口
- 引入spring-ai-alibaba-starter-dashscope依赖
- 更新OpenAIController移除无用导入
- 在pom.xml中添加spring-ai-alibaba-bom管理依赖版本
2025-10-23 14:27:38 +08:00
hanserwei
0782148820 feat(config): 添加 CORS 配置支持跨域请求
- 新增 CorsConfig 类实现 WebMvcConfigurer 接口
- 配置允许所有域名、方法和请求头的跨域访问- 支持发送 Cookie 凭证信息- 设置预检请求有效期为 1 小时

fix(controller): 修正流式响应的内容类型- 将 OpenAIController 的 generateStream 接口返回类型改为 TEXT_EVENT_STREAM_VALUE
- 使用 MediaType.TEXT_EVENT_STREAM_VALUE 替代硬编码字符串
- 确保服务端推送事件(SSE)能正确被客户端接收
2025-10-22 14:05:31 +08:00
bfbfdbc90d feat(ai): 集成多种大模型并支持会话记忆功能
- 新增 Ollama、智谱 AI 和 OpenAI 大模型接入配置- 实现基于 ChatMemory 的会话上下文管理
- 添加流式输出接口以提升响应体验
- 更新加密工具类密钥及测试数据
- 引入多个 AI 控制器用于不同模型的服务调用
- 在 pom.xml 中添加相关依赖项以支持多模型集成
2025-10-21 22:33:19 +08:00
hanserwei
ef527aab00 feat(ai): 新增 ChatClient 配置与控制器
- 添加 ChatClientConfig 配置类,初始化 ChatClient 并配置系统提示和顾问
- 创建 ChatClientController 控制器,支持普通对话与流式对话接口- 引入 lombok依赖并添加 MyLoggerAdvisor 日志顾问实现
- 调整 DeepSeekR1ChatController,优化流式输出内容处理逻辑
- 更新 application.yml 中默认模型名称及日志级别配置
2025-10-21 14:53:01 +08:00
53 changed files with 2500 additions and 132 deletions

1
.gitignore vendored
View File

@@ -30,3 +30,4 @@ build/
### VS Code ###
.vscode/
/logs/

116
pom.xml
View File

@@ -16,36 +16,123 @@
<java.version>21</java.version>
<spring-ai.version>1.0.3</spring-ai.version>
<commons-lang3.version>3.19.0</commons-lang3.version>
<lombok.version>1.18.40</lombok.version>
<jsonschema-generator.version>4.38.0</jsonschema-generator.version>
<p6spy.version>3.9.1</p6spy.version>
<guava.version>33.0.0-jre</guava.version>
<okhttp.version>4.12.0</okhttp.version>
<jsoup.version>1.17.2</jsoup.version>
<hutool.version>5.8.39</hutool.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<!-- 排除默认的 Logback 依赖 -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<!-- Deepseek 模型 -->
<!-- 阿里云AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-deepseek</artifactId>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-generator</artifactId>
<version>${jsonschema-generator.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
<!-- Mybatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<!-- p6spy 组件 -->
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>${p6spy.version}</version>
</dependency>
<!-- AOP 切面 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Log4j2 日志 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<!-- 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- 相关工具类 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<!-- OKHttp-->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>${okhttp.version}</version>
</dependency>
<!-- Jsoup -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>${jsoup.version}</version>
</dependency>
<!-- 分页插件 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
</dependency>
<!-- Hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
@@ -56,6 +143,21 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-bom</artifactId>
<version>1.0.0.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Mybatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-bom</artifactId>
<version>3.5.14</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

View File

@@ -0,0 +1,100 @@
package com.hanserwei.airobot.advisor;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.google.common.collect.Lists;
import com.hanserwei.airobot.domain.dos.ChatMessageDO;
import com.hanserwei.airobot.domain.mapper.ChatMessageMapper;
import com.hanserwei.airobot.model.vo.chat.AiChatReqVO;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.UserMessage;
import reactor.core.publisher.Flux;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
@Slf4j
public class CustomChatMemoryAdvisor implements StreamAdvisor {
private final ChatMessageMapper chatMessageMapper;
private final AiChatReqVO aiChatReqVO;
private final int limit;
public CustomChatMemoryAdvisor(ChatMessageMapper chatMessageMapper, AiChatReqVO aiChatReqVO, int limit) {
this.chatMessageMapper = chatMessageMapper;
this.aiChatReqVO = aiChatReqVO;
this.limit = limit;
}
@Override
public int getOrder() {
return 2; // order 值越小,越先执行
}
@NotNull
@Override
public String getName() {
return this.getClass().getSimpleName();
}
@NotNull
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) {
log.info("## 自定义聊天记忆 Advisor...");
// 对话 UUID
String chatUuid = aiChatReqVO.getChatId();
// 查询数据库拉取最新的聊天消息
List<ChatMessageDO> messages = chatMessageMapper.selectList(Wrappers.<ChatMessageDO>lambdaQuery()
.eq(ChatMessageDO::getChatUuid, chatUuid) // 查询指定对话 UUID 下的聊天记录
.orderByDesc(ChatMessageDO::getCreateTime) // 查询最新的消息
.last(String.format("LIMIT %d", limit))); // 仅查询 LIMIT 条
// 按发布时间升序排列
List<ChatMessageDO> sortedMessages = messages.stream()
.sorted(Comparator.comparing(ChatMessageDO::getCreateTime)) // 升序排列
.toList();
// 所有消息
List<Message> messageList = getMessageList(sortedMessages);
// 除了记忆消息,还需要添加当前用户消息
messageList.addAll(chatClientRequest.prompt().getInstructions());
// 构建一个新的 ChatClientRequest 请求对象
ChatClientRequest processedChatClientRequest = chatClientRequest
.mutate()
.prompt(chatClientRequest.prompt().mutate().messages(messageList).build())
.build();
return streamAdvisorChain.nextStream(processedChatClientRequest);
}
@NotNull
private static List<Message> getMessageList(List<ChatMessageDO> sortedMessages) {
List<Message> messageList = Lists.newArrayList();
// 将数据库记录转换为对应类型的消息
for (ChatMessageDO chatMessageDO : sortedMessages) {
// 消息类型
String type = chatMessageDO.getRole();
if (Objects.equals(type, MessageType.USER.getValue())) { // 用户消息
Message userMessage = new UserMessage(chatMessageDO.getContent());
messageList.add(userMessage);
} else if (Objects.equals(type, MessageType.ASSISTANT.getValue())) { // AI 助手消息
Message assistantMessage = new AssistantMessage(chatMessageDO.getContent());
messageList.add(assistantMessage);
}
}
return messageList;
}
}

View File

@@ -0,0 +1,108 @@
package com.hanserwei.airobot.advisor;
import com.hanserwei.airobot.domain.dos.ChatMessageDO;
import com.hanserwei.airobot.domain.mapper.ChatMessageMapper;
import com.hanserwei.airobot.model.vo.chat.AiChatReqVO;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.transaction.support.TransactionTemplate;
import reactor.core.publisher.Flux;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicReference;
@Slf4j
public class CustomStreamLoggerAndMessage2DBAdvisor implements StreamAdvisor {
private final ChatMessageMapper chatMessageMapper;
private final AiChatReqVO aiChatReqVO;
private final TransactionTemplate transactionTemplate;
public CustomStreamLoggerAndMessage2DBAdvisor(ChatMessageMapper chatMessageMapper,
AiChatReqVO aiChatReqVO,
TransactionTemplate transactionTemplate) {
this.chatMessageMapper = chatMessageMapper;
this.aiChatReqVO = aiChatReqVO;
this.transactionTemplate = transactionTemplate;
}
@Override
public int getOrder() {
return 99; // order 值越小,越先执行
}
@NotNull
@Override
public String getName() {
return this.getClass().getSimpleName();
}
@NotNull
@Override
public Flux<ChatClientResponse> adviseStream(@NotNull ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) {
Flux<ChatClientResponse> chatClientResponseFlux = streamAdvisorChain.nextStream(chatClientRequest);
// 对话 UUID
String chatUuid = aiChatReqVO.getChatId();
// 用户消息
String userMessage = aiChatReqVO.getMessage();
// 创建 AI 流式回答聚合容器(线程安全)
AtomicReference<StringBuilder> fullContent = new AtomicReference<>(new StringBuilder());
// 返回处理后的流
return chatClientResponseFlux
.doOnNext(response -> {
// 逐块收集内容
String chunk = null;
if (response.chatResponse() != null) {
chunk = response.chatResponse().getResult().getOutput().getText();
}
log.info("## chunk: {}", chunk);
// 若 chunk 块不为空,则追加到 fullContent 中
if (chunk != null) {
fullContent.get().append(chunk);
}
})
.doOnComplete(() -> {
// 流完成后打印完整回答
String completeResponse = fullContent.get().toString();
log.info("\n==== FULL AI RESPONSE ====\n{}\n========================", completeResponse);
// 开启编程式事务
transactionTemplate.execute(status -> {
try {
// 1. 存储用户消息
chatMessageMapper.insert(ChatMessageDO.builder()
.chatUuid(chatUuid)
.content(userMessage)
.role(MessageType.USER.getValue()) // 用户消息
.createTime(LocalDateTime.now())
.build());
// 2. 存储 AI 回答
chatMessageMapper.insert(ChatMessageDO.builder()
.chatUuid(chatUuid)
.content(completeResponse)
.role(MessageType.ASSISTANT.getValue()) // AI 回答
.createTime(LocalDateTime.now())
.build());
return true;
} catch (Exception ex) {
status.setRollbackOnly(); // 标记事务为回滚
log.error("", ex);
}
return false;
});
});
}
}

View File

@@ -0,0 +1,172 @@
package com.hanserwei.airobot.advisor;
import com.hanserwei.airobot.model.dto.SearchResultDTO;
import com.hanserwei.airobot.service.SearXNGService;
import com.hanserwei.airobot.service.SearchResultContentFetcherService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
/**
* 网络搜索增强顾问类,用于在聊天客户端处理用户请求前,通过联网搜索获取相关信息,
* 并将搜索结果作为上下文整合进原始提示词中以提升模型回答质量。
*
* <p>该类实现了 {@link StreamAdvisor} 接口,支持流式调用链中的增强逻辑。</p>
*/
@Slf4j
public class NetworkSearchAdvisor implements StreamAdvisor {
private final SearXNGService searXNGService;
private final SearchResultContentFetcherService searchResultContentFetcherService;
/**
* 联网搜索提示词模板:定义了如何基于用户的提问和网络搜索得到的上下文构造新的提示词。
* 包含用户问题、上下文信息以及一系列任务要求(如提取核心信息、交叉验证等)。
*/
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
## 用户问题
{question}
## 上下文
上下文信息如下,由以下符号包围:
---------------------
{context}
---------------------
请根据上下文内容来回复用户:
## 任务要求
1. 综合分析上下文内容,提取与用户问题直接相关的核心信息
2. 特别关注匹配度较高的结果
3. 对矛盾信息进行交叉验证,优先采用多个来源证实的信息
4. 请避免使用诸如 “根据上下文……” 或 “所提供的信息……” 这类表述
5. 当上下文内容不足或存在知识缺口时,再考虑使用本身已拥有的先验知识
6. 在关键信息后标注对应的来源,包含编号与页面跳转链接,格式如 [<a href="https://www.douyin.com/shipin/7532759629252544512" target="_blank">来源1</a>]
""");
/**
* 构造方法,注入所需的外部服务依赖。
*
* @param searXNGService 搜索引擎服务接口,用于发起网络搜索请求
* @param searchResultContentFetcherService 内容抓取服务接口,用于并发获取网页正文内容
*/
public NetworkSearchAdvisor(SearXNGService searXNGService, SearchResultContentFetcherService searchResultContentFetcherService) {
this.searXNGService = searXNGService;
this.searchResultContentFetcherService = searchResultContentFetcherService;
}
/**
* 实现 StreamAdvisor 的核心方法,在聊天请求处理过程中插入联网搜索逻辑。
*
* <p>流程包括:</p>
* <ul>
* <li>从当前请求中解析出用户消息</li>
* <li>调用搜索引擎获取初步搜索结果</li>
* <li>并发抓取各搜索结果页面的内容</li>
* <li>过滤掉未成功抓取的结果</li>
* <li>构建结构化的上下文字符串</li>
* <li>替换原提示词并继续后续处理链</li>
* </ul>
*
* @param chatClientRequest 当前聊天客户端请求对象,包含原始提示词和其他配置
* @param streamAdvisorChain 处理链对象,用于传递控制权到下一个顾问节点
* @return 返回经过增强后的响应流
*/
@NotNull
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) {
// 获取用户输入的提示词
Prompt prompt = chatClientRequest.prompt();
UserMessage userMessage = prompt.getUserMessage();
// 调用 SearXNG 获取搜索结果
List<SearchResultDTO> searchResults = searXNGService.search(userMessage.getText());
// 并发请求,获取搜索结果页面的内容
CompletableFuture<List<SearchResultDTO>> resultsFuture = searchResultContentFetcherService.batchFetch(searchResults, 7, TimeUnit.SECONDS);
List<SearchResultDTO> results = resultsFuture.join();
// 过滤掉获取失败的结果
List<SearchResultDTO> successfulResults = results.stream()
.filter(r -> StringUtils.isNotBlank(r.getContent()))
.toList();
// 构建搜索结果上下文信息
String searchContext = buildContext(successfulResults);
// 填充提示词占位符,转换为 Prompt 提示词对象
Prompt newPrompt = DEFAULT_PROMPT_TEMPLATE.create(Map.of("question", userMessage.getText(),
"context", searchContext), chatClientRequest.prompt().getOptions());
log.info("## 重新构建的增强提示词: {}", newPrompt.getUserMessage().getText());
// 重新构建 ChatClientRequest设置重新构建的 “增强提示词”
ChatClientRequest newChatClientRequest = ChatClientRequest.builder()
.prompt(newPrompt)
.build();
return streamAdvisorChain.nextStream(newChatClientRequest);
}
/**
* 将成功的搜索结果构建成结构化文本形式的上下文。
*
* <p>每条记录包括其序号、相关性评分、URL 和实际抓取到的页面内容。</p>
*
* @param successfulResults 成功抓取内容的搜索结果列表
* @return 格式化后的上下文字符串
*/
private String buildContext(List<SearchResultDTO> successfulResults) {
int i = 1;
StringBuilder contextTemp = new StringBuilder();
for (SearchResultDTO searchResult : successfulResults) {
contextTemp.append(String.format("""
### 来源 %s | 相关性: %s
- 页面链接: %s
- 页面文本:
%s
\n
""", i, searchResult.getScore(), searchResult.getUrl(), searchResult.getContent()));
i++;
}
return contextTemp.toString();
}
/**
* 获取当前顾问器的名称,默认为类名。
*
* @return 类简单名称
*/
@NotNull
@Override
public String getName() {
// 获取类名称
return this.getClass().getSimpleName();
}
/**
* 定义当前顾问器在处理链中的顺序。数值越小表示越早执行。
*
* @return 执行顺序值(默认为 1
*/
@Override
public int getOrder() {
return 1; // order 值越小,越先执行
}
}

View File

@@ -0,0 +1,16 @@
package com.hanserwei.airobot.aspect;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface ApiOperationLog {
/**
* API 功能描述
*
* @return API 功能描述
*/
String description() default "";
}

View File

@@ -0,0 +1,101 @@
package com.hanserwei.airobot.aspect;
import com.hanserwei.airobot.utils.JsonUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* API操作日志切面类用于记录被 @ApiOperationLog 注解标记的方法的执行信息,
* 包括方法描述、入参、出参以及执行耗时等。
*/
@Aspect
@Component
@Slf4j
public class ApiOperationLogAspect {
/** 以自定义 @ApiOperationLog 注解为切点,凡是添加 @ApiOperationLog 的方法,都会执行环绕中的代码 */
@Pointcut("@annotation(com.hanserwei.airobot.aspect.ApiOperationLog)")
public void apiOperationLog() {}
/**
* 环绕通知方法,用于记录目标方法的执行日志。
* 包括方法开始时间、类名、方法名、入参、功能描述、执行结果和耗时。
*
* @param joinPoint 切点对象,封装了目标方法的相关信息
* @return 目标方法的返回值
* @throws Throwable 目标方法可能抛出的异常
*/
@Around("apiOperationLog()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
// 请求开始时间
long startTime = System.currentTimeMillis();
// 获取被请求的类和方法
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
// 请求入参
Object[] args = joinPoint.getArgs();
// 入参转 JSON 字符串
String argsJsonStr = Arrays.stream(args).map(toJsonStr()).collect(Collectors.joining(", "));
// 功能描述信息
String description = getApiOperationLogDescription(joinPoint);
// 打印请求相关参数
log.info("====== 请求开始: [{}], 入参: {}, 请求类: {}, 请求方法: {} =================================== ",
description, argsJsonStr, className, methodName);
// 执行切点方法
Object result = joinPoint.proceed();
// 执行耗时
long executionTime = System.currentTimeMillis() - startTime;
// 打印出参等相关信息
log.info("====== 请求结束: [{}], 耗时: {}ms, 出参: {} =================================== ",
description, executionTime, JsonUtil.toJsonString(result));
return result;
}
/**
* 获取目标方法上 @ApiOperationLog 注解的描述信息。
*
* @param joinPoint 切点对象,用于获取目标方法信息
* @return 注解中定义的功能描述字符串
*/
private String getApiOperationLogDescription(ProceedingJoinPoint joinPoint) {
// 1. 从 ProceedingJoinPoint 获取 MethodSignature
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 2. 使用 MethodSignature 获取当前被注解的 Method
Method method = signature.getMethod();
// 3. 从 Method 中提取 LogExecution 注解
ApiOperationLog apiOperationLog = method.getAnnotation(ApiOperationLog.class);
// 4. 从 LogExecution 注解中获取 description 属性
return apiOperationLog.description();
}
/**
* 返回一个将对象转换为 JSON 字符串的函数。
*
* @return 将对象序列化为 JSON 字符串的函数
*/
private Function<Object, String> toJsonStr() {
return JsonUtil::toJsonString;
}
}

View File

@@ -0,0 +1,26 @@
package com.hanserwei.airobot.config;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ChatClientConfig {
/**
* 初始化 ChatClient 客户端
*
* @param chatModel 模型
* @return ChatClient
*/
@Bean
public ChatClient chatClient(DashScopeChatModel chatModel) {
return ChatClient.builder(chatModel)
.build();
}
}

View File

@@ -0,0 +1,19 @@
package com.hanserwei.airobot.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 匹配所有路径
.allowedOriginPatterns("*") // 允许所有域名(生产环境应指定具体域名)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的请求方法
.allowedHeaders("*") // 允许所有请求头
.allowCredentials(true) // 允许发送 Cookie
.maxAge(3600); // 预检请求的有效期(秒)
}
}

View File

@@ -0,0 +1,65 @@
package com.hanserwei.airobot.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.YearMonthDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.YearMonthSerializer;
import com.hanserwei.airobot.constant.DateConstants;
import com.hanserwei.airobot.utils.JsonUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.YearMonth;
import java.util.TimeZone;
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
// 初始化一个 ObjectMapper 对象,用于自定义 Jackson 的行为
ObjectMapper objectMapper = new ObjectMapper();
// 忽略未知属性
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
// 设置凡是为 null 的字段,返参中均不返回,请根据项目组约定是否开启
// objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
// 设置时区
objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
// JavaTimeModule 用于指定序列化和反序列化规则
JavaTimeModule javaTimeModule = new JavaTimeModule();
// 支持 LocalDateTime、LocalDate、LocalTime
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateConstants.DATE_FORMAT_Y_M_D_H_M_S));
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateConstants.DATE_FORMAT_Y_M_D_H_M_S));
javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateConstants.DATE_FORMAT_Y_M_D));
javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateConstants.DATE_FORMAT_Y_M_D));
javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateConstants.DATE_FORMAT_H_M_S));
javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateConstants.DATE_FORMAT_H_M_S));
// 支持 YearMonth
javaTimeModule.addSerializer(YearMonth.class, new YearMonthSerializer(DateConstants.DATE_FORMAT_Y_M));
javaTimeModule.addDeserializer(YearMonth.class, new YearMonthDeserializer(DateConstants.DATE_FORMAT_Y_M));
objectMapper.registerModule(javaTimeModule);
// 初始化 JsonUtils 中的 ObjectMapper
JsonUtil.init(objectMapper);
return objectMapper;
}
}

View File

@@ -0,0 +1,23 @@
package com.hanserwei.airobot.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.hanserwei.airobot.domain.mapper")
public class MybatisPlusConfig {
/**
* 添加分页插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL)); // 如果配置多个插件, 切记分页最后添加
// 如果有多数据源可以不配具体类型, 否则都建议配上具体的 DbType
return interceptor;
}
}

View File

@@ -0,0 +1,31 @@
package com.hanserwei.airobot.config;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
@Configuration
public class OkHttpConfig {
@Bean
public OkHttpClient okHttpClient(
@Value("${okhttp.connect-timeout}") int connectTimeout,
@Value("${okhttp.read-timeout}") int readTimeout,
@Value("${okhttp.write-timeout}") int writeTimeout,
@Value("${okhttp.max-idle-connections}") int maxIdleConnections,
@Value("${okhttp.keep-alive-duration}") int keepAliveDuration) {
return new OkHttpClient.Builder()
.connectTimeout(connectTimeout, TimeUnit.MILLISECONDS)
.readTimeout(readTimeout, TimeUnit.MILLISECONDS)
.writeTimeout(writeTimeout, TimeUnit.MILLISECONDS)
.connectionPool(new ConnectionPool(maxIdleConnections, keepAliveDuration, TimeUnit.MINUTES))
.build();
}
}

View File

@@ -0,0 +1,45 @@
package com.hanserwei.airobot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
public class ThreadPoolConfig {
/**
* HTTP 请求线程池IO 密集型任务)
* @return HTTP 请求线程池
*/
@Bean("httpRequestExecutor")
public ThreadPoolTaskExecutor httpRequestExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(50); // 核心线程数(保持常驻)
executor.setMaxPoolSize(200); // 最大线程数(突发流量时扩容)
executor.setQueueCapacity(1000); // 任务队列容量(缓冲突发请求)
executor.setKeepAliveSeconds(120); // 空闲线程存活时间(秒)
executor.setThreadNamePrefix("http-fetcher-"); // 线程名前缀(便于监控)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝策略(由调用线程执行)
executor.initialize(); // 初始化线程池
return executor;
}
/**
* 结果处理线程池CPU 密集型任务)
* @return 结果处理线程池CPU 密集型任务)
*/
@Bean("resultProcessingExecutor")
public ThreadPoolTaskExecutor resultProcessingExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(Runtime.getRuntime().availableProcessors()); // 核心线程数等于CPU核心数
executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2); // 最大线程数不超过CPU核心数2倍
executor.setQueueCapacity(200); // 较小队列(避免任务堆积)
executor.setThreadNamePrefix("result-processor-"); // 线程名前缀(便于监控)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); // 拒绝策略(直接抛出异常)
executor.initialize(); // 初始化线程池
return executor;
}
}

View File

@@ -0,0 +1,36 @@
package com.hanserwei.airobot.constant;
import java.time.format.DateTimeFormatter;
public interface DateConstants {
/**
* DateTimeFormatter年-月-日 时:分:秒
*/
DateTimeFormatter DATE_FORMAT_Y_M_D_H_M_S = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* DateTimeFormatter年-月-日
*/
DateTimeFormatter DATE_FORMAT_Y_M_D = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/**
* DateTimeFormatter月-日
*/
DateTimeFormatter DATE_FORMAT_M_D = DateTimeFormatter.ofPattern("MM-dd");
/**
* DateTimeFormatter
*/
DateTimeFormatter DATE_FORMAT_H_M_S = DateTimeFormatter.ofPattern("HH:mm:ss");
/**
* DateTimeFormatter
*/
DateTimeFormatter DATE_FORMAT_H_M = DateTimeFormatter.ofPattern("HH:mm");
/**
* DateTimeFormatter年-月
*/
DateTimeFormatter DATE_FORMAT_Y_M = DateTimeFormatter.ofPattern("yyyy-MM");
}

View File

@@ -0,0 +1,136 @@
package com.hanserwei.airobot.controller;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import com.google.common.collect.Lists;
import com.hanserwei.airobot.advisor.CustomChatMemoryAdvisor;
import com.hanserwei.airobot.advisor.CustomStreamLoggerAndMessage2DBAdvisor;
import com.hanserwei.airobot.advisor.NetworkSearchAdvisor;
import com.hanserwei.airobot.aspect.ApiOperationLog;
import com.hanserwei.airobot.domain.mapper.ChatMessageMapper;
import com.hanserwei.airobot.model.vo.chat.*;
import com.hanserwei.airobot.service.ChatService;
import com.hanserwei.airobot.service.SearXNGService;
import com.hanserwei.airobot.service.SearchResultContentFetcherService;
import com.hanserwei.airobot.utils.PageResponse;
import com.hanserwei.airobot.utils.Response;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.List;
@RestController
@RequestMapping("/chat")
@Slf4j
public class ChatController {
@Resource
private ChatService chatService;
@Resource
private ChatMessageMapper chatMessageMapper;
@Resource
private TransactionTemplate transactionTemplate;
@Resource
private SearXNGService searXNGService;
@Resource
private SearchResultContentFetcherService searchResultContentFetcherService;
@Value("${spring.ai.dashscope.api-key}")
private String apiKey;
@PostMapping("/new")
@ApiOperationLog(description = "新建对话")
public Response<?> newChat(@RequestBody @Validated NewChatReqVO newChatReqVO) {
return chatService.newChat(newChatReqVO);
}
@PostMapping(value = "/completion", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@ApiOperationLog(description = "流式对话")
public Flux<AiResponse> chat(@RequestBody @Validated AiChatReqVO aiChatReqVO) {
// 用户消息
String message = aiChatReqVO.getMessage();
// 模型名称
String modelName = aiChatReqVO.getModelName();
// 温度
Double temperature = aiChatReqVO.getTemperature();
// 构建ChatModel
ChatModel chatModel = DashScopeChatModel.builder()
.dashScopeApi(DashScopeApi.builder()
.apiKey(apiKey)
.build())
.build();
// 动态设置模型名称和温度
ChatClient.ChatClientRequestSpec chatClientRequestSpec = ChatClient.create(chatModel)
.prompt()
.options(DashScopeChatOptions.builder()
.withModel(modelName)
.withTemperature(temperature)
.build())
.user(message);
// 是否开启联网搜索
boolean networkSearch = aiChatReqVO.getNetworkSearch();
// Advisor 集合
List<Advisor> advisors = Lists.newArrayList();
// 是否开启了联网搜索
if (networkSearch) {
advisors.add(new NetworkSearchAdvisor(searXNGService, searchResultContentFetcherService));
} else {
// 添加自定义对话记忆 Advisor以最新的 50 条消息作为记忆)
advisors.add(new CustomChatMemoryAdvisor(chatMessageMapper, aiChatReqVO, 50));
}
// 添加自定义对话记忆 Advisor以最新的 50 条消息作为记忆)
advisors.add(new CustomChatMemoryAdvisor(chatMessageMapper, aiChatReqVO, 50));
// 添加自定义打印流式对话日志 Advisor
advisors.add(new CustomStreamLoggerAndMessage2DBAdvisor(chatMessageMapper, aiChatReqVO, transactionTemplate));
// 应用 Advisor 集合
chatClientRequestSpec.advisors(advisors);
// 流式输出
return chatClientRequestSpec.stream()
.content()
.mapNotNull(text -> AiResponse.builder().v(text).build());
}
@PostMapping("/message/list")
@ApiOperationLog(description = "查询对话历史消息")
public PageResponse<FindChatHistoryMessagePageListRspVO> findChatMessagePageList(@RequestBody @Validated FindChatHistoryMessagePageListReqVO findChatHistoryMessagePageListReqVO) {
return chatService.findChatHistoryMessagePageList(findChatHistoryMessagePageListReqVO);
}
@PostMapping("/list")
@ApiOperationLog(description = "查询历史对话")
public PageResponse<FindChatHistoryPageListRspVO> findChatHistoryPageList(@RequestBody @Validated FindChatHistoryPageListReqVO findChatHistoryPageListReqVO) {
return chatService.findChatHistoryPageList(findChatHistoryPageListReqVO);
}
@PostMapping("/summary/rename")
@ApiOperationLog(description = "重命名对话摘要")
public Response<?> renameChatSummary(@RequestBody @Validated RenameChatReqVO renameChatReqVO) {
return chatService.renameChatSummary(renameChatReqVO);
}
@PostMapping("/delete")
@ApiOperationLog(description = "删除对话")
public Response<?> deleteChat(@RequestBody @Validated DeleteChatReqVO deleteChatReqVO) {
return chatService.deleteChat(deleteChatReqVO);
}
}

View File

@@ -1,46 +0,0 @@
package com.hanserwei.airobot.controller;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.deepseek.DeepSeekChatModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/ai")
public class DeepSeekChatController {
@Resource
private DeepSeekChatModel chatModel;
/**
* 普通对话
* @param message 对话输入内容
* @return 对话结果
*/
@GetMapping("/generate")
public String generate(@RequestParam(value = "message", defaultValue = "你是谁?") String message) {
// 一次性返回结果
return chatModel.call(message);
}
/**
* 流式对话
* @param message 对话输入内容
* @return 对话结果
*/
@GetMapping(value = "/generateStream", produces = "text/html;charset=utf-8")
public Flux<String> generateStream(@RequestParam(value = "message", defaultValue = "你是谁?") String message) {
// 构建提示词
Prompt prompt = new Prompt(new UserMessage(message));
// 流式输出
return chatModel.stream(prompt)
.mapNotNull(chatResponse -> chatResponse.getResult().getOutput().getText());
}
}

View File

@@ -1,49 +0,0 @@
package com.hanserwei.airobot.controller;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.deepseek.DeepSeekAssistantMessage;
import org.springframework.ai.deepseek.DeepSeekChatModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/v1/ai")
public class DeepSeekR1ChatController {
@Resource
private DeepSeekChatModel chatModel;
/**
* 流式对话
* @param message 对话输入内容
* @return 对话结果
*/
@GetMapping(value = "/generateStream", produces = "text/html;charset=utf-8")
public Flux<String> generateStream(@RequestParam(value = "message", defaultValue = "你是谁?") String message) {
// 构建提示词
Prompt prompt = new Prompt(new UserMessage(message));
// 流式输出
return chatModel.stream(prompt)
.mapNotNull(chatResponse -> {
// 获取响应内容
DeepSeekAssistantMessage deepSeekAssistantMessage = (DeepSeekAssistantMessage) chatResponse.getResult().getOutput();
// 推理内容
String reasoningContent = deepSeekAssistantMessage.getReasoningContent();
// 推理结束后的正式回答
String text = deepSeekAssistantMessage.getText();
// 若推理内容有值,则响应推理内容,否则,说明推理结束了,响应正式回答
return StringUtils.isNotBlank(reasoningContent) ? reasoningContent : text;
});
}
}

View File

@@ -0,0 +1,26 @@
package com.hanserwei.airobot.domain.dos;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_chat")
public class ChatDO {
@TableId(type = IdType.AUTO)
private Long id;
private String uuid;
private String summary;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,26 @@
package com.hanserwei.airobot.domain.dos;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_chat_message")
public class ChatMessageDO {
@TableId(type = IdType.AUTO)
private Long id;
private String chatUuid;
private String content;
private String role;
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,29 @@
package com.hanserwei.airobot.domain.mapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hanserwei.airobot.domain.dos.ChatDO;
public interface ChatMapper extends BaseMapper<ChatDO> {
/**
* 分页查询聊天记录列表
* @param current 当前页码
* @param size 每页大小
* @return 分页结果
*/
default Page<ChatDO> selectPageList(Long current, Long size) {
// 创建分页对象,指定当前页和每页数量
Page<ChatDO> page = new Page<>(current, size);
// 构建查询条件,按更新时间倒序排列
LambdaQueryWrapper<ChatDO> wrapper = Wrappers.<ChatDO>lambdaQuery()
.orderByDesc(ChatDO::getUpdateTime);
// 执行分页查询并返回结果
return selectPage(page, wrapper);
}
}

View File

@@ -0,0 +1,31 @@
package com.hanserwei.airobot.domain.mapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hanserwei.airobot.domain.dos.ChatMessageDO;
public interface ChatMessageMapper extends BaseMapper<ChatMessageDO> {
/**
* 分页查询聊天消息列表
*
* @param current 当前页码
* @param size 每页显示记录数
* @param chatId 聊天对话ID
* @return 分页结果,包含聊天消息列表及相关分页信息
*/
default Page<ChatMessageDO> selectPageList(Long current, Long size, String chatId) {
// 分页对象(查询第几页、每页多少数据)
Page<ChatMessageDO> page = new Page<>(current, size);
// 构建查询条件
LambdaQueryWrapper<ChatMessageDO> wrapper = Wrappers.<ChatMessageDO>lambdaQuery()
.eq(ChatMessageDO::getChatUuid, chatId) // 对话 ID
.orderByDesc(ChatMessageDO::getCreateTime); // 按创建时间倒序
return selectPage(page, wrapper);
}
}

View File

@@ -0,0 +1,26 @@
package com.hanserwei.airobot.enums;
import com.hanserwei.airobot.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 通用异常状态码 -----------
SYSTEM_ERROR("10000", "出错啦,后台小哥正在努力修复中..."),
PARAM_NOT_VALID("10001", "参数错误"),
// ----------- 业务异常状态码 -----------
CHAT_NOT_EXISTED("20000", "此对话不存在"),
// TODO 待填充
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMessage;
}

View File

@@ -0,0 +1,7 @@
package com.hanserwei.airobot.exception;
public interface BaseExceptionInterface {
String getErrorCode();
String getErrorMessage();
}

View File

@@ -0,0 +1,18 @@
package com.hanserwei.airobot.exception;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class BizException extends RuntimeException {
// 异常码
private String errorCode;
// 错误信息
private String errorMessage;
public BizException(BaseExceptionInterface baseExceptionInterface) {
this.errorCode = baseExceptionInterface.getErrorCode();
this.errorMessage = baseExceptionInterface.getErrorMessage();
}
}

View File

@@ -0,0 +1,90 @@
package com.hanserwei.airobot.exception;
import com.hanserwei.airobot.enums.ResponseCodeEnum;
import com.hanserwei.airobot.utils.Response;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.Optional;
/**
* 全局异常处理器,用于统一处理系统中抛出的各类异常,并返回格式化的错误响应。
*/
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 捕获自定义业务异常 BizException并记录警告日志返回对应的失败响应。
*
* @param request 当前HTTP请求对象
* @param e 抛出的业务异常对象
* @return 返回封装后的失败响应对象
*/
@ExceptionHandler({ BizException.class })
@ResponseBody
public Response<Object> handleBizException(HttpServletRequest request, BizException e) {
log.warn("{} request fail, errorCode: {}, errorMessage: {}", request.getRequestURI(), e.getErrorCode(), e.getErrorMessage());
return Response.fail(e);
}
/**
* 捕获参数校验异常 MethodArgumentNotValidException提取字段校验错误信息并组合成可读性较强的错误描述
* 记录警告日志后返回参数校验失败的响应。
*
* @param request 当前HTTP请求对象
* @param e 参数校验异常对象
* @return 返回封装后的参数校验失败响应对象
*/
@ExceptionHandler({ MethodArgumentNotValidException.class })
@ResponseBody
public Response<Object> handleMethodArgumentNotValidException(HttpServletRequest request, MethodArgumentNotValidException e) {
// 参数错误异常码
String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();
// 获取 BindingResult
BindingResult bindingResult = e.getBindingResult();
StringBuilder sb = new StringBuilder();
// 获取校验不通过的字段,并组合错误信息,格式为: email 邮箱格式不正确, 当前值: '123124qq.com';
Optional.of(bindingResult.getFieldErrors()).ifPresent(errors -> {
errors.forEach(error ->
sb.append(error.getField())
.append(" ")
.append(error.getDefaultMessage())
.append(", 当前值: '")
.append(error.getRejectedValue())
.append("'; ")
);
});
// 错误信息
String errorMessage = sb.toString();
log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);
return Response.fail(errorCode, errorMessage);
}
/**
* 捕获其他未被处理的异常类型,记录错误日志并返回系统内部错误的响应。
*
* @param request 当前HTTP请求对象
* @param e 抛出的异常对象
* @return 返回封装后的系统错误响应对象
*/
@ExceptionHandler({ Exception.class })
@ResponseBody
public Response<Object> handleOtherException(HttpServletRequest request, Exception e) {
log.error("{} request error, ", request.getRequestURI(), e);
return Response.fail(ResponseCodeEnum.SYSTEM_ERROR);
}
}

View File

@@ -0,0 +1,15 @@
package com.hanserwei.airobot.model.common;
import lombok.Data;
@Data
public class BasePageQuery {
/**
* 当前页码, 默认第一页
*/
private Long current = 1L;
/**
* 每页展示的数据数量,默认每页展示 10 条数据
*/
private Long size = 10L;
}

View File

@@ -0,0 +1,28 @@
package com.hanserwei.airobot.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class SearchResultDTO {
/**
* 页面访问链接
*/
private String url;
/**
* 相关性评分
*/
private Double score;
/**
* 页面内容
*/
private String content;
}

View File

@@ -0,0 +1,35 @@
package com.hanserwei.airobot.model.vo.chat;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class AiChatReqVO {
@NotBlank(message = "用户消息不能为空")
private String message;
/**
* 对话 ID
*/
private String chatId;
/**
* 联网搜索
*/
private Boolean networkSearch = false;
@NotBlank(message = "调用的 AI 大模型名称不能为空")
private String modelName;
/**
* 温度值,默认为 0.7
*/
private Double temperature = 0.7;
}

View File

@@ -0,0 +1,17 @@
package com.hanserwei.airobot.model.vo.chat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class AiResponse {
/**
* 响应内容
*/
private String v;
}

View File

@@ -0,0 +1,18 @@
package com.hanserwei.airobot.model.vo.chat;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class DeleteChatReqVO {
@NotBlank(message = "对话 UUID 不能为空")
private String uuid;
}

View File

@@ -0,0 +1,16 @@
package com.hanserwei.airobot.model.vo.chat;
import com.hanserwei.airobot.model.common.BasePageQuery;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
@EqualsAndHashCode(callSuper = true)
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindChatHistoryMessagePageListReqVO extends BasePageQuery {
@NotBlank(message = "对话 ID 不能为空")
private String chatId;
}

View File

@@ -0,0 +1,36 @@
package com.hanserwei.airobot.model.vo.chat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindChatHistoryMessagePageListRspVO {
/**
* 消息 ID
*/
private Long id;
/**
* 对话 ID
*/
private String chatId;
/**
* 内容
*/
private String content;
/**
* 消息类型
*/
private String role;
/**
* 发布时间
*/
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,14 @@
package com.hanserwei.airobot.model.vo.chat;
import com.hanserwei.airobot.model.common.BasePageQuery;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
@Data
@AllArgsConstructor
@Builder
public class FindChatHistoryPageListReqVO extends BasePageQuery {
}

View File

@@ -0,0 +1,32 @@
package com.hanserwei.airobot.model.vo.chat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindChatHistoryPageListRspVO {
/**
* 对话 ID
*/
private Long id;
/**
* 对话 UUID
*/
private String uuid;
/**
* 对话摘要
*/
private String summary;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,18 @@
package com.hanserwei.airobot.model.vo.chat;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class NewChatReqVO {
@NotBlank(message = "用户消息不能为空")
private String message;
}

View File

@@ -0,0 +1,22 @@
package com.hanserwei.airobot.model.vo.chat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class NewChatRspVO {
/**
* 摘要
*/
private String summary;
/**
* 对话 UUID
*/
private String uuid;
}

View File

@@ -0,0 +1,22 @@
package com.hanserwei.airobot.model.vo.chat;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class RenameChatReqVO {
@NotNull(message = "对话 ID 不能为空")
private Long id;
@NotBlank(message = "对话摘要不能为空")
private String summary;
}

View File

@@ -0,0 +1,48 @@
package com.hanserwei.airobot.service;
import com.hanserwei.airobot.model.vo.chat.*;
import com.hanserwei.airobot.utils.PageResponse;
import com.hanserwei.airobot.utils.Response;
public interface ChatService {
/**
* 新建对话
*
* @param newChatReqVO 新建对话请求参数
* @return 新建对话结果
*/
Response<NewChatRspVO> newChat(NewChatReqVO newChatReqVO);
/**
* 查询历史消息
*
* @param findChatHistoryMessagePageListReqVO 查询历史消息请求参数
* @return 查询历史消息结果
*/
PageResponse<FindChatHistoryMessagePageListRspVO> findChatHistoryMessagePageList(FindChatHistoryMessagePageListReqVO findChatHistoryMessagePageListReqVO);
/**
* 查询历史对话
*
* @param findChatHistoryPageListReqVO 查询历史对话请求参数
* @return 查询历史对话结果
*/
PageResponse<FindChatHistoryPageListRspVO> findChatHistoryPageList(FindChatHistoryPageListReqVO findChatHistoryPageListReqVO);
/**
* 重命名对话摘要
*
* @param renameChatReqVO 重命名对话摘要请求参数
* @return 重命名对话摘要结果
*/
Response<?> renameChatSummary(RenameChatReqVO renameChatReqVO);
/**
* 删除对话
*
* @param deleteChatReqVO 删除对话请求参数
* @return 删除对话结果
*/
Response<?> deleteChat(DeleteChatReqVO deleteChatReqVO);
}

View File

@@ -0,0 +1,9 @@
package com.hanserwei.airobot.service;
import com.hanserwei.airobot.model.dto.SearchResultDTO;
import java.util.List;
public interface SearXNGService {
List<SearchResultDTO> search(String query);
}

View File

@@ -0,0 +1,12 @@
package com.hanserwei.airobot.service;
import com.hanserwei.airobot.model.dto.SearchResultDTO;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public interface SearchResultContentFetcherService {
CompletableFuture<List<SearchResultDTO>> batchFetch(List<SearchResultDTO> searchResults, long timeout, TimeUnit unit);
}

View File

@@ -0,0 +1,154 @@
package com.hanserwei.airobot.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hanserwei.airobot.domain.dos.ChatDO;
import com.hanserwei.airobot.domain.dos.ChatMessageDO;
import com.hanserwei.airobot.domain.mapper.ChatMapper;
import com.hanserwei.airobot.domain.mapper.ChatMessageMapper;
import com.hanserwei.airobot.enums.ResponseCodeEnum;
import com.hanserwei.airobot.exception.BizException;
import com.hanserwei.airobot.model.vo.chat.*;
import com.hanserwei.airobot.service.ChatService;
import com.hanserwei.airobot.utils.PageResponse;
import com.hanserwei.airobot.utils.Response;
import com.hanserwei.airobot.utils.StringUtil;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class ChatServiceImpl implements ChatService {
@Resource
private ChatMapper chatMapper;
@Resource
private ChatMessageMapper chatMessageMapper;
@Override
public Response<NewChatRspVO> newChat(NewChatReqVO newChatReqVO) {
// 用户发来的消息
String message = newChatReqVO.getMessage();
// 生成对话的UUID
String uuid = UUID.randomUUID().toString();
// 截取用户发送的消息,作为对话的摘要
String summary = StringUtil.truncate(message, 20);
// 存储对话记录到数据库中
chatMapper.insert(ChatDO.builder()
.summary(summary)
.uuid(uuid)
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.build());
// 将摘要、UUID 返回给前端
return Response.success(NewChatRspVO.builder()
.uuid(uuid)
.summary(summary)
.build());
}
@Override
public PageResponse<FindChatHistoryMessagePageListRspVO> findChatHistoryMessagePageList(FindChatHistoryMessagePageListReqVO findChatHistoryMessagePageListReqVO) {
// 获取当前页、以及每页需要展示的数据数量
Long current = findChatHistoryMessagePageListReqVO.getCurrent();
Long size = findChatHistoryMessagePageListReqVO.getSize();
String chatId = findChatHistoryMessagePageListReqVO.getChatId();
// 执行分页查询
Page<ChatMessageDO> chatMessageDOPage = chatMessageMapper.selectPageList(current, size, chatId);
List<ChatMessageDO> chatMessageDOS = chatMessageDOPage.getRecords();
// DO 转 VO
List<FindChatHistoryMessagePageListRspVO> vos = null;
if (CollUtil.isNotEmpty(chatMessageDOS)) {
vos = chatMessageDOS.stream()
.map(chatMessageDO -> FindChatHistoryMessagePageListRspVO.builder() // 构建返参 VO 实体类
.id(chatMessageDO.getId())
.chatId(chatMessageDO.getChatUuid())
.content(chatMessageDO.getContent())
.role(chatMessageDO.getRole())
.createTime(chatMessageDO.getCreateTime())
.build())
// 升序排序
.sorted(Comparator.comparing(FindChatHistoryMessagePageListRspVO::getCreateTime))
.collect(Collectors.toList());
}
return PageResponse.success(chatMessageDOPage, vos);
}
@Override
public PageResponse<FindChatHistoryPageListRspVO> findChatHistoryPageList(FindChatHistoryPageListReqVO findChatHistoryPageListReqVO) {
// 获取当前页、以及每页需要展示的数据数量
Long current = findChatHistoryPageListReqVO.getCurrent();
Long size = findChatHistoryPageListReqVO.getSize();
// 执行分页查询
Page<ChatDO> chatDOPage = chatMapper.selectPageList(current, size);
// 获取查询结果
List<ChatDO> chatDOS = chatDOPage.getRecords();
// DO 转 VO
List<FindChatHistoryPageListRspVO> vos = null;
if (CollUtil.isNotEmpty(chatDOS)) {
vos = chatDOS.stream()
.map(chatDO -> FindChatHistoryPageListRspVO.builder() // 构建返参 VO
.id(chatDO.getId())
.uuid(chatDO.getUuid())
.summary(chatDO.getSummary())
.updateTime(chatDO.getUpdateTime())
.build())
.collect(Collectors.toList());
}
return PageResponse.success(chatDOPage, vos);
}
@Override
public Response<?> renameChatSummary(RenameChatReqVO renameChatReqVO) {
// 对话 ID
Long chatId = renameChatReqVO.getId();
// 摘要
String summary = renameChatReqVO.getSummary();
// 根据主键 ID 更新摘要
chatMapper.updateById(ChatDO.builder()
.id(chatId)
.summary(summary)
.build());
return Response.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Response<?> deleteChat(DeleteChatReqVO deleteChatReqVO) {
// 对话 UUID
String uuid = deleteChatReqVO.getUuid();
// 删除对话
int count = chatMapper.delete(Wrappers.<ChatDO>lambdaQuery()
.eq(ChatDO::getUuid, uuid));
// 如果删除操作影响的行数为 0说明想要删除的对话不存在
if (count == 0) {
throw new BizException(ResponseCodeEnum.CHAT_NOT_EXISTED);
}
// 批量删除对话下的所有消息
chatMessageMapper.delete(Wrappers.<ChatMessageDO>lambdaQuery()
.eq(ChatMessageDO::getChatUuid, uuid));
return Response.success();
}
}

View File

@@ -0,0 +1,110 @@
package com.hanserwei.airobot.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hanserwei.airobot.model.dto.SearchResultDTO;
import com.hanserwei.airobot.service.SearXNGService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
/**
* SearXNG 搜索服务实现类
* <p>
* 该类通过调用 SearXNG 的 API 实现聚合搜索引擎功能,支持从多个搜索引擎获取结果并按评分排序。
* </p>
*/
@Service
@Slf4j
public class SearXNGServiceImpl implements SearXNGService {
@Resource
private OkHttpClient okHttpClient;
@Resource
private ObjectMapper objectMapper;
@Value("${searxng.url}")
private String searxngUrl;
@Value("${searxng.count}")
private int count;
/**
* 根据关键词执行搜索操作
*
* @param query 搜索关键词,不能为空
* @return 搜索结果列表,每个元素包含 URL 和评分;若发生异常或无结果则返回空列表
*/
@Override
public List<SearchResultDTO> search(String query) {
// 构建 SearXNG API 请求 URL
HttpUrl httpUrl = Objects.requireNonNull(HttpUrl.parse(searxngUrl)).newBuilder()
.addQueryParameter("q", query) // 设置搜索关键词
.addQueryParameter("format", "json") // 指定返回 JSON 格式
.addQueryParameter("engines", "wolframalpha,presearch,seznam,mwmbl,encyclosearch,bpb,mojeek,right dao,wikimini,crowdview,searchmysite,bing,naver,360search") // 指定聚合的目标搜索引擎(配置本地网络能够访问的通的搜索引擎)
.build();
// 创建 HTTP GET 请求
Request request = new Request.Builder()
.url(httpUrl)
.get()
.build();
// 发送 HTTP 请求
try (Response response = okHttpClient.newCall(request).execute()) {
// 判断请求是否成功
if (response.isSuccessful()) {
// 拿到返回结果
String result = response.body().string();
log.info("## SearXNG 搜索结果: {}", result);
// 解析 JSON 响应
JsonNode root = objectMapper.readTree(result);
JsonNode results = root.get("results"); // 获取结果数组节点
// 定义 Record 类型:用于临时存储分数和节点引用
record NodeWithUrlAndScore(double score, JsonNode node) {
}
// 处理搜索结果流:
// 1. 提取评分
// 2. 按评分降序排序
// 3. 限制返回结果数量
List<NodeWithUrlAndScore> nodesWithScore = StreamSupport.stream(results.spliterator(), false)
.map(node -> {
// 只提取分数,避免构建完整对象
double score = node.path("score").asDouble(0.0); // 提取评分
return new NodeWithUrlAndScore(score, node);
})
.sorted(Comparator.comparingDouble(NodeWithUrlAndScore::score).reversed()) // 按评分降序
.limit(count) // 限制返回结果数量
.toList();
// 转换为 SearchResult 对象集合
return nodesWithScore.stream()
.map(n -> {
JsonNode node = n.node();
String originalUrl = node.path("url").asText(""); // 提取 URL
return SearchResultDTO.builder()
.url(originalUrl)
.score(n.score()) // 保留评分
.build();
})
.collect(Collectors.toList());
}
} catch (Exception e) {
log.error("", e);
}
// 返回空集合
return Collections.emptyList();
}
}

View File

@@ -0,0 +1,154 @@
package com.hanserwei.airobot.service.impl;
import com.hanserwei.airobot.model.dto.SearchResultDTO;
import com.hanserwei.airobot.service.SearchResultContentFetcherService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 搜索结果内容抓取服务实现类
* <p>
* 提供并发批量获取网页内容的功能,支持超时控制与异常处理。
* </p>
*/
@Service
@Slf4j
public class SearchResultContentFetcherServiceImpl implements SearchResultContentFetcherService {
@Resource
private OkHttpClient okHttpClient;
@Resource(name = "httpRequestExecutor")
private ThreadPoolTaskExecutor httpExecutor;
@Resource(name = "resultProcessingExecutor")
private ThreadPoolTaskExecutor processingExecutor;
/**
* 并发批量获取搜索结果页面的内容
*
* @param searchResults 待抓取的搜索结果列表
* @param timeout 超时时间
* @param unit 时间单位
* @return 包含页面内容的搜索结果列表的 CompletableFuture
*/
@Override
public CompletableFuture<List<SearchResultDTO>> batchFetch(List<SearchResultDTO> searchResults, long timeout, TimeUnit unit) {
// 步骤1为每个搜索结果创建独立的异步获取任务
List<CompletableFuture<SearchResultDTO>> futures = searchResults.stream()
.map(result -> asynFetchContentForResult(result, timeout, unit))
.toList();
// 步骤2合并所有独立任务为一个聚合任务
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
// 步骤3当所有任务完成后收集结果并提取纯文本内容
return allFutures.thenApplyAsync(v ->
futures.stream()
.map(future -> {
SearchResultDTO searchResult = future.join();
// 获取页面 HTML 代码
String html = searchResult.getContent();
if (StringUtils.isNotBlank(html)) {
// 提取 HTML 中的文本
searchResult.setContent(Jsoup.parse(html).text());
}
return searchResult;
})
.collect(Collectors.toList()),
processingExecutor
);
}
/**
* 异步获取单个 SearchResult 对象对应的页面内容
*
* @param result 搜索结果对象
* @param timeout 超时时间
* @param unit 时间单位
* @return 包含页面内容的 SearchResultDTO 的 CompletableFuture
*/
private CompletableFuture<SearchResultDTO> asynFetchContentForResult(
SearchResultDTO result,
long timeout,
TimeUnit unit) {
// 异步执行 HTTP 请求并设置超时及异常处理逻辑
return CompletableFuture.supplyAsync(() -> {
// 获取 HTML 内容
String html = syncFetchHtmlContent(result.getUrl());
return SearchResultDTO.builder()
.url(result.getUrl())
.score(result.getScore())
.content(html)
.build();
}, httpExecutor)
// 超时处理
.completeOnTimeout(createFallbackResult(result), timeout, unit)
// 异常处理
.exceptionally(e -> {
// 记录错误日志
log.error("## 获取页面内容异常, URL: {}", result.getUrl(), e);
return createFallbackResult(result);
});
}
/**
* 创建回退结果(请求失败时使用)
*
* @param searchResult 原始搜索结果对象
* @return 回退用的 SearchResultDTO 实例,其 content 字段为空字符串
*/
private SearchResultDTO createFallbackResult(SearchResultDTO searchResult) {
return SearchResultDTO.builder()
.url(searchResult.getUrl())
.score(searchResult.getScore())
.content("") // 空字符串表示获取页面内容失败
.build();
}
/**
* 同步获取指定 URL 的 HTML 内容
*
* @param url 目标网址
* @return 页面 HTML 内容;若发生异常或响应无效则返回空字符串
*/
private String syncFetchHtmlContent(String url) {
// 构建 HTTP GET 请求
Request request = new Request.Builder()
.url(url) // 设置要访问的目标 URL
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") // 设置浏览器标识,模拟真实浏览器访问
.header("Accept", "text/html") // 指定接受 HTML 格式的响应
.build();
try (Response response = okHttpClient.newCall(request).execute()) { // 执行请求并自动关闭响应资源
// 检查响应状态和内容
if (!response.isSuccessful() || response.body() == null) { // 响应失败或响应体为空
return ""; // 返回空字符串
}
// 读取响应体内容并返回
return response.body().string();
} catch (IOException e) { // 捕获网络 IO 异常
return ""; // 异常时返回空字符串
}
}
}

View File

@@ -1,11 +0,0 @@
package com.hanserwei.airobot.utils;
import org.jasypt.util.text.AES256TextEncryptor;
public class EncryptorUtil {
public static void main(String[] args) {
AES256TextEncryptor textEncryptor = new AES256TextEncryptor();
textEncryptor.setPassword("password");
System.out.println(textEncryptor.encrypt("sk-xxxxxxxxxxxxxx"));
}
}

View File

@@ -0,0 +1,124 @@
package com.hanserwei.airobot.utils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* JSON 工具类,提供对象与 JSON 字符串之间的相互转换功能。
* 支持普通对象、List、Set、Map 等结构的序列化和反序列化。
*/
public class JsonUtil {
private static ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static {
// 忽略未知属性,防止反序列化失败
OBJECT_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// 允许序列化空对象
OBJECT_MAPPER.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
// 注册 JavaTimeModule 以支持 LocalDateTime 的序列化
OBJECT_MAPPER.registerModules(new JavaTimeModule());
}
/**
* 初始化:统一使用 Spring Boot 个性化配置的 ObjectMapper
*
* @param objectMapper 外部传入的 ObjectMapper 实例
*/
public static void init(ObjectMapper objectMapper) {
OBJECT_MAPPER = objectMapper;
}
/**
* 将对象转换为 JSON 字符串
*
* @param obj 待转换的对象
* @return 转换后的 JSON 字符串
*/
@SneakyThrows
public static String toJsonString(Object obj) {
return OBJECT_MAPPER.writeValueAsString(obj);
}
/**
* 将 JSON 字符串转换为指定类型的对象
*
* @param jsonStr JSON 字符串
* @param clazz 目标对象的类类型
* @param <T> 泛型参数,表示目标对象的类型
* @return 转换后的对象实例,如果输入为空则返回 null
*/
@SneakyThrows
public static <T> T parseObject(String jsonStr, Class<T> clazz) {
if (StringUtils.isBlank(jsonStr)) {
return null;
}
return OBJECT_MAPPER.readValue(jsonStr, clazz);
}
/**
* 将 JSON 字符串转换为指定键值类型的 Map 对象
*
* @param jsonStr JSON 字符串
* @param keyClass Map 键的类型
* @param valueClass Map 值的类型
* @param <K> 泛型参数,表示 Map 键的类型
* @param <V> 泛型参数,表示 Map 值的类型
* @return 转换后的 Map 实例
* @throws Exception 当解析失败时抛出异常
*/
public static <K, V> Map<K, V> parseMap(String jsonStr, Class<K> keyClass, Class<V> valueClass) throws Exception {
// 构造 Map 类型并进行反序列化
return OBJECT_MAPPER.readValue(jsonStr, OBJECT_MAPPER.getTypeFactory().constructMapType(Map.class, keyClass, valueClass));
}
/**
* 将 JSON 字符串解析为指定元素类型的 List 对象
*
* @param jsonStr JSON 字符串
* @param clazz List 中元素的类型
* @param <T> 泛型参数,表示 List 元素的类型
* @return 转换后的 List 实例
* @throws Exception 当解析失败时抛出异常
*/
public static <T> List<T> parseList(String jsonStr, Class<T> clazz) throws Exception {
// 构造 List 类型并进行反序列化
return OBJECT_MAPPER.readValue(jsonStr, new TypeReference<List<T>>() {
@Override
public CollectionType getType() {
return OBJECT_MAPPER.getTypeFactory().constructCollectionType(List.class, clazz);
}
});
}
/**
* 将 JSON 字符串解析为指定元素类型的 Set 对象
*
* @param jsonStr JSON 字符串
* @param clazz Set 中元素的类型
* @param <T> 泛型参数,表示 Set 元素的类型
* @return 转换后的 Set 实例
* @throws Exception 当解析失败时抛出异常
*/
public static <T> Set<T> parseSet(String jsonStr, Class<T> clazz) throws Exception {
// 构造 Set 类型并进行反序列化
return OBJECT_MAPPER.readValue(jsonStr, new TypeReference<>() {
@Override
public CollectionType getType() {
return OBJECT_MAPPER.getTypeFactory().constructCollectionType(Set.class, clazz);
}
});
}
}

View File

@@ -0,0 +1,74 @@
package com.hanserwei.airobot.utils;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
import java.util.Objects;
@EqualsAndHashCode(callSuper = true)
@Data
public class PageResponse<T> extends Response<List<T>> {
/**
* 总记录数
*/
private long total = 0L;
/**
* 每页显示的记录数,默认每页显示 10 条
*/
private long size = 10L;
/**
* 当前页码
*/
private long current;
/**
* 总页数
*/
private long pages;
/**
* 创建成功的分页响应对象
* @param page Mybatis Plus 提供的分页接口
* @param data 分页数据列表
* @return 包含分页信息的成功响应对象
* @param <T> 数据类型泛型参数
*/
public static <T> PageResponse<T> success(IPage page, List<T> data) {
PageResponse<T> response = new PageResponse<>();
response.setSuccess(true);
response.setCurrent(Objects.isNull(page) ? 1L : page.getCurrent());
response.setSize(Objects.isNull(page) ? 10L : page.getSize());
response.setPages(Objects.isNull(page) ? 0L : page.getPages());
response.setTotal(Objects.isNull(page) ? 0L : page.getTotal());
response.setData(data);
return response;
}
/**
* 创建成功的分页响应对象
* @param total 总记录数
* @param current 当前页码
* @param size 每页显示的记录数
* @param data 分页数据列表
* @return 包含分页信息的成功响应对象
* @param <T> 数据类型泛型参数
*/
public static <T> PageResponse<T> success(long total, long current, long size, List<T> data) {
PageResponse<T> response = new PageResponse<>();
response.setSuccess(true);
response.setCurrent(current);
response.setSize(size);
// 计算总页数
int pages = (int) Math.ceil((double) total / size);
response.setPages(pages);
response.setTotal(total);
response.setData(data);
return response;
}
}

View File

@@ -0,0 +1,70 @@
package com.hanserwei.airobot.utils;
import com.hanserwei.airobot.exception.BaseExceptionInterface;
import com.hanserwei.airobot.exception.BizException;
import lombok.Data;
import java.io.Serializable;
@Data
public class Response<T> implements Serializable {
// 是否成功,默认为 true
private boolean success = true;
// 响应消息
private String message;
// 异常码
private String errorCode;
// 响应数据
private T data;
// =================================== 成功响应 ===================================
public static <T> Response<T> success() {
return new Response<>();
}
public static <T> Response<T> success(T data) {
Response<T> response = new Response<>();
response.setData(data);
return response;
}
// =================================== 失败响应 ===================================
public static <T> Response<T> fail() {
Response<T> response = new Response<>();
response.setSuccess(false);
return response;
}
public static <T> Response<T> fail(String errorMessage) {
Response<T> response = new Response<>();
response.setSuccess(false);
response.setMessage(errorMessage);
return response;
}
public static <T> Response<T> fail(String errorCode, String errorMessage) {
Response<T> response = new Response<>();
response.setSuccess(false);
response.setErrorCode(errorCode);
response.setMessage(errorMessage);
return response;
}
public static <T> Response<T> fail(BizException bizException) {
Response<T> response = new Response<>();
response.setSuccess(false);
response.setErrorCode(bizException.getErrorCode());
response.setMessage(bizException.getErrorMessage());
return response;
}
public static <T> Response<T> fail(BaseExceptionInterface baseExceptionInterface) {
Response<T> response = new Response<>();
response.setSuccess(false);
response.setErrorCode(baseExceptionInterface.getErrorCode());
response.setMessage(baseExceptionInterface.getErrorMessage());
return response;
}
}

View File

@@ -0,0 +1,30 @@
package com.hanserwei.airobot.utils;
import org.apache.commons.lang3.StringUtils;
public class StringUtil {
/**
* 截取用户问题的前面部分文字作为摘要
*
* @param message 用户问题
* @param maxLength 最大截取长度
* @return 摘要文本,如果原问题长度不足则返回原问题
*/
public static String truncate(String message, int maxLength) {
// 判空
if (StringUtils.isBlank(message)) {
return "";
}
String trimmed = message.trim();
// 如果文本长度小于等于最大长度,直接返回
if (trimmed.length() <= maxLength) {
return trimmed;
}
// 截取指定长度
return trimmed.substring(0, maxLength);
}
}

View File

@@ -2,16 +2,53 @@
spring:
application:
name: han-ai-robot-springboot
datasource:
driver-class-name: com.p6spy.engine.spy.P6SpyDriver # 数据库驱动类名
url: jdbc:p6spy:postgresql://localhost:5432/han_ai_robot # 数据库连接 URL
username: postgres # 数据库用户名
password: postgressql # 数据库密码
hikari: # HikariCP 连接池配置
pool-name: AI-Robot-HikariCP # 自定义连接池名称
auto-commit: true # 是否自动提交事务
connection-timeout: 30000 # 连接超时时间(毫秒)
idle-timeout: 600000 # 空闲连接存活最大时间(毫秒)
max-lifetime: 1800000 # 连接最大存活时间(毫秒)
minimum-idle: 5 # 最小空闲连接数
maximum-pool-size: 20 # 最大连接池大小
connection-test-query: SELECT 1 # 连接测试查询
validation-timeout: 5000 # 验证连接的有效性
ai:
deepseek:
api-key: ENC(MROXdiEHmWk08koE63bTzFqW52MaXLpMkM9Cyl40Ubj+Lw1yKeZuHLEcs6jTFY8ditY4gJ1365LMAY8Z9G1uwfYFYaYdb3NyijplX7GuDZA=) # 填写 DeepSeek Api Key, 改成你自己的
base-url: https://api.deepseek.com # DeepSeek 的请求 URL, 可不填,默认值为 api.deepseek.com
dashscope:
api-key: ENC(cMgcKZkFllyE88DIbGwLKot9Vg02co+gsmY8L8o4/o3UjhcmqO4lJzFU35Sx0n+qFG8pDL0wBjoWrT8X6BuRw9vNlQhY1LgRWHaF9S1zzyM=)
chat:
options:
model: deepseek-reasoner # 使用哪个模型
temperature: 0.8 # 温度值
model: qwen-plus
temperature: 0.5
chat:
memory:
repository:
cassandra:
keyspace: han_ai_robot
table: t_ai_chat_memory
time-to-live: 1095d
initialize-schema: true
okhttp: # OkHttp 客户端配置
connect-timeout: 5000 # 建立连接的最大等待时间(毫秒)
read-timeout: 30000 # 读取数据的最大等待时间(毫秒)
write-timeout: 15000 # 写入数据的最大等待时间(毫秒)
max-idle-connections: 200 # 连接池中保持的最大空闲连接数
keep-alive-duration: 5 # 空闲连接在连接池中的存活时间(分钟)
searxng: # SearXNG 搜索引擎配置
url: http://localhost:8888/search # SearXNG 服务的 API 端点地址
count: 10 # 每次从搜索结果中,提取的最大数量
jasypt:
encryptor:
password: ${jasypt.encryptor.password}
algorithm: PBEWithHMACSHA512AndAES_256
iv-generator-classname: org.jasypt.iv.RandomIvGenerator
logging:
level:
org.springframework.ai.chat.client.advisor: debug
config: classpath:log4j2.xml # 设置日志配置文件路径

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
status: 设置 Log4j2 内部日志的级别,可选值: OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL
monitorInterval: 配置自动检测间隔(秒),在此时间后检查配置文件是否修改并自动重新加载,无需重启应用
-->
<Configuration status="WARN" monitorInterval="30">
<!-- 定义全局属性 -->
<Properties>
<!-- 日志输出格式模式 -->
<Property name="LOG_PATTERN">%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Property>
<!-- 日志文件存储根目录 -->
<Property name="APP_LOG_ROOT">./logs</Property>
<!-- 使用日期变量作为日志文件名的一部分 -->
<Property name="LOG_DATE_PATTERN">%d{yyyy-MM-dd}</Property>
</Properties>
<Appenders>
<!-- 控制台输出 -->
<Console name="Console" target="SYSTEM_OUT">
<!-- 使用预定义的日志格式 -->
<PatternLayout pattern="${LOG_PATTERN}"/>
</Console>
<!--
滚动文件附加器
fileName: 当前正在写入的日志文件路径和名称
filePattern: 滚动(归档)日志文件的命名模式
-->
<RollingFile name="FileAppender"
fileName="${APP_LOG_ROOT}/application-${date:yyyy-MM-dd}.log"
filePattern="${APP_LOG_ROOT}/application-%d{yyyy-MM-dd}-%i.log">
<PatternLayout pattern="${LOG_PATTERN}"/>
<Policies>
<!-- 基于时间的滚动策略: 每天滚动一次 -->
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
<!-- 基于文件大小的滚动策略: 当日志文件达到 10MB 时滚动 -->
<SizeBasedTriggeringPolicy size="10 MB"/>
</Policies>
<!--
滚动策略配置: 自动删除旧日志文件
maxDepth: 搜索深度1 表示只搜索当前目录
-->
<DefaultRolloverStrategy>
<Delete basePath="${APP_LOG_ROOT}" maxDepth="1">
<!-- 匹配文件名模式 -->
<IfFileName glob="application-*.log" />
<!-- 保留最近 30 天的日志文件 -->
<IfLastModified age="30d" />
</Delete>
</DefaultRolloverStrategy>
</RollingFile>
<!--
错误专用日志附加器 - 只记录 ERROR 级别及以上的日志
-->
<RollingFile name="ErrorAppender"
fileName="${APP_LOG_ROOT}/error-${date:yyyy-MM-dd}.log"
filePattern="${APP_LOG_ROOT}/error-%d{yyyy-MM-dd}-%i.log">
<PatternLayout pattern="${LOG_PATTERN}"/>
<!-- Filters: 过滤器配置,只接受 ERROR 及以上级别的日志 -->
<Filters>
<ThresholdFilter level="ERROR" onMatch="ACCEPT" onMismatch="DENY"/>
</Filters>
<Policies>
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
<SizeBasedTriggeringPolicy size="10 MB"/>
</Policies>
<!-- 滚动策略: 最多保留 30 个归档文件 -->
<DefaultRolloverStrategy max="30"/>
</RollingFile>
</Appenders>
<Loggers>
<!--
根日志记录器: 所有日志记录的默认配置
level: 日志级别阈值,只有等于或高于此级别的日志才会被处理
-->
<Root level="info">
<!-- 引用控制台附加器 -->
<AppenderRef ref="Console"/>
<!-- 引用滚动文件附加器 -->
<AppenderRef ref="FileAppender"/>
<!-- 引用错误专用日志附加器 -->
<AppenderRef ref="ErrorAppender"/>
</Root>
<!-- 设置特定包的日志级别(未来备用) -->
<Logger name="com.example.demo" level="debug" additivity="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="FileAppender"/>
<AppenderRef ref="ErrorAppender"/>
</Logger>
</Loggers>
</Configuration>

View File

@@ -0,0 +1,33 @@
# 模块列表,根据版本选择合适的配置
modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory
# 自定义日志格式
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
# 日志输出到控制台
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
# 取消JDBC驱动注册
deregisterdrivers=true
# 使用前缀
useprefix=true
# 排除的日志类别
excludecategories=info,debug,result,commit,resultset
# 日期格式
dateformat=yyyy-MM-dd HH:mm:ss
# 实际驱动列表
# driverlist=org.h2.Driver
# 开启慢SQL记录
outagedetection=true
# 慢SQL记录标准单位
outagedetectioninterval=2
# 过滤 flw_ 开头的表 SQL 打印
filter=true
exclude=flw_*

View File

@@ -1,13 +0,0 @@
package com.hanserwei.airobot;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class HanAiRobotSpringbootApplicationTests {
@Test
void contextLoads() {
}
}

View File

@@ -0,0 +1,31 @@
package com.hanserwei.airobot;
import com.hanserwei.airobot.domain.dos.ChatDO;
import com.hanserwei.airobot.domain.mapper.ChatMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDateTime;
import java.util.UUID;
@SpringBootTest
class MybatisPlusTests {
@Resource
private ChatMapper chatMapper;
/**
* 添加数据
*/
@Test
void testInsert() {
chatMapper.insert(ChatDO.builder()
.uuid(UUID.randomUUID().toString())
.summary("新对话")
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.build());
}
}