|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 【文件说明】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<K,V> 字典】
|
|
|
|
|
|
/// Dictionary<string, long> 是键值对集合:
|
|
|
|
|
|
/// - 键:string(配置分类 ID)
|
|
|
|
|
|
/// - 值:long(菜单 ID)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// StringComparer.Ordinal 参数:
|
|
|
|
|
|
/// - 使用序数比较(按字符编码比较)
|
|
|
|
|
|
/// - 区分大小写
|
|
|
|
|
|
/// - 性能最好
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 对比 Java:
|
|
|
|
|
|
/// Java: Map<String, Long> map = new HashMap<>();
|
|
|
|
|
|
/// Java 默认使用 equals 比较,C# 可以指定比较器。
|
|
|
|
|
|
/// </para>
|
|
|
|
|
|
/// <para>
|
|
|
|
|
|
/// 【C# 语法知识点 - 字典初始化器】
|
|
|
|
|
|
/// new Dictionary<...> { ["3"] = 2L, ["4"] = 7L }
|
|
|
|
|
|
/// 这是集合初始化器语法,等价于:
|
|
|
|
|
|
/// var map = new Dictionary<...>();
|
|
|
|
|
|
/// 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<String> 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+", "");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|