☰
.NET 9多租户权限系统:从动态API到数据权限落地实践
2026/10/18 16:44:43 网站建设 项目流程

简介:基于 .Net9 和 Vue3、Element Plus,搭配 uni-app 与 uviewui 开发的前后端分离权限管理系统,面向需要快速构建企业级后台的开发人员。系统支持多租户、数据权限、动态 Api、任务调度、OSS 文件上传、滑块拼图验证,以及国内外主流数据库自由切换,并集成了统一认证授权、事件总线、分布式缓存与事务、IP 限流、全 Api 鉴权、性能分析和健康检查等能力,适合作为高安全、可扩展后台的参考实现。资源包共 1409 个文件,压缩后约 15.24MB。文件以 594 个 C# 源码为主,构成后端服务;另有 273 个 Vue 文件和 103 个 TypeScript 文件支撑前端页面,并包含 JSON 配置、SCSS 样式、Dockerfile 与脚本等,目录结构完整,便于编译调试和二次改造。当前已有 85 人学习下载。通过学习源码,可以掌握多租户数据隔离、数据权限过滤、动态 Api 鉴权、任务调度和 OSS 集成的具体写法,是一份上手即用的权限管理解决方案。

1. 一套技术栈覆盖PC与移动端:这套权限系统在拆解什么

一套管理系统要同时支持公司后台和员工手机,还要让各分公司只看到自己的业务数据,这是很多内部交付项目共同的出发点。基于 .NET 9 和 Vue3 + Element Plus、uniapp + uviewui 开发的前后端分离权限管理系统,正好把这条链路拆得很干净:.NET 9 的 WebAPI 作为唯一后端,多租户、数据权限、动态API、任务调度、OSS 文件上传、动态高级查询都收在服务端;Vue3 + Element Plus 管管理端的表格、表单和配置页面;uniapp + uviewui 管移动端的审批、填报、消息提醒。

我按模拟项目 X 的落地顺序展开:先谈架构选型和动态API,再谈多租户与数据权限,然后是高频踩坑与排查,最后落一个自动化报表的组合技巧。这套组合的适用人群很明确:手里要交付中后台系统的开发者、正在给老项目做前后端分离改造的团队、以及想在多租户权限方案上少走弯路的技术负责人。

2. 后端架构与动态API:让新表自动长出增删改查

2.1 后端项目分层:Host、Application、Domain、Infrastructure

先讲分层,是因为权限系统的横切逻辑太多了。如果一个 Controller 直接拿着 DbContext 操作,后面每加一个需求都要改入口,多租户过滤器在哪挂、数据权限在哪拼、审计日志在哪记,全都混在一起。模拟项目 X 里我用的是四个程序集:Host 负责启动、中间件、Swagger、认证注册;Application 只放服务接口、DTO 和业务编排;Domain 放实体、仓储接口和业务常量;Infrastructure 实现 EF Core 上下文、仓储实现、OSS 客户端、任务调度。依赖方向是单向的,Domain 不依赖任何外部项目,Infrastructure 依赖 Domain 和 Application 接口,Host 把一切组装起来。

这种分层对动态API特别有利。动态API要做的是“按实体类型统一生产 CRUD 接口”,它需要的实体元数据和仓储入口都在 Domain 与 Infrastructure 里,启动时把注册好的实体列表扫一遍,接口就能自动生成。业务人员通常不用直接感知动态API的存在,他们看到的只是一个资源路由。

分层时有两个约定建议提前定死。一个是主键类型,我默认 long 自增,后续做分页和排序都是这个类型;另一个是业务表统一包含 TenantId、CreatedBy、DeptId 这三个字段,多租户和数据权限都靠它们。下表是我常用的职责划分,按表建目录基本不会错。

项目典型内容依赖方向
HostProgram.cs、中间件、认证注册、Swagger引用其余三个
ApplicationIOrderService、OrderDto、业务编排引用 Domain
DomainOrder、IRepository<Order>、常量无外部依赖
InfrastructureAppDbContext、OrderRepository、OssClient引用 Domain 与 Application

很多人在这一步会想“我就一个小项目,分层是不是过度设计”。我的经验是:权限系统一旦接入数据权限,写查询的地方都会被追加条件,没有分层的话,条件追加会散落在十几个 Controller 里,到时候改一处漏一处。分层不是装饰,是给权限兜底。

2.2 动态API的两种实现路径:反射注册与通用端点

动态API的目标是同一套增删改查逻辑覆盖所有实体,避免每张表都手写一个 Controller。我见过的落地方式大致有两种。第一种在启动时用反射扫描实体程序集,为每个实体动态生成 Controller 类,路由类似 /api/order;第二种是做一个通用端点,路由类似 /api/dynamic/{entityName},一个 Action 接收实体名和操作参数,统一处理所有实体的查询、新增、修改、删除。我最终选了第二种,原因是可维护性。

反射生成 Controller 的好处是 IDE 里能直接看到方法,Swagger 也能自动列出来,但代价是每新增一种操作(批量导入、复制、审核)都要改生成模板,模板越写越厚,最后变成新的维护负担。通用端点把操作类型收敛成几个固定动作:page、detail、add、update、delete、export。前端传操作的名称,后端分发,大部分业务动作都能覆盖。动态API的核心代码可以分为注册表、表达式解析、权限追加三部分,这里先看控制器入口:

[ApiController] [Route("api/dynamic/{entityName}")] public class DynamicApiController : ControllerBase { private readonly IServiceProvider _provider; public DynamicApiController(IServiceProvider provider) { _provider = provider; } [HttpGet("page")] public async Task<IActionResult> Page(string entityName, [FromQuery] int pageIndex, [FromQuery] int pageSize, [FromQuery] string gridFilter) { // 1. 从注册表找实体类型,未注册直接返回 404,避免反射攻击 var entityType = DynamicApiRegistry.Entities .FirstOrDefault(e => string.Equals(e.Name, entityName, StringComparison.OrdinalIgnoreCase)); if (entityType == null) return NotFound($"实体 {entityName} 未注册"); // 2. 反射构造泛型仓储并取得 IQueryable var repoType = typeof(IRepository<>).MakeGenericType(entityType); var repo = _provider.GetRequiredService(repoType); var query = (IQueryable)repoType.GetMethod("AsQueryable")!.Invoke(repo, null)!; // 3. 前端 JSON 条件转成 Expression,再叠加数据权限 query = DynamicQueryBuilder.Apply(entityType, query, gridFilter); query = DataScopeApplier.Apply(entityType, query, DataScopeContext.Current); // 4. 分页并统一返回 var total = await query.CountAsync(); var list = await query .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToListAsync(); return Ok(new { total, list }); } }

这里要强调三个设计点。第一,entityName 匹配用忽略大小写的方式,但注册表里的名称必须唯一,实体重名会直接启动失败,启动时就该报错而不是留到请求阶段。第二,gridFilter 是 JSON 字符串而不是散列查询参数,它可以表达或和且、范围、包含等嵌套条件,前端拼装灵活,后端只需要解析一次。为了限制解析成本,我限制了单个查询的条件数量上限和字段数量上限,超过就直接拒绝。

第三,DataScopeApplier 必须和动态查询的 Expression 拼接处于同一个阶段,谁先谁后并不影响结果,但在代码里我固定为先拼动态条件、再拼数据权限,排查时只要按这个顺序阅读即可。如果数据权限在查询执行前没加上,动态API就会变成一个越权接口,这是动态API最容易被忽略的风险。

提示:通用端点并不是不设防。动态查询涉及的字段必须过白名单,前端传什么字段就查什么字段,会让动态API成为注入通道。

2.3 管理端与移动端的鉴权链路:JWT与Token刷新

管理端用 Vue3 + Element Plus,移动端用 uniapp + uviewui,两边都连同一个 .NET 9 API,自然要共用一套鉴权链路。我采用的是 JWT 双 Token 方案:Access Token 两个小时过期,Refresh Token 七天。前端两个端各自保存 Token,但刷新逻辑保持同一个约定,避免管理端能自动续期、移动端却要反复登录的割裂体验。

管理端用 axios 封装请求,拦截器统一附加 Authorization 头,遇到 401 先并发锁刷新再重放请求。uniapp 端包一层 uni.request,逻辑一致,但移动端网络切换频繁,刷新并发锁必须写好,不然多个请求同时 401 会触发多次刷新请求,刷新接口也返回 401,前端就卡在登录页。这里有一个容易被忽略的点:刷新成功之后,已经带上旧 Access Token 的请求不会自动重放。拦截器里要维护一个等待队列,把 401 期间排队的所有请求在刷新完成后统一带新 Token 发出。uniapp 端同样处理,只是要注意页面 unload 时中断队列,避免请求作用域泄漏。

登录成功后,后端把用户ID、租户ID、部门ID、数据权限范围都写进 Token 的声明里。租户ID放在 Token 里有讲究:多租户过滤必须在后端无感知地完成,如果每次要求前端把租户ID当成参数传,伪造租户就太容易了。我见过有项目把租户ID放在请求体的公共参数里,结果被人改了几次请求就串了数据,这个教训后面避坑章会细说。

3. 多租户与数据权限:三种隔离方案与数据范围拼接

3.1 多租户三种隔离方案怎么选:共享表起步

多租户隔离大体三种:独立数据库、独立 Schema、共享表加租户ID字段。独立库隔离最彻底,数据迁移和报表要跨库查,成本高;独立 Schema 介于中间,权限管理比独立库方便,但很多数据库对 Schema 的支持程度不一致,运维复杂;共享表成本最低,租户ID字段过滤,所有租户数据在物理上同处一张表,漏一行过滤就是串号。对于多数内部管理系统,我推荐共享表起步,原因很直接:租户数量通常可控,数据量还没到需要物理隔离的程度,租户过滤靠的是全局的 Query Filter,只要写入和查询两端都覆盖,共享表对业务层的影响是最小的。

选择共享表后有一个铁律:所有业务表必须有 TenantId 字段,且写入时由框架层统一赋值。TenantId 在写入时如果允许业务代码自由指定,租户隔离就形同虚设。我们在模拟项目 X 里把这个约定做成了代码评审必查项,启动时也会扫描实体类型,缺 TenantId 的业务实体在启动阶段直接报错。

维度独立数据库独立 Schema共享表 + 租户ID
数据隔离最强中逻辑隔离
跨租户报表需要跨库方便方便
运维成本高中低
漏过滤风险低中高
适用规模大型租户中型多数内部系统

3.2 租户ID怎么注入:过滤器与AsyncLocal

租户ID的注入横跨写入和查询两个过程。写入时用 SaveChanges 拦截,新增实体的 TenantId 自动填充;查询时用 EF Core 的全局 Query Filter,每个 IQueryable 出场都带上租户条件。这两条路要一起做,漏掉写入端会出现空 TenantId,漏掉查询端会出现串租户。

EF Core 的 Query Filter 在模型构建阶段生成,需要绑定一个表达式。如果这个表达式里写死租户ID,那所有请求共享一个租户,显然不行。我用的方案是 AsyncLocal 传递当前租户,过滤器表达式读取静态属性,请求中间件在进入管道时设置,请求结束时清理:

public static class TenantContext { private static readonly AsyncLocal<Guid?> _tenantId = new(); private static readonly AsyncLocal<bool> _ignore = new(); public static Guid? TenantId { get => _tenantId.Value; set => _tenantId.Value = value; } // 系统级任务可以打开忽略开关,业务用户永远不允许 public static bool Ignore { get => _ignore.Value; set => _ignore.Value = value; } }

Ignore 默认是 false,只有系统级报表任务在独立作用域中会打开,业务请求路径上不允许改动。在 Program.cs 里注册中间件,每个请求从 JWT 里取租户ID:

app.Use(async (context, next) => { var tenantId = context.User.FindFirst("tenant_id")?.Value; if (Guid.TryParse(tenantId, out var id)) { TenantContext.TenantId = id; } try { await next(); } finally { TenantContext.TenantId = null; } });

这里有一个细节:finally 里清空租户ID,而不是在 next 返回之后再清空,是为了防止异步请求作用域复用导致的串号。AsyncLocal 的原理是按执行上下文流动,如果不及时清空,同一个线程池线程处理完 A 租户请求后,处理 B 租户请求时可能读到残留值。A 同学当时遇到过一次偶发性的数据串租户,重启后消失,最后定位就是少了清空逻辑。

模型配置里给实现 ITenantEntity 接口的类型挂上过滤器:

foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType)) { var param = Expression.Parameter(entityType.ClrType, "x"); var prop = Expression.Property(param, "TenantId"); var tenant = Expression.Property(null, typeof(TenantContext), nameof(TenantContext.TenantId)); var body = Expression.Equal(prop, Expression.Convert(tenant, typeof(Guid))); entityType.SetQueryFilter(Expression.Lambda(body, param)); } }

这里不能用“常量等于常量”的方式写,表达式右侧要从 TenantContext 静态属性读取当前值,EF Core 会自动把它翻译成参数化查询。参数化之后,数据库执行计划可以复用,这是共享表模式下性能不会崩的一个重要原因。

3.3 数据权限的四种范围:表达式拼接与部门树

多租户解决租户与租户之间的隔离,数据权限解决租户内部不同角色能看哪些数据。常见规则是四种:全部数据、本部门及以下、本部门、仅本人。实现上,我先在实体上约定 CreatedBy 和 DeptId 两个字段,创建数据时有上下文的请求自动填充,和数据权限规则对应。

ApplyDataScope 做的事情不复杂,核心是把规则翻译成 Expression 条件,再和现有查询叠加:

public static IQueryable<T> ApplyDataScope<T>(this IQueryable<T> query, LoginUser user) where T : class { if (user.IsSuperAdmin || user.DataScope == DataScopeEnum.All) return query; var param = Expression.Parameter(typeof(T), "x"); Expression body; switch (user.DataScope) { case DataScopeEnum.DeptAndChildren: // 本部门及以下,deptIds 已在登录时算好 body = BuildInExpression<T>(param, "DeptId", user.DeptAndChildrenIds); break; case DataScopeEnum.Dept: body = BuildEqualExpression<T>(param, "DeptId", user.DeptId); break; case DataScopeEnum.Self: body = BuildEqualExpression<T>(param, "CreatedBy", user.UserId); break; default: body = null; break; } return body == null ? query : query.Where(Expression.Lambda<Func<T, bool>>(body, param)); }

注意 DeptAndChildren 的 deptIds 必须在登录那一刻就算好,而不是查询时递归部门表。登录时把当前部门的 ID 和所有下级部门 ID 一次性放进缓存,会话期间部门树变了就主动清缓存。如果每次都递归查部门表,这个过滤条件会因为子查询而无法索引化,性能急剧下降。

还要提醒一点:BuildInExpression 要求 T 必须包含 DeptId 属性,但这在编译期无法保证。我在项目启动时扫描所有注册了数据权限的实体,凡是配置了数据权限过滤但缺少 DeptId 或 CreatedBy 的,直接启动失败。这样把运行时错误前移到启动期,我们团队的一位同事曾在这个问题上卡了一下午,线上查询全部报错,根因却是“某个实体没有部门ID”这种低级问题。

关于部门树还有一个跨租户的坑:不同租户的部门ID可能完全相同,部门缓存必须带租户维度。如果某个缓存 Key 只放部门ID集合,不拼租户ID,就可能出现租户 A 的人根据租户 B 同 ID 部门查到了别人的数据。缓存 Key 我统一拼成 tenant:{租户ID}:dept:{用户ID}。

4. 高频踩坑与排查:租户串号、Swagger失灵、任务重复执行

避坑内容没法靠看书学会,我把模拟项目 X 里遇到过的高频问题按现象、原因、解决三步写出来。每一条的背后都是线上数据或发布环境真实出现过的问题,不是理论推演,排查顺序也一并说明,方便对照自己的项目排查。

4.1 租户ID从请求体传:一次删库级串号事故

现象:批量导入 Excel 后,A 公司里出现了一批 B 公司的数据,后台按 A 公司租户查询查不到,但 B 公司能看到。

原因:导入服务当时图省事,接收了前端传过来的 TenantId 参数,并把该值当作当前租户写入实体。前端为了兼容导出再导入的场景,把 TenantId 放在了 Excel 模板的隐藏列里,同事导出后改了几行又导回来,隐藏在单元格里的租户ID就被改成了别的值。

解决:第一,停掉所有租户ID入参的接口,租户上下文统一从 JWT 解析;第二,中间件加一致性校验,请求体里的 TenantId 可以和 Token 里的租户不同,但这种请求在写入前一律拒绝;第三,导入模板里不再保留 TenantId 列,服务端从当前 Token 取租户ID。导出功能同样不允许包含租户ID,避免文件流转时被改动。这次事情以后,我把“租户上下文只能来自 Token,不能来自请求体”写进了代码评审清单。

4.2 动态API发布后Swagger异常

现象:本地开发时动态API在 Swagger 里显示正常,发布到服务器后部分路由 404,Swagger 页面里出现重复的端点,有的端点点了没法测试。

原因:动态API的实体注册走的是运行时反射,Swagger 的 ApiDescription 依赖启动时的扫描顺序。发布环境下程序集文件顺序和本地不同,实体注册顺序不稳定,导致部分控制器的元数据没有被 Swagger 正确捕获。另外,同一实体可以通过多个路由前缀到达时,Swagger 会把每个路由都渲染成独立端点,产生一堆看起来长得差不多的重复项。

解决:把动态API的实体注册从反射程序集改成显式注册集合,在 Program.cs 里声明哪些实体参与动态API。这样丢失的自动化换来了发布环境的确定性,Swagger 能不能渲染变得可预期。重复端点则是通过路由约束解决:只让 Swagger 展示标准路由前缀下的端点。排查这个问题的顺序我建议是:先看启动日志里实体注册表的输出,确认实体有没有进注册表;再检查路由约束是否重复;最后才是 Swagger 自身的缓存和分组问题。

4.3 日期差8小时:JSON序列化时区

现象:移动端提交的审批单,在管理端查看时日期时间比实际提交时间晚了 8 小时;导出的 Excel 里时间也不对,某些字段显示成数字串。

原因:.NET 9 的 System.Text.Json 默认序列化 DateTime 时会输出带时区的 ISO 8601 格式,字符串结尾带 Z。uniapp 运行在部分低版本安卓 WebView 里,对带 Z 后缀的时区解析并不一致,同一段 JSON 在不同机型上会被解析成不同的本地时间。Excel 导出时,如果单元格直接写入带 Z 的字符串,也会被识别成 UTC 时间,再转换一次又差 8 小时。

解决:在 .NET 9 里统一配置日期序列化格式,输出北京时间无时区的“yyyy-MM-dd HH:mm:ss”,并约定所有接口的时间参数都按该格式传。前端拿到的是纯本地时间字符串,不做时区换算。具体做法是注册一个自定义 JsonConverter,对 DateTime 和 DateTime? 统一处理,写全局配置而不是每个 DTO 单独标注属性。注意不要写在 DTO 属性上用特性标注的方式,因为动态API场景下 DTO 类型是反射生成的,特性标注很难同步维护。

4.4 定时任务跑了两遍:多实例缺分布式锁

现象:每天凌晨的数据同步任务在日志里执行了两次,两次相隔几秒,导致汇总表里的金额被翻倍。

原因:任务调度配置本身只触发一次,但服务部署了两个实例,两个实例都注册了同一套 Quartz 触发规则,到点同时执行。这跟调度框架本身的配置没关系,是分布式部署场景下的经典问题:调度器在每个节点都是独立运行的,不会自动互斥。

解决:用分布式锁,Redis 环境下用原子占锁,锁 Key 用“任务名+触发时间”拼接,持有时间设为任务超时上限加缓冲。执行前先拿锁,拿不到就跳过本次执行。

// 拿锁成功才执行任务 var lockKey = $"job:{jobName}:{triggerTime}"; if (await _redis.LockAsync(lockKey, TimeSpan.FromMinutes(30))) { try { await ExecuteCore(context); } finally { await _redis.UnlockAsync(lockKey); } }

数据库环境也可以用唯一记录表实现,但要注意锁过期时间必须大于任务最长执行时间,否则长任务中途释放锁,后到的实例会重复执行。抢锁失败的实例要记录一条日志,方便判断是不是锁时间设置不合理。

4.5 OSS上传反复403:签名过期时间太短

现象:移动端提交流程里,附件传到一半报签名过期;用户多等几秒后又好了,但换个表单又报 403。

原因:上传签名有效期设成了 5 分钟,用户填表的过程远超 5 分钟,前端到真正调用 OSS 接口时签名已失效。部分页面的前端组件还会把签名缓存在内存里复用,一个签名会在多个上传任务中重复使用。

解决:签名有效期调整到 30 分钟,前端上传组件上传前检查剩余时间,低于 3 分钟重新向后端申请签名。后端签名策略里限制目录前缀和文件大小白名单,过期时间放宽本身不放大安全风险,但必须配合 OSS 回调校验:文件上传完成后由 OSS 回调后端服务,后端根据回调里的 bucket、object、etag 信息登记文件记录,而不单纯信任前端回传的 URL。回调接口收到通知后,核对 bucket 是否属于本系统、目录前缀是否符合业务约定、文件大小是否在白名单内,校验通过才生成文件记录。这个回调校验补上之后,伪造文件 URL 的问题也一并解决了。

5. 定时任务 + 动态查询 + OSS 上传:一个自动化报表流程

最后讲一个把多租户、数据权限、动态查询、任务调度、OSS 串起来的合体流程:每天凌晨自动跑“待结算订单汇总”,结果生成 Excel 传到 OSS,再把下载链接写回业务表,并给移动端推送一条通知。

Quartz 配置大概是这样:Job 负责调动态查询接口拿到数据,触发信息读取任务配置表里的查询条件 JSON,把它传给动态查询构造器。这个场景里必须额外传租户范围,因为调度上下文没有“当前登录用户”,普通的数据权限无从谈起。我专门建了一个任务租户作用域上下文,任务执行前按配置写入,执行完毕后立刻清空。很多人第一次配这个流程都会遇到“任务跑出来是全量数据”或“任务一条数据都查不到”的现象,本质都一样:数据权限缺租户上下文,不是权限规则写错了。

文件上传走 OSS 客户端直传,后端只签策略,文件从应用直接传到对象存储,上传成功由 OSS 回调服务端登记文件记录。回调里要做验签,不能用前端自己报的 URL 登记。报表文件按月份分目录,文件名拼上租户ID前缀,避免不同租户的文件在同一个目录下互相覆盖。这个流程跑顺以后,整个系统的“定时任务取数 → 生成文件 → 对象存储 → 消息通知”链路就闭环了。

验证方法上,我在本地用秒表对比过“不加权限条件的裸查询”和“加了多租户+数据权限+动态查询条件”的耗时,差距在 1.2 倍以内属于正常范围,说明查询过滤是下推到了数据库;如果超过 3 倍,优先怀疑权限条件里出现了字符串比较或非索引列函数操作。有一次我发现差距突然拉大,查了很久才发现条件拼接里把数字型字段的常量写成了字符串,数据库索引没走上,改成强类型转换后立刻恢复。每次动完权限相关的拼接代码,我都会先跑这个对比,这个习惯帮我节省了大量排查时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询