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