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}