feat(search): 增强笔记搜索功能支持类型筛选和多种排序方式

- 新增笔记类型筛选功能,支持图文和视频类型过滤
- 添加多种排序方式:最新发布、最多点赞、最多评论、最多收藏
- 实现综合排序逻辑,基于 function_score 查询优化搜索结果
- 增加搜索结果高亮显示标题功能
-重构查询构建逻辑,支持动态构建 bool 查询和 function_score 查询
- 添加 NoteSortTypeEnum 枚举类管理排序类型
- 优化搜索响应处理,支持高亮字段解析和数据格式化
- 更新 SearchNoteReqVO 模型,添加 type 和 sort 查询参数
- 改进异常处理逻辑,使用 IOException 替代通用 Exception
- 移除旧的 stream 处理方式,采用循环遍历处理搜索结果
This commit is contained in:
2025-11-01 22:34:58 +08:00
parent 3d33a73462
commit 34c7092abc
3 changed files with 252 additions and 97 deletions

View File

@@ -0,0 +1,39 @@
package com.hanserwei.hannote.search.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum NoteSortTypeEnum {
// 最新
LATEST(0),
// 最新点赞
MOST_LIKE(1),
// 最多评论
MOST_COMMENT(2),
// 最多收藏
MOST_COLLECT(3),
;
private final Integer code;
/**
* 根据类型 code 获取对应的枚举
*
* @param code 类型 code
* @return 枚举
*/
public static NoteSortTypeEnum valueOf(Integer code) {
for (NoteSortTypeEnum noteSortTypeEnum : NoteSortTypeEnum.values()) {
if (Objects.equals(code, noteSortTypeEnum.getCode())) {
return noteSortTypeEnum;
}
}
return null;
}
}

View File

@@ -19,4 +19,14 @@ public class SearchNoteReqVO {
@Min(value = 1, message = "页码不能小于 1") @Min(value = 1, message = "页码不能小于 1")
private Integer pageNo = 1; // 默认值为第一页 private Integer pageNo = 1; // 默认值为第一页
/**
* 笔记类型null综合 / 0图文 / 1视频
*/
private Integer type;
/**
* 排序null不限 / 0最新 / 1最多点赞 / 2最多评论 / 3最多收藏
*/
private Integer sort;
} }

View File

@@ -2,15 +2,18 @@ package com.hanserwei.hannote.search.service.impl;
import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.CollUtil;
import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.SortOptions;
import co.elastic.clients.elasticsearch._types.SortOrder; import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.elasticsearch._types.query_dsl.*; import co.elastic.clients.elasticsearch._types.query_dsl.*;
import co.elastic.clients.elasticsearch.core.SearchRequest; import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Highlight;
import co.elastic.clients.elasticsearch.core.search.HighlightField; import co.elastic.clients.elasticsearch.core.search.HighlightField;
import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.elasticsearch.core.search.Hit;
import co.elastic.clients.util.NamedValue; import co.elastic.clients.util.NamedValue;
import com.hanserwei.framework.common.response.PageResponse; import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.utils.NumberUtils; import com.hanserwei.framework.common.utils.NumberUtils;
import com.hanserwei.hannote.search.enums.NoteSortTypeEnum;
import com.hanserwei.hannote.search.index.NoteIndex; import com.hanserwei.hannote.search.index.NoteIndex;
import com.hanserwei.hannote.search.model.vo.SearchNoteReqVO; import com.hanserwei.hannote.search.model.vo.SearchNoteReqVO;
import com.hanserwei.hannote.search.model.vo.SearchNoteRspVO; import com.hanserwei.hannote.search.model.vo.SearchNoteRspVO;
@@ -19,10 +22,12 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors;
@Service @Service
@Slf4j @Slf4j
@@ -37,121 +42,222 @@ public class NoteServiceImpl implements NoteService {
String keyword = searchNoteReqVO.getKeyword(); String keyword = searchNoteReqVO.getKeyword();
// 当前页码 // 当前页码
Integer pageNo = searchNoteReqVO.getPageNo(); Integer pageNo = searchNoteReqVO.getPageNo();
// 笔记类型
Integer type = searchNoteReqVO.getType();
// 排序方式
Integer sort = searchNoteReqVO.getSort();
int pageSize = 10; // 每页展示数据量 // --- 2. 分页参数 ---
int from = (pageNo - 1) * pageSize; // 偏移量 int pageSize = 10;
int from = (pageNo - 1) * pageSize;
// 1. 构建基础 Multi-Match Query (原始查询条件) //条件查询
// 创建查询条件
// "query": {
// "bool": {
// "must": [
// {
// "multi_match": {
// "query": "壁纸",
// "fields": [
// "title^2.0",
// "topic^1.0"
// ]
// }
// }
// ],
// "filter": [
// {
// "term": {
// "type": {
// "value": 0
// }
// }
// }
// ]
// }
// },
BoolQuery.Builder boolQueryBuilder = new BoolQuery.Builder();
MultiMatchQuery multiMatchQuery = MultiMatchQuery.of(m -> m MultiMatchQuery multiMatchQuery = MultiMatchQuery.of(m -> m
.query(keyword) .query(keyword)
// 字段权重设置,与 RHL 的 .field(field, boost) 对应 // 新客户端推荐在字段名中直接附加权重
.fields(NoteIndex.FIELD_NOTE_TITLE + "^2.0", NoteIndex.FIELD_NOTE_TOPIC) .fields(NoteIndex.FIELD_NOTE_TITLE + "^2.0", NoteIndex.FIELD_NOTE_TOPIC)
); );
Query functionScoreQuery = Query.of(q -> q boolQueryBuilder.must(multiMatchQuery);
.functionScore(fs -> fs // 3.2. 构建 term (filter)
// 设置初始查询条件 if (Objects.nonNull(type)) {
.query(innerQuery -> innerQuery.multiMatch(multiMatchQuery)) boolQueryBuilder.filter(f -> f
.scoreMode(FunctionScoreMode.Sum) .term(t -> t
.boostMode(FunctionBoostMode.Sum) .field(NoteIndex.FIELD_NOTE_TYPE)
// 设置function数组 .value(type) // .value() 会自动处理 Integer, Long, String 等
.functions(List.of( )
// 评分函数1 );
FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf }
.field(NoteIndex.FIELD_NOTE_LIKE_TOTAL) BoolQuery boolQuery = boolQueryBuilder.build();
.factor(0.5) // --- 4. 构建排序 (Sort) 和 FunctionScore ---
.modifier(FieldValueFactorModifier.Sqrt) Query finalQuery;
.missing(0.0))), List<SortOptions> sortOptions = CollUtil.newArrayList();
// 评分函数2 NoteSortTypeEnum noteSortTypeEnum = NoteSortTypeEnum.valueOf(sort);
FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf if (Objects.nonNull(noteSortTypeEnum)) {
.field(NoteIndex.FIELD_NOTE_COLLECT_TOTAL) // 4.1. CASE 1: 按字段排序
.factor(0.3) finalQuery = boolQuery._toQuery(); // 查询主体就是 bool 查询
.modifier(FieldValueFactorModifier.Sqrt)
.missing(0.0))), switch (noteSortTypeEnum) {
// 评分函数3 // 按笔记发布时间降序
FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf case LATEST -> sortOptions.add(SortOptions.of(s -> s
.field(NoteIndex.FIELD_NOTE_COMMENT_TOTAL) .field(f -> f.field(NoteIndex.FIELD_NOTE_CREATE_TIME).order(SortOrder.Desc))
.factor(0.2) ));
.modifier(FieldValueFactorModifier.Sqrt) // 按笔记点赞量降序
.missing(0.0))) case MOST_LIKE -> sortOptions.add(SortOptions.of(s -> s
)) .field(f -> f.field(NoteIndex.FIELD_NOTE_LIKE_TOTAL).order(SortOrder.Desc))
) ));
); // 按评论量降序
// 3. 构建 Highlight 配置 case MOST_COMMENT -> sortOptions.add(SortOptions.of(s -> s
.field(f -> f.field(NoteIndex.FIELD_NOTE_COMMENT_TOTAL).order(SortOrder.Desc))
));
// 按收藏量降序
case MOST_COLLECT -> sortOptions.add(SortOptions.of(s -> s
.field(f -> f.field(NoteIndex.FIELD_NOTE_COLLECT_TOTAL).order(SortOrder.Desc))
));
}
} else {
// 4.2. CASE 2: 综合排序 (Function Score)
// 综合排序,按 _score 降序
sortOptions.add(SortOptions.of(s -> s.field(f -> f.field("_score").order(SortOrder.Desc))));
// 4.2.1. 构建 function_score 的 functions 列表
List<FunctionScore> functions = new ArrayList<>();
// Function 1: like_total
functions.add(FunctionScore.of(fs -> fs
.fieldValueFactor(fvf -> fvf
.field(NoteIndex.FIELD_NOTE_LIKE_TOTAL)
.factor(0.5) // 新版客户端使用 double
.modifier(FieldValueFactorModifier.Sqrt)
.missing(0.0) // missing 值也为 double
)
));
// 创建 FilterFunctionBuilder 数组
// "functions": [
// {
// "field_value_factor": {
// "field": "like_total",
// "factor": 0.5,
// "modifier": "sqrt",
// "missing": 0
// }
// },
// {
// "field_value_factor": {
// "field": "collect_total",
// "factor": 0.3,
// "modifier": "sqrt",
// "missing": 0
// }
// },
// {
// "field_value_factor": {
// "field": "comment_total",
// "factor": 0.2,
// "modifier": "sqrt",
// "missing": 0
// }
// }
// ],
// Function 2: collect_total
functions.add(FunctionScore.of(fs -> fs
.fieldValueFactor(fvf -> fvf
.field(NoteIndex.FIELD_NOTE_COLLECT_TOTAL)
.factor(0.3)
.modifier(FieldValueFactorModifier.Sqrt)
.missing(0.0)
)
));
// Function 3: comment_total
functions.add(FunctionScore.of(fs -> fs
.fieldValueFactor(fvf -> fvf
.field(NoteIndex.FIELD_NOTE_COMMENT_TOTAL)
.factor(0.2)
.modifier(FieldValueFactorModifier.Sqrt)
.missing(0.0)
)
));
// 4.2.2. 构建 FunctionScoreQuery
FunctionScoreQuery functionScoreQuery = FunctionScoreQuery.of(fsq -> fsq
.query(boolQuery._toQuery()) // 基础查询
.functions(functions) // 评分函数
.scoreMode(FunctionScoreMode.Sum) // 对应 score_mode
.boostMode(FunctionBoostMode.Sum) // 对应 boost_mode
);
finalQuery = functionScoreQuery._toQuery(); // 最终查询是 function_score
}
// --- 5. 构建高亮 (Highlight) ---
HighlightField titleHighlight = HighlightField.of(hf -> hf HighlightField titleHighlight = HighlightField.of(hf -> hf
.preTags("<strong>") .preTags("<strong>")
.postTags("</strong>") .postTags("</strong>")
); );
// 4. 构建最终的 SearchRequest Highlight highlight = Highlight.of(h -> h.fields(NamedValue.of(NoteIndex.FIELD_NOTE_TITLE, titleHighlight)));
SearchRequest searchRequest = SearchRequest.of(r -> r // --- 6. 构建最终的 SearchRequest ---
SearchRequest searchRequest = SearchRequest.of(s -> s
.index(NoteIndex.NAME) .index(NoteIndex.NAME)
.query(functionScoreQuery) // 设置 function_score 查询 .query(finalQuery) // 设置查询
.sort(sortOptions) // 设置排序
// 排序:按 _score 降序 .from(from) // 设置分页
.sort(s -> s.score(d -> d.order(SortOrder.Desc))) .size(pageSize) // 设置分页
.highlight(highlight) // 设置高亮
// 分页
.from(from)
.size(pageSize)
// 高亮
.highlight(h -> h
.fields(NamedValue.of(NoteIndex.FIELD_NOTE_TITLE, titleHighlight))
)
); );
// 返参 VO 集合 // --- 7. 执行查询和解析响应 ---
List<SearchNoteRspVO> searchNoteRspVOS = null; List<SearchNoteRspVO> searchNoteRspVOS = new ArrayList<>();
long total = 0; long total = 0;
try { try {
log.info("==> NoteSearchRequest: {}", searchRequest.toString()); log.info("==> SearchRequest: {}", searchRequest.toString());
// ⭐️ 执行查询请求,并自动反序列化文档源到 SearchNoteRspVO // 执行查询请求
SearchResponse<SearchNoteRspVO> searchResponse = SearchResponse<SearchNoteRspVO> searchResponse = client.search(searchRequest, SearchNoteRspVO.class);
client.search(searchRequest, SearchNoteRspVO.class); if (searchResponse.hits().total() != null) {
total = searchResponse.hits().total().value();
total = searchResponse.hits().total() != null ? searchResponse.hits().total().value() : 0; }
log.info("==> 命中文档总数, hits: {}", total); log.info("==> 命中文档总数, hits: {}", total);
List<Hit<SearchNoteRspVO>> hits = searchResponse.hits().hits();
for (Hit<SearchNoteRspVO> hit : hits) {
// 获取source
SearchNoteRspVO source = hit.source();
// 7.3. 获取高亮字段
String highlightedTitle = null;
Map<String, List<String>> highlightFields = hit.highlight();
if (highlightFields.containsKey(NoteIndex.FIELD_NOTE_TITLE)) {
highlightedTitle = highlightFields.get(NoteIndex.FIELD_NOTE_TITLE).getFirst();
}
if (source != null) {
Long noteId = source.getNoteId();
String cover = source.getCover();
String title = source.getTitle();
String highlightTitle = source.getHighlightTitle();
String avatar = source.getAvatar();
String nickname = source.getNickname();
LocalDateTime updateTime = source.getUpdateTime();
String likeTotal = source.getLikeTotal();
searchNoteRspVOS.add(SearchNoteRspVO.builder()
.noteId(noteId)
.cover(cover)
.title(title)
.highlightTitle(highlightTitle)
.avatar(avatar)
.nickname(nickname)
.updateTime(updateTime)
.highlightTitle(highlightedTitle)
.likeTotal(NumberUtils.formatNumberString(Long.parseLong(likeTotal)))
.build());
// ⭐️ 处理搜索结果:合并原始文档和高亮数据 }
searchNoteRspVOS = searchResponse.hits().hits().stream() }
.map(this::processNoteHit) } catch (IOException e) {
.filter(Objects::nonNull) log.error("==> 搜索笔记异常: {}", e.getMessage());
.collect(Collectors.toList());
} catch (Exception e) {
log.error("==> 查询 Elasticsearch 异常: ", e);
} }
return PageResponse.success(searchNoteRspVOS, pageNo, total); return PageResponse.success(searchNoteRspVOS, pageNo, total);
} }
/**
* 辅助方法:处理 Hit合并 Source 和 Highlight 数据
*/
private SearchNoteRspVO processNoteHit(Hit<SearchNoteRspVO> hit) {
SearchNoteRspVO rspVO = hit.source();
if (rspVO == null) {
return null;
}
// 2. ⭐️ 处理高亮字段
Map<String, List<String>> highlights = hit.highlight();
if (CollUtil.isNotEmpty(highlights)) {
List<String> titleHighlights = highlights.get(NoteIndex.FIELD_NOTE_TITLE);
if (CollUtil.isNotEmpty(titleHighlights)) {
// 设置高亮标题
rspVO.setHighlightTitle(titleHighlights.getFirst());
}
}
// 3. 确保 highlightTitle 有值 (如果没有高亮结果,使用原始 title)
if (rspVO.getHighlightTitle() == null) {
rspVO.setHighlightTitle(rspVO.getTitle());
}
// 4. 处理特殊格式化(如 RHL 代码中的点赞数格式化)
if (rspVO.getLikeTotal() != null) {
rspVO.setLikeTotal(NumberUtils.formatNumberString(Long.parseLong(rspVO.getLikeTotal())));
}
return rspVO;
}
} }