前言
之前笔者写过一篇推广Blazor的博客《安利一下Blazor:.NET开发者的全栈"优"选项》,简单的聊过一点Blazor的话题,以及它和一些前端框架(如Vue,React)的异曲同工之处。
近期在开发的一个基于Blazor Server框架的管理后台项目,由于对管理人员权限的要求比较细致,这里我们根据实际的场景需求,我想分享一下我在项目中,通过组件化封装实现的一套覆盖维度比较广的权限方案,并探讨在架构设计中,如何正确看待系统的"复杂度"。
本文提到的"Blazor"统一代表"Blazor Server"模式,而Blazor的另一个架构模式,Blazor WebAssembly不能代入本文。
场景
在我的场景里,对权限的控制需求基本是这样的
- 有上帝视角的超级管理员,可以对系统进行一切操作,但操作都有记录
- 超级管理员可以创建不同的角色,并分配权限
- 权限类型分为
- 路由访问权限,有权限就可以进入这个界面,没有就不能
- 活动管理权限(即数据权限),活动数据是系统管理的中心模型,权限可以绑定不同的活动,然后具备该权限的管理员才可以看到对应的活动
- 活动子权限,意思是活动下面还有附加的属性,比如组别,区域等,这些也可以单独授权,比如A管理员拥有活动1下A区域和A组别的权限,B拥有活动1下B区域和B组别的权限,以此类推,这属于细化的数据权限
- 还有一种组件使用权限,我们开发的一些功能组件,也要纳入权限控制范围,具备该权限的用户才能使用这些组件,比如数据导出,附加下载等
为了支撑业务需求,我将权限模块拆解为四个逻辑层级:
- 上帝视角(超级管理员):拥有全局最高权限,绕过所有过滤逻辑,但所有操作由拦截器记录审计日志。
- 页面路由权限:控制用户能否进入特定功能模块。
- 组件功能权限:控制页面内具体操作(如:导出、下载)的可见性与可用性。
- 精细化数据权限 :
- 横向隔离:常规管理员仅能看到授权给他的"活动"数据。
- 纵向细分:在特定活动内,进一步根据"赛区"和"组别"进行过滤。
技术实现
实际上在稍微上点规模的管理系统里,权限模块的设计都是直接体现其系统质量好坏的一个典型标准。首先你不可能想小项目一样到处写判定,写魔法值,而是应该将分布式的权限控制进行收敛,将复杂的问题归一化,既保证权限的精准控制,又保证操作的灵活性和便利性。
我这里是通过两个核心组件,将权限校验从繁杂的业务代码中剥离出来。
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,处理场景有:
- 全局权限:如果是超级管理员,不添加任何过滤条件。
- 活动过滤:如果用户只负责特定活动,则自动注入限定条件,如where DecMainId in (...) 。
- 细颗粒度过滤:这是最复杂的部分。组件会判断是否启用了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;
}
执行效果
- 调整数据权限之前

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

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

此时再回到刚才的界面,统一活动条件下,筛选框里就变成了"保定""石家庄"和"小学组""中学"了
至此,通过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 里怎么实现一个细粒度的权限过滤模块,结果一不小心聊到了架构、复杂度,甚至还"跨界"对比了前端框架......
好了,就聊这么多,下次继续。