diff --git a/src/main/java/com/yunhai/userbase/constant/AppLevelModuleId.java b/src/main/java/com/yunhai/userbase/constant/AppLevelModuleId.java
new file mode 100644
index 0000000..713432b
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/constant/AppLevelModuleId.java
@@ -0,0 +1,45 @@
+package com.yunhai.userbase.constant;
+
+/**
+ * 「整应用授权」的 module_id 哨兵编码。
+ *
+ * 授权关系表 {@code sys_group_module_perm} / {@code sys_app_role_module_rel} 都只有
+ * (owner_id, module_id) 两列,没有 app_id。当某个应用下没有任何 {@code app_module} 记录时
+ * (如「一周安排」这类单页应用),模块级授权无处可挂,于是用 -appId 作为该应用
+ * 「整体已授权」的哨兵值:
+ *
+ * - 不是 workaround:module_id 命名空间里真实模块 id 恒为正整数,负数段天然是空闲的
+ * 应用级通道,与真实模块不会冲突;
+ * - 不同应用编码不同,满足 {@code uk_group_module(group_id, module_id)} /
+ * {@code uk_app_role_module(role_id, module_id)} 唯一键,同组/同角色可同时授权多个无模块应用;
+ * - 权限判定链路(hasModulePermission / collectEnabledRoleModuleIds 等)按真实 moduleId 匹配,
+ * 哨兵值永远匹配不上,不会被误判成某个模块的权限。
+ *
+ * 写入侧由 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;
+ }
+}
diff --git a/src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzAppVo.java b/src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzAppVo.java
index 0d41d16..13c5dfd 100644
--- a/src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzAppVo.java
+++ b/src/main/java/com/yunhai/userbase/model/approle/AppRoleAuthzAppVo.java
@@ -29,6 +29,13 @@ public class AppRoleAuthzAppVo {
/** 排序号。 */
private Integer sortNo;
+ /**
+ * 应用级勾选态:仅当该应用下没有任何模块时才有效。
+ * 此时授权落在 sys_app_role_module_rel 的 -appId 哨兵记录上(见 {@code AppLevelModuleId});
+ * 有模块的应用恒为 false,勾选态由 modules 树逐节点表达。
+ */
+ private Boolean checked = Boolean.FALSE;
+
/** 该应用下的模块权限树(含勾选回显)。 */
private List modules = new ArrayList<>();
}
diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzAppVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzAppVo.java
index c90d124..87f9cfa 100644
--- a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzAppVo.java
+++ b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupAuthzAppVo.java
@@ -30,6 +30,16 @@ public class GroupAuthzAppVo {
private Integer sortNo;
+ /**
+ * 应用级勾选态:仅当该应用下没有任何模块时才有效。
+ * 此时授权落在 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 modules = new ArrayList<>();
}
diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupModuleAuthzItem.java b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupModuleAuthzItem.java
index ef3b2c4..52bbb9e 100644
--- a/src/main/java/com/yunhai/userbase/model/groupauthz/GroupModuleAuthzItem.java
+++ b/src/main/java/com/yunhai/userbase/model/groupauthz/GroupModuleAuthzItem.java
@@ -32,7 +32,9 @@ public class GroupModuleAuthzItem {
/**
* 模块ID;传 0 表示「该应用下全部模块」。
* 注意:后端不会向 sys_group_module_perm 写入 module_id=0 的伪记录,
- * 而是把 0 展开为应用下每个真实模块各写一条权限记录。
+ * 而是把 0 展开为应用下每个真实模块各写一条权限记录。
+ * 特殊情形:应用下一个模块都没有时,改写成 -appId 的整应用哨兵记录
+ * (见 {@code AppLevelModuleId}),保证这类应用也能被授权与回显。
*/
@NotNull(message = "模块ID不能为空(整应用传 0)")
private Integer moduleId;
diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java
index 94dd524..3ad25ed 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java
@@ -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 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);
diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java
index 03b8d2b..2941e99 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java
@@ -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 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集合。
* - moduleId 为 null 或 0:返回该应用下全部真实模块的 ID(「整应用」语义)。
+ * - 应用下没有任何模块时:返回 {@link AppLevelModuleId#of(Integer)}(-appId)——
+ * 把「整应用」授权落到哨兵记录上,否则这类应用永远写不进授予、也永远回显为空。
* - 具体 moduleId:校验其确实存在且属于该应用,越权写别的应用的模块会被拒绝。
*/
private List 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 roots = childrenMap.getOrDefault(0, Collections.emptyList());
+ if (roots.isEmpty() && !modules.isEmpty()) {
+ // 脏数据兜底:parent_id 指向已删除模块时根节点全丢,会把「有模块的应用」误判成无模块应用
+ // (前端表现为该应用没有 + 号、勾选态恒为空),此时把所有模块提升为根节点展示
+ roots = modules;
+ }
List tree = new ArrayList<>();
for (AppModule root : roots) {
tree.add(buildModuleNode(root, childrenMap, permMap));