You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

457 lines
18 KiB
C#

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<HwSearchService> _logger;
public HwSearchService(
HwSearchQueryService queryService,
LegacyHwSearchQueryService legacyQueryService,
PortalSearchDocConverter converter,
IOptions<HwPortalSearchOptions> options,
ILogger<HwSearchService> logger)
{
_queryService = queryService;
_legacyQueryService = legacyQueryService;
_converter = converter;
_options = options.Value;
_logger = logger;
}
public Task<SearchPageDTO> Search(string keyword, int? pageNum, int? pageSize)
{
// false 代表展示端搜索,不需要返回编辑端路由。
return DoSearch(keyword, pageNum, pageSize, false);
}
public Task<SearchPageDTO> SearchForEdit(string keyword, int? pageNum, int? pageSize)
{
// true 代表编辑端搜索,需要额外产出 editRoute 给后台编辑器跳转。
return DoSearch(keyword, pageNum, pageSize, true);
}
private async Task<SearchPageDTO> DoSearch(string keyword, int? pageNum, int? pageSize, bool editMode)
{
// 先把输入参数统一规范化,避免后面的业务逻辑到处判空。
string normalizedKeyword = ValidateKeyword(keyword);
int normalizedPageNum = NormalizePageNum(pageNum);
int normalizedPageSize = NormalizePageSize(pageSize);
// 这里先拿“候选记录全集”,再在内存里做二次打分和分页。
// 这么做不是为了偷懒,而是为了严格贴住 Java 源实现:
// 源模块也是先查候选,再在服务层做过滤、高亮、打分、分页。
List<SearchRawRecord> rawRecords = await LoadSearchRecordsAsync(normalizedKeyword);
if (rawRecords.Count == 0)
{
return new SearchPageDTO();
}
// LINQ 链式处理:
// Select = 投影
// Where = 过滤
// OrderByDescending = 倒序排序
// ToList = 立即执行并转成 List
List<SearchResultDTO> 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))
{
// Whyweb / 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<string, object> BuildRouteQuery(SearchRawRecord raw)
{
if (!string.IsNullOrWhiteSpace(raw.RouteQueryJson))
{
Dictionary<string, object> parsed = ParseRouteQueryJson(raw.RouteQueryJson);
if (parsed.Count > 0)
{
// 索引链路如果已经带了 routeQueryJson优先相信索引层产出的结果。
// 这样可以避免搜索服务和索引服务各自维护一套路由规则,后续改动时更容易失配。
return parsed;
}
}
Dictionary<string, object> 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<List<SearchRawRecord>> LoadSearchRecordsAsync(string keyword)
{
if (!UseIndexedEngine())
{
// 明确走 legacy 模式时,直接退回旧 SQL。
// 这里不能再“顺手尝试一下索引表”,否则配置就失去可预测性了。
return await _legacyQueryService.SearchAsync(keyword);
}
try
{
// 主链路优先走索引表查询。
// 索引表的好处是结构统一、可扩展性更好,也更方便后续替换成真正 ES。
List<SearchRawRecord> 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)
{
// 这里返回带 <em> 标签的 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}", "<em class=\"search-hit\">$0</em>");
}
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<string, object> ParseRouteQueryJson(string routeQueryJson)
{
try
{
Dictionary<string, string> values = JsonSerializer.Deserialize<Dictionary<string, string>>(routeQueryJson) ?? new Dictionary<string, string>();
return values.ToDictionary(item => item.Key, item => (object)item.Value);
}
catch
{
// routeQueryJson 解析失败时直接吞掉并回退到动态拼装。
// 这是一个有意的容错策略:索引文档里偶发脏数据不应该把整条搜索结果打挂。
return new Dictionary<string, object>();
}
}
}