基于.NET9的多租户权限系统:数据权限与动态API落地实践
简介这份资源是一套面向 .NET 与 Vue 全栈开发者的中台权限管理系统后端基于 .Net9前端采用 Vue3 Element Plus并配套 uniapp uviewui 移动端方案实现前后端分离。系统覆盖多租户隔离、数据权限、动态 Api、任务调度、OSS 文件上传、滑块拼图验证、国内外主流数据库自由切换和动态高级查询等企业级能力并内置统一认证授权、事件总线、数据验证、分布式缓存、分布式事务、IP 限流、全 Api 鉴权、健康检查及接口文档适合需要快速搭建企业级管理后台或深入学习现代权限体系设计的开发者。压缩包共 1409 个文件约 15.24MB以 594 个 C# 服务端源码、273 个 Vue 组件、103 个 TypeScript、116 个 JSON 配置文件为主另有安装构建脚本与 Dockerfile目录结构清晰便于按模块研读。目前已有 85 人学习浏览。通过该项目可掌握多租户数据隔离、动态查询、任务调度等技术落地方式并可直接对源码进行二次开发移植到实际业务系统中。1. 为什么我一看这个标题就决定用它做权限系统底座前阵子收到一个某公司内部人力系统的改造需求要求就三点子公司之间数据隔离、领导能看全量主管只能看本部门、业务表今天建好明天就要有接口。表面上是写增删改查实际上是要在 .NET9 和 Vue3 Element Plus 这个技术栈上搭一套权限底座再用 uniapp 和 uviewui 把移动端接上同一套 API。标题里的多租户、数据权限、动态API、任务调度、OSS 文件上传分别对应租户隔离、行级权限、接口自动化、定时任务和文件存储这几条最容易重复建设的链路。它适合项目型公司做标准化交付也适合内部系统每月都被需求方追着改接口的团队。下面我按从建表到上线的实际顺序拆开重点讲参数、边界和踩坑。先说结论这类系统能用而且适合长期演进但前提是别把每个功能都当成独立插件去堆。租户模型、数据权限规则、动态API入口这三件事在数据库设计阶段就要耦合好否则后面每加一张表都要手工改权限判断动态API反而变成维护负担。下面这几章会从模块地图开始逐步落到中间的代码和最后一章的交付技巧。2. 模块地图多租户、数据权限、动态API与任务调度如何协作2.1 三层权限模型从“能进哪个页面”到“能看哪一行数据”权限管理系统最容易被误解的部分是“权限就是菜单”。真实系统里权限至少分三层菜单权限解决“能进哪个页面”按钮权限解决“能不能点导出”数据权限解决“接口返回多少行数据”。用一张表可以帮助理清这个关系权限层次作用对象常见实现方式典型失败原因菜单权限页面入口与路由登录后返回角色对应的菜单树后端不校验直接访问路由地址按钮权限页面内操作前端自定义指令控制显示隐藏只做前端隐藏后端接口不设防数据权限查询结果的数据行后端规则解析后拼接过滤条件前端拿到全量后再过滤等于没做这里想强调一个原则数据权限绝不能靠前端过滤。前端隐藏只能改善体验接口返回的全量数据仍然可以被浏览器调试工具看到安全边界必须收口在后端查询层。常见做法是让每个查询在进入仓储前先经过一个权限规则解析器把“只看本部门及以下”这类描述翻译成 WHERE 条件。部门层级不深时可以用递归查子部门层级深或数据量大时建议在部门表里维护 path 冗余字段用前缀匹配完成下级部门过滤避免每条查询都递归一次。多租户与数据权限的关系是垂直隔离和水平切片的关系。多租户先把整个数据集按租户切开放进独立空间数据权限再在这个空间里按角色切行。如果租户ID在查询里漏了数据权限条件再严也可能出现跨租户泄漏这也是后面要提到的串租户问题。因此设计实体基类时我会把 TenantId 做成统一字段数据库里所有业务表都带这个字段并且所有仓储查询入口强制注入租户条件而不是靠开发者自觉。数据权限规则我一般不是写在代码里的而是落成一张规则表例如角色表上带 data_scope 字段取值包括全部、本部门及以下、本部门、仅本人、自定义部门。自定义部门时再关联一张角色部门授权表。这样做的好处是调整权限时不需要重新发布运营人员在前端配置即可生效。规则下发到查询层时再解析成当前用户的部门ID、用户ID等参数和业务查询条件做与运算。注意“与”运算的优先级规则条件一定要和用户手填的条件同时满足不能因为前端没传就放行。2.2 动态API、OSS上传与任务调度把高频定制需求抽象成通用能力这三个能力看起来互不相干但它们在项目里的定位一致把重复劳动抽象成通用机制。动态API解决的是“业务表一多Controller 写到手软”的问题。常见做法是给每张需要开放的业务表在元数据表里登记能力项比如允许查询、允许新增、允许修改、哪些字段参与筛选系统运行时根据元数据统一暴露接口。这样新增一张表多数情况下只需要建表和登记元数据不需要新建 Controller。这套设计和 AIGC 生成接口描述的场景也能接上只要生成结果符合元数据约定就能直接挂载运行。OSS 文件上传则要提前定好两件事上传凭证的时效和文件归属。我一般会让后端生成带时效的临时凭证前端直传对象存储而不是把文件流经过应用服务器再转发。文件归属要落到业务数据上比如工单附件、用户头像并在 OSS 路径里带上租户ID和业务ID例如 uploads/{tenantId}/employee/{userId}/avatar.jpg避免后续做数据迁移时分不清文件属于谁。任务调度在这个系统里要避免单机思维尤其是部署多个实例时定时任务必须配合分布式锁否则同一时刻多个实例会重复执行同一个任务给下游产生重复数据。后面第五章会专门讲这个坑。任务调度还要考虑租户上下文缺失的问题。HTTP 请求里的租户ID可以从 JWT 中获得但后台任务没有请求对象我一般会要求任务表里显式保存 tenant_id执行器启动前从任务上下文手动注入租户服务让任务内部的数据操作同样走租户过滤。这样定时任务导出的文件、生成的报表才不会越过租户边界。动态API本身也要能在任务里复用比如夜间定时清理过期数据直接调用动态API的删除接口或底层执行器比再写一套只执行一次的专用逻辑更简单。这一节的结论是标题里的功能不是相互独立的插件而是围绕租户上下文、权限上下文和数据上下文搭建的四个能力。后端框架在设计时先把这些上下文注入到依赖注入容器和查询管道里前端才能做到统一路由、统一请求头、统一菜单渲染。下面进入数据库初始化和工程搭建。3. 从空目录到跑通第一个租户建库表、初始化认证与菜单权限3.1 数据库初始化租户、用户、角色与菜单表的核心结构先把最小的表结构建出来。以下 SQL 能在 MySQL 或 PostgreSQL 上直接执行主键类型按实际情况调整-- 租户表code 全局唯一status 控制启停 CREATE TABLE sys_tenant ( id BIGINT PRIMARY KEY, code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, connection_string VARCHAR(1024), -- 独立库模式下使用共享库模式为 NULL status SMALLINT NOT NULL DEFAULT 1 ); -- 用户表tenant_id 是租户隔离的核心字段 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES sys_tenant(id), username VARCHAR(64) NOT NULL, password_hash VARCHAR(128) NOT NULL, dept_id BIGINT, status SMALLINT NOT NULL DEFAULT 1 ); -- 角色数据权限范围直接放在角色上 CREATE TABLE sys_role ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES sys_tenant(id), code VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL, data_scope VARCHAR(16) NOT NULL DEFAULT self ); -- 菜单type 1目录 2菜单 3按钮 CREATE TABLE sys_menu ( id BIGINT PRIMARY KEY, parent_id BIGINT, type SMALLINT NOT NULL, name VARCHAR(64) NOT NULL, path VARCHAR(255) NULL, component VARCHAR(255) NULL, permission_code VARCHAR(128) NULL, sort_no INT NOT NULL DEFAULT 0 ); -- 用户角色与角色菜单关系表 CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ); CREATE TABLE sys_role_menu ( role_id BIGINT NOT NULL, menu_id BIGINT NOT NULL, PRIMARY KEY (role_id, menu_id) ); CREATE INDEX idx_user_tenant ON sys_user(tenant_id, username); CREATE INDEX idx_role_tenant ON sys_role(tenant_id, code);这段建表的核心逻辑是租户 ID 贯穿所有业务表角色上直接挂数据权限范围菜单里的 permission_code 为按钮级权限提供后端校验依据。开发环境可以不加 connection_string默认所有租户共享同一套数据库靠 TenantId 过滤只有少数数据量大、隔离要求高的租户才走独立库这也把部署成本控制住了。3.2 用中间件解析租户上下文JWT、请求头与仓储过滤租户上下文不宜从上到下手工传递我一般在 .NET9 的中间件管道里加一个租户解析器。以下代码是一个可直接落地的骨架public sealed class TenantContextMiddleware { private readonly RequestDelegate _next; public TenantContextMiddleware(RequestDelegate next) _next next; public async Task InvokeAsync(HttpContext context) { // 已登录请求优先从 JWT 声明中取租户ID var tenantId context.User.FindFirst(tenant_id)?.Value // 少部分匿名或跨系统调用允许显式指定租户 ?? context.Request.Headers[X-Tenant-Id].FirstOrDefault(); if (long.TryParse(tenantId, out var id)) { var tenantService context.RequestServices.GetRequiredServiceITenantService(); tenantService.SetCurrent(id); } await _next(context); } }逻辑说明这段中间件的关键是 scoped 服务里维护租户上下文。Scoped 生命周期与请求绑定不会跨请求污染如果为了省事改成静态变量并发下一旦两个租户请求交错会出现 A 租户条件套到 B 租户查询上的情况这是串租户最常见的来源。在 Program.cs 注册中间件时要放在 UseAuthentication 之后保证 context.User 已经完成认证填充否则还要额外解析 JWT。参数说明tenant_id 声明名要和登录颁发令牌时保持一致X-Tenant-Id 请求头只建议给内部服务调用或未登录场景使用并且要在网关层把该请求头列入白名单。EF Core 侧可以在实体基类里统一声明 TenantId 属性在重写 SaveChangesAsync 时自动写入当前租户查询侧通过一个全局查询过滤器自动附加租户条件这样开发者写业务代码时就不会因为忘记传租户条件而漏数据。3.3 PC端与移动端共用同一套APIVue3和uniapp的请求封装前端两套壳子复用同一套后端接口关键是请求层把认证和租户信息统一带上。PC 端我用 axios 封装移动端用 uniapp 的 request 封装两边的拦截逻辑几乎一致。// Vue3 Element Plus 端请求封装 import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { config.headers[Authorization] Bearer getToken() config.headers[X-Tenant-Id] getTenantId() return config }) request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { redirectToLogin() } return Promise.reject(error) } )uniapp 端没有 axios我习惯在 common/request.js 里封装请求头一致Authorization 放登录令牌X-Tenant-Id 放当前租户ID。uviewui 的上拉加载组件配合分页参数时注意把 page、size 和动态查询条件一起塞到 data 中不要只传页码否则第二页会丢失筛选条件。移动端网络环境不稳定超时时间建议拉长到 15 秒以上并在失败时保留上一页数据而不是直接清空列表。逻辑说明这套封装多端共用的关键是后端完全不知道对面是浏览器还是手机应用只认请求头。移动端不需要单独写一套认证逻辑登录接口返回的令牌和租户信息存到 storage后续接口自动带上。这里有一个容易忽略的参数租户ID在用户切换租户时例如一个账号分属多个公司需要在前端重新请求菜单和权限不能只改请求头而继续使用旧的菜单缓存。4. 数据权限与动态API实现请求从控制器到SQL的完整链路4.1 数据权限表达式把“只看本部门”翻译成可执行的过滤条件把“本部门及以下”变成 SQL 条件常见思路是先解析角色上的 data_scope再结合当前用户所属部门生成表达式。以 EF Core 为例我会在查询执行前构造表达式树public static ExpressionFuncT, bool BuildDataScopeT( long tenantId, string dataScope, long deptId, long userId) { var p Expression.Parameter(typeof(T), o); // 租户条件是第一道隔离始终参与过滤 var tenantExpr Expression.Equal( Expression.Property(p, TenantId), Expression.Constant(tenantId)); // 按角色范围追加部门或用户条件 Expression scopeExpr dataScope switch { dept (Expression)Expression.Equal( Expression.Property(p, DeptId), Expression.Constant(deptId)), self (Expression)Expression.Equal( Expression.Property(p, CreateUserId), Expression.Constant(userId)), _ Expression.Constant(true) }; return Expression.LambdaFuncT, bool( Expression.AndAlso(tenantExpr, scopeExpr), p); }逻辑说明这个方法的输出是强类型表达式EF Core 能把它翻译成WHERE TenantId 1001 AND DeptId 2003这样的 SQL不会把整张表加载到内存再过滤。注意字段名是通过字符串反射找到的业务实体上必须真的有 TenantId、DeptId、CreateUserId 属性属性名不一致或实体里没建表达式树会在运行时抛异常这是比较隐蔽的坑。为此我会在系统启动时做一个实体元数据校验扫描所有参与数据权限的实体把不存在的字段提前暴露在日志里而不是等到用户查询时才报错。参数说明data_scope 不要写成裸字符串建议定义成常量或枚举如果需要支持“自定义部门”上面返回的 deptId 还要换成 deptIds 集合用 Contains 而不是 Equal 判定。这里最容易翻车的地方在表达式组合优先级业务条件、租户条件、数据权限条件三者必须用 AndAlso 串在一起数据权限条件不能单独替换用户传入的查询条件否则就变成越权查询。对一个系统来说最大的数据资产安全风险往往不是外部攻击而是内部越权这一层漏了后面的报表和导出都会跟着失控。4.2 动态API的元数据驱动设计用统一入口收敛生成控制器的边界动态API有两种实现路线一种是在启动时反射生成 Controller另一种是做一个统一的动态API入口。我实际生产项目里更倾向于后者因为动态生成的 Controller 会让 Swagger 文档膨胀而且在运行时注册路由会带来调试不便。统一入口的做法是所有动态实体走同一个 Controller根据 entity 参数查找元数据再分派执行[ApiController] [Route(api/dynamic)] public sealed class DynamicApiController : ControllerBase { private readonly IDynamicApiExecutor _executor; private readonly IDynamicApiAuthorizer _authorizer; public DynamicApiController( IDynamicApiExecutor executor, IDynamicApiAuthorizer authorizer) { _executor executor; _authorizer authorizer; } [HttpPost({entity}/query)] public async TaskIActionResult Query(string entity, [FromBody] DynamicQueryRequest request) { // 实体级权限统一在这里拦截防止动态API绕过菜单权限 if (!await _authorizer.CheckAsync(entity, User, DataOperation.Query)) { return Forbid(); } var result await _executor.ExecuteQueryAsync(entity, request); return Ok(result); } [HttpPost({entity})] public async TaskIActionResult Create(string entity, [FromBody] JsonElement body) { if (!await _authorizer.CheckAsync(entity, User, DataOperation.Create)) return Forbid(); return Ok(await _executor.ExecuteCreateAsync(entity, body)); } }逻辑说明这个控制器的每个动作都先过实体授权再执行动态API不等于无权限API。元数据表里登记的内容至少包括实体名、对应表名、允许的操作、可查询字段、可写字段、字段类型以及数据权限规则是否对当前实体生效。新增一张业务表时开发者只需建表并插入一行元数据执行器通过 Snapshot 的结构信息完成查询和写入。参数说明entity 名称建议用统一前缀例如ai_employee这样执行器能快速判断它属于动态API范围避免外部传入任意字符串触发非预期查询元数据表里的可查询字段需要做白名单不能把子查询、聚合函数开放给前端。这里要特别说明动态API的真正价值是收口不是无限开放。我见过把前端所有列表页都改成直接查动态API的项目结果统计报表、跨表 join、数据导出这些场景根本没法在这种通用查询里做最后还是回到普通 Controller。所以我的边界通常是单表为主、需要 CRUD、字段筛选简单的模块走动态API涉及聚合、join、事务的模块还是老老实实写专用接口。4.3 动态高级查询参数约定从UI表单到查询条件的映射动态高级查询的体验取决于前端怎么把表单条件翻译成查询参数。我常用的一套约定是字段加操作符后缀GET /api/dynamic/employee?tenant_id1001dept_id2003name_like张 hire_date_gte2024-01-01hire_date_lte2024-12-31 status_in1,2sort-hire_datepage1size20参数说明_like对应 LIKE 模糊匹配_gte/_lte对应大于等于和小于等于_in对应 IN 集合sort 字段用负号表示倒序。这套命名的好处是可以从查询字符串直接映射到查询表达式前端组件只要按约定的规则拼接即可后端也不需要为了每个模块单独写查询 DTO。需要注意的点是in 参数的多个值要约定分隔符我常用英文逗号但值本身如果可能含逗号例如名称、路径要改用 JSON 数组编码否则会出现解析错误。移动端 uviewui 的上拉加载页面我会把查询条件维护在一个响应式对象里任何筛选条件变更都重置 page 为 1加载更多时只改 page 并带上同一个查询对象。这里前端常见的错误是把 page 单独放在 data 里下拉刷新后忘了同时同步查询条件导致第一页和后续页条件不一致。后端响应中需要返回 total 和 records 两个字段移动端用 total 判断是否触底而不要依赖 records.length 小于 size 来判断。5. 避坑记录串租户、接口裸奔、OSS失控与调度重复执行以下五个坑都是我在不同项目里真实处理过的现象、原因、解决三件套写在下边对照检查比重新造轮子省时间。5.1 现象A租户的管理员看到了B租户的订单数据现象往往是某一天 A 公司的管理员反馈后台订单列表里出现了 B 公司的用户姓名单条数据可以解释为测试数据但出现多次就不是偶然了。检查时发现业务表确实都有 tenant_id但问题出在关联查询没有带租户条件订单表过滤了 TenantId而 join 出来的经办人、客户地址等表没有过滤数据串了。原因查询入口只对主表做租户过滤关联子表接的字段没有 TenantId或者 JWT 里取到的租户ID在某些接口上被手动拼成的连接字符串覆盖掉。还有一种隐蔽情况是缓存键只用了业务ID没有拼租户ID导致两个租户的核心数据落在同一个缓存条目里。解决查询入口统一做两件事一是设计一个全局查询过滤器所有业务实体自动附加 TenantId 条件二是所有缓存的 key 必须包含 TenantId 前缀。对我自己项目的兜底是加一层运行期检查查询返回的数据实体集合里如果有租户ID字段且出现与当前租户不一致的值直接抛异常并记录日志这能第一时间暴露出漏过滤的表。5.2 现象动态API不带权限校验成了绕过系统的后门动态API上线后不久就有人直接拼接/api/dynamic/employee/query的地址绕过菜单权限拉到了数据。原因很直接动态API的控制器在最初做验证时只验证登录状态没有验证实体权限把权限判断全部放在前端菜单上。菜单只是把入口藏起来动态API本身是公开路由任何人知道路径都可以调用。解决给动态API入口强制加实体级授权校验每个 entity 关联一个访问权限标识后端在执行查询前调用授权器检查当前用户是否拥有该实体的 Query 权限元数据只需提供一个可配置字段例如 permission_code需要动态API的实体把它登记成hr:employee:query。排查时有个技巧在日志里单独记录动态API的调用路径反向看访问者的行为和权限判断结果比逐条抓包高效得多。5.3 现象前端按按钮权限隐藏了入口后端接口仍能直接调用一类常见现象是界面上没有导出按钮了但有人直接请求/export接口拿走了全量数据。前端用 v-permission 指令隐藏按钮只能解决体验问题后端每个写操作、导出操作、敏感查询操作都必须重新验一遍权限。解决方法是给后端接口增加一个统一的权限过滤器在接口定义上声明 permission_code过滤器在 JWT 解析出角色后与当前用户权限集合做匹配。不要用方法名自动推断权限码权限码和接口绑定要显式写在元数据或 Attribute 上这样在审计时能直接看出来。权限审计里最需要盯的不是登录失败而是这种“登录成功但越权访问”的成功型异常。5.4 现象OSS上传不校验大小与类型一次活动把存储桶写爆有一次上线了带上传功能的活动页运营一次性传入了大量十几 MB 的原始图片对象存储的流量和容量在当天就翻了几倍。原因不是 OSS 本身不稳而是获取上传凭证的接口只返回了路径和凭证没有限制文件大小和类型前端也没有做校验。解决获取上传凭证时在服务端显式声明content-length-range例如限制最大 5MB以及允许的content-type列表同时在上传完成的回调接口里再读一次文件头信息校验扩展名与真实文件类型是否一致防止改后缀绕过。文件路径按租户ID和模块分目录也能让后续清理和传输加速策略有边界。5.5 现象任务调度在多实例部署下重复执行系统部署成多个实例后定时任务在整点同时跑了两遍结果数据库里生成了两批相同的统计记录。原因是开始用的定时器是进程内单机调度每个实例都维护自己的触发点彼此不知道对方已经执行过了。解决采用强占锁机制任务开始前在数据库或分布式锁里抢一个唯一 key抢到才执行执行完释放锁要带过期时间防止任务进程崩溃后锁不释放。任务执行单元内部还要能接收租户上下文否则同一任务在共享数据源里会跨租户处理正确做法是把 tenant_id 作为任务参数显式传递执行单元内重新初始化租户上下文。6. 落地技巧基于动态API的生成式模板三天交付一个业务模块6.1 元数据模板约定优于配置的生成式交付前面几章把底座搭好之后真正的效率提升点在于交付模板。我现在的习惯是数据库建表、登记元数据、配置菜单三步走然后前端自动生成列表页和表单页。以员工档案模块为例元数据登记长这样{ entity: ai_employee, label: 员工档案, api: { list: true, detail: true, create: true, update: true, delete: false }, query: { fields: [name, dept_id, hire_date, status], enable: true }, columns: [ { prop: name, label: 姓名 }, { prop: dept_id, label: 部门, type: tree-select }, { prop: hire_date, label: 入职日期, type: date } ] }这段配置跑起来后后端动态API直接提供列表、详情、新增、修改能力前端读取同一份 JSON 渲染表格和表单。以前做一个这样的小模块要写控制器、DTO、服务、前端页面和联调现在只需要建表、登记元数据、在菜单里挂一个指向该配置的页面。删除操作默认不开因为员工档案通常走停用而不是物理删除这个开关在配置里一眼可见。这套模板本身没有引入低代码平台底层还是 .NET9 和 Vue3 的常规代码但通过约定优于配置把大量重复页面收口。一个真实的使用习惯我把所有动态API实体的表名统一加ai_前缀启动时扫描数据库凡是以ai_开头的表自动尝试加载元数据没登记元数据的表会在日志里提示但不阻塞系统。这种设计让 AIGC 生成的建表脚本和接口描述也能直接挂进系统只要生成的表带上前缀、生成的元数据符合 JSON Schema 规范就能从一次生成直接变成可点击的菜单。6.2 上线前的三分钟自检最后一件事是验证方法不要只看界面通不通要看逻辑删是否生效、权限码是否真的拦截住了越权调用、多租户下两个租户的数据是否彼此不可见。我的三分钟自检顺序是先切到租户账号 A确认看不到租户 B 的数据再用一个只读角色调用写接口确认 403最后直接构造动态API地址确认实体权限拦截生效。这三条过了再开始做页面验收。权限系统这条路没有捷径但把元数据和动态API用好确实能让团队从改接口的循环里抽身。我现在每交付一个模块都先走一遍这个检查链路再写业务代码省下来的返工时间远大于那三分钟。希望帮到你。本文还有配套的精品资源点击获取