using System.Text.Json; using Microsoft.Extensions.Options; namespace Admin.NET.Plugin.HwPortal; public class HwSearchService : ITransient { // 搜索高亮时,关键词里如果包含正则特殊字符,需要先转义。 private static readonly Regex EscapePattern = new(@"([\\.*+\[\](){}^$?|])", RegexOptions.Compiled); // 这里同时注入“索引主链路查询服务”和“legacy SQL 查询服务”。 // 这么做是因为当前门户搜索不是单引擎,而是“主链路 + 回退链路”的双通道设计。 private readonly HwSearchQueryService _queryService; private readonly LegacyHwSearchQueryService _legacyQueryService; private readonly PortalSearchDocConverter _converter; private readonly HwPortalSearchOptions _options; private readonly ILogger _logger; public HwSearchService( HwSearchQueryService queryService, LegacyHwSearchQueryService legacyQueryService, PortalSearchDocConverter converter, IOptions options, ILogger logger) { _queryService = queryService; _legacyQueryService = legacyQueryService; _converter = converter; _options = options.Value; _logger = logger; } public Task Search(string keyword, int? pageNum, int? pageSize) { // false 代表展示端搜索,不需要返回编辑端路由。 return DoSearch(keyword, pageNum, pageSize, false); } public Task SearchForEdit(string keyword, int? pageNum, int? pageSize) { // true 代表编辑端搜索,需要额外产出 editRoute 给后台编辑器跳转。 return DoSearch(keyword, pageNum, pageSize, true); } private async Task DoSearch(string keyword, int? pageNum, int? pageSize, bool editMode) { // 先把输入参数统一规范化,避免后面的业务逻辑到处判空。 string normalizedKeyword = ValidateKeyword(keyword); int normalizedPageNum = NormalizePageNum(pageNum); int normalizedPageSize = NormalizePageSize(pageSize); // 这里先拿“候选记录全集”,再在内存里做二次打分和分页。 // 这么做不是为了偷懒,而是为了严格贴住 Java 源实现: // 源模块也是先查候选,再在服务层做过滤、高亮、打分、分页。 List rawRecords = await LoadSearchRecordsAsync(normalizedKeyword); if (rawRecords.Count == 0) { return new SearchPageDTO(); } // LINQ 链式处理: // Select = 投影 // Where = 过滤 // OrderByDescending = 倒序排序 // ToList = 立即执行并转成 List List all = rawRecords .Select(raw => ToResult(raw, normalizedKeyword, editMode)) .Where(item => item != null) // 这里排序不能偷懒省掉。 // 因为 legacy SQL 虽然已经按 score/update_time 做了一轮排序,但服务层还会再追加“标题命中 +20、正文命中 +10”的二次打分。 // 如果这里不重新按 Score 排,最终顺序就会和 Java 源实现不一致。 .OrderByDescending(item => item.Score) .ToList(); SearchPageDTO page = new() { Total = all.Count }; int from = Math.Max(0, (normalizedPageNum - 1) * normalizedPageSize); if (from >= all.Count) { return page; } page.Rows = all.Skip(from).Take(normalizedPageSize).ToList(); return page; } private SearchResultDTO ToResult(SearchRawRecord raw, string keyword, bool editMode) { string sourceType = raw.SourceType; string content = raw.Content ?? string.Empty; if (string.Equals(sourceType, PortalSearchDocConverter.SourceWeb, StringComparison.Ordinal) || string.Equals(sourceType, PortalSearchDocConverter.SourceWeb1, StringComparison.Ordinal)) { // Why:web / web1 的正文是 JSON,需要先抽取可搜索文本,不能直接拿原 JSON 生搜。 // 这里再次抽取而不是完全依赖索引层,是因为 legacy SQL 回退场景下查出来的仍然可能是原始 JSON。 content = _converter.ExtractSearchableText(content); if (!ContainsIgnoreCase(raw.Title, keyword) && !ContainsIgnoreCase(content, keyword)) { return null; } } else if (!ContainsIgnoreCase(raw.Title, keyword) && !ContainsIgnoreCase(content, keyword)) { // 非 web/web1 来源不需要做 JSON 文本抽取,直接看标题和正文即可。 // 如果两者都不命中,就说明这条候选记录只是 SQL 初筛命中,但不满足最终展示条件。 return null; } SearchResultDTO dto = new() { // 对象初始化器: // 是 C# 中创建对象并同时赋值的常见语法。 SourceType = sourceType, Title = raw.Title, Snippet = BuildSnippet(raw.Title, content, keyword), Score = CalculateScore(raw, keyword), Route = string.IsNullOrWhiteSpace(raw.Route) ? BuildRoute(sourceType, raw.WebCode) : raw.Route, RouteQuery = BuildRouteQuery(raw) }; if (editMode) { // 只有编辑端才需要这个字段。 // 展示端不返回 editRoute,可以避免前台无意中依赖后台路由协议。 dto.EditRoute = string.IsNullOrWhiteSpace(raw.EditRoute) ? BuildEditRoute(raw) : raw.EditRoute; } return dto; } private static int CalculateScore(SearchRawRecord raw, string keyword) { // Why:保持原 Java 逻辑,标题命中比正文命中权重更高。 int score = raw.Score ?? 0; if (ContainsIgnoreCase(raw.Title, keyword)) { // 标题命中给更高权重,是因为用户通常更信任标题语义。 // 如果正文和标题都命中,标题相关结果应该优先展示。 score += 20; } if (ContainsIgnoreCase(raw.Content, keyword)) { score += 10; } return score; } private static string BuildRoute(string sourceType, string webCode) { if (string.Equals(sourceType, PortalSearchDocConverter.SourceMenu, StringComparison.Ordinal)) { return "/test"; } if (string.Equals(sourceType, PortalSearchDocConverter.SourceWeb, StringComparison.Ordinal)) { if (string.Equals(webCode, "-1", StringComparison.Ordinal)) { return "/index"; } if (string.Equals(webCode, "7", StringComparison.Ordinal)) { return "/productCenter"; } return "/test"; } if (string.Equals(sourceType, PortalSearchDocConverter.SourceWeb1, StringComparison.Ordinal)) { return "/productCenter/detail"; } if (string.Equals(sourceType, PortalSearchDocConverter.SourceDocument, StringComparison.Ordinal)) { return "/serviceSupport"; } if (string.Equals(sourceType, PortalSearchDocConverter.SourceConfigType, StringComparison.Ordinal)) { return "/productCenter"; } // 未知来源统一给首页兜底,避免前端因为 route 为 null 直接报错。 return "/index"; } private static Dictionary BuildRouteQuery(SearchRawRecord raw) { if (!string.IsNullOrWhiteSpace(raw.RouteQueryJson)) { Dictionary parsed = ParseRouteQueryJson(raw.RouteQueryJson); if (parsed.Count > 0) { // 索引链路如果已经带了 routeQueryJson,优先相信索引层产出的结果。 // 这样可以避免搜索服务和索引服务各自维护一套路由规则,后续改动时更容易失配。 return parsed; } } Dictionary query = new(); if (string.Equals(raw.SourceType, PortalSearchDocConverter.SourceMenu, StringComparison.Ordinal)) { query["id"] = raw.MenuId; return query; } if (string.Equals(raw.SourceType, PortalSearchDocConverter.SourceWeb, StringComparison.Ordinal) && !string.Equals(raw.WebCode, "-1", StringComparison.Ordinal) && !string.Equals(raw.WebCode, "7", StringComparison.Ordinal)) { query["id"] = raw.WebCode; return query; } if (string.Equals(raw.SourceType, PortalSearchDocConverter.SourceWeb1, StringComparison.Ordinal)) { query["webCode"] = raw.WebCode; query["typeId"] = raw.TypeId; query["deviceId"] = raw.DeviceId; return query; } if (string.Equals(raw.SourceType, PortalSearchDocConverter.SourceDocument, StringComparison.Ordinal)) { query["documentId"] = raw.DocumentId; query["webCode"] = raw.WebCode; query["typeId"] = raw.TypeId; return query; } if (string.Equals(raw.SourceType, PortalSearchDocConverter.SourceConfigType, StringComparison.Ordinal)) { // 配置分类不能直接把数据库主键原样吐给前端。 // 当前前端真实消费的是页面入口编码,所以这里优先取 webCode,其次才退回 typeId 做兜底。 string routeWebCode = !string.IsNullOrWhiteSpace(raw.WebCode) ? raw.WebCode : raw.TypeId; query["id"] = routeWebCode; query["configTypeId"] = routeWebCode; } return query; } private async Task> LoadSearchRecordsAsync(string keyword) { if (!UseIndexedEngine()) { // 明确走 legacy 模式时,直接退回旧 SQL。 // 这里不能再“顺手尝试一下索引表”,否则配置就失去可预测性了。 return await _legacyQueryService.SearchAsync(keyword); } try { // 主链路优先走索引表查询。 // 索引表的好处是结构统一、可扩展性更好,也更方便后续替换成真正 ES。 List records = await _queryService.SearchAsync(keyword, _options.TakeLimit); if (records.Count > 0) { return records; } if (_options.EnableLegacyFallback && !await _queryService.HasIndexedDocumentAsync()) { // 索引表为空时自动回退旧 SQL,是为了兼容“索引还没建好/被清空”的冷启动场景。 // 只有在确认没有任何有效索引文档时才触发,避免把正常空结果误判成需要降级。 return await _legacyQueryService.SearchAsync(keyword); } return records; } catch (Exception ex) when (_options.EnableLegacyFallback) { // 这里 catch 后降级,而不是把异常抛给前端。 // 业务目标是“搜索尽量可用”,所以主链路失败时优先保服务,再考虑排查索引问题。 _logger.LogWarning(ex, "portal search indexed query failed, fallback to legacy mapper"); return await _legacyQueryService.SearchAsync(keyword); } } private bool UseIndexedEngine() { // 这里把 mysql / legacy 都视为“明确关闭索引表主链路”的配置。 // 这样老系统切换配置时,只需要改一个字符串,不用再理解内部是否是 EF 或全文索引实现。 return !string.Equals(_options.Engine, "mysql", StringComparison.OrdinalIgnoreCase) && !string.Equals(_options.Engine, "legacy", StringComparison.OrdinalIgnoreCase); } private static string BuildEditRoute(SearchRawRecord raw) { if (string.Equals(raw.SourceType, PortalSearchDocConverter.SourceMenu, StringComparison.Ordinal)) { return $"/editor?type=1&id={raw.MenuId}"; } if (string.Equals(raw.SourceType, PortalSearchDocConverter.SourceWeb, StringComparison.Ordinal)) { if (string.Equals(raw.WebCode, "7", StringComparison.Ordinal)) { return "/productCenter/edit"; } if (string.Equals(raw.WebCode, "-1", StringComparison.Ordinal)) { return "/editor?type=3&id=-1"; } return $"/editor?type=1&id={raw.WebCode}"; } if (string.Equals(raw.SourceType, PortalSearchDocConverter.SourceWeb1, StringComparison.Ordinal)) { return $"/editor?type=2&id={raw.WebCode},{raw.TypeId},{raw.DeviceId}"; } if (string.Equals(raw.SourceType, PortalSearchDocConverter.SourceDocument, StringComparison.Ordinal)) { if (!string.IsNullOrWhiteSpace(raw.WebCode) && !string.IsNullOrWhiteSpace(raw.TypeId) && !string.IsNullOrWhiteSpace(raw.DeviceId)) { // 文档如果同时绑定了详情页三元组,就尽量把上下文一起带给编辑器。 // 这样后台编辑时可以直接还原到“哪个详情页下的哪个文档”。 return $"/editor?type=2&id={raw.WebCode},{raw.TypeId},{raw.DeviceId}&documentId={raw.DocumentId}"; } return $"/editor?type=2&documentId={raw.DocumentId}"; } if (string.Equals(raw.SourceType, PortalSearchDocConverter.SourceConfigType, StringComparison.Ordinal)) { return "/productCenter/edit"; } return "/editor"; } private static string BuildSnippet(string title, string content, string keyword) { // 优先用标题做摘要;标题不命中再从正文里截取关键片段。 if (!string.IsNullOrWhiteSpace(title) && ContainsIgnoreCase(title, keyword)) { return Highlight(title, keyword); } if (string.IsNullOrWhiteSpace(content)) { return string.Empty; } string normalized = Regex.Replace(content, @"\s+", " ").Trim(); int index = normalized.IndexOf(keyword, StringComparison.OrdinalIgnoreCase); if (index < 0) { // 没命中时直接截前 120 个字符,和原 Java 行为保持一致。 // 这里不要擅自改成长摘要或整段正文,否则前端列表会明显变长。 return normalized[..Math.Min(120, normalized.Length)]; } int start = Math.Max(0, index - 60); int end = Math.Min(normalized.Length, index + keyword.Length + 60); // 摘要窗口固定为“命中点前后各约 60 个字符”。 // 这是原模块的可见行为之一,前端样式和用户感知都已经围绕这个长度形成预期,不宜随意改大改小。 string snippet = normalized[start..end]; if (start > 0) { snippet = "..." + snippet; } if (end < normalized.Length) { snippet += "..."; } return Highlight(snippet, keyword); } private static string Highlight(string text, string keyword) { // 这里返回带 标签的 HTML,前端可以直接做高亮展示。 if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(keyword)) { return text ?? string.Empty; } // 先对关键词做正则转义,再参与 Regex.Replace。 // 否则用户搜 “C++” “(test)” 这类带特殊字符的词时,高亮逻辑会直接跑偏。 string escaped = EscapePattern.Replace(keyword, "\\$1"); Match match = Regex.Match(text, $"(?i){escaped}"); if (!match.Success) { return text; } return Regex.Replace(text, $"(?i){escaped}", "$0"); } private static bool ContainsIgnoreCase(string text, string keyword) { return !string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(keyword) && text.Contains(keyword, StringComparison.OrdinalIgnoreCase); } private static int NormalizePageNum(int? pageNum) { return pageNum is > 0 ? pageNum.Value : 1; } private static int NormalizePageSize(int? pageSize) { if (pageSize is null or <= 0) { return 20; } // 页大小上限固定 50,是为了防止单次搜索返回过大结果集,把高亮和摘要处理拖慢。 return Math.Min(pageSize.Value, 50); } private static string ValidateKeyword(string keyword) { // Why:先把无效输入挡在入口,避免把空关键词或超长关键词直接带进数据库查询。 string normalized = keyword?.Trim(); if (string.IsNullOrWhiteSpace(normalized)) { throw Oops.Oh("关键词不能为空"); } if (normalized.Length > 50) { // 50 这个长度限制来自原门户实现,不是随便拍脑袋定的。 // 它同时在兜底 SQL、摘要处理、前端输入体验上形成了统一约束。 throw Oops.Oh("关键词长度不能超过50"); } return normalized; } private static Dictionary ParseRouteQueryJson(string routeQueryJson) { try { Dictionary values = JsonSerializer.Deserialize>(routeQueryJson) ?? new Dictionary(); return values.ToDictionary(item => item.Key, item => (object)item.Value); } catch { // routeQueryJson 解析失败时直接吞掉并回退到动态拼装。 // 这是一个有意的容错策略:索引文档里偶发脏数据不应该把整条搜索结果打挂。 return new Dictionary(); } } }