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")
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 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.query_dsl.*;
import co.elastic.clients.elasticsearch.core.SearchRequest;
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.Hit;
import co.elastic.clients.util.NamedValue;
import com.hanserwei.framework.common.response.PageResponse;
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.model.vo.SearchNoteReqVO;
import com.hanserwei.hannote.search.model.vo.SearchNoteRspVO;
@@ -19,10 +22,12 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
@Slf4j
@@ -37,121 +42,222 @@ public class NoteServiceImpl implements NoteService {
String keyword = searchNoteReqVO.getKeyword();
// 当前页码
Integer pageNo = searchNoteReqVO.getPageNo();
// 笔记类型
Integer type = searchNoteReqVO.getType();
// 排序方式
Integer sort = searchNoteReqVO.getSort();
int pageSize = 10; // 每页展示数据量
int from = (pageNo - 1) * pageSize; // 偏移量
// --- 2. 分页参数 ---
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
.query(keyword)
// 字段权重设置,与 RHL 的 .field(field, boost) 对应
// 新客户端推荐在字段名中直接附加权重
.fields(NoteIndex.FIELD_NOTE_TITLE + "^2.0", NoteIndex.FIELD_NOTE_TOPIC)
);
Query functionScoreQuery = Query.of(q -> q
.functionScore(fs -> fs
// 设置初始查询条件
.query(innerQuery -> innerQuery.multiMatch(multiMatchQuery))
.scoreMode(FunctionScoreMode.Sum)
.boostMode(FunctionBoostMode.Sum)
// 设置function数组
.functions(List.of(
// 评分函数1
FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf
.field(NoteIndex.FIELD_NOTE_LIKE_TOTAL)
.factor(0.5)
.modifier(FieldValueFactorModifier.Sqrt)
.missing(0.0))),
// 评分函数2
FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf
.field(NoteIndex.FIELD_NOTE_COLLECT_TOTAL)
.factor(0.3)
.modifier(FieldValueFactorModifier.Sqrt)
.missing(0.0))),
// 评分函数3
FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf
.field(NoteIndex.FIELD_NOTE_COMMENT_TOTAL)
.factor(0.2)
.modifier(FieldValueFactorModifier.Sqrt)
.missing(0.0)))
))
)
);
// 3. 构建 Highlight 配置
boolQueryBuilder.must(multiMatchQuery);
// 3.2. 构建 term (filter)
if (Objects.nonNull(type)) {
boolQueryBuilder.filter(f -> f
.term(t -> t
.field(NoteIndex.FIELD_NOTE_TYPE)
.value(type) // .value() 会自动处理 Integer, Long, String 等
)
);
}
BoolQuery boolQuery = boolQueryBuilder.build();
// --- 4. 构建排序 (Sort) 和 FunctionScore ---
Query finalQuery;
List<SortOptions> sortOptions = CollUtil.newArrayList();
NoteSortTypeEnum noteSortTypeEnum = NoteSortTypeEnum.valueOf(sort);
if (Objects.nonNull(noteSortTypeEnum)) {
// 4.1. CASE 1: 按字段排序
finalQuery = boolQuery._toQuery(); // 查询主体就是 bool 查询
switch (noteSortTypeEnum) {
// 按笔记发布时间降序
case LATEST -> sortOptions.add(SortOptions.of(s -> s
.field(f -> f.field(NoteIndex.FIELD_NOTE_CREATE_TIME).order(SortOrder.Desc))
));
// 按笔记点赞量降序
case MOST_LIKE -> sortOptions.add(SortOptions.of(s -> s
.field(f -> f.field(NoteIndex.FIELD_NOTE_LIKE_TOTAL).order(SortOrder.Desc))
));
// 按评论量降序
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
.preTags("<strong>")
.postTags("</strong>")
);
// 4. 构建最终的 SearchRequest
SearchRequest searchRequest = SearchRequest.of(r -> r
Highlight highlight = Highlight.of(h -> h.fields(NamedValue.of(NoteIndex.FIELD_NOTE_TITLE, titleHighlight)));
// --- 6. 构建最终的 SearchRequest ---
SearchRequest searchRequest = SearchRequest.of(s -> s
.index(NoteIndex.NAME)
.query(functionScoreQuery) // 设置 function_score 查询
// 排序:按 _score 降序
.sort(s -> s.score(d -> d.order(SortOrder.Desc)))
// 分页
.from(from)
.size(pageSize)
// 高亮
.highlight(h -> h
.fields(NamedValue.of(NoteIndex.FIELD_NOTE_TITLE, titleHighlight))
)
.query(finalQuery) // 设置查询
.sort(sortOptions) // 设置排序
.from(from) // 设置分页
.size(pageSize) // 设置分页
.highlight(highlight) // 设置高亮
);
// 返参 VO 集合
List<SearchNoteRspVO> searchNoteRspVOS = null;
// --- 7. 执行查询和解析响应 ---
List<SearchNoteRspVO> searchNoteRspVOS = new ArrayList<>();
long total = 0;
try {
log.info("==> NoteSearchRequest: {}", searchRequest.toString());
log.info("==> SearchRequest: {}", searchRequest.toString());
// ⭐️ 执行查询请求,并自动反序列化文档源到 SearchNoteRspVO
SearchResponse<SearchNoteRspVO> searchResponse =
client.search(searchRequest, SearchNoteRspVO.class);
total = searchResponse.hits().total() != null ? searchResponse.hits().total().value() : 0;
// 执行查询请求
SearchResponse<SearchNoteRspVO> searchResponse = client.search(searchRequest, SearchNoteRspVO.class);
if (searchResponse.hits().total() != null) {
total = searchResponse.hits().total().value();
}
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)
.filter(Objects::nonNull)
.collect(Collectors.toList());
} catch (Exception e) {
log.error("==> 查询 Elasticsearch 异常: ", e);
}
}
} catch (IOException e) {
log.error("==> 搜索笔记异常: {}", e.getMessage());
}
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;
}
}