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()); }