|
|
// ============================================================================
|
|
|
// 【文件说明】PortalSearchDocConverter.cs - 搜索文档转换器
|
|
|
// ============================================================================
|
|
|
// 这个类负责把业务数据转换成搜索索引文档格式。
|
|
|
//
|
|
|
// 【核心功能】
|
|
|
// 1. 从 JSON 配置中提取可搜索文本
|
|
|
// 2. 过滤掉不需要搜索的字段(如 URL、图片路径)
|
|
|
// 3. 清理 HTML 标签和多余空白
|
|
|
//
|
|
|
// 【为什么需要转换器?】
|
|
|
// 业务数据存储格式和搜索需求不同:
|
|
|
// - 业务数据:结构化 JSON,包含配置、URL、图片等
|
|
|
// - 搜索需求:纯文本,便于全文检索
|
|
|
//
|
|
|
// 例如:
|
|
|
// - 业务数据:{ "title": "产品介绍", "icon": "/img/icon.png", "desc": "<p>产品描述</p>" }
|
|
|
// - 搜索文本:产品介绍 产品描述
|
|
|
//
|
|
|
// 【与 Java Spring Boot 的对比】
|
|
|
// Java 通常用工具类实现类似功能:
|
|
|
// public class SearchTextExtractor {
|
|
|
// public static String extract(String json) { ... }
|
|
|
// }
|
|
|
//
|
|
|
// C# 这里用实例类,便于依赖注入和测试。
|
|
|
// ============================================================================
|
|
|
|
|
|
namespace Admin.NET.Plugin.HwPortal;
|
|
|
|
|
|
/// <summary>
|
|
|
/// 搜索文档转换器。
|
|
|
/// <para>
|
|
|
/// 【服务职责】
|
|
|
/// 把业务实体转换成搜索索引文档:
|
|
|
/// 1. 提取 JSON 中的可搜索文本
|
|
|
/// 2. 过滤掉 URL、图片等非搜索字段
|
|
|
/// 3. 清理 HTML 标签,保留纯文本
|
|
|
/// </para>
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - sealed 密封类】
|
|
|
/// sealed 表示不能被继承。转换器类通常不需要扩展。
|
|
|
///
|
|
|
/// 注意:这个类没有实现 ITransient 等接口。
|
|
|
/// 这意味着它不会被自动注册到 DI 容器。
|
|
|
/// 使用时需要手动创建实例或手动注册。
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
public sealed class PortalSearchDocConverter
|
|
|
{
|
|
|
/// <summary>
|
|
|
/// 来源类型常量 - 菜单。
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - const 常量】
|
|
|
/// const 是编译期常量,值在编译时就确定了。
|
|
|
/// 这些常量用于标识搜索文档的来源类型。
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java: public static final String SOURCE_MENU = "menu";
|
|
|
/// C#: public const string SourceMenu = "menu";
|
|
|
///
|
|
|
/// C# 的 const 隐式是 static 的,不能加 static 修饰符。
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
public const string SourceMenu = "menu";
|
|
|
|
|
|
/// <summary>
|
|
|
/// 来源类型常量 - 页面。
|
|
|
/// </summary>
|
|
|
public const string SourceWeb = "web";
|
|
|
|
|
|
/// <summary>
|
|
|
/// 来源类型常量 - 页面1。
|
|
|
/// </summary>
|
|
|
public const string SourceWeb1 = "web1";
|
|
|
|
|
|
/// <summary>
|
|
|
/// 来源类型常量 - 文档。
|
|
|
/// </summary>
|
|
|
public const string SourceDocument = "document";
|
|
|
|
|
|
/// <summary>
|
|
|
/// 来源类型常量 - 配置类型。
|
|
|
/// </summary>
|
|
|
public const string SourceConfigType = "configType";
|
|
|
|
|
|
/// <summary>
|
|
|
/// 需要跳过的 JSON 字段名集合。
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - HashSet<T> 哈希集合】
|
|
|
/// HashSet<T> 是不包含重复元素的集合:
|
|
|
/// - 查找速度快(O(1))
|
|
|
/// - 自动去重
|
|
|
/// - 适合存储需要快速判断是否存在的元素
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java: private static final Set<String> SKIP_KEYS = new HashSet<>(Arrays.asList(...));
|
|
|
/// C#: private static readonly HashSet<string> SkipJsonKeys = new(...) { ... };
|
|
|
///
|
|
|
/// 【StringComparer.OrdinalIgnoreCase 参数】
|
|
|
/// 创建集合时传入比较器,实现忽略大小写的比较:
|
|
|
/// - "Icon" 和 "icon" 被视为相同
|
|
|
/// - 查找时自动忽略大小写
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java 需要用 TreeSet 或自定义 HashSet:
|
|
|
/// new TreeSet<>(String.CASE_INSENSITIVE_ORDER)
|
|
|
/// </para>
|
|
|
/// <para>
|
|
|
/// 【为什么跳过这些字段?】
|
|
|
/// 这些字段不适合全文搜索:
|
|
|
/// - icon, img, banner:图片路径
|
|
|
/// - url, route:URL 路径
|
|
|
/// - uuid, id:技术标识符
|
|
|
/// - secretkey:敏感信息
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
private static readonly HashSet<string> SkipJsonKeys = new(StringComparer.OrdinalIgnoreCase)
|
|
|
{
|
|
|
"icon", "url", "banner", "banner1", "img", "imglist", "type", "uuid", "filename",
|
|
|
"documentaddress", "secretkey", "route", "routequery", "webcode", "deviceid", "typeid",
|
|
|
"configtypeid", "id"
|
|
|
};
|
|
|
|
|
|
/// <summary>
|
|
|
/// 从文本中提取可搜索文本。
|
|
|
/// <para>
|
|
|
/// 【处理流程】
|
|
|
/// 1. 如果文本为空,返回空字符串
|
|
|
/// 2. 先尝试作为 JSON 解析
|
|
|
/// 3. 如果 JSON 解析成功,提取所有文本节点
|
|
|
/// 4. 如果 JSON 解析失败,清理 HTML 标签后返回
|
|
|
/// </para>
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - string.IsNullOrWhiteSpace】
|
|
|
/// string.IsNullOrWhiteSpace(text) 检查字符串是否:
|
|
|
/// - null
|
|
|
/// - 空字符串 ""
|
|
|
/// - 只包含空白字符(空格、制表符、换行等)
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java: StringUtils.isBlank(text)(Apache Commons)
|
|
|
/// 或: text == null || text.trim().isEmpty()
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
/// <param name="text">输入文本(可能是 JSON 或 HTML)</param>
|
|
|
/// <returns>提取的可搜索文本</returns>
|
|
|
public string ExtractSearchableText(string text)
|
|
|
{
|
|
|
// 【空值检查】
|
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
|
{
|
|
|
return string.Empty;
|
|
|
}
|
|
|
|
|
|
// 【HTML 标签清理】
|
|
|
// 先清理 HTML,作为备用结果。
|
|
|
string stripped = StripHtml(text);
|
|
|
|
|
|
try
|
|
|
{
|
|
|
// 【JSON 解析】
|
|
|
// JsonDocument 是 System.Text.Json 的高性能 JSON 解析器。
|
|
|
// using 语句确保资源正确释放。
|
|
|
using JsonDocument document = JsonDocument.Parse(text);
|
|
|
|
|
|
// 【StringBuilder 文本拼接】
|
|
|
// StringBuilder 用于高效拼接大量字符串。
|
|
|
// 对比:string + string 每次都创建新对象,性能差。
|
|
|
StringBuilder builder = new();
|
|
|
|
|
|
// 【递归提取文本节点】
|
|
|
CollectNodeText(document.RootElement, null, builder);
|
|
|
|
|
|
// 【规范化空白】
|
|
|
string extracted = NormalizeWhitespace(builder.ToString());
|
|
|
|
|
|
// 【返回结果】
|
|
|
// 如果提取的文本为空,返回清理后的 HTML 文本。
|
|
|
return string.IsNullOrWhiteSpace(extracted) ? stripped : extracted;
|
|
|
}
|
|
|
catch
|
|
|
{
|
|
|
// 【JSON 解析失败】
|
|
|
// 如果不是有效的 JSON,返回清理后的 HTML 文本。
|
|
|
return stripped;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
/// 递归收集 JSON 节点的文本内容。
|
|
|
/// <para>
|
|
|
/// 【递归遍历 JSON】
|
|
|
/// JSON 可能是嵌套结构,需要递归遍历:
|
|
|
/// - Object:遍历每个属性
|
|
|
/// - Array:遍历每个元素
|
|
|
/// - String:提取文本值
|
|
|
/// - Number/Boolean/Null:跳过(不适合搜索)
|
|
|
/// </para>
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - switch 表达式】
|
|
|
/// switch (element.ValueKind) 根据 JSON 值类型分支处理:
|
|
|
/// - JsonValueKind.Object:JSON 对象
|
|
|
/// - JsonValueKind.Array:JSON 数组
|
|
|
/// - JsonValueKind.String:字符串
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java 需要用 if-else 或 switch 语句:
|
|
|
/// switch (element.getValueKind()) {
|
|
|
/// case OBJECT: ...
|
|
|
/// case ARRAY: ...
|
|
|
/// }
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
/// <param name="element">JSON 元素</param>
|
|
|
/// <param name="fieldName">字段名(用于判断是否跳过)</param>
|
|
|
/// <param name="output">输出 StringBuilder</param>
|
|
|
private void CollectNodeText(JsonElement element, string fieldName, StringBuilder output)
|
|
|
{
|
|
|
switch (element.ValueKind)
|
|
|
{
|
|
|
case JsonValueKind.Object:
|
|
|
// 【遍历对象属性】
|
|
|
// EnumerateObject() 返回对象的所有属性。
|
|
|
foreach (JsonProperty property in element.EnumerateObject())
|
|
|
{
|
|
|
// 【过滤跳过字段】
|
|
|
// 如果字段名在跳过列表中,不处理。
|
|
|
if (!ShouldSkip(property.Name))
|
|
|
{
|
|
|
CollectNodeText(property.Value, property.Name, output);
|
|
|
}
|
|
|
}
|
|
|
break;
|
|
|
|
|
|
case JsonValueKind.Array:
|
|
|
// 【遍历数组元素】
|
|
|
// EnumerateArray() 返回数组的所有元素。
|
|
|
foreach (JsonElement child in element.EnumerateArray())
|
|
|
{
|
|
|
CollectNodeText(child, fieldName, output);
|
|
|
}
|
|
|
break;
|
|
|
|
|
|
case JsonValueKind.String:
|
|
|
// 【提取字符串值】
|
|
|
// 如果字段需要跳过,直接返回。
|
|
|
if (ShouldSkip(fieldName))
|
|
|
{
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 【获取字符串值】
|
|
|
// element.GetString() 获取 JSON 字符串的值。
|
|
|
string value = NormalizeWhitespace(StripHtml(element.GetString()));
|
|
|
|
|
|
// 【过滤 URL 和空值】
|
|
|
// URL 不适合搜索,跳过。
|
|
|
if (string.IsNullOrWhiteSpace(value) || value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
|
{
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 【追加到输出】
|
|
|
// StringBuilder.Append() 追加文本。
|
|
|
// .Append(' ') 追加空格分隔。
|
|
|
output.Append(value).Append(' ');
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
/// 判断字段是否应该跳过。
|
|
|
/// <para>
|
|
|
/// 【跳过规则】
|
|
|
/// 1. 字段名在 SkipJsonKeys 集合中
|
|
|
/// 2. 字段名以 "url" 结尾(如 imageUrl, videoUrl)
|
|
|
/// 3. 字段名以 "icon" 结尾(如 menuIcon)
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
/// <param name="fieldName">字段名</param>
|
|
|
/// <returns>是否跳过</returns>
|
|
|
private static bool ShouldSkip(string fieldName)
|
|
|
{
|
|
|
// 【空字段名检查】
|
|
|
if (string.IsNullOrWhiteSpace(fieldName))
|
|
|
{
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
// 【多条件判断】
|
|
|
// Contains 检查是否在跳过集合中。
|
|
|
// EndsWith 检查是否以特定后缀结尾。
|
|
|
return SkipJsonKeys.Contains(fieldName)
|
|
|
|| fieldName.EndsWith("url", StringComparison.OrdinalIgnoreCase)
|
|
|
|| fieldName.EndsWith("icon", StringComparison.OrdinalIgnoreCase);
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
/// 清理 HTML 标签。
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - 正则表达式】
|
|
|
/// Regex.Replace(input, pattern, replacement)
|
|
|
/// - input:输入字符串
|
|
|
/// - pattern:正则表达式模式
|
|
|
/// - replacement:替换字符串
|
|
|
///
|
|
|
/// "<[^>]+>" 匹配所有 HTML 标签:
|
|
|
/// - <:匹配 <
|
|
|
/// - [^>]+:匹配一个或多个非 > 的字符
|
|
|
/// - >:匹配 >
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java: text.replaceAll("<[^>]+>", " ")
|
|
|
///
|
|
|
/// C# 的 Regex.Replace 性能更好,支持编译正则。
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
/// <param name="text">输入文本</param>
|
|
|
/// <returns>清理后的文本</returns>
|
|
|
private static string StripHtml(string text)
|
|
|
{
|
|
|
// 用空格替换 HTML 标签,避免标签内容粘连。
|
|
|
return NormalizeWhitespace(Regex.Replace(text ?? string.Empty, "<[^>]+>", " "));
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
/// 规范化空白字符。
|
|
|
/// <para>
|
|
|
/// 【处理说明】
|
|
|
/// 把连续的空白字符(空格、制表符、换行等)替换为单个空格:
|
|
|
/// - "a b\n\tc" → "a b c"
|
|
|
/// - 去除首尾空白
|
|
|
/// </para>
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - 正则表达式 @"\s+"】
|
|
|
/// @ 符号表示"逐字字符串"(Verbatim String):
|
|
|
/// - 不需要转义反斜杠
|
|
|
/// - @"\s+" 等价于 "\\s+"
|
|
|
///
|
|
|
/// \s+ 匹配一个或多个空白字符。
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
/// <param name="text">输入文本</param>
|
|
|
/// <returns>规范化后的文本</returns>
|
|
|
private static string NormalizeWhitespace(string text)
|
|
|
{
|
|
|
// 把连续空白替换为单个空格,并去除首尾空白。
|
|
|
return Regex.Replace(text ?? string.Empty, @"\s+", " ").Trim();
|
|
|
}
|
|
|
}
|