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#

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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);
}
}