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#

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