ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从RBAC到代码生成器:ASP.NET权限管理框架实践

从RBAC到代码生成器:ASP.NET权限管理框架实践 简介面向.NET开发人员的权限管理与快速开发框架资源适用于需要快速搭建后台管理系统、多应用权限体系的中小型项目团队。资源以zip压缩包形式提供共367个文件总大小约4.59MB内部以C#源码、Razor视图、JS/CSS样式、配置文件及图片素材等为主目录模块划分清晰便于直接查阅与二次开发。框架核心模块涵盖组织机构、角色用户、权限授权、多系统与多应用管理、定时任务、业务单据编码规则以及代码生成器等并整合了Asp.Net Core MVC、EF、Dapper、WebAPI、Swagger、Vue等主流技术既可学习分层架构设计思路也可作为项目初始模板快速落地。已有81人浏览下载适合有一定ASP.NET基础、希望提升开发效率并掌握权限控制与快速开发框架实践的开发者。1. 权限管理框架选型ASP.NET 下权限模型与快速开发的取舍大多数中小团队做权限管理都是从一张 User 表加 Session 开始的。登录能过菜单能出但等到要控制按钮、区分多应用、做组织数据权限时代码就成了一锅粥。这套基于 ASP.NET 的权限管理及快速开发框架把组织机构、角色用户、权限授权、多系统多应用、定时任务和代码生成器打包到一起解决的不只是登录认证而是“新功能怎么在权限体系里快速生长”。它用分层思路把 EF、Dapper 和 Vue 揉在一起既保留复杂查询的灵活性又给前端足够的控制力。对要用 .NET 快速交付业务系统的团队值得拆开看一遍。2. Suk.* 五层架构从 Common 到 WebApplication1 的分层与权限数据流解压这个工程包之后第一眼看到的是 WebApplication1 和 Suk.Common、Suk.Models、Suk.Interface、Suk.Service 这几个项目。这不是教科书式的三层架构而是把“接口定义”单独拆了一层出来让上层只依赖抽象不直接依赖实现。权限这种极易被改动的模块如果业务代码到处 new 一个 Service后面每次加缓存、加审计、换 ORM 都会牵连一片。2.1 工程职责与引用方向五个项目的职责大致如下工程名职责主要存放内容Suk.Common通用基础能力缓存封装、Result 返回体、加密解密、扩展方法、常量定义Suk.Models数据实体与 DTOEF 实体、数据库表模型、前端入参出参对象、枚举Suk.Interface接口抽象层服务接口、仓储接口、权限接口、定时任务接口Suk.Service业务实现层用户、角色、菜单、权限码、单据编码等业务逻辑WebApplication1启动与配置Controllers、Startup、配置文件、Swagger、Vue 静态资源我把它的工程结构整理成下面这样WebApplication1/ ├── Controllers/ │ ├── AuthController.cs │ ├── BillCodeController.cs │ └── Sys/ │ ├── UserController.cs │ └── RoleController.cs ├── Suk.Common/ │ ├── Result.cs │ ├── Cache/RedisCache.cs │ └── Extensions/Pageable.cs ├── Suk.Models/ │ ├── Entity/Sys_User.cs │ ├── Entity/Sys_Permission.cs │ └── Dto/UserQueryInput.cs ├── Suk.Interface/ │ ├── IService/IUserService.cs │ └── IService/IPermissionService.cs └── Suk.Service/ ├── Service/UserService.cs └── Service/PermissionService.cs这个结构里最重要的是引用方向WebApplication1 引用 Suk.Interface、Suk.Service、Suk.ModelsSuk.Service 只引用 Suk.Interface 和 Suk.ModelsSuk.Models 不引用任何项目。这样做的直接好处是权限校验这类横切关注点可以放在 Service 层统一实现Controller 拿到接口后不关心背后是 EF 还是 Dapper也不关心权限数据是来自 Redis 还是数据库。2.2 权限数据模型RBAC 上的组织与应用扩展这套框架的权限模型仍然是经典的 RBAC但加了两层扩展一层是组织架构一层是多应用隔离。组织架构让权限可以按部门、岗位下放多应用隔离则避免在一个大系统里让菜单和权限码互相污染。核心表关系可以简化为下面这段建表脚本CREATE TABLE Sys_App ( Id INT IDENTITY PRIMARY KEY, AppCode NVARCHAR(50) NOT NULL UNIQUE, AppName NVARCHAR(100) NOT NULL ); CREATE TABLE Sys_Org ( Id INT IDENTITY PRIMARY KEY, ParentId INT NULL, OrgCode NVARCHAR(50) NOT NULL, OrgName NVARCHAR(100) NOT NULL ); CREATE TABLE Sys_User ( Id INT IDENTITY PRIMARY KEY, OrgId INT NULL, UserName NVARCHAR(64) NOT NULL, PasswordHash NVARCHAR(256) NOT NULL ); CREATE TABLE Sys_Permission ( Id INT IDENTITY PRIMARY KEY, AppId INT NOT NULL, ParentId INT NULL, Code NVARCHAR(100) NOT NULL UNIQUE, Name NVARCHAR(100) NOT NULL, Type TINYINT NOT NULL -- 1菜单 2按钮 3接口 ); CREATE TABLE Sys_Role ( Id INT IDENTITY PRIMARY KEY, RoleCode NVARCHAR(50) NOT NULL, RoleName NVARCHAR(100) NOT NULL ); CREATE TABLE Sys_UserRole ( UserId INT NOT NULL, RoleId INT NOT NULL ); CREATE TABLE Sys_RolePermission ( RoleId INT NOT NULL, PermissionId INT NOT NULL );Sys_Permission 表里的 Code 是整个权限体系的钥匙。我见到的编码方式一般是order:view、order:export、user:add这种「模块:动作」风格。冒号前面的模块名直接对应 Sys_App 里的 AppCode这样多应用共用一套用户和角色表时每个应用只加载自己前缀下的权限码不会把另一个系统的菜单漏出来。2.3 接口抽象与依赖注入EF 和 Dapper 并存在 Startup 里框架会做这样一组注册// WebApplication1/Startup.cs public void ConfigureServices(IServiceCollection services) { services.AddDbContextAppDbContext(options options.UseSqlServer(Configuration.GetConnectionString(Default))); services.AddScopedIPermissionService, PermissionService(); services.AddScopedIUserService, UserService(); services.AddScopedIBillCodeRuleService, BillCodeRuleService(); // 通用仓储默认 EF 实现需要 Dapper 时直接用 IDapperRepository services.AddScoped(typeof(IBaseRepository), typeof(EfBaseRepository)); services.AddScopedIDapperRepository, DapperRepository(); }这里需要注意 IPermissionService 和 UserService 注册的服务生命周期是 Scoped。权限校验里会读取当前请求用户如果用 Singleton 注入 DbContext 或 IHttpContextAccessor很容易在并发时拿到错误的用户上下文。Dapper 那边的仓储是独立的适合写复杂联表查询不参与 EF 的模型跟踪。EF 负责模型迁移和常规 CRUDDapper 负责权限码、报表、跨应用统计这类“读多写少”的链路。数据流是请求先到 Controller再走 IUserService 或 IPermissionService 接口实现类内部按需选择 EF 还是 Dapper。这样拆完之后你换数据库、换缓存组件都不需要动 Controller 层。3. 从 WebAPI 到 Swagger把权限校验链路搭起来第 2 章把分层和数据模型梳理清楚了这一章重点看落地。权限管理框架如果没有一个可复用的认证和鉴权入口前面建再多的表也只是摆设。这个框架的默认链路是 JWT 做身份认证自定义 Attribute 做接口权限点校验Swagger 负责把调试入口暴露给前端和测试。3.1 JWT 认证注册与 Swagger 鉴权按钮先配置认证服务// Program.cs / Startup.ConfigureServices services.AddAuthentication(Bearer) .AddJwtBearer(Bearer, options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer suk, ValidateAudience true, ValidAudience suk-web, ValidateLifetime true, ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(Configuration[Jwt:Secret])) }; });TokenValidationParameters 里的 Issuer 和 Audience 要和登录接口签发 token 时保持一致。Jwt:Secret 不能写在代码里要放到 appsettings.json 或环境变量。ValidateLifetime 为 true 时过期 token 会自动返回 401配合定时任务调度时要记得给后台任务单独签发长生命周期的 token否则定时任务很容易在凌晨跑到一半突然鉴权失败。Swagger 这边也要加一个 Bearer 方案否则前端拿 token 后都没地方填services.AddSwaggerGen(c { c.SwaggerDoc(v1, new OpenApiInfo { Title Suk Framework API, Version v1 }); c.AddSecurityDefinition(Bearer, new OpenApiSecurityScheme { Type SecuritySchemeType.Http, Scheme bearer, BearerFormat JWT, In ParameterLocation.Header, Name Authorization }); c.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference new OpenApiReference { Type ReferenceType.SecurityScheme, Id Bearer } }, Array.Emptystring() } }); });AddSecurityDefinition 声明了 Swagger 页面的 Authorize 按钮AddSecurityRequirement 让所有接口都默认携带 Authorization 请求头。调试时不用每次手动拼 Bearer 前缀直接从登录接口复制 token 粘贴进去就行。3.2 自定义 PermissionAuthorizeAttribute区分 401 和 403登录态检查和权限点检查要分开。框架里可以这样写一个权限过滤器[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class PermissionAuthorizeAttribute : Attribute, IAuthorizationFilter { private readonly string _permissionCode; public PermissionAuthorizeAttribute(string permissionCode null) { _permissionCode permissionCode; } public void OnAuthorization(AuthorizationFilterContext context) { var user context.HttpContext.User; if (user?.Identity?.IsAuthenticated ! true) { context.Result new UnauthorizedResult(); return; } if (string.IsNullOrEmpty(_permissionCode)) return; var userIdClaim user.FindFirst(uid)?.Value; if (!long.TryParse(userIdClaim, out var userId)) { context.Result new ForbidResult(); return; } var permissionService context.HttpContext.RequestServices .GetServiceIPermissionService(); if (permissionService null || !permissionService.HasPermission(userId, _permissionCode)) { context.Result new ForbidResult(); } } }使用方式很简单[HttpPost(export)] [PermissionAuthorize(order:export)] public IActionResult Export(ExportQueryInput input) { // 导出逻辑 }这里刻意把登录校验和权限校验分开是因为很多新手会用一个 Authorize 属性同时管登录和权限。结果是用户没登录报 403权限不足也报 403前端根本分不清到底是去重新登录还是去找管理员加权限。用上面的代码未登录返回 401登录了但没权限返回 403错误语义一清二楚。3.3 权限查询实现参数化 SQL 和缓存IPermissionService 的默认实现可以用 Dapper 直连表关系避免 EF 在复杂连表时生成低效 SQLpublic class PermissionService : IPermissionService { private readonly IDapperRepository _dapper; public PermissionService(IDapperRepository dapper) { _dapper dapper; } public bool HasPermission(long userId, string permissionCode) { const string sql SELECT COUNT(1) FROM Sys_Permission p JOIN Sys_RolePermission rp ON p.Id rp.PermissionId JOIN Sys_UserRole ur ON rp.RoleId ur.RoleId WHERE ur.UserId userId AND p.Code permissionCode; return _dapper.ExecuteScalarint(sql, new { userId, permissionCode }) 0; } }userId 和 permissionCode 是参数化查询避免拼接 SQL 造成注入。若当前用户角色很多可以用 EXISTS 改写或者把权限码先放到哈希集合里再判断。实际项目中这个查询会被高频调用建议在 Service 层加一层缓存缓存失效策略见第 6 章的角色版本号方案。4. Vue 侧权限落地动态路由、菜单过滤和按钮级 v-permission权限管理框架的后端只是提供了数据和控制点真正让用户直观感受到权限的是前端菜单和按钮。这个框架的 ASP.NET Core WebAPI 同时承担了静态资源托管Vue 构建后的文件可以放进 WebApplication1 的 wwwroot 目录。前端权限控制重点在动态路由和按钮指令。4.1 登录接口返回的菜单与权限点登录成功后后端一次性返回 token、菜单树和权限码集合{ token: eyJhbGciOi..., menus: [ { path: /order/list, component: order/List, permission: order:view, appCode: erp } ], permissions: [ order:view, order:export, user:add ] }menus 给动态路由用permissions 给按钮指令用。为什么要把菜单和权限码分开因为一个菜单往往需要多个权限点配合比如订单列表页的“导出”按钮不在菜单里而是在按钮级权限中。合在一起会让前端路由注册和按钮过滤互相纠缠。4.2 动态路由注册这类框架通常用 Vue Router 的 addRoute 在登录后注册当前用户可见的页面路由// store/modules/user.js async loadAuthData(store) { const data await getAuthData() const modules import.meta.glob(../views/**/*.vue) data.menus.forEach(menu { router.addRoute({ path: menu.path, name: menu.path, component: modules[../views/${menu.component}.vue], meta: { permission: menu.permission } }) }) store.commit(setMenus, data.menus) store.commit(setPermissions, data.permissions) }这里有个常见的坑import.meta.glob是 vite 的懒加载匹配方式打包时它必须能静态分析到路径。如果 component 字段是从数据库读出来的路径里的字符串顺序不能反过来否则编译后找不到组件。我一般会在后端 constrain component 的值让它必须是views/下的相对路径前端只做白名单校验不让用户传入任意字符串。4.3 按钮权限指令按钮权限可以用自定义指令实现// directives/permission.js import store from /store export default function setupPermissionDirective(app) { app.directive(permission, { mounted(el, binding) { const requiredPermission binding.value const permissions store.state.user.permissions if (!permissions.includes(requiredPermission)) { el.parentNode?.removeChild(el) } } }) }页面里这样用el-button v-permissionorder:export clickhandleExport导出订单/el-button指令里删除 DOM 会有一个副作用如果权限按钮原本在表格工具栏里删除后再用 Vue 的 v-if 控制不会重新渲染。所以框架里也可以改成不删除而是设置 disabled 和 title让用户知道这个功能存在但没有权限。具体选哪种取决于产品要求不要盲目用 hidden 方式。前端权限和后端权限的边界需要明确层次控制内容实现方式弱点路由页面级可见性动态 addRoute用户可尝试直接输入 URL菜单导航栏可见性后端返回 menus不拦截直接请求 API按钮操作级可见性v-permission 指令DOM 删除可被绕过接口数据级安全PermissionAuthorize无前端感知数据行行级数据范围SQL where 条件依赖组织权限设计真正可靠的还是后端接口的 PermissionAuthorize 拦截前端只是体验层的过滤。第 3 章的属性有了前端按钮也控制了整体权限链路才算封闭。5. 代码生成器与业务单据编码规则把重复工作交给模板权限和快速开发是这个框架的两条腿。权限解决了“谁能用”代码生成器解决“新的业务模块怎么快点上”。业务单据编码规则则是会计、采购、库存这类模块都离不开的通用能力。这三件事放在一起正好是框架提升开发效率的完整链路。5.1 代码生成器生成的 Service 模板常见的 APS.NET Core 代码生成器会读取数据库表结构生成实体、DTO、仓储、服务和控制器。这套框架里生成的 Service 一般长这样public class OrderAppService : IOrderAppService { private readonly IBaseRepositoryOrder _repository; public OrderAppService(IBaseRepositoryOrder repository) { _repository repository; } public async TaskPageResultOrder PageQuery(OrderQueryInput input) { var query _repository.Query() .WhereIf(!string.IsNullOrWhiteSpace(input.OrderNo), o o.OrderNo.Contains(input.OrderNo)) .WhereIf(input.Status.HasValue, o o.Status input.Status.Value); return await query.PageAsync(input.Page, input.PageSize); } }这里两个扩展方法是这套框架的重点。WhereIf 让可选过滤条件不再写一长串 ifPageAsync 统一接收分页参数返回带 Total、PageIndex、PageSize 的 PageResult。生成器生成的代码不是只能跑而是要直接符合团队约定所有 Service 接口都放在 Suk.Interface实现都放在 Suk.Service控制器只做参数绑定和返回值包装。这样后期加统一日志、加缓存切面只需要扫一遍生成模板即可。5.2 业务单据编码规则业务单据编码的表结构一般包含这几列字段示例说明BillTypeOrder单据类型PrefixPO前缀DatePartyyyyMMdd日期格式SeqLength4流水号长度ResetTypeDay重置周期常用 Day / Month / Year生成编码的逻辑并不复杂关键是并发时不能重复。我惯用的方式是用 Redis 的 INCR 按“单据类型日期”做自增public string NextCode(string billType) { var rule _ruleRepository.GetRule(billType); if (rule null) throw new InvalidOperationException($未配置{billType}的编码规则); var datePart DateTime.Now.ToString(rule.DatePart); var seq _cache.Increment($bill:seq:{billType}:{datePart}, 1); return ${rule.Prefix}{datePart}{seq.ToString().PadLeft(rule.SeqLength, 0)}; }INCR 的 key 每天自然过期周期切换时不需要额外清数据。seq 超过 SeqLength 位数时PadLeft 不会截断而是直接变长这时可以通过告警通知排查业务量是否异常或者调整 SeqLength。代码生成器加上编码规则后新业务模块的骨架可以在半小时内搭好剩下的重点就放在业务规则本身而不是反复写分页查询和单号生成。6. 定时任务里的权限上下文与两个容易翻车的检查点6.1 定时任务中不要依赖 HttpContext.User权限管理容易漏掉一个场景定时任务。框架里如果集成了定时任务模块任务执行时是没有 HTTP 请求的HttpContext.User 为 null。很多人在定时任务里写:var userId _httpContextAccessor.HttpContext?.User.FindFirst(uid)?.Value;这里的 userId 拿到的是 null。如果是做导出任务导出数据里带不上操作人如果是自动审批任务会因为权限过滤不到对象而静默失败。常见做法是在任务配置里增加 OperatorId 或 System User 字段。执行时把发起人和系统账号一起传到权限上下文public void Execute(Sys_Job job) { var operatorId job.OperatorId ?? 1L; // 系统管理员 var permissionService new PermissionService(_dapper); var hasRight permissionService.HasPermission(operatorId, order:auto-approve); if (!hasRight) { _logger.LogWarning(任务 {JobId} 未配置审批权限job{JobCode}, job.Id, job.JobCode); return; } }用系统账号跑定时任务时一定要把任务执行日志和业务表里的 operator_id 分开存。这样即使系统账号权限很大审计时也能追踪到具体是哪个任务、哪条配置发起的操作。6.2 权限缓存版本号与 Swagger 调试建议权限变化不生效是最常见的排错场景。用户改了角色但旧权限还留在缓存里。处理办法是每个用户存一个角色版本号或者每个角色存一个版本号string cacheKey $suk:permissions:{userId}:{roleVersion};roleVersion 可以在角色权限变更时 1这样权限缓存无需显式删除旧缓存自然访问不到。如果角色未变而用户调整了组织归属还需要把 OrgId 一起放进缓存 key。简洁做法是suk:permissions:{userId}:{orgId}:{roleVersion}换取缓存粒度变细后权限查询接口要注意 Redis 的命中率和扩容成本。Swagger 调试时也有一个容易翻车的地方有些框架里的 Swagger 会显示 401 和 403 都是红叉但语义完全不同。看到 401先检查 token 是否过期、Authorization 头是否带了 Bearer 前缀看到 403去检查用户角色和权限码是否匹配。前端偶尔报 404 也不是路由写错而是权限过滤后组件没注册成功。按照“401 查登录态403 查权限点404 查动态路由”这个顺序大多数权限问题能在十分钟内定位。本文还有配套的精品资源点击获取
返回列表