Skip to content

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 是业务底座"),但它是编译期事实。 两个后果:

  1. system 里可以直接 @Resource 注入 infra 的 XxxApi(FormDesignApi、DataModelApi 等), infra 里不能注入 system 的任何东西。
  2. 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字典类型 + 字典数据上游
ipIP 归属地查询上游
logger登录日志、操作日志上游
mail邮箱账号 / 模板 / 日志上游
notice站内公告上游
notify站内信模板 / 消息上游
oauth2OAuth2 客户端 / 令牌上游
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):

java
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 与注解核对过):

表DOextends@TenantIgnore实际口径
sys_resourceResourceDOBaseDO✅全局(跨租户共享)
sys_ruleRuleDOBaseDO✅全局
sys_slot_mappingSlotMappingDOBaseDO✅全局
sys_grantGrantDOTenantBaseDO❌分租户
sys_subjectSubjectDOTenantBaseDO❌分租户
sys_subject_memberSubjectMemberDOTenantBaseDO❌分租户
sys_record_shareRecordShareDOTenantBaseDO❌分租户

为什么这么切,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 的分布是有讲究的:

行条件返回失效方向
:167tableName 空白pass()🔴 fail-open
:172userId == nullpass()🔴 fail-open(源码原话见下)
:181取主体闭包抛异常denyAll()fail-closed
:184主体闭包为空denyAll()fail-closed
:189是超管pass()设计如此
:195这张表没有对应的数据集资源pass()设计如此(按需创建,默认 ALLOW)
:211有资源但没有任何授权命中defaultEffectOf(resources)看资源的 default_effect
:215命中的授权一条规则都没绑pass()设计如此
:246ALLOW/DENY 授权指向的规则不存在denyAll()fail-closed
:264DENY 规则求不出条件denyAll()fail-closed
:275ALLOW 规则求不出条件不 return,log.warn 后继续见 handleUnevaluableRule
:313 / :316defaultEffectOf 内部denyAll() / pass()—

第 :172 行那处 fail-open,源码注释逐字是(:169-170):

java
// 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> 直接登录成那个用户。 三个事实凑在一起才看得清它当初的风险:

  1. enlev-framework/enlev-spring-boot-starter-security/.../config/SecurityProperties.java:34private Boolean mockEnable = false; —— 代码默认是关的。
  2. 同文件 :39-40:
    java
    @NotEmpty(message = "mock 模式的密钥不能为空") // 这里设置了一个默认值,因为实际上只有 mockEnable 为 true 时才需要配置。
    private String mockSecret = "test";
    —— 密钥默认值是字面量 test,而且它不在任何配置文件里,改它只能靠覆盖 enlev.security.mock-secret。
  3. enlev-server/src/main/resources/application-prod.yaml:
    yaml
    enlev:
      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 就是开关又被打开了。

bash
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)逐字是:

java
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/PermissionCommonApisysteminfra(表单运行时裁字段)+ enlev-spring-boot-starter-biz-data-permission行过滤、字段权限、数据集字段模式
biz/system/permission/ProcessResourceCommonApisystembpm流程资源
biz/bpm/BpmParticipantCommonApibpmsystem(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 的同口径值)。

接口清单 ​

维度实测值
Controller45(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/。

怎么扩展 ​

加一个子域(比如新增一类通知渠道) ​

  1. enums/ErrorCodeConstants.java 里接着现有号段往后加,别插号 —— 插号会让已发布的错误码含义漂移。
  2. dal/dataobject/<子域>/XxxDO.java:先决定租户口径(见上面第 ⑤ 步), 分租户就 extends TenantBaseDO,全局就 extends BaseDO + 显式打 @TenantIgnore。 两者都不做 = 静默查不到数据。
  3. dal/mysql/<子域>/XxxMapper.java extends BaseMapperX<XxxDO>。
  4. service/<子域>/ 接口 + Impl;Impl 上的 @Validated 别漏。
  5. controller/admin/<子域>/ Controller + vo/ 下的 ReqVO/RespVO。
  6. convert/<子域>/XxxConvert.java(MapStruct)。
  7. 建表 SQL 追加到 sql/mysql/ainb-init.sql,并在 sql/mysql/ 下另存一份增量脚本。
  8. 菜单 + 权限按钮: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(改了不报错,但结果是错的)

  1. 🔴 extends BaseDO 不等于全局表。没打 @TenantIgnore 的普通 BaseDO 仍会被追加 tenant_id = ?, 而它没有这个字段 ⟹ 恒查不到行、不报错。见 TenantDatabaseInterceptor 第 ⑤ 步。
  2. 🔴 application-prod.yaml 的 mock-enable 曾长期是 true ⟹ 生产环境 Bearer test1 = 超管 userId 1, 租户从请求头任选,全程无日志。已修(三个 profile 现为 false),但代码路径一行没删, 开关一打开就立刻复活——所以它仍留在「静默 fail-open」这一类里。见本页上面的「免登录调试的 mock token」一节。
  3. 🔴 DataRowFilterResolver:171-173 在 userId == null 时 pass()(放行且不裁)。 对定时任务是对的,对 AI 工具执行是错的。
  4. sys_resource / sys_rule / sys_slot_mapping 的 tenant_id 列是死列, DDL 里有、代码不读不写,恒为 0。看 DDL 判租户口径必错。
  5. system_sms_code 靠代码手写 tenant_id 条件做隔离,拦截器被 @TenantIgnore 关掉了。 改它的查询时漏掉手写条件 = 跨租户读验证码。
  6. getParticipatedBusinessKeys 返回空集合 ≠ 返回 null。 空集合必须过滤成「一行都看不到」;把它当「无需过滤」= 全表可见。
  7. resolve 在 tableName 空白时也 pass()(:167)。调用方传错表名不会报错,只会静默不裁。
  8. AgentArchitectureTest:264 引用的 DataRowFilterResolver:144-146 行号已失效(实际 :171-173)。 结论对、行号错 —— 别拿注释里的行号当权威。
  9. socail 是上游的拼写错误(应为 social)。目录名、包名、类名里都是这个拼法, 改了会连带一片 import 与前端路径,收益为零。照抄。

fail-closed(会明确报错或明确拒绝)

  1. DataRowFilterResolver:181 取主体闭包抛异常 → denyAll()。 源码注释:「把异常变成『不过滤』等于把异常变成越权」。
  2. :184 主体闭包为空 → denyAll()。
  3. :246 授权指向的规则不存在 → denyAll() + log.error。
  4. :264 DENY 规则求不出条件 → denyAll()。 (对比 :275:ALLOW 规则求不出条件只 warn 不 return —— DENY 求不出就往严里判、ALLOW 求不出就往宽里判,这个不对称是有意的。)
  5. BpmModelServiceImpl#validateScopeWritable:只持有 DESIGN 权的人改 startUserIds / startDeptIds / managerUserIds → 抛 MODEL_UPDATE_FAIL_NOT_MANAGER_FOR_SCOPE。 源码注释逐字说明了为什么必须硬拦而不是静默保留旧值: 「静默丢弃的结果是界面显示已保存、实际没保存,比报错更糟」。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

维度实测值
测试文件83
@Test 方法1000
@Disabled8
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 与前端路径」—— 我没有真的数过引用点,是按改名影响面推的。

相关页面 ​