简介:基于 .Net9 与 Vue3 + Element Plus、uniapp + uviewui 构建的前后端分离权限管理系统,面向需要快速落地企业级中后台或进行权限模块二次开发的 .NET 全栈开发者。系统覆盖多租户、数据权限、动态 API、任务调度、OSS 文件上传、滑块拼图验证、分布式缓存与事务、IP 限流、全 API 鉴权等能力,适合用于学习统一认证授权、事件总线、性能分析与健康检查等企业级设计思路。压缩包共 1409 个文件,大小 15.24MB,源码以 594 个 C# 文件、273 个 Vue 文件为主,辅以 JSON/TypeScript/JavaScript 配置、SCSS 样式、解决方案与 Dockerfile,并附带启动批处理和环境示例,目录结构清晰。目前已有 84 人学习下载。资源提供了完整可运行的前后端分离工程,既有后台服务与前端管理端的实现,也包含多租户隔离、动态接口生成等核心机制,便于开发者在此基础上扩展业务模块,或将其作为 .NET9 与 Vue3 技术栈的中后台脚手架。

1. 项目背景与整体定位

前后端分离的权限管理系统,在这个时间点已经不是什么新鲜概念,但真正能把多租户、数据权限、动态 Api、任务调度、OSS 文件上传和动态高级查询这些能力揉进一个项目,还保持代码可控,其实并不常见。我最近基于 .Net9 和 Vue3 + Element Plus、uniapp + uviewui 这套组合,从零重构了一套权限中台,管理端和移动端共用同一套后端接口,目前已经稳定跑了三个多月。这篇文章我打算把选型思路、核心模块的落地过程和踩坑记录一次性讲清楚。如果你正准备做类似的后台管理系统,或者想把手头 .NET 项目往新版本迁,这篇应该能帮上忙。

1.1 为什么是 .Net9 + Vue3 + uniapp 这套组合

先聊后端。有人可能觉得 .NET 8 还没捂热就上 9,步子有点大。但 .NET 9 在性能、API 编写体验、OpenAPI 元数据生成这几个方面提升很明显,尤其是 minimal API 和端点路由的能力,对“动态 Api”这类需要运行时生成接口的场景非常友好。虽然 .NET 9 是标准期限支持版本,不是 LTS,但对业务系统来说,18 个月的支持周期完全覆盖当前迭代窗口。我当时升级的核心动力就是接口开发效率和性能优化,不是单纯追新。

管理端选 Vue3 + Element Plus 几乎不用纠结。Vue3 的组合式 API 在复杂表单、权限指令、路由守卫这些场景下写起来比 Options API 顺手太多。Element Plus 的组件覆盖度和中后台场景的成熟度,在团队协作里能省下大量踩坑时间。移动端选 uniapp + uviewui,是因为我需要同一套代码同时覆盖 App 和微信小程序。虽然现在还有 flutter、Taro 这些跨端方案,但如果团队主要技术栈是 Vue,uniapp 的学习成本和招聘成本都更现实,uviewui 则恰好补上了 uniapp 原生组件不够用的短板。

1.2 这套系统到底解决了什么问题

一个企业级后台管理系统,60% 以上的功能其实是重复的:登录认证、用户管理、角色权限、菜单配置、文件上传、定时任务、列表查询。如果每个项目都从零写一遍,团队很快会疲于应付。而多租户和数据权限又是 SaaS 化过程中最容易翻车的部分,很多人做完了功能却发现租户之间数据串了,或者同一个租户内销售员能看到全公司的订单,这些都是事故级的 bug。

所以我做这套系统时,目标很明确:把公共能力抽成独立模块,业务代码只关心自己的表结构和业务规则。租户上下文自动注入、权限校验自动生效、数据范围自动过滤、查询条件自动拼接。这样接到新业务时,写 Service 层的 CRUD 就够了,复杂的横切逻辑全部由框架层处理。也正因为目标清晰,后面每一个模块的边界都有了明确的依据。

2. 核心功能拆解:多租户、数据权限与权限模型

2.1 用户-角色-菜单-按钮的权限模型设计

权限模型我采用的是经典 RBAC 扩展:用户关联角色,角色关联菜单,菜单下面再挂按钮权限点。对应的表结构是用户表、角色表、用户角色表、菜单表、角色菜单表。按钮权限点单独成表或者直接当成一个字段挂在菜单上都可以,我建议单独维护,因为后端的接口权限校验要依赖它。

前端用 v-permission 指令控制按钮是否渲染,这属于用户体验层面的控制。但真正的安全边界必须放在后端。我习惯把权限判断收敛成一个特性,比如 [Permission("system:user:add")] ,通过 .NET 9 的 endpoint filter 或中间件统一拦截。千万不能在每个 Controller 里手动判断,一旦业务多了就会漏,漏一个就是越权。

[Permission("system:user:delete")]
[HttpDelete("{id}")]
public async Task DeleteUser(long id)
{
    await _userService.DeleteAsync(id);
}

这里的经验是:权限信息不要每次请求都查数据库,用户登录成功后把权限编码列表加载进 Redis,请求进来时用 userId 去取,命中就直接放行,没命中再查库并回填缓存。权限变更时,要么主动删用户缓存,要么用一个权限版本号做整体失效,后者实现简单且不会漏。

2.2 多租户隔离的落地方式

多租户的核心是数据隔离,常见有三种:独立数据库、共享库独立 Schema、共享表共享字段。我采用的是共享表加 TenantId 字段,因为 SaaS 初期成本最低,迁移、备份、统计都方便。如果后面某个租户数据量实在撑不住,再单独抽独立库也不迟,接口层尽量屏蔽这种差异。

具体实现上,每个租户一个 TenantId,用户登录后从 Token 里解析租户信息,写入一个异步上下文,仓储层在做查询、更新、删除时自动拼接租户条件。这样所有数据访问都能默认带上租户过滤,业务代码根本不用感知自己是在哪个租户环境下工作。

public class TenantFilter : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var tenantId = context.HttpContext.User.FindFirst("tenantId")?.Value;
        TenantContext.Current = tenantId;
        await next();
    }
}

这里最容易犯的错是在某些 Service 方法里手动写 Where(t => t.TenantId == currentTenantId) 。一两次没问题,时间长了总会有地方漏掉,而且排查起来极其痛苦。正确做法是把租户过滤做成全局查询过滤器,不管是 EFCore 还是 SqlSugar 都支持,统一在框架层处理。

2.3 数据权限:从“能进系统”到“能看哪些数据”

多租户解决的是租户之间互不可见的问题,数据权限解决的是同一个租户内部不同角色能看到什么范围的数据。典型的场景是销售经理能看全公司订单,普通销售只能看自己和下属的订单,财务只能看已审核单据。

我在权限模型里给角色增加了数据范围字段,值包括:全部、本部门、本部门及以下、本人、自定义部门。查询业务数据时,框架根据当前用户角色算出数据范围,再拼接过滤条件。比如“本部门及以下”就需要先把部门树的子节点都查出来,再生成 deptId in (…) 的表达式。

这里要注意一个边界:数据权限和动态查询是叠加关系,不是替换关系。用户在前台选的筛选条件要合并到数据权限条件后面,而不是让前端条件覆盖权限条件。我在设计动态查询时把“基础过滤条件”和“用户自定义条件”分成两层,基础过滤永远在前,用户条件永远在后面追加,这样才能保证用户不能通过构造请求绕过自己的数据范围。

3. 效率型基础能力:动态Api、任务调度、OSS与动态高级查询

3.1 动态Api是怎么省掉控制器样板代码的

动态 Api 简单说,就是 Service 层写一个方法,框架自动把它暴露成 HTTP 接口,不用手写 Controller。社区里 Furion 把这条路走得比较早,我是参考相同思路做了一套轻量实现:Service 类名以 AppService 结尾,方法命名按约定来,比如 CreateUser 对应 POST、 UpdateUser 对应 PUT、 DeleteUser 对应 DELETE、 GetList 对应 GET。框架启动时反射扫描这些类型,动态注册到 ASP.NET Core 端点路由里。

services.AddDynamicApiCore(options =>
{
    options.RoutePrefix = "api";
    options.Assembly = typeof(ISystemService).Assembly;
    options.VerbMapping = new Dictionary<string, string>
    {
        ["Create*"] = HttpMethod.Post.ToString(),
        ["Update*"] = HttpMethod.Put.ToString(),
        ["Delete*"] = HttpMethod.Delete.ToString(),
        ["Get*"] = HttpMethod.Get.ToString()
    };
});

动态 Api 最大的好处是减少重复代码,新增一个业务模块基本只需要写 Service 和实体,接口、Swagger 文档、权限注册都能自动生成。但代价也很实际:开发时 IDE 的 Find Usages 不能直接跳到接口实现,调试路由时得依赖运行时产生的 Swagger 地址。所以动态 Api 必须配合作风强约束,方法命名、参数绑定、注释规则都要定好,不然时间一长接口风格会乱。

3.2 任务调度模块的封装思路

任务调度我选了 Quartz.NET,原因是任务持久化、Cron 表达式、集群模式这些能力都比较稳。Hangfire 的 UI 虽然好看,但对当前系统来说有点重,而且多租户场景下我需要给每个租户单独控制任务启停,Quartz 的 JobDataMap 和分组机制用起来更灵活。

我把任务调度封装成了 SysJob 模块,支持新增、暂停、恢复、手动执行一次,任务代码通过反射加载实现了 IJob 的类。调度中心只负责触发,真正的业务逻辑放在 Job 类里。任务执行前、执行后都写日志,包括执行时长、异常堆栈、下次触发时间,这样即使半夜任务挂了,第二天也能直接定位。

这里有个经验:Cron 表达式非常容易写错,尤其新手容易把“每分钟执行”和“每秒钟执行”搞混。我在管理界面加了“下次执行时间预览”,用户保存 Cron 表达式时后端直接解析出接下来五次执行时间回显出来,能规避大量配置错误。

3.3 OSS文件上传的统一抽象

OSS 上传这件事,不能只盯着阿里云 OSS。现阶段腾讯云 COS、MinIO 私有化部署都很常见,所以我在项目里做了一个统一存储接口 IStorageProvider ,下面挂 AliyunOssProvider MinioProvider ,通过配置中心动态切换当前使用哪套。

public interface IStorageProvider
{
    Task<StorageResult> UploadAsync(Stream stream, string objectName, string contentType);
    Task DeleteAsync(string objectName);
    string GetUrl(string objectName);
}

统一抽象之后的收益很明显:团队不用关心里面是哪家云,前端上传组件只调一个固定接口。但真正要注意的不是接口抽象,而是安全细节。上传前要限制文件类型和大小,文件名不能用原始文件名,重新生成随机名称;可执行文件、html、svg 这类容易触发 XSS 的类型直接拦截;如果开启了签名 URL,还要注意过期时间不要太长。

3.4 动态高级查询的前后端联动

后台管理系统里查询列表是最常见的需求,但如果每张表都写一个查询接口,后端会变成接口仓库。我的做法是统一走动态高级查询接口,前端把查询条件以 JSON 结构传过来,后端解析后动态生成表达式。

[
  { "field": "Name", "op": "contains", "value": "张三" },
  { "field": "CreateTime", "op": "range", "value": ["2024-01-01", "2024-12-31"] },
  { "field": "Status", "op": "eq", "value": 1 }
]

后端在解析这些条件时,有两个底线。第一,字段名必须做白名单校验,不能允许前端传任意列名,否则等于把数据库结构暴露给用户。第二,值必须走 ORM 参数化或者表达式树,绝对不能拼接 SQL 字符串。我见过不少项目喜欢用 Where("Name like '%" + name + "%'") 这种写法,在权限系统里出现一次就是致命的。解析完成后的表达式,再和前面说的数据权限基础过滤条件叠加,最终产物才是真正执行查询的表达式。

4. 前端实现与实战踩坑

4.1 Vue3 + Element Plus 管理端的封装沉淀

管理端前端我做了三层沉淀:请求层、组件层、页面层。请求层就是 axios 实例封装,统一 baseURL、统一 Token 注入、响应拦截器统一处理业务码和 401 异常。组件层主要封装了 CrudTable、SearchForm、ModalForm 这类中后台高频组件。页面层则通过配置化生成,很多列表页只需要写列配置和查询表单配置,不需要重复堆模板。

动态路由是权限管理系统的前端核心。菜单权限从后端接口获取后,用 router.addRoute 动态注册,而不是在静态路由里一次性写死。这里有个多次踩过的坑:用户退出登录后,必须重置路由实例,否则下一个账号登录后会残留上一个账号的路由。而且路由状态最好和 Pinia 一起清空,不然权限变更后刷新页面还会拿旧菜单。

Element Plus 按需引入我用的是 unplugin-auto-import unplugin-vue-components ,这样打包体积明显下降。另外有个小问题容易被忽略:Element Plus 的 Notification 组件反复触发时,会一个叠一个铺满屏幕。解决办法是给通知加上 grouping: true ,相同内容自动合并,或者自定义 offset 错开位置,视觉上会干净很多。

4.2 uniapp + uviewui 移动端的权限同步

移动端不像管理端那样需要完整的动态菜单,它更接近一个功能固定的业务入口。所以移动端登录后只需要拿到用户信息和按钮权限点,然后在页面跳转和操作按钮层面做控制。Token 统一存在 uni.setStorageSync ,请求封装里带上 Authorization 头。如果需要显示后端动态下发的菜单,也不要直接照搬管理端的菜单数据,移动端的菜单一般更适合自定义成页面树。

uviewui 不是开箱即用的,项目里要先在 main.js 里引入 UI 库,还要在 App.vue 里加载样式文件。多端运行的时候,manifest.json 的配置必须提前想清楚:应用名称、appid、图标、启动页、权限声明、小程序 appid,这些不配置好,后面打包上架会非常折腾。

4.3 移动端环境里最让我头疼的四个坑

第一个坑是页面下拉刷新和内部滚动冲突。当页面里用了 scroll-view 做滚动区域,又开启了 enablePullDownRefresh ,手指在顶部下滑经常触发页面下拉而不是内部滚动。后来我尽量让页面原生滚动,不嵌套 scroll-view ;如果必须嵌套,就用 scroll-view 自带的 refresher-enabled ,不要开页面级下拉。

第二个坑是 webview 打开页面有过渡白屏。处理思路是 onLoad 时先显示 loading 遮罩,等 webview 的 loaded 事件触发后再关掉,同时外层壳要尽量提前预创建 webview,避免每次冷启动都要重新初始化。另外如果打开的是 H5 页面,域名和证书一定要提前配好,否则正式环境会有一堆诡异问题。

第三个坑是 renderjs 相关的视频播放问题。我用 renderjs 做了一些复杂事件处理,结果视频在部分 Android 机型上无法播放。最后排查发现是本地打包资源路径和 H5 环境不一致,最好的方式还是把视频放服务器上用 URL 播放,本地路径只适合存放静态资源。

第四个坑是离线打包的 SDK 版本问题。HBuilderX 版本和本地 Android SDK 版本一旦对不上,打出来的包启动时很容易直接闪退。这个问题没有啥捷径,就是统一版本号,并且每次升级 HBuilderX 后重新拉一遍对应版本的 SDK。

5. 部署、性能与安全加固经验

5.1 前后端分离部署结构与环境隔离

这套系统部署结构比较常规:后端 API 部署在服务器上用 Kestrel 托管,前端管理端打包成静态文件交给 Nginx,移动端通过 uniapp 打包成 App 或小程序。Nginx 只需要处理好静态资源和反向代理,接口路径统一以 /api 开头转发到后端服务。

Nginx 配置里最容易翻车的是前端路由用 history 模式后刷新 404。解决办法就是加一个 fallback:

location / {
    root /www/dist;
    index index.html;
    try_files $uri $uri/ /index.html;
}

location /api/ {
    proxy_pass http://127.0.0.1:5000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

环境隔离方面,我分了 dev、test、prod 三套配置,连接字符串、Redis、OSS 配置全部走环境变量或配置中心,源代码里不放任何真实账号密码。这不是小题大做,权限系统一旦泄露配置,整个租户体系全部暴露,没有补救余地。

5.2 认证授权与缓存策略

认证方案是 JWT 加 RefreshToken。AccessToken 短期有效,RefreshToken 相对长期,前端在响应拦截器里发现 401 后,自动携带 RefreshToken 换新 Token,用户无感续期。这样既能避免 Token 被长期窃取滥用,又不会逼用户频繁重新登录。

权限和菜单缓存我建议加版本号。权限变更时,不需要精确删掉每个用户的缓存,只需要把权限版本号递增,所有用户下一次请求都会因版本号不匹配而重新加载。实现起来很简单,但能省掉你很多排查“改了权限但用户还是旧权限”的烦恼。

另外,系统里的常用字典、租户信息、数据范围规则,能放 Redis 就不要每次都查库。权限系统的性能瓶颈通常不在业务表,而在这些高频元数据上。

5.3 线上问题定位和日志聚合

权限系统的线上问题,最麻烦的是“用户反馈看不到某个数据”,排查时既涉及权限范围,又涉及租户上下文,还有可能只是前端没传查询参数。所以日志必须带上足够的上下文:当前用户、当前租户、请求路径、查询条件、数据范围、执行时长、异常堆栈。

我用的日志方案是 Serilog 写入文件加日志中心,任务调度模块的执行记录单独持久化到数据库。这样应用日志管运行轨迹,调度日志管任务结果,两边分开看,定位速度会快很多。

日志里尤其注意脱敏:登录日志不能记密码,OSS 签名 URL 不要完整打印,用户手机号、身份证这类字段输出时要加脱敏函数。否则日志中心一旦被人翻看,就是新的数据泄露点。

5.4 给同样在做权限系统的朋友几点建议

第一,多租户和数据权限的边界测试要做成自动化用例,不能只靠人工点一点。租户 A 的用户换一个租户 B 的 Token 去请求接口,系统必须正确拒绝或者返回空数据,而不是默认放行。第二,动态 Api 和动态查询都依赖强约定,约定文档必须写清楚,尤其对团队新人,否则很容易写出风格不统一、接口不可控的代码。第三,权限系统上线前要做一次越权测试,找个没参与开发的同事,专门尝试访问自己没有权限的接口、数据范围以外的数据、别的租户的数据,把漏洞堵在前面。

我在实际做这套系统的过程中,最大的体会是:权限系统的难点从来不在功能实现,而在那些“看起来没问题”的暗处。租户过滤漏一个条件,数据权限被自定义查询条件覆盖,缓存刷新不及时导致权限残留,这些问题都不是写代码当时能发现的。所以先把横切逻辑收敛到框架层,再通过测试用例兜底,整体风险会小很多。最后再说一句,技术选型永远是为业务服务的,.Net9 加 Vue3 加 uniapp 这套组合,只是刚好在性能、开发效率和跨端覆盖上满足了当前的需要,真正决定项目成败的,还是有没有把边界想清楚。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐