feat(system): 扩展用户部门权限功能

- 在 AdminUserService 中新增 listEnabledUserIdsByDeptIds 方法获取指定部门集合下启用且未离职的用户 ID 集合
- 在 DeptService 中新增 listDescendantDeptIds 方法获得指定部门集合及其所有子孙部门的 ID 集合
- 在 DeptService 中新增 listCodesByIds 方法按 id 集合批量查询部门 code 集合
- 在 OrgLeaderRelationService 中新增 listEffectiveDeptIdsByUserId 方法查询指定用户当前生效的负责人关系所对应的 dept_id 集合
- 在 PermissionApi 中新增 isSuperAdmin 接口判断用户是否超管
- 在 ObjectPermissionApi 中新增 getObjectRolePermissionDetailMerged 接口按 roleId 列表聚合菜单 + 权限码
- 扩展 ProductContextRoleRespVO 添加多角色场景的附加角色名称列表
- 扩展 ProductCreateWithTeamReqVO 支持创建时添加关心人用户 ID 列表
- 优化 ProductMemberServiceImpl 支持同一用户多角色显示,区分主角色和附加角色
- 新增 MEMBER_ACTION_REACTIVATE 复活动作类型用于处理 INACTIVE 成员行重新激活场景
- 在 ObjectStatusModelDO 中新增 progressExcludedFlag 字段控制是否参与上层进度统计
- 更新 AGENTS.md 和 CLAUDE.md 添加 Git 操作纪律规范
- 在 rdms-project-api 中新增多个错误码常量支持角色转移和内置角色配置验证
This commit is contained in:
2026-05-14 13:58:40 +08:00
parent 3946c0a0aa
commit 8f6b762bf3
85 changed files with 3908 additions and 277 deletions

View File

@@ -0,0 +1,52 @@
package com.njcn.rdms.module.system.api.dept;
import cn.hutool.core.collection.CollUtil;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.service.dept.DeptService;
import com.njcn.rdms.module.system.service.dept.OrgLeaderRelationService;
import com.njcn.rdms.module.system.service.user.AdminUserService;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
/**
* 组织负责人 RPC 接口实现
*/
@RestController
@Validated
@Hidden
public class OrgLeaderApiImpl implements OrgLeaderApi {
@Resource
private OrgLeaderRelationService orgLeaderRelationService;
@Resource
private DeptService deptService;
@Resource
private AdminUserService adminUserService;
@Override
public CommonResult<Set<Long>> getReachableUserIds(Long currentUserId) {
// 1. 当前用户作为 leader 生效中的 dept_id 集合
Set<Long> leaderDeptIds = orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId);
if (CollUtil.isEmpty(leaderDeptIds)) {
return success(Collections.emptySet());
}
// 2. 含递归子孙节点的 dept_id 集合(按 path 前缀匹配,一次 SQL 完成)
Set<Long> allDeptIds = deptService.listDescendantDeptIds(leaderDeptIds);
// 3. 这些 dept 下启用且未离职的 user_id 集合
Set<Long> userIds = adminUserService.listEnabledUserIdsByDeptIds(allDeptIds);
// 4. 移除自己(自己看自己走通道 1不在本结果集里
userIds.remove(currentUserId);
return success(userIds);
}
}

View File

@@ -17,8 +17,11 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@@ -77,6 +80,62 @@ public class ObjectPermissionApiImpl implements ObjectPermissionApi {
return success(detail);
}
@Override
public CommonResult<ObjectRolePermissionRespDTO> getObjectRolePermissionDetailMerged(
Collection<Long> roleIds, String scopeType, String objectType) {
if (roleIds == null || roleIds.isEmpty()) {
return success(emptyPermissionDetail());
}
// 拿全部启用的角色,过滤 null 和未启用
List<RoleDO> activeRoles = roleIds.stream()
.distinct()
.map(id -> getEnabledScopedRole(id, scopeType, objectType))
.filter(r -> r != null)
.toList();
if (activeRoles.isEmpty()) {
return success(emptyPermissionDetail());
}
// 主角色:按 sort 升序,决胜按 id 升序
Comparator<RoleDO> rolePriority = Comparator
.comparingInt((RoleDO r) -> r.getSort() == null ? Integer.MAX_VALUE : r.getSort())
.thenComparingLong(RoleDO::getId);
RoleDO primaryRole = activeRoles.stream().min(rolePriority).orElseThrow();
// 非主角色名(按 sort 升序保持稳定顺序)
List<String> additionalRoleNames = activeRoles.stream()
.filter(r -> !r.getId().equals(primaryRole.getId()))
.sorted(rolePriority)
.map(RoleDO::getName)
.toList();
// 菜单 union按 menu.id 去重,按 menu.sort 排序)
Map<Long, MenuDO> mergedMenus = new LinkedHashMap<>();
for (RoleDO role : activeRoles) {
for (MenuDO menu : permissionService.getScopedMenusByRoleId(role.getId(), scopeType, objectType)) {
mergedMenus.putIfAbsent(menu.getId(), menu);
}
}
List<ObjectMenuRespDTO> menus = mergedMenus.values().stream()
.sorted(Comparator.comparingInt(m -> m.getSort() == null ? Integer.MAX_VALUE : m.getSort()))
.map(this::convertMenu)
.toList();
// 权限码 unionLinkedHashSet 保持稳定顺序)
Set<String> permissions = new LinkedHashSet<>();
for (RoleDO role : activeRoles) {
permissions.addAll(permissionService.getScopedPermissionsByRoleId(role.getId(), scopeType, objectType));
}
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(convertRole(primaryRole));
detail.setAdditionalRoleNames(additionalRoleNames);
detail.setMenus(menus);
detail.setPermissions(permissions);
return success(detail);
}
private ObjectRolePermissionRespDTO emptyPermissionDetail() {
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(null);

View File

@@ -2,6 +2,7 @@ package com.njcn.rdms.module.system.api.permission;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.service.permission.PermissionService;
import com.njcn.rdms.module.system.service.permission.RoleService;
import io.swagger.v3.oas.annotations.Hidden;
import org.springframework.context.annotation.Primary;
import org.springframework.validation.annotation.Validated;
@@ -22,6 +23,9 @@ public class PermissionApiImpl implements PermissionApi {
@Resource
private PermissionService permissionService;
@Resource
private RoleService roleService;
@Override
public CommonResult<Set<Long>> getUserRoleIdListByRoleIds(Collection<Long> roleIds) {
return success(permissionService.getUserRoleIdListByRoleId(roleIds));
@@ -37,5 +41,14 @@ public class PermissionApiImpl implements PermissionApi {
return success(permissionService.hasAnyRoles(userId, roles));
}
@Override
public CommonResult<Boolean> isSuperAdmin(Long userId) {
Set<Long> roleIds = permissionService.getUserRoleIdListByUserId(userId);
if (roleIds.isEmpty()) {
return success(false);
}
return success(roleService.hasAnySuperAdmin(roleIds));
}
}

View File

@@ -0,0 +1,56 @@
package com.njcn.rdms.module.system.api.permission;
import cn.hutool.core.collection.CollUtil;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
import com.njcn.rdms.module.system.service.dept.DeptService;
import com.njcn.rdms.module.system.service.permission.UserVisibilityConfigService;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashSet;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
/**
* 用户可见性配置 RPC 接口实现。
*
* directionId → directionCode 的转换在 API 端完成,
* 业务侧rdms-project不需要再 join system_dept。
*/
@RestController
@Validated
@Hidden
public class UserVisibilityConfigApiImpl implements UserVisibilityConfigApi {
@Resource
private UserVisibilityConfigService userVisibilityConfigService;
@Resource
private DeptService deptService;
@Override
public CommonResult<UserVisibilityConfigRespDTO> getConfig(Long userId) {
UserVisibilityConfigDO cfg = userVisibilityConfigService.getByUserId(userId);
if (cfg == null) {
return success(null);
}
UserVisibilityConfigRespDTO dto = new UserVisibilityConfigRespDTO();
dto.setType(cfg.getVisibilityType());
if ("directions".equals(cfg.getVisibilityType())
&& CollUtil.isNotEmpty(cfg.getVisibleDirectionIds())) {
// directionId → directionCode 转换:在 API 端做,避免 rdms-project 再 join system_dept
dto.setDirectionCodes(deptService.listCodesByIds(cfg.getVisibleDirectionIds()));
}
if ("projects".equals(cfg.getVisibilityType())
&& CollUtil.isNotEmpty(cfg.getVisibleProjectIds())) {
dto.setProjectIds(new HashSet<>(cfg.getVisibleProjectIds()));
}
return success(dto);
}
}

View File

@@ -0,0 +1,60 @@
package com.njcn.rdms.module.system.controller.admin.permission;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig.UserVisibilityConfigRespVO;
import com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig.UserVisibilityConfigSaveReqVO;
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
import com.njcn.rdms.module.system.service.permission.UserVisibilityConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - 用户数据可见性配置")
@RestController
@RequestMapping("/system/user-visibility-config")
@Validated
public class UserVisibilityConfigController {
@Resource
private UserVisibilityConfigService userVisibilityConfigService;
@GetMapping("/get")
@Operation(summary = "查询用户可见性配置")
@Parameter(name = "userId", description = "用户ID", required = true, example = "1")
@PreAuthorize("@ss.hasPermission('system:user-visibility-config:query')")
public CommonResult<UserVisibilityConfigRespVO> getUserVisibilityConfig(@RequestParam("userId") Long userId) {
UserVisibilityConfigDO config = userVisibilityConfigService.getByUserId(userId);
return success(BeanUtils.toBean(config, UserVisibilityConfigRespVO.class));
}
@PostMapping("/save")
@Operation(summary = "保存用户可见性配置(存在则更新,不存在则新增)")
@PreAuthorize("@ss.hasPermission('system:user-visibility-config:save')")
public CommonResult<Long> saveUserVisibilityConfig(@Valid @RequestBody UserVisibilityConfigSaveReqVO saveReqVO) {
return success(userVisibilityConfigService.saveOrUpdate(saveReqVO));
}
@DeleteMapping("/delete")
@Operation(summary = "删除用户可见性配置")
@Parameter(name = "userId", description = "用户ID", required = true, example = "1")
@PreAuthorize("@ss.hasPermission('system:user-visibility-config:delete')")
public CommonResult<Boolean> deleteUserVisibilityConfig(@RequestParam("userId") Long userId) {
userVisibilityConfigService.deleteByUserId(userId);
return success(true);
}
}

View File

@@ -0,0 +1,37 @@
package com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 用户可见性配置 Response VO")
@Data
public class UserVisibilityConfigRespVO {
@Schema(description = "配置ID", example = "1")
private Long id;
@Schema(description = "用户ID", example = "100")
private Long userId;
@Schema(description = "可见范围类型all / directions / projects", example = "directions")
private String visibilityType;
@Schema(description = "补充可见方向ID集合", example = "[107, 103]")
private List<Long> visibleDirectionIds;
@Schema(description = "补充可见项目ID集合", example = "[1001, 1002]")
private List<Long> visibleProjectIds;
@Schema(description = "备注")
private String remark;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,32 @@
package com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - 用户可见性配置 Save Request VO")
@Data
public class UserVisibilityConfigSaveReqVO {
@Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "用户ID不能为空")
private Long userId;
@Schema(description = "可见范围类型all / directions / projects",
requiredMode = Schema.RequiredMode.REQUIRED, example = "directions")
@NotBlank(message = "可见范围类型不能为空")
private String visibilityType;
@Schema(description = "补充可见方向ID集合type=directions 时必填)", example = "[107, 103]")
private List<Long> visibleDirectionIds;
@Schema(description = "补充可见项目ID集合type=projects 时必填,业务暂不引导)", example = "[1001, 1002]")
private List<Long> visibleProjectIds;
@Schema(description = "备注")
private String remark;
}

View File

@@ -0,0 +1,66 @@
package com.njcn.rdms.module.system.dal.dataobject.permission;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 用户数据可见性配置 DO
*
* 每个用户至多一条记录user_id 有唯一索引),记录该用户在数据可见性维度的配置。
*/
@TableName(value = "system_user_visibility_config", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
public class UserVisibilityConfigDO extends BaseDO {
/**
* 主键 ID
*/
@TableId
private Long id;
/**
* 用户 ID
*
* 唯一约束,每个用户至多一条可见性配置。
*/
private Long userId;
/**
* 可见性类型
*
* 取值all全部可见/ directions按方向/ projects按项目
*/
private String visibilityType;
/**
* 可见的方向 ID 列表JSON 存储)
*
* visibilityType = "directions" 时有效,存储用户有权查看的方向 ID 集合。
* autoResultMap = true 已在 @TableName 上声明typeHandler 才能正常反序列化。
*/
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> visibleDirectionIds;
/**
* 可见的项目 ID 列表JSON 存储)
*
* visibilityType = "projects" 时有效,存储用户有权查看的项目 ID 集合。
* 业务暂未消费,预留字段。
*/
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> visibleProjectIds;
/**
* 备注
*/
private String remark;
}

View File

@@ -5,6 +5,7 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.system.dal.dataobject.dept.OrgLeaderRelationDO;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
@@ -23,4 +24,22 @@ public interface OrgLeaderRelationMapper extends BaseMapperX<OrgLeaderRelationDO
.orderByDesc(OrgLeaderRelationDO::getId));
}
/**
* 查询指定用户在指定时间点生效的负责人记录列表
*
* @param userId 用户 ID
* @param now 当前时间(生效期判断基准)
* @return 生效中的负责人关系列表
*/
default List<OrgLeaderRelationDO> selectEffectiveListByUserId(Long userId, LocalDateTime now) {
return selectList(new LambdaQueryWrapperX<OrgLeaderRelationDO>()
.eq(OrgLeaderRelationDO::getUserId, userId)
// effectiveFrom 为空或 <= now
.and(w -> w.isNull(OrgLeaderRelationDO::getEffectiveFrom)
.or().le(OrgLeaderRelationDO::getEffectiveFrom, now))
// effectiveUntil 为空或 >= now
.and(w -> w.isNull(OrgLeaderRelationDO::getEffectiveUntil)
.or().ge(OrgLeaderRelationDO::getEffectiveUntil, now)));
}
}

View File

@@ -0,0 +1,22 @@
package com.njcn.rdms.module.system.dal.mysql.permission;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户数据可见性配置 Mapper
*/
@Mapper
public interface UserVisibilityConfigMapper extends BaseMapperX<UserVisibilityConfigDO> {
/**
* 按 user_id 查单条配置(唯一索引保证一人一条)。
*/
default UserVisibilityConfigDO selectByUserId(Long userId) {
return selectOne(new LambdaQueryWrapperX<UserVisibilityConfigDO>()
.eq(UserVisibilityConfigDO::getUserId, userId));
}
}

View File

@@ -0,0 +1,54 @@
package com.njcn.rdms.module.system.framework.permission;
import com.njcn.rdms.framework.common.enums.CommonStatusEnum;
import com.njcn.rdms.module.system.dal.dataobject.permission.RoleDO;
import com.njcn.rdms.module.system.enums.permission.ObjectRoleConstants;
import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum;
import com.njcn.rdms.module.system.service.permission.RoleService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 启动时校验 {@link ObjectRoleConstants} 列出的内置对象角色,要求 system_role 表里全部存在、启用、object_type 匹配。
* 缺一抛 IllegalStateException 让进程退出 —— 避免运行期按 code 查不到才暴雷。
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class ObjectRoleStartupValidator implements ApplicationRunner {
private final RoleService roleService;
@Override
public void run(ApplicationArguments args) {
List<String> errors = new ArrayList<>();
for (ObjectRoleConstants required : ObjectRoleConstants.values()) {
RoleDO role = roleService.getRoleByCode(
required.getCode(),
PermissionScopeTypeEnum.OBJECT.getScopeType(),
required.getObjectType());
if (role == null) {
errors.add(String.format("缺失 [%s/%s] (object_type=%s)",
required.getCode(), required.getName(), required.getObjectType()));
continue;
}
if (!CommonStatusEnum.ENABLE.getStatus().equals(role.getStatus())) {
errors.add(String.format("已停用 [%s/%s]", required.getCode(), required.getName()));
}
}
if (!errors.isEmpty()) {
String detail = String.join("\n - ", errors);
log.error("[ObjectRoleStartupValidator] 内置对象角色校验失败:\n - {}", detail);
throw new IllegalStateException("内置对象角色校验失败,请检查 system_role 后重启:\n - " + detail);
}
log.info("[ObjectRoleStartupValidator] 内置对象角色 {} 条全部就位",
ObjectRoleConstants.values().length);
}
}

View File

@@ -108,6 +108,15 @@ public interface DeptService {
*/
Set<Long> getChildDeptIdListFromCache(Long id);
/**
* 获得指定部门集合及其所有子孙部门的 ID 集合。
* 基于 system_dept.path 字段前缀匹配,一次 SQL 查询完成,避免递归。
*
* @param rootDeptIds 根部门 ID 集合
* @return 含根节点本身及所有子孙节点的 ID 集合
*/
Set<Long> listDescendantDeptIds(Collection<Long> rootDeptIds);
/**
* 校验部门们是否有效
*
@@ -115,4 +124,15 @@ public interface DeptService {
*/
void validateDeptList(Collection<Long> ids);
/**
* 按 id 集合批量查询部门 code 集合。
*
* code 为空null / 空字符串)的记录会被过滤掉。
* 用于 API 端将 directionId → directionCode 转换,避免业务侧再 join system_dept。
*
* @param ids 部门 id 集合
* @return 非空 code 的集合
*/
Set<String> listCodesByIds(Collection<Long> ids);
}

View File

@@ -23,11 +23,13 @@ import jakarta.annotation.Resource;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.framework.common.util.collection.CollectionUtils.convertSet;
@@ -251,6 +253,36 @@ public class DeptServiceImpl implements DeptService {
return convertSet(children, DeptDO::getId);
}
@Override
public Set<Long> listDescendantDeptIds(Collection<Long> rootDeptIds) {
if (CollUtil.isEmpty(rootDeptIds)) {
return Collections.emptySet();
}
Set<Long> result = new HashSet<>(rootDeptIds);
// 逐个根节点按 path 前缀匹配子孙节点,避免递归查询
for (Long rootId : rootDeptIds) {
DeptDO root = deptMapper.selectById(rootId);
if (root == null || StrUtil.isBlank(root.getPath())) {
continue;
}
List<DeptDO> descendants = deptMapper.selectListByPathPrefix(root.getPath());
descendants.forEach(d -> result.add(d.getId()));
}
return result;
}
@Override
public Set<String> listCodesByIds(Collection<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return Collections.emptySet();
}
// 复用 getDeptList 已有的空判断与批量查询,过滤 code 为空的记录
return getDeptList(ids).stream()
.filter(d -> StrUtil.isNotBlank(d.getCode()))
.map(DeptDO::getCode)
.collect(Collectors.toSet());
}
@Override
public void validateDeptList(Collection<Long> ids) {
if (CollUtil.isEmpty(ids)) {

View File

@@ -5,6 +5,7 @@ import com.njcn.rdms.module.system.dal.dataobject.dept.OrgLeaderRelationDO;
import com.njcn.rdms.module.system.dal.dataobject.user.AdminUserDO;
import java.util.List;
import java.util.Set;
/**
* 组织负责人关系 Service
@@ -49,4 +50,12 @@ public interface OrgLeaderRelationService {
*/
List<AdminUserDO> getCandidateUsersByDeptId(Long deptId);
/**
* 查询指定用户当前生效的负责人关系所对应的 dept_id 集合
*
* @param userId 用户 ID
* @return 当前生效的组织 ID 集合,无关系时返回空集
*/
Set<Long> listEffectiveDeptIdsByUserId(Long userId);
}

View File

@@ -20,6 +20,8 @@ import java.util.List;
import java.util.Objects;
import java.util.Set;
import static com.njcn.rdms.framework.common.util.collection.CollectionUtils.convertSet;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.*;
import static java.util.Collections.singleton;
@@ -68,6 +70,12 @@ public class OrgLeaderRelationServiceImpl implements OrgLeaderRelationService {
return orgLeaderRelationMapper.selectListByDeptId(deptId);
}
@Override
public Set<Long> listEffectiveDeptIdsByUserId(Long userId) {
List<OrgLeaderRelationDO> relations = orgLeaderRelationMapper.selectEffectiveListByUserId(userId, LocalDateTime.now());
return convertSet(relations, OrgLeaderRelationDO::getDeptId);
}
@Override
public List<AdminUserDO> getCandidateUsersByDeptId(Long deptId) {
validateDeptExists(deptId);

View File

@@ -0,0 +1,34 @@
package com.njcn.rdms.module.system.service.permission;
import com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig.UserVisibilityConfigSaveReqVO;
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
/**
* 用户数据可见性配置 Service 接口
*/
public interface UserVisibilityConfigService {
/**
* 按 userId 查询可见性配置(可为 null表示该用户未配置
*/
UserVisibilityConfigDO getByUserId(Long userId);
/**
* 保存或更新用户可见性配置。
*
* 一人一条配置user_id 唯一索引):已有记录则 update否则 insert。
* 保存前校验 visibilityType 与字段的一致性:
* - all → visibleDirectionIds / visibleProjectIds 均须为 null
* - directions → visibleDirectionIds 非空visibleProjectIds 须为 null
* - projects → visibleProjectIds 非空visibleDirectionIds 须为 null
*
* @return 配置记录 id
*/
Long saveOrUpdate(UserVisibilityConfigSaveReqVO reqVO);
/**
* 按 userId 删除配置(唯一索引,无需按 id 删)。
*/
void deleteByUserId(Long userId);
}

View File

@@ -0,0 +1,101 @@
package com.njcn.rdms.module.system.service.permission;
import cn.hutool.core.collection.CollUtil;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig.UserVisibilityConfigSaveReqVO;
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
import com.njcn.rdms.module.system.dal.mysql.permission.UserVisibilityConfigMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH;
/**
* 用户数据可见性配置 Service 实现
*/
@Service
public class UserVisibilityConfigServiceImpl implements UserVisibilityConfigService {
@Resource
private UserVisibilityConfigMapper userVisibilityConfigMapper;
@Override
public UserVisibilityConfigDO getByUserId(Long userId) {
return userVisibilityConfigMapper.selectByUserId(userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long saveOrUpdate(UserVisibilityConfigSaveReqVO reqVO) {
// 校验 visibilityType 与字段的一致性
validateTypeFieldConsistency(reqVO);
UserVisibilityConfigDO existing = userVisibilityConfigMapper.selectByUserId(reqVO.getUserId());
if (existing == null) {
// 不存在 → insert
UserVisibilityConfigDO configDO = BeanUtils.toBean(reqVO, UserVisibilityConfigDO.class);
userVisibilityConfigMapper.insert(configDO);
return configDO.getId();
} else {
// 已存在 → update覆盖全部字段
UserVisibilityConfigDO configDO = BeanUtils.toBean(reqVO, UserVisibilityConfigDO.class);
configDO.setId(existing.getId());
userVisibilityConfigMapper.updateById(configDO);
return existing.getId();
}
}
@Override
public void deleteByUserId(Long userId) {
UserVisibilityConfigDO existing = userVisibilityConfigMapper.selectByUserId(userId);
if (existing != null) {
userVisibilityConfigMapper.deleteById(existing.getId());
}
}
/**
* 校验 visibilityType 与关联字段的一致性:
* - alldirectionIds / projectIds 均须为 null
* - directionsdirectionIds 非空projectIds 须为 null
* - projectsprojectIds 非空directionIds 须为 null
*/
private void validateTypeFieldConsistency(UserVisibilityConfigSaveReqVO reqVO) {
String type = reqVO.getVisibilityType();
boolean hasDirectionIds = CollUtil.isNotEmpty(reqVO.getVisibleDirectionIds());
boolean hasProjectIds = CollUtil.isNotEmpty(reqVO.getVisibleProjectIds());
switch (type) {
case "all" -> {
if (hasDirectionIds || hasProjectIds) {
throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
"type=all 时visibleDirectionIds 和 visibleProjectIds 均须为空");
}
}
case "directions" -> {
if (!hasDirectionIds) {
throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
"type=directions 时visibleDirectionIds 不能为空");
}
if (hasProjectIds) {
throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
"type=directions 时visibleProjectIds 须为空");
}
}
case "projects" -> {
if (!hasProjectIds) {
throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
"type=projects 时visibleProjectIds 不能为空");
}
if (hasDirectionIds) {
throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
"type=projects 时visibleDirectionIds 须为空");
}
}
default -> throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
"不支持的 visibilityType合法值all / directions / projects");
}
}
}

View File

@@ -17,6 +17,7 @@ import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 后台用户 Service 接口
@@ -243,4 +244,12 @@ public interface AdminUserService {
* @return 用户列表
*/
List<AdminUserDO> getAllUserByDeptId(Long deptId);
/**
* 获得指定部门集合下启用且未离职的用户 ID 集合
*
* @param deptIds 部门 ID 集合
* @return 可用用户 ID 集合
*/
Set<Long> listEnabledUserIdsByDeptIds(Collection<Long> deptIds);
}

View File

@@ -496,6 +496,15 @@ public class AdminUserServiceImpl implements AdminUserService {
&& !isUserResigned(user);
}
@Override
public Set<Long> listEnabledUserIdsByDeptIds(Collection<Long> deptIds) {
List<AdminUserDO> users = getUserListByDeptIds(deptIds);
return users.stream()
.filter(this::isUserAvailable)
.map(AdminUserDO::getId)
.collect(java.util.stream.Collectors.toSet());
}
@Override
public List<AdminUserDO> getAllUserByDeptId(Long deptId) {
Set<Long> deptCondition = getDeptCondition(deptId);

View File

@@ -0,0 +1,110 @@
package com.njcn.rdms.module.system.api.dept;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.system.service.dept.DeptService;
import com.njcn.rdms.module.system.service.dept.OrgLeaderRelationService;
import com.njcn.rdms.module.system.service.user.AdminUserService;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
class OrgLeaderApiImplTest extends BaseMockitoUnitTest {
@Mock
private OrgLeaderRelationService orgLeaderRelationService;
@Mock
private DeptService deptService;
@Mock
private AdminUserService adminUserService;
@InjectMocks
private OrgLeaderApiImpl orgLeaderApi;
/** 用户没有任何生效的负责人关系,直接返回空集 */
@Test
void getReachableUserIds_returnsEmpty_whenUserIsNotLeader() {
Long currentUserId = 1L;
when(orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId))
.thenReturn(Collections.emptySet());
CommonResult<Set<Long>> result = orgLeaderApi.getReachableUserIds(currentUserId);
assertTrue(result.getCheckedData().isEmpty());
}
/** 叶子节点 leader只有直属部门用户无子孙部门 */
@Test
void getReachableUserIds_returnsDirectSubordinates_whenUserIsLeafLeader() {
Long currentUserId = 10L;
Long deptId = 100L;
Long subordinateId = 20L;
when(orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId))
.thenReturn(Set.of(deptId));
// 叶子节点listDescendantDeptIds 仅返回自身
when(deptService.listDescendantDeptIds(Set.of(deptId)))
.thenReturn(Set.of(deptId));
when(adminUserService.listEnabledUserIdsByDeptIds(Set.of(deptId)))
.thenReturn(new HashSet<>(Set.of(subordinateId)));
CommonResult<Set<Long>> result = orgLeaderApi.getReachableUserIds(currentUserId);
assertEquals(Set.of(subordinateId), result.getCheckedData());
}
/** 父节点 leader结果包含所有子孙部门的可用用户 */
@Test
void getReachableUserIds_returnsRecursiveSubordinates_whenUserIsParentLeader() {
Long currentUserId = 10L;
Long rootDeptId = 100L;
Long childDeptId = 200L;
Long user1 = 21L;
Long user2 = 22L;
when(orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId))
.thenReturn(Set.of(rootDeptId));
// 父节点listDescendantDeptIds 返回自身 + 子孙
when(deptService.listDescendantDeptIds(Set.of(rootDeptId)))
.thenReturn(Set.of(rootDeptId, childDeptId));
when(adminUserService.listEnabledUserIdsByDeptIds(Set.of(rootDeptId, childDeptId)))
.thenReturn(new HashSet<>(Set.of(user1, user2)));
CommonResult<Set<Long>> result = orgLeaderApi.getReachableUserIds(currentUserId);
assertEquals(Set.of(user1, user2), result.getCheckedData());
}
/** 自己currentUserId即使在部门用户列表中也不应出现在结果集里 */
@Test
void getReachableUserIds_excludesSelf() {
Long currentUserId = 10L;
Long deptId = 100L;
Long otherId = 20L;
when(orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId))
.thenReturn(Set.of(deptId));
when(deptService.listDescendantDeptIds(Set.of(deptId)))
.thenReturn(Set.of(deptId));
// 返回集合包含 leader 自己
when(adminUserService.listEnabledUserIdsByDeptIds(Set.of(deptId)))
.thenReturn(new HashSet<>(Set.of(currentUserId, otherId)));
CommonResult<Set<Long>> result = orgLeaderApi.getReachableUserIds(currentUserId);
assertFalse(result.getCheckedData().contains(currentUserId), "结果集不能包含 currentUserId 自身");
assertTrue(result.getCheckedData().contains(otherId));
}
}

View File

@@ -0,0 +1,55 @@
package com.njcn.rdms.module.system.api.permission;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.system.dal.dataobject.permission.RoleDO;
import com.njcn.rdms.module.system.enums.permission.RoleCodeEnum;
import com.njcn.rdms.module.system.service.permission.PermissionService;
import com.njcn.rdms.module.system.service.permission.RoleService;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
class PermissionApiImplTest extends BaseMockitoUnitTest {
@Mock
private PermissionService permissionService;
@Mock
private RoleService roleService;
@InjectMocks
private PermissionApiImpl permissionApi;
@Test
void isSuperAdmin_returnsTrue_whenUserHasSuperAdminRole() {
Long userId = 1L;
Set<Long> roleIds = Set.of(100L);
when(permissionService.getUserRoleIdListByUserId(userId)).thenReturn(roleIds);
when(roleService.hasAnySuperAdmin(roleIds)).thenReturn(true);
CommonResult<Boolean> result = permissionApi.isSuperAdmin(userId);
assertTrue(result.getCheckedData());
}
@Test
void isSuperAdmin_returnsFalse_otherwise() {
Long userId = 2L;
Set<Long> roleIds = Set.of(200L);
when(permissionService.getUserRoleIdListByUserId(userId)).thenReturn(roleIds);
when(roleService.hasAnySuperAdmin(roleIds)).thenReturn(false);
CommonResult<Boolean> result = permissionApi.isSuperAdmin(userId);
assertFalse(result.getCheckedData());
}
}

View File

@@ -0,0 +1,101 @@
package com.njcn.rdms.module.system.api.permission;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
import com.njcn.rdms.module.system.service.dept.DeptService;
import com.njcn.rdms.module.system.service.permission.UserVisibilityConfigService;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
class UserVisibilityConfigApiImplTest extends BaseMockitoUnitTest {
@Mock
private UserVisibilityConfigService userVisibilityConfigService;
@Mock
private DeptService deptService;
@InjectMocks
private UserVisibilityConfigApiImpl userVisibilityConfigApi;
/** 用户无配置时返回 null data不抛异常 */
@Test
void getConfig_returnsNull_whenNoConfig() {
when(userVisibilityConfigService.getByUserId(1L)).thenReturn(null);
CommonResult<UserVisibilityConfigRespDTO> result = userVisibilityConfigApi.getConfig(1L);
assertNull(result.getCheckedData());
}
/** type=all 时directionCodes / projectIds 均不填充 */
@Test
void getConfig_returnsAllType_withoutDirectionsOrProjects() {
UserVisibilityConfigDO cfg = buildDO(1L, "all", null, null);
when(userVisibilityConfigService.getByUserId(1L)).thenReturn(cfg);
CommonResult<UserVisibilityConfigRespDTO> result = userVisibilityConfigApi.getConfig(1L);
UserVisibilityConfigRespDTO dto = result.getCheckedData();
assertEquals("all", dto.getType());
assertNull(dto.getDirectionCodes());
assertNull(dto.getProjectIds());
}
/** type=directions 时directionIds 经 deptService.listCodesByIds 转换为 code 集合 */
@Test
void getConfig_returnsDirectionsTypeWithCodes() {
List<Long> directionIds = List.of(101L, 102L);
UserVisibilityConfigDO cfg = buildDO(2L, "directions", directionIds, null);
when(userVisibilityConfigService.getByUserId(2L)).thenReturn(cfg);
when(deptService.listCodesByIds(directionIds)).thenReturn(Set.of("dir_code_A", "dir_code_B"));
CommonResult<UserVisibilityConfigRespDTO> result = userVisibilityConfigApi.getConfig(2L);
UserVisibilityConfigRespDTO dto = result.getCheckedData();
assertEquals("directions", dto.getType());
assertEquals(Set.of("dir_code_A", "dir_code_B"), dto.getDirectionCodes());
assertNull(dto.getProjectIds());
}
/** type=projects 时projectIds 直接透传到 DTO不调用 deptService */
@Test
void getConfig_returnsProjectsType() {
List<Long> projectIds = List.of(201L, 202L);
UserVisibilityConfigDO cfg = buildDO(3L, "projects", null, projectIds);
when(userVisibilityConfigService.getByUserId(3L)).thenReturn(cfg);
CommonResult<UserVisibilityConfigRespDTO> result = userVisibilityConfigApi.getConfig(3L);
UserVisibilityConfigRespDTO dto = result.getCheckedData();
assertEquals("projects", dto.getType());
assertNull(dto.getDirectionCodes());
assertTrue(dto.getProjectIds().containsAll(Set.of(201L, 202L)));
assertEquals(2, dto.getProjectIds().size());
}
// ===== 辅助方法 =====
private static UserVisibilityConfigDO buildDO(Long userId, String type,
List<Long> directionIds,
List<Long> projectIds) {
UserVisibilityConfigDO do_ = new UserVisibilityConfigDO();
do_.setUserId(userId);
do_.setVisibilityType(type);
do_.setVisibleDirectionIds(directionIds);
do_.setVisibleProjectIds(projectIds);
return do_;
}
}

View File

@@ -0,0 +1,103 @@
package com.njcn.rdms.module.system.service.permission;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig.UserVisibilityConfigSaveReqVO;
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
import com.njcn.rdms.module.system.dal.mysql.permission.UserVisibilityConfigMapper;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class UserVisibilityConfigServiceImplTest extends BaseMockitoUnitTest {
@Mock
private UserVisibilityConfigMapper userVisibilityConfigMapper;
@InjectMocks
private UserVisibilityConfigServiceImpl userVisibilityConfigService;
/** 用户无配置时执行 insert返回新记录 id */
@Test
void saveOrUpdate_insertNew_whenUserHasNoConfig() {
UserVisibilityConfigSaveReqVO reqVO = buildReqVO(1L, "all", null, null);
when(userVisibilityConfigMapper.selectByUserId(1L)).thenReturn(null);
// insert 由 BaseMapper 填充 id这里验证确实调了 insert而非 updateById
userVisibilityConfigService.saveOrUpdate(reqVO);
verify(userVisibilityConfigMapper).insert(any(UserVisibilityConfigDO.class));
verify(userVisibilityConfigMapper, never()).updateById(any(UserVisibilityConfigDO.class));
}
/** 用户已有配置时执行 update返回已有记录 id */
@Test
void saveOrUpdate_updateExisting_whenUserAlreadyHasConfig() {
Long existingId = 999L;
UserVisibilityConfigDO existing = buildDO(existingId, 2L, "all");
UserVisibilityConfigSaveReqVO reqVO = buildReqVO(2L, "directions", List.of(101L, 102L), null);
when(userVisibilityConfigMapper.selectByUserId(2L)).thenReturn(existing);
Long returnedId = userVisibilityConfigService.saveOrUpdate(reqVO);
assertEquals(existingId, returnedId);
verify(userVisibilityConfigMapper).updateById(any(UserVisibilityConfigDO.class));
verify(userVisibilityConfigMapper, never()).insert(any(UserVisibilityConfigDO.class));
}
/** type=all 但传了 visibleDirectionIds应抛类型字段不匹配异常 */
@Test
void saveOrUpdate_typeAllWithDirectionIds_shouldThrowMismatch() {
UserVisibilityConfigSaveReqVO reqVO = buildReqVO(3L, "all", List.of(101L), null);
ServiceException ex = assertThrows(ServiceException.class,
() -> userVisibilityConfigService.saveOrUpdate(reqVO));
assertEquals(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH.getCode(), ex.getCode());
verify(userVisibilityConfigMapper, never()).selectByUserId(any(Long.class));
}
/** type=directions 但未传 visibleDirectionIds应抛类型字段不匹配异常 */
@Test
void saveOrUpdate_typeDirectionsWithoutDirectionIds_shouldThrowMismatch() {
UserVisibilityConfigSaveReqVO reqVO = buildReqVO(4L, "directions", null, null);
ServiceException ex = assertThrows(ServiceException.class,
() -> userVisibilityConfigService.saveOrUpdate(reqVO));
assertEquals(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH.getCode(), ex.getCode());
verify(userVisibilityConfigMapper, never()).selectByUserId(any(Long.class));
}
// ===== 辅助方法 =====
private static UserVisibilityConfigSaveReqVO buildReqVO(Long userId, String type,
List<Long> directionIds,
List<Long> projectIds) {
UserVisibilityConfigSaveReqVO reqVO = new UserVisibilityConfigSaveReqVO();
reqVO.setUserId(userId);
reqVO.setVisibilityType(type);
reqVO.setVisibleDirectionIds(directionIds);
reqVO.setVisibleProjectIds(projectIds);
return reqVO;
}
private static UserVisibilityConfigDO buildDO(Long id, Long userId, String type) {
UserVisibilityConfigDO config = new UserVisibilityConfigDO();
config.setId(id);
config.setUserId(userId);
config.setVisibilityType(type);
return config;
}
}