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