From fdab553ba1ddab8d8bd1cfc712fd4a611eadff11 Mon Sep 17 00:00:00 2001 From: Hanserwei <2628273921@qq.com> Date: Mon, 3 Nov 2025 22:08:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=AE=9E=E7=8E=B0=E8=81=94?= =?UTF-8?q?=E7=BD=91=E6=90=9C=E7=B4=A2=E4=B8=8E=E5=AF=B9=E8=AF=9D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD=20-=20=E6=96=B0=E5=A2=9E=20OkHttp?= =?UTF-8?q?=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=85=8D=E7=BD=AE=E5=8F=8A?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=20-=20=E6=B7=BB=E5=8A=A0=20SearXNG=20?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=BC=95=E6=93=8E=E9=9B=86=E6=88=90=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20-=20=E5=88=9B=E5=BB=BA=E5=9F=BA=E7=A1=80=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E6=9F=A5=E8=AF=A2=E7=B1=BB=20BasePageQuery=20-=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=BD=91=E7=BB=9C=E6=90=9C=E7=B4=A2=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E9=A1=BE=E9=97=AE=20NetworkSearchAdvisor=20-=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=81=8A=E5=A4=A9=E5=8E=86=E5=8F=B2=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=92=8C=E5=AF=B9=E8=AF=9D=E7=9A=84=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3=20-=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=91=98=E8=A6=81=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E4=B8=8E=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD=20-=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20MyBatis=20Plus=20=E5=88=86=E9=A1=B5=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=94=AF=E6=8C=81=20-=20=E5=BC=95=E5=85=A5=20Jsoup?= =?UTF-8?q?=E7=94=A8=E4=BA=8E=E7=BD=91=E9=A1=B5=E5=86=85=E5=AE=B9=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=20-=20=E6=96=B0=E5=A2=9E=20Hutool=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=BA=93=E4=BE=9D=E8=B5=96=20-=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C=E5=86=85=E5=AE=B9=E6=8A=93?= =?UTF-8?q?=E5=8F=96=E6=9C=8D=E5=8A=A1=20-=20=E6=B7=BB=E5=8A=A0=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E7=BB=93=E6=9E=9C=20DTO=20=E5=92=8C=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E6=8E=A5=E5=8F=A3=20-=20=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E7=A0=81=E6=9E=9A=E4=B8=BE=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E4=B8=8D=E5=AD=98=E5=9C=A8=E6=83=85=E5=86=B5?= =?UTF-8?q?=20-=20=E6=96=B0=E5=A2=9E=E5=A4=9A=E4=B8=AA=20VO=20=E7=B1=BB?= =?UTF-8?q?=E7=94=A8=E4=BA=8E=E8=AF=B7=E6=B1=82=E5=92=8C=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 28 +++ .../airobot/advisor/NetworkSearchAdvisor.java | 172 ++++++++++++++++++ .../airobot/config/MybatisPlusConfig.java | 14 ++ .../airobot/config/OkHttpConfig.java | 31 ++++ .../airobot/config/ThreadPoolConfig.java | 45 +++++ .../airobot/controller/ChatController.java | 46 ++++- .../airobot/domain/mapper/ChatMapper.java | 22 +++ .../domain/mapper/ChatMessageMapper.java | 23 +++ .../airobot/enums/ResponseCodeEnum.java | 1 + .../airobot/model/common/BasePageQuery.java | 15 ++ .../airobot/model/dto/SearchResultDTO.java | 28 +++ .../model/vo/chat/DeleteChatReqVO.java | 18 ++ .../FindChatHistoryMessagePageListReqVO.java | 16 ++ .../FindChatHistoryMessagePageListRspVO.java | 36 ++++ .../vo/chat/FindChatHistoryPageListReqVO.java | 14 ++ .../vo/chat/FindChatHistoryPageListRspVO.java | 32 ++++ .../model/vo/chat/RenameChatReqVO.java | 22 +++ .../airobot/service/ChatService.java | 37 +++- .../airobot/service/SearXNGService.java | 9 + .../SearchResultContentFetcherService.java | 12 ++ .../airobot/service/impl/ChatServiceImpl.java | 113 +++++++++++- .../service/impl/SearXNGServiceImpl.java | 110 +++++++++++ ...SearchResultContentFetcherServiceImpl.java | 154 ++++++++++++++++ .../hanserwei/airobot/utils/PageResponse.java | 74 ++++++++ src/main/resources/application.yml | 11 ++ 25 files changed, 1076 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/hanserwei/airobot/advisor/NetworkSearchAdvisor.java create mode 100644 src/main/java/com/hanserwei/airobot/config/OkHttpConfig.java create mode 100644 src/main/java/com/hanserwei/airobot/config/ThreadPoolConfig.java create mode 100644 src/main/java/com/hanserwei/airobot/model/common/BasePageQuery.java create mode 100644 src/main/java/com/hanserwei/airobot/model/dto/SearchResultDTO.java create mode 100644 src/main/java/com/hanserwei/airobot/model/vo/chat/DeleteChatReqVO.java create mode 100644 src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryMessagePageListReqVO.java create mode 100644 src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryMessagePageListRspVO.java create mode 100644 src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryPageListReqVO.java create mode 100644 src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryPageListRspVO.java create mode 100644 src/main/java/com/hanserwei/airobot/model/vo/chat/RenameChatReqVO.java create mode 100644 src/main/java/com/hanserwei/airobot/service/SearXNGService.java create mode 100644 src/main/java/com/hanserwei/airobot/service/SearchResultContentFetcherService.java create mode 100644 src/main/java/com/hanserwei/airobot/service/impl/SearXNGServiceImpl.java create mode 100644 src/main/java/com/hanserwei/airobot/service/impl/SearchResultContentFetcherServiceImpl.java create mode 100644 src/main/java/com/hanserwei/airobot/utils/PageResponse.java diff --git a/pom.xml b/pom.xml index af2cca0..a37b4f2 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,9 @@ 4.38.0 3.9.1 33.0.0-jre + 4.12.0 + 1.17.2 + 5.8.39 @@ -104,6 +107,31 @@ guava ${guava.version} + + + com.squareup.okhttp3 + okhttp + ${okhttp.version} + + + + + org.jsoup + jsoup + ${jsoup.version} + + + + com.baomidou + mybatis-plus-jsqlparser + + + + + cn.hutool + hutool-all + ${hutool.version} + diff --git a/src/main/java/com/hanserwei/airobot/advisor/NetworkSearchAdvisor.java b/src/main/java/com/hanserwei/airobot/advisor/NetworkSearchAdvisor.java new file mode 100644 index 0000000..c73757c --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/advisor/NetworkSearchAdvisor.java @@ -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; + +/** + * 网络搜索增强顾问类,用于在聊天客户端处理用户请求前,通过联网搜索获取相关信息, + * 并将搜索结果作为上下文整合进原始提示词中以提升模型回答质量。 + * + *

该类实现了 {@link StreamAdvisor} 接口,支持流式调用链中的增强逻辑。

+ */ +@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. 在关键信息后标注对应的来源,包含编号与页面跳转链接,格式如 [来源1] + """); + + /** + * 构造方法,注入所需的外部服务依赖。 + * + * @param searXNGService 搜索引擎服务接口,用于发起网络搜索请求 + * @param searchResultContentFetcherService 内容抓取服务接口,用于并发获取网页正文内容 + */ + public NetworkSearchAdvisor(SearXNGService searXNGService, SearchResultContentFetcherService searchResultContentFetcherService) { + this.searXNGService = searXNGService; + this.searchResultContentFetcherService = searchResultContentFetcherService; + } + + /** + * 实现 StreamAdvisor 的核心方法,在聊天请求处理过程中插入联网搜索逻辑。 + * + *

流程包括:

+ * + * + * @param chatClientRequest 当前聊天客户端请求对象,包含原始提示词和其他配置 + * @param streamAdvisorChain 处理链对象,用于传递控制权到下一个顾问节点 + * @return 返回经过增强后的响应流 + */ + @NotNull + @Override + public Flux adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) { + // 获取用户输入的提示词 + Prompt prompt = chatClientRequest.prompt(); + UserMessage userMessage = prompt.getUserMessage(); + + // 调用 SearXNG 获取搜索结果 + List searchResults = searXNGService.search(userMessage.getText()); + + // 并发请求,获取搜索结果页面的内容 + CompletableFuture> resultsFuture = searchResultContentFetcherService.batchFetch(searchResults, 7, TimeUnit.SECONDS); + + List results = resultsFuture.join(); + + // 过滤掉获取失败的结果 + List 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); + } + + /** + * 将成功的搜索结果构建成结构化文本形式的上下文。 + * + *

每条记录包括其序号、相关性评分、URL 和实际抓取到的页面内容。

+ * + * @param successfulResults 成功抓取内容的搜索结果列表 + * @return 格式化后的上下文字符串 + */ + private String buildContext(List 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 值越小,越先执行 + } +} diff --git a/src/main/java/com/hanserwei/airobot/config/MybatisPlusConfig.java b/src/main/java/com/hanserwei/airobot/config/MybatisPlusConfig.java index 9db43da..7038617 100644 --- a/src/main/java/com/hanserwei/airobot/config/MybatisPlusConfig.java +++ b/src/main/java/com/hanserwei/airobot/config/MybatisPlusConfig.java @@ -1,9 +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; + } } \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/config/OkHttpConfig.java b/src/main/java/com/hanserwei/airobot/config/OkHttpConfig.java new file mode 100644 index 0000000..30bcde6 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/config/OkHttpConfig.java @@ -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(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/config/ThreadPoolConfig.java b/src/main/java/com/hanserwei/airobot/config/ThreadPoolConfig.java new file mode 100644 index 0000000..1f7d413 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/config/ThreadPoolConfig.java @@ -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; + } + +} diff --git a/src/main/java/com/hanserwei/airobot/controller/ChatController.java b/src/main/java/com/hanserwei/airobot/controller/ChatController.java index 43e1eca..c0ddabd 100644 --- a/src/main/java/com/hanserwei/airobot/controller/ChatController.java +++ b/src/main/java/com/hanserwei/airobot/controller/ChatController.java @@ -6,12 +6,14 @@ 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.AiChatReqVO; -import com.hanserwei.airobot.model.vo.chat.AiResponse; -import com.hanserwei.airobot.model.vo.chat.NewChatReqVO; +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; @@ -41,6 +43,10 @@ public class ChatController { private ChatMessageMapper chatMessageMapper; @Resource private TransactionTemplate transactionTemplate; + @Resource + private SearXNGService searXNGService; + @Resource + private SearchResultContentFetcherService searchResultContentFetcherService; @Value("${spring.ai.dashscope.api-key}") private String apiKey; @@ -77,8 +83,18 @@ public class ChatController { .build()) .user(message); + // 是否开启联网搜索 + boolean networkSearch = aiChatReqVO.getNetworkSearch(); + // Advisor 集合 List 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 @@ -93,4 +109,28 @@ public class ChatController { .mapNotNull(text -> AiResponse.builder().v(text).build()); } + @PostMapping("/message/list") + @ApiOperationLog(description = "查询对话历史消息") + public PageResponse findChatMessagePageList(@RequestBody @Validated FindChatHistoryMessagePageListReqVO findChatHistoryMessagePageListReqVO) { + return chatService.findChatHistoryMessagePageList(findChatHistoryMessagePageListReqVO); + } + + @PostMapping("/list") + @ApiOperationLog(description = "查询历史对话") + public PageResponse 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); + } + } \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/domain/mapper/ChatMapper.java b/src/main/java/com/hanserwei/airobot/domain/mapper/ChatMapper.java index adf6c94..5053781 100644 --- a/src/main/java/com/hanserwei/airobot/domain/mapper/ChatMapper.java +++ b/src/main/java/com/hanserwei/airobot/domain/mapper/ChatMapper.java @@ -1,7 +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 { + + + /** + * 分页查询聊天记录列表 + * @param current 当前页码 + * @param size 每页大小 + * @return 分页结果 + */ + default Page selectPageList(Long current, Long size) { + // 创建分页对象,指定当前页和每页数量 + Page page = new Page<>(current, size); + + // 构建查询条件,按更新时间倒序排列 + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .orderByDesc(ChatDO::getUpdateTime); + + // 执行分页查询并返回结果 + return selectPage(page, wrapper); + } } \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/domain/mapper/ChatMessageMapper.java b/src/main/java/com/hanserwei/airobot/domain/mapper/ChatMessageMapper.java index e0e2f92..9e2698f 100644 --- a/src/main/java/com/hanserwei/airobot/domain/mapper/ChatMessageMapper.java +++ b/src/main/java/com/hanserwei/airobot/domain/mapper/ChatMessageMapper.java @@ -1,8 +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 { + /** + * 分页查询聊天消息列表 + * + * @param current 当前页码 + * @param size 每页显示记录数 + * @param chatId 聊天对话ID + * @return 分页结果,包含聊天消息列表及相关分页信息 + */ + default Page selectPageList(Long current, Long size, String chatId) { + // 分页对象(查询第几页、每页多少数据) + Page page = new Page<>(current, size); + + // 构建查询条件 + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(ChatMessageDO::getChatUuid, chatId) // 对话 ID + .orderByDesc(ChatMessageDO::getCreateTime); // 按创建时间倒序 + + return selectPage(page, wrapper); + } + } \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/enums/ResponseCodeEnum.java b/src/main/java/com/hanserwei/airobot/enums/ResponseCodeEnum.java index 93e609d..31f14d4 100644 --- a/src/main/java/com/hanserwei/airobot/enums/ResponseCodeEnum.java +++ b/src/main/java/com/hanserwei/airobot/enums/ResponseCodeEnum.java @@ -14,6 +14,7 @@ public enum ResponseCodeEnum implements BaseExceptionInterface { // ----------- 业务异常状态码 ----------- + CHAT_NOT_EXISTED("20000", "此对话不存在"), // TODO 待填充 ; diff --git a/src/main/java/com/hanserwei/airobot/model/common/BasePageQuery.java b/src/main/java/com/hanserwei/airobot/model/common/BasePageQuery.java new file mode 100644 index 0000000..1271374 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/model/common/BasePageQuery.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/model/dto/SearchResultDTO.java b/src/main/java/com/hanserwei/airobot/model/dto/SearchResultDTO.java new file mode 100644 index 0000000..5e10357 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/model/dto/SearchResultDTO.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/model/vo/chat/DeleteChatReqVO.java b/src/main/java/com/hanserwei/airobot/model/vo/chat/DeleteChatReqVO.java new file mode 100644 index 0000000..6839b26 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/model/vo/chat/DeleteChatReqVO.java @@ -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; + +} \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryMessagePageListReqVO.java b/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryMessagePageListReqVO.java new file mode 100644 index 0000000..65d4159 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryMessagePageListReqVO.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryMessagePageListRspVO.java b/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryMessagePageListRspVO.java new file mode 100644 index 0000000..eee15cb --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryMessagePageListRspVO.java @@ -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; + +} diff --git a/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryPageListReqVO.java b/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryPageListReqVO.java new file mode 100644 index 0000000..43dab81 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryPageListReqVO.java @@ -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 { +} \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryPageListRspVO.java b/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryPageListRspVO.java new file mode 100644 index 0000000..039a67e --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/model/vo/chat/FindChatHistoryPageListRspVO.java @@ -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; + +} \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/model/vo/chat/RenameChatReqVO.java b/src/main/java/com/hanserwei/airobot/model/vo/chat/RenameChatReqVO.java new file mode 100644 index 0000000..342b5fd --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/model/vo/chat/RenameChatReqVO.java @@ -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; + +} \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/service/ChatService.java b/src/main/java/com/hanserwei/airobot/service/ChatService.java index de96f07..1ba242a 100644 --- a/src/main/java/com/hanserwei/airobot/service/ChatService.java +++ b/src/main/java/com/hanserwei/airobot/service/ChatService.java @@ -1,15 +1,48 @@ package com.hanserwei.airobot.service; -import com.hanserwei.airobot.model.vo.chat.NewChatReqVO; -import com.hanserwei.airobot.model.vo.chat.NewChatRspVO; +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 newChat(NewChatReqVO newChatReqVO); + + /** + * 查询历史消息 + * + * @param findChatHistoryMessagePageListReqVO 查询历史消息请求参数 + * @return 查询历史消息结果 + */ + PageResponse findChatHistoryMessagePageList(FindChatHistoryMessagePageListReqVO findChatHistoryMessagePageListReqVO); + + /** + * 查询历史对话 + * + * @param findChatHistoryPageListReqVO 查询历史对话请求参数 + * @return 查询历史对话结果 + */ + PageResponse findChatHistoryPageList(FindChatHistoryPageListReqVO findChatHistoryPageListReqVO); + + /** + * 重命名对话摘要 + * + * @param renameChatReqVO 重命名对话摘要请求参数 + * @return 重命名对话摘要结果 + */ + Response renameChatSummary(RenameChatReqVO renameChatReqVO); + + /** + * 删除对话 + * + * @param deleteChatReqVO 删除对话请求参数 + * @return 删除对话结果 + */ + Response deleteChat(DeleteChatReqVO deleteChatReqVO); } \ No newline at end of file diff --git a/src/main/java/com/hanserwei/airobot/service/SearXNGService.java b/src/main/java/com/hanserwei/airobot/service/SearXNGService.java new file mode 100644 index 0000000..2167a80 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/service/SearXNGService.java @@ -0,0 +1,9 @@ +package com.hanserwei.airobot.service; + +import com.hanserwei.airobot.model.dto.SearchResultDTO; + +import java.util.List; + +public interface SearXNGService { + List search(String query); +} diff --git a/src/main/java/com/hanserwei/airobot/service/SearchResultContentFetcherService.java b/src/main/java/com/hanserwei/airobot/service/SearchResultContentFetcherService.java new file mode 100644 index 0000000..8ea0bc3 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/service/SearchResultContentFetcherService.java @@ -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> batchFetch(List searchResults, long timeout, TimeUnit unit); +} diff --git a/src/main/java/com/hanserwei/airobot/service/impl/ChatServiceImpl.java b/src/main/java/com/hanserwei/airobot/service/impl/ChatServiceImpl.java index 244c8f6..d157838 100644 --- a/src/main/java/com/hanserwei/airobot/service/impl/ChatServiceImpl.java +++ b/src/main/java/com/hanserwei/airobot/service/impl/ChatServiceImpl.java @@ -1,23 +1,36 @@ 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.model.vo.chat.NewChatReqVO; -import com.hanserwei.airobot.model.vo.chat.NewChatRspVO; +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 newChat(NewChatReqVO newChatReqVO) { @@ -42,4 +55,100 @@ public class ChatServiceImpl implements ChatService { .summary(summary) .build()); } + + @Override + public PageResponse findChatHistoryMessagePageList(FindChatHistoryMessagePageListReqVO findChatHistoryMessagePageListReqVO) { + // 获取当前页、以及每页需要展示的数据数量 + Long current = findChatHistoryMessagePageListReqVO.getCurrent(); + Long size = findChatHistoryMessagePageListReqVO.getSize(); + String chatId = findChatHistoryMessagePageListReqVO.getChatId(); + + // 执行分页查询 + Page chatMessageDOPage = chatMessageMapper.selectPageList(current, size, chatId); + + List chatMessageDOS = chatMessageDOPage.getRecords(); + // DO 转 VO + List 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 findChatHistoryPageList(FindChatHistoryPageListReqVO findChatHistoryPageListReqVO) { + // 获取当前页、以及每页需要展示的数据数量 + Long current = findChatHistoryPageListReqVO.getCurrent(); + Long size = findChatHistoryPageListReqVO.getSize(); + + // 执行分页查询 + Page chatDOPage = chatMapper.selectPageList(current, size); + + // 获取查询结果 + List chatDOS = chatDOPage.getRecords(); + + // DO 转 VO + List 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.lambdaQuery() + .eq(ChatDO::getUuid, uuid)); + + // 如果删除操作影响的行数为 0,说明想要删除的对话不存在 + if (count == 0) { + throw new BizException(ResponseCodeEnum.CHAT_NOT_EXISTED); + } + + // 批量删除对话下的所有消息 + chatMessageMapper.delete(Wrappers.lambdaQuery() + .eq(ChatMessageDO::getChatUuid, uuid)); + + return Response.success(); + } } diff --git a/src/main/java/com/hanserwei/airobot/service/impl/SearXNGServiceImpl.java b/src/main/java/com/hanserwei/airobot/service/impl/SearXNGServiceImpl.java new file mode 100644 index 0000000..7c8f8fd --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/service/impl/SearXNGServiceImpl.java @@ -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 搜索服务实现类 + *

+ * 该类通过调用 SearXNG 的 API 实现聚合搜索引擎功能,支持从多个搜索引擎获取结果并按评分排序。 + *

+ */ +@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 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 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(); + } +} diff --git a/src/main/java/com/hanserwei/airobot/service/impl/SearchResultContentFetcherServiceImpl.java b/src/main/java/com/hanserwei/airobot/service/impl/SearchResultContentFetcherServiceImpl.java new file mode 100644 index 0000000..0875aa5 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/service/impl/SearchResultContentFetcherServiceImpl.java @@ -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; + +/** + * 搜索结果内容抓取服务实现类 + *

+ * 提供并发批量获取网页内容的功能,支持超时控制与异常处理。 + *

+ */ +@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> batchFetch(List searchResults, long timeout, TimeUnit unit) { + // 步骤1:为每个搜索结果创建独立的异步获取任务 + List> futures = searchResults.stream() + .map(result -> asynFetchContentForResult(result, timeout, unit)) + .toList(); + + // 步骤2:合并所有独立任务为一个聚合任务 + CompletableFuture 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 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 ""; // 异常时返回空字符串 + } + } +} diff --git a/src/main/java/com/hanserwei/airobot/utils/PageResponse.java b/src/main/java/com/hanserwei/airobot/utils/PageResponse.java new file mode 100644 index 0000000..f2f7c67 --- /dev/null +++ b/src/main/java/com/hanserwei/airobot/utils/PageResponse.java @@ -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 extends Response> { + + /** + * 总记录数 + */ + private long total = 0L; + + /** + * 每页显示的记录数,默认每页显示 10 条 + */ + private long size = 10L; + + /** + * 当前页码 + */ + private long current; + + /** + * 总页数 + */ + private long pages; + + /** + * 创建成功的分页响应对象 + * @param page Mybatis Plus 提供的分页接口 + * @param data 分页数据列表 + * @return 包含分页信息的成功响应对象 + * @param 数据类型泛型参数 + */ + public static PageResponse success(IPage page, List data) { + PageResponse 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 数据类型泛型参数 + */ + public static PageResponse success(long total, long current, long size, List data) { + PageResponse 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; + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bd14655..9647460 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,6 +32,17 @@ spring: 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}