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.

399 lines
16 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.

// ============================================================================
// 【文件说明】PortalSearchRouteResolver.cs - 门户搜索路由解析器
// ============================================================================
// 这个服务负责解析搜索结果的前端跳转路由。
//
// 【业务背景】
// 用户搜索后点击结果,需要跳转到对应的页面。但问题是:
// - 搜索索引存储的是"配置分类"信息
// - 前端需要的是"页面编码"web_code
// - 两者不是直接对应关系
//
// 例如:
// - 搜索命中:配置分类"产品案例"config_type_id = 3
// - 前端跳转:页面编码"2"(对应"产品"栏目)
//
// 【解析逻辑】
// 1. 根据配置分类的名称,在菜单树中查找匹配的菜单
// 2. 优先匹配指定栏目下的菜单(避免同名菜单跳错)
// 3. 找到菜单后,返回菜单 ID 作为页面编码
//
// 【与 Java 若依的对比】
// Java 若依通常在搜索 SQL 中直接返回路由:
// SELECT *, 'product' as route FROM hw_product WHERE ...
//
// C# 这里用解析器动态计算,更灵活但更复杂。
// ============================================================================
namespace Admin.NET.Plugin.HwPortal;
/// <summary>
/// 门户搜索路由解析器。
/// <para>
/// 【业务说明】
/// 配置分类命中后,前端真正需要的是页面 web_code而不是分类主键本身。
/// 这个服务负责把"配置分类"解析成"页面编码"。
/// </para>
/// <para>
/// 【C# 语法知识点 - sealed 密封类 + ITransient】
/// sealed + ITransient 表示:
/// - 这是一个瞬态服务(每次请求创建新实例)
/// - 不能被继承(不需要扩展)
/// </para>
/// </summary>
public sealed class PortalSearchRouteResolver : ITransient
{
/// <summary>
/// 配置分类到根菜单的映射表。
/// <para>
/// 【业务约定】
/// 这张映射表是"配置分类 → 门户一级根菜单"的业务约定:
/// - "3" → 2L配置分类 3 对应菜单 2产品栏目
/// - "4" → 7L配置分类 4 对应菜单 7解决方案栏目
/// - "5" → 4L配置分类 5 对应菜单 4服务栏目
/// - "6" → 24L配置分类 6 对应菜单 24案例栏目
///
/// 它不是技术规则,而是前端栏目结构和搜索跳转口径绑定出来的业务知识。
/// 后续如果官网栏目调整,这里必须跟着业务一起改,不能只改前端路由。
/// </para>
/// <para>
/// 【C# 语法知识点 - Dictionary&lt;K,V&gt; 字典】
/// Dictionary&lt;string, long&gt; 是键值对集合:
/// - 键string配置分类 ID
/// - 值long菜单 ID
///
/// StringComparer.Ordinal 参数:
/// - 使用序数比较(按字符编码比较)
/// - 区分大小写
/// - 性能最好
///
/// 对比 Java
/// Java: Map&lt;String, Long&gt; map = new HashMap&lt;&gt;();
/// Java 默认使用 equals 比较C# 可以指定比较器。
/// </para>
/// <para>
/// 【C# 语法知识点 - 字典初始化器】
/// new Dictionary&lt;...&gt; { ["3"] = 2L, ["4"] = 7L }
/// 这是集合初始化器语法,等价于:
/// var map = new Dictionary&lt;...&gt;();
/// map["3"] = 2L;
/// map["4"] = 7L;
///
/// 2L 中的 L 后缀表示 long 类型字面量。
/// </para>
/// </summary>
private static readonly Dictionary<string, long> ConfigTypeRootMenuMap = new(StringComparer.Ordinal)
{
["3"] = 2L,
["4"] = 7L,
["5"] = 4L,
["6"] = 24L
};
/// <summary>
/// 菜单服务(依赖注入)。
/// </summary>
private readonly HwWebMenuService _webMenuService;
/// <summary>
/// 构造函数(依赖注入)。
/// </summary>
/// <param name="webMenuService">菜单服务</param>
public PortalSearchRouteResolver(HwWebMenuService webMenuService)
{
_webMenuService = webMenuService;
}
/// <summary>
/// 解析配置分类的页面编码。
/// <para>
/// 【解析流程】
/// 1. 获取当前有效菜单树
/// 2. 构建候选名称列表(分类名称、首页名称)
/// 3. 在菜单树中查找匹配的菜单
/// 4. 返回菜单 ID 作为页面编码
/// </para>
/// </summary>
/// <param name="configType">配置分类实体</param>
/// <returns>页面编码(菜单 ID</returns>
public async Task<string> ResolveConfigTypeWebCode(HwPortalConfigType configType)
{
if (configType == null)
{
return null;
}
// 配置分类路由解析必须依赖当前有效菜单树,而不能只看 config_type 自身主键。
// 因为前端实际拿来跳转的是页面/菜单入口编码,不是分类表主键。
List<HwWebMenu> menus = await _webMenuService.SelectHwWebMenuList(new HwWebMenu());
// 这里把"中文标题 + 首页标题 + 分类归属"一起带进解析流程。
// 目的是尽量复刻原 Java 里"按名称猜入口页面"的做法,避免迁移后搜索跳转口径变掉。
return ResolveConfigTypeWebCode(BuildCandidateNames(configType.ConfigTypeName, configType.HomeConfigTypeName), configType.ConfigTypeClassfication, menus);
}
/// <summary>
/// 根据标题解析页面编码(重载方法)。
/// </summary>
/// <param name="title">标题</param>
/// <returns>页面编码</returns>
public async Task<string> ResolveConfigTypeWebCode(string title)
{
List<HwWebMenu> menus = await _webMenuService.SelectHwWebMenuList(new HwWebMenu());
return ResolveConfigTypeWebCode(BuildCandidateNames(title), null, menus);
}
/// <summary>
/// 核心解析方法:根据候选名称和分类归属查找匹配菜单。
/// <para>
/// 【匹配策略】
/// 第一轮:名称匹配 + 根菜单约束(优先)
/// - 避免不同栏目下同名菜单跳错
/// - 优先选择直接子节点
///
/// 第二轮:纯名称匹配(降级)
/// - 兼容历史脏数据或未维护 ancestors 的菜单
/// - 宁可命中一个近似入口,也不要直接返回空
/// </para>
/// <para>
/// 【C# 语法知识点 - LINQ 查询链】
/// menus.Where(...).Where(...).OrderBy(...).ThenBy(...).FirstOrDefault()
///
/// 这是 LINQ 的链式调用:
/// - Where过滤
/// - OrderBy排序升序
/// - ThenBy次级排序
/// - FirstOrDefault取第一个没有则返回默认值
///
/// 对比 Java Stream
/// Java: menus.stream().filter(...).filter(...).sorted(...).findFirst().orElse(null)
/// </para>
/// </summary>
/// <param name="candidateNames">候选名称列表</param>
/// <param name="configTypeClassfication">分类归属</param>
/// <param name="menus">菜单列表</param>
/// <returns>页面编码</returns>
private static string ResolveConfigTypeWebCode(List<string> candidateNames, string configTypeClassfication, List<HwWebMenu> menus)
{
if (candidateNames.Count == 0 || menus == null || menus.Count == 0)
{
return null;
}
// 【安全取字典值】
// TryGetValue 是 C# 常见的"安全取字典值"写法。
// 它不会像直接下标访问那样在 key 不存在时抛异常,更适合处理这种可空的业务映射。
//
// 对比 Java
// Java: Long rootId = map.get(key); // 返回 null 如果不存在
// C#: bool found = map.TryGetValue(key, out long value);
long? expectedRootMenuId = ConfigTypeRootMenuMap.TryGetValue(configTypeClassfication ?? string.Empty, out long rootMenuId) ? rootMenuId : null;
// 【第一轮匹配 - 带根菜单约束】
// 这是为了避免不同一级栏目下出现同名菜单时,搜索命中后跳错页面。
HwWebMenu matchedMenu = menus
.Where(menu => candidateNames.Contains(Normalize(menu.WebMenuName)))
.Where(menu => MatchesRootMenu(menu, expectedRootMenuId))
.OrderBy(menu => !IsDirectChild(menu, expectedRootMenuId)) // 优先直接子节点
.ThenBy(menu => menu.WebMenuId)
.FirstOrDefault();
// 【第二轮匹配 - 纯名称匹配】
// ??= 是 C# 8 的"空合并赋值"运算符:
// - 如果 matchedMenu 不为 null不执行赋值
// - 如果 matchedMenu 为 null执行赋值
//
// 这样做是为了兼容历史脏数据或未维护 ancestors 的菜单记录,
// 宁可命中一个近似入口,也不要直接返回空。
matchedMenu ??= menus
.Where(menu => candidateNames.Contains(Normalize(menu.WebMenuName)))
.OrderBy(menu => menu.WebMenuId)
.FirstOrDefault();
// 【返回结果】
// 这里返回的是 WebMenuId 对应的页面入口编码字符串。
// 返回 null 代表"本次无法解析到安全的前端跳转入口",后续上层会自动走兜底逻辑。
//
// CultureInfo.InvariantCulture 用于数字转字符串:
// - 不受区域设置影响
// - 总是使用英文格式(如 "123" 而不是 "123,00"
return matchedMenu?.WebMenuId?.ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// 构建候选名称列表。
/// <para>
/// 【为什么需要候选列表?】
/// 同一个配置分类可能有多个名称:
/// - ConfigTypeName分类名称
/// - HomeConfigTypeName首页显示名称
/// - 历史数据可能还有"XX案例"和"XX"的混用
///
/// 构建候选列表可以增加匹配成功率。
/// </para>
/// <para>
/// 【C# 语法知识点 - params 参数数组】
/// params string[] values 表示可变参数:
/// - 调用时可以传任意数量的参数
/// - 编译器自动组装成数组
///
/// 对比 Java
/// Java: public static List&lt;String&gt; buildNames(String... values)
/// </para>
/// <para>
/// 【C# 语法知识点 - HashSet 去重】
/// HashSet 自动去重,避免重复比较。
/// StringComparer.Ordinal 使用序数比较。
/// </para>
/// </summary>
/// <param name="values">名称列表</param>
/// <returns>去重后的候选名称列表</returns>
private static List<string> BuildCandidateNames(params string[] values)
{
// HashSet 用来做"去重后的候选集合"。
// 搜索路由解析里经常会出现多个别名指向同一个菜单,用 HashSet 可以避免重复比较。
HashSet<string> candidates = new(StringComparer.Ordinal);
// 【LINQ 过滤 + foreach】
// Where 过滤掉空白值,然后遍历处理。
foreach (string value in values.Where(item => !string.IsNullOrWhiteSpace(item)))
{
string normalized = Normalize(value);
if (string.IsNullOrWhiteSpace(normalized))
{
continue;
}
candidates.Add(normalized);
// 【特殊处理"案例"后缀】
// 历史数据里存在"XX案例"和"XX"混用的情况。
// 搜索路由解析这里主动补一个去后缀别名,能降低菜单命名不统一导致的跳转失败。
if (normalized.EndsWith("案例", StringComparison.Ordinal))
{
// 【C# 语法知识点 - 范围操作符】
// normalized[..^2] 是范围操作符:
// - .. 表示"从开始到"
// - ^2 表示"倒数第 2 个"
// - normalized[..^2] 表示"从开始到倒数第 2 个"
//
// 例如:"产品案例"[..^2] = "产品"
candidates.Add(Normalize(normalized[..^2]));
}
}
return candidates.ToList();
}
/// <summary>
/// 检查菜单是否匹配根菜单约束。
/// <para>
/// 【匹配规则】
/// 1. 如果没有根菜单约束,直接返回 true
/// 2. 如果菜单是根菜单的直接子节点,返回 true
/// 3. 如果菜单的祖先链包含根菜单,返回 true
///
/// 这样既兼容两级菜单,也兼容更深层的树结构。
/// </para>
/// </summary>
/// <param name="menu">菜单</param>
/// <param name="expectedRootMenuId">期望的根菜单 ID</param>
/// <returns>是否匹配</returns>
private static bool MatchesRootMenu(HwWebMenu menu, long? expectedRootMenuId)
{
if (menu == null)
{
return false;
}
if (!expectedRootMenuId.HasValue)
{
// 没有归属根菜单约束时,名称命中即可。
// 这对应的是"未知分类/历史数据"场景,策略上更偏向尽量给出跳转结果。
return true;
}
// 这里允许两种命中方式:
// 1. 当前菜单就是根菜单的直接子节点
// 2. 当前菜单虽然不是直接子节点,但 ancestors 链上包含根菜单
// 这样既兼容两级菜单,也兼容更深层的树结构。
return IsDirectChild(menu, expectedRootMenuId) || ContainsAncestor(menu.Ancestors, expectedRootMenuId.Value);
}
/// <summary>
/// 检查菜单是否是根菜单的直接子节点。
/// </summary>
/// <param name="menu">菜单</param>
/// <param name="expectedRootMenuId">期望的根菜单 ID</param>
/// <returns>是否直接子节点</returns>
private static bool IsDirectChild(HwWebMenu menu, long? expectedRootMenuId)
{
// menu?.Parent 使用 null 条件运算符:
// - 如果 menu 为 null返回 null不访问 Parent
// - 如果 menu 不为 null返回 Parent
return menu?.Parent != null && expectedRootMenuId.HasValue && menu.Parent.Value == expectedRootMenuId.Value;
}
/// <summary>
/// 检查祖先链是否包含指定的菜单 ID。
/// <para>
/// 【ancestors 格式说明】
/// ancestors 在这套门户模型里是逗号分隔的祖先链,例如 "0,2,7"
/// - 0根节点
/// - 2一级菜单
/// - 7二级菜单
///
/// 这里先拆分再精确比较,而不是直接 Contains 字符串,
/// 是为了避免 "2" 误匹配到 "12" 这类脏命中。
/// </para>
/// <para>
/// 【C# 语法知识点 - String.Split 选项】
/// Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
/// - RemoveEmptyEntries移除空元素
/// - TrimEntries去除每个元素的首尾空白
///
/// 对比 Java
/// Java: String[] parts = text.split(",");
// // 需要手动处理空白
/// </para>
/// </summary>
/// <param name="ancestors">祖先链字符串</param>
/// <param name="expectedRootMenuId">期望的根菜单 ID</param>
/// <returns>是否包含</returns>
private static bool ContainsAncestor(string ancestors, long expectedRootMenuId)
{
if (string.IsNullOrWhiteSpace(ancestors))
{
return false;
}
// ancestors 在这套门户模型里是逗号分隔的祖先链,例如 "0,2,7"。
// 这里先拆分再精确比较,而不是直接 Contains 字符串,是为了避免 "2" 误匹配到 "12" 这类脏命中。
return ancestors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Contains(expectedRootMenuId.ToString(CultureInfo.InvariantCulture), StringComparer.Ordinal);
}
/// <summary>
/// 规范化字符串(去除空白)。
/// <para>
/// 【处理说明】
/// 这里只做最保守的归一化trim + 去连续空白。
/// 不做大小写折叠或中文转换,避免把业务词条误伤到别的菜单名。
/// </para>
/// <para>
/// 【C# 语法知识点 - 正则表达式】
/// Regex.Replace(value?.Trim() ?? string.Empty, "\\s+", "")
/// - \\s+:匹配一个或多个空白字符
/// - 替换为空字符串
/// </para>
/// </summary>
/// <param name="value">输入字符串</param>
/// <returns>规范化后的字符串</returns>
private static string Normalize(string value)
{
// 这里只做最保守的归一化trim + 去连续空白。
// 不做大小写折叠或中文转换,避免把业务词条误伤到别的菜单名。
return Regex.Replace(value?.Trim() ?? string.Empty, "\\s+", "");
}
}