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