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.

347 lines
15 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
namespace Admin.NET.Plugin.HwPortal;
public sealed class HwSearchIndexService : IHwSearchIndexService, ITransient
{
// 这里保留 DbContext是因为当前搜索索引表走的是独立读模型方案。
// 业务表继续由 SqlSugar + MyBatisExecutor 负责,搜索索引表则由 EF 负责统一 upsert。
private readonly HwPortalSearchDbContext _db;
private readonly PortalSearchDocConverter _converter;
private readonly IHwSearchSchemaService _schemaService;
private readonly PortalSearchRouteResolver _routeResolver;
public HwSearchIndexService(HwPortalSearchDbContext db, PortalSearchDocConverter converter, IHwSearchSchemaService schemaService, PortalSearchRouteResolver routeResolver)
{
_db = db;
_converter = converter;
_schemaService = schemaService;
_routeResolver = routeResolver;
}
public async Task UpsertMenuAsync(HwWebMenu menu, CancellationToken cancellationToken = default)
{
if (menu?.WebMenuId == null)
{
return;
}
// 搜索索引表是读模型,不参与主业务事务。
// 这里先确保表结构,再写入索引,避免第一次触发搜索重建时因为表不存在直接中断主链路。
await _schemaService.EnsureCreatedAsync(cancellationToken);
await UpsertAsync(new HwPortalSearchDoc
{
DocId = BuildMenuDocId(menu.WebMenuId.Value),
SourceType = PortalSearchDocConverter.SourceMenu,
BizId = menu.WebMenuId.Value.ToString(),
// 菜单索引标题只保留中文主标题。
// 当前 Java 源实现并不会把英文名塞进 title因此这里不能为了“看起来更丰富”擅自扩字段口径。
Title = menu.WebMenuName,
Content = NormalizeSearchText(menu.WebMenuName, menu.Ancestors),
MenuId = menu.WebMenuId.Value.ToString(),
// route / routeQueryJson 是搜索结果最终给前端跳转用的协议字段。
// 把它们提前固化进索引文档,可以让搜索服务层只关注打分和摘要,不再关心复杂跳转规则。
Route = "/test",
RouteQueryJson = SerializeRouteQuery(new Dictionary<string, string> { ["id"] = menu.WebMenuId.Value.ToString() }),
UpdatedAt = menu.UpdateTime ?? menu.CreateTime ?? HwPortalContextHelper.Now()
}, cancellationToken);
}
public async Task UpsertWebAsync(HwWeb web, CancellationToken cancellationToken = default)
{
if (web?.WebCode == null)
{
return;
}
await _schemaService.EnsureCreatedAsync(cancellationToken);
string webCode = web.WebCode.Value.ToString();
await UpsertAsync(new HwPortalSearchDoc
{
DocId = BuildWebDocId(web.WebCode.Value),
SourceType = PortalSearchDocConverter.SourceWeb,
BizId = web.WebId?.ToString(),
Title = $"页面#{webCode}",
// web/web1 搜索正文必须先走 JSON 文本抽取。
// 如果直接存整段 JSON后续 mysql like / 全文索引都会把样式字段、URL、图标键一起纳入命中搜索会非常脏。
Content = _converter.ExtractSearchableText(web.WebJsonString),
WebCode = webCode,
// 这里不单独存 BaseScore是因为当前服务层最后仍会根据来源类型和标题/正文命中再次打分。
// 这能保证索引链路与 legacy SQL 链路最终排序尽量保持一致。
Route = BuildWebRoute(webCode),
RouteQueryJson = BuildWebRouteQueryJson(webCode),
UpdatedAt = web.UpdateTime ?? web.CreateTime ?? HwPortalContextHelper.Now()
}, cancellationToken);
}
public async Task UpsertWeb1Async(HwWeb1 web1, CancellationToken cancellationToken = default)
{
if (web1?.WebCode == null || web1.TypeId == null || web1.DeviceId == null)
{
return;
}
await _schemaService.EnsureCreatedAsync(cancellationToken);
string webCode = web1.WebCode.Value.ToString();
string typeId = web1.TypeId.Value.ToString();
string deviceId = web1.DeviceId.Value.ToString();
await UpsertAsync(new HwPortalSearchDoc
{
DocId = BuildWeb1DocId(web1.WebCode.Value, web1.TypeId.Value, web1.DeviceId.Value),
SourceType = PortalSearchDocConverter.SourceWeb1,
BizId = web1.WebId?.ToString(),
Title = $"详情#{webCode}-{typeId}-{deviceId}",
Content = _converter.ExtractSearchableText(web1.WebJsonString),
WebCode = webCode,
TypeId = typeId,
DeviceId = deviceId,
// web1 是详情页模型,所以 routeQuery 里必须同时保留 webCode/typeId/deviceId 三元组。
// 少任何一个维度,前端都无法唯一定位到正确详情页。
Route = "/productCenter/detail",
RouteQueryJson = SerializeRouteQuery(new Dictionary<string, string>
{
["webCode"] = webCode,
["typeId"] = typeId,
["deviceId"] = deviceId
}),
UpdatedAt = web1.UpdateTime ?? web1.CreateTime ?? HwPortalContextHelper.Now()
}, cancellationToken);
}
public async Task UpsertDocumentAsync(HwWebDocument document, CancellationToken cancellationToken = default)
{
if (document == null || string.IsNullOrWhiteSpace(document.DocumentId))
{
return;
}
await _schemaService.EnsureCreatedAsync(cancellationToken);
string title = string.IsNullOrWhiteSpace(document.Json) ? document.DocumentId : document.Json;
await UpsertAsync(new HwPortalSearchDoc
{
DocId = BuildDocumentDocId(document.DocumentId),
SourceType = PortalSearchDocConverter.SourceDocument,
BizId = document.DocumentId,
// 文档这里刻意保留原 JSON/原文内容,而不是做 JSON 抽取。
// 原因是最新源模块的旧 SQL 搜索就是直接对 json 字段做 like迁移时要保持这个命中口径。
Title = title,
Content = document.Json ?? string.Empty,
WebCode = document.WebCode,
TypeId = document.Type,
DocumentId = document.DocumentId,
// 文档搜索前台统一落到 serviceSupport 页面,再由 documentId 做二次定位。
// 这就是为什么这里 route 固定、但 routeQueryJson 仍然要保留 documentId。
Route = "/serviceSupport",
RouteQueryJson = SerializeRouteQuery(new Dictionary<string, string> { ["documentId"] = document.DocumentId }),
UpdatedAt = document.UpdateTime ?? document.CreateTime ?? HwPortalContextHelper.Now()
}, cancellationToken);
}
public async Task UpsertConfigTypeAsync(HwPortalConfigType configType, CancellationToken cancellationToken = default)
{
if (configType?.ConfigTypeId == null)
{
return;
}
await _schemaService.EnsureCreatedAsync(cancellationToken);
string configTypeId = configType.ConfigTypeId.Value.ToString();
string routeWebCode = await _routeResolver.ResolveConfigTypeWebCode(configType);
await UpsertAsync(new HwPortalSearchDoc
{
DocId = BuildConfigTypeDocId(configType.ConfigTypeId.Value),
SourceType = PortalSearchDocConverter.SourceConfigType,
BizId = configTypeId,
Title = configType.ConfigTypeName,
Content = NormalizeSearchText(configType.ConfigTypeName, configType.HomeConfigTypeName, configType.ConfigTypeDesc),
// 这里同时保留 TypeId 和解析后的 WebCode
// TypeId 用于保底还原业务来源,
// WebCode 用于前端真正跳转。
// 两者不能互相覆盖,否则后续查问题时会丢失来源语义。
WebCode = routeWebCode,
TypeId = configTypeId,
Route = "/productCenter",
// 编辑端入口这里固定写 productCenter/edit是为了保持和源模块“配置分类统一从产品中心编辑页维护”的口径一致。
RouteQueryJson = BuildConfigTypeRouteQueryJson(routeWebCode, configTypeId),
EditRoute = "/productCenter/edit",
UpdatedAt = configType.UpdateTime ?? configType.CreateTime ?? HwPortalContextHelper.Now()
}, cancellationToken);
}
public Task DeleteMenuAsync(long menuId, CancellationToken cancellationToken = default)
{
return DeleteByDocIdAsync(BuildMenuDocId(menuId), cancellationToken);
}
public Task DeleteWebAsync(long webCode, CancellationToken cancellationToken = default)
{
return DeleteByDocIdAsync(BuildWebDocId(webCode), cancellationToken);
}
public Task DeleteWeb1Async(long webCode, long typeId, long deviceId, CancellationToken cancellationToken = default)
{
return DeleteByDocIdAsync(BuildWeb1DocId(webCode, typeId, deviceId), cancellationToken);
}
public Task DeleteDocumentAsync(string documentId, CancellationToken cancellationToken = default)
{
return DeleteByDocIdAsync(BuildDocumentDocId(documentId), cancellationToken);
}
public Task DeleteConfigTypeAsync(long configTypeId, CancellationToken cancellationToken = default)
{
return DeleteByDocIdAsync(BuildConfigTypeDocId(configTypeId), cancellationToken);
}
public async Task DeleteByDocIdAsync(string docId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(docId))
{
return;
}
await _schemaService.EnsureCreatedAsync(cancellationToken);
HwPortalSearchDoc existing = await _db.SearchDocs.SingleOrDefaultAsync(x => x.DocId == docId, cancellationToken);
if (existing == null)
{
return;
}
// Why
// 删除业务数据时保留索引行而不是物理删掉,后续同 docId 再创建时可以直接复用同一条索引文档。
existing.IsDelete = "1";
existing.ModifiedAt = HwPortalContextHelper.Now();
await _db.SaveChangesAsync(cancellationToken);
}
private async Task UpsertAsync(HwPortalSearchDoc input, CancellationToken cancellationToken)
{
// SingleOrDefaultAsync 对应 Java 里“按唯一键查一条,查不到返回 null”。
// 这里按 DocId 查,而不是按数据库自增 Id 查,是因为 DocId 才是业务稳定键。
HwPortalSearchDoc existing = await _db.SearchDocs.SingleOrDefaultAsync(x => x.DocId == input.DocId, cancellationToken);
DateTime now = HwPortalContextHelper.Now();
if (existing == null)
{
// 新文档直接创建一条索引记录。
// 这里强制落 IsDelete=0是为了兼容之前可能被逻辑删过、但现在又重新出现的业务对象。
input.IsDelete = "0";
input.CreatedAt = now;
input.ModifiedAt = now;
input.UpdatedAt ??= now;
_db.SearchDocs.Add(input);
}
else
{
// 已存在时做覆盖更新,而不是新插一条。
// 原因是 doc_id 被设计成搜索文档自然键,后续 rebuild / 静默修复都依赖它具备幂等性。
// 下面这一组字段赋值可以理解成“把最新业务快照完整覆盖回索引表”。
// 这里不做脏字段比较,是因为索引表重建更看重稳定和简单,而不是极致写入性能。
existing.SourceType = input.SourceType;
existing.BizId = input.BizId;
existing.Title = input.Title;
existing.Content = input.Content;
existing.WebCode = input.WebCode;
existing.TypeId = input.TypeId;
existing.DeviceId = input.DeviceId;
existing.MenuId = input.MenuId;
existing.DocumentId = input.DocumentId;
existing.BaseScore = input.BaseScore;
existing.Route = input.Route;
existing.RouteQueryJson = input.RouteQueryJson;
existing.EditRoute = input.EditRoute;
existing.IsDelete = "0";
existing.UpdatedAt = input.UpdatedAt ?? existing.UpdatedAt ?? now;
existing.ModifiedAt = now;
}
// SaveChangesAsync 就是把当前 DbContext 跟踪到的变更真正落库。
// 和 Java JPA 类似,在调用它之前,对象只是“内存态已修改”,还没有写进数据库。
await _db.SaveChangesAsync(cancellationToken);
}
private static string NormalizeSearchText(params string[] values)
{
// 统一把多段文本拼接成一段可搜索正文,并压缩连续空白。
// 这样能减少不同来源文本格式差异对全文索引和摘要截取的影响。
string combined = string.Join(' ', values.Where(item => !string.IsNullOrWhiteSpace(item)));
return Regex.Replace(combined, @"\s+", " ").Trim();
}
private static string SerializeRouteQuery(Dictionary<string, string> routeQuery)
{
return JsonSerializer.Serialize(routeQuery ?? new Dictionary<string, string>());
}
private static string BuildMenuDocId(long menuId) => $"menu:{menuId}";
private static string BuildWebDocId(long webCode) => $"web:{webCode}";
private static string BuildWeb1DocId(long webCode, long typeId, long deviceId) => $"web1:{webCode}:{typeId}:{deviceId}";
private static string BuildDocumentDocId(string documentId) => $"doc:{documentId}";
private static string BuildConfigTypeDocId(long configTypeId) => $"configType:{configTypeId}";
private static string BuildWebRoute(string webCode)
{
if (string.Equals(webCode, "-1", StringComparison.Ordinal))
{
// -1 在门户里约定表示首页。
return "/index";
}
if (string.Equals(webCode, "7", StringComparison.Ordinal))
{
// 7 在门户里约定表示产品中心。
return "/productCenter";
}
// 其余页面目前统一走 /test再由 query 参数决定真正内容。
// 这属于历史前端路由设计,后端不能自作主张改掉。
return "/test";
}
private static string BuildWebRouteQueryJson(string webCode)
{
if (string.Equals(webCode, "-1", StringComparison.Ordinal) || string.Equals(webCode, "7", StringComparison.Ordinal))
{
return SerializeRouteQuery(new Dictionary<string, string>());
}
return SerializeRouteQuery(new Dictionary<string, string> { ["id"] = webCode });
}
private static string BuildConfigTypeRouteQueryJson(string routeWebCode, string configTypeId)
{
string normalized = !string.IsNullOrWhiteSpace(routeWebCode) ? routeWebCode : configTypeId;
if (string.IsNullOrWhiteSpace(normalized))
{
return SerializeRouteQuery(new Dictionary<string, string>());
}
// 这里同时写 id / configTypeId 两个键,是为了兼容前台旧逻辑。
// 旧前台某些页面读 id某些页面读 configTypeId只保留一个键会导致部分跳转失效。
return SerializeRouteQuery(new Dictionary<string, string>
{
["id"] = normalized,
["configTypeId"] = normalized
});
}
}