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#

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.

// ============================================================================
// 【文件说明】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();
}
}