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.

352 lines
13 KiB
C#

// ============================================================================
// 【文件说明】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&lt;T&gt; 哈希集合】
/// HashSet&lt;T&gt; 是不包含重复元素的集合:
/// - 查找速度快O(1)
/// - 自动去重
/// - 适合存储需要快速判断是否存在的元素
///
/// 对比 Java
/// Java: private static final Set&lt;String&gt; SKIP_KEYS = new HashSet&lt;&gt;(Arrays.asList(...));
/// C#: private static readonly HashSet&lt;string&gt; SkipJsonKeys = new(...) { ... };
///
/// 【StringComparer.OrdinalIgnoreCase 参数】
/// 创建集合时传入比较器,实现忽略大小写的比较:
/// - "Icon" 和 "icon" 被视为相同
/// - 查找时自动忽略大小写
///
/// 对比 Java
/// Java 需要用 TreeSet 或自定义 HashSet
/// new TreeSet&lt;&gt;(String.CASE_INSENSITIVE_ORDER)
/// </para>
/// <para>
/// 【为什么跳过这些字段?】
/// 这些字段不适合全文搜索:
/// - icon, img, banner图片路径
/// - url, routeURL 路径
/// - 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.ObjectJSON 对象
/// - JsonValueKind.ArrayJSON 数组
/// - 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替换字符串
///
/// "&lt;[^&gt;]+&gt;" 匹配所有 HTML 标签:
/// - &lt;:匹配 &lt;
/// - [^&gt;]+:匹配一个或多个非 &gt; 的字符
/// - &gt;:匹配 &gt;
///
/// 对比 Java
/// Java: text.replaceAll("&lt;[^&gt;]+&gt;", " ")
///
/// 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();
}
}