feat(搜索): 添加详细注释

重构搜索服务核心逻辑,新增ES索引支持与路由解析器,优化搜索结果处理流程

1. 新增PortalSearchRouteResolver路由解析器,实现配置分类到菜单路由的智能映射
2. 重构PortalSearchEsServiceImpl,增加索引自愈机制和查询结果归一化处理
3. 优化PortalSearchDocConverter文档转换逻辑,完善各来源类型的字段映射规则
4. 增强HwSearchServiceImpl的MySQL兜底查询,保持与ES结果结构一致性
5. 为所有核心类添加详细注释,明确各字段用途和业务逻辑
6. 实现关键词高亮、摘要生成和分页查询等基础功能
7. 支持编辑模式特殊路由,便于后台快速定位配置项
main
zch 3 months ago
parent 6d8d0cc5fb
commit e4548f1d5c

@ -26,12 +26,18 @@ import java.util.stream.Collectors;
@Component
public class PortalSearchDocConverter
{
// 菜单来源标识;用于在索引与查询侧统一识别“菜单”类型文档。
public static final String SOURCE_MENU = "menu";
// 页面来源标识;用于区分 hw_web 聚合页面文档。
public static final String SOURCE_WEB = "web";
// 详情来源标识;用于区分 hw_web1 设备详情文档。
public static final String SOURCE_WEB1 = "web1";
// 文档来源标识;用于区分下载资料类文档。
public static final String SOURCE_DOCUMENT = "document";
// 配置分类来源标识;用于产品中心分类命中场景的路由与参数兼容处理。
public static final String SOURCE_CONFIG_TYPE = "configType";
// JSON 文本抽取时需要跳过的字段集合;这些键多为图片/链接/路由元数据,纳入索引会引入噪声。
private static final Set<String> SKIP_JSON_KEYS = new HashSet<>(Arrays.asList(
"icon", "url", "banner", "banner1", "img", "imglist", "type", "uuid", "filename",
"documentaddress", "secretkey", "route", "routequery", "webcode", "deviceid", "typeid",
@ -42,246 +48,380 @@ public class PortalSearchDocConverter
public PortalSearchDoc fromWebMenu(HwWebMenu menu)
{
// 初始化基础文档对象;统一设置公共字段(如逻辑删除标记)。
PortalSearchDoc doc = initBase();
// 构造菜单来源的稳定主键;防止与其他来源 ID 冲突。
String id = "menu:" + menu.getWebMenuId();
// 设置 ES 文档主键;用于覆盖写入和去重。
doc.setId(id);
// 同步设置业务文档 ID便于跨层排查与回溯。
doc.setDocId(id);
// 标记来源类型为菜单;查询侧据此决定路由和编辑入口。
doc.setSourceType(SOURCE_MENU);
// 使用菜单名作为标题;保证搜索结果可读性。
doc.setTitle(menu.getWebMenuName());
// 将菜单名和祖先链拼成可检索文本;提升菜单相关词命中率。
doc.setContent(joinText(menu.getWebMenuName(), menu.getAncestors()));
// 记录菜单 ID用于前端跳转参数构建。
doc.setMenuId(String.valueOf(menu.getWebMenuId()));
// 设置展示端路由;菜单命中统一进入测试页路由。
doc.setRoute("/test");
// 序列化路由参数 JSON前端可直接读取 id 进行页面定位。
doc.setRouteQueryJson(toJson(Map.of("id", menu.getWebMenuId())));
// 更新文档时间戳;便于索引增量同步判定新旧。
doc.setUpdatedAt(new Date());
// 返回转换后的搜索文档。
return doc;
}
public PortalSearchDoc fromWeb(HwWeb web)
{
// 初始化基础文档;复用公共默认值设置。
PortalSearchDoc doc = initBase();
// 统一将 webCode 转为字符串;避免 Long/Integer 混用带来的比较问题。
String webCode = toString(web.getWebCode());
// 构造页面来源主键;确保页面记录在索引中唯一。
String id = "web:" + webCode;
// 设置 ES 主键。
doc.setId(id);
// 设置业务文档主键。
doc.setDocId(id);
// 标记来源类型为页面配置。
doc.setSourceType(SOURCE_WEB);
// 生成默认标题;即使页面无显式标题也可被识别。
doc.setTitle("页面#" + webCode);
// 抽取页面 JSON 中可检索文本;过滤结构字段和无意义内容。
doc.setContent(extractSearchableText(web.getWebJsonString()));
// 回填页面编码;供路由与编辑入口使用。
doc.setWebCode(webCode);
// 首页使用固定路由;与前端首页入口保持一致。
if ("-1".equals(webCode))
{
// 设置首页路由。
doc.setRoute("/index");
// 首页无需额外 query 参数。
doc.setRouteQueryJson("{}");
}
// 产品中心聚合页使用固定路由;避免误走普通菜单页。
else if ("7".equals(webCode))
{
// 设置产品中心路由。
doc.setRoute("/productCenter");
// 产品中心入口无需 query 参数。
doc.setRouteQueryJson("{}");
}
// 其他页面走通用测试页并携带 id 参数。
else
{
// 设置普通页面路由。
doc.setRoute("/test");
// 写入页面 id 参数;前端据此打开对应内容。
doc.setRouteQueryJson(toJson(Map.of("id", webCode)));
}
// 设置更新时间;支持后续按时间增量更新。
doc.setUpdatedAt(new Date());
// 返回页面转换结果。
return doc;
}
public PortalSearchDoc fromWeb1(HwWeb1 web1)
{
// 初始化基础文档对象。
PortalSearchDoc doc = initBase();
// 读取并标准化 webCode确保后续拼键一致。
String webCode = toString(web1.getWebCode());
// 读取并标准化 typeId用于详情三元组键。
String typeId = toString(web1.getTypeId());
// 读取并标准化 deviceId用于设备级详情定位。
String deviceId = toString(web1.getDeviceId());
// 组合详情唯一键;同一 webCode/typeId/deviceId 只保留一条。
String id = String.format("web1:%s:%s:%s", webCode, typeId, deviceId);
// 设置 ES 主键。
doc.setId(id);
// 设置业务文档 ID。
doc.setDocId(id);
// 标记来源类型为详情页配置。
doc.setSourceType(SOURCE_WEB1);
// 生成可读标题;便于后台检索时识别三元组。
doc.setTitle("详情#" + webCode + "-" + typeId + "-" + deviceId);
// 提取详情 JSON 可检索文本;提高正文搜索命中。
doc.setContent(extractSearchableText(web1.getWebJsonString()));
// 回填 webCode。
doc.setWebCode(webCode);
// 回填 typeId。
doc.setTypeId(typeId);
// 回填 deviceId。
doc.setDeviceId(deviceId);
// 设置详情展示路由。
doc.setRoute("/productCenter/detail");
// 写入详情路由参数 JSON前端可直接还原详情页上下文。
doc.setRouteQueryJson(toJson(Map.of("webCode", webCode, "typeId", typeId, "deviceId", deviceId)));
// 写入更新时间。
doc.setUpdatedAt(new Date());
// 返回详情转换结果。
return doc;
}
public PortalSearchDoc fromDocument(HwWebDocument document)
{
// 初始化基础文档。
PortalSearchDoc doc = initBase();
// 构造文档来源主键;确保下载资料类记录唯一。
String id = "doc:" + document.getDocumentId();
// 设置 ES 主键。
doc.setId(id);
// 设置业务文档 ID。
doc.setDocId(id);
// 标记来源类型为资料文档。
doc.setSourceType(SOURCE_DOCUMENT);
// 优先使用 json 字段作为标题,缺失时回退 documentId避免标题为空。
String title = StringUtils.isNotBlank(document.getJson()) ? document.getJson() : document.getDocumentId();
// 设置文档标题。
doc.setTitle(title);
// 设置文档内容;供关键词检索匹配。
doc.setContent(StringUtils.defaultString(document.getJson()));
// 回填 documentId用于下载定位。
doc.setDocumentId(document.getDocumentId());
// 回填 webCode用于关联来源页面。
doc.setWebCode(document.getWebCode());
// 复用 type 字段写入 typeId与查询 DTO 字段保持一致。
doc.setTypeId(document.getType());
// 资料命中统一跳服务支持页。
doc.setRoute("/serviceSupport");
// 路由参数只携带 documentId保证最小可用跳转信息。
doc.setRouteQueryJson(toJson(Map.of("documentId", document.getDocumentId())));
// 设置更新时间。
doc.setUpdatedAt(new Date());
// 返回文档转换结果。
return doc;
}
public PortalSearchDoc fromConfigType(HwPortalConfigType configType)
{
// 默认不显式传 webCode复用重载逻辑统一处理。
return fromConfigType(configType, null);
}
public PortalSearchDoc fromConfigType(HwPortalConfigType configType, String webCode)
{
// 初始化基础文档。
PortalSearchDoc doc = initBase();
// 使用配置分类主键构造稳定 ID保证分类索引文档可幂等更新。
String id = "configType:" + configType.getConfigTypeId();
// 设置 ES 主键。
doc.setId(id);
// 设置业务文档 ID。
doc.setDocId(id);
// 标记来源类型为配置分类。
doc.setSourceType(SOURCE_CONFIG_TYPE);
// 使用分类名称作为标题;符合用户检索习惯。
doc.setTitle(configType.getConfigTypeName());
// 拼接分类名称、首页名称和描述作为检索内容;覆盖更多查询词。
doc.setContent(joinText(configType.getConfigTypeName(), configType.getHomeConfigTypeName(), configType.getConfigTypeDesc()));
// 写入页面编码(可能为空);查询侧会做归一化兜底。
doc.setWebCode(webCode);
// 保留原分类主键到 typeId便于排查来源数据。
doc.setTypeId(toString(configType.getConfigTypeId()));
// 配置分类命中统一跳产品中心页。
doc.setRoute("/productCenter");
// 这里同时保留 id/configTypeId 两个键,前者兼容当前展示端路由,后者兼容既有搜索结果消费逻辑。
// 构建并序列化路由参数;兼容新旧前端参数读取方式。
doc.setRouteQueryJson(toJson(buildConfigTypeRouteQuery(configType.getConfigTypeId(), webCode)));
// 写入更新时间。
doc.setUpdatedAt(new Date());
// 返回配置分类转换结果。
return doc;
}
public String extractSearchableText(String text)
{
// 输入为空白时直接返回空串;避免后续 JSON/HTML 解析无意义消耗。
if (StringUtils.isBlank(text))
{
// 返回统一空字符串常量。
return StringUtils.EMPTY;
}
// 先做 HTML 去标签兜底文本;即使 JSON 解析失败也有可检索内容。
String stripped = stripHtml(text);
// 尝试把内容按 JSON 结构提取文本;能更精准排除无关字段。
try
{
// 解析 JSON 根节点。
JsonNode root = objectMapper.readTree(text);
// 创建字符串构建器累积抽取文本;减少字符串拼接开销。
StringBuilder builder = new StringBuilder();
// 递归收集节点文本;按字段白名单/黑名单过滤。
collectNodeText(root, null, builder);
// 标准化空白字符;保证搜索索引内容整洁。
String extracted = normalizeWhitespace(builder.toString());
// 若抽取结果为空则回退 stripped保证至少有基础文本可检索。
return StringUtils.isBlank(extracted) ? stripped : extracted;
}
catch (Exception ignored)
{
// JSON 解析失败时返回 HTML 去标签结果;兼容历史非 JSON 文本。
return stripped;
}
}
private void collectNodeText(JsonNode node, String fieldName, StringBuilder out)
{
// 节点为空或 JSON null 时直接结束;避免递归处理无效节点。
if (node == null || node.isNull())
{
// 提前返回减少无效分支。
return;
}
// 对象节点需要遍历字段;递归进入各子字段。
if (node.isObject())
{
// 遍历每个字段并按字段名过滤后递归收集。
node.fields().forEachRemaining(entry ->
{
// 过滤应跳过字段(如 icon/url减少噪音文本入索引。
if (!shouldSkip(entry.getKey()))
{
// 递归处理字段值并传递字段名上下文。
collectNodeText(entry.getValue(), entry.getKey(), out);
}
});
// 对象节点处理完毕后返回;避免继续落入后续分支。
return;
}
// 数组节点逐个处理子元素;保留列表中的可检索文本。
if (node.isArray())
{
// 遍历数组子节点。
for (JsonNode child : node)
{
// 递归处理数组元素并沿用当前字段上下文。
collectNodeText(child, fieldName, out);
}
// 数组处理完成后返回。
return;
}
// 文本节点才进入索引内容收集流程。
if (node.isTextual())
{
// 若当前字段应跳过则直接返回;避免把链接或图片字段写入索引。
if (shouldSkip(fieldName))
{
// 终止当前文本节点处理。
return;
}
// 去标签并标准化文本;减少格式噪声。
String value = normalizeWhitespace(stripHtml(node.asText()));
// 文本为空则跳过;避免写入无意义空片段。
if (StringUtils.isBlank(value))
{
// 结束当前节点处理。
return;
}
// 纯 URL 文本不纳入搜索;避免检索结果被链接污染。
if (value.startsWith("http://") || value.startsWith("https://"))
{
// 跳过 URL 内容。
return;
}
// 将有效文本追加到输出缓冲区并补空格;便于后续分词。
out.append(value).append(' ');
}
}
private boolean shouldSkip(String fieldName)
{
// 字段名为空时不跳过;避免误伤匿名数组中的有效文本。
if (StringUtils.isBlank(fieldName))
{
// 返回 false 表示保留该字段内容。
return false;
}
// 统一小写比较;避免大小写差异导致过滤失效。
String normalized = fieldName.toLowerCase();
// 命中预定义跳过键或 URL/ICON 后缀即跳过;减少媒体字段噪音。
return SKIP_JSON_KEYS.contains(normalized) || normalized.endsWith("url") || normalized.endsWith("icon");
}
private PortalSearchDoc initBase()
{
// 创建空文档对象;作为所有来源转换的基础承载体。
PortalSearchDoc doc = new PortalSearchDoc();
// 默认标记为未删除;与业务逻辑删除规则保持一致。
doc.setIsDelete("0");
// 返回初始化后的文档。
return doc;
}
private String joinText(String... parts)
{
// 过滤空白片段并做标准化后用空格拼接;生成干净可读的检索文本。
return Arrays.stream(parts)
// 去除空白元素;避免出现多余分隔符。
.filter(StringUtils::isNotBlank)
// 统一空白格式;保证索引文本一致性。
.map(this::normalizeWhitespace)
// 以单空格连接各片段;兼顾可读性和分词效果。
.collect(Collectors.joining(" "));
}
private String stripHtml(String text)
{
// 输入为空时返回空串;避免正则处理空值。
if (StringUtils.isBlank(text))
{
// 返回统一空字符串常量。
return StringUtils.EMPTY;
}
// 使用正则去掉 HTML 标签;保留纯文本供搜索。
String noTags = text.replaceAll("<[^>]+>", " ");
// 规范化空白后返回;避免标签替换后产生连续空格。
return normalizeWhitespace(noTags);
}
private String normalizeWhitespace(String value)
{
// 空输入返回空串;保证调用方无需额外判空。
if (value == null)
{
// 返回统一空字符串常量。
return StringUtils.EMPTY;
}
// 将连续空白压缩为单空格并去首尾空格;让索引文本更稳定。
return value.replaceAll("\\s+", " ").trim();
}
private String toString(Object value)
{
// 统一对象到字符串的空安全转换;减少上层重复判空。
return value == null ? null : String.valueOf(value);
}
private String toJson(Map<String, Object> routeQuery)
{
// 尝试序列化路由参数;保证索引内参数结构可扩展。
try
{
// 使用 ObjectMapper 输出标准 JSON 字符串。
return objectMapper.writeValueAsString(routeQuery);
}
catch (Exception e)
{
// 序列化失败时返回空对象 JSON避免 null 导致查询侧解析报错。
return "{}";
}
}
private Map<String, Object> buildConfigTypeRouteQuery(Long configTypeId, String webCode)
{
// 优先使用已解析 webCode缺失时回退 configTypeId 字符串;兼容旧数据。
String routeWebCode = StringUtils.isNotBlank(webCode) ? webCode : toString(configTypeId);
// routeWebCode 为空时返回空映射;避免输出无效路由参数。
if (StringUtils.isBlank(routeWebCode))
{
// 返回不可变空 Map表达“无可用参数”。
return Map.of();
}
// 同时返回 id 与 configTypeId兼容新旧消费方字段约定。
return Map.of("id", routeWebCode, "configTypeId", routeWebCode);
}
}

@ -62,141 +62,169 @@ public class PortalSearchDoc
public String getId()
{
// 返回 ES 文档主键;调用方需要用它做更新或删除定位。
return id;
}
public void setId(String id)
{
// 写入 ES 文档主键;保存前必须设置以保证主键可控。
this.id = id;
}
public String getDocId()
{
// 返回业务侧文档标识;用于跨来源统一追踪同一条搜索记录。
return docId;
}
public void setDocId(String docId)
{
// 设置业务文档标识;便于索引重建时保持幂等键语义。
this.docId = docId;
}
public String getSourceType()
{
// 返回来源类型;前端和服务层据此决定路由与展示策略。
return sourceType;
}
public void setSourceType(String sourceType)
{
// 写入来源类型;用于区分 menu/web/web1/document/configType 等来源。
this.sourceType = sourceType;
}
public String getTitle()
{
// 返回标题字段;搜索结果通常优先展示该字段。
return title;
}
public void setTitle(String title)
{
// 设置标题文本;供分词检索与高亮命中使用。
this.title = title;
}
public String getContent()
{
// 返回正文内容;用于全文检索和摘要截取。
return content;
}
public void setContent(String content)
{
// 设置正文内容;索引时需要该字段承载可检索文本。
this.content = content;
}
public String getWebCode()
{
// 返回页面编码;用于搜索命中后构造页面级路由参数。
return webCode;
}
public void setWebCode(String webCode)
{
// 写入页面编码;不同来源会复用该字段承载页面定位信息。
this.webCode = webCode;
}
public String getTypeId()
{
// 返回类型 ID用于产品分类或文档类型等维度区分。
return typeId;
}
public void setTypeId(String typeId)
{
// 设置类型 ID保证详情路由与过滤维度可回放。
this.typeId = typeId;
}
public String getDeviceId()
{
// 返回设备 ID用于产品详情页的设备级精确跳转。
return deviceId;
}
public void setDeviceId(String deviceId)
{
// 设置设备 ID让搜索结果可还原到具体设备详情。
this.deviceId = deviceId;
}
public String getMenuId()
{
// 返回菜单 ID菜单来源命中时需要该值拼装跳转参数。
return menuId;
}
public void setMenuId(String menuId)
{
// 写入菜单 ID确保 menu 类型文档能精准回到目标菜单页。
this.menuId = menuId;
}
public String getDocumentId()
{
// 返回文档 ID下载资料命中时依赖此值定位文件记录。
return documentId;
}
public void setDocumentId(String documentId)
{
// 设置文档 ID用于构造文档详情或下载链接参数。
this.documentId = documentId;
}
public String getRoute()
{
// 返回前端路由路径;搜索结果点击时直接用于导航。
return route;
}
public void setRoute(String route)
{
// 设置前端路由;索引构建时预先固化路由可减少查询侧分支判断。
this.route = route;
}
public String getRouteQueryJson()
{
// 返回路由参数 JSON用于携带 id/webCode 等跳转上下文。
return routeQueryJson;
}
public void setRouteQueryJson(String routeQueryJson)
{
// 写入路由参数 JSON采用字符串存储可兼容可变结构参数。
this.routeQueryJson = routeQueryJson;
}
public String getIsDelete()
{
// 返回逻辑删除标记;查询时据此过滤失效文档。
return isDelete;
}
public void setIsDelete(String isDelete)
{
// 设置逻辑删除标记;便于索引与业务表删除状态保持一致。
this.isDelete = isDelete;
}
public Date getUpdatedAt()
{
// 返回更新时间;用于判断索引新旧与增量同步。
return updatedAt;
}
public void setUpdatedAt(Date updatedAt)
{
// 写入更新时间;重建和增量更新时可据此比较时序。
this.updatedAt = updatedAt;
}
}

@ -33,10 +33,13 @@ import java.util.stream.Collectors;
@Service
public class PortalSearchEsServiceImpl implements PortalSearchEsService
{
// 类级日志器;用于记录索引自愈、解析失败等关键运行信息,方便运维排障。
private static final Logger log = LoggerFactory.getLogger(PortalSearchEsServiceImpl.class);
// ES 索引名常量;集中定义避免硬编码分散导致查询与建索引名称不一致。
private static final String INDEX_NAME = "hw_portal_content";
// 关键词正则转义模式;防止用户输入正则元字符时高亮匹配语义被破坏。
private static final Pattern ESCAPE_PATTERN = Pattern.compile("([\\\\.*+\\[\\](){}^$?|])");
private final PortalSearchMapper portalSearchMapper;
@ -51,8 +54,11 @@ public class PortalSearchEsServiceImpl implements PortalSearchEsService
@Autowired(required = false) IHwSearchRebuildService hwSearchRebuildService,
PortalSearchRouteResolver routeResolver)
{
// 注入 ES Mapper后续所有索引查询与分页都通过它执行。
this.portalSearchMapper = portalSearchMapper;
// 注入索引重建服务(可选);用于索引缺失或空索引时自动自愈。
this.hwSearchRebuildService = hwSearchRebuildService;
// 注入路由解析器;用于配置分类结果在查询侧做 webCode 归一化。
this.routeResolver = routeResolver;
}
@ -62,211 +68,310 @@ public class PortalSearchEsServiceImpl implements PortalSearchEsService
// 先确保索引可用且不是“空壳索引”,避免切到 ES 后只建了索引结构却没有任何文档,导致搜索始终返回空
ensureIndexReady();
// 创建 ES Lambda 查询包装器;统一承载查询条件并支持类型安全字段引用。
LambdaEsQueryWrapper<PortalSearchDoc> wrapper = new LambdaEsQueryWrapper<>();
// 组合查询条件:标题高权重匹配 + 内容低权重匹配;提升标题命中结果排序质量。
wrapper.and(q -> q.match(PortalSearchDoc::getTitle, keyword, 5.0f).or().match(PortalSearchDoc::getContent, keyword, 2.0f));
// 只查未删除数据;保持与业务表逻辑删除语义一致。
wrapper.eq(PortalSearchDoc::getIsDelete, "0");
// 执行 ES 分页查询;避免一次性拉取全部数据导致内存和网络压力。
EsPageInfo<PortalSearchDoc> pageInfo = portalSearchMapper.pageQuery(wrapper, pageNum, pageSize);
// 将 ES 文档映射为接口返回 DTO同时完成摘要和编辑路由拼装。
List<SearchResultDTO> rows = pageInfo.getList().stream().map(doc -> toResult(doc, keyword, editMode)).collect(Collectors.toList());
// 创建分页响应对象;统一封装 rows 与 total。
SearchPageDTO page = new SearchPageDTO();
// 写入当前页记录;供前端渲染列表。
page.setRows(rows);
// 写入总条数;供前端分页控件计算页码。
page.setTotal(pageInfo.getTotal());
// 返回分页结果;完成 ES 查询主流程。
return page;
}
private void ensureIndexReady()
{
// 捕获索引探测过程中的异常;区分可自愈异常与真实故障。
try
{
// 若索引不存在则触发重建;避免后续查询直接报错。
if (!portalSearchMapper.existsIndex(INDEX_NAME))
{
// 记录重建原因“索引不存在”;便于日志追踪。
rebuildIndex("索引不存在");
// 重建后本次检查即可结束;无需继续做文档数判断。
return;
}
// 索引存在且文档数大于 0 时说明可用;直接返回避免多余重建。
if (getIndexedDocumentCount() > 0)
{
// 直接结束检查;让查询继续执行。
return;
}
// 索引为空时触发重建;修复“有索引无数据”的空壳状态。
rebuildIndex("索引为空");
}
catch (Exception e)
{
// 若异常不是“索引不存在”类型则原样抛出;避免吞掉真实故障。
if (!isIndexNotFoundException(e))
{
// 抛出原异常;让上层感知并处理不可恢复错误。
throw e;
}
// 捕获到“索引不存在”异常时走重建;覆盖部分驱动不会先返回 exists=false 的情况。
rebuildIndex("索引不存在");
}
}
private long getIndexedDocumentCount()
{
// 创建空条件查询;仅用于读取总数,不限制具体字段。
LambdaEsQueryWrapper<PortalSearchDoc> wrapper = new LambdaEsQueryWrapper<>();
// 只拉取 1 条记录但读取 total以最小开销拿到文档总量。
EsPageInfo<PortalSearchDoc> pageInfo = portalSearchMapper.pageQuery(wrapper, 1, 1);
// 判空兜底返回 0避免底层异常场景引发空指针。
return pageInfo == null ? 0L : pageInfo.getTotal();
}
private void rebuildIndex(String reason)
{
// 若未注入重建服务则直接失败;避免误以为已自愈导致持续空结果。
if (hwSearchRebuildService == null)
{
// 抛出非法状态异常;明确提示部署缺少必要组件。
throw new IllegalStateException("门户搜索索引不可用,且未注入索引重建服务");
}
// 自动重建只处理“索引缺失/空索引”两类可自愈问题,避免把其它查询异常误判为需要删库重建
// 记录告警日志;便于运维定位为什么触发了自动重建。
log.warn("portal search index {} {}start rebuild automatically", INDEX_NAME, reason);
// 执行全量重建;确保索引结构和数据一次性恢复到可查询状态。
hwSearchRebuildService.rebuildAll();
}
private boolean isIndexNotFoundException(Throwable throwable)
{
// 从当前异常开始向 cause 链遍历;兼容异常被多层包装的场景。
Throwable current = throwable;
// 逐层检查异常信息;直到链尾结束。
while (current != null)
{
// 读取当前层异常消息;用于关键字判断。
String message = current.getMessage();
// 命中 ES 典型“索引不存在”文案则返回 true用于触发自动重建。
if (StringUtils.containsIgnoreCase(message, "index_not_found_exception")
|| StringUtils.containsIgnoreCase(message, "no such index [" + INDEX_NAME + "]"))
{
// 明确判定为索引缺失异常。
return true;
}
// 继续检查下一层 cause避免遗漏深层根因。
current = current.getCause();
}
// 全链路未命中关键字则返回 false表示不应按索引缺失处理。
return false;
}
private SearchResultDTO toResult(PortalSearchDoc doc, String keyword, boolean editMode)
{
// 创建结果 DTO用于承载接口输出字段。
SearchResultDTO result = new SearchResultDTO();
// 回填来源类型;前端可根据类型显示不同图标或操作。
result.setSourceType(doc.getSourceType());
// 设置标题;作为主展示文本。
result.setTitle(doc.getTitle());
// 构建摘要并高亮关键词;提升搜索结果可读性。
result.setSnippet(buildSnippet(doc.getTitle(), doc.getContent(), keyword));
// ES 侧暂未暴露原始相关性分值,先置 0 保持字段契约稳定。
result.setScore(0);
// 直接使用文档内路由;减少查询侧重复分支计算。
result.setRoute(doc.getRoute());
// 构建路由参数;配置分类会在这里做兼容归一化。
result.setRouteQuery(buildRouteQuery(doc));
// 编辑模式下追加后台编辑路由;满足管理端直达编辑需求。
if (editMode)
{
// 根据来源类型拼装编辑链接;保持与旧版编辑入口一致。
result.setEditRoute(buildEditRoute(doc.getSourceType(), doc.getWebCode(), doc.getTypeId(), doc.getDeviceId(), doc.getMenuId(), doc.getDocumentId()));
}
// 返回转换后的结果对象。
return result;
}
private Map<String, Object> buildRouteQuery(PortalSearchDoc doc)
{
// 先解析文档中已存的路由参数 JSON优先复用索引构建时的参数。
Map<String, Object> routeQuery = parseRouteQuery(doc.getRouteQueryJson());
// 非配置分类来源直接返回原参数;避免不必要改写。
if (!PortalSearchDocConverter.SOURCE_CONFIG_TYPE.equals(doc.getSourceType()))
{
// 直接返回原始参数映射。
return routeQuery;
}
// 优先使用文档 webCode为空时根据标题回查菜单解析 webCode兼容历史旧数据。
String routeWebCode = StringUtils.isNotBlank(doc.getWebCode()) ? doc.getWebCode() : routeResolver.resolveConfigTypeWebCode(doc.getTitle());
// 仍无法得到 webCode 时返回原参数;避免构造无效 id 导致前端跳错。
if (StringUtils.isBlank(routeWebCode))
{
// 保留原 routeQuery 作为兜底。
return routeQuery;
}
// 这里在查询侧再次归一化一次,兼容历史 ES 文档尚未重建时仍然写着旧的 config_type_id。
// 新建归一化参数映射;避免污染原始解析结果。
Map<String, Object> normalizedQuery = new HashMap<>();
// 写入 id兼容展示端当前使用的主路由参数名。
normalizedQuery.put("id", routeWebCode);
// 同步写入 configTypeId兼容既有消费方仍读取该键。
normalizedQuery.put("configTypeId", routeWebCode);
// 返回归一化后的参数。
return normalizedQuery;
}
private Map<String, Object> parseRouteQuery(String json)
{
// 空 JSON 字符串直接返回空 Map避免反序列化异常并统一返回类型。
if (StringUtils.isBlank(json))
{
// 返回可写空 Map调用方后续可继续 put 扩展参数。
return new HashMap<>();
}
// 尝试将 JSON 解析为键值映射;用于构建路由 query。
try
{
// 使用 TypeReference 保留泛型信息;确保反序列化成 Map<String, Object>。
return objectMapper.readValue(json, new TypeReference<Map<String, Object>>()
{
});
}
catch (Exception e)
{
// 记录解析失败日志;便于定位脏数据来源。
log.warn("parse routeQueryJson failed: {}", json);
// 返回空 Map 兜底;避免因为单条坏数据影响整体搜索结果。
return new HashMap<>();
}
}
private String buildSnippet(String title, String content, String keyword)
{
// 标题命中关键词时优先展示标题高亮;标题语义通常比正文片段更清晰。
if (StringUtils.isNotBlank(title) && StringUtils.containsIgnoreCase(title, keyword))
{
// 直接返回高亮后的标题。
return highlight(title, keyword);
}
// 对正文做空值兜底;避免后续截断逻辑出现空指针。
String normalized = StringUtils.defaultString(content);
// 正文为空时返回空串;前端可按空摘要处理。
if (StringUtils.isBlank(normalized))
{
// 使用统一空字符串常量返回。
return StringUtils.EMPTY;
}
// 定位关键词首次出现位置;用于截取上下文窗口。
int idx = StringUtils.indexOfIgnoreCase(normalized, keyword);
// 若正文未命中关键词则返回前 120 字;保证结果列表仍有可读摘要。
if (idx < 0)
{
// 截取正文开头作为通用摘要。
return StringUtils.substring(normalized, 0, Math.min(120, normalized.length()));
}
// 计算摘要起始位置,尽量向前保留 60 字上下文。
int start = Math.max(0, idx - 60);
// 计算摘要结束位置,尽量向后保留关键词后 60 字。
int end = Math.min(normalized.length(), idx + keyword.length() + 60);
// 截取关键词附近片段;提高命中可解释性。
String snippet = normalized.substring(start, end);
// 起始非 0 时补前缀省略号;提示用户这是中间片段。
if (start > 0)
{
// 在片段前追加省略号。
snippet = "..." + snippet;
}
// 结束未到正文尾时补后缀省略号;表示后面仍有内容。
if (end < normalized.length())
{
// 在片段后追加省略号。
snippet = snippet + "...";
}
// 对摘要执行关键词高亮;提升扫描效率。
return highlight(snippet, keyword);
}
private String highlight(String text, String keyword)
{
// 文本或关键词为空时直接返回原文本;避免构造无意义正则。
if (StringUtils.isBlank(text) || StringUtils.isBlank(keyword))
{
// 返回非空安全字符串;避免上层再判空。
return StringUtils.defaultString(text);
}
// 转义关键词中的正则元字符;防止用户输入导致正则语义被篡改。
String escaped = ESCAPE_PATTERN.matcher(keyword).replaceAll("\\\\$1");
// 构建忽略大小写匹配器;兼容大小写混合输入。
Matcher matcher = Pattern.compile("(?i)" + escaped).matcher(text);
// 若无命中则直接返回原文;避免不必要字符串替换。
if (!matcher.find())
{
// 原样返回文本。
return text;
}
// 将命中词包裹 em 标签;前端可统一样式高亮展示。
return matcher.replaceAll("<em class=\"search-hit\">$0</em>");
}
private String buildEditRoute(String sourceType, String webCode, String typeId, String deviceId, String menuId, String documentId)
{
// 菜单来源跳转到菜单编辑模式;便于运营快速定位菜单配置。
if (PortalSearchDocConverter.SOURCE_MENU.equals(sourceType))
{
// 返回菜单编辑路由并携带菜单 ID。
return "/editor?type=1&id=" + menuId;
}
// 页面来源按 webCode 区分首页、产品中心和普通菜单页。
if (PortalSearchDocConverter.SOURCE_WEB.equals(sourceType))
{
// webCode=7 固定跳产品中心编辑页。
if ("7".equals(webCode))
{
// 返回产品中心编辑入口。
return "/productCenter/edit";
}
// webCode=-1 固定跳首页编辑模式。
if ("-1".equals(webCode))
{
// 返回首页编辑入口。
return "/editor?type=3&id=-1";
}
// 其他 webCode 按普通页面编辑入口处理。
return "/editor?type=1&id=" + webCode;
}
// 详情来源进入 type=2 编辑模式,并携带三元组键。
if (PortalSearchDocConverter.SOURCE_WEB1.equals(sourceType))
{
// 返回详情编辑路由。
return "/editor?type=2&id=" + webCode + "," + typeId + "," + deviceId;
}
// 文档来源优先携带详情三元组;让编辑端可定位到具体挂载位置。
if (PortalSearchDocConverter.SOURCE_DOCUMENT.equals(sourceType))
{
// 三元组完整时拼完整 id 参数并带 documentId提供最精准定位。
if (StringUtils.isNotBlank(webCode) && StringUtils.isNotBlank(typeId) && StringUtils.isNotBlank(deviceId))
{
// 返回带详情上下文的文档编辑路由。
return "/editor?type=2&id=" + webCode + "," + typeId + "," + deviceId + "&documentId=" + documentId;
}
// 三元组缺失时退化为仅 documentId 查询;兼容历史文档数据。
return "/editor?type=2&documentId=" + documentId;
}
// 配置分类来源统一落到产品中心编辑;当前该类型由产品中心维护。
if (PortalSearchDocConverter.SOURCE_CONFIG_TYPE.equals(sourceType))
{
// 返回产品中心编辑入口。
return "/productCenter/edit";
}
// 未知来源返回通用编辑首页;保证链接始终可点击。
return "/editor";
}
}

@ -0,0 +1,178 @@
package com.ruoyi.portal.search.support;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.portal.domain.HwPortalConfigType;
import com.ruoyi.portal.domain.HwWebMenu;
import com.ruoyi.portal.mapper.HwWebMenuMapper;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.stream.Collectors;
/**
*
*
* @author ruoyi
*/
@Component
public class PortalSearchRouteResolver
{
// 配置分类编码到根菜单 ID 的映射;解析配置分类路由时先约束菜单树范围以减少误匹配。
private static final Map<String, Long> CONFIG_TYPE_ROOT_MENU_MAP = Map.of(
"3", 2L,
"4", 7L,
"5", 4L,
"6", 24L
);
private final HwWebMenuMapper hwWebMenuMapper;
public PortalSearchRouteResolver(HwWebMenuMapper hwWebMenuMapper)
{
// 保存菜单 Mapper 依赖;后续解析路由时需要实时读取菜单树,避免使用过期缓存数据。
this.hwWebMenuMapper = hwWebMenuMapper;
}
public String resolveConfigTypeWebCode(HwPortalConfigType configType, List<HwWebMenu> menus)
{
// 先做空值保护;避免上游未传配置对象时触发空指针并中断搜索流程。
if (configType == null)
{
// 无法解析时返回空;让调用方按兜底逻辑处理比抛异常更稳妥。
return null;
}
// 委托到统一解析入口;集中匹配规则便于维护并保证两种入口行为一致。
return resolveConfigTypeWebCode(
// 组装候选名称集合;兼容配置名称和首页展示名称两种命名来源。
buildCandidateNames(configType.getConfigTypeName(), configType.getHomeConfigTypeName()),
// 传入分类编码;用于约束应命中的根菜单范围,降低误匹配。
configType.getConfigTypeClassfication(),
// 传入菜单列表;避免在同一请求中重复查库。
menus
);
}
public String resolveConfigTypeWebCode(String title)
{
// 标题直查场景走同一套候选名解析;确保与配置对象场景的匹配逻辑一致。
return resolveConfigTypeWebCode(buildCandidateNames(title), null, loadMenus());
}
private String resolveConfigTypeWebCode(List<String> candidateNames, String configTypeClassfication, List<HwWebMenu> menus)
{
// 前置校验候选名与菜单集;缺任一关键输入时直接返回空,避免无意义遍历。
if (candidateNames.isEmpty() || menus == null || menus.isEmpty())
{
// 返回空表示未命中;让上层决定是否走默认路由。
return null;
}
// 根据分类编码映射期望根菜单;后续优先在该根菜单子树中匹配以提升准确率。
Long expectedRootMenuId = configTypeClassfication != null ? CONFIG_TYPE_ROOT_MENU_MAP.get(configTypeClassfication) : null;
// 这里优先按“分类所属根菜单 + 精确名称”匹配,避免把配置分类主键错误地当成页面 web_code 返回给前端。
// 在菜单列表中按候选名筛选并排序取首个;优先更接近业务期望的目标菜单。
HwWebMenu matchedMenu = menus.stream()
// 先按标准化后的菜单名做精确命中;减少模糊匹配带来的串线风险。
.filter(menu -> candidateNames.contains(normalize(menu.getWebMenuName())))
// 再按根菜单关系过滤;保证命中的菜单属于对应业务分类。
.filter(menu -> matchesRootMenu(menu, expectedRootMenuId))
// 排序时先让直系子菜单排前;因为它通常是前端实际跳转入口。
.sorted(Comparator
.comparing((HwWebMenu menu) -> !isDirectChild(menu, expectedRootMenuId))
// 同优先级再按菜单 ID 升序;保证结果稳定,避免同输入返回不同菜单。
.thenComparing(HwWebMenu::getWebMenuId, Comparator.nullsLast(Long::compareTo)))
// 取首个最佳匹配;减少后续处理复杂度。
.findFirst()
// 若根菜单约束无命中则降级为全局同名匹配;兼容历史数据不规范场景。
.orElseGet(() -> menus.stream()
// 继续使用相同标准化名称匹配;确保降级策略仍可解释。
.filter(menu -> candidateNames.contains(normalize(menu.getWebMenuName())))
// 按 ID 升序取第一个;保证降级结果可预测。
.sorted(Comparator.comparing(HwWebMenu::getWebMenuId, Comparator.nullsLast(Long::compareTo)))
// 获取降级匹配结果。
.findFirst()
// 仍未命中则返回空。
.orElse(null));
// 返回命中菜单 ID 字符串;前端路由参数以字符串传递可避免类型歧义。
return matchedMenu == null || matchedMenu.getWebMenuId() == null ? null : String.valueOf(matchedMenu.getWebMenuId());
}
private List<HwWebMenu> loadMenus()
{
// 查询全量菜单;标题直查场景没有外部传入菜单时需要自行加载。
return hwWebMenuMapper.selectHwWebMenuList(new HwWebMenu());
}
private List<String> buildCandidateNames(String... values)
{
// 使用有序去重集合存候选名;既要去重又要保留输入优先级。
Set<String> candidates = new LinkedHashSet<>();
// 遍历所有输入名称;统一做清洗和扩展以提升匹配命中率。
Arrays.stream(values)
// 过滤空白值;避免无效候选污染匹配集合。
.filter(StringUtils::isNotBlank)
// 统一标准化(去首尾空格与多空白);减少命名差异影响。
.map(this::normalize)
// 写入候选集合并按规则补充变体;兼容“案例”后缀命名。
.forEach(value ->
{
// 加入标准化名称本身;这是最直接的匹配键。
candidates.add(value);
// 若名称以“案例”结尾则补充去后缀版本;兼容菜单与配置命名不一致。
if (value.endsWith("案例"))
{
// 去掉“案例”后再次标准化后加入;避免截断后残留空白影响匹配。
candidates.add(normalize(StringUtils.substring(value, 0, value.length() - 2)));
}
});
// 转为列表返回;方便后续 contains 判断并保持候选顺序。
return new ArrayList<>(candidates);
}
private boolean matchesRootMenu(HwWebMenu menu, Long expectedRootMenuId)
{
// 菜单为空直接不匹配;防止访问空对象属性。
if (menu == null)
{
// 返回 false 明确告诉调用方当前记录不可用。
return false;
}
// 未指定期望根菜单时默认放行;用于标题直查等无分类上下文场景。
if (expectedRootMenuId == null)
{
// 返回 true 表示不做根菜单限制。
return true;
}
// 命中条件:直系子节点或祖先链包含目标根;兼容多级菜单结构。
return isDirectChild(menu, expectedRootMenuId) || containsAncestor(menu.getAncestors(), expectedRootMenuId);
}
private boolean isDirectChild(HwWebMenu menu, Long expectedRootMenuId)
{
// 通过 parent 与期望根菜单比较判断是否直系;这是最高优先级的匹配关系。
return menu != null && expectedRootMenuId != null && Objects.equals(menu.getParent(), expectedRootMenuId);
}
private boolean containsAncestor(String ancestors, Long expectedRootMenuId)
{
// 祖先串为空或目标根为空时无法判断包含关系;直接返回 false。
if (StringUtils.isBlank(ancestors) || expectedRootMenuId == null)
{
// 返回 false 避免误判菜单归属。
return false;
}
// 拆分祖先链并去空白后转集合;便于 O(1) 判断目标根是否存在。
return Arrays.stream(ancestors.split(","))
// 去除每段两端空格;兼容存储中可能存在的格式噪声。
.map(String::trim)
// 收集为集合提升 contains 查询效率。
.collect(Collectors.toSet())
// 判断目标根 ID 字符串是否在祖先链中;用于识别间接子孙菜单。
.contains(String.valueOf(expectedRootMenuId));
}
private String normalize(String value)
{
// 统一去首尾空白并压缩内部空白;保证不同来源文本可以稳定比对。
return StringUtils.trimToEmpty(value).replaceAll("\\s+", "");
}
}

@ -32,8 +32,10 @@ import java.util.stream.Collectors;
@Service
public class HwSearchServiceImpl implements IHwSearchService
{
// 类级日志器;用于记录 ES 降级、异常兜底等关键事件,方便线上问题追踪。
private static final Logger log = LoggerFactory.getLogger(HwSearchServiceImpl.class);
// 关键词正则转义模式;用于高亮替换前转义特殊字符,避免用户输入破坏正则语义。
private static final Pattern ESCAPE_PATTERN = Pattern.compile("([\\\\.*+\\[\\](){}^$?|])");
private final HwSearchMapper hwSearchMapper;
@ -51,316 +53,467 @@ public class HwSearchServiceImpl implements IHwSearchService
public HwSearchServiceImpl(HwSearchMapper hwSearchMapper, PortalSearchDocConverter converter,
@Autowired(required = false) PortalSearchEsService portalSearchEsService)
{
// 注入 MySQL 搜索 MapperES 不可用时需要它做兜底查询。
this.hwSearchMapper = hwSearchMapper;
// 注入文档转换器;用于把 JSON 正文抽取为可检索文本。
this.converter = converter;
// 注入 ES 搜索服务(可选);按配置启用 ES 时走该实现。
this.portalSearchEsService = portalSearchEsService;
}
@Override
public SearchPageDTO search(String keyword, Integer pageNum, Integer pageSize)
{
// 普通展示搜索统一走 doSearch并关闭编辑路由返回。
return doSearch(keyword, pageNum, pageSize, false);
}
@Override
public SearchPageDTO searchForEdit(String keyword, Integer pageNum, Integer pageSize)
{
// 编辑端搜索复用 doSearch但开启 editMode 以返回编辑入口。
return doSearch(keyword, pageNum, pageSize, true);
}
private SearchPageDTO doSearch(String keyword, Integer pageNum, Integer pageSize, boolean editMode)
{
// 先校验并规范化关键词;避免空关键词或超长关键词进入查询层。
String normalizedKeyword = validateKeyword(keyword);
// 规范化页码;确保分页计算稳定且不出现负偏移。
int normalizedPageNum = normalizePageNum(pageNum);
// 规范化页大小;避免一次拉取过多数据影响性能。
int normalizedPageSize = normalizePageSize(pageSize);
// 若当前配置允许且 ES 可用则优先走 ES获取更强检索能力与扩展性。
if (useEsEngine())
{
// 尝试执行 ES 搜索;成功则直接返回。
try
{
// 调用 ES 服务并传递编辑模式标识;保持两端结果结构一致。
return portalSearchEsService.search(normalizedKeyword, normalizedPageNum, normalizedPageSize, editMode);
}
catch (Exception e)
{
// ES 异常时记录错误并降级;保证搜索功能可用性优先。
log.error("ES search failed, fallback to mysql. keyword={}", normalizedKeyword, e);
}
}
// 走 MySQL 兜底实现;确保在 ES 不可用时仍可返回结果。
return searchByMysql(normalizedKeyword, normalizedPageNum, normalizedPageSize, editMode);
}
private boolean useEsEngine()
{
// 只有“ES开关开启 + ES服务已注入 + 引擎配置为es”三者同时满足才启用 ES。
return esEnabled && portalSearchEsService != null && "es".equalsIgnoreCase(searchEngine);
}
private SearchPageDTO searchByMysql(String keyword, int pageNum, int pageSize, boolean editMode)
{
// 执行 MySQL 关键词查询,拿到跨来源的原始命中记录集合。
List<SearchRawRecord> rawRecords = hwSearchMapper.searchByKeyword(keyword);
// 无结果时直接返回空分页对象;避免后续不必要处理。
if (rawRecords == null || rawRecords.isEmpty())
{
// 返回空对象让前端按“无数据”渲染。
return new SearchPageDTO();
}
// 用于承接所有可用结果 DTO后续统一排序与分页。
List<SearchResultDTO> all = new ArrayList<>();
// 遍历原始记录并逐条转换成结果 DTO。
for (SearchRawRecord raw : rawRecords)
{
// 执行单条记录转换,包含过滤、摘要与路由构建。
SearchResultDTO dto = toResult(raw, keyword, editMode);
// 仅加入有效命中;被过滤的记录会返回 null。
if (dto != null)
{
// 收集可展示记录用于后续排序分页。
all.add(dto);
}
}
// 按分数倒序排序;优先展示更相关结果。
all = all.stream().sorted(Comparator.comparing(SearchResultDTO::getScore).reversed()).collect(Collectors.toList());
// 创建分页响应对象。
SearchPageDTO page = new SearchPageDTO();
// 设置总条数;用于前端分页控件展示总量。
page.setTotal(all.size());
// 计算当前页起始下标并做下界保护。
int from = Math.max(0, (pageNum - 1) * pageSize);
// 若起始下标超出数据范围,返回空行集但保留 total。
if (from >= all.size())
{
// 设置空列表避免前端收到 null。
page.setRows(new ArrayList<>());
// 提前返回避免 subList 越界。
return page;
}
// 计算当前页结束下标并做上界保护。
int to = Math.min(all.size(), from + pageSize);
// 截取当前页数据。
page.setRows(all.subList(from, to));
// 返回分页结果。
return page;
}
private SearchResultDTO toResult(SearchRawRecord raw, String keyword, boolean editMode)
{
// 读取来源类型;后续按来源决定过滤策略与路由拼装。
String sourceType = raw.getSourceType();
// 对正文做空安全兜底;防止空值影响匹配判断。
String content = StringUtils.defaultString(raw.getContent());
// 页面类来源web/web1先做 JSON 文本抽取;避免结构化字段干扰关键词匹配。
if (PortalSearchDocConverter.SOURCE_WEB.equals(sourceType) || PortalSearchDocConverter.SOURCE_WEB1.equals(sourceType))
{
// 提取正文中的可检索文本;过滤 html 标签和媒体字段。
content = converter.extractSearchableText(content);
// 标题和正文都不命中则丢弃该记录;保持结果相关性。
if (!containsIgnoreCase(raw.getTitle(), keyword) && !containsIgnoreCase(content, keyword))
{
// 返回 null 表示该记录不应进入搜索结果。
return null;
}
}
// 非页面类来源直接按原正文匹配;命中判定规则保持一致。
else if (!containsIgnoreCase(raw.getTitle(), keyword) && !containsIgnoreCase(content, keyword))
{
// 未命中时返回 null减少噪音结果。
return null;
}
// 创建结果 DTO 承载响应字段。
SearchResultDTO dto = new SearchResultDTO();
// 回填来源类型;前端可按类型展示不同卡片样式。
dto.setSourceType(sourceType);
// 设置标题;作为搜索结果主文案。
dto.setTitle(raw.getTitle());
// 构建摘要并高亮关键词;提升可读性。
dto.setSnippet(buildSnippet(raw.getTitle(), content, keyword));
// 计算相关性分数;用于排序。
dto.setScore(calculateScore(raw, keyword));
// 构建前台路由路径。
dto.setRoute(buildRoute(sourceType, raw.getWebCode()));
// 构建前台路由参数。
dto.setRouteQuery(buildRouteQuery(raw));
// 编辑模式下补充编辑路由;便于后台快速定位配置。
if (editMode)
{
// 根据来源类型拼接编辑端地址。
dto.setEditRoute(buildEditRoute(raw));
}
// 返回转换后的结果。
return dto;
}
private int calculateScore(SearchRawRecord raw, String keyword)
{
// 读取基础分,空值按 0 处理;保证分值计算稳定。
int base = raw.getScore() == null ? 0 : raw.getScore();
// 标题做空安全处理;避免空值参与 contains。
String title = StringUtils.defaultString(raw.getTitle());
// 正文做空安全处理;避免空值参与 contains。
String content = StringUtils.defaultString(raw.getContent());
// 标题命中加更高权重;标题通常比正文更能代表主题。
if (containsIgnoreCase(title, keyword))
{
// 增加标题命中奖励分。
base += 20;
}
// 正文命中加次级权重;补充内容相关性。
if (containsIgnoreCase(content, keyword))
{
// 增加正文命中奖励分。
base += 10;
}
// 返回综合分值用于排序。
return base;
}
private String buildRoute(String sourceType, String webCode)
{
// 菜单来源统一跳测试页;与当前展示端菜单入口约定一致。
if (PortalSearchDocConverter.SOURCE_MENU.equals(sourceType))
{
// 返回菜单展示路由。
return "/test";
}
// 页面来源按 webCode 识别首页/产品中心/普通页。
if (PortalSearchDocConverter.SOURCE_WEB.equals(sourceType))
{
// 首页固定路由。
if ("-1".equals(webCode))
{
// 返回首页路径。
return "/index";
}
// 产品中心聚合页固定路由。
if ("7".equals(webCode))
{
// 返回产品中心路径。
return "/productCenter";
}
// 其他页面走测试页。
return "/test";
}
// 详情来源跳产品详情页。
if (PortalSearchDocConverter.SOURCE_WEB1.equals(sourceType))
{
// 返回详情路径。
return "/productCenter/detail";
}
// 文档来源跳服务支持页。
if (PortalSearchDocConverter.SOURCE_DOCUMENT.equals(sourceType))
{
// 返回服务支持路径。
return "/serviceSupport";
}
// 配置分类来源跳产品中心页。
if (PortalSearchDocConverter.SOURCE_CONFIG_TYPE.equals(sourceType))
{
// 返回产品中心路径。
return "/productCenter";
}
// 未识别来源时回退首页;保证返回路由始终有效。
return "/index";
}
private Map<String, Object> buildRouteQuery(SearchRawRecord raw)
{
// 新建可变参数映射;按来源类型填充 query。
Map<String, Object> query = new HashMap<>();
// 读取来源类型用于分支判断。
String sourceType = raw.getSourceType();
// 菜单来源仅需 menuId 作为 id 参数。
if (PortalSearchDocConverter.SOURCE_MENU.equals(sourceType))
{
// 写入 id 参数供前端定位菜单页。
query.put("id", raw.getMenuId());
// 返回菜单 query。
return query;
}
// 普通页面(排除首页/产品中心)使用 webCode 作为 id 参数。
if (PortalSearchDocConverter.SOURCE_WEB.equals(sourceType) && !"-1".equals(raw.getWebCode()) && !"7".equals(raw.getWebCode()))
{
// 写入页面 id 参数。
query.put("id", raw.getWebCode());
// 返回页面 query。
return query;
}
// 详情来源需要三元组参数才能唯一定位页面。
if (PortalSearchDocConverter.SOURCE_WEB1.equals(sourceType))
{
// 写入 webCode。
query.put("webCode", raw.getWebCode());
// 写入 typeId。
query.put("typeId", raw.getTypeId());
// 写入 deviceId。
query.put("deviceId", raw.getDeviceId());
// 返回详情 query。
return query;
}
// 文档来源返回 documentId 及其关联页面上下文。
if (PortalSearchDocConverter.SOURCE_DOCUMENT.equals(sourceType))
{
// 写入 documentId 用于定位下载文档。
query.put("documentId", raw.getDocumentId());
// 写入 webCode 作为关联页面信息。
query.put("webCode", raw.getWebCode());
// 写入 typeId 作为关联分类信息。
query.put("typeId", raw.getTypeId());
// 返回文档 query。
return query;
}
// 配置分类命中时把 webCode 作为页面 id 输出,避免前端误用 configType 主键。
if (PortalSearchDocConverter.SOURCE_CONFIG_TYPE.equals(sourceType))
{
// 优先用 webCode缺失时回退 typeId兼容历史数据未回填 webCode 的情况。
String routeWebCode = StringUtils.isNotBlank(raw.getWebCode()) ? raw.getWebCode() : raw.getTypeId();
// 配置分类命中需要跳到页面 web_code对前端暴露分类主键会把分类查询参数误当成页面入口参数。
// 同时写入 id 键;兼容展示端当前参数约定。
query.put("id", routeWebCode);
// 同时写入 configTypeId 键;兼容旧消费逻辑仍读取该字段。
query.put("configTypeId", routeWebCode);
// 返回配置分类 query。
return query;
}
// 其他来源返回空 query表示无需额外参数。
return query;
}
private String buildEditRoute(SearchRawRecord raw)
{
// 读取来源类型;按类型决定后台编辑入口。
String sourceType = raw.getSourceType();
// 菜单来源进入菜单编辑模式。
if (PortalSearchDocConverter.SOURCE_MENU.equals(sourceType))
{
// 拼接菜单编辑链接并带 menuId。
return "/editor?type=1&id=" + raw.getMenuId();
}
// 页面来源按 webCode 区分产品中心、首页、普通页面。
if (PortalSearchDocConverter.SOURCE_WEB.equals(sourceType))
{
// 产品中心配置跳独立编辑页。
if ("7".equals(raw.getWebCode()))
{
// 返回产品中心编辑地址。
return "/productCenter/edit";
}
// 首页配置跳首页编辑模式。
if ("-1".equals(raw.getWebCode()))
{
// 返回首页编辑地址。
return "/editor?type=3&id=-1";
}
// 其他页面跳通用页面编辑模式。
return "/editor?type=1&id=" + raw.getWebCode();
}
// 详情来源进入详情编辑模式并携带三元组。
if (PortalSearchDocConverter.SOURCE_WEB1.equals(sourceType))
{
// 拼接详情编辑地址。
return "/editor?type=2&id=" + raw.getWebCode() + "," + raw.getTypeId() + "," + raw.getDeviceId();
}
// 文档来源优先携带详情三元组,便于编辑端精确定位文档挂载点。
if (PortalSearchDocConverter.SOURCE_DOCUMENT.equals(sourceType))
{
// 当 webCode/typeId/deviceId 都存在时拼完整上下文。
if (StringUtils.isNotBlank(raw.getWebCode()) && StringUtils.isNotBlank(raw.getTypeId()) && StringUtils.isNotBlank(raw.getDeviceId()))
{
// 返回包含详情上下文与 documentId 的编辑地址。
return "/editor?type=2&id=" + raw.getWebCode() + "," + raw.getTypeId() + "," + raw.getDeviceId() + "&documentId=" + raw.getDocumentId();
}
// 缺少上下文字段时退化为仅 documentId 查询。
return "/editor?type=2&documentId=" + raw.getDocumentId();
}
// 配置分类统一由产品中心编辑页维护。
if (PortalSearchDocConverter.SOURCE_CONFIG_TYPE.equals(sourceType))
{
// 返回产品中心编辑入口。
return "/productCenter/edit";
}
// 其他来源回退到编辑首页;确保链接可用。
return "/editor";
}
private String buildSnippet(String title, String content, String keyword)
{
// 标题命中时优先返回标题高亮,用户通常更关注标题语义。
if (StringUtils.isNotBlank(title) && containsIgnoreCase(title, keyword))
{
// 返回标题高亮结果。
return highlight(title, keyword);
}
// 正文为空则直接返回空摘要,避免后续截取逻辑报错。
if (StringUtils.isBlank(content))
{
// 返回统一空字符串常量。
return StringUtils.EMPTY;
}
// 标准化正文空白字符,提升截取和展示一致性。
String normalized = content.replaceAll("\\s+", " ").trim();
// 定位关键词首次出现位置,用于构建上下文片段。
int index = StringUtils.indexOfIgnoreCase(normalized, keyword);
// 未命中关键词时返回正文前 120 字,保证有基本预览信息。
if (index < 0)
{
// 返回开头摘要片段。
return StringUtils.substring(normalized, 0, Math.min(120, normalized.length()));
}
// 计算摘要起始下标,尽量保留命中点前文上下文。
int start = Math.max(0, index - 60);
// 计算摘要结束下标,尽量保留命中点后文上下文。
int end = Math.min(normalized.length(), index + keyword.length() + 60);
// 截取命中附近片段。
String snippet = normalized.substring(start, end);
// 非正文开头时添加前缀省略号,提示是中间片段。
if (start > 0)
{
// 拼接前缀省略号。
snippet = "..." + snippet;
}
// 非正文结尾时添加后缀省略号,提示后续仍有内容。
if (end < normalized.length())
{
// 拼接后缀省略号。
snippet = snippet + "...";
}
// 对片段做关键词高亮,增强视觉可读性。
return highlight(snippet, keyword);
}
private String highlight(String text, String keyword)
{
// 文本或关键词为空时直接返回原文本,避免无意义正则计算。
if (StringUtils.isBlank(text) || StringUtils.isBlank(keyword))
{
// 返回空安全文本,避免上层二次判空。
return StringUtils.defaultString(text);
}
// 转义关键词中的正则元字符,防止关键字被当作正则表达式解释。
String escaped = ESCAPE_PATTERN.matcher(keyword).replaceAll("\\\\$1");
// 构造忽略大小写匹配器,兼容用户大小写混合输入。
Matcher matcher = Pattern.compile("(?i)" + escaped).matcher(text);
// 若无命中则返回原文,避免不必要字符串替换。
if (!matcher.find())
{
// 原样返回文本。
return text;
}
// 将命中词包裹 em 标签,前端可通过样式统一高亮。
return matcher.replaceAll("<em class=\"search-hit\">$0</em>");
}
private boolean containsIgnoreCase(String text, String keyword)
{
// 同时保证文本和关键词非空,再执行忽略大小写包含判断,减少重复空值校验。
return StringUtils.isNotBlank(text) && StringUtils.isNotBlank(keyword) && StringUtils.containsIgnoreCase(text, keyword);
}
private int normalizePageNum(Integer pageNum)
{
// 页码为空或非法时回退到 1保证分页从第一页开始。
if (pageNum == null || pageNum <= 0)
{
// 返回默认页码 1。
return 1;
}
// 合法页码原样返回。
return pageNum;
}
private int normalizePageSize(Integer pageSize)
{
// 页大小为空或非法时回退默认值 20避免查全表。
if (pageSize == null || pageSize <= 0)
{
// 返回默认分页大小。
return 20;
}
// 限制最大页大小为 50避免单次返回过大影响性能。
return Math.min(pageSize, 50);
}
private String validateKeyword(String keyword)
{
// 去除关键词首尾空白,避免用户输入空格导致误判。
String normalized = StringUtils.trim(keyword);
// 关键词为空时抛业务异常,阻止无意义搜索。
if (StringUtils.isBlank(normalized))
{
// 抛出明确错误提示,指导前端提示用户输入关键词。
throw new ServiceException("关键词不能为空");
}
// 限制关键词长度,防止超长输入拖慢查询或造成异常。
if (normalized.length() > 50)
{
// 抛出长度超限异常,保护系统资源。
throw new ServiceException("关键词长度不能超过50");
}
// 返回校验后的关键词用于后续查询。
return normalized;
}
}

Loading…
Cancel
Save