简介:这份资源是一套面向 .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.GetRequiredService<ITenantService>(); 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端与移动端共用同一套API:Vue3和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 Expression<Func<T, bool>> BuildDataScope<T>( 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.Lambda<Func<T, 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 Task<IActionResult> 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 Task<IActionResult> 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_id=1001&dept_id=2003&name_like=张 &hire_date_gte=2024-01-01&hire_date_lte=2024-12-31 &status_in=1,2&sort=-hire_date&page=1&size=20参数说明:_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用好,确实能让团队从改接口的循环里抽身。我现在每交付一个模块都先走一遍这个检查链路,再写业务代码,省下来的返工时间远大于那三分钟。希望帮到你。
本文还有配套的精品资源,点击获取