|
|
|
|
|
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))
|
|
|
|
|
|
{
|
|
|
|
|
|
// 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<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>();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|