using System.Data; using System.Data.Common; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Admin.NET.Plugin.HwPortal; public sealed class HwSearchQueryService : ITransient { private readonly HwPortalSearchDbContext _db; private readonly HwPortalSearchOptions _options; private readonly IHwSearchSchemaService _schemaService; public HwSearchQueryService(HwPortalSearchDbContext db, IOptions options, IHwSearchSchemaService schemaService) { _db = db; _options = options.Value; _schemaService = schemaService; } public async Task> SearchAsync(string keyword, int take, CancellationToken cancellationToken = default) { await _schemaService.EnsureCreatedAsync(cancellationToken); int normalizedTake = Math.Clamp(take, 1, _options.TakeLimit); List fullTextResult = await SearchWithFullTextAsync(keyword, normalizedTake, cancellationToken); if (fullTextResult.Count > 0) { return fullTextResult; } return await SearchWithLikeAsync(keyword, normalizedTake, cancellationToken); } public async Task HasIndexedDocumentAsync(CancellationToken cancellationToken = default) { await _schemaService.EnsureCreatedAsync(cancellationToken); return await _db.SearchDocs.AsNoTracking().AnyAsync(x => x.IsDelete == "0", cancellationToken); } private async Task> SearchWithFullTextAsync(string keyword, int take, CancellationToken cancellationToken) { DbConnection connection = _db.Database.GetDbConnection(); bool shouldClose = connection.State != ConnectionState.Open; if (shouldClose) { await connection.OpenAsync(cancellationToken); } try { await using DbCommand command = connection.CreateCommand(); command.CommandText = """ SELECT s.source_type AS SourceType, s.biz_id AS BizId, s.title AS Title, s.content AS Content, s.web_code AS WebCode, s.type_id AS TypeId, s.device_id AS DeviceId, s.menu_id AS MenuId, s.document_id AS DocumentId, s.base_score AS Score, s.updated_at AS UpdatedAt, s.route AS Route, s.route_query_json AS RouteQueryJson, s.edit_route AS EditRoute FROM hw_portal_search_doc s WHERE s.is_delete = '0' AND MATCH(s.title, s.content) AGAINST (@keyword IN NATURAL LANGUAGE MODE) ORDER BY MATCH(s.title, s.content) AGAINST (@keyword IN NATURAL LANGUAGE MODE) DESC, CASE WHEN s.title IS NOT NULL AND s.title LIKE CONCAT('%', @likeKeyword, '%') THEN 1 ELSE 0 END DESC, CASE WHEN s.content IS NOT NULL AND s.content LIKE CONCAT('%', @likeKeyword, '%') THEN 1 ELSE 0 END DESC, s.base_score DESC, s.updated_at DESC LIMIT @take; """; AddParameter(command, "@keyword", keyword); AddParameter(command, "@likeKeyword", keyword); AddParameter(command, "@take", take); List rows = new(); await using DbDataReader reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { rows.Add(new SearchRawRecord { SourceType = GetString(reader, "SourceType"), BizId = GetString(reader, "BizId"), Title = GetString(reader, "Title"), Content = GetString(reader, "Content"), WebCode = GetString(reader, "WebCode"), TypeId = GetString(reader, "TypeId"), DeviceId = GetString(reader, "DeviceId"), MenuId = GetString(reader, "MenuId"), DocumentId = GetString(reader, "DocumentId"), Score = GetNullableInt(reader, "Score"), UpdatedAt = GetNullableDateTime(reader, "UpdatedAt"), Route = GetString(reader, "Route"), RouteQueryJson = GetString(reader, "RouteQueryJson"), EditRoute = GetString(reader, "EditRoute") }); } return rows; } finally { if (shouldClose) { await connection.CloseAsync(); } } } private async Task> SearchWithLikeAsync(string keyword, int take, CancellationToken cancellationToken) { string likeKeyword = $"%{keyword}%"; // Why: // FULLTEXT 对中文和短词的命中并不稳定,因此这里保留 EF LINQ 版兜底, // 即使全文索引未命中,也能保证门户搜索功能可用。 List docs = await _db.SearchDocs .AsNoTracking() .Where(x => x.IsDelete == "0") .Where(x => (x.Title != null && EF.Functions.Like(x.Title, likeKeyword)) || (x.Content != null && EF.Functions.Like(x.Content, likeKeyword))) .OrderByDescending(x => x.Title != null && EF.Functions.Like(x.Title, likeKeyword)) .ThenByDescending(x => x.Content != null && EF.Functions.Like(x.Content, likeKeyword)) .ThenByDescending(x => x.BaseScore) .ThenByDescending(x => x.UpdatedAt) .Take(take) .ToListAsync(cancellationToken); return docs.Select(ToRawRecord).ToList(); } private static SearchRawRecord ToRawRecord(HwPortalSearchDoc doc) { return new SearchRawRecord { SourceType = doc.SourceType, BizId = doc.BizId, Title = doc.Title, Content = doc.Content, WebCode = doc.WebCode, TypeId = doc.TypeId, DeviceId = doc.DeviceId, MenuId = doc.MenuId, DocumentId = doc.DocumentId, Score = doc.BaseScore, UpdatedAt = doc.UpdatedAt, Route = doc.Route, RouteQueryJson = doc.RouteQueryJson, EditRoute = doc.EditRoute }; } private static void AddParameter(DbCommand command, string name, object value) { DbParameter parameter = command.CreateParameter(); parameter.ParameterName = name; parameter.Value = value ?? DBNull.Value; command.Parameters.Add(parameter); } private static string GetString(DbDataReader reader, string name) { object value = reader[name]; return value == DBNull.Value ? null : Convert.ToString(value); } private static int? GetNullableInt(DbDataReader reader, string name) { object value = reader[name]; return value == DBNull.Value ? null : Convert.ToInt32(value); } private static DateTime? GetNullableDateTime(DbDataReader reader, string name) { object value = reader[name]; return value == DBNull.Value ? null : Convert.ToDateTime(value); } }