在Blazor项目里构造一个覆盖面广泛的权限组件

前言

之前笔者写过一篇推广Blazor的博客《安利一下Blazor:.NET开发者的全栈"优"选项》,简单的聊过一点Blazor的话题,以及它和一些前端框架(如Vue,React)的异曲同工之处。

近期在开发的一个基于Blazor Server框架的管理后台项目,由于对管理人员权限的要求比较细致,这里我们根据实际的场景需求,我想分享一下我在项目中,通过组件化封装实现的一套覆盖维度比较广的权限方案,并探讨在架构设计中,如何正确看待系统的"复杂度"。

本文提到的"Blazor"统一代表"Blazor Server"模式,而Blazor的另一个架构模式,Blazor WebAssembly不能代入本文。

场景

在我的场景里,对权限的控制需求基本是这样的

  1. 有上帝视角的超级管理员,可以对系统进行一切操作,但操作都有记录
  2. 超级管理员可以创建不同的角色,并分配权限
  3. 权限类型分为
    • 路由访问权限,有权限就可以进入这个界面,没有就不能
    • 活动管理权限(即数据权限),活动数据是系统管理的中心模型,权限可以绑定不同的活动,然后具备该权限的管理员才可以看到对应的活动
    • 活动子权限,意思是活动下面还有附加的属性,比如组别,区域等,这些也可以单独授权,比如A管理员拥有活动1下A区域和A组别的权限,B拥有活动1下B区域和B组别的权限,以此类推,这属于细化的数据权限
    • 还有一种组件使用权限,我们开发的一些功能组件,也要纳入权限控制范围,具备该权限的用户才能使用这些组件,比如数据导出,附加下载等

为了支撑业务需求,我将权限模块拆解为四个逻辑层级:

  1. 上帝视角(超级管理员):拥有全局最高权限,绕过所有过滤逻辑,但所有操作由拦截器记录审计日志。
  2. 页面路由权限:控制用户能否进入特定功能模块。
  3. 组件功能权限:控制页面内具体操作(如:导出、下载)的可见性与可用性。
  4. 精细化数据权限
    • 横向隔离:常规管理员仅能看到授权给他的"活动"数据。
    • 纵向细分:在特定活动内,进一步根据"赛区"和"组别"进行过滤。

技术实现

实际上在稍微上点规模的管理系统里,权限模块的设计都是直接体现其系统质量好坏的一个典型标准。首先你不可能想小项目一样到处写判定,写魔法值,而是应该将分布式的权限控制进行收敛,将复杂的问题归一化,既保证权限的精准控制,又保证操作的灵活性和便利性。

我这里是通过两个核心组件,将权限校验从繁杂的业务代码中剥离出来。

1. 权限守卫组件:PermissionGuard

这个组件负责"拦截"。它支持权限码(Key)和角色(Role)的双重校验,并且处理了异步加载时的占位状态。

csharp 复制代码
<PermissionGuard PermissionKey="permission.edit">
    <Authorized>
        <MudButton Variant="Variant.Filled" Color="Color.Primary">编辑</MudButton>
    </Authorized>
    <NotAuthorized>
        <MudButton Disabled="true">无权访问</MudButton>
    </NotAuthorized>
</PermissionGuard>

这里主要有两点值得提一下

  • 状态处理:内置了dataLoaded 状态,在权限异步计算完成前显示一个加载动画,避免 UI 闪烁(就是每次加载页面前闪现一下"无权访问")。
  • 参数响应:重载了生命周期函数OnParametersSetAsync,当传入的权限 Key 发生变化时,能够自动重新计算权限状态。同时对权限数据进行了缓存,提高性能。

注意,PermissionGuard的拦截操作,并不是隐藏或编辑元素,而是在服务器渲染阶段就决定了组件树的构成。如果权限校验不通过,相应的Html标签和事件代码等根本不会被发送到客户端。

组件的csharp代码部分我稍微灌一点,主要还是思路的分享。

csharp 复制代码
[Parameter] public string PermissionKey { get; set; } = string.Empty;
[Parameter] public string[] PermissionKeys { get; set; } = Array.Empty<string>();
// 省略传递的入参,这里还可以传递角色名称等

private bool dataLoaded = false;
// 部分参数定义省略


protected override async Task OnInitializedAsync()
{  
    await LoadPermissionAsync();
}

// 权限的计算逻辑要落到这里,Blazor的生命周期函数拿捏真的太精准了
protected override async Task OnParametersSetAsync()
{
    if (ShouldRecalculatePermission())
    {
        await LoadPermissionAsync();
    }
}

private bool ShouldRecalculatePermission()
{
    //是否需要重新计算权限,略
}

//加载
private async Task LoadPermissionAsync()
{
    _cachedPermissionKey = PermissionKey;
    _cachedPermissionKeys = PermissionKeys?.ToArray() ?? Array.Empty<string>();
    _cachedRole = Role;
    _cachedRoles = Roles?.ToArray() ?? Array.Empty<string>();
    _cachedRequireAll = RequireAll;

    _hasPermission = await CalculatePermissionAsync();
    _isFirstRender = false;
    dataLoaded = true;
}

private bool ArraysEqual(string[] array1, string[] array2)
{
    // 比对逻辑
}

执行效果

2. 数据过滤组件:DataScopeFilter

通过"拦截"的方式,可以方便的实现路由守护,组件渲染等控制,再下沉到数据层面光靠"PermissionGuard"就不够了。因此我又实现了一个数据过滤的组件,

这是实现数据权限的核心。它不参与 UI 渲染,而是作为一个"切面",

它通过维护一个统一的筛选条件构造器 (DynamicFilterBuilder)

在数据请求前自动完成 SQL 过滤条件的构建。

这里我多说两句,如果你对传统WebForms框架足够了解,看到Blazor的组件设计应该会十分亲切,Blazor 与 ASP.NET Web 窗体有很多共同之处,实现效果相似但实现逻辑已经完全不同了,更贴近Vue之类的现代前端框架里的组件。但对基于WebForm的系统来说,Blazor仍然是最好的转型方向。(learn.microsoft.com/zh-cn/dotne...

核心代码逻辑

组件会调用DataScopeService 获取当前用户的数据范围(DataScopeInfo),然后根据这些信息自动操作FilterBuilder,处理场景有:

  1. 全局权限:如果是超级管理员,不添加任何过滤条件。
  2. 活动过滤:如果用户只负责特定活动,则自动注入限定条件,如where DecMainId in (...) 。
  3. 细颗粒度过滤:这是最复杂的部分。组件会判断是否启用了UseDecMainAuxFilter,如果启用,会针对特定的活动 ID,嵌套加上区域(Area)和组别(Group)的过滤逻辑。

在父组件里的调用案例如下

csharp 复制代码
<DataScopeFilter FilterBuilder="@filterBuilder"
                 UseDecMainAuxFilter="true">
    <MudTable Items="@_data">
        <!-- 表格内容 -->
    </MudTable>
</DataScopeFilter>

@code {
    private DynamicFilterBuilder filterBuilder = new();

    // 伪代码,数据查询时自动应用权限过滤
    private IQueryable<Activity> GetFilteredData()
    {
        return _repository.Query()
            .Where(filterBuilder.Build()) // 自动注入权限条件
            .ToList();
    }
}

上述组件的特性"FilterBuilder"就是我在项目中全局维护的一个检索式构造器,父组件中提交查询时,会使用这个构造器,同时DataScopeFilter里会自动注入拼接好的范围,进而实现数据过滤的效果。

SQL构造原理简述:

组件内部会根据用户权限生成对应的查询条件。例如:

  • 无子权限:WHERE DecMainId IN (1,2,3)
  • 有子权限:WHERE (DecMainId IN (1,2,3) AND DecAreaId IN (101,102)) OR (DecMainId IN (4,5))

这种条件组合确保了用户只能看到自己被授权的数据,且对上层业务代码完全透明。

而UseDecMainAuxFilter也是系统专属的一个特性,属于对更细粒度的权限控制,当开启之后,会对活动属性再进行一次逻辑过滤;

部分的逻辑代码如下

csharp 复制代码
@using xxx
@inject IDataScopeService DataScopeService

@if (!dataLoaded)
{
    <MudPaper Class="pa-16 ma-2" Elevation="0">
        <MudCard>
            <MudCardContent>
                <MudProgressCircular Color="Color.Primary" Style="height:100px;width:100px;" Indeterminate=true
                                     Size="Size.Large">
                    <ChildContent>
                        loading...
                    </ChildContent>
                </MudProgressCircular>
            </MudCardContent>
            <MudCardActions>
                <MudButton Variant="Variant.Text" Color="Color.Primary">数据权限加载中...</MudButton>
            </MudCardActions>
        </MudCard>
    </MudPaper>
}

@code{
    [Parameter]
    public Infrastructures.DynamicFilterBuilder? FilterBuilder { get; set; }
    [Parameter]
    public bool UseDecMainAuxFilter { get; set; } = false;
    // 其他参数省略
    
    /// <summary>
    /// 自动应用数据权限过滤
    /// </summary>
    private void ApplyDataScopeFilter(DataScopeInfo dataScopeInfo)
    {
        // 自动应用数据权限过滤的逻辑片段
        if (UseDecMainAuxFilter && dataScopeInfo.DecMainWithAuxes.Any())
        {
            FilterBuilder.Or(group => {
                // 针对有细化权限的活动,拼接特定的区域和组别条件
                foreach (var auxItem in dataScopeInfo.DecMainWithAuxes)
                {
                    group.And(subGroup => {
                        subGroup.Add(FilterFieldName, auxItem.DecMainId);
                        subGroup.Add(FilterFieldNameAuxArea, auxItem.DecAreaIds, DynamicFilterOperator.Any);
                        // ... 更多细化逻辑
                    });
                }
            });
        }
    }    
}

过滤服务

为了更好的实现"过滤",我这里封装了一个底层的服务"DataScopeService",它的核心职责只有一个,根据当前用户的身份和角色动,态计算出他/她有权访问的活动(DecMain)ID 列表及其附属范围(如区域、分组)。它是一个"决策后端",每当用户进入一个需要数据筛选的页面(比如活动列表页),过滤组件会调用 GetCurrentUserDataScopeAsync() 获取当前用户的权限范围。

核心代码逻辑如下

csharp 复制代码
// 权限范围信息
public class DataScopeInfo
{
    public List<long> DecMainIds { get; set; } = new();
    public List<DecMainWithAux> DecMainWithAuxes { get; set; } = new();
    public bool HasAllDataAccess { get; set; } = true; // 默认超级管理员
}

public class DecMainWithAux
{
    public long DecMainId { get; set; }
    public List<long>? DecAreaIds { get; set; }
    public List<long>? DecGroupIds { get; set; }
}

public interface IDataScopeService
{
    Task<DataScopeInfo> GetCurrentUserDataScopeAsync();
    void ClearCache();
}

public class DataScopeService : IDataScopeService
{
    private readonly AuthenticationStateProvider _authStateProvider;
    // ...其他依赖(略)

    private DataScopeInfo? _cachedDataScope;
    private DateTime _cacheTime = DateTime.MinValue;
    private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(5);

    public async Task<DataScopeInfo> GetCurrentUserDataScopeAsync()
    {
        // 缓存有效则直接返回
        if (_cachedDataScope != null && DateTime.Now - _cacheTime < CacheExpiration)
            return _cachedDataScope;

        var user = (await _authStateProvider.GetAuthenticationStateAsync()).User;
        if (!user.Identity?.IsAuthenticated ?? true)
            return new DataScopeInfo { HasAllDataAccess = false };

        // 从 Claims 获取当前管理员 ID
        if (!long.TryParse(user.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var adminId))
            return new DataScopeInfo { HasAllDataAccess = false };

        // 获取该管理员的所有角色 → 角色对应的权限 → 类型为"活动管辖权"的权限
        var roleIds = await GetAdminRoleIds(adminId);
        var permissionIds = await GetPermissionIdsByRoles(roleIds);
        var dataPermissions = await GetEnabledDataPermissions(permissionIds);

        // 若无数据权限配置,默认无访问权
        if (!dataPermissions.Any())
            return new DataScopeInfo { HasAllDataAccess = false };

        var decMainIds = new HashSet<long>();
        var decMainAuxes = new HashSet<DecMainWithAux>();

        foreach (var perm in dataPermissions)
        {
            if (!string.IsNullOrEmpty(perm.DataFilterJson))
                ParseDecMainIds(perm.DataFilterJson, decMainIds);

            if (!string.IsNullOrEmpty(perm.DataAuxFilterJson))
                ParseDecMainWithAux(perm.DataAuxFilterJson, decMainAuxes);
        }

        var scope = new DataScopeInfo
        {
            DecMainIds = decMainIds.ToList(),
            DecMainWithAuxes = decMainAuxes.ToList(),
            HasAllDataAccess = false // 只要配置了权限,就视为受限用户
        };

        _cachedDataScope = scope;
        _cacheTime = DateTime.Now;
        return scope;
    }

    private void ParseDecMainIds(string json, HashSet<long> target)
    {
        var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
        if (dict?.TryGetValue("DecMainIds", out var array) == true)
        {
            foreach (var id in array.EnumerateArray())
                target.Add(id.GetInt64());
        }
    }

    private void ParseDecMainWithAux(string json, HashSet<DecMainWithAux> target)
    {
        var list = JsonSerializer.Deserialize<List<DecMainAuxDto>>(json);
        if (list == null) return;

        foreach (var item in list)
        {
            target.Add(new DecMainWithAux
            {
                DecMainId = item.DecMainId,
                DecAreaIds = item.DecAreaScopes?.Select(x => x.Id).ToList(),
                DecGroupIds = item.DecGroupScopes?.Select(x => x.Id).ToList()
            });
        }
    }

    public void ClearCache() => _cachedDataScope = null;
}

执行效果

  1. 调整数据权限之前

调整之前,这里可以看"保定"区域以及"小学组"的数据

  1. 调整数据权限

调整时,增加对"石家庄"和"中学组"的访问权限

  1. 调整数据权限之后

此时再回到刚才的界面,统一活动条件下,筛选框里就变成了"保定""石家庄"和"小学组""中学"了

至此,通过2个不同的组件,将复杂的权限控制都收敛到了统一的地方,实现灵活多样的权限控制。

架构思考

设计这套模块时,我并没有选择"最快"的路,我也曾犹豫:是否应该"简化"设计,只做简单的角色权限,复杂的业务需求用"文档"或"培训"来解决?

也就是所谓的"先跑起来,有时间再回来优化",实际上大家都清楚,不会再有时间了。

掩盖复杂性不会让它消失,只会让它以Bug,维护成本以及繁琐的沟通形式加倍偿还。

在架构设计中,复杂性是不可避免的,但关键在于你把复杂性放在哪里,我们不能总想着"怎么简单怎么来",既要避免"过度设计"的陷阱,也要拒绝"先有后优"的诱惑。

而是要实事求是,对未来可能发生的问题适当做一些前置思考,既要考虑满足当下的任务需求,又要对后续的扩展性留足演化空间。因此架构设计的思想应该贯穿整个项目的开发周期,而绝不仅仅是"搭个应该启动框架"就叫架构设计了!

而对于这次的权限模块开发案例,结合深度使用Blazor这个框架,我对架构设计在这个项目中的体会主要有两个。

1. 边界感

首先就是体会到系统设计和代码开发之间的边界感,对全栈工程师来说,你很难提前把一切都想明白之后再去动手写代码,很多时候都是边想边做,做着做着就悟了,而明悟的那个感觉就是边界感。

以此次项目为例,架构设计的核心任务之一是收拢复杂度如果权限逻辑散落在 100 个页面里,那不是简单,而是灾难!通过DataScopeFilter,我将复杂的嵌套 And/Or 逻辑封装在底层。这种局部的复杂性换取了全局的健壮性。

2. Blazor 的组件哲学

回到Blazor架构本身,我在上一篇介绍它的博客里(安利一下Blazor:.NET开发者的全栈"优"选项),就深刻感受到 Blazor 的设计哲学与 Vue、React 等前端框架有着跨越框架的共鸣,深度使用之后我对这个观点更加肯定。Blazor 并不是在强行把后端逻辑塞进浏览器,而是完全遵循了现代组件化的 UI 逻辑。

  • 数据传递**:Blazor 的[Parameter]完美对标 React/Vue 中的Props,定义了单向数据流的入口。
  • 事件回调:Blazor 的EventCallback 对应 Vue 的 $emit 或 React 的回调函数,确保了组件状态变更的可追溯性。
  • 依赖注入:通过 CascadingParameters(级联参数),我们可以像使用 React 或 Vue 的Provide/Inject 一样,在深层组件树中优雅地共享权限状态。

这种高度的一致性意味着:.NET 开发者做全栈开发,不必再执着于引入 Vue 或 React。 逻辑底层是完全相通的,你不仅能享受 C# 强大的类型系统,还能无缝应用现代前端的设计思想,换句话说,你不必担心被Blazor套牢。

总结

本来只是想聊聊在 Blazor 里怎么实现一个细粒度的权限过滤模块,结果一不小心聊到了架构、复杂度,甚至还"跨界"对比了前端框架......

好了,就聊这么多,下次继续。

相关推荐
阿杰AJie3 小时前
Docker 常用镜像启动参数对照表
后端
码上研社3 小时前
Maven配置阿里云镜像
java·后端
资源站shanxueit或com3 小时前
基于C#的通信过程与协议实操需要
后端
一 乐3 小时前
办公系统|基于springboot + vueOA办公管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·spring
Tony Bai3 小时前
Go 1.26 新特性前瞻:从 Green Tea GC 到语法糖 new(expr),性能与体验的双重进化
开发语言·后端·golang
资源站shanxueit或com3 小时前
Python入门教程:从零到实战的保姆级指南(避坑大全) 原创
后端
越千年3 小时前
工作中常用到的二进制运算
后端·go
转转技术团队3 小时前
转转大数据与AI——数据治理安全打标实践
大数据·人工智能·后端