system 域开发
适用端:后端 | 类型:参考
上游模块,但被改得最重
enlev-module-system 来自上游 yudao,负责认证、组织、租户、字典、通知这些平台底座。 但本项目的权限体系 v9(资源 / 规则 / 授权 / 主体 / 行过滤)整套是自研的, 而它就落在 system 里 —— 7 张 sys_* 表、service/permission/ 一整包。 所以这一页的读法和纯上游模块不同:先分清哪部分是你不能乱动的底座、哪部分是本项目自己的东西。
判断办法很简单,看 @author:写着「芋道源码」的是上游,写着「走进新时代」的是本项目。
定位:说清 system 域的边界、租户口径、行权限求值的 fail-open 面,以及三条容易被踩的底座约定。
前置阅读:数据库约定与表总览(BaseDO / TenantBaseDO / 逻辑删除)、 权限体系总览(本页反复引用它的三层语义)、 后端架构与模块依赖。
它解决什么问题
system 域回答三个问题:你是谁(认证 / OAuth2 / 租户)、你在哪个组织里(用户 / 部门 / 岗位 / 角色)、 你能做什么(菜单权限 + 本项目自研的资源 / 规则 / 授权)。
🔴 依赖方向是 system → infra,不是反过来
enlev-module-system/pom.xml:23 明确依赖 enlev-module-infra;infra 的 pom 里没有 system。
这一点反直觉("infra 是基础设施,system 是业务底座"),但它是编译期事实。 两个后果:
- system 里可以直接
@Resource注入 infra 的XxxApi(FormDesignApi、DataModelApi等), infra 里不能注入 system 的任何东西。 - infra 要用 system 的能力(行权限、字段权限),只能走接口反转: 接口定义在
enlev-framework/enlev-common,system 实现、infra 用ObjectProvider取。 见下面「跨模块通道」。
写代码前如果搞反了方向,症状是循环依赖导致 Spring 启动失败,不是编译错误 —— 排查成本更高。
代码在哪
enlev-module-system/src/main/java/cn/enlev/ainb/module/system/
├── api/ # 给别的模块用的接口实现(PermissionApiImpl、DeptApiImpl…)
├── controller/
│ ├── admin/ # 42 个 Controller,前缀 /admin-api/system/**(含 ★ NavNodeController)
│ └── app/ # 3 个 Controller,前缀 /app-api/system/**
├── convert/ # MapStruct 转换器
├── dal/{dataobject,mysql}/
├── enums/ # ErrorCodeConstants(180 条)+ 各子域枚举
├── framework/ # 本模块自己的横切(datapermission、web、sms…)
├── job/ # 定时任务
├── mq/ # 消息生产/消费
├── service/ # 业务实现,按子域分包
└── util/16 个子域(controller/admin/ 下的一级目录,逐个 ls 数出来的):
| 子域 | 干什么 | 归属 |
|---|---|---|
auth | 登录 / 登出 / 刷新 token / 社交登录回调 | 上游 |
captcha | 图形验证码 | 上游 |
dept | 部门树 | 上游 |
dict | 字典类型 + 字典数据 | 上游 |
ip | IP 归属地查询 | 上游 |
logger | 登录日志、操作日志 | 上游 |
mail | 邮箱账号 / 模板 / 日志 | 上游 |
notice | 站内公告 | 上游 |
notify | 站内信模板 / 消息 | 上游 |
oauth2 | OAuth2 客户端 / 令牌 | 上游 |
permission | 菜单 + 角色 + 本项目自研的资源/规则/授权/主体/行过滤 | 混合,见下 |
portal | 门户 | 上游 |
sms | 短信渠道 / 模板 / 验证码 / 日志 | 上游 |
socail | 社交用户绑定(⚠️ 上游把 social 拼成了 socail,目录名照抄别改) | 上游 |
tenant | 租户 + 租户套餐 | 上游 |
user | 用户 + 个人中心 | 上游 |
permission 子域里,MenuDO / RoleDO / UserRoleDO / DeptRole* 是上游的; ResourceDO / RuleDO / SlotMappingDO / GrantDO / SubjectDO / SubjectMemberDO / RecordShareDO (对应 7 张 sys_* 表)以及 DataRowFilterResolver、GrantDecisionService、SubjectClosureService 是本项目自研的,详见权限体系开发那一组。
核心机制
多租户在 DB 层怎么落地:五步判定
真正的开关不在 system 模块里,而在 enlev-framework/enlev-spring-boot-starter-biz-tenant/.../core/db/TenantDatabaseInterceptor.java。 它实现 MyBatis-Plus 的 TenantLineHandler,ignoreTable(String) 决定这张表要不要被自动追加 tenant_id = ?。 判定顺序逐字如下(:46-81):
public boolean ignoreTable(String tableName) {
// 情况一,全局忽略多租户
if (TenantContextHolder.isIgnore()) { return true; }
// 情况二,忽略多租户的表
tableName = SqlParserUtils.removeWrapperSymbol(tableName);
Boolean ignore = ignoreTables.get(tableName.toLowerCase());
if (ignore == null) {
ignore = computeIgnoreTable(tableName);
synchronized (ignoreTables) { addIgnoreTable(tableName, ignore); }
}
return ignore;
}
private boolean computeIgnoreTable(String tableName) {
// 找不到的表,说明不是 enlev 项目里的,不进行拦截(忽略租户)
TableInfo tableInfo = TableInfoHelper.getTableInfo(tableName);
if (tableInfo == null) { return true; }
// 如果继承了 TenantBaseDO 基类,显然不忽略租户
if (TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) { return false; }
// 如果添加了 @TenantIgnore 注解,则忽略租户
TenantIgnore tenantIgnore = tableInfo.getEntityType().getAnnotation(TenantIgnore.class);
return tenantIgnore != null;
}拆成五步读:
| 步 | 判据 | 结论 |
|---|---|---|
| ① | TenantContextHolder.isIgnore()(TenantUtils.executeIgnore 或 @TenantIgnore AOP 设过) | 忽略 |
| ② | 表名在 enlev.tenant.ignore-tables 配置里 | 忽略 |
| ③ | TableInfoHelper.getTableInfo() 返回 null(不是本项目的 MyBatis 实体,比如 Flowable 的 ACT_*) | 忽略 |
| ④ | 实体 extends TenantBaseDO | 不忽略(要过滤) |
| ⑤ | 实体上有 @TenantIgnore → 忽略;没有 → 不忽略 | 见下 |
🔴 第 ⑤ 步是反直觉的那一步:extends BaseDO 不等于「全局表」
最后那行 return tenantIgnore != null; 的意思是——一个既没继承 TenantBaseDO、也没打 @TenantIgnore 的普通 BaseDO,仍然会被追加 tenant_id = ?。
而普通 BaseDO 没有 tenantId 字段,所以:
- 查询会被加上
WHERE tenant_id = <当前租户>,但表里那一列(如果 DDL 里有)永远是DEFAULT 0; - 结果就是一行都查不到,而且不报错。
这是本项目真踩过的坑,ResourceDO 的类注释里逐字记着(:42-45):
⚠️ 这一点踩过一次坑:本类最初继承 TenantBaseDO,而资源行是启动期迁移进来的 (tenant_id = 0)。于是租户 1 的用户查资源时带上
tenant_id = 1, 一行都查不到 —— 所有@ss.hasPermission对非超管用户恒为 false, 且没有任何报错。表里 tenant_id 列保留但不再参与读写。
⟹ 新建 DO 时,「不分租户」必须显式写 @TenantIgnore,不能靠「我没继承 TenantBaseDO」来表达。
7 张 sys_* 表的租户口径是不对称的
这是最容易看错的一处。7 张表在 DDL 里全都有 tenant_id bigint NOT NULL DEFAULT 0, 但 DO 层的口径分成两组(逐个文件读 extends 与注解核对过):
| 表 | DO | extends | @TenantIgnore | 实际口径 |
|---|---|---|---|---|
sys_resource | ResourceDO | BaseDO | ✅ | 全局(跨租户共享) |
sys_rule | RuleDO | BaseDO | ✅ | 全局 |
sys_slot_mapping | SlotMappingDO | BaseDO | ✅ | 全局 |
sys_grant | GrantDO | TenantBaseDO | ❌ | 分租户 |
sys_subject | SubjectDO | TenantBaseDO | ❌ | 分租户 |
sys_subject_member | SubjectMemberDO | TenantBaseDO | ❌ | 分租户 |
sys_record_share | RecordShareDO | TenantBaseDO | ❌ | 分租户 |
为什么这么切,ResourceDO:36-40 说得清楚:
全局表,不分租户(与 system_menu 对齐)
资源回答的是「这套系统里存在哪些可授权对象」,这与租户无关 —— system_menu 本来就是
@TenantIgnore的,权限侧从它拆出来自然也该是。 租户差异体现在sys_grant(谁被授予了什么),那张表是分租户的。
三张全局表里的 tenant_id 列是死列
sys_resource / sys_rule / sys_slot_mapping 的 DDL 里 tenant_id 还在,但 DO 不继承 TenantBaseDO ⟹ 没有 tenantId 字段 ⟹ 既不读也不写,所有行恒为 DEFAULT 0。
看到列存在就以为有隔离,是这一处最典型的误判。要判断真实口径,只看 DO 的 extends 和注解,不看 DDL。
(ResourceDO:45 的原话是「表里 tenant_id 列保留但不再参与读写」—— 保留是为了不动存量数据,不是为了以后用。)
顺带一条同形的上游遗留:system_sms_code 是唯一一张既有 tenant_id 列、DO 又打了 @TenantIgnore 的上游表 (SmsCodeDO:@TableName("system_sms_code") + extends BaseDO + @TenantIgnore,DDL 里 tenant_id 存在)。 它靠代码里手动带 tenant_id 条件来做隔离,而不是靠拦截器。改它的时候别以为拦截器会兜住。
行权限求值:DataRowFilterResolver 的 pass / denyAll 分布
service/permission/DataRowFilterResolver.java(778 行)是行级过滤的唯一求值器。 它的 resolve(...) 有两个重载(:149 三参 → :164 四参),返回 DataRowFilterRespDTO, 只有三种形态:pass()(不追加过滤)、denyAll()(一行都看不到)、of(sql, params)(追加条件)。
把全部返回点列出来,fail-open 与 fail-closed 的分布是有讲究的:
| 行 | 条件 | 返回 | 失效方向 |
|---|---|---|---|
:167 | tableName 空白 | pass() | 🔴 fail-open |
:172 | userId == null | pass() | 🔴 fail-open(源码原话见下) |
:181 | 取主体闭包抛异常 | denyAll() | fail-closed |
:184 | 主体闭包为空 | denyAll() | fail-closed |
:189 | 是超管 | pass() | 设计如此 |
:195 | 这张表没有对应的数据集资源 | pass() | 设计如此(按需创建,默认 ALLOW) |
:211 | 有资源但没有任何授权命中 | defaultEffectOf(resources) | 看资源的 default_effect |
:215 | 命中的授权一条规则都没绑 | pass() | 设计如此 |
:246 | ALLOW/DENY 授权指向的规则不存在 | denyAll() | fail-closed |
:264 | DENY 规则求不出条件 | denyAll() | fail-closed |
:275 | ALLOW 规则求不出条件 | 不 return,log.warn 后继续 | 见 handleUnevaluableRule |
:313 / :316 | defaultEffectOf 内部 | denyAll() / pass() | — |
第 :172 行那处 fail-open,源码注释逐字是(:169-170):
// 1. 没有登录用户:交给上层决定拒绝与否,这里不追加过滤。
// 定时任务、MQ 消费者这类无人上下文会走到这里
if (userId == null) {
return DataRowFilterRespDTO.pass();
}🔴 这个前提对 AI 不成立
「无人上下文 = 定时任务 / MQ 消费者 = 本来就该看全部」对人类调用方是对的。 但 agent 模块的工具执行恰恰是「有真实用户、却可能拿不到 userId」(预创建的线程池上 ThreadLocal 为 null)。 一旦落到这条分支,行权限放行且不裁,而 AI 的执行结果是要流向用户的。
所以 agent 侧自己加了一道网关兜住 —— 见 安全与提示注入防护 · 权限网关。 这不是重复校验,是补一个上游没考虑到的前提。
同类还有一处:PermissionFieldHelper#resolve(字段权限)在拿不到 userId 时也是放行且不裁。
AgentArchitectureTest 里引用的行号已经失效
enlev-module-agent/src/test/java/.../framework/AgentArchitectureTest.java:264 的注释写着 「行权限在拿不到 userId 时是放行的(DataRowFilterResolver:144-146)」。 实测 :144 现在是 javadoc 的 @param userId 行,真正的 userId == null → pass() 在 :171-173。
结论没错,行号错了。这正说明注释里的行号不能当权威 —— 本项目开发手册的通篇做法是「文件路径 + 类名 + 方法名」,行号只用来标关键锚点,就是这个原因。
免登录调试的 mock token:生产配置里曾经开着,现已关闭
上游给了一个开发期便利:Authorization: Bearer <mockSecret><userId> 直接登录成那个用户。 三个事实凑在一起才看得清它当初的风险:
enlev-framework/enlev-spring-boot-starter-security/.../config/SecurityProperties.java:34private Boolean mockEnable = false;—— 代码默认是关的。- 同文件
:39-40:java—— 密钥默认值是字面量@NotEmpty(message = "mock 模式的密钥不能为空") // 这里设置了一个默认值,因为实际上只有 mockEnable 为 true 时才需要配置。 private String mockSecret = "test";test,而且它不在任何配置文件里,改它只能靠覆盖enlev.security.mock-secret。 enlev-server/src/main/resources/application-prod.yaml:yamlenlev: captcha: enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试; security: mock-enable: false # ← 现在是 false;本页上一版记录时这里是 true
✅ 已修:三个 profile 现在都是 mock-enable: false
476b4b2637 fix(平台):修掉录制实录视频时挖出的后端缺陷,含 prod 的 mock-enable 提权口子(2026-10-18)把 application-local.yaml:246、application-dev.yaml:247、application-prod.yaml:248 三处全部改成了 false, 并在 local 里留了注释说明为什么本地也关(「本地服务常被局域网内其他机器访问」)。 实测 2026-10-25:三处均为 false,mock-secret 仍未显式配置(走默认值 "test",但开关关着所以用不上)。
验证方法:对着跑着的服务发下面这条,必须返回 code: 401;返回 code: 0 且拿到 admin 就是开关又被打开了。
curl -s -H "Authorization: Bearer test1" -H "tenant-id: 1" \
http://127.0.0.1:48080/admin-api/system/user/profile/get代码路径一行没动,所以下面这段仍然是「开关一旦打开会发生什么」的权威描述。 生效路径在 TokenAuthenticationFilter::50-53 先试真 token,返回 null 才落到 mock; mockLoginUser(:105-119)逐字是:
private LoginUser mockLoginUser(HttpServletRequest request, String token, Integer userType) {
if (!securityProperties.getMockEnable()) { return null; }
// 必须以 mockSecret 开头
if (!token.startsWith(securityProperties.getMockSecret())) { return null; }
// 构建模拟用户
Long userId = Long.valueOf(token.substring(securityProperties.getMockSecret().length()));
return new LoginUser().setId(userId).setUserType(userType)
.setTenantId(WebFrameworkUtils.getTenantId(request));
}合起来的后果:开关打开且没配 mock-secret 时,Authorization: Bearer test1 就是 userId=1(超管), 而 tenantId 是从请求头取的(WebFrameworkUtils.getTenantId(request))⟹ 租户也可以随便挑。 整条路径不打日志,TokenAuthenticationFilter 自己的 javadoc(:98)写着 「注意,在线上环境下,一定要关闭该功能!!!」—— 当初注释和配置是矛盾的,现在对齐了。
失效方向:fail-open,且完全静默。这也是为什么它值得单独记一节: mockEnable 的代码默认值是安全的(false),风险完全来自配置文件覆盖, 而配置文件是复制粘贴传播的(prod 那份连「本地环境」的注释都是从 local 抄来的,见 A4)。 🔴 所以新增 profile 或复制 yaml 时,这一行必须逐个核,不能指望默认值兜底。
跨模块通道:两条方向相反的接口反转
system 与它的两个邻居都通过 enlev-framework/enlev-common 里的接口通信, 但方向正好相反,这是本项目的固定套路:
接口(都在 enlev-common) | 谁实现 | 谁取用 | 干什么 |
|---|---|---|---|
biz/system/permission/PermissionCommonApi | system | infra(表单运行时裁字段)+ enlev-spring-boot-starter-biz-data-permission | 行过滤、字段权限、数据集字段模式 |
biz/system/permission/ProcessResourceCommonApi | system | bpm | 流程资源 |
biz/bpm/BpmParticipantCommonApi | bpm | system(DataRowFilterResolver 的派生可见性) | 查某人参与过哪些流程业务主键 |
BpmParticipantCommonApi 的类注释把这条路数讲得最清楚(逐字):
派生可见性(v9 §4.3)要回答的是:「这个人参与过哪些流程, 因此该看到业务表的哪几行」。问题在于两头分属不同模块 —— 参与关系在
enlev-module-bpm,而行级过滤的求值在enlev-module-system,而 system 不依赖 bpm。所以接口定义在 common,由 bpm 实现、system 通过
ObjectProvider取用。 与PermissionCommonApi(system 实现、infra 取用)是同一条路数,方向相反而已。
🔴 getParticipatedBusinessKeys 的空集合与 null 含义不同
接口 javadoc 逐字:
- 空集合 —— 查到了,但他一条都没参与过。 调用方应当据此过滤成「一行都看不到」,而不是不过滤
- null —— 查不了(模块不可用)。调用方自行决定回落策略
这个区分是必须的:把「没参与过任何流程」误当成「无需过滤」, 结果就是给了他全表可见 —— 配了规则反而比不配还宽
实现方在 DataRowFilterResolver#evaluateBpmParticipated(:438 附近), 拿到集合后拼成 col IN (...)。这里还有一个 SQL 层的陷阱: col IN (NULL) 恒不成立,所以「null 元素」和「空集合」是两回事,别把 null 塞进集合里当占位。
数据模型
41 张表,前缀分两组(从 @TableName 逐个提出来数的,不是估的):
| 前缀 | 张数 | 内容 |
|---|---|---|
system_* | 34 | 上游:用户 / 部门 / 岗位 / 角色 / 菜单 / 字典 / 租户 / OAuth2 / 短信 / 邮件 / 站内信 / 日志… + ★ system_nav_node(分端导航树,本项目自研但沿用了 system_ 前缀) |
sys_* | 7 | 本项目自研权限体系:sys_resource、sys_rule、sys_slot_mapping、sys_grant、sys_subject、sys_subject_member、sys_record_share |
错误码 180 条,全在 enums/ErrorCodeConstants.java。
为什么是 sys_ 而不是 system_
两套前缀共存是刻意的:system_* 是上游的、跟着上游升级走;sys_* 是本项目的、独立演进。 混用前缀会让「这张表能不能被上游更新脚本覆盖」变成需要逐张查的问题。 ⟹ 新增自研表沿用 sys_,不要图省事写 system_。
system_nav_node 是这条约定的唯一例外
它是本项目自研的,却挂在 system_ 前缀下(NavNodeDO 的 @TableName("system_nav_node"))。 后果很实际:判断「这张表会不会被上游更新脚本覆盖」时,不能只看前缀就把它归到上游那一边。 本页上一版写的是「40 张表 / system_* 33 张」,差额就是它。
菜单侧的实测口径(直接查 system_menu 表,不是读 SQL 文件): permission like 'system:%' 且 deleted = 0 的共 112 条,全部 status = 0(开启),没有一条被关掉; 只数按钮(type = 3)则是 106 条。库为 ainb-professional-init(上一版记的 108 是旧库 ruoyi-vue-pro 的同口径值)。
接口清单
| 维度 | 实测值 |
|---|---|
| Controller | 45(controller/admin/ 42 + controller/app/ 3) |
| 端点 | 269 |
| 路径前缀 | /admin-api/system/**、/app-api/system/** |
本页上一版记的是 44 / 261。差的那 1 个 Controller 与 8 个端点是 ★ NavNodeController (controller/admin/permission/NavNodeController.java,基路径 /system/nav-node), 由 bf82d570b2「新增分端导航树 system_nav_node」引入——即上面「数据模型」里那张新表的服务端入口。
逐条清单不在这里维护 —— 用 tools/gen-api.mjs 生成,产物在 docs/.generated/api-system.md(生成脚本的口径说明见接口约定与 CRUD 骨架)。
🔴 数 Controller 别用 grep -rl "@RestController"——会多数一个
这么数出来是 46,多出的那个是 framework/permission/core/scan/PermissionAnnotationScanner.java:42,它的注释里写着 「只扫描有 @RestController 或 @RequestMapping 的 Bean,减少无效遍历」—— 它自己是个 @Component,不是 Controller。(本页上一版把路径写成 framework/permission/PermissionAnnotationScanner.java,少了 core/scan/ 两级,照着找会找不到。)
同理,数端点时 grep -o '@GetMapping' 不加行首锚定会命中注释掉的代码: infra 域这么数出 223,加 ^\s* 锚定后是 219,与生成脚本一致;差的 4 条是 controller/admin/datamodel/DataModelDataController.java:72,81,93,104 四行被 // 注掉的 @PostMapping / @PutMapping / @DeleteMapping。
口径以 tools/gen-api.mjs 为准(它按「注解挂在能解析出的方法签名上」计数), 不要用裸 grep 的数字。
.generated/ 里的清单可能滞后
它是脚本产物,只在跑过 gen-api.mjs 之后才是最新的。 引用具体端点前,先重跑一次生成,或者直接 grep 源码。 本手册正文里出现的所有数字都是当次实测的,不抄 .generated/。
怎么扩展
加一个子域(比如新增一类通知渠道)
enums/ErrorCodeConstants.java里接着现有号段往后加,别插号 —— 插号会让已发布的错误码含义漂移。dal/dataobject/<子域>/XxxDO.java:先决定租户口径(见上面第 ⑤ 步), 分租户就extends TenantBaseDO,全局就extends BaseDO+ 显式打@TenantIgnore。 两者都不做 = 静默查不到数据。dal/mysql/<子域>/XxxMapper.javaextends BaseMapperX<XxxDO>。service/<子域>/接口 + Impl;Impl 上的@Validated别漏。controller/admin/<子域>/Controller +vo/下的 ReqVO/RespVO。convert/<子域>/XxxConvert.java(MapStruct)。- 建表 SQL 追加到
sql/mysql/ainb-init.sql,并在sql/mysql/下另存一份增量脚本。 - 菜单 + 权限按钮:
system_menu插行,type=2 是菜单、type=3 是按钮,按钮的permission就是@PreAuthorize("@ss.hasPermission('...')")里那个串。
🔴 增量 SQL 脚本要真的存在
代码注释里引用的 sql/mysql/xpy-*.sql 中,实测有 10 个文件在仓库里不存在 (sql/mysql/ 下只有 6 个文件:ai-2026-04-16.sql、ainb-init.sql、bpm-2026-04-18.sql、 im-2026-06-20.sql、quartz.sql、ruoyi-vue-pro.sql)。
例如 BillTemplateDO.java:20 注释里写着「见 sql/mysql/xpy-2026-09-20-bill-template-tenant.sql」—— 那个文件不在仓库里。这些脚本当初执行过、效果已经并进 ainb-init.sql,但文件本身没入库。
后果:新环境只导 ainb-init.sql 是对的;但照着注释去找增量脚本会找不到, 而且无法判断某条列到底是哪次改动加的。⟹ 以后写增量脚本,要么入库、要么别在注释里点名。
加一个权限资源点
见权限资源那一页,这里只提 system 侧的落点: 资源行在 sys_resource(全局表),授权行在 sys_grant(分租户)。 「资源存在」与「谁被授予」是两张表、两种租户口径,不要合并。
坑与注意
静默 / fail-open(改了不报错,但结果是错的)
- 🔴
extends BaseDO不等于全局表。没打@TenantIgnore的普通BaseDO仍会被追加tenant_id = ?, 而它没有这个字段 ⟹ 恒查不到行、不报错。见TenantDatabaseInterceptor第 ⑤ 步。 - 🔴
application-prod.yaml的mock-enable曾长期是true⟹ 生产环境Bearer test1= 超管 userId 1, 租户从请求头任选,全程无日志。已修(三个 profile 现为false),但代码路径一行没删, 开关一打开就立刻复活——所以它仍留在「静默 fail-open」这一类里。见本页上面的「免登录调试的 mock token」一节。 - 🔴
DataRowFilterResolver:171-173在userId == null时pass()(放行且不裁)。 对定时任务是对的,对 AI 工具执行是错的。 sys_resource/sys_rule/sys_slot_mapping的tenant_id列是死列, DDL 里有、代码不读不写,恒为 0。看 DDL 判租户口径必错。system_sms_code靠代码手写tenant_id条件做隔离,拦截器被@TenantIgnore关掉了。 改它的查询时漏掉手写条件 = 跨租户读验证码。getParticipatedBusinessKeys返回空集合 ≠ 返回 null。 空集合必须过滤成「一行都看不到」;把它当「无需过滤」= 全表可见。resolve在tableName空白时也pass()(:167)。调用方传错表名不会报错,只会静默不裁。AgentArchitectureTest:264引用的DataRowFilterResolver:144-146行号已失效(实际:171-173)。 结论对、行号错 —— 别拿注释里的行号当权威。socail是上游的拼写错误(应为social)。目录名、包名、类名里都是这个拼法, 改了会连带一片 import 与前端路径,收益为零。照抄。
fail-closed(会明确报错或明确拒绝)
DataRowFilterResolver:181取主体闭包抛异常 →denyAll()。 源码注释:「把异常变成『不过滤』等于把异常变成越权」。:184主体闭包为空 →denyAll()。:246授权指向的规则不存在 →denyAll()+log.error。:264DENY 规则求不出条件 →denyAll()。 (对比:275:ALLOW 规则求不出条件只warn不 return —— DENY 求不出就往严里判、ALLOW 求不出就往宽里判,这个不对称是有意的。)BpmModelServiceImpl#validateScopeWritable:只持有 DESIGN 权的人改startUserIds/startDeptIds/managerUserIds→ 抛MODEL_UPDATE_FAIL_NOT_MANAGER_FOR_SCOPE。 源码注释逐字说明了为什么必须硬拦而不是静默保留旧值: 「静默丢弃的结果是界面显示已保存、实际没保存,比报错更糟」。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
| 维度 | 实测值 |
|---|---|
| 测试文件 | 83 |
@Test 方法 | 1000 |
@Disabled | 8 |
src/test/resources/application-unit-test.yaml | ✅ 存在 |
application-unit-test.yaml 存在意味着 system 域可以写 DB 单测(BaseDbUnitTest,H2,profile unit-test)。 全仓只有 bpm / im / infra / system 四个模块有这份配置 —— agent 与 ai 都没有,所以那两个模块的测试绝大多数是纯 Mockito。 要在 system 里加 DB 测试,直接 extends BaseDbUnitTest,并确认 /sql/clean.sql 里加了你那张表的清理语句。
未证实
读源码读到、但没跑起来验证的
- 「生产环境
Bearer test1真的能登进超管」—— 我是把SecurityProperties:34/:40+application-prod.yaml里当时那行mock-enable: true+TokenAuthenticationFilter:50-53/:105-119四处读通得出的,没有真的用 prod profile 启动服务打过这个请求。 中间可能还有网关、Nginx 或部署脚本覆盖enlev.security.mock-enable,那一层我没查。 - 「
system_sms_code靠代码手写tenant_id条件」—— 我确认了 DO 上的@TenantIgnore与 DDL 里的tenant_id列, 但没有逐个读SmsCodeMapper的每条查询确认它们都手写了条件。 - 「108 条
system:*权限按钮全部开启」—— 查的是本地库ruoyi-vue-pro的system_menu表, 不是生产库。别的环境状态可能不同。 🔴 这个数已经过期:口径是「permission like 'system:%'且deleted = 0,不限type」, 旧库ruoyi-vue-pro给 108,而现在后端实际连的ainb-professional-init给 112(同样全部status = 0); 若只数按钮(type = 3)则是旧库 103 / 新库 106。差额来自 nav-node 与菜单拆分那几批新增行。 代码里去重后的system:*权限串是 104 个,与库里的 106 / 112 都不相等 —— 库里有代码不再引用的历史行,别拿任一侧当权威。 - 41 张表里我只逐个核对了 7 张
sys_*的租户口径;34 张system_*(含 ★system_nav_node)是按前缀统计的,没有逐张验。
推理得出、没有取证的
- 「新增自研表应沿用
sys_前缀」—— 这是我从「两套前缀共存 +sys_*全是本项目自研」 归纳出来的约定,源码里没有任何一处写下这条规则。可能只是历史巧合。 - 「
ErrorCodeConstants别插号」—— 通用工程实践,不是本仓库明文规定。 - 「改
socail拼写会连带一片 import 与前端路径」—— 我没有真的数过引用点,是按改名影响面推的。
相关页面
- 权限体系总览 ——
sys_*七张表与三层权限的完整设计 - 行过滤规则 ——
DataRowFilterResolver的规则语法与求值 - 授权与决策引擎 ——
GrantDecisionService与主体闭包 - infra 域开发 —— 依赖方向的另一端,以及
PermissionCommonApi的取用方 - bpm 域开发 ——
BpmParticipantCommonApi的实现方 - 数据库约定与表总览 ——
BaseDO/TenantBaseDO/ 逻辑删除 / 主键策略 - 接口约定与 CRUD 骨架 —— 以「岗位」为样本的完整分层
- 测试与质量 —— 全仓测试口径与
BaseDbUnitTest - 坑位索引 —— 全仓坑位汇总
