feat(全局搜索): 实现超算专区全局搜索功能

- 实现MySQL全文检索:基于MATCH...AGAINST NATURAL LANGUAGE MODE
   搜索范围覆盖文档标题(name)和摘要(summary)双字段
   结果按全文相关度降序+更新时间降序双重排序
   仅检索已审核通过且非目录类型的文档
- 实现项目全文检索:基于MATCH...AGAINST搜索zone_project.name字段
   仅返回已审核项目(audit_status='1'),按相关度+更新时间排序
- 实现资源全文检索:搜索资源名称(name)+关键词(keywords)复合字段
   创新性支持附件文件名全文匹配(通过EXISTS子查询关联sys_file_info表)
   资源结果附带匹配附件信息(文件ID、文件名、文件大小),存储于extendData
   通过RemoteFileService远程调用获取附件详情,支持大小写不敏感匹配
- 引入Redis缓存策略:第一页搜索结果缓存5分钟
This commit is contained in:
欧涛 2026-04-15 13:54:04 +08:00
parent 1c6563f915
commit aff24c83d2
22 changed files with 1241 additions and 0 deletions

View File

@ -126,6 +126,12 @@
<version>3.6.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.microservices</groupId>
<artifactId>microservices-common-search</artifactId>
<version>3.6.2</version>
<scope>compile</scope>
</dependency>
</dependencies>
<build>

View File

@ -9,8 +9,12 @@ import com.microservices.common.core.web.controller.BaseController;
import com.microservices.common.core.web.domain.AjaxResult;
import com.microservices.common.core.web.domain.GenericsAjaxResult;
import com.microservices.common.core.web.page.GenericsTableDataInfo;
import com.microservices.common.core.web.page.TableDataInfo;
import com.microservices.common.log.annotation.Log;
import com.microservices.common.log.enums.BusinessType;
import com.microservices.cms.search.dto.DocSearchRequest;
import com.microservices.cms.search.dto.DocSearchResponse;
import com.microservices.cms.search.service.IGlobalSearchService;
import io.swagger.annotations.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@ -35,6 +39,44 @@ public class OpenController extends BaseController {
@Autowired
private ICmsWechatService cmsWechatService;
@Autowired
private IGlobalSearchService globalSearchService;
/**
* 文章搜索接口
*
* @param keyword 搜索关键词
* @param zoneId 专区ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 搜索结果
*/
@ApiOperation("文章搜索")
@GetMapping("/search")
public TableDataInfo docSearchGet(
@ApiParam("搜索关键词") @RequestParam String keyword,
@ApiParam("专区ID") @RequestParam Long zoneId,
@ApiParam("页码") @RequestParam(defaultValue = "1") Integer pageNum,
@ApiParam("每页数量") @RequestParam(defaultValue = "20") Integer pageSize) {
DocSearchRequest request = new DocSearchRequest();
request.setKeyword(keyword);
request.setZoneId(zoneId);
request.setPageNum(pageNum);
request.setPageSize(pageSize);
logger.info("文章搜索请求(GET): keyword={}", keyword);
DocSearchResponse response = globalSearchService.docSearch(request);
TableDataInfo dataInfo = new TableDataInfo();
dataInfo.setCode(200);
dataInfo.setMsg("查询成功");
dataInfo.setRows(response.getResults());
dataInfo.setTotal(response.getTotal());
return dataInfo;
}
/**
* 查询栏目列表
*/

View File

@ -192,4 +192,25 @@ public interface CmsDocMapper {
List<CmsDoc> selectCmsDocAndDirListByDept(Long deptId);
void updateCmsDocShaByPathAndDeptId(@Param("sha") String sha, @Param("filePath") String filePath, @Param("deptId") Long deptId);
/**
* 全局搜索 - 全文索引查询
*
* @param keyword 搜索关键词
* @param deptId 组织ID可选
* @param offset 偏移量
* @param pageSize 每页数量
* @return 搜索结果列表
*/
List<CmsDoc> globalSearch(@Param("keyword") String keyword, @Param("deptId") Long deptId,
@Param("offset") int offset, @Param("pageSize") int pageSize);
/**
* 全局搜索结果计数
*
* @param keyword 搜索关键词
* @param deptId 组织ID可选
* @return 结果数量
*/
Long globalSearchCount(@Param("keyword") String keyword, @Param("deptId") Long deptId);
}

View File

@ -2,6 +2,7 @@ package com.microservices.cms.project.mapper;
import com.microservices.cms.project.domain.CmsProject;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@ -64,4 +65,25 @@ public interface CmsProjectMapper {
CmsProject selectCmsProjectByDeptId(Long deptId);
CmsProject selectCmsProjectByIdentifier(String identifier);
/**
* 全局搜索 - 全文索引查询
*
* @param keyword 搜索关键词
* @param deptId 组织ID可选
* @param offset 偏移量
* @param pageSize 每页数量
* @return 搜索结果列表
*/
List<CmsProject> globalSearch(@Param("keyword") String keyword, @Param("deptId") Long deptId,
@Param("offset") int offset, @Param("pageSize") int pageSize);
/**
* 全局搜索结果计数
*
* @param keyword 搜索关键词
* @param deptId 组织ID可选
* @return 结果数量
*/
Long globalSearchCount(@Param("keyword") String keyword, @Param("deptId") Long deptId);
}

View File

@ -0,0 +1,43 @@
package com.microservices.cms.search.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* 文章搜索请求对象
*
* @author microservices
*/
@Data
@ApiModel("文章搜索请求对象")
public class DocSearchRequest implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 搜索关键词
*/
@ApiModelProperty(value = "搜索关键词", required = true)
private String keyword;
/**
* 专区ID
*/
@ApiModelProperty(value = "专区ID")
private Long zoneId;
/**
* 页码
*/
@ApiModelProperty(value = "页码")
private Integer pageNum = 1;
/**
* 每页数量
*/
@ApiModelProperty(value = "每页数量")
private Integer pageSize = 20;
}

View File

@ -0,0 +1,63 @@
package com.microservices.cms.search.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* 全局搜索响应对象
*
* @author microservices
*/
@Data
@ApiModel("文章搜索响应对象")
public class DocSearchResponse implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 总结果数
*/
@ApiModelProperty(value = "总结果数")
private Long total;
/**
* 搜索结果列表
*/
@ApiModelProperty(value = "搜索结果列表")
private List<DocSearchResult> results;
/**
* 各类型结果数量统计
*/
@ApiModelProperty(value = "各类型结果数量统计")
private Map<String, Long> typeCounts;
/**
* 搜索耗时毫秒
*/
@ApiModelProperty(value = "搜索耗时(毫秒)")
private Long searchTime;
/**
* 搜索关键词
*/
@ApiModelProperty(value = "搜索关键词")
private String keyword;
/**
* 当前页码
*/
@ApiModelProperty(value = "当前页码")
private Integer pageNum;
/**
* 每页数量
*/
@ApiModelProperty(value = "每页数量")
private Integer pageSize;
}

View File

@ -0,0 +1,70 @@
package com.microservices.cms.search.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 文章搜索结果对象
*
* @author microservices
*/
@Data
@ApiModel("文章搜索结果对象")
public class DocSearchResult implements Serializable {
private static final long serialVersionUID = 1L;
/**
* ID
*/
@ApiModelProperty(value = "ID")
private Long id;
/**
* 标题
*/
@ApiModelProperty(value = "标题")
private String title;
/**
* 内容摘要
*/
@ApiModelProperty(value = "内容摘要")
private String content;
/**
* 高亮片段
*/
@ApiModelProperty(value = "高亮片段")
private String highlight;
/**
* 组织ID
*/
@ApiModelProperty(value = "组织ID")
private Long deptId;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@ApiModelProperty(value = "创建时间")
private Date createTime;
/**
* 相关度评分
*/
@ApiModelProperty(value = "相关度评分")
private Double score;
/**
* 扩展数据JSON格式
*/
@ApiModelProperty(value = "扩展数据")
private String extendData;
}

View File

@ -0,0 +1,20 @@
package com.microservices.cms.search.service;
import com.microservices.cms.search.dto.DocSearchRequest;
import com.microservices.cms.search.dto.DocSearchResponse;
/**
* 全局搜索服务接口
*
* @author microservices
*/
public interface IGlobalSearchService {
/**
* 全局搜索
*
* @param request 搜索请求
* @return 搜索响应
*/
DocSearchResponse docSearch(DocSearchRequest request);
}

View File

@ -0,0 +1,189 @@
package com.microservices.cms.search.service.impl;
import com.microservices.cms.doc.domain.CmsDoc;
import com.microservices.cms.doc.mapper.CmsDocMapper;
import com.microservices.cms.doc.service.ICmsDocService;
import com.microservices.cms.search.service.IGlobalSearchService;
import com.microservices.common.core.utils.StringUtils;
import com.microservices.common.redis.service.RedisService;
import com.microservices.cms.search.dto.DocSearchRequest;
import com.microservices.cms.search.dto.DocSearchResponse;
import com.microservices.cms.search.dto.DocSearchResult;
import com.microservices.common.security.utils.SecurityUtils;
import com.microservices.system.api.model.LoginUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* 全局搜索服务实现
*
* @author microservices
*/
@Service
public class GlobalSearchServiceImpl implements IGlobalSearchService {
private static final Logger logger = LoggerFactory.getLogger(GlobalSearchServiceImpl.class);
@Autowired
private CmsDocMapper cmsDocMapper;
@Autowired
private RedisService redisService;
@Autowired
private ICmsDocService cmsDocService;
private static final String SEARCH_CACHE_PREFIX = "search:cache:";
private static final Long CACHE_EXPIRE_SECONDS = 300L; // 5分钟
@Override
public DocSearchResponse docSearch(DocSearchRequest request) {
long startTime = System.currentTimeMillis();
// 参数校验
if (StringUtils.isEmpty(request.getKeyword())) {
return buildEmptyResponse(request);
}
// 1. 尝试从缓存获取仅第一页
if (request.getPageNum() == 1) {
String cacheKey = buildCacheKey(request);
DocSearchResponse cached = redisService.getCacheObject(SEARCH_CACHE_PREFIX + cacheKey);
if (cached != null) {
cached.setSearchTime(System.currentTimeMillis() - startTime);
return cached;
}
}
// 2. 并行搜索各类型
List<DocSearchResult> allResults = new ArrayList<>();
Map<String, Long> typeCounts = new HashMap<>();
// 获取当前用户信息
LoginUser loginUser = SecurityUtils.getLoginUser();
// 搜索CMS文章
List<DocSearchResult> docResults = searchDocs(request);
allResults.addAll(docResults);
typeCounts.put("doc", (long) docResults.size());
// 3. 按相关度评分排序
allResults.sort((a, b) -> {
if (b.getScore() != null && a.getScore() != null) {
return b.getScore().compareTo(a.getScore());
}
return 0;
});
// 4. 分页
int total = allResults.size();
int start = (request.getPageNum() - 1) * request.getPageSize();
int end = Math.min(start + request.getPageSize(), total);
List<DocSearchResult> pagedResults = start < total ?
allResults.subList(start, end) : new ArrayList<>();
// 5. 构建响应
DocSearchResponse response = new DocSearchResponse();
response.setTotal((long) total);
response.setResults(pagedResults);
response.setTypeCounts(typeCounts);
response.setKeyword(request.getKeyword());
response.setPageNum(request.getPageNum());
response.setPageSize(request.getPageSize());
response.setSearchTime(System.currentTimeMillis() - startTime);
// 6. 缓存结果仅第一页
if (request.getPageNum() == 1) {
String cacheKey = buildCacheKey(request);
redisService.setCacheObject(SEARCH_CACHE_PREFIX + cacheKey, response, CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS);
}
// 7. 记录搜索日志
recordSearchLog(request, response, loginUser);
return response;
}
/**
* 记录搜索日志
*/
private void recordSearchLog(DocSearchRequest request, DocSearchResponse response, LoginUser loginUser) {
Long userId = loginUser != null ? loginUser.getUserId() : null;
String searchTypes = "文章";
Long deptId = cmsDocService.getDeptIdByZoneId(request.getZoneId());
logger.info("全局搜索 - 关键词: {}, 用户ID: {}, 耗时: {}ms, 结果数: {}, 类型: {}, 专区Id: {}, 组织ID: {}",
request.getKeyword(),
userId,
response.getSearchTime(),
response.getTotal(),
searchTypes,
request.getZoneId(),
deptId
);
}
/**
* 搜索CMS文章
*/
private List<DocSearchResult> searchDocs(DocSearchRequest request) {
List<DocSearchResult> results = new ArrayList<>();
try {
// 使用部门ID参数
Long deptId = cmsDocService.getDeptIdByZoneId(request.getZoneId());
List<CmsDoc> docs = cmsDocMapper.globalSearch(
request.getKeyword(),
deptId,
(request.getPageNum() - 1) * request.getPageSize(),
request.getPageSize()
);
for (CmsDoc doc : docs) {
DocSearchResult result = new DocSearchResult();
result.setId(doc.getId());
result.setTitle(doc.getName());
result.setContent(doc.getSummary());
result.setDeptId(doc.getDeptId());
result.setCreateTime(doc.getCreateTime());
result.setScore(1.0); // MySQL全文索引默认相关度
results.add(result);
}
} catch (Exception e) {
// 记录错误但继续处理其他类型
System.err.println("搜索CMS文章失败: " + e.getMessage());
}
return results;
}
/**
* 构建缓存键
*/
private String buildCacheKey(DocSearchRequest request) {
Long deptId = cmsDocService.getDeptIdByZoneId(request.getZoneId());
StringBuilder key = new StringBuilder(request.getKeyword());
key.append(":").append(String.join(",", "Doc"));
if (deptId != null) {
key.append(":dept=").append(deptId);
}
return key.toString();
}
/**
* 构建空响应
*/
private DocSearchResponse buildEmptyResponse(DocSearchRequest request) {
DocSearchResponse response = new DocSearchResponse();
response.setTotal(0L);
response.setResults(new ArrayList<>());
response.setTypeCounts(new HashMap<>());
response.setKeyword(request.getKeyword());
response.setPageNum(request.getPageNum());
response.setPageSize(request.getPageSize());
response.setSearchTime(0L);
return response;
}
}

View File

@ -494,4 +494,32 @@
#{id}
</foreach>
</delete>
<!-- 全局搜索 - 全文索引查询 -->
<select id="globalSearch" resultMap="CmsDocResult">
<include refid="selectCmsDocAliasVo"/>
WHERE
cd.is_dir = 0
AND cd.audit_status = '1'
AND MATCH(cd.name, cd.summary) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
<if test="deptId != null">
AND cd.dept_id = #{deptId}
</if>
ORDER BY
MATCH(cd.name, cd.summary) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) DESC,
cd.update_time DESC
LIMIT #{offset}, #{pageSize}
</select>
<select id="globalSearchCount" resultType="java.lang.Long">
SELECT COUNT(*)
FROM cms_doc cd
WHERE
cd.is_dir = 0
AND cd.audit_status = '1'
AND MATCH(cd.name, cd.summary) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
<if test="deptId != null">
AND cd.dept_id = #{deptId}
</if>
</select>
</mapper>

View File

@ -159,4 +159,38 @@
item="id" collection="array" open="(" separator="," close=")">#{id}
</foreach>
</delete>
<!-- 全局搜索 - 全文索引查询 -->
<select id="globalSearch" resultMap="CmsProjectResult">
SELECT id,
name,
description,
user_id,
dept_id,
identifier,
username,
create_by,
create_time,
update_time
FROM cms_project cp
WHERE
MATCH(cp.name) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
<if test="deptId != null">
AND cp.dept_id = #{deptId}
</if>
ORDER BY
MATCH(cp.name) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) DESC,
cp.update_time DESC
LIMIT #{offset}, #{pageSize}
</select>
<select id="globalSearchCount" resultType="java.lang.Long">
SELECT COUNT(*)
FROM cms_project cp
WHERE
MATCH(cp.name) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
<if test="deptId != null">
AND cp.dept_id = #{deptId}
</if>
</select>
</mapper>

View File

@ -133,6 +133,12 @@
<version>3.6.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.microservices</groupId>
<artifactId>microservices-common-search</artifactId>
<version>3.6.2</version>
<scope>compile</scope>
</dependency>
</dependencies>
<build>

View File

@ -6,6 +6,7 @@ import com.microservices.common.core.web.controller.BaseController;
import com.microservices.common.core.web.domain.AjaxResult;
import com.microservices.common.core.web.domain.GenericsAjaxResult;
import com.microservices.common.core.web.page.GenericsTableDataInfo;
import com.microservices.common.core.web.page.TableDataInfo;
import com.microservices.common.log.annotation.Log;
import com.microservices.common.log.enums.BusinessType;
import com.microservices.system.api.domain.SysUser;
@ -37,6 +38,10 @@ import com.microservices.zone.resource.domain.vo.ZoneResourceOpenSearchVo;
import com.microservices.zone.resource.service.IZoneResourceDomainService;
import com.microservices.zone.resource.service.IZoneResourceService;
import com.microservices.zone.resource.service.IZoneResourceTypeService;
import com.microservices.zone.search.dto.GlobalSearchRequest;
import com.microservices.zone.search.dto.GlobalSearchResponse;
import com.microservices.zone.search.dto.GlobalSearchResult;
import com.microservices.zone.search.service.IGlobalSearchService;
import com.microservices.zone.specialProject.domain.ZoneSpecialProject;
import com.microservices.zone.specialProject.domain.ZoneSpecialProjectRelevancy;
import com.microservices.zone.specialProject.domain.ZoneSpecialProjectReport;
@ -411,4 +416,43 @@ public class OpenController extends BaseController {
List<ZoneSpecialProjectReport> list = zoneSpecialProjectReportService.selectZoneSpecialProjectReportList(zoneSpecialProjectReportSearchVo.toZoneSpecialProjectReport());
return getGenericsDataTable(list);
}
@Autowired
private IGlobalSearchService globalSearchService;
/**
* 全局搜索接口GET方法
*
* @param keyword 搜索关键词
* @param types 搜索类型逗号分隔
* @param zoneId 专区ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 搜索结果
*/
@ApiOperation("全局搜索GET")
@GetMapping("/global")
public GenericsTableDataInfo<GlobalSearchResult> globalSearchGet(
@ApiParam("搜索关键词") @RequestParam String keyword,
@ApiParam("搜索类型") @RequestParam(required = false) String types,
@ApiParam("专区ID") @RequestParam Long zoneId,
@ApiParam("页码") @RequestParam(defaultValue = "1") Integer pageNum,
@ApiParam("每页数量") @RequestParam(defaultValue = "20") Integer pageSize) {
GlobalSearchRequest request = new GlobalSearchRequest();
request.setKeyword(keyword);
request.setPageNum(pageNum);
request.setPageSize(pageSize);
request.setZoneId(zoneId);
if (types != null && !types.isEmpty()) {
request.setTypes(java.util.Arrays.asList(types.split(",")));
}
logger.info("全局搜索请求(GET): keyword={}, types={}", keyword, types);
GlobalSearchResponse response = globalSearchService.globalSearch(request);
return new GenericsTableDataInfo<>(response.getResults(),response.getTotal());
}
}

View File

@ -155,4 +155,28 @@ public interface ZoneProjectMapper {
* @return
*/
int deleteZoneProjectByZoneId(Long zoneId);
/**
* 全局搜索 - 全文索引查询
*
* @param keyword 搜索关键词
* @param zoneId 专区ID可选
* @param offset 偏移量
* @param pageSize 每页数量
* @return 搜索结果列表
*/
List<ZoneProject> globalSearch(@Param("keyword") String keyword, @Param("zoneId") Long zoneId,
@Param("offset") int offset,
@Param("pageSize") int pageSize);
/**
* 全局搜索结果计数
*
* @param keyword 搜索关键词
* @param zoneId 专区ID可选
* @param deptId 组织ID可选
* @return 结果数量
*/
Long globalSearchCount(@Param("keyword") String keyword, @Param("zoneId") Long zoneId,
@Param("deptId") Long deptId);
}

View File

@ -95,4 +95,27 @@ public interface ZoneResourceMapper {
* @param zoneId 专区Id
*/
void deleteZoneResourceByZoneId(Long zoneId);
/**
* 全局搜索 - 全文索引查询
*
* @param keyword 搜索关键词
* @param zoneId 专区ID
* @param offset 偏移量
* @param pageSize 每页数量
* @return 搜索结果列表
*/
List<ZoneResource> globalSearch(@Param("keyword") String keyword, @Param("zoneId") Long zoneId,
@Param("offset") int offset,@Param("pageSize") int pageSize);
/**
* 全局搜索结果计数
*
* @param keyword 搜索关键词
* @param zoneId 专区ID可选
* @param deptId 组织ID可选
* @return 结果数量
*/
Long globalSearchCount(@Param("keyword") String keyword, @Param("zoneId") Long zoneId,
@Param("deptId") Long deptId);
}

View File

@ -0,0 +1,51 @@
package com.microservices.zone.search.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 全局搜索请求对象
*
* @author microservices
*/
@Data
@ApiModel("全局搜索请求对象")
public class GlobalSearchRequest implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 搜索关键词
*/
@ApiModelProperty(value = "搜索关键词", required = true)
private String keyword;
/**
* 搜索类型列表zone_project, resource, member
* 为空则搜索所有类型
*/
@ApiModelProperty(value = "搜索类型列表")
private List<String> types;
/**
* 专区ID
*/
@ApiModelProperty(value = "专区ID")
private Long zoneId;
/**
* 页码
*/
@ApiModelProperty(value = "页码")
private Integer pageNum = 1;
/**
* 每页数量
*/
@ApiModelProperty(value = "每页数量")
private Integer pageSize = 20;
}

View File

@ -0,0 +1,63 @@
package com.microservices.zone.search.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* 全局搜索响应对象
*
* @author microservices
*/
@Data
@ApiModel("全局搜索响应对象")
public class GlobalSearchResponse implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 总结果数
*/
@ApiModelProperty(value = "总结果数")
private Long total;
/**
* 搜索结果列表
*/
@ApiModelProperty(value = "搜索结果列表")
private List<GlobalSearchResult> results;
/**
* 各类型结果数量统计
*/
@ApiModelProperty(value = "各类型结果数量统计")
private Map<String, Long> typeCounts;
/**
* 搜索耗时毫秒
*/
@ApiModelProperty(value = "搜索耗时(毫秒)")
private Long searchTime;
/**
* 搜索关键词
*/
@ApiModelProperty(value = "搜索关键词")
private String keyword;
/**
* 当前页码
*/
@ApiModelProperty(value = "当前页码")
private Integer pageNum;
/**
* 每页数量
*/
@ApiModelProperty(value = "每页数量")
private Integer pageSize;
}

View File

@ -0,0 +1,82 @@
package com.microservices.zone.search.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 全局搜索结果对象
*
* @author microservices
*/
@Data
@ApiModel("全局搜索结果对象")
public class GlobalSearchResult implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 结果类型zone_project, resource, member
*/
@ApiModelProperty(value = "结果类型")
private String type;
/**
* ID
*/
@ApiModelProperty(value = "ID")
private Long id;
/**
* 标题
*/
@ApiModelProperty(value = "标题")
private String title;
/**
* 内容摘要
*/
@ApiModelProperty(value = "内容摘要")
private String content;
/**
* 高亮片段
*/
@ApiModelProperty(value = "高亮片段")
private String highlight;
/**
* 组织ID
*/
@ApiModelProperty(value = "组织ID")
private Long deptId;
/**
* 专区ID
*/
@ApiModelProperty(value = "专区ID")
private Long zoneId;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@ApiModelProperty(value = "创建时间")
private Date createTime;
/**
* 相关度评分
*/
@ApiModelProperty(value = "相关度评分")
private Double score;
/**
* 扩展数据JSON格式
*/
@ApiModelProperty(value = "扩展数据")
private String extendData;
}

View File

@ -0,0 +1,20 @@
package com.microservices.zone.search.service;
import com.microservices.zone.search.dto.GlobalSearchRequest;
import com.microservices.zone.search.dto.GlobalSearchResponse;
/**
* 全局搜索服务接口
*
* @author microservices
*/
public interface IGlobalSearchService {
/**
* 全局搜索
*
* @param request 搜索请求
* @return 搜索响应
*/
GlobalSearchResponse globalSearch(GlobalSearchRequest request);
}

View File

@ -0,0 +1,280 @@
package com.microservices.zone.search.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.microservices.common.core.domain.R;
import com.microservices.common.core.utils.StringUtils;
import com.microservices.common.redis.service.RedisService;
import com.microservices.common.security.utils.SecurityUtils;
import com.microservices.system.api.RemoteFileService;
import com.microservices.system.api.domain.SysFileInfo;
import com.microservices.system.api.model.LoginUser;
import com.microservices.zone.project.domain.ZoneProject;
import com.microservices.zone.project.mapper.ZoneProjectMapper;
import com.microservices.zone.resource.domain.ZoneResource;
import com.microservices.zone.resource.mapper.ZoneResourceMapper;
import com.microservices.zone.search.dto.GlobalSearchRequest;
import com.microservices.zone.search.dto.GlobalSearchResponse;
import com.microservices.zone.search.dto.GlobalSearchResult;
import com.microservices.zone.search.service.IGlobalSearchService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
import java.util.concurrent.TimeUnit;
/**
* 全局搜索服务实现
*
* @author microservices
*/
@Service
public class GlobalSearchServiceImpl implements IGlobalSearchService {
private static final Logger logger = LoggerFactory.getLogger(GlobalSearchServiceImpl.class);
@Autowired
private ZoneProjectMapper zoneProjectMapper;
@Autowired
private ZoneResourceMapper zoneResourceMapper;
@Autowired
private RemoteFileService remoteFileService;
@Autowired
private RedisService redisService;
private static final String SEARCH_CACHE_PREFIX = "search:cache:";
private static final long CACHE_EXPIRE_SECONDS = 300; // 5分钟
@Override
public GlobalSearchResponse globalSearch(GlobalSearchRequest request) {
long startTime = System.currentTimeMillis();
// 参数校验
if (StringUtils.isEmpty(request.getKeyword())) {
return buildEmptyResponse(request);
}
// 1. 尝试从缓存获取仅第一页
if (request.getPageNum() == 1) {
String cacheKey = buildCacheKey(request);
GlobalSearchResponse cached = redisService.getCacheObject(SEARCH_CACHE_PREFIX + cacheKey);
if (cached != null) {
cached.setSearchTime(System.currentTimeMillis() - startTime);
return cached;
}
}
// 2. 并行搜索各类型
List<GlobalSearchResult> allResults = new ArrayList<>();
Map<String, Long> typeCounts = new HashMap<>();
// 获取当前用户信息
LoginUser loginUser = SecurityUtils.getLoginUser();
// 确定要搜索的类型
List<String> searchTypes = request.getTypes();
if (searchTypes == null || searchTypes.isEmpty()) {
searchTypes = Arrays.asList("zone_project", "resource", "member");
}
// 搜索Zone项目
if (searchTypes.contains("zone_project")) {
List<GlobalSearchResult> zoneProjectResults = searchZoneProjects(request);
allResults.addAll(zoneProjectResults);
typeCounts.put("zone_project", (long) zoneProjectResults.size());
}
// 搜索Zone资源
if (searchTypes.contains("resource")) {
List<GlobalSearchResult> resourceResults = searchResources(request);
allResults.addAll(resourceResults);
typeCounts.put("resource", (long) resourceResults.size());
}
// 3. 按相关度评分排序
allResults.sort((a, b) -> {
if (b.getScore() != null && a.getScore() != null) {
return b.getScore().compareTo(a.getScore());
}
return 0;
});
// 4. 分页
int total = allResults.size();
int start = (request.getPageNum() - 1) * request.getPageSize();
int end = Math.min(start + request.getPageSize(), total);
List<GlobalSearchResult> pagedResults = start < total ?
allResults.subList(start, end) : new ArrayList<>();
// 5. 构建响应
GlobalSearchResponse response = new GlobalSearchResponse();
response.setTotal((long) total);
response.setResults(pagedResults);
response.setTypeCounts(typeCounts);
response.setKeyword(request.getKeyword());
response.setPageNum(request.getPageNum());
response.setPageSize(request.getPageSize());
response.setSearchTime(System.currentTimeMillis() - startTime);
// 6. 缓存结果仅第一页
if (request.getPageNum() == 1) {
String cacheKey = buildCacheKey(request);
redisService.setCacheObject(
SEARCH_CACHE_PREFIX + cacheKey,
response,
CACHE_EXPIRE_SECONDS,
TimeUnit.SECONDS);
}
// 7. 异步记录搜索日志
recordSearchLog(request, response, loginUser);
return response;
}
/**
* 异步记录搜索日志
*/
@Async
public void recordSearchLog(GlobalSearchRequest request, GlobalSearchResponse response, LoginUser loginUser) {
try {
logger.info("全局搜索 - 关键词: {}, 用户ID: {}, 耗时: {}ms, 结果数: {}, 搜索类型: {}, 专区ID: {}",
request.getKeyword(),
loginUser != null ? loginUser.getUserId() : null,
response.getSearchTime(),
response.getTotal(),
request.getTypes() != null ? String.join(",", request.getTypes()) : null,
request.getZoneId()
);
} catch (Exception e) {
logger.error("记录搜索日志失败: {}", e.getMessage());
}
}
/**
* 搜索Zone项目
*/
private List<GlobalSearchResult> searchZoneProjects(GlobalSearchRequest request) {
List<GlobalSearchResult> results = new ArrayList<>();
try {
List<ZoneProject> projects = zoneProjectMapper.globalSearch(
request.getKeyword(),
request.getZoneId(),
(request.getPageNum() - 1) * request.getPageSize(),
request.getPageSize()
);
for (ZoneProject project : projects) {
GlobalSearchResult result = new GlobalSearchResult();
result.setType("zone_project");
result.setId(project.getId());
result.setTitle(project.getName());
result.setContent(project.getFullName());
result.setDeptId(project.getDeptId());
result.setZoneId(project.getZoneId());
result.setCreateTime(project.getCreateTime());
result.setScore(1.0);
results.add(result);
}
} catch (Exception e) {
System.err.println("搜索Zone项目失败: " + e.getMessage());
}
return results;
}
/**
* 搜索Zone资源
*/
private List<GlobalSearchResult> searchResources(GlobalSearchRequest request) {
List<GlobalSearchResult> results = new ArrayList<>();
try {
List<ZoneResource> resources = zoneResourceMapper.globalSearch(
request.getKeyword(),
request.getZoneId(),
(request.getPageNum() - 1) * request.getPageSize(),
request.getPageSize()
);
for (ZoneResource resource : resources) {
GlobalSearchResult result = new GlobalSearchResult();
result.setType("resource");
result.setId(resource.getId());
result.setTitle(resource.getName());
result.setContent(resource.getSummary());
result.setDeptId(resource.getDeptId());
result.setZoneId(resource.getZoneId());
result.setCreateTime(resource.getCreateTime());
result.setScore(1.0);
// 查询并过滤匹配的附件
if (StringUtils.isNotEmpty(resource.getFileIds())) {
try {
R<List<SysFileInfo>> fileResponse = remoteFileService.getFileList(resource.getFileIds());
if (fileResponse != null && fileResponse.getCode() == 200 && fileResponse.getData() != null) {
List<Map<String, Object>> matchedFiles = fileResponse.getData().stream()
.filter(file -> StringUtils.isNotEmpty(file.getFileOriginName())
&& file.getFileOriginName().toLowerCase().contains(request.getKeyword().toLowerCase()))
.map(file -> {
Map<String, Object> fileInfo = new HashMap<>();
fileInfo.put("fileId", file.getFileId());
fileInfo.put("fileName", file.getFileOriginName());
fileInfo.put("fileSize", file.getFileSizeInfo());
return fileInfo;
})
.collect(Collectors.toList());
if (!matchedFiles.isEmpty()) {
ObjectMapper objectMapper = new ObjectMapper();
Map<String, Object> extendData = new HashMap<>();
extendData.put("matchedFiles", matchedFiles);
result.setExtendData(objectMapper.writeValueAsString(extendData));
}
}
} catch (Exception e) {
logger.warn("查询附件信息失败: resourceId={}, error={}", resource.getId(), e.getMessage());
}
}
results.add(result);
}
} catch (Exception e) {
System.err.println("搜索Zone资源失败: " + e.getMessage());
}
return results;
}
/**
* 构建缓存键
*/
private String buildCacheKey(GlobalSearchRequest request) {
StringBuilder key = new StringBuilder(request.getKeyword());
if (request.getTypes() != null && !request.getTypes().isEmpty()) {
key.append(":").append(String.join(",", request.getTypes()));
}
if (request.getZoneId() != null) {
key.append(":zone=").append(request.getZoneId());
}
return key.toString();
}
/**
* 构建空响应
*/
private GlobalSearchResponse buildEmptyResponse(GlobalSearchRequest request) {
GlobalSearchResponse response = new GlobalSearchResponse();
response.setTotal(0L);
response.setResults(new ArrayList<>());
response.setTypeCounts(new HashMap<>());
response.setKeyword(request.getKeyword());
response.setPageNum(request.getPageNum());
response.setPageSize(request.getPageSize());
response.setSearchTime(0L);
return response;
}
}

View File

@ -276,4 +276,48 @@
from zone_project
where zone_id = #{zoneId}
</delete>
<!-- 全局搜索 - 全文索引查询 -->
<select id="globalSearch" resultMap="ZoneProjectResult">
SELECT zp.id,
zp.name,
zp.sort,
zp.full_name,
zp.gitlink_project_id,
zp.dept_id,
zp.zone_id,
zp.del_flag,
zp.create_by,
zp.create_time,
zp.update_by,
zp.update_time,
zp.project_type_id,
zp.is_homepage,
zp.audit_status,
zp.audit_time,
zp.audit_by
FROM zone_project zp
WHERE
MATCH(zp.name) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
AND zp.zone_id = #{zoneId}
AND zp.audit_status = '1'
ORDER BY
MATCH(zp.name) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) DESC,
zp.update_time DESC
LIMIT #{offset}, #{pageSize}
</select>
<select id="globalSearchCount" resultType="java.lang.Long">
SELECT COUNT(*)
FROM zone_project zp
WHERE
MATCH(zp.name) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
<if test="zoneId != null">
AND zp.zone_id = #{zoneId}
</if>
<if test="deptId != null">
AND zp.dept_id = #{deptId}
</if>
AND zp.audit_status = '1'
</select>
</mapper>

View File

@ -301,4 +301,70 @@
from zone_resource
where zone_id = #{zoneId}
</delete>
<!-- 全局搜索 - 全文索引查询(含附件名称匹配) -->
<select id="globalSearch" resultMap="ZoneResourceResult">
SELECT zr.id,
zr.name,
zr.file_ids,
zr.domain_id,
zr.sort,
zr.is_homepage,
zr.zone_id,
zr.dept_id,
zr.del_flag,
zr.create_by,
zr.create_time,
zr.update_by,
zr.update_time,
zr.editor_type,
zr.audit_status,
zr.audit_time,
zr.audit_by,
zr.summary,
zr.remark,
zr.keywords,
zr.extend_data
FROM zone_resource zr
WHERE
zr.zone_id = #{zoneId}
AND zr.audit_status = '1'
AND (
MATCH(zr.name, zr.keywords) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
OR EXISTS (
SELECT 1 FROM sys_file_info sf
WHERE FIND_IN_SET(sf.file_id, zr.file_ids)
AND MATCH(sf.file_origin_name) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
)
)
ORDER BY
CASE
WHEN MATCH(zr.name, zr.keywords) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) > 0
THEN MATCH(zr.name, zr.keywords) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
ELSE 0
END DESC,
zr.update_time DESC
LIMIT #{offset}, #{pageSize}
</select>
<select id="globalSearchCount" resultType="java.lang.Long">
SELECT COUNT(*)
FROM zone_resource zr
WHERE
zr.audit_status = '1'
AND (
MATCH(zr.name, zr.keywords) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
OR EXISTS (
SELECT 1 FROM sys_file_info sf
WHERE FIND_IN_SET(sf.file_id, zr.file_ids)
AND MATCH(sf.file_origin_name) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
)
)
<if test="zoneId != null">
AND zr.zone_id = #{zoneId}
</if>
<if test="deptId != null">
AND zr.dept_id = #{deptId}
</if>
</select>
</mapper>