diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/search/convert/PortalSearchDocConverter.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/search/convert/PortalSearchDocConverter.java index d387116..683c943 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/search/convert/PortalSearchDocConverter.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/search/convert/PortalSearchDocConverter.java @@ -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 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 routeQuery) { + // 尝试序列化路由参数;保证索引内参数结构可扩展。 try { + // 使用 ObjectMapper 输出标准 JSON 字符串。 return objectMapper.writeValueAsString(routeQuery); } catch (Exception e) { + // 序列化失败时返回空对象 JSON;避免 null 导致查询侧解析报错。 return "{}"; } } private Map 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); } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/search/es/entity/PortalSearchDoc.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/search/es/entity/PortalSearchDoc.java index 1f6b673..45ced97 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/search/es/entity/PortalSearchDoc.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/search/es/entity/PortalSearchDoc.java @@ -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; } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/search/service/impl/PortalSearchEsServiceImpl.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/search/service/impl/PortalSearchEsServiceImpl.java index 041881a..f95beec 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/search/service/impl/PortalSearchEsServiceImpl.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/search/service/impl/PortalSearchEsServiceImpl.java @@ -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 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 pageInfo = portalSearchMapper.pageQuery(wrapper, pageNum, pageSize); + // 将 ES 文档映射为接口返回 DTO;同时完成摘要和编辑路由拼装。 List 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 wrapper = new LambdaEsQueryWrapper<>(); + // 只拉取 1 条记录但读取 total;以最小开销拿到文档总量。 EsPageInfo 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 buildRouteQuery(PortalSearchDoc doc) { + // 先解析文档中已存的路由参数 JSON;优先复用索引构建时的参数。 Map 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 normalizedQuery = new HashMap<>(); + // 写入 id;兼容展示端当前使用的主路由参数名。 normalizedQuery.put("id", routeWebCode); + // 同步写入 configTypeId;兼容既有消费方仍读取该键。 normalizedQuery.put("configTypeId", routeWebCode); + // 返回归一化后的参数。 return normalizedQuery; } private Map parseRouteQuery(String json) { + // 空 JSON 字符串直接返回空 Map;避免反序列化异常并统一返回类型。 if (StringUtils.isBlank(json)) { + // 返回可写空 Map;调用方后续可继续 put 扩展参数。 return new HashMap<>(); } + // 尝试将 JSON 解析为键值映射;用于构建路由 query。 try { + // 使用 TypeReference 保留泛型信息;确保反序列化成 Map。 return objectMapper.readValue(json, new TypeReference>() { }); } 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("$0"); } 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"; } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/search/support/PortalSearchRouteResolver.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/search/support/PortalSearchRouteResolver.java new file mode 100644 index 0000000..d7027ed --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/search/support/PortalSearchRouteResolver.java @@ -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 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 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 candidateNames, String configTypeClassfication, List 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 loadMenus() + { + // 查询全量菜单;标题直查场景没有外部传入菜单时需要自行加载。 + return hwWebMenuMapper.selectHwWebMenuList(new HwWebMenu()); + } + + private List buildCandidateNames(String... values) + { + // 使用有序去重集合存候选名;既要去重又要保留输入优先级。 + Set 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+", ""); + } +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/service/impl/HwSearchServiceImpl.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/service/impl/HwSearchServiceImpl.java index ac50ef1..9fc88e7 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/service/impl/HwSearchServiceImpl.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/service/impl/HwSearchServiceImpl.java @@ -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 搜索 Mapper;ES 不可用时需要它做兜底查询。 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 rawRecords = hwSearchMapper.searchByKeyword(keyword); + // 无结果时直接返回空分页对象;避免后续不必要处理。 if (rawRecords == null || rawRecords.isEmpty()) { + // 返回空对象让前端按“无数据”渲染。 return new SearchPageDTO(); } + // 用于承接所有可用结果 DTO;后续统一排序与分页。 List 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 buildRouteQuery(SearchRawRecord raw) { + // 新建可变参数映射;按来源类型填充 query。 Map 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("$0"); } 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; } }