diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java b/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java
index a10c6d1..2aee346 100644
--- a/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java
+++ b/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java
@@ -108,16 +108,17 @@ public class AppRoleController {
}
/**
- * 查询角色已绑定的用户列表。
+ * 查询角色已绑定的用户列表(一行 = 一条「用户×组」绑定,含部门路径)。
+ * 路径查询时按 groupId 实时拼接;非超管按数据范围过滤行并裁剪路径(学校管理员只看到本校路径)。
*/
@GetMapping("/RoleUsers")
public List roleUsers(@RequestParam Integer roleId) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
- return appRoleService.findRoleUsers(roleId);
+ return appRoleService.findRoleUsers(roleId, accessScopeService.scopeGroupIds());
}
/**
- * 角色绑定用户保存(角色维度,全量覆盖)。
+ * 角色绑定用户保存(角色维度,增量添加「用户×组」绑定)。
*/
@OperationLog("保存角色用户绑定")
@PostMapping("SaveRoleUsers")
@@ -127,7 +128,7 @@ public class AppRoleController {
}
/**
- * 用户绑定角色保存(用户维度,全量覆盖)。
+ * 用户绑定角色保存(用户维度,按组覆盖:只覆盖该用户在指定组下的绑定)。
*/
@OperationLog("保存用户角色绑定")
@PostMapping("SaveUserRoles")
@@ -137,13 +138,14 @@ public class AppRoleController {
}
/**
- * 移除单个用户与角色的绑定关系(精确删除单条记录,非全量重提)。
+ * 移除单条「用户×组×角色」绑定(精确删除一行,非全量重提)。
* 前端「用户管理 - 删除」调用此接口。
*/
@OperationLog("移除角色用户绑定")
@PostMapping("RemoveRoleUser")
- public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId) {
+ public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId,
+ @RequestParam Integer groupId) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
- appRoleService.removeRoleUser(roleId, userId);
+ appRoleService.removeRoleUser(roleId, userId, groupId);
}
}
diff --git a/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java b/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java
index f45e472..e94930e 100644
--- a/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java
+++ b/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java
@@ -148,30 +148,35 @@ public class GroupAuthzController {
/**
* 权限一览:查询用户所属的业务角色列表(中间下方「所属角色」区域展示用)。
- * 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。
+ * 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。
+ * groupId 可选:传入时只返回该组下的绑定(编辑弹窗按所在组回显角色用);为空返回全部绑定聚合去重。
*/
@GetMapping("/UserRoles")
- public List userRoles(@RequestParam Integer userId) {
+ public List userRoles(@RequestParam Integer userId,
+ @RequestParam(required = false) Integer groupId) {
if (!accessScopeService.resolveCurrentScope().isSuper()
&& !accessScopeService.userIntersectsScope(userId)) {
return Collections.emptyList();
}
- return groupAuthzService.findUserRoles(userId);
+ return groupAuthzService.findUserRoles(userId, groupId);
}
/**
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。
+ * groupId 可选(上下文组):传入且用户确属该组时只画该组的祖先链——中间列表每行是「用户×组」对,
+ * 所属部门只展示行所在组的链路,而非用户全部归属组。
* 非超管:目标用户归属组与数据范围无交集时返回空集合;且树按数据范围剪枝——
* 学校管理员视角只展示到学校组(区域组/平台根不在其范围内,不再冒头到根节点)。
*/
@GetMapping("/UserGroupTree")
- public List userGroupTree(@RequestParam Integer userId) {
+ public List userGroupTree(@RequestParam Integer userId,
+ @RequestParam(required = false) Integer groupId) {
if (!accessScopeService.resolveCurrentScope().isSuper()
&& !accessScopeService.userIntersectsScope(userId)) {
return Collections.emptyList();
}
- return groupAuthzService.findUserGroupTree(userId, accessScopeService.scopeGroupIds());
+ return groupAuthzService.findUserGroupTree(userId, accessScopeService.scopeGroupIds(), groupId);
}
/**
diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserAppRoleRel.java b/src/main/java/com/yunhai/userbase/entity/SysUserAppRoleRel.java
index 631a609..b8c0e6c 100644
--- a/src/main/java/com/yunhai/userbase/entity/SysUserAppRoleRel.java
+++ b/src/main/java/com/yunhai/userbase/entity/SysUserAppRoleRel.java
@@ -15,7 +15,9 @@ import javax.validation.constraints.NotNull;
* @author chenx
* @date 2026/7/27
* @description 用户-业务角色关系表(逻辑外键,无物理 FK)。
- * 一个用户可绑定多个业务角色;一个角色也可绑定多个用户。
+ * 一行 = 用户 u 在组 g 下拥有角色 r;一个用户可在多个组下分别绑定角色,同一组下同一角色唯一。
+ * 组维度(2026-09-16):groupId = 绑定发生时所选的组(角色授权页添加用户弹窗左侧树选中组 / 用户编辑弹窗所在组),
+ * NULL = 历史数据视为全局生效(代码侧兜底,不参与按组过滤的排除)。
*/
@NoArgsConstructor
@Data
@@ -24,10 +26,11 @@ import javax.validation.constraints.NotNull;
name = "sys_user_app_role_rel",
indexes = {
@Index(name = "idx_user_app_role_user", columnList = "user_id"),
- @Index(name = "idx_user_app_role_role", columnList = "role_id")
+ @Index(name = "idx_user_app_role_role", columnList = "role_id"),
+ @Index(name = "idx_user_app_role_group", columnList = "group_id")
},
uniqueConstraints = {
- @UniqueConstraint(name = "uk_user_app_role", columnNames = {"user_id", "role_id"})
+ @UniqueConstraint(name = "uk_user_app_role_group", columnNames = {"user_id", "role_id", "group_id"})
}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
@@ -46,4 +49,10 @@ public class SysUserAppRoleRel extends BaseEntity {
@NotNull
@Column(name = "role_id", nullable = false)
private Integer roleId;
+
+ /**
+ * 绑定发生时所选的组ID(逻辑外键 → SysGroup.id);NULL = 历史数据视为全局。
+ */
+ @Column(name = "group_id")
+ private Integer groupId;
}
diff --git a/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBindRequest.java b/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBindRequest.java
index 8a4b952..efca55a 100644
--- a/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBindRequest.java
+++ b/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBindRequest.java
@@ -2,12 +2,14 @@ package com.yunhai.userbase.model.approle;
import lombok.Data;
+import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
- * 角色绑定用户保存入参(全量覆盖,角色维度)。
- * userIds 为该角色本次绑定的用户ID全量列表;传空列表即解绑该角色全部用户。
+ * 角色绑定用户保存入参(增量添加,角色维度)。
+ * bindings 为本次新增的「用户×组」绑定明细(组 = 弹窗左侧树当前选中组);已存在的 (userId, groupId) 跳过。
+ * 组维度改造(2026-09-16)后不再全量覆盖:覆盖会把其他组下的既有绑定一并删掉。
*
* @author chenx
*/
@@ -21,7 +23,7 @@ public class AppRoleUserBindRequest {
private Integer roleId;
/**
- * 绑定的用户ID全量列表。
+ * 本次新增的「用户×组」绑定明细。
*/
- private List userIds;
+ private List<@Valid AppRoleUserBinding> bindings;
}
diff --git a/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBinding.java b/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBinding.java
new file mode 100644
index 0000000..ac8a6c9
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserBinding.java
@@ -0,0 +1,23 @@
+package com.yunhai.userbase.model.approle;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * 角色绑定用户明细项:一行 = 用户在指定组下的绑定。
+ * groupId = 添加用户弹窗左侧树当前选中组(绑定发生时的组),部门列展示时按组ID实时拼路径。
+ *
+ * @author chenx
+ */
+@Data
+public class AppRoleUserBinding {
+
+ /** 用户ID。 */
+ @NotNull(message = "用户ID不能为空")
+ private Integer userId;
+
+ /** 绑定发生时所选的组ID。 */
+ @NotNull(message = "组ID不能为空")
+ private Integer groupId;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserVo.java b/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserVo.java
index d6e66a9..0c34942 100644
--- a/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserVo.java
+++ b/src/main/java/com/yunhai/userbase/model/approle/AppRoleUserVo.java
@@ -3,7 +3,9 @@ package com.yunhai.userbase.model.approle;
import lombok.Data;
/**
- * 角色已绑定用户列表项视图。
+ * 角色已绑定用户列表项视图(一行 = 一条「用户×组」绑定)。
+ * groupPath 为查询时按 groupId 实时拼接的组路径(不落库),scope 非空时按查看者数据范围裁剪——
+ * 学校管理员只看到本校路径(如 /云海小学/教师组),超管看全路径。
*
* @author chenx
*/
@@ -18,4 +20,10 @@ public class AppRoleUserVo {
/** 真实姓名。 */
private String realName;
+
+ /** 绑定发生时所选的组ID。 */
+ private Integer groupId;
+
+ /** 组路径(查询时按 groupId 现算,scope 裁剪后)。 */
+ private String groupPath;
}
diff --git a/src/main/java/com/yunhai/userbase/model/approle/UserAppRoleBindRequest.java b/src/main/java/com/yunhai/userbase/model/approle/UserAppRoleBindRequest.java
index 18e038e..afee2d7 100644
--- a/src/main/java/com/yunhai/userbase/model/approle/UserAppRoleBindRequest.java
+++ b/src/main/java/com/yunhai/userbase/model/approle/UserAppRoleBindRequest.java
@@ -6,8 +6,9 @@ import javax.validation.constraints.NotNull;
import java.util.List;
/**
- * 用户绑定角色保存入参(全量覆盖,用户维度)。
- * roleIds 为该用户本次绑定的角色ID全量列表;传空列表即解绑该用户全部角色。
+ * 用户绑定角色保存入参(按组覆盖,用户维度)。
+ * roleIds 为该用户在 groupId 组下的角色ID全量列表;传空列表即解绑该用户在该组下的全部角色,
+ * 不影响其在其他组的绑定;groupId 为空时兜底主组(历史调用方兼容)。
*
* @author chenx
*/
@@ -21,7 +22,12 @@ public class UserAppRoleBindRequest {
private Integer userId;
/**
- * 绑定的角色ID全量列表。
+ * 绑定组ID(可选):角色归属的组,空则兜底该用户主组。
+ */
+ private Integer groupId;
+
+ /**
+ * 该组下绑定的角色ID全量列表。
*/
private List roleIds;
}
diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserAppRoleRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserAppRoleRelRepository.java
index 82b7231..3f40fd5 100644
--- a/src/main/java/com/yunhai/userbase/repository/SysUserAppRoleRelRepository.java
+++ b/src/main/java/com/yunhai/userbase/repository/SysUserAppRoleRelRepository.java
@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import java.util.Collection;
import java.util.List;
/**
@@ -20,6 +21,24 @@ public interface SysUserAppRoleRelRepository extends JpaRepository findByRoleId(Integer roleId);
+ /** 指定用户在指定组下的绑定(编辑弹窗按所在组回显/覆盖保存)。 */
+ List findByUserIdAndGroupId(Integer userId, Integer groupId);
+
+ /** 指定用户无组维度(NULL=历史数据视为全局)的绑定;用户不属于任何组时权限计算只认这些行。 */
+ List findByUserIdAndGroupIdIsNull(Integer userId);
+
+ /**
+ * 权限计算取数:用户「当前所在组」上的绑定 ∪ NULL(历史数据视为全局)。
+ * groupIds 为空时勿调用(IN () 空集合 SQL 兼容性差),改走 {@link #findByUserIdAndGroupIdIsNull(Integer)}。
+ */
+ @Query("SELECT r FROM SysUserAppRoleRel r WHERE r.userId = :userId "
+ + "AND (r.groupId IS NULL OR r.groupId IN :groupIds)")
+ List findEffectiveByUserId(@Param("userId") Integer userId,
+ @Param("groupIds") Collection groupIds);
+
+ /** 判断 (用户, 角色, 组) 绑定是否已存在(增量添加去重)。 */
+ boolean existsByUserIdAndRoleIdAndGroupId(Integer userId, Integer roleId, Integer groupId);
+
/**
* 按角色ID批量物理删除绑定记录(bulk delete 直接发 SQL)。
* 用于「角色绑定用户」全量覆盖式保存的先删后插,以及删除角色时级联清理。
@@ -43,9 +62,17 @@ public interface SysUserAppRoleRelRepository extends JpaRepository
+ * 按用户ID + 组ID 批量物理删除该组下的全部绑定(编辑弹窗按组覆盖保存的先删后插)。
*/
@Modifying
- @Query("DELETE FROM SysUserAppRoleRel r WHERE r.roleId = :roleId AND r.userId = :userId")
- void deleteByRoleIdAndUserId(@Param("roleId") Integer roleId, @Param("userId") Integer userId);
+ @Query("DELETE FROM SysUserAppRoleRel r WHERE r.userId = :userId AND r.groupId = :groupId")
+ void deleteByUserIdAndGroupIdBulk(@Param("userId") Integer userId, @Param("groupId") Integer groupId);
+
+ /**
+ * 按角色ID + 用户ID + 组ID 删除单条绑定记录(角色用户列表按行移除)。
+ */
+ @Modifying
+ @Query("DELETE FROM SysUserAppRoleRel r WHERE r.roleId = :roleId AND r.userId = :userId AND r.groupId = :groupId")
+ void deleteByRoleIdAndUserIdAndGroupId(@Param("roleId") Integer roleId, @Param("userId") Integer userId,
+ @Param("groupId") Integer groupId);
}
diff --git a/src/main/java/com/yunhai/userbase/service/IAppRoleService.java b/src/main/java/com/yunhai/userbase/service/IAppRoleService.java
index 681240c..a76378b 100644
--- a/src/main/java/com/yunhai/userbase/service/IAppRoleService.java
+++ b/src/main/java/com/yunhai/userbase/service/IAppRoleService.java
@@ -74,32 +74,38 @@ public interface IAppRoleService {
void saveRoleModules(AppRoleModuleAssignRequest request);
/**
- * 查询角色已绑定的用户列表。
+ * 查询角色已绑定的用户列表(一行 = 一条「用户×组」绑定)。
+ * groupPath 为查询时按 groupId 实时拼接的组路径;scopeGroupIds 非空(非超管)时滤除
+ * 绑定组不在范围内的行,且路径只拼到范围内的祖先为止(学校管理员只看到本校路径)。
*
- * @param roleId 角色ID
- * @return 用户列表
+ * @param roleId 角色ID
+ * @param scopeGroupIds 当前管理端数据范围组集合(null=不限制)
+ * @return 绑定列表
*/
- List findRoleUsers(Integer roleId);
+ List findRoleUsers(Integer roleId, java.util.Set scopeGroupIds);
/**
- * 角色绑定用户保存(角色维度,全量覆盖):bulk 先删该角色全部绑定再批量插入。
+ * 角色绑定用户保存(角色维度,增量添加):bindings 为「用户×组」明细,
+ * 已存在的 (userId, groupId) 跳过;不做全量覆盖——覆盖会把其他组下的既有绑定删掉。
*
- * @param request 角色ID + 用户ID全量列表
+ * @param request 角色ID + 绑定明细列表
*/
void saveRoleUsers(AppRoleUserBindRequest request);
/**
- * 用户绑定角色保存(用户维度,全量覆盖):bulk 先删该用户全部绑定再批量插入。
+ * 用户绑定角色保存(用户维度,按组覆盖):只覆盖该用户在 groupId 组下的绑定,
+ * 不影响其在其他组的绑定;groupId 为空时兜底该用户主组。
*
- * @param request 用户ID + 角色ID全量列表
+ * @param request 用户ID + 组ID + 该组下角色ID全量列表
*/
void saveUserRoles(UserAppRoleBindRequest request);
/**
- * 移除单个用户与角色的绑定关系(精确删除单条记录,非全量重提)。
+ * 移除单条「用户×组×角色」绑定(精确删除一行,非全量重提)。
*
- * @param roleId 角色ID
- * @param userId 用户ID
+ * @param roleId 角色ID
+ * @param userId 用户ID
+ * @param groupId 绑定组ID
*/
- void removeRoleUser(Integer roleId, Integer userId);
+ void removeRoleUser(Integer roleId, Integer userId, Integer groupId);
}
diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java
index cf1daae..14ac9f5 100644
--- a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java
+++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java
@@ -104,26 +104,30 @@ public interface IGroupAuthzService {
List findUserPermissionTree(Integer groupId, Integer userId);
/**
- * 权限一览:查询用户所属的业务角色列表(中间下方「所属角色」区域展示用)。
- * 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。
+ * 查询用户所属的业务角色列表(权限一览「所属角色」/ 编辑弹窗角色回显)。
+ * 返回该用户绑定的角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称,按角色去重。
*
- * @param userId 用户ID
+ * @param userId 用户ID
+ * @param groupId 组ID(可选):非空时只取该组下的绑定(编辑弹窗按所在组回显);为空取全部绑定聚合
* @return 用户所属角色列表
*/
- List findUserRoles(Integer userId);
+ List findUserRoles(Integer userId, Integer groupId);
/**
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组),
* 多个归属组时合并为一棵树(公共祖先只出现一次)。
* 数据范围剪枝:scopeGroupIds 非空时树节点只保留范围内的组——学校管理员视角
- * 区域组/根节点不在其范围内,学校组即成为展示树的根(不再冒头到平台根);null 表示不剪枝(超管)。
+ * 区域组/根节点不在其范围内,学校组即成为展示树的根(不再冒头到平台根);null 表示不剪枝(超管)。
+ * 上下文收窄:contextGroupId 非空且该用户确属此组时,只画该组的祖先链(权限一览按行查看
+ * 「用户×组」对时,所属部门只展示行所在组的链路,而非用户全部归属组);此时「本组」标记也指向该组。
*
- * @param userId 用户ID
- * @param scopeGroupIds 当前管理端数据范围组集合(null=不限制)
+ * @param userId 用户ID
+ * @param scopeGroupIds 当前管理端数据范围组集合(null=不限制)
+ * @param contextGroupId 上下文组ID(null=不收窄,画全部归属组链路)
* @return 所属部门树(根节点列表);用户无组归属时返回空列表
*/
- List findUserGroupTree(Integer userId, java.util.Set scopeGroupIds);
+ List findUserGroupTree(Integer userId, java.util.Set scopeGroupIds, Integer contextGroupId);
/**
* 权限一览:查询组本身的有效已授权应用模块树(点击「所属部门」树节点联动右侧面板用)。
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 9fd8d01..2ce2694 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java
@@ -5,13 +5,16 @@ import com.yunhai.userbase.entity.AppModule;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.entity.SysAppRole;
import com.yunhai.userbase.entity.SysAppRoleModuleRel;
+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.event.AppAuthzChangedEvent;
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
import com.yunhai.userbase.model.approle.AppRoleAuthzModuleVo;
import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest;
import com.yunhai.userbase.model.approle.AppRoleUserBindRequest;
+import com.yunhai.userbase.model.approle.AppRoleUserBinding;
import com.yunhai.userbase.model.approle.AppRoleUserVo;
import com.yunhai.userbase.model.approle.SysAppRoleQuery;
import com.yunhai.userbase.model.approle.SysAppRoleRequest;
@@ -21,9 +24,12 @@ import com.yunhai.userbase.repository.AppModuleRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
import com.yunhai.userbase.repository.SysAppRoleRepository;
+import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
+import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.service.IAppRoleService;
+import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
@@ -56,7 +62,8 @@ import java.util.Set;
* @description 应用业务角色服务实现。
* 角色创建:普通角色(allAppFlag=false,appId 必填,同应用下重名校验)/ 全应用角色(allAppFlag=true,appId 为空,全应用角色间重名校验)。
* 模块授权:sys_app_role_module_rel 全量覆盖式保存(bulk 先删后插);普通角色仅能授权本应用模块,全应用角色可跨应用。
- * 用户绑定:sys_user_app_role_rel 支持角色维度 / 用户维度两个方向的全量覆盖保存。
+ * 用户绑定:sys_user_app_role_rel 带组维度(一行 = 用户×组×角色,2026-09-16)——
+ * 角色维度增量添加(绑定发生时的组)、用户维度按组覆盖(编辑弹窗所在组),组路径查询时实时拼接不落库。
*/
@Service
public class AppRoleService implements IAppRoleService {
@@ -65,6 +72,9 @@ public class AppRoleService implements IAppRoleService {
* 无应用类型;全应用授权范围须排除它——与 ApplicationInfoRepository「appKey > 100000」口径一致。 */
private static final int USER_CENTER_APP_KEY = 100000;
+ /** 组根节点的 parentId 约定值(与 GroupAuthzService.ROOT_PARENT_ID 同语义)。 */
+ private static final int ROOT_PARENT_ID = 0;
+
@Autowired
private SysAppRoleRepository sysAppRoleRepository;
@@ -83,6 +93,15 @@ public class AppRoleService implements IAppRoleService {
@Autowired
private SysUserRepository sysUserRepository;
+ @Autowired
+ private SysGroupRepository sysGroupRepository;
+
+ @Autowired
+ private SysUserGroupRelRepository sysUserGroupRelRepository;
+
+ @Autowired
+ private MainGroupResolver mainGroupResolver;
+
@Autowired
private ApplicationEventPublisher eventPublisher;
@@ -274,10 +293,10 @@ public class AppRoleService implements IAppRoleService {
publishAuthzEvent("saveRoleModules");
}
- // ===================== 用户角色绑定 =====================
+ // ===================== 用户角色绑定(组维度:一行 = 用户×组×角色) =====================
@Override
- public List findRoleUsers(Integer roleId) {
+ public List findRoleUsers(Integer roleId, Set scopeGroupIds) {
Objects.requireNonNull(roleId, "角色ID不能为空");
if (!sysAppRoleRepository.existsById(roleId)) {
throw new DException("角色不存在: " + roleId);
@@ -286,16 +305,30 @@ public class AppRoleService implements IAppRoleService {
if (rels.isEmpty()) {
return Collections.emptyList();
}
+ // scope 非空(非超管)时只保留绑定组在范围内的行(与 searchUsers 同口径)
+ List visibleRels = rels;
+ if (scopeGroupIds != null) {
+ visibleRels = new ArrayList<>();
+ for (SysUserAppRoleRel rel : rels) {
+ if (rel.getGroupId() != null && scopeGroupIds.contains(rel.getGroupId())) {
+ visibleRels.add(rel);
+ }
+ }
+ }
Set userIds = new LinkedHashSet<>();
- for (SysUserAppRoleRel rel : rels) {
- userIds.add(rel.getUserId());
+ for (SysUserAppRoleRel rel : visibleRels) {
+ if (rel.getUserId() != null) {
+ userIds.add(rel.getUserId());
+ }
}
Map userMap = new HashMap<>();
for (SysUser user : sysUserRepository.findAllById(userIds)) {
userMap.put(user.getId(), user);
}
+ // 部门路径不落库:按绑定行的 groupId 实时拼路径,scope 非空时裁到范围内祖先为止
+ Map groupMap = buildAllGroupMap();
List result = new ArrayList<>();
- for (SysUserAppRoleRel rel : rels) {
+ for (SysUserAppRoleRel rel : visibleRels) {
SysUser user = userMap.get(rel.getUserId());
if (user == null) {
continue;
@@ -304,6 +337,8 @@ public class AppRoleService implements IAppRoleService {
vo.setId(user.getId());
vo.setAccount(user.getAccount());
vo.setRealName(user.getRealName());
+ vo.setGroupId(rel.getGroupId());
+ vo.setGroupPath(buildScopedGroupPath(rel.getGroupId(), groupMap, scopeGroupIds));
result.add(vo);
}
return result;
@@ -317,21 +352,29 @@ public class AppRoleService implements IAppRoleService {
if (!sysAppRoleRepository.existsById(roleId)) {
throw new DException("角色不存在: " + roleId);
}
- // 去重并校验用户均存在
- List userIds = new ArrayList<>(new LinkedHashSet<>(
- request.getUserIds() == null ? Collections.emptyList() : request.getUserIds()));
- for (Integer userId : userIds) {
- if (!sysUserRepository.existsById(userId)) {
- throw new DException("用户不存在: " + userId);
- }
- }
- // 全量覆盖:bulk 先删旧绑定,再批量插入
- sysUserAppRoleRelRepository.deleteByRoleIdBulk(roleId);
+ // 增量添加「用户×组」绑定(组=弹窗左侧树当前选中组),已存在的 (userId, groupId) 跳过;
+ // 不做全量覆盖——覆盖会把该角色在其他组下的既有绑定一并删掉
+ List bindings = request.getBindings() == null
+ ? Collections.emptyList() : request.getBindings();
List batch = new ArrayList<>();
- for (Integer userId : userIds) {
+ for (AppRoleUserBinding binding : bindings) {
+ if (binding == null || binding.getUserId() == null || binding.getGroupId() == null) {
+ continue;
+ }
+ if (!sysUserRepository.existsById(binding.getUserId())) {
+ throw new DException("用户不存在: " + binding.getUserId());
+ }
+ if (!sysGroupRepository.existsById(binding.getGroupId())) {
+ throw new DException("系统组不存在: " + binding.getGroupId());
+ }
+ if (sysUserAppRoleRelRepository.existsByUserIdAndRoleIdAndGroupId(
+ binding.getUserId(), roleId, binding.getGroupId())) {
+ continue;
+ }
SysUserAppRoleRel rel = new SysUserAppRoleRel();
- rel.setUserId(userId);
+ rel.setUserId(binding.getUserId());
rel.setRoleId(roleId);
+ rel.setGroupId(binding.getGroupId());
batch.add(rel);
}
if (!batch.isEmpty()) {
@@ -348,6 +391,26 @@ public class AppRoleService implements IAppRoleService {
if (!sysUserRepository.existsById(userId)) {
throw new DException("用户不存在: " + userId);
}
+ // 绑定组:入参为空兜底主组(历史调用方兼容);组必须属于该用户实际所在组,防止给任意组写绑定
+ List userRels = sysUserGroupRelRepository.findByUserId(userId);
+ Integer groupId = request.getGroupId();
+ if (groupId == null) {
+ SysUserGroupRel mainRel = mainGroupResolver.resolve(userRels);
+ groupId = mainRel != null ? mainRel.getGroupId() : null;
+ }
+ if (groupId == null) {
+ throw new DException("用户未归属任何组,无法绑定角色");
+ }
+ boolean inGroup = false;
+ for (SysUserGroupRel rel : userRels) {
+ if (groupId.equals(rel.getGroupId())) {
+ inGroup = true;
+ break;
+ }
+ }
+ if (!inGroup) {
+ throw new DException("绑定组不在该用户所在组范围内: " + groupId);
+ }
// 去重并校验角色均存在
List roleIds = new ArrayList<>(new LinkedHashSet<>(
request.getRoleIds() == null ? Collections.emptyList() : request.getRoleIds()));
@@ -356,13 +419,14 @@ public class AppRoleService implements IAppRoleService {
throw new DException("角色不存在: " + roleId);
}
}
- // 全量覆盖:bulk 先删旧绑定,再批量插入
- sysUserAppRoleRelRepository.deleteByUserIdBulk(userId);
+ // 按组覆盖:只删该用户在该组下的绑定,其他组绑定不受影响
+ sysUserAppRoleRelRepository.deleteByUserIdAndGroupIdBulk(userId, groupId);
List batch = new ArrayList<>();
for (Integer roleId : roleIds) {
SysUserAppRoleRel rel = new SysUserAppRoleRel();
rel.setUserId(userId);
rel.setRoleId(roleId);
+ rel.setGroupId(groupId);
batch.add(rel);
}
if (!batch.isEmpty()) {
@@ -373,16 +437,17 @@ public class AppRoleService implements IAppRoleService {
@Override
@Transactional
- public void removeRoleUser(Integer roleId, Integer userId) {
+ public void removeRoleUser(Integer roleId, Integer userId, Integer groupId) {
Objects.requireNonNull(roleId, "角色ID不能为空");
Objects.requireNonNull(userId, "用户ID不能为空");
+ Objects.requireNonNull(groupId, "组ID不能为空");
if (!sysAppRoleRepository.existsById(roleId)) {
throw new DException("角色不存在: " + roleId);
}
if (!sysUserRepository.existsById(userId)) {
throw new DException("用户不存在: " + userId);
}
- sysUserAppRoleRelRepository.deleteByRoleIdAndUserId(roleId, userId);
+ sysUserAppRoleRelRepository.deleteByRoleIdAndUserIdAndGroupId(roleId, userId, groupId);
publishAuthzEvent("removeRoleUser");
}
@@ -394,6 +459,42 @@ public class AppRoleService implements IAppRoleService {
eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "AppRoleService." + sourceOp));
}
+ /** 全量组快照(组表量级小,与 GroupAuthzService.buildAllGroupMap 同思路)。 */
+ private Map buildAllGroupMap() {
+ Map map = new HashMap<>();
+ for (SysGroup g : sysGroupRepository.findAll()) {
+ map.put(g.getId(), g);
+ }
+ return map;
+ }
+
+ /**
+ * scope 感知的组路径拼接:自绑定组向上收集祖先名,遇到不在 scope 内的祖先即停(不含)。
+ * scope 为 null(超管)拼全路径;学校管理员只看到本校路径(如 /云海小学/教师组),
+ * 平台/局端看到各自的范围内路径。
+ */
+ private String buildScopedGroupPath(Integer groupId, Map groupMap, Set scopeGroupIds) {
+ if (groupId == null) {
+ return "";
+ }
+ List names = new ArrayList<>();
+ Set visited = new HashSet<>();
+ Integer cursor = groupId;
+ while (cursor != null && cursor != ROOT_PARENT_ID && visited.add(cursor)) {
+ if (scopeGroupIds != null && !scopeGroupIds.contains(cursor)) {
+ break;
+ }
+ SysGroup g = groupMap.get(cursor);
+ if (g == null) {
+ break;
+ }
+ names.add(g.getName() == null ? "" : g.getName());
+ cursor = g.getParentId();
+ }
+ Collections.reverse(names);
+ return names.isEmpty() ? "" : "/" + String.join("/", names);
+ }
+
// ===================== 私有工具 =====================
/**
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 504ac94..8a428d4 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java
@@ -1259,12 +1259,15 @@ public class GroupAuthzService implements IGroupAuthzService {
}
@Override
- public List findUserRoles(Integer userId) {
+ public List findUserRoles(Integer userId, Integer groupId) {
Objects.requireNonNull(userId, "用户ID不能为空");
if (!sysUserRepository.existsById(userId)) {
throw new DException("用户不存在");
}
- List rels = sysUserAppRoleRelRepository.findByUserId(userId);
+ // groupId 非空 = 只取该组下的绑定(编辑弹窗按所在组回显);为空 = 全部绑定聚合去重(权限一览展示)
+ List rels = groupId == null
+ ? sysUserAppRoleRelRepository.findByUserId(userId)
+ : sysUserAppRoleRelRepository.findByUserIdAndGroupId(userId, groupId);
if (rels.isEmpty()) {
return Collections.emptyList();
}
@@ -1310,7 +1313,7 @@ public class GroupAuthzService implements IGroupAuthzService {
}
@Override
- public List findUserGroupTree(Integer userId, Set scopeGroupIds) {
+ public List findUserGroupTree(Integer userId, Set scopeGroupIds, Integer contextGroupId) {
Objects.requireNonNull(userId, "用户ID不能为空");
if (!sysUserRepository.existsById(userId)) {
throw new DException("用户不存在");
@@ -1329,6 +1332,13 @@ public class GroupAuthzService implements IGroupAuthzService {
if (selfGroupIds.isEmpty()) {
return Collections.emptyList();
}
+ // 1.5 上下文收窄:指定组且用户确属该组时只画该组链路(权限一览按行查看「用户×组」对);
+ // 「本组」标记同步指向该组——主组可能在被收窄掉的链路上,沿用主组会导致整棵树没有「本组」
+ if (contextGroupId != null && selfGroupIds.contains(contextGroupId)) {
+ selfGroupIds = new LinkedHashSet<>();
+ selfGroupIds.add(contextGroupId);
+ mainGroupId = contextGroupId;
+ }
// 2. 祖先链并集(含自身);多个归属组共享祖先时只出现一次
Map allGroupMap = buildAllGroupMap();
Set nodeIds = new LinkedHashSet<>();
@@ -1851,10 +1861,21 @@ public class GroupAuthzService implements IGroupAuthzService {
/**
* 收集用户经「启用状态」业务角色获得授权的模块ID集合(角色授权并集的角色侧输入)。
+ * 组维度(2026-09-16):只算「用户当前所在组」上的绑定(group_id ∈ 当前组集合),
+ * group_id 为 NULL 的历史行视为全局生效(兜底防迁移脚本未跑导致权限丢失)。
+ * 调整组不删绑定:旧组上的绑定因组关系重建而自然失效,调回该组自动恢复。
* 禁用角色(disabled=true)不参与权限计算;用户无绑定或角色全被禁用时返回空集合。
*/
private Set collectEnabledRoleModuleIds(Integer userId) {
- List rels = sysUserAppRoleRelRepository.findByUserId(userId);
+ List userGroupIds = new ArrayList<>();
+ for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) {
+ if (rel.getGroupId() != null) {
+ userGroupIds.add(rel.getGroupId());
+ }
+ }
+ List rels = userGroupIds.isEmpty()
+ ? sysUserAppRoleRelRepository.findByUserIdAndGroupIdIsNull(userId)
+ : sysUserAppRoleRelRepository.findEffectiveByUserId(userId, userGroupIds);
if (rels.isEmpty()) {
return Collections.emptySet();
}
diff --git a/src/main/resources/sql/sys_user_app_role_rel_add_group_dimension.sql b/src/main/resources/sql/sys_user_app_role_rel_add_group_dimension.sql
new file mode 100644
index 0000000..e9b0591
--- /dev/null
+++ b/src/main/resources/sql/sys_user_app_role_rel_add_group_dimension.sql
@@ -0,0 +1,44 @@
+-- ============================================================================
+-- 用户角色绑定加组维度迁移(2026-09-16)· 直执行完整版
+-- 适用:新后端尚未启动过(ddl-auto=update 还没建结构)的情况;逐条执行,勿整段粘贴。
+-- 与启动先后无关:若新后端已启动过,结构语句会报"已存在",按下述忽略规则处理即可。
+-- 不幂等的语句及忽略规则(报错=已做过,视为成功):
+-- ① 报 1060 Duplicate column name → group_id 列已存在,忽略
+-- ② 报 1061 Duplicate key name → 索引/约束已存在,忽略
+-- ④ 报 1091 index doesn't exist → 旧约束已删过,忽略
+-- 回填语句(③)本身幂等,重复执行 0 行。
+-- ============================================================================
+
+-- ① 加列(只存组ID,不存路径字符串;部门列展示时按组ID实时拼路径)
+ALTER TABLE sys_user_app_role_rel ADD COLUMN group_id INT NULL AFTER role_id;
+
+-- ② 加新唯一约束 + 组维度索引
+ALTER TABLE sys_user_app_role_rel ADD UNIQUE KEY uk_user_app_role_group (user_id, role_id, group_id);
+ALTER TABLE sys_user_app_role_rel ADD INDEX idx_user_app_role_group (group_id);
+
+-- ③ 存量回填:各绑定行挂到该用户的主组(is_main=1 优先,无主组则取最小 rel.id 的组)
+UPDATE sys_user_app_role_rel r
+JOIN (
+ SELECT rel.user_id,
+ SUBSTRING_INDEX(GROUP_CONCAT(rel.group_id ORDER BY rel.is_main DESC, rel.id ASC), ',', 1) + 0 AS main_group_id
+ FROM sys_user_group_rel rel
+ WHERE rel.group_id IS NOT NULL
+ GROUP BY rel.user_id
+) m ON m.user_id = r.user_id
+SET r.group_id = m.main_group_id
+WHERE r.group_id IS NULL;
+
+-- ④ 删除旧二元唯一约束(不删则同用户跨组绑同角色必撞 DuplicateKey)
+ALTER TABLE sys_user_app_role_rel DROP INDEX uk_user_app_role;
+
+-- ============================================================================
+-- 自检:
+-- 1) 应返回 0 行:有组关系的用户却仍挂着 NULL 绑定(回填漏网)
+SELECT r.id, r.user_id, r.role_id
+FROM sys_user_app_role_rel r
+JOIN sys_user_group_rel g ON g.user_id = r.user_id AND g.group_id IS NOT NULL
+WHERE r.group_id IS NULL
+GROUP BY r.id, r.user_id, r.role_id;
+
+-- 2) 应只剩新约束:uk_user_app_role_group 在列,uk_user_app_role 不在列
+SHOW INDEX FROM sys_user_app_role_rel WHERE Key_name LIKE 'uk%';
diff --git a/src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java b/src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java
index 4ac40cd..720d401 100644
--- a/src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java
+++ b/src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java
@@ -4,11 +4,14 @@ import com.yunhai.userbase.entity.AppCategory;
import com.yunhai.userbase.entity.AppModule;
import com.yunhai.userbase.entity.SysAppRole;
import com.yunhai.userbase.entity.SysGroupAppRel;
+import com.yunhai.userbase.entity.SysUserAppRoleRel;
+import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.event.AppAuthzChangedEvent;
import com.yunhai.userbase.model.appcategory.AppCategoryRequest;
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest;
import com.yunhai.userbase.model.approle.AppRoleUserBindRequest;
+import com.yunhai.userbase.model.approle.AppRoleUserBinding;
import com.yunhai.userbase.model.approle.SysAppRoleRequest;
import com.yunhai.userbase.model.approle.UserAppRoleBindRequest;
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
@@ -23,9 +26,11 @@ import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysSchoolAppOverrideRepository;
import com.yunhai.userbase.repository.SysSchoolCategoryOverrideRepository;
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
+import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.enums.AccessLevelEnum;
import com.yunhai.userbase.service.resolve.AccessScope;
+import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -35,9 +40,11 @@ import org.mockito.MockitoAnnotations;
import org.springframework.context.ApplicationEventPublisher;
import java.util.Collections;
+import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -67,6 +74,10 @@ class AppAuthzEventPublishTest {
@Mock
private SysUserRepository sysUserRepository;
@Mock
+ private SysUserGroupRelRepository sysUserGroupRelRepository;
+ @Mock
+ private MainGroupResolver mainGroupResolver;
+ @Mock
private ApplicationEventPublisher eventPublisher;
// AppCategoryService
@@ -154,9 +165,14 @@ class AppAuthzEventPublishTest {
void appRole_saveRoleUsers_publishes() {
when(sysAppRoleRepository.existsById(1)).thenReturn(true);
when(sysUserRepository.existsById(2)).thenReturn(true);
+ when(sysGroupRepository.existsById(3)).thenReturn(true);
+ when(sysUserAppRoleRelRepository.existsByUserIdAndRoleIdAndGroupId(2, 1, 3)).thenReturn(false);
+ AppRoleUserBinding binding = new AppRoleUserBinding();
+ binding.setUserId(2);
+ binding.setGroupId(3);
AppRoleUserBindRequest request = new AppRoleUserBindRequest();
request.setRoleId(1);
- request.setUserIds(Collections.singletonList(2));
+ request.setBindings(Collections.singletonList(binding));
appRoleService.saveRoleUsers(request);
assertEquals("AppRoleService.saveRoleUsers", capturedOp().getSourceOp());
}
@@ -165,8 +181,14 @@ class AppAuthzEventPublishTest {
void appRole_saveUserRoles_publishes() {
when(sysUserRepository.existsById(2)).thenReturn(true);
when(sysAppRoleRepository.existsById(1)).thenReturn(true);
+ SysUserGroupRel rel = new SysUserGroupRel();
+ rel.setUserId(2);
+ rel.setGroupId(3);
+ when(sysUserGroupRelRepository.findByUserId(2)).thenReturn(Collections.singletonList(rel));
+ when(mainGroupResolver.resolve(anyList())).thenReturn(rel);
UserAppRoleBindRequest request = new UserAppRoleBindRequest();
request.setUserId(2);
+ request.setGroupId(3);
request.setRoleIds(Collections.singletonList(1));
appRoleService.saveUserRoles(request);
assertEquals("AppRoleService.saveUserRoles", capturedOp().getSourceOp());
@@ -176,7 +198,7 @@ class AppAuthzEventPublishTest {
void appRole_removeRoleUser_publishes() {
when(sysAppRoleRepository.existsById(1)).thenReturn(true);
when(sysUserRepository.existsById(2)).thenReturn(true);
- appRoleService.removeRoleUser(1, 2);
+ appRoleService.removeRoleUser(1, 2, 3);
assertEquals("AppRoleService.removeRoleUser", capturedOp().getSourceOp());
}