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 { ["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 { ["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 { ["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 routeQuery) { return JsonSerializer.Serialize(routeQuery ?? new Dictionary()); } 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()); } return SerializeRouteQuery(new Dictionary { ["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()); } // 这里同时写 id / configTypeId 两个键,是为了兼容前台旧逻辑。 // 旧前台某些页面读 id,某些页面读 configTypeId;只保留一个键会导致部分跳转失效。 return SerializeRouteQuery(new Dictionary { ["id"] = normalized, ["configTypeId"] = normalized }); } }