Compare commits

...

4 Commits

Author SHA1 Message Date
39d2eb1063 feat(search): 集成 Canal 实现数据库变更监听与词典热更新
- 新增 Canal 客户端配置与连接管理
- 实现 Canal 数据订阅与消费调度任务
- 添加外部词典热更新接口与服务实现- 配置 Elasticsearch词典热更新支持
- 引入 Canal 相关依赖并统一版本管理- 启用 Spring 定时任务支持以驱动 Canal 消费- 增加项目词典以优化拼写检查准确性
2025-11-02 19:03:26 +08:00
96b4127873 feat(search): 集成 Canal 实现数据库变更监听与词典热更新
- 新增 Canal 客户端配置与连接管理
- 实现 Canal 数据订阅与消费调度任务
- 添加外部词典热更新接口与服务实现- 配置 Elasticsearch词典热更新支持
- 引入 Canal 相关依赖并统一版本管理- 启用 Spring 定时任务支持以驱动 Canal 消费- 增加项目词典以优化拼写检查准确性
2025-11-02 19:02:52 +08:00
7c62f1dcf9 feat(search): 增加笔记发布时间范围筛选功能
- 在 DateConstants 中新增 MM-dd 和 HH:mm 时间格式常量
- 在 DateUtils 中增加 localDateTime2String 和 formatRelativeTime 方法- 新增 NotePublishTimeRangeEnum 枚举类用于定义发布时间范围
- 在搜索服务中实现按发布时间范围筛选逻辑
- 修改 SearchNoteReqVO 添加 publishTimeRange 参数
- 修改 SearchNoteRspVO 将 updateTime 改为字符串类型并新增评论数和收藏数字段
- 更新搜索结果处理逻辑以支持新的时间格式化和数据展示
2025-11-02 14:40:42 +08:00
1335582827 fix(search):修复用户搜索服务中的空指针异常和高亮逻辑
- 修复了likeTotal字段为null时的空指针异常- 重构用户搜索逻辑,优化查询构建和响应处理
- 移除了过时的Guava Lists依赖,使用ArrayList替代
- 改进了高亮字段处理逻辑,确保正确提取高亮内容
- 更新异常处理类型从Exception到具体的IOException-优化代码结构,添加注释分段标识提高可读性- 调整粉丝总数格式化逻辑,增强空值处理能力
2025-11-02 14:13:10 +08:00
17 changed files with 572 additions and 109 deletions

View File

@@ -2,6 +2,7 @@
<dictionary name="project"> <dictionary name="project">
<words> <words>
<w>asyn</w> <w>asyn</w>
<w>entrys</w>
<w>hannote</w> <w>hannote</w>
<w>hanserwei</w> <w>hanserwei</w>
<w>jobhandler</w> <w>jobhandler</w>

View File

@@ -63,6 +63,20 @@
<groupId>org.elasticsearch.client</groupId> <groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId> <artifactId>elasticsearch-rest-client</artifactId>
</dependency> </dependency>
<!-- Canal -->
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.common</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.protocol</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -2,8 +2,10 @@ package com.hanserwei.hannote.search;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling
public class HannoteSearchApplication { public class HannoteSearchApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(HannoteSearchApplication.class, args); SpringApplication.run(HannoteSearchApplication.class, args);

View File

@@ -0,0 +1,64 @@
package com.hanserwei.hannote.search.canal;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.net.InetSocketAddress;
import java.util.Objects;
@Component
@Slf4j
public class CanalClient implements DisposableBean {
@Resource
private CanalProperties canalProperties;
private CanalConnector canalConnector;
/**
* 实例化 Canal 链接对象
*
* @return CanalConnector
*/
@Bean
public CanalConnector getCanalConnector() {
// Canal 链接地址
String address = canalProperties.getAddress();
String[] addressArr = address.split(":");
// IP 地址
String host = addressArr[0];
// 端口
int port = Integer.parseInt(addressArr[1]);
// 创建一个 CanalConnector 实例,连接到指定的 Canal 服务端
canalConnector = CanalConnectors.newSingleConnector(
new InetSocketAddress(host, port),
canalProperties.getDestination(),
canalProperties.getUsername(),
canalProperties.getPassword());
// 连接到 Canal 服务端
canalConnector.connect();
// 订阅 Canal 中的数据变化,指定要监听的数据库和表(可以使用表名、数据库名的通配符)
canalConnector.subscribe(canalProperties.getSubscribe());
// 回滚 Canal 消费者的位点,回滚到上次提交的消费位置
canalConnector.rollback();
return canalConnector;
}
/**
* 在 Spring 容器销毁时释放资源
*/
@Override
public void destroy() {
if (Objects.nonNull(canalConnector)) {
// 断开 canalConnector 与 Canal 服务的连接
canalConnector.disconnect();
}
}
}

View File

@@ -0,0 +1,43 @@
package com.hanserwei.hannote.search.canal;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = CanalProperties.PREFIX)
@Component
@Data
public class CanalProperties {
public static final String PREFIX = "canal";
/**
* Canal 链接地址
*/
private String address;
/**
* 数据目标
*/
private String destination;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 订阅规则
*/
private String subscribe;
/**
* 一批次拉取数据量,默认 1000 条
*/
private int batchSize = 1000;
}

View File

@@ -0,0 +1,112 @@
package com.hanserwei.hannote.search.canal;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
public class CanalSchedule implements Runnable {
@Resource
private CanalProperties canalProperties;
@Resource
private CanalConnector canalConnector;
/**
* 打印字段信息
*
* @param columns 字段信息
*/
private static void printColumn(List<CanalEntry.Column> columns) {
for (CanalEntry.Column column : columns) {
System.out.println(column.getName() + " : " + column.getValue() + " update=" + column.getUpdated());
}
}
@Override
@Scheduled(fixedDelay = 100) // 每隔 100ms 被执行一次
public void run() {
// 初始化批次 ID-1 表示未开始或未获取到数据
long batchId = -1;
try {
// 从 canalConnector 获取批量消息,返回的数据量由 batchSize 控制,若不足,则拉取已有的
Message message = canalConnector.getWithoutAck(canalProperties.getBatchSize());
// 获取当前拉取消息的批次 ID
batchId = message.getId();
// 获取当前批次中的数据条数
long size = message.getEntries().size();
if (batchId == -1 || size == 0) {
try {
// 拉取数据为空,休眠 1s, 防止频繁拉取
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
log.error("消费 Canal 批次数据异常", e);
}
} else {
// 如果当前批次有数据,打印这批次中的数据条目
printEntry(message.getEntries());
}
// 对当前批次的消息进行 ack 确认,表示该批次的数据已经被成功消费
canalConnector.ack(batchId);
} catch (Exception e) {
log.error("消费 Canal 批次数据异常", e);
// 如果出现异常,需要进行数据回滚,以便重新消费这批次的数据
canalConnector.rollback(batchId);
}
}
/**
* 打印这一批次中的数据条目(和官方示例代码一致,后续小节中会自定义这块)
*
* @param entrys 批次数据
*/
private void printEntry(List<CanalEntry.Entry> entrys) {
for (CanalEntry.Entry entry : entrys) {
if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONBEGIN
|| entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONEND) {
continue;
}
CanalEntry.RowChange rowChage;
try {
rowChage = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
} catch (Exception e) {
throw new RuntimeException("ERROR ## parser of eromanga-event has an error , data:" + entry,
e);
}
CanalEntry.EventType eventType = rowChage.getEventType();
System.out.printf("================> binlog[%s:%s] , name[%s,%s] , eventType : %s%n",
entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
entry.getHeader().getSchemaName(), entry.getHeader().getTableName(),
eventType);
for (CanalEntry.RowData rowData : rowChage.getRowDatasList()) {
if (eventType == CanalEntry.EventType.DELETE) {
printColumn(rowData.getBeforeColumnsList());
} else if (eventType == CanalEntry.EventType.INSERT) {
printColumn(rowData.getAfterColumnsList());
} else {
System.out.println("-------> before");
printColumn(rowData.getBeforeColumnsList());
System.out.println("-------> after");
printColumn(rowData.getAfterColumnsList());
}
}
}
}
}

View File

@@ -0,0 +1,26 @@
package com.hanserwei.hannote.search.controller;
import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
import com.hanserwei.hannote.search.service.ExtDictService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/search")
@Slf4j
public class ExtDictController {
@Resource
private ExtDictService extDictService;
@GetMapping("/ext/dict")
@ApiOperationLog(description = "热更新词典")
public ResponseEntity<String> extDict() {
return extDictService.getHotUpdateExtDict();
}
}

View File

@@ -0,0 +1,37 @@
package com.hanserwei.hannote.search.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum NotePublishTimeRangeEnum {
// 一天内
DAY(0),
// 一周内
WEEK(1),
// 半年内
HALF_YEAR(2),
;
private final Integer code;
/**
* 根据类型 code 获取对应的枚举
*
* @param code 类型 code
* @return 枚举
*/
public static NotePublishTimeRangeEnum valueOf(Integer code) {
for (NotePublishTimeRangeEnum notePublishTimeRangeEnum : NotePublishTimeRangeEnum.values()) {
if (Objects.equals(code, notePublishTimeRangeEnum.getCode())) {
return notePublishTimeRangeEnum;
}
}
return null;
}
}

View File

@@ -29,4 +29,9 @@ public class SearchNoteReqVO {
*/ */
private Integer sort; private Integer sort;
/**
* 发布时间范围null不限 / 0一天内 / 1一周内 / 2半年内
*/
private Integer publishTimeRange;
} }

View File

@@ -7,8 +7,6 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data @Data
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
@@ -51,7 +49,7 @@ public class SearchNoteRspVO {
* 最后一次编辑时间 * 最后一次编辑时间
*/ */
@JsonAlias("update_time") @JsonAlias("update_time")
private LocalDateTime updateTime; private String updateTime;
/** /**
* 被点赞总数 * 被点赞总数
@@ -59,4 +57,14 @@ public class SearchNoteRspVO {
@JsonAlias("like_total") @JsonAlias("like_total")
private String likeTotal; private String likeTotal;
/**
* 被评论数
*/
private String commentTotal;
/**
* 被收藏数
*/
private String collectTotal;
} }

View File

@@ -0,0 +1,13 @@
package com.hanserwei.hannote.search.service;
import org.springframework.http.ResponseEntity;
public interface ExtDictService {
/**
* 获取热更新词典
*
* @return 热更新词典
*/
ResponseEntity<String> getHotUpdateExtDict();
}

View File

@@ -0,0 +1,54 @@
package com.hanserwei.hannote.search.service.impl;
import com.hanserwei.hannote.search.service.ExtDictService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Collectors;
@Service
@Slf4j
public class ExtDictServiceImpl implements ExtDictService {
/**
* 热更新词典路径
*/
@Value("${elasticsearch.hotUpdateExtDict}")
private String hotUpdateExtDict;
@Override
public ResponseEntity<String> getHotUpdateExtDict() {
try {
// 获取文件的最后修改时间
Path path = Paths.get(hotUpdateExtDict);
long lastModifiedTime = Files.getLastModifiedTime(path).toMillis();
// 生成 ETag使用文件内容的哈希值
String fileContent = Files.lines(path).collect(Collectors.joining("\n"));
String eTag = String.valueOf(fileContent.hashCode());
// 设置响应头
HttpHeaders headers = new HttpHeaders();
headers.set("ETag", eTag);
// 设置内容类型为 UTF-8
headers.setContentType(MediaType.valueOf("text/plain;charset=UTF-8"));
// 返回文件内容和 HTTP 头部
return ResponseEntity.ok()
.headers(headers)
.lastModified(lastModifiedTime) // 请求头中设置 Last-Modified
.body(fileContent);
} catch (Exception e) {
log.error("==> 获取热更新词典异常: ", e);
}
return null;
}
}

View File

@@ -11,8 +11,11 @@ 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.constant.DateConstants;
import com.hanserwei.framework.common.response.PageResponse; import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.utils.DateUtils;
import com.hanserwei.framework.common.utils.NumberUtils; import com.hanserwei.framework.common.utils.NumberUtils;
import com.hanserwei.hannote.search.enums.NotePublishTimeRangeEnum;
import com.hanserwei.hannote.search.enums.NoteSortTypeEnum; 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;
@@ -20,6 +23,7 @@ import com.hanserwei.hannote.search.model.vo.SearchNoteRspVO;
import com.hanserwei.hannote.search.service.NoteService; import com.hanserwei.hannote.search.service.NoteService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
@@ -46,6 +50,8 @@ public class NoteServiceImpl implements NoteService {
Integer type = searchNoteReqVO.getType(); Integer type = searchNoteReqVO.getType();
// 排序方式 // 排序方式
Integer sort = searchNoteReqVO.getSort(); Integer sort = searchNoteReqVO.getSort();
// 发布时间范围
Integer publishTimeRange = searchNoteReqVO.getPublishTimeRange();
// --- 2. 分页参数 --- // --- 2. 分页参数 ---
int pageSize = 10; int pageSize = 10;
@@ -93,6 +99,31 @@ public class NoteServiceImpl implements NoteService {
) )
); );
} }
// 按发布时间范围过滤
NotePublishTimeRangeEnum notePublishTimeRangeEnum = NotePublishTimeRangeEnum.valueOf(publishTimeRange);
if (Objects.nonNull(notePublishTimeRangeEnum)) {
// 结束时间
String endTime = LocalDateTime.now().format(DateConstants.DATE_FORMAT_Y_M_D_H_M_S);
// 开始时间
String startTime = null;
switch (notePublishTimeRangeEnum) {
case DAY -> startTime = DateUtils.localDateTime2String(LocalDateTime.now().minusDays(1)); // 一天之前的时间
case WEEK -> startTime = DateUtils.localDateTime2String(LocalDateTime.now().minusWeeks(1)); // 一周之前的时间
case HALF_YEAR ->
startTime = DateUtils.localDateTime2String(LocalDateTime.now().minusMonths(6)); // 半年之前的时间
}
// 设置时间范围
if (StringUtils.isNoneBlank(startTime)) {
String finalStartTime = startTime;
boolQueryBuilder.filter(f -> f.range(r -> r
.term(t -> t.field(NoteIndex.FIELD_NOTE_CREATE_TIME)
.gte(finalStartTime)
.lte(endTime)))
);
}
}
BoolQuery boolQuery = boolQueryBuilder.build(); BoolQuery boolQuery = boolQueryBuilder.build();
// --- 4. 构建排序 (Sort) 和 FunctionScore --- // --- 4. 构建排序 (Sort) 和 FunctionScore ---
Query finalQuery; Query finalQuery;
@@ -238,8 +269,10 @@ public class NoteServiceImpl implements NoteService {
String highlightTitle = source.getHighlightTitle(); String highlightTitle = source.getHighlightTitle();
String avatar = source.getAvatar(); String avatar = source.getAvatar();
String nickname = source.getNickname(); String nickname = source.getNickname();
LocalDateTime updateTime = source.getUpdateTime(); String updateTime = source.getUpdateTime();
String likeTotal = source.getLikeTotal(); String likeTotal = source.getLikeTotal();
String collectTotal = source.getCollectTotal();
String commentTotal = source.getCommentTotal();
searchNoteRspVOS.add(SearchNoteRspVO.builder() searchNoteRspVOS.add(SearchNoteRspVO.builder()
.noteId(noteId) .noteId(noteId)
.cover(cover) .cover(cover)
@@ -247,9 +280,11 @@ public class NoteServiceImpl implements NoteService {
.highlightTitle(highlightTitle) .highlightTitle(highlightTitle)
.avatar(avatar) .avatar(avatar)
.nickname(nickname) .nickname(nickname)
.updateTime(updateTime) .updateTime(DateUtils.formatRelativeTime(LocalDateTime.parse(updateTime, DateConstants.DATE_FORMAT_Y_M_D_H_M_S)))
.highlightTitle(highlightedTitle) .highlightTitle(highlightedTitle)
.likeTotal(NumberUtils.formatNumberString(Long.parseLong(likeTotal))) .likeTotal(likeTotal == null ? "0" : NumberUtils.formatNumberString(Long.parseLong(likeTotal)))
.collectTotal(collectTotal == null ? "0" : NumberUtils.formatNumberString(Long.parseLong(collectTotal)))
.collectTotal(commentTotal == null ? "0" : NumberUtils.formatNumberString(Long.parseLong(commentTotal)))
.build()); .build());
} }

View File

@@ -1,14 +1,16 @@
package com.hanserwei.hannote.search.service.impl; package com.hanserwei.hannote.search.service.impl;
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.Query;
import co.elastic.clients.elasticsearch._types.query_dsl.TextQueryType; import co.elastic.clients.elasticsearch._types.query_dsl.TextQueryType;
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.google.common.collect.Lists;
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.index.UserIndex; import com.hanserwei.hannote.search.index.UserIndex;
@@ -19,6 +21,8 @@ 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.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -31,128 +35,83 @@ public class UserServiceImpl implements UserService {
@Override @Override
public PageResponse<SearchUserRspVO> searchUser(SearchUserReqVO searchUserReqVO) { public PageResponse<SearchUserRspVO> searchUser(SearchUserReqVO searchUserReqVO) {
// 查询关键词 // --- 2. 获取请求参数 ---
String keyword = searchUserReqVO.getKeyword(); String keyword = searchUserReqVO.getKeyword();
// 当前页码
Integer pageNo = searchUserReqVO.getPageNo(); Integer pageNo = searchUserReqVO.getPageNo();
int pageSize = 10; // 每页展示数据量 // --- 3. 设置分页 ---
int from = (pageNo - 1) * pageSize; // 偏移量 int pageSize = 10; // 假设每页大小为10
int from = (pageNo - 1) * pageSize;
HighlightField nicknameHighlight = HighlightField.of(hf -> hf // --- 4. 构建 multi_match 查询 ---
Query multiMatchQuery = Query.of(q -> q
.multiMatch(m -> m
.query(keyword)
.type(TextQueryType.PhrasePrefix)
.fields(UserIndex.FIELD_USER_NICKNAME, UserIndex.FIELD_USER_HAN_NOTE_ID)
)
);
// --- 5. 构建排序 ---
SortOptions sortOptions = SortOptions.of(so -> so
.field(f -> f
.field(UserIndex.FIELD_USER_FANS_TOTAL)
.order(SortOrder.Desc)));
// --- 6. 构建高亮 ---
HighlightField nikeNameHighlight = HighlightField.of(hf -> hf
.preTags("<strong>") .preTags("<strong>")
.postTags("</strong>") .postTags("</strong>")
); );
Highlight highlight = Highlight.of(h -> h
.fields(NamedValue.of(UserIndex.FIELD_USER_NICKNAME, nikeNameHighlight)));
SearchRequest searchRequest = SearchRequest.of(r -> r // --- 7. 构建 SearchRequest ---
SearchRequest searchRequest = SearchRequest.of(s -> s
.index(UserIndex.NAME) .index(UserIndex.NAME)
.query(multiMatchQuery)
// 1. 构建 Query: multiMatchQuery (RHL 风格的匹配) .sort(sortOptions)
.query(q -> q .highlight(highlight)
.multiMatch(m -> m
.query(keyword)
.fields(UserIndex.FIELD_USER_NICKNAME, UserIndex.FIELD_USER_HAN_NOTE_ID)
// 默认使用 MatchQuery 行为,如果要模糊匹配,请添加 .fuzziness("AUTO")
.type(TextQueryType.PhrasePrefix)
)
)
// 2. 构建 Sort
.sort(s -> s
.field(f -> f
.field(UserIndex.FIELD_USER_FANS_TOTAL)
.order(SortOrder.Desc)
)
)
.highlight(h -> h.fields(NamedValue.of(UserIndex.FIELD_USER_NICKNAME, nicknameHighlight)))
// 3. 分页 from 和 size
.from(from) .from(from)
.size(pageSize) .size(pageSize));
); // --- 8. 执行查询和解析响应 ---
List<SearchUserRspVO> searchUserRspVOS = new ArrayList<>();
// 返参 VO 集合
List<SearchUserRspVO> searchUserRspVOS = Lists.newArrayList();
// 总文档数,默认为 0
long total = 0; long total = 0;
try { try {
log.info("==> SearchRequest: {}", searchRequest.toString()); log.info("==> searchRequest: {}", searchRequest);
// 执行查询请求
SearchResponse<SearchUserRspVO> searchResponse = client.search(searchRequest, SearchUserRspVO.class); SearchResponse<SearchUserRspVO> searchResponse = client.search(searchRequest, SearchUserRspVO.class);
// 8.2. 处理响应
// 处理搜索结果
List<Hit<SearchUserRspVO>> hits = searchResponse.hits().hits();
if (searchResponse.hits().total() != null) { if (searchResponse.hits().total() != null) {
total = searchResponse.hits().total().value(); total = searchResponse.hits().total().value();
} }
log.info("==> 命中文档总数, hits: {}", total);
searchUserRspVOS = Lists.newArrayList(); List<Hit<SearchUserRspVO>> hits = searchResponse.hits().hits();
for (Hit<SearchUserRspVO> hit : hits) { for (Hit<SearchUserRspVO> hit : hits) {
// 1. 获取原始文档数据 (source) // 获取source
SearchUserRspVO source = hit.source(); SearchUserRspVO source = hit.source();
// 8.3. 获取高亮字段
// 2. 获取高亮数据 (highlight) String highlightNickname = null;
Map<String, List<String>> highlights = hit.highlight(); Map<String, List<String>> highlightFiled = hit.highlight();
if (highlightFiled.containsKey(UserIndex.FIELD_USER_NICKNAME)) {
highlightNickname = highlightFiled.get(UserIndex.FIELD_USER_NICKNAME).getFirst();
}
if (source != null) { if (source != null) {
// 3. 调用辅助方法合并数据和高亮 Long userId = source.getUserId();
SearchUserRspVO searchUserRspVO = mergeHitToRspVO(source, highlights); String nickname = source.getNickname();
searchUserRspVOS.add(searchUserRspVO); String avatar = source.getAvatar();
String hanNoteId = source.getHanNoteId();
Integer noteTotal = source.getNoteTotal();
String fansTotal = source.getFansTotal();
searchUserRspVOS.add(SearchUserRspVO.builder()
.userId(userId)
.nickname(nickname)
.highlightNickname(highlightNickname)
.avatar(avatar)
.hanNoteId(hanNoteId)
.noteTotal(noteTotal)
.fansTotal(fansTotal == null ? "0" : NumberUtils.formatNumberString(Long.parseLong(fansTotal)))
.build());
} }
} }
} catch (IOException e) {
} catch (Exception e) { log.error("==> search error: {}", e.getMessage());
log.error("==> 查询 Elasticsearch 异常: ", e);
} }
return PageResponse.success(searchUserRspVOS, pageNo, total); return PageResponse.success(searchUserRspVOS, pageNo, total);
} }
/**
* 将原始文档和高亮数据合并到 SearchUserRspVO
*
* @param source 原始文档数据 (已自动反序列化)
* @param highlights 高亮数据 Map
* @return SearchUserRspVO
*/
private SearchUserRspVO mergeHitToRspVO(SearchUserRspVO source, Map<String, List<String>> highlights) {
if (source == null) {
return null;
}
// 1. 复制原始文档字段 (假设 SearchUserRspVO 使用 Lombok @Data 或 Builder)
SearchUserRspVO searchUserRspVO = SearchUserRspVO.builder()
.userId(source.getUserId())
.nickname(source.getNickname())
.avatar(source.getAvatar())
.hanNoteId(source.getHanNoteId()) // 字段名应与您的 VO 保持一致
.noteTotal(source.getNoteTotal())
.build();
if (source.getFansTotal() != null) {
searchUserRspVO.setFansTotal(NumberUtils.formatNumberString(Long.parseLong(source.getFansTotal())));
}
// 2. ⭐️ 核心逻辑:处理并设置高亮字段
if (highlights != null) {
// 尝试从 highlights Map 中获取 nickname 字段的高亮结果
List<String> nicknameHighlights = highlights.get(UserIndex.FIELD_USER_NICKNAME);
if (nicknameHighlights != null && !nicknameHighlights.isEmpty()) {
searchUserRspVO.setHighlightNickname(nicknameHighlights.getFirst());
}
}
// 3. 如果高亮字段为空,默认使用原始 nickname
if (searchUserRspVO.getHighlightNickname() == null) {
searchUserRspVO.setHighlightNickname(source.getNickname());
}
return searchUserRspVO;
}
} }

View File

@@ -23,4 +23,15 @@ public interface DateConstants {
* DateTimeFormatter年-月 * DateTimeFormatter年-月
*/ */
DateTimeFormatter DATE_FORMAT_Y_M = DateTimeFormatter.ofPattern("yyyy-MM"); DateTimeFormatter DATE_FORMAT_Y_M = DateTimeFormatter.ofPattern("yyyy-MM");
/**
* DateTimeFormatter月-日
*/
DateTimeFormatter DATE_FORMAT_M_D = DateTimeFormatter.ofPattern("MM-dd");
/**
* DateTimeFormatter
*/
DateTimeFormatter DATE_FORMAT_H_M = DateTimeFormatter.ofPattern("HH:mm");
} }

View File

@@ -1,7 +1,10 @@
package com.hanserwei.framework.common.utils; package com.hanserwei.framework.common.utils;
import com.hanserwei.framework.common.constant.DateConstants;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
public class DateUtils { public class DateUtils {
@@ -14,4 +17,63 @@ public class DateUtils {
public static long localDateTime2Timestamp(LocalDateTime localDateTime) { public static long localDateTime2Timestamp(LocalDateTime localDateTime) {
return localDateTime.toInstant(ZoneOffset.UTC).toEpochMilli(); return localDateTime.toInstant(ZoneOffset.UTC).toEpochMilli();
} }
/**
* LocalDateTime 转 String 字符串
*
* @param time LocalDateTime
* @return String
*/
public static String localDateTime2String(LocalDateTime time) {
return time.format(DateConstants.DATE_FORMAT_Y_M_D_H_M_S);
}
/**
* LocalDateTime 转友好的相对时间字符串
*
* @param dateTime LocalDateTime
* @return String友好的相对时间字符串
*/
public static String formatRelativeTime(LocalDateTime dateTime) {
// 当前时间
LocalDateTime now = LocalDateTime.now();
// 计算与当前时间的差距
long daysDiff = ChronoUnit.DAYS.between(dateTime, now);
long hoursDiff = ChronoUnit.HOURS.between(dateTime, now);
long minutesDiff = ChronoUnit.MINUTES.between(dateTime, now);
if (daysDiff < 1) { // 如果是今天
if (hoursDiff < 1) { // 如果是几分钟前
return minutesDiff + "分钟前";
} else { // 如果是几小时前
return hoursDiff + "小时前";
}
} else if (daysDiff == 1) { // 如果是昨天
return "昨天 " + dateTime.format(DateConstants.DATE_FORMAT_H_M);
} else if (daysDiff < 7) { // 如果是最近一周
return daysDiff + "天前";
} else if (dateTime.getYear() == now.getYear()) { // 如果是今年
return dateTime.format(DateConstants.DATE_FORMAT_M_D);
} else { // 如果是去年或更早
return dateTime.format(DateConstants.DATE_FORMAT_Y_M_D);
}
}
public static void main(String[] args) {
// 测试示例
LocalDateTime dateTime1 = LocalDateTime.now().minusMinutes(10); // 10分钟前
LocalDateTime dateTime2 = LocalDateTime.now().minusHours(3); // 3小时前
LocalDateTime dateTime3 = LocalDateTime.now().minusDays(1).minusHours(5); // 昨天 20:12
LocalDateTime dateTime4 = LocalDateTime.now().minusDays(2); // 2天前
LocalDateTime dateTime5 = LocalDateTime.now().minusDays(10); // 11-06
LocalDateTime dateTime6 = LocalDateTime.of(2023, 12, 1, 12, 30, 0); // 2023-12-01
System.out.println(formatRelativeTime(dateTime1)); // 输出 "10分钟前"
System.out.println(formatRelativeTime(dateTime2)); // 输出 "3小时前"
System.out.println(formatRelativeTime(dateTime3)); // 输出 "昨天 20:12"
System.out.println(formatRelativeTime(dateTime4)); // 输出 "2天前"
System.out.println(formatRelativeTime(dateTime5)); // 输出 "11-06"
System.out.println(formatRelativeTime(dateTime6)); // 输出 "2023-12-01"
}
} }

17
pom.xml
View File

@@ -70,6 +70,7 @@
<xxl-job.version>3.2.0</xxl-job.version> <xxl-job.version>3.2.0</xxl-job.version>
<elasticsearch-java.version>9.2.0</elasticsearch-java.version> <elasticsearch-java.version>9.2.0</elasticsearch-java.version>
<elasticsearch.version>9.2.0</elasticsearch.version> <elasticsearch.version>9.2.0</elasticsearch.version>
<canal.version>1.1.8</canal.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
@@ -314,6 +315,22 @@
<artifactId>elasticsearch-rest-client</artifactId> <artifactId>elasticsearch-rest-client</artifactId>
<version>${elasticsearch.version}</version> <version>${elasticsearch.version}</version>
</dependency> </dependency>
<!-- Canal -->
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>${canal.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.common</artifactId>
<version>${canal.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.protocol</artifactId>
<version>${canal.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>