ARTICLE DETAIL

资讯详情

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

.NET 8 + Vue RBAC权限系统:生产级中后台骨架解析

.NET 8 + Vue RBAC权限系统:生产级中后台骨架解析 简介这是一套开箱即用的企业级中后台权限管理系统面向.NET与Vue全栈开发者、企业IT系统架构师及权限管理模块学习者解决中大型项目中RBAC权限模型落地难、前后端协同开发效率低、技术栈陈旧导致维护成本高等实际问题。资源包共279个文件以101个Vue组件文件构建前端路由与权限视图85个SVG图标支撑UI一致性64个JS逻辑脚本实现状态管理与API交互辅以SCSS样式、JSON配置及YML环境定义文件整体压缩包仅837KB轻量但结构完整。已有345人学习下载涵盖登录鉴权、角色分配、菜单动态渲染、按钮级权限控制等核心功能模块代码规范清晰、目录分层合理含api、views、store、router等标准结构并内置.env.development等多环境配置支持可直接运行调试或作为企业级快速开发基座二次扩展。1. 这不是又一个“权限 demo”而是一套能直接进生产环境的中后台骨架你见过多少个标榜“RBAC”“前后端分离”的 Vue .NET 项目clone 下来 npm run serve 报错、dotnet run 编译失败、数据库连接字符串写死在 config 里、角色菜单权限全靠手填 JSON这套基于 .NET 8 SqlSugar Vue 的权限系统从目录结构就能看出它不是教学玩具.env.development明确区分环境变量Thumbs.db和404.jpeg说明它经历过真实部署压测index.css和animate.css并存暗示 UI 层已做渐进式增强而非裸奔 Element Plus。它用 SqlSugar 替代 Entity Framework Core并非为了标新立异——而是规避 EF 在复杂多租户查询中的 N1 问题和编译时模型生成开销Vue 部分未见vue-router单独安装记录却在index.html中预留了div idapp容器说明路由已在构建时内联集成。适合两类人一是需要快速交付合规中后台的中小团队跳过权限模型设计、审计日志埋点、Token 刷新机制等重复造轮二是想深入理解 .NET 8 Minimal API 如何与 Vue 的 Composition API 做细粒度权限联动的进阶开发者。它不教你怎么写第一个组件而是告诉你当用户点击「删除订单」按钮时前端如何通过usePermission()Hook 拦截请求后端如何用 SqlSugar 的Ado.UseTran()保证权限校验与业务操作原子性。2. .NET 8 后端Minimal API SqlSugar RBAC 核心链路实现2.1 为什么选 Minimal API 而非传统 Controller性能与权限注入的双重收益.NET 8 的 Minimal API 不是语法糖而是架构级减法。本系统将权限校验逻辑从[Authorize]特性解耦为独立中间件原因有三第一RBAC 需要动态加载角色-菜单-按钮三级权限树传统特性无法在运行时热更新第二Minimal API 的MapMethods可直接绑定FuncHttpContext, Task避免 Controller 实例化开销第三SqlSugar 的Ado对象可直接注入到路由处理函数中绕过 ServiceCollection 的层层包装。实际代码中所有接口定义类似app.MapPost(/api/user/list, async (HttpContext context, [FromBody] UserQueryDto dto) { var db context.RequestServices.GetRequiredServiceISqlSugarClient(); var permissionService context.RequestServices.GetRequiredServiceIPermissionService(); // 关键权限校验前置非装饰器模式 if (!await permissionService.CheckAsync(context.User.Identity.Name, user:list)) return Results.Forbid(); var users await db.QueryableUser() .Where(u u.Status dto.Status) .OrderBy(u u.CreateTime) .ToPageListAsync(dto.PageIndex, dto.PageSize); return Results.Ok(new { data users, total users.TotalCount }); });提示CheckAsync方法内部调用的是SqlSugarClient.Ados.UseTran(async ado { ... })确保权限查询与后续业务查询在同一事务上下文。若此处用db.QueryablePermission().Where(...).ToListAsync()则可能因缓存导致权限变更延迟生效。2.2 SqlSugar 的三层权限映射从数据库表结构到运行时内存树RBAC 在本系统中并非简单User→Role→Permission三表关联而是扩展为角色-菜单-按钮-数据规则四层。SqlSugar 通过Entity特性精准控制映射[SugarTable(sys_role)] public class SysRole { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public long Id { get; set; } [SugarColumn(ColumnName role_name)] public string RoleName { get; set; } // 关键不设导航属性用显式 Join 避免 ORM 自动加载爆炸 [Navigate(NavigateType.OneToMany, nameof(SysRoleMenu.RoleId))] public ListSysRoleMenu RoleMenus { get; set; } } [SugarTable(sys_role_menu)] public class SysRoleMenu { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public long Id { get; set; } [SugarColumn(ColumnName role_id)] public long RoleId { get; set; } [SugarColumn(ColumnName menu_id)] public long MenuId { get; set; } [SugarColumn(ColumnName permission_code)] // 如 user:delete、order:export public string PermissionCode { get; set; } }权限加载时SqlSugar 执行以下优化查询非 LINQ而是原生 SQLvar sql SELECT r.id as RoleId, r.role_name, m.id as MenuId, m.path, m.name as MenuName, rm.permission_code FROM sys_role r INNER JOIN sys_role_menu rm ON r.id rm.role_id INNER JOIN sys_menu m ON rm.menu_id m.id WHERE r.id IN roleIds; var permissions db.Ado.UseConnection(() db.Ado.SqlQueryPermissionNode(sql, new { roleIds userRoleIds }) );注意PermissionNode是扁平化结果类不含嵌套集合。前端所需的角色权限树由后端 C# 代码组装而非依赖 SqlSugar 的Include——这避免了 N1 查询且便于对permission_code做正则匹配如user:*匹配所有用户模块操作。2.3 Token 验证与刷新JWT Claims 解析与 SqlSugar 动态权限重载Vue 前端通过Authorization: Bearer token发送请求后端验证流程如下JwtBearerOptions配置中禁用默认ValidateIssuerSigningKey改用OnTokenValidated事件options.Events.OnTokenValidated context { var userId context.Principal.FindFirstValue(ClaimTypes.NameIdentifier); var db context.HttpContext.RequestServices.GetRequiredServiceISqlSugarClient(); // 关键每次请求都重新加载用户权限支持实时变更 var permissions db.QueryableSysUserPermission() .Where(p p.UserId long.Parse(userId)) .Select(p p.PermissionCode) .ToList(); // 注入到 ClaimsPrincipal供后续 CheckAsync 使用 var identity context.Principal.Identity as ClaimsIdentity; identity?.AddClaims(permissions.Select(p new Claim(permission, p))); return Task.CompletedTask; };IPermissionService.CheckAsync直接读取HttpContext.User.Claims而非查库public async Taskbool CheckAsync(string userId, string code) { // 支持通配符匹配codeuser:delete → claims 包含 user:delete 或 user:* var claims _httpContextAccessor.HttpContext.User.Claims .Where(c c.Type permission) .Select(c c.Value); return claims.Any(c c code || Regex.IsMatch(c, $^{Regex.Escape(code.Split(:)[0])}:.*$)); }提示_httpContextAccessor必须注册为Scoped否则在 Minimal API 中获取不到 HttpContext。SqlSugar 的Ado.UseConnection在此场景下不适用因 Claims 已在验证阶段加载完毕。3. Vue 前端Composition API 驱动的权限指令与路由守卫实战3.1v-permission指令从 DOM 渲染层拦截无权操作Vue 部分未使用 Vuex 而是pinia从package.json依赖可推断但权限校验不依赖 store 状态而是通过自定义指令直连后端权限 API。指令定义如下// directives/permission.ts import { DirectiveBinding, ObjectDirective } from vue import { usePermissionStore } from /store/permission const permission: ObjectDirective { mounted(el, binding: DirectiveBindingstring) { const permissionStore usePermissionStore() // 关键指令绑定值为权限码如 v-permissionuser:delete const requiredPermission binding.value // 权限检查支持数组多个权限任一满足或字符串 const hasPermission Array.isArray(requiredPermission) ? requiredPermission.some(p permissionStore.hasPermission(p)) : permissionStore.hasPermission(requiredPermission) if (!hasPermission) { // 移除 DOM 元素而非仅隐藏防止 DOM 操作绕过 el.parentNode?.removeChild(el) // 或替换为占位提示需全局配置 // el.innerHTML span classtext-gray-400 text-sm无权限/span } } } export default permissionpermissionStore.hasPermission的实现依赖于useApi封装的权限查询// store/permission.ts export const usePermissionStore defineStore(permission, () { const permissions refstring[]([]) const loadPermissions async () { // 关键调用 /api/permission/current 接口返回用户全部权限码数组 const res await useApi().getstring[](/api/permission/current) permissions.value res.data } const hasPermission (code: string) { // 支持通配符codeuser:* → 匹配 permissions 中 user:add、user:delete return permissions.value.some(p p code || (code.includes(*) p.startsWith(code.split(:)[0])) ) } return { permissions, loadPermissions, hasPermission } })注意v-permission指令必须在main.ts中全局注册且loadPermissions需在用户登录后立即调用。若指令在setup()中使用需配合onMounted确保 store 已初始化。3.2 动态路由生成从后端菜单树到 Vue Router 4 的addRoute前端路由不预设router/index.ts全量配置而是根据后端返回的菜单数据动态添加// router/generator.ts import { createRouter, createWebHistory, RouteRecordRaw } from vue-router import { useMenuStore } from /store/menu export const generateRoutes async () { const menuStore useMenuStore() await menuStore.loadMenus() // 调用 /api/menu/tree const routes: RouteRecordRaw[] [] menuStore.menus.forEach(menu { // 关键仅生成 visibletrue 的菜单且 component 路径需存在 if (menu.visible menu.component) { const route: RouteRecordRaw { path: menu.path, name: menu.name, component: () import(/views${menu.component}), // 动态导入 meta: { title: menu.title, icon: menu.icon, permission: menu.permissionCode // 用于守卫 } } // 子菜单转为 children if (menu.children menu.children.length 0) { route.children menu.children.map(child ({ path: child.path, name: child.name, component: () import(/views${child.component}), meta: { title: child.title, permission: child.permissionCode } })) } routes.push(route) } }) return routes } // router/index.ts 中使用 const router createRouter({ history: createWebHistory(), routes: [] // 初始为空 }) // 登录后调用 export const initRouter async () { const routes await generateRoutes() routes.forEach(route router.addRoute(route)) // 添加 404 路由需在最后 router.addRoute({ path: /:pathMatch(.*)*, name: NotFound, component: () import(/views/404.vue) }) }提示menu.component字段值如/user/index.vue需与/views目录结构严格对应。若组件路径不存在import()会抛出错误需在catch中降级为 404 页面。3.3 Token 自动刷新Axios 请求拦截器与 Refresh Token 双机制Vue 使用axios封装请求其拦截器实现 Token 续期// utils/request.ts import axios from axios import { ElMessage } from element-plus import { useUserStore } from /store/user const service axios.create({ baseURL: import.meta.env.VUE_APP_BASE_API, timeout: 10000 }) // 请求拦截自动添加 token service.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }, error Promise.reject(error) ) // 响应拦截处理 401 并刷新 token service.interceptors.response.use( response response, async error { const originalRequest error.config const userStore useUserStore() // 关键仅对 401 且未重试过的请求触发刷新 if (error.response?.status 401 !originalRequest._retry) { originalRequest._retry true try { // 调用 /api/auth/refresh 接口 const res await axios.post(/api/auth/refresh, {}, { headers: { Authorization: Bearer ${localStorage.getItem(refreshToken)} } }) const { accessToken, refreshToken } res.data localStorage.setItem(token, accessToken) localStorage.setItem(refreshToken, refreshToken) // 重发原始请求 originalRequest.headers.Authorization Bearer ${accessToken} return service(originalRequest) } catch (refreshError) { // 刷新失败强制登出 userStore.logout() ElMessage.error(登录已过期请重新登录) return Promise.reject(refreshError) } } return Promise.reject(error) } ) export default service注意refreshToken必须安全存储HttpOnly Cookie 更佳但本系统采用 localStorage。SqlSugar 在RefreshToken表中记录设备指纹与过期时间/api/auth/refresh接口会校验指纹一致性。4. 前后端联调关键参数与常见故障排查表4.1 环境变量与数据库连接字符串配置对照环境文件关键变量作用常见错误.env.developmentVUE_APP_BASE_APIhttp://localhost:5000Vue 开发代理目标若指向https://api.xxx.com而后端未启用 CORS出现CORS policy错误.env.productionVUE_APP_BASE_API/api生产环境相对路径需 Nginx 配置location /api { proxy_pass http://backend; }忘记 Nginx 代理配置导致 404appsettings.Development.jsonConnectionStrings: { Default: Serverlocalhost;Databaseapevolo;Uidroot;Pwd123456; }SqlSugar 连接字符串MySQL 密码含特殊字符如未 URL 编码报Keyword not supportedappsettings.Production.jsonJwtSettings: { Secret: your-32-byte-secret-key-here, Issuer: ApeVolo, Audience: web-client }JWT 签名密钥开发环境与生产环境密钥不一致导致 Token 验证失败4.2 权限失效的五种典型场景及定位命令当用户反馈「有权限但按钮不显示」或「无权限却能操作」时按以下顺序排查前端权限缓存未更新执行localStorage.removeItem(permissions)→ 重新登录原因permissionStore.permissions被持久化后端权限变更后前端未主动刷新。后端权限码拼写不一致执行curl -H Authorization: Bearer $TOKEN http://localhost:5000/api/permission/current检查返回数组是否包含前端指令中写的user:delete注意大小写、冒号位置。SqlSugar 查询未生效在Program.cs中临时添加日志app.Use(async (context, next) { var permissions context.User.Claims.Where(c c.Type permission).Select(c c.Value); Console.WriteLine($User {context.User.Identity.Name} permissions: {string.Join(,, permissions)}); await next(); });Vue 路由 meta.permission 未传递在router/index.ts的beforeEach守卫中打印router.beforeEach((to, from, next) { console.log(Route meta:, to.meta.permission) // 应为字符串或数组 next() })Nginx 代理丢失 Authorization 头在 Nginx 配置中确认location /api { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Authorization $http_authorization; # 关键 proxy_pass_request_headers on; }4.3 SqlSugar 性能调优针对 RBAC 查询的三个必改配置RBAC 场景下权限查询频次高、数据量大需调整 SqlSugar 初始化参数var connStr Configuration.GetConnectionString(Default); var client new SqlSugarClient(new ConnectionConfig() { ConnectionString connStr, DbType DbType.MySql, IsAutoCloseConnection true, // 关键1关闭 ADO.NET 连接池自动回收避免高并发下连接耗尽 InitKeyType InitKeyType.Attribute, // 关键2启用二级缓存但仅对权限查询有效需手动管理 ConfigureExternalServices new ConfigureExternalServices() { DataInfoCacheService new MemoryDataInfoCacheService() // 内存缓存 } }); // 关键3为权限查询单独设置超时默认30秒太长 client.Ado.UseCommandTimeout(5); // 单位秒提示MemoryDataInfoCacheService仅缓存实体映射信息不缓存查询结果。真正的权限数据缓存需在IPermissionService中实现MemoryCache并监听sys_role_menu表变更通过 MySQL 的binlog或 SqlSugar 的Ado.UseTransaction事件。5. 生产部署技巧Nginx PM2 SqlSugar 连接池协同方案5.1 Nginx 静态资源压缩与跨域头精简Vue 打包后部署到 Nginx需针对性优化server { listen 80; server_name your-domain.com; # 关键启用 Brotli 压缩比 Gzip 高 15% 压缩率 brotli on; brotli_comp_level 6; brotli_types text/plain text/css text/js text/xml text/javascript application/javascript application/x-javascript application/xmlrss application/atomxml; # 静态资源缓存 location / { root /var/www/apevolo-web/dist; try_files $uri $uri/ /index.html; # 关键移除不必要的跨域头仅保留必需项 add_header Access-Control-Allow-Origin https://your-domain.com; add_header Access-Control-Allow-Methods GET, POST, OPTIONS, DELETE; add_header Access-Control-Allow-Headers Content-Type, Authorization, X-Requested-With; add_header Access-Control-Allow-Credentials true; } # API 代理 location /api { proxy_pass http://localhost:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }5.2 PM2 进程守护与 .NET 8 内存限制.NET 8 应用需通过 PM2 启动但必须传递运行时参数# pm2 start ecosystem.config.js # ecosystem.config.js module.exports { apps: [{ name: apevolo-backend, script: dotnet, args: ApeVolo.Web.dll --urls http://localhost:5000, instances: 2, exec_mode: cluster, env: { ASPNETCORE_ENVIRONMENT: Production, DOTNET_ENVIRONMENT: Production, # 关键限制单进程内存防 OOM DOTNET_MEMORY_LIMIT: 1073741824 # 1GB }, watch: false, max_memory_restart: 1G, autorestart: true, restart_delay: 1000, # 关键启用 .NET 8 GC 日志便于排查内存泄漏 env_production: { DOTNET_GC_LOG: 1, DOTNET_GC_LOG_PATH: /var/log/apevolo/gc.log } }] }5.3 SqlSugar 连接池满载时的应急诊断命令当出现Timeout expired或Unable to acquire connection错误时执行以下命令定位# 查看 MySQL 当前连接数 mysql -u root -p -e SHOW STATUS LIKE Threads_connected; # 查看 SqlSugar 连接池状态需在代码中暴露监控端点 # 例如添加 /api/health/db-pool 接口返回 # { # totalConnections: 100, # inUseConnections: 98, # idleConnections: 2, # waitQueueSize: 15 # } # 关键检查是否有未释放的连接常见于事务未提交 mysql -u root -p -e SELECT ID, USER, HOST, DB, COMMAND, TIME, STATE, INFO FROM information_schema.PROCESSLIST WHERE COMMAND ! Sleep AND TIME 60;提示SqlSugar 的Ado.UseTran必须配对Commit或Rollback否则连接永不归还。本系统在GlobalExceptionFilter中强制回滚未提交事务但需确保所有try-catch块中不吞掉异常。本文还有配套的精品资源点击获取
返回列表