// ============================================================================ // 【文件说明】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; /// /// 门户搜索路由解析器。 /// /// 【业务说明】 /// 配置分类命中后,前端真正需要的是页面 web_code,而不是分类主键本身。 /// 这个服务负责把"配置分类"解析成"页面编码"。 /// /// /// 【C# 语法知识点 - sealed 密封类 + ITransient】 /// sealed + ITransient 表示: /// - 这是一个瞬态服务(每次请求创建新实例) /// - 不能被继承(不需要扩展) /// /// public sealed class PortalSearchRouteResolver : ITransient { /// /// 配置分类到根菜单的映射表。 /// /// 【业务约定】 /// 这张映射表是"配置分类 → 门户一级根菜单"的业务约定: /// - "3" → 2L:配置分类 3 对应菜单 2(产品栏目) /// - "4" → 7L:配置分类 4 对应菜单 7(解决方案栏目) /// - "5" → 4L:配置分类 5 对应菜单 4(服务栏目) /// - "6" → 24L:配置分类 6 对应菜单 24(案例栏目) /// /// 它不是技术规则,而是前端栏目结构和搜索跳转口径绑定出来的业务知识。 /// 后续如果官网栏目调整,这里必须跟着业务一起改,不能只改前端路由。 /// /// /// 【C# 语法知识点 - Dictionary<K,V> 字典】 /// Dictionary<string, long> 是键值对集合: /// - 键:string(配置分类 ID) /// - 值:long(菜单 ID) /// /// StringComparer.Ordinal 参数: /// - 使用序数比较(按字符编码比较) /// - 区分大小写 /// - 性能最好 /// /// 对比 Java: /// Java: Map<String, Long> map = new HashMap<>(); /// Java 默认使用 equals 比较,C# 可以指定比较器。 /// /// /// 【C# 语法知识点 - 字典初始化器】 /// new Dictionary<...> { ["3"] = 2L, ["4"] = 7L } /// 这是集合初始化器语法,等价于: /// var map = new Dictionary<...>(); /// map["3"] = 2L; /// map["4"] = 7L; /// /// 2L 中的 L 后缀表示 long 类型字面量。 /// /// private static readonly Dictionary ConfigTypeRootMenuMap = new(StringComparer.Ordinal) { ["3"] = 2L, ["4"] = 7L, ["5"] = 4L, ["6"] = 24L }; /// /// 菜单服务(依赖注入)。 /// private readonly HwWebMenuService _webMenuService; /// /// 构造函数(依赖注入)。 /// /// 菜单服务 public PortalSearchRouteResolver(HwWebMenuService webMenuService) { _webMenuService = webMenuService; } /// /// 解析配置分类的页面编码。 /// /// 【解析流程】 /// 1. 获取当前有效菜单树 /// 2. 构建候选名称列表(分类名称、首页名称) /// 3. 在菜单树中查找匹配的菜单 /// 4. 返回菜单 ID 作为页面编码 /// /// /// 配置分类实体 /// 页面编码(菜单 ID) public async Task ResolveConfigTypeWebCode(HwPortalConfigType configType) { if (configType == null) { return null; } // 配置分类路由解析必须依赖当前有效菜单树,而不能只看 config_type 自身主键。 // 因为前端实际拿来跳转的是页面/菜单入口编码,不是分类表主键。 List menus = await _webMenuService.SelectHwWebMenuList(new HwWebMenu()); // 这里把"中文标题 + 首页标题 + 分类归属"一起带进解析流程。 // 目的是尽量复刻原 Java 里"按名称猜入口页面"的做法,避免迁移后搜索跳转口径变掉。 return ResolveConfigTypeWebCode(BuildCandidateNames(configType.ConfigTypeName, configType.HomeConfigTypeName), configType.ConfigTypeClassfication, menus); } /// /// 根据标题解析页面编码(重载方法)。 /// /// 标题 /// 页面编码 public async Task ResolveConfigTypeWebCode(string title) { List menus = await _webMenuService.SelectHwWebMenuList(new HwWebMenu()); return ResolveConfigTypeWebCode(BuildCandidateNames(title), null, menus); } /// /// 核心解析方法:根据候选名称和分类归属查找匹配菜单。 /// /// 【匹配策略】 /// 第一轮:名称匹配 + 根菜单约束(优先) /// - 避免不同栏目下同名菜单跳错 /// - 优先选择直接子节点 /// /// 第二轮:纯名称匹配(降级) /// - 兼容历史脏数据或未维护 ancestors 的菜单 /// - 宁可命中一个近似入口,也不要直接返回空 /// /// /// 【C# 语法知识点 - LINQ 查询链】 /// menus.Where(...).Where(...).OrderBy(...).ThenBy(...).FirstOrDefault() /// /// 这是 LINQ 的链式调用: /// - Where:过滤 /// - OrderBy:排序(升序) /// - ThenBy:次级排序 /// - FirstOrDefault:取第一个,没有则返回默认值 /// /// 对比 Java Stream: /// Java: menus.stream().filter(...).filter(...).sorted(...).findFirst().orElse(null) /// /// /// 候选名称列表 /// 分类归属 /// 菜单列表 /// 页面编码 private static string ResolveConfigTypeWebCode(List candidateNames, string configTypeClassfication, List 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); } /// /// 构建候选名称列表。 /// /// 【为什么需要候选列表?】 /// 同一个配置分类可能有多个名称: /// - ConfigTypeName:分类名称 /// - HomeConfigTypeName:首页显示名称 /// - 历史数据可能还有"XX案例"和"XX"的混用 /// /// 构建候选列表可以增加匹配成功率。 /// /// /// 【C# 语法知识点 - params 参数数组】 /// params string[] values 表示可变参数: /// - 调用时可以传任意数量的参数 /// - 编译器自动组装成数组 /// /// 对比 Java: /// Java: public static List<String> buildNames(String... values) /// /// /// 【C# 语法知识点 - HashSet 去重】 /// HashSet 自动去重,避免重复比较。 /// StringComparer.Ordinal 使用序数比较。 /// /// /// 名称列表 /// 去重后的候选名称列表 private static List BuildCandidateNames(params string[] values) { // HashSet 用来做"去重后的候选集合"。 // 搜索路由解析里经常会出现多个别名指向同一个菜单,用 HashSet 可以避免重复比较。 HashSet 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(); } /// /// 检查菜单是否匹配根菜单约束。 /// /// 【匹配规则】 /// 1. 如果没有根菜单约束,直接返回 true /// 2. 如果菜单是根菜单的直接子节点,返回 true /// 3. 如果菜单的祖先链包含根菜单,返回 true /// /// 这样既兼容两级菜单,也兼容更深层的树结构。 /// /// /// 菜单 /// 期望的根菜单 ID /// 是否匹配 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); } /// /// 检查菜单是否是根菜单的直接子节点。 /// /// 菜单 /// 期望的根菜单 ID /// 是否直接子节点 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; } /// /// 检查祖先链是否包含指定的菜单 ID。 /// /// 【ancestors 格式说明】 /// ancestors 在这套门户模型里是逗号分隔的祖先链,例如 "0,2,7": /// - 0:根节点 /// - 2:一级菜单 /// - 7:二级菜单 /// /// 这里先拆分再精确比较,而不是直接 Contains 字符串, /// 是为了避免 "2" 误匹配到 "12" 这类脏命中。 /// /// /// 【C# 语法知识点 - String.Split 选项】 /// Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) /// - RemoveEmptyEntries:移除空元素 /// - TrimEntries:去除每个元素的首尾空白 /// /// 对比 Java: /// Java: String[] parts = text.split(","); // // 需要手动处理空白 /// /// /// 祖先链字符串 /// 期望的根菜单 ID /// 是否包含 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); } /// /// 规范化字符串(去除空白)。 /// /// 【处理说明】 /// 这里只做最保守的归一化:trim + 去连续空白。 /// 不做大小写折叠或中文转换,避免把业务词条误伤到别的菜单名。 /// /// /// 【C# 语法知识点 - 正则表达式】 /// Regex.Replace(value?.Trim() ?? string.Empty, "\\s+", "") /// - \\s+:匹配一个或多个空白字符 /// - 替换为空字符串 /// /// /// 输入字符串 /// 规范化后的字符串 private static string Normalize(string value) { // 这里只做最保守的归一化:trim + 去连续空白。 // 不做大小写折叠或中文转换,避免把业务词条误伤到别的菜单名。 return Regex.Replace(value?.Trim() ?? string.Empty, "\\s+", ""); } }