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.

189 lines
7.3 KiB
C#

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<HwPortalSearchOptions> options, IHwSearchSchemaService schemaService)
{
_db = db;
_options = options.Value;
_schemaService = schemaService;
}
public async Task<List<SearchRawRecord>> SearchAsync(string keyword, int take, CancellationToken cancellationToken = default)
{
await _schemaService.EnsureCreatedAsync(cancellationToken);
int normalizedTake = Math.Clamp(take, 1, _options.TakeLimit);
List<SearchRawRecord> fullTextResult = await SearchWithFullTextAsync(keyword, normalizedTake, cancellationToken);
if (fullTextResult.Count > 0)
{
return fullTextResult;
}
return await SearchWithLikeAsync(keyword, normalizedTake, cancellationToken);
}
public async Task<bool> HasIndexedDocumentAsync(CancellationToken cancellationToken = default)
{
await _schemaService.EnsureCreatedAsync(cancellationToken);
return await _db.SearchDocs.AsNoTracking().AnyAsync(x => x.IsDelete == "0", cancellationToken);
}
private async Task<List<SearchRawRecord>> 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<SearchRawRecord> 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<List<SearchRawRecord>> SearchWithLikeAsync(string keyword, int take, CancellationToken cancellationToken)
{
string likeKeyword = $"%{keyword}%";
// Why
// FULLTEXT 对中文和短词的命中并不稳定,因此这里保留 EF LINQ 版兜底,
// 即使全文索引未命中,也能保证门户搜索功能可用。
List<HwPortalSearchDoc> 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);
}
}