fix(授权): 父级勾选态改「任一子节点选中即选中」+ 无模块应用整应用授权(-appId 哨兵)
build / build-push (push) Failing after 2m2s

1. 父级选中态(组授权 / 角色授权口径统一)
   两页 isAppChecked 由「全部子模块都勾选才 true」改为「任一子孙被勾选即 true」,
   配合前端样式把 zTree checkbox_true_part 图标指向 checkbox_true_full,
   折叠态也能一眼看出「部分授权」,不再依赖视觉很弱的半选。

2. 无子模块应用(如「一周安排」)此前勾选不落库、刷新即失效
   - 新增 constant/AppLevelModuleId:用 -appId 作「整应用授权」的 module_id 哨兵。
     两套授权表都是 (owner_id, module_id) 唯一键,用 0 会让同组/同角色的第二个
     无模块应用撞唯一键;真实模块 id 恒正,负数段天然空闲且权限判定永不命中。
   - GroupAuthzService#resolveModuleIds:应用无任何模块时返回 -appId,使「整应用」
     授权能落库;findGroupAppsWithModules 按 -appId 回填应用级 checked / inheritFlag。
   - AppRoleService:findRoleModuleTree 同口径回填 checked;saveRoleModules 对负数项
     跳过模块校验,改为校验应用存在性与角色授权范围(角色授权是全量覆盖,哨兵必须原样落库)。
   - GroupAuthzService#buildModuleTree:脏数据兜底——parent_id 指向已删模块导致根节点
     全丢时,把全部模块提升为根,避免「有模块的应用」被误判成无模块应用。

涉及前端配套改动见 UserBaseFront 同名提交(GroupAuth.vue / AppModuleCheckTree.vue)。
This commit is contained in:
chenxing
2026-10-20 12:18:22 +08:00
parent c790187f35
commit fe6c07cc4b
6 changed files with 99 additions and 4 deletions
@@ -0,0 +1,45 @@
package com.yunhai.userbase.constant;
/**
* 「整应用授权」的 module_id 哨兵编码。<br>
* <p>
* 授权关系表 {@code sys_group_module_perm} / {@code sys_app_role_module_rel} 都只有
* (owner_id, module_id) 两列,<b>没有 app_id</b>。当某个应用下没有任何 {@code app_module} 记录时
* (如「一周安排」这类单页应用),模块级授权无处可挂,于是用 <b>-appId</b> 作为该应用
* 「整体已授权」的哨兵值:
* <ul>
* <li><b>不是 workaround</b>:module_id 命名空间里真实模块 id 恒为正整数,负数段天然是空闲的
* 应用级通道,与真实模块不会冲突;</li>
* <li>不同应用编码不同,满足 {@code uk_group_module(group_id, module_id)} /
* {@code uk_app_role_module(role_id, module_id)} 唯一键,同组/同角色可同时授权多个无模块应用;</li>
* <li>权限判定链路(hasModulePermission / collectEnabledRoleModuleIds 等)按真实 moduleId 匹配,
* 哨兵值永远匹配不上,不会被误判成某个模块的权限。</li>
* </ul>
* 写入侧由 Service 内部把「入参 moduleId=0(整应用语义)」翻译成本编码,前端无需感知。
*/
public final class AppLevelModuleId {
private AppLevelModuleId() {
}
/**
* 应用ID → 整应用授权的哨兵 module_id(-appId)。appId 为空时返回 null。
*/
public static Integer of(Integer appId) {
return appId == null ? null : -appId;
}
/**
* 哨兵 module_id → 应用ID;传入真实模块 id(正数)或 null 时返回 null。
*/
public static Integer resolveAppId(Integer moduleId) {
return (moduleId != null && moduleId < 0) ? -moduleId : null;
}
/**
* 是否为整应用授权的哨兵值。
*/
public static boolean isAppLevel(Integer moduleId) {
return moduleId != null && moduleId < 0;
}
}
@@ -29,6 +29,13 @@ public class AppRoleAuthzAppVo {
/** 排序号。 */
private Integer sortNo;
/**
* 应用级勾选态:<b>仅当该应用下没有任何模块时</b>才有效。<br>
* 此时授权落在 sys_app_role_module_rel 的 -appId 哨兵记录上(见 {@code AppLevelModuleId});
* 有模块的应用恒为 false,勾选态由 modules 树逐节点表达。
*/
private Boolean checked = Boolean.FALSE;
/** 该应用下的模块权限树(含勾选回显)。 */
private List<AppRoleAuthzModuleVo> modules = new ArrayList<>();
}
@@ -30,6 +30,16 @@ public class GroupAuthzAppVo {
private Integer sortNo;
/**
* 应用级勾选态:<b>仅当该应用下没有任何模块时</b>才有效。<br>
* 此时授权落在 sys_group_module_perm 的 module_id=0 记录上(整应用授权);
* 有模块的应用恒为 false,勾选态由 modules 树逐节点表达。
*/
private Boolean checked = Boolean.FALSE;
/** 应用级子组继承:仅当该应用下没有模块时有意义,对应 module_id=0 记录的 inherit_flag。 */
private Boolean inheritFlag = Boolean.FALSE;
/** 该应用下的模块权限树;默认空列表,避免 JSON 序列化出现 null。 */
private List<GroupAuthzModuleVo> modules = new ArrayList<>();
}
@@ -32,7 +32,9 @@ public class GroupModuleAuthzItem {
/**
* 模块ID;传 0 表示「该应用下全部模块」。<br>
* 注意:后端不会向 sys_group_module_perm 写入 module_id=0 的伪记录,<br>
* 而是把 0 展开为应用下每个真实模块各写一条权限记录。
* 而是把 0 展开为应用下每个真实模块各写一条权限记录。<br>
* 特殊情形:应用下<b>一个模块都没有</b>时,改写成 -appId 的整应用哨兵记录
* (见 {@code AppLevelModuleId}),保证这类应用也能被授权与回显。
*/
@NotNull(message = "模块ID不能为空(整应用传 0)")
private Integer moduleId;
@@ -9,6 +9,7 @@ import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserAppRoleRel;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.constant.AppLevelModuleId;
import com.yunhai.userbase.constant.AppNature;
import com.yunhai.userbase.event.AppAuthzChangedEvent;
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
@@ -242,8 +243,12 @@ public class AppRoleService implements IAppRoleService {
AppRoleAuthzAppVo appVo = new AppRoleAuthzAppVo();
BeanUtils.copyProperties(app, appVo);
List<AppModule> modules = appModuleMap.get(app.getId());
if (modules != null) {
if (modules != null && !modules.isEmpty()) {
appVo.setModules(buildModuleTree(modules, checkedModuleIds));
} else {
// 无子模块应用:授权挂在 -appId 的整应用哨兵记录上(见 AppLevelModuleId),
// 否则这类应用勾选态永远无回显(勾了刷新就丢)。
appVo.setChecked(checkedModuleIds.contains(AppLevelModuleId.of(app.getId())));
}
result.add(appVo);
}
@@ -266,6 +271,18 @@ public class AppRoleService implements IAppRoleService {
moduleMap.put(m.getId(), m);
}
for (Integer moduleId : moduleIds) {
// 整应用授权哨兵(-appId):不走模块校验,改为校验应用存在性与角色授权范围
Integer appLevelAppId = AppLevelModuleId.resolveAppId(moduleId);
if (appLevelAppId != null) {
if (!applicationInfoRepository.existsById(appLevelAppId)) {
throw new DException("应用不存在: " + appLevelAppId);
}
if (!Boolean.TRUE.equals(role.getAllAppFlag())
&& !Objects.equals(appLevelAppId, role.getAppId())) {
throw new DException("应用 " + appLevelAppId + " 不属于该角色所属应用,普通角色不能跨应用授权");
}
continue;
}
AppModule m = moduleMap.get(moduleId);
if (m == null) {
throw new DException("模块不存在: " + moduleId);
@@ -3,6 +3,7 @@ package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userbase.constant.AppLevelModuleId;
import com.yunhai.userbase.entity.*;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.platform.utils.enums.IEnum;
@@ -261,8 +262,14 @@ public class GroupAuthzService implements IGroupAuthzService {
GroupAuthzAppVo appVo = new GroupAuthzAppVo();
BeanUtils.copyProperties(app, appVo);
List<AppModule> modules = appModuleMap.get(app.getId());
if (modules != null) {
if (modules != null && !modules.isEmpty()) {
appVo.setModules(buildModuleTree(modules, permMap));
} else {
// 无子模块应用(如「一周安排」):授权挂在 -appId 的整应用哨兵记录上(见 AppLevelModuleId),
// 有记录即视为已授权,inherit_flag 同步回显,否则该应用勾选态永远无回显。
SysGroupModulePerm appPerm = permMap.get(AppLevelModuleId.of(app.getId()));
appVo.setChecked(appPerm != null);
appVo.setInheritFlag(appPerm != null && Boolean.TRUE.equals(appPerm.getInheritFlag()));
}
result.add(appVo);
}
@@ -520,6 +527,8 @@ public class GroupAuthzService implements IGroupAuthzService {
/**
* 解析某条授权项实际要操作的模块ID集合。<br>
* - moduleId 为 null 或 0:返回该应用下全部真实模块的 ID(「整应用」语义)。<br>
* - 应用下没有任何模块时:返回 {@link AppLevelModuleId#of(Integer)}(-appId)——
* 把「整应用」授权落到哨兵记录上,否则这类应用永远写不进授予、也永远回显为空。<br>
* - 具体 moduleId:校验其确实存在且属于该应用,越权写别的应用的模块会被拒绝。<br>
*/
private List<Integer> resolveModuleIds(Integer appId, Integer moduleId) {
@@ -528,7 +537,7 @@ public class GroupAuthzService implements IGroupAuthzService {
for (AppModule m : appModuleRepository.findByAppIdIn(Collections.singletonList(appId))) {
ids.add(m.getId());
}
return ids;
return ids.isEmpty() ? Collections.singletonList(AppLevelModuleId.of(appId)) : ids;
}
AppModule m = appModuleRepository.findById(moduleId).orElse(null);
if (m == null) {
@@ -1893,6 +1902,11 @@ public class GroupAuthzService implements IGroupAuthzService {
siblings.sort(siblingOrder);
}
List<AppModule> roots = childrenMap.getOrDefault(0, Collections.emptyList());
if (roots.isEmpty() && !modules.isEmpty()) {
// 脏数据兜底:parent_id 指向已删除模块时根节点全丢,会把「有模块的应用」误判成无模块应用
// (前端表现为该应用没有 + 号、勾选态恒为空),此时把所有模块提升为根节点展示
roots = modules;
}
List<GroupAuthzModuleVo> tree = new ArrayList<>();
for (AppModule root : roots) {
tree.add(buildModuleNode(root, childrenMap, permMap));