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

@@ -36,7 +36,9 @@
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dtest=VisibilityScopeResolverImplTest' '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|ERROR|FAIL' | Select-Object -Last 40)",
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|ERROR|FAILED|FAIL' | Select-Object -Last 100)",
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test-compile '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'ERROR|BUILD|FAIL' | Select-Object -Last 40)",
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot test '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|FAILED|ERROR' | Select-Object -Last 80)"
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot test '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|FAILED|ERROR' | Select-Object -Last 80)",
"Skill(code-review:code-review)",
"Bash(Test-Path *)"
]
}
}

View File

@@ -227,6 +227,28 @@ rdms-xxx
8. 修改跨模块使用的 API 时,需要同时更新提供方实现和对应的 `rdms-system-api` 或对应 `rdms-xxx-api` 契约。
9. 除非用户明确要求,否则不执行任何编译、构建、测试、打包或其他会实际运行项目的命令,包括但不限于 `mvn`、启动命令和脚本。
## Git 操作纪律
### 默认不引导分支管理(**首要**
用户在本仓库长期固定在 `main` 上工作。开发流程中:
- **不要主动建议建 feature 分支**`git checkout -b feat/xxx``git switch -c ...`)。
- **不要把"先切到 xxx 分支再操作"作为方案前置步骤**。
- 一切围绕 `main` 展开:直接在 `main` 上改、`main` 上提交、`main` 上推。
- 例外:用户明确要求建分支、或涉及多人协作 / PR 评审 / 大规模重构(此时仍只是"提一句作为可选",不强推)。
理由:曾出现一次"误把文件名当分支名建出怪分支 `用户行动清单.md`,后续 `git branch -D` 删分支时险些丢用户 3 天工作"的事故。事故根因就是分支管理本身——少走分支 = 少埋雷。
### 破坏性 git 命令必须先核实
任何**会丢工作**的 git 命令——`branch -D``reset --hard``clean -fd``push --force` / `--force-with-lease``checkout` / `switch` 带未提交改动、`rebase` 在已推送分支上、直接动 `.git/` 内部文件(`refs/``HEAD``packed-refs`)——**给出建议前必须先核实**,不得凭"看起来安全"就甩命令:
1. **目标 ref 上是否有未推送 / 未合并 commit**:让用户跑 `git log --oneline -5 <ref>``git log <主线>..<ref>` 把输出贴回来。
2. **工作区是否干净**`git status`
3. **先挂救生圈**:建议用 `git tag backup-xxx <sha>` 锁定当前 SHA**再执行**破坏性命令。
4. **明示翻车回滚路径**:例如"如果不对,`git reset --hard backup-xxx` 即可回到此处"。
## 测试指引
先定义验证方式,再实施修改。默认通过以下方式验证:

View File

@@ -126,6 +126,30 @@
7. 改跨模块 API 时,提供方实现与对应 `*-api` 契约同步更新。
8. **未经用户明确同意,不执行任何 `mvn`、启动命令、脚本等会实际运行项目的命令。**
## Git 操作纪律
### 默认不引导分支管理(**首要**
用户在本仓库长期固定在 `main` 上工作。开发流程中:
- **不要主动建议建 feature 分支**`git checkout -b feat/xxx``git switch -c ...`)。
- **不要把"先切到 xxx 分支再操作"作为方案前置步骤**。
- 一切围绕 `main` 展开:直接在 `main` 上改、`main` 上提交、`main` 上推。
- 例外:用户明确要求建分支、或涉及多人协作 / PR 评审 / 大规模重构(此时仍只是"提一句作为可选",不强推)。
**理由**:这次差点丢用户 3 天工作的事故,根因就是分支管理本身——某次操作意外把文件名当成分支名(建出 `用户行动清单.md` 分支),后续"切回 main + `git branch -D` 删怪分支"流程里就把未推送 commit `8bad989` 干掉了。**少走分支 = 少埋雷**。
### 破坏性 git 命令必须先核实
任何**会丢工作**的 git 命令——`branch -D``reset --hard``clean -fd``push --force` / `--force-with-lease``checkout` / `switch` 带未提交改动、`rebase` 在已推送分支上、直接动 `.git/` 内部文件(`refs/``HEAD``packed-refs`)——**给出建议前必须先核实**,不得凭"看起来安全"就甩命令:
1. **目标 ref 上是否有未推送 / 未合并 commit**:让用户跑 `git log --oneline -5 <ref>``git log <主线>..<ref>` 把输出贴回来。
2. **工作区是否干净**`git status`
3. **先挂救生圈**:建议用 `git tag backup-xxx <sha>` 锁定当前 SHA**再执行**破坏性命令。
4. **明示翻车回滚路径**:例如"如果不对,`git reset --hard backup-xxx` 即可回到此处"。
与 memory `feedback_no_git_commands.md``feedback_main_branch_workflow.md` 衔接:用户已要求"不主动跑 git 子命令(含只读)"+"在 main 上工作";本节进一步约束——**即使是让用户执行的建议命令**,也必须先满足上述核实清单。
## 验证默认动作
先定义验证方式,再做修改。默认静态验证:

View File

@@ -37,6 +37,8 @@ public interface ErrorCodeConstants {
ErrorCode PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED = new ErrorCode(1_008_001_024, "初始团队必须包含产品经理");
ErrorCode PRODUCT_INITIAL_TEAM_MEMBER_DUPLICATE = new ErrorCode(1_008_001_025, "初始团队成员存在重复");
ErrorCode PRODUCT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_001_026, "初始团队中存在非法角色");
ErrorCode PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_001_027, "原产品经理在该产品已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试");
ErrorCode PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_001_028, "内置产品角色【{}】未在 system_role 找到,请联系管理员");
// ========== 产品需求 1-008-002-000 ==========
ErrorCode REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_002_000, "产品需求不存在");
@@ -98,6 +100,8 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_INITIAL_TEAM_MEMBER_DUPLICATE = new ErrorCode(1_008_002_030, "初始团队成员存在重复");
ErrorCode PROJECT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_002_031, "初始团队中存在非法角色");
ErrorCode PROJECT_DIRECTION_NOT_MATCH_PRODUCT = new ErrorCode(1_008_002_032, "项目方向与所属产品方向不一致");
ErrorCode PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_002_033, "原项目经理在该项目已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试");
ErrorCode PROJECT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_002_034, "内置项目角色【{}】未在 system_role 找到,请联系管理员");
// ========== 执行管理 1-008-003-000 ==========
ErrorCode PROJECT_EXECUTION_NOT_EXISTS = new ErrorCode(1_008_003_000, "执行不存在");

View File

@@ -71,6 +71,11 @@ public final class ObjectActivityConstants {
public static final String MEMBER_ACTION_ADD = "add_member";
public static final String MEMBER_ACTION_UPDATE = "update_member";
public static final String MEMBER_ACTION_REMOVE = "remove_member";
/**
* 复活动作:原 INACTIVE 成员行被重新激活status: 1 → 0用于把"再次新增 / update 改 role 命中老 INACTIVE 行"路径
* 跟物理"新增 / 更新"的 audit 语义区分开。createXxxMember 命中 INACTIVE 三元组复活老行时使用本动作,避免 ADD 语义误用。
*/
public static final String MEMBER_ACTION_REACTIVATE = "reactivate_member";
public static final String EXECUTION_ACTION_CREATE = "create_execution_entity";
public static final String EXECUTION_ACTION_UPDATE = "update_execution_entity";
public static final String EXECUTION_ACTION_DELETE = "delete_execution_entity";
@@ -98,7 +103,7 @@ public final class ObjectActivityConstants {
PRODUCT_ACTION_CREATE, PRODUCT_ACTION_CHANGE_MANAGER);
public static final List<String> MEMBER_TIMELINE_ACTION_TYPES = List.of(
MEMBER_ACTION_ADD, MEMBER_ACTION_UPDATE, MEMBER_ACTION_REMOVE);
MEMBER_ACTION_ADD, MEMBER_ACTION_UPDATE, MEMBER_ACTION_REMOVE, MEMBER_ACTION_REACTIVATE);
private static final Set<String> STATUS_ACTION_TYPE_SET = Set.copyOf(STATUS_ACTION_TYPES);
@@ -145,6 +150,7 @@ public final class ObjectActivityConstants {
case MEMBER_ACTION_ADD -> "新增成员";
case MEMBER_ACTION_UPDATE -> "调整成员";
case MEMBER_ACTION_REMOVE -> "移出成员";
case MEMBER_ACTION_REACTIVATE -> "重新激活成员";
default -> normalizedActionType;
};
}

View File

@@ -15,6 +15,12 @@ public final class ProjectTaskConstants {
*/
public static final String OBJECT_TYPE = "task";
/**
* 任务"已完成"状态码,对应 rdms_object_status_model 中 object_type='task' 且 status_code='completed' 的状态。
* 用于 execution 的 complete 按钮可见性判定:要求根任务在排除排除集后全部为该状态。
*/
public static final String STATUS_COMPLETED = "completed";
/**
* 任务业务类型。
*/

View File

@@ -1,9 +1,12 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@Schema(description = "管理后台 - 产品团队成员 Response VO")
@Data
@@ -42,4 +45,7 @@ public class ProductMemberRespVO {
@Schema(description = "备注", example = "当前负责需求收敛")
private String remark;
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用(如同人 manager + creator单角色时为空数组", example = "产品创建者"))
private List<String> additionalRoleNames = Collections.emptyList();
}

View File

@@ -1,8 +1,12 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Collections;
import java.util.List;
@Schema(description = "管理后台 - 产品上下文中的当前角色 Response VO")
@Data
public class ProductContextRoleRespVO {
@@ -10,10 +14,16 @@ public class ProductContextRoleRespVO {
@Schema(description = "对象角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3201")
private Long roleId;
@Schema(description = "对象角色编码", example = "product_manager")
@Schema(description = "对象角色编码(主角色 code权限判断兼容字段", example = "product_manager")
private String roleCode;
@Schema(description = "对象角色名称", example = "产品经理")
@Schema(description = "对象角色名称(主角色 name", example = "产品经理")
private String roleName;
@Schema(description = "是否游客上下文(隐式 observer 兜底时为 true", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
private Boolean guestFlag;
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用;单角色时为空数组", example = "创建者"))
private List<String> additionalRoleNames = Collections.emptyList();
}

View File

@@ -39,4 +39,14 @@ public class ProductCreateWithTeamReqVO {
@Valid
private List<ProductMemberSaveReqVO> members;
/**
* 关心人 user ID 列表(创建时手动添加,可选)。
*
* <p>跟 members 是平行字段watcher 不参与团队管理,只是被授予"产品关心人"角色product_watcher
* 数据可见性。允许跟 members 的 user 重叠(多角色合法);后端按 (user, object, role) 三元组写入,
* 重复跳过 / INACTIVE 复活,业务侧不强校验。
*/
@Schema(description = "关心人用户ID列表可选可与团队成员重叠", example = "101,102")
private List<Long> watcherUserIds;
}

View File

@@ -2,6 +2,8 @@ package com.njcn.rdms.module.project.controller.admin.project.task;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
@@ -74,6 +76,14 @@ public class ProjectTaskController {
return success(projectStatusBoardService.getTaskStatusBoard(projectId, executionId, reqVO));
}
@GetMapping("/board-page")
@Operation(summary = "获取任务看板分页(按状态分列 + 每列分页 + 各列总数)")
public CommonResult<ProjectTaskBoardPageRespVO> getTaskBoardPage(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@Valid ProjectTaskBoardPageReqVO reqVO) {
return success(projectStatusBoardService.getTaskBoardPage(projectId, executionId, reqVO));
}
@PostMapping("/{taskId}/change-status")
@Operation(summary = "变更任务状态")
public CommonResult<Boolean> changeStatus(@PathVariable("projectId") Long projectId,

View File

@@ -0,0 +1,41 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
/**
* 看板视图任务分页 Request VO。
* <p>过滤口径keyword / parentTaskId / ownerId / updateTime与 {@link ProjectTaskPageReqVO} 严格一致;
* statusCode 升级为数组:缺省=返回该执行下任务状态字典的全部列;传若干个=只返回这些状态的列;
* 字典外的 statusCode 静默忽略。pageNo / pageSize 应用到返回的所有列(每列各自分页但页码统一)。
*/
@Schema(description = "管理后台 - 任务看板分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProjectTaskBoardPageReqVO extends PageParam {
@Schema(description = "列选择;缺省返回全部状态列,传若干个只返回这些状态的列;字典外的值静默忽略",
example = "[\"pending\",\"active\"]")
private String[] statusCode;
@Schema(description = "关键词,匹配任务标题", example = "联调")
private String keyword;
@Schema(description = "父任务编号", example = "9001")
private Long parentTaskId;
@Schema(description = "任务负责人用户编号", example = "3002")
private Long ownerId;
@Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime;
}

View File

@@ -0,0 +1,46 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 看板视图任务分页 Response VO。
* <p>每个 item 表示一列列定义statusCode/statusName/sort/terminal+ 当前页切片list+ 该列在当前过滤条件下的总数total
* list 元素结构与 {@link ProjectTaskRespVO} 完全一致。
*/
@Schema(description = "管理后台 - 任务看板分页 Response VO")
@Data
public class ProjectTaskBoardPageRespVO {
@Schema(description = "列数组(按 sort 升序)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<ColumnItemVO> items;
@Schema(description = "任务看板单列分页")
@Data
public static class ColumnItemVO {
@Schema(description = "状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending")
private String statusCode;
@Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "待开始")
private String statusName;
@Schema(description = "排序权重(与 /status-board.items[].sort 同源)", requiredMode = Schema.RequiredMode.REQUIRED,
example = "10")
private Integer sort;
@Schema(description = "是否终态", example = "false")
private Boolean terminal;
@Schema(description = "该列当前页切片;元素结构与 /tasks/page 的 list 元素一致",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<ProjectTaskRespVO> list;
@Schema(description = "该列在当前过滤条件下的总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "12")
private Long total;
}
}

View File

@@ -1,19 +1,25 @@
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Collections;
import java.util.List;
@Schema(description = "管理后台 - 项目上下文中的当前角色 Response VO")
@Data
public class ProjectContextRoleRespVO {
@Schema(description = "对象角色编号", example = "3201")
private Long roleId;
@Schema(description = "对象角色编码", example = "project_manager")
@Schema(description = "对象角色编码(主角色 code权限判断兼容字段", example = "project_manager")
private String roleCode;
@Schema(description = "对象角色名称", example = "项目经理")
@Schema(description = "对象角色名称(主角色 name", example = "项目经理")
private String roleName;
@Schema(description = "是否游客上下文", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
private Boolean guestFlag;
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用;单角色时为空数组", example = "创建者"))
private List<String> additionalRoleNames = Collections.emptyList();
}

View File

@@ -40,4 +40,14 @@ public class ProjectCreateWithTeamReqVO {
@Valid
private List<ProjectMemberSaveReqVO> members;
/**
* 关心人 user ID 列表(创建时手动添加,可选)。
*
* <p>跟 members 是平行字段watcher 不参与团队管理,只是被授予"项目关心人"角色project_watcher
* 数据可见性。允许跟 members 的 user 重叠(多角色合法);后端按 (user, object, role) 三元组写入,
* 重复跳过 / INACTIVE 复活,业务侧不强校验。
*/
@Schema(description = "关心人用户ID列表可选可与团队成员重叠", example = "101,102")
private List<Long> watcherUserIds;
}

View File

@@ -51,6 +51,10 @@ public class ObjectStatusModelDO extends BaseDO {
* 是否允许编辑对象主数据
*/
private Boolean allowEdit;
/**
* 是否不参与上层进度统计。
*/
private Boolean progressExcludedFlag;
/**
* 备注
*/

View File

@@ -5,6 +5,7 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@@ -42,6 +43,19 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
.eq(UserObjectRoleDO::getStatus, 0));
}
/**
* multi-role拿 user 在某对象内全部 ACTIVE 角色行(含 manager + creator + watcher 等所有显式角色)。
* 用于对象域鉴权ProductObjectPermissionService / ProjectObjectPermissionService 的 anyMatch 权限聚合)
* 与 Context 主角色挑选(按 sort 升序排,主角色 + additionalRoleNames
*/
default List<UserObjectRoleDO> selectActiveListByObjectAndUserId(String objectType, Long objectId, Long userId) {
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId)
.eq(UserObjectRoleDO::getUserId, userId)
.eq(UserObjectRoleDO::getStatus, 0));
}
default List<UserObjectRoleDO> selectListByIdsAndObject(List<Long> ids, String objectType, Long objectId) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
@@ -59,4 +73,42 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
.eq(UserObjectRoleDO::getStatus, 0));
}
/**
* multi-role 唯一索引精确命中:按 (object_type, object_id, user_id, role_id) 单查任一记录。
* 不带 status —— ACTIVE / INACTIVE 都要返回,用于 insertOrReactivate / pre-check 撞索引场景。
*/
default UserObjectRoleDO selectByObjectUserAndRole(String objectType, Long objectId, Long userId, Long roleId) {
return selectOne(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId)
.eq(UserObjectRoleDO::getUserId, userId)
.eq(UserObjectRoleDO::getRoleId, roleId));
}
/**
* 同 {@link #selectByObjectUserAndRole},但仅返回 ACTIVE 行status=0
* 用于 manager 转岗:按 (user, object, manager_role_id) 三元组定位 manager ACTIVE 行后改 role_id 降级。
*/
default UserObjectRoleDO selectActiveByObjectUserAndRole(String objectType, Long objectId, Long userId, Long roleId) {
return selectOne(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId)
.eq(UserObjectRoleDO::getUserId, userId)
.eq(UserObjectRoleDO::getRoleId, roleId)
.eq(UserObjectRoleDO::getStatus, 0));
}
/**
* 通道 2 用:批量按 userIds 反查指定 objectType 下的活跃记录status=0用于"组织负责人 → 下属参与的对象"反推。
*/
default List<UserObjectRoleDO> selectListByUserIdsAndObjectType(Collection<Long> userIds, String objectType) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyList();
}
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
.in(UserObjectRoleDO::getUserId, userIds)
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getStatus, 0));
}
}

View File

@@ -1,14 +1,10 @@
package com.njcn.rdms.module.project.dal.mysql.product;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
@@ -16,21 +12,6 @@ import java.util.Map;
@Mapper
public interface ProductMapper extends BaseMapperX<ProductDO> {
default PageResult<ProductDO> selectPage(ProductPageReqVO reqVO) {
LambdaQueryWrapperX<ProductDO> queryWrapper = new LambdaQueryWrapperX<>();
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProductDO::getCode, reqVO.getKeyword())
.or()
.like(ProductDO::getName, reqVO.getKeyword()));
}
queryWrapper.eqIfPresent(ProductDO::getDirectionCode, reqVO.getDirectionCode())
.eqIfPresent(ProductDO::getManagerUserId, reqVO.getManagerUserId())
.eqIfPresent(ProductDO::getStatusCode, reqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime())
.orderByDesc(BaseDO::getCreateTime);
return selectPage(reqVO, queryWrapper);
}
default ProductDO selectByCode(String code) {
return selectOne(ProductDO::getCode, code);
}

View File

@@ -1,14 +1,11 @@
package com.njcn.rdms.module.project.dal.mysql.project;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.List;
@@ -17,23 +14,6 @@ import java.util.Map;
@Mapper
public interface ProjectMapper extends BaseMapperX<ProjectDO> {
default PageResult<ProjectDO> selectPage(ProjectPageReqVO reqVO) {
LambdaQueryWrapperX<ProjectDO> queryWrapper = new LambdaQueryWrapperX<>();
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectDO::getProjectCode, reqVO.getKeyword())
.or()
.like(ProjectDO::getProjectName, reqVO.getKeyword()));
}
queryWrapper.eqIfPresent(ProjectDO::getProjectType, reqVO.getProjectType())
.eqIfPresent(ProjectDO::getDirectionCode, reqVO.getDirectionCode())
.eqIfPresent(ProjectDO::getProductId, reqVO.getProductId())
.eqIfPresent(ProjectDO::getManagerUserId, reqVO.getManagerUserId())
.eqIfPresent(ProjectDO::getStatusCode, reqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime())
.orderByDesc(BaseDO::getCreateTime);
return selectPage(reqVO, queryWrapper);
}
default ProjectDO selectByCode(String projectCode) {
return selectOne(ProjectDO::getProjectCode, projectCode);
}

View File

@@ -125,28 +125,40 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
* 取直接子任务的 progressRate 列表(用于父任务进度简单平均汇总)。
* 只读取必要字段,避免拉回整行;逻辑删除的子任务由 BaseMapper 自动过滤。
*/
default List<ProjectTaskDO> selectChildrenProgressByParentTaskId(Long parentTaskId) {
default List<ProjectTaskDO> selectChildrenProgressByParentTaskId(Long parentTaskId,
Collection<String> excludedStatusCodes) {
if (parentTaskId == null) {
return Collections.emptyList();
}
return selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
.select(ProjectTaskDO::getId, ProjectTaskDO::getProgressRate)
.eq(ProjectTaskDO::getParentTaskId, parentTaskId));
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.select(ProjectTaskDO::getId, ProjectTaskDO::getProgressRate);
queryWrapper.eq(ProjectTaskDO::getParentTaskId, parentTaskId);
if (excludedStatusCodes != null && !excludedStatusCodes.isEmpty()) {
queryWrapper.notIn(ProjectTaskDO::getStatusCode, excludedStatusCodes);
}
return selectList(queryWrapper);
}
/**
* 执行详情进度:按当前执行下一级任务 progressRate 简单平均;无一级任务时 SQL 返回 null。
*/
@Select("""
<script>
SELECT AVG(COALESCE(progress_rate, 0))
FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND execution_id = #{executionId}
AND parent_task_id IS NULL
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
AND status_code NOT IN
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
</if>
</script>
""")
BigDecimal selectRootTaskAvgProgressByExecutionId(@Param("projectId") Long projectId,
@Param("executionId") Long executionId);
@Param("executionId") Long executionId,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/**
* 执行分页进度:按当前页 executionId 批量聚合一级任务 progressRate避免列表 N+1。
@@ -160,12 +172,70 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
AND execution_id IN
<foreach collection="executionIds" item="id" open="(" separator="," close=")">#{id}</foreach>
AND parent_task_id IS NULL
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
AND status_code NOT IN
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
</if>
GROUP BY execution_id
</script>
""")
List<Map<String, Object>> selectRootTaskAvgProgressGroupByExecutionIds(
@Param("projectId") Long projectId,
@Param("executionIds") Collection<Long> executionIds);
@Param("executionIds") Collection<Long> executionIds,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/**
* 执行详情完成态聚合:返回根任务总数与"已完成"数,用于 execution complete 按钮判定。
* 与 selectRootTaskAvgProgressByExecutionId 共用同一根任务筛选口径execution_id + parent_task_id IS NULL + excludedStatusCodes
* 业务侧判定 totals > 0 && totals == completedCount 即视为"根任务全部已完成"空集totals = 0按"不全部完成"处理。
*/
@Select("""
<script>
SELECT COUNT(*) AS totals,
SUM(CASE WHEN status_code = #{completedStatusCode} THEN 1 ELSE 0 END) AS completedCount
FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND execution_id = #{executionId}
AND parent_task_id IS NULL
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
AND status_code NOT IN
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
</if>
</script>
""")
Map<String, Object> selectRootTaskCompletionStateByExecutionId(@Param("projectId") Long projectId,
@Param("executionId") Long executionId,
@Param("completedStatusCode") String completedStatusCode,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/**
* 执行分页完成态批量聚合:按 executionId 一次性返回 (totals, completedCount),避免列表 N+1。
* 筛选口径与 selectRootTaskCompletionStateByExecutionId 同源。
*/
@Select("""
<script>
SELECT execution_id AS executionId,
COUNT(*) AS totals,
SUM(CASE WHEN status_code = #{completedStatusCode} THEN 1 ELSE 0 END) AS completedCount
FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND execution_id IN
<foreach collection="executionIds" item="id" open="(" separator="," close=")">#{id}</foreach>
AND parent_task_id IS NULL
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
AND status_code NOT IN
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
</if>
GROUP BY execution_id
</script>
""")
List<Map<String, Object>> selectRootTaskCompletionStateGroupByExecutionIds(
@Param("projectId") Long projectId,
@Param("executionIds") Collection<Long> executionIds,
@Param("completedStatusCode") String completedStatusCode,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/**
* 仅更新单个任务的 progressRate不动其他字段避免污染 lastStatusReason 等)。

View File

@@ -57,4 +57,17 @@ public interface ObjectStatusModelMapper extends BaseMapperX<ObjectStatusModelDO
.collect(Collectors.toList());
}
/**
* 查询某对象类型下所有已启用、且不参与上层进度统计的状态码。
*/
default List<String> selectProgressExcludedStatusCodesByObjectTypeEnabled(String objectType) {
return selectList(new LambdaQueryWrapperX<ObjectStatusModelDO>()
.eq(ObjectStatusModelDO::getObjectType, objectType)
.eq(ObjectStatusModelDO::getStatus, 0)
.eq(ObjectStatusModelDO::getProgressExcludedFlag, true))
.stream()
.map(ObjectStatusModelDO::getStatusCode)
.collect(Collectors.toList());
}
}

View File

@@ -1,8 +1,11 @@
package com.njcn.rdms.module.project.framework.rpc.config;
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.file.FileApi;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.PermissionApi;
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;
@@ -11,6 +14,6 @@ import org.springframework.context.annotation.Configuration;
* Project 模块的 RPC 配置
*/
@Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false)
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class})
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class, PermissionApi.class, OrgLeaderApi.class, UserVisibilityConfigApi.class})
public class RpcConfiguration {
}

View File

@@ -13,6 +13,7 @@ import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@@ -41,9 +42,10 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
throw invalidParamException("对象编号不能为空");
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
UserObjectRoleDO currentMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (currentMember == null) {
// 多角色支持:拿 user 在对象内全部 ACTIVE 角色行
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (userRoles.isEmpty()) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED,
buildDeniedPermission(permission, memberOnly));
}
@@ -51,7 +53,12 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
return;
}
String normalizedPermission = normalizePermission(permission);
if (!getRolePermissions(currentMember.getRoleId()).contains(normalizedPermission)) {
// 任一角色含该权限码即放行(等价于多角色 union短路求值
boolean allowed = userRoles.stream()
.map(UserObjectRoleDO::getRoleId)
.distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
if (!allowed) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, normalizedPermission);
}
}

View File

@@ -13,6 +13,7 @@ import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@@ -41,9 +42,10 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
throw invalidParamException("对象编号不能为空");
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
UserObjectRoleDO currentMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (currentMember == null) {
// 多角色支持:拿 user 在对象内全部 ACTIVE 角色行
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (userRoles.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED,
buildDeniedPermission(permission, memberOnly));
}
@@ -51,7 +53,12 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
return;
}
String normalizedPermission = normalizePermission(permission);
if (!getRolePermissions(currentMember.getRoleId()).contains(normalizedPermission)) {
// 任一角色含该权限码即放行(等价于多角色 union短路求值权限码命中早 return
boolean allowed = userRoles.stream()
.map(UserObjectRoleDO::getRoleId)
.distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
if (!allowed) {
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, normalizedPermission);
}
}

View File

@@ -0,0 +1,63 @@
package com.njcn.rdms.module.project.service.datascope;
import lombok.Getter;
import java.util.Collections;
import java.util.Set;
/**
* 数据权限范围:用户在某 objectTypeproject/product下能看到哪些对象。
* 不可变。三态ALL看全部不加 SQL 条件)/ ID_LIST看具体集合/ EMPTY看不到任何
*
* 设计来源docs/superpowers/specs/2026-05-14-object-data-scope-design.md 第 3.1 节
*/
@Getter
public final class ObjectDataScope {
public enum State { ALL, ID_LIST, EMPTY }
private final State state;
private final Set<Long> ids; // 仅 ID_LIST 时有值
private final Set<String> directionCodes; // 仅 ID_LIST 时有值
private ObjectDataScope(State state, Set<Long> ids, Set<String> directionCodes) {
this.state = state;
this.ids = ids == null ? Collections.emptySet() : Collections.unmodifiableSet(ids);
this.directionCodes = directionCodes == null ? Collections.emptySet() : Collections.unmodifiableSet(directionCodes);
}
public static ObjectDataScope all() {
return new ObjectDataScope(State.ALL, null, null);
}
public static ObjectDataScope empty() {
return new ObjectDataScope(State.EMPTY, null, null);
}
public static ObjectDataScope idList(Set<Long> ids, Set<String> directionCodes) {
boolean idsEmpty = ids == null || ids.isEmpty();
boolean dcEmpty = directionCodes == null || directionCodes.isEmpty();
if (idsEmpty && dcEmpty) {
return empty();
}
return new ObjectDataScope(State.ID_LIST, ids, directionCodes);
}
/**
* 详情入口判定:当前 user 是否能"看到" (objectId, directionCode)。
* - ALL → true
* - ID_LIST → ids.contains(objectId) || directionCodes.contains(directionCode)
* - EMPTY → false
*/
public boolean contains(Long objectId, String objectDirectionCode) {
switch (state) {
case ALL: return true;
case EMPTY: return false;
case ID_LIST:
if (objectId != null && ids.contains(objectId)) return true;
if (objectDirectionCode != null && directionCodes.contains(objectDirectionCode)) return true;
return false;
default: return false;
}
}
}

View File

@@ -0,0 +1,12 @@
package com.njcn.rdms.module.project.service.datascope;
public interface ObjectDataScopeService {
/**
* 计算 user 在某 objectType 下能看到的对象范围。
*
* @param userId 登录用户 id
* @param objectType "product" 或 "project"
*/
ObjectDataScope compute(Long userId, String objectType);
}

View File

@@ -0,0 +1,94 @@
package com.njcn.rdms.module.project.service.datascope;
import cn.hutool.core.collection.CollUtil;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
import com.njcn.rdms.module.system.api.permission.PermissionApi;
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 数据权限 scope 计算实现。3 通道并集 + 超管短路。
*
* 设计docs/superpowers/specs/2026-05-14-object-data-scope-design.md 第 3.3 节
*
* 3 通道全接通 + 超管短路:通道 1自己参与+ 通道 2组织负责人反推+ 通道 3用户可见性配置
*/
@Service
@Slf4j
public class ObjectDataScopeServiceImpl implements ObjectDataScopeService {
@Resource
private PermissionApi permissionApi;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
// channel1 用 Mapper 直接查同模块channel3 在阶段 2 注入跨模块 API
@Resource
private OrgLeaderApi orgLeaderApi;
@Resource
private UserVisibilityConfigApi userVisibilityConfigApi;
@Override
public ObjectDataScope compute(Long userId, String objectType) {
if (Boolean.TRUE.equals(permissionApi.isSuperAdmin(userId).getCheckedData())) {
return ObjectDataScope.all();
}
Set<Long> ids = new HashSet<>();
Set<String> directionCodes = new HashSet<>();
ids.addAll(computeChannel1(userId, objectType));
ids.addAll(computeChannel2(userId, objectType));
UserVisibilityConfigRespDTO cfg = userVisibilityConfigApi.getConfig(userId).getCheckedData();
if (cfg != null) {
if ("all".equals(cfg.getType())) {
return ObjectDataScope.all(); // 短路
}
if ("directions".equals(cfg.getType()) && CollUtil.isNotEmpty(cfg.getDirectionCodes())) {
directionCodes.addAll(cfg.getDirectionCodes());
}
// projects 当前不消费(设计文档明示)
}
log.info("[ObjectDataScope] user={} type={} ids.size={} directions.size={}",
userId, objectType, ids.size(), directionCodes.size());
if (ids.isEmpty() && directionCodes.isEmpty()) {
return ObjectDataScope.empty();
}
return ObjectDataScope.idList(ids, directionCodes);
}
Set<Long> computeChannel1(Long userId, String objectType) {
return userObjectRoleMapper.selectActiveListByObjectTypeAndUserId(objectType, userId).stream()
.map(UserObjectRoleDO::getObjectId)
.collect(Collectors.toSet());
}
/**
* 通道 2组织负责人反推。
* 通过 OrgLeaderApi 拿到当前用户作为负责人可覆盖的下属 userId 集合,
* 再查这批下属参与了哪些同类型对象,合并进 ids。
*/
Set<Long> computeChannel2(Long userId, String objectType) {
Set<Long> reachableUserIds = orgLeaderApi.getReachableUserIds(userId).getCheckedData();
if (CollUtil.isEmpty(reachableUserIds)) {
return Set.of();
}
return userObjectRoleMapper.selectListByUserIdsAndObjectType(reachableUserIds, objectType).stream()
.map(UserObjectRoleDO::getObjectId)
.collect(Collectors.toSet());
}
}

View File

@@ -0,0 +1,54 @@
package com.njcn.rdms.module.project.service.member;
import java.util.List;
/**
* 对象角色自动分配服务:新建产品 / 项目时按规则自动写 rdms_user_object_role。
*
* 写入规则(参 spec 7.1 节):
* - 创建者 = 责任人时,仍写 2 条 (user 同, role 不同),让 creator 信息不丢
* - 创建者 ≠ 责任人时,写 2 条 (user 不同, role 不同)
*
* watcher 批量写入参 spec 7.2 节,允许 watcher 跟 manager 是同一 user。
*
* 使用复活语义:(user, object, role) 三元组若存在 INACTIVE 行 → update 复活;
* 不存在 → INSERT已 ACTIVE → 跳过。
*/
public interface ObjectRoleAutoAssignService {
/**
* 自动落地 creator + manager 双角色记录(一次性写两条,给 fresh 创建流程使用)。
*
* @param objectType "product" 或 "project"
* @param objectId 新建对象 ID
* @param creatorUserId 创建者 user ID一般取 LoginUser
* @param managerUserId 责任人 user ID
* @param creatorRoleCode creator 角色 codeproduct_creator / project_creator
* @param managerRoleCode manager 角色 codeproduct_manager / project_manager
*/
void assignCreatorAndManager(String objectType, Long objectId,
Long creatorUserId, Long managerUserId,
String creatorRoleCode, String managerRoleCode);
/**
* 自动落地 creator 单角色记录manager 由现有业务流程已经写好的场景使用)。
*
* @param objectType "product" 或 "project"
* @param objectId 新建对象 ID
* @param creatorUserId 创建者 user ID
* @param creatorRoleCode creator 角色 code
*/
void assignCreator(String objectType, Long objectId, Long creatorUserId, String creatorRoleCode);
/**
* 自动落地 watcher 角色记录(批量、自动去重;空列表直接返回)。
*
* @param objectType "product" 或 "project"
* @param objectId 对象 ID
* @param watcherUserIds 关心人 user ID 列表(可空 / 重复,会去重)
* @param watcherRoleCode watcher 角色 codeproduct_watcher / project_watcher
*/
void assignWatchers(String objectType, Long objectId,
List<Long> watcherUserIds, String watcherRoleCode);
}

View File

@@ -0,0 +1,116 @@
package com.njcn.rdms.module.project.service.member;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* ObjectRoleAutoAssignService 实现。
*
* 写入分支(参 spec 7.1 / 7.2 节):
* - (user, object, role) 三元组不存在 → INSERT
* - 存在 ACTIVE → 跳过(防御性,正常流程不会走到)
* - 存在 INACTIVE → 复活status=ACTIVE, leftTime=null, joinedTime=now
*
* 用 selectByObjectUserAndRole不带 status 过滤)查老行,避免 INACTIVE 占索引位导致 INSERT 冲突。
*/
@Service
@Slf4j
public class ObjectRoleAutoAssignServiceImpl implements ObjectRoleAutoAssignService {
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private ObjectPermissionApi objectPermissionApi;
@Override
public void assignCreatorAndManager(String objectType, Long objectId,
Long creatorUserId, Long managerUserId,
String creatorRoleCode, String managerRoleCode) {
Long creatorRoleId = resolveRoleId(creatorRoleCode, objectType);
Long managerRoleId = resolveRoleId(managerRoleCode, objectType);
insertOrReactivate(objectType, objectId, managerUserId, managerRoleId, "auto: manager");
insertOrReactivate(objectType, objectId, creatorUserId, creatorRoleId, "auto: creator");
}
@Override
public void assignCreator(String objectType, Long objectId, Long creatorUserId, String creatorRoleCode) {
Long creatorRoleId = resolveRoleId(creatorRoleCode, objectType);
insertOrReactivate(objectType, objectId, creatorUserId, creatorRoleId, "auto: creator");
}
@Override
public void assignWatchers(String objectType, Long objectId,
List<Long> watcherUserIds, String watcherRoleCode) {
if (watcherUserIds == null || watcherUserIds.isEmpty()) {
return;
}
Long watcherRoleId = resolveRoleId(watcherRoleCode, objectType);
watcherUserIds.stream()
.filter(Objects::nonNull)
.distinct()
.forEach(uid -> insertOrReactivate(objectType, objectId, uid, watcherRoleId, "auto: watcher"));
}
private Long resolveRoleId(String roleCode, String objectType) {
ObjectRoleRespDTO role = objectPermissionApi
.getObjectRoleByCode(roleCode, ObjectRoleConstants.ROLE_SCOPE_OBJECT, objectType)
.getCheckedData();
if (role == null || role.getId() == null) {
// 按 objectType 派发到对应业务错误码,避免 IllegalStateException 透出 500
if (ProductObjectConstants.OBJECT_TYPE.equals(objectType)) {
throw exception(ErrorCodeConstants.PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED, roleCode);
}
if (ProjectObjectConstants.OBJECT_TYPE.equals(objectType)) {
throw exception(ErrorCodeConstants.PROJECT_INTERNAL_ROLE_NOT_CONFIGURED, roleCode);
}
// 未知 objectType 兜底(理论不会走到——调用方都用 ProductObjectConstants / ProjectObjectConstants
throw new IllegalStateException(
"内置对象角色未在 system_role 找到: code=" + roleCode + ", object_type=" + objectType);
}
return role.getId();
}
private void insertOrReactivate(String objectType, Long objectId, Long userId, Long roleId, String remark) {
UserObjectRoleDO existing = userObjectRoleMapper
.selectByObjectUserAndRole(objectType, objectId, userId, roleId);
if (existing != null && Objects.equals(existing.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
return;
}
LocalDateTime now = LocalDateTime.now();
if (existing == null) {
UserObjectRoleDO row = new UserObjectRoleDO();
row.setUserId(userId);
row.setObjectType(objectType);
row.setObjectId(objectId);
row.setRoleId(roleId);
row.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
row.setJoinedTime(now);
row.setLeftTime(null);
row.setRemark(remark);
userObjectRoleMapper.insert(row);
} else {
existing.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
existing.setLeftTime(null);
existing.setJoinedTime(now);
existing.setRemark(remark);
userObjectRoleMapper.updateById(existing);
}
}
}

View File

@@ -29,7 +29,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -65,7 +67,61 @@ public class ProductMemberServiceImpl implements ProductMemberService {
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId);
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
return members.stream().map(member -> {
// 拆分 ACTIVE / INACTIVE
// - ACTIVE 行按 userId 聚合同人多角色合并成一行manager 优先做主),非主角色名放 additionalRoleNames
// - INACTIVE 行保持独立(历史角色行各自留痕,便于审计)
List<UserObjectRoleDO> activeRows = new ArrayList<>();
List<UserObjectRoleDO> inactiveRows = new ArrayList<>();
for (UserObjectRoleDO m : members) {
if (Objects.equals(m.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
activeRows.add(m);
} else {
inactiveRows.add(m);
}
}
Map<Long, List<UserObjectRoleDO>> activeByUser = activeRows.stream()
.collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList()));
List<ProductMemberRespVO> result = new ArrayList<>();
activeByUser.forEach((userId, rows) -> {
UserObjectRoleDO primary = pickPrimaryRow(rows, roleMap);
List<String> additionalRoleNames = rows.stream()
.filter(r -> !Objects.equals(r.getId(), primary.getId()))
.map(r -> {
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
return role == null ? null : role.getName();
})
.filter(Objects::nonNull)
.sorted()
.collect(Collectors.toList());
result.add(toRespVO(primary, roleMap, userMap, product, additionalRoleNames));
});
// INACTIVE 行各自独立成行
inactiveRows.forEach(m -> result.add(toRespVO(m, roleMap, userMap, product, Collections.emptyList())));
return result;
}
/**
* 同 userId 多角色时选主角色行MANAGER_ROLE_CODE 优先,否则按 roleId 升序兜底。
*/
private UserObjectRoleDO pickPrimaryRow(List<UserObjectRoleDO> rows, Map<Long, ObjectRoleRespDTO> roleMap) {
return rows.stream()
.filter(r -> {
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
return role != null && Objects.equals(ProductObjectConstants.MANAGER_ROLE_CODE, role.getCode());
})
.findFirst()
.orElseGet(() -> rows.stream()
.min(Comparator.comparing(UserObjectRoleDO::getRoleId))
.orElseThrow());
}
private ProductMemberRespVO toRespVO(UserObjectRoleDO member,
Map<Long, ObjectRoleRespDTO> roleMap,
Map<Long, AdminUserRespDTO> userMap,
ProductDO product,
List<String> additionalRoleNames) {
ProductMemberRespVO respVO = new ProductMemberRespVO();
respVO.setId(member.getId());
respVO.setUserId(member.getUserId());
@@ -81,8 +137,8 @@ public class ProductMemberServiceImpl implements ProductMemberService {
respVO.setJoinedTime(member.getJoinedTime());
respVO.setLeftTime(member.getLeftTime());
respVO.setRemark(member.getRemark());
respVO.setAdditionalRoleNames(additionalRoleNames);
return respVO;
}).collect(Collectors.toList());
}
@Override
@@ -92,8 +148,10 @@ public class ProductMemberServiceImpl implements ProductMemberService {
public Long createProductMember(Long productId, ProductMemberSaveReqVO reqVO) {
ProductDO product = validateProductEditable(productId);
ObjectRoleRespDTO targetRole = validateProductRole(reqVO.getRoleId());
// 多角色支持:按 (user, object, role) 三元组判存在;不带 status 过滤以便 INACTIVE 行复活(避免唯一索引 INSERT 冲突)
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, productId, reqVO.getUserId());
.selectByObjectUserAndRole(ProductObjectConstants.OBJECT_TYPE, productId,
reqVO.getUserId(), targetRole.getId());
if (existingMember != null && Objects.equals(existingMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS);
}
@@ -101,6 +159,8 @@ public class ProductMemberServiceImpl implements ProductMemberService {
UserObjectRoleDO member;
UserObjectRoleDO before = null;
LocalDateTime now = LocalDateTime.now();
// 物理 INSERT vs INACTIVE 复活的 audit 语义区分null=新增INACTIVE 复活=REACTIVATE
String actionType;
if (existingMember == null) {
member = new UserObjectRoleDO();
member.setUserId(reqVO.getUserId());
@@ -112,6 +172,7 @@ public class ProductMemberServiceImpl implements ProductMemberService {
member.setLeftTime(null);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
before = cloneMember(existingMember);
member = existingMember;
@@ -121,9 +182,10 @@ public class ProductMemberServiceImpl implements ProductMemberService {
member.setLeftTime(null);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
userObjectRoleMapper.updateById(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_REACTIVATE;
}
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, before, member, null);
writeMemberAuditLog(member, actionType, before, member, null);
if (isManagerRole(targetRole)) {
transferManager(product, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
}
@@ -145,6 +207,16 @@ public class ProductMemberServiceImpl implements ProductMemberService {
UserObjectRoleDO before = cloneMember(member);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
// 多角色边界:避免 setRoleId 撞唯一索引——若 user 在该 product 已有 (targetRole) 的另一行(含 INACTIVE 历史行),
// 直接 update 会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突。业务侧明确报错。
if (!Objects.equals(member.getRoleId(), targetRole.getId())) {
UserObjectRoleDO conflict = userObjectRoleMapper.selectByObjectUserAndRole(
ProductObjectConstants.OBJECT_TYPE, productId, member.getUserId(), targetRole.getId());
if (conflict != null && !Objects.equals(conflict.getId(), member.getId())) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS);
}
}
if (isManagerRole(targetRole)) {
member.setRoleId(targetRole.getId());
userObjectRoleMapper.updateById(member);
@@ -268,13 +340,26 @@ public class ProductMemberServiceImpl implements ProductMemberService {
}
private void transferPreviousManager(Long productId, Long previousManagerUserId, Long previousManagerRoleId, String reason) {
// 多角色边界校验:若 user 在 (product, previousManagerRoleId) 已有任意行ACTIVE 或 INACTIVE 历史行),
// 后续 update / INSERT 都会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突 ——
// 一刀切抛业务异常让用户先去人工清理历史行。selectByObjectUserAndRole 不带 status 过滤。
UserObjectRoleDO targetRoleExisting = userObjectRoleMapper.selectByObjectUserAndRole(
ProductObjectConstants.OBJECT_TYPE, productId, previousManagerUserId, previousManagerRoleId);
if (targetRoleExisting != null) {
throw exception(ErrorCodeConstants.PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE, previousManagerRoleId);
}
// 多角色支持:按 (user, object, manager_role_id) 三元组定位 manager 行user 的 creator/specialist 等其他角色行不动
Long productManagerRoleId = resolveProductManagerRoleId();
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, productId, previousManagerUserId);
.selectActiveByObjectUserAndRole(ProductObjectConstants.OBJECT_TYPE, productId,
previousManagerUserId, productManagerRoleId);
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
LocalDateTime now = LocalDateTime.now();
UserObjectRoleDO member;
String actionType;
if (existingMember == null) {
// user 当前没有 manager 角色 ACTIVE 行 —— 兼容老逻辑:插入 previousManagerRoleId 行
member = new UserObjectRoleDO();
member.setUserId(previousManagerUserId);
member.setObjectType(ProductObjectConstants.OBJECT_TYPE);
@@ -287,21 +372,30 @@ public class ProductMemberServiceImpl implements ProductMemberService {
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
// existingMember 是 manager 行 ACTIVEupdate 改 role_id 成 previousManagerRoleId"降级"该行)
member = existingMember;
member.setRoleId(previousManagerRoleId);
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
member.setLeftTime(null);
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
member.setJoinedTime(now);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
}
userObjectRoleMapper.updateById(member);
}
writeMemberAuditLog(member, actionType, before, member, reason);
}
private Long resolveProductManagerRoleId() {
ObjectRoleRespDTO role = objectPermissionApi
.getObjectRoleByCode(ProductObjectConstants.MANAGER_ROLE_CODE,
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProductObjectConstants.OBJECT_TYPE)
.getCheckedData();
if (role == null || role.getId() == null) {
throw exception(ErrorCodeConstants.PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED,
ProductObjectConstants.MANAGER_ROLE_CODE);
}
return role.getId();
}
private boolean isManagerRole(ObjectRoleRespDTO role) {
return Objects.equals(ProductObjectConstants.MANAGER_ROLE_CODE, role.getCode());
}

View File

@@ -4,7 +4,11 @@ import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
@@ -81,6 +85,10 @@ public class ProductServiceImpl implements ProductService {
private AdminUserApi adminUserApi;
@Resource
private ProductRequirementModuleMapper requirementModuleMapper;
@Resource
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -99,6 +107,10 @@ public class ProductServiceImpl implements ProductService {
productMapper.insert(product);
initManagerMemberRelation(product);
// 多角色支持:自动落 creator 行(即使 creator == manager 也独立写一条,确保创建者身份不丢)
objectRoleAutoAssignService.assignCreator(ProductObjectConstants.OBJECT_TYPE, product.getId(),
SecurityFrameworkUtils.getLoginUserId(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PRODUCT_CREATOR.getCode());
initDefaultRequirementModule(product);
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
buildProductFieldChanges(null, product), null);
@@ -150,7 +162,17 @@ public class ProductServiceImpl implements ProductService {
// 5) 产品维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
writeManagerInitAuditLog(product.getId(), product.getManagerUserId());
// 6) 产品创建审计
// 6) 多角色支持:自动落 creator 行(不论 creator 是否在 members 列表里,都独立写一条 creator 角色)
objectRoleAutoAssignService.assignCreator(ProductObjectConstants.OBJECT_TYPE, product.getId(),
SecurityFrameworkUtils.getLoginUserId(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PRODUCT_CREATOR.getCode());
// 7) 关心人批量落地watcherUserIds 可空;可与 members 的 user 重叠 —— 多角色合法)
objectRoleAutoAssignService.assignWatchers(ProductObjectConstants.OBJECT_TYPE, product.getId(),
reqVO.getWatcherUserIds(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PRODUCT_WATCHER.getCode());
// 8) 产品创建审计
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
buildProductFieldChanges(null, product), null);
return product.getId();
@@ -241,43 +263,173 @@ public class ProductServiceImpl implements ProductService {
ProductDO product = validateProductExists(id);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
UserObjectRoleDO currentMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, id, loginUserId);
ProductContextRespVO respVO = new ProductContextRespVO();
respVO.setCurrentProduct(buildCurrentProduct(product));
if (currentMember == null) {
respVO.setNavs(Collections.emptyList());
respVO.setButtons(Collections.emptyList());
return respVO;
// 多角色支持:拿全部 ACTIVE 角色 ID 传聚合 API主角色由 API 端按 sort 升序挑选 + 附 additionalRoleNames
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, id, loginUserId);
if (!userRoles.isEmpty()) {
List<Long> roleIds = userRoles.stream().map(UserObjectRoleDO::getRoleId).distinct().toList();
return buildProductContext(product, roleIds, false, null);
}
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
.getObjectRolePermissionDetail(currentMember.getRoleId(), ObjectRoleConstants.ROLE_SCOPE_OBJECT,
// 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段)
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
if (!scope.contains(id, product.getDirectionCode())) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, "查看");
}
return buildImplicitObserverContext(product);
}
/**
* 隐式 observer 兜底上下文:用户无显式产品角色但在 scope 范围内,按 implicit_observer_product 角色渲染菜单/权限。
*/
private ProductContextRespVO buildImplicitObserverContext(ProductDO product) {
ObjectRoleRespDTO observerRole = objectPermissionApi
.getObjectRoleByCode(
com.njcn.rdms.module.system.enums.permission.ObjectRoleConstants.IMPLICIT_OBSERVER_PRODUCT.getCode(),
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProductObjectConstants.OBJECT_TYPE)
.getCheckedData();
ObjectRoleRespDTO currentRole = permissionDetail == null ? null : permissionDetail.getCurrentRole();
if (observerRole == null || observerRole.getId() == null) {
// 角色未配置,降级为无菜单的 guest 上下文(不应发生,属配置缺失)
return buildProductContextWithoutMenus(product, true);
}
return buildProductContext(product, List.of(observerRole.getId()), true, observerRole);
}
private ProductContextRespVO buildProductContext(ProductDO product, List<Long> roleIds, boolean guestFlag,
ObjectRoleRespDTO fallbackRole) {
ProductContextRespVO respVO = new ProductContextRespVO();
respVO.setCurrentProduct(buildCurrentProduct(product));
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
.getObjectRolePermissionDetailMerged(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProductObjectConstants.OBJECT_TYPE)
.getCheckedData();
ObjectRoleRespDTO currentRole = permissionDetail == null || permissionDetail.getCurrentRole() == null
? fallbackRole
: permissionDetail.getCurrentRole();
List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
? Collections.emptyList()
: permissionDetail.getMenus();
respVO.setCurrentRole(buildCurrentRole(currentMember, currentRole));
List<String> additionalRoleNames = permissionDetail == null || permissionDetail.getAdditionalRoleNames() == null
? Collections.emptyList()
: permissionDetail.getAdditionalRoleNames();
Long primaryRoleId = currentRole != null ? currentRole.getId() : (roleIds.isEmpty() ? null : roleIds.get(0));
respVO.setCurrentRole(buildCurrentRole(primaryRoleId, currentRole, guestFlag, additionalRoleNames));
respVO.setNavs(buildContextNavs(menus));
respVO.setButtons(buildContextButtons(menus));
return respVO;
}
private ProductContextRespVO buildProductContextWithoutMenus(ProductDO product, boolean guestFlag) {
ProductContextRespVO respVO = new ProductContextRespVO();
respVO.setCurrentProduct(buildCurrentProduct(product));
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag, Collections.emptyList()));
respVO.setNavs(Collections.emptyList());
respVO.setButtons(Collections.emptyList());
return respVO;
}
@Override
public PageResult<ProductDO> getProductPage(ProductPageReqVO pageReqVO) {
return productMapper.selectPage(pageReqVO);
// 计算当前用户在 product 域的数据权限范围
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
if (scope.getState() == ObjectDataScope.State.EMPTY) {
return PageResult.empty();
}
// 保留原有业务过滤条件(同 ProductMapper.selectPage 默认方法)
LambdaQueryWrapperX<ProductDO> wrapper = new LambdaQueryWrapperX<>();
if (StringUtils.hasText(pageReqVO.getKeyword())) {
wrapper.and(w -> w.like(ProductDO::getCode, pageReqVO.getKeyword())
.or()
.like(ProductDO::getName, pageReqVO.getKeyword()));
}
wrapper.eqIfPresent(ProductDO::getDirectionCode, pageReqVO.getDirectionCode())
.eqIfPresent(ProductDO::getManagerUserId, pageReqVO.getManagerUserId())
.eqIfPresent(ProductDO::getStatusCode, pageReqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, pageReqVO.getUpdateTime())
.orderByDesc(BaseDO::getCreateTime);
// 注入 scope 数据权限过滤条件(在所有业务条件之后)
if (scope.getState() == ObjectDataScope.State.ID_LIST) {
Set<Long> ids = scope.getIds();
Set<String> dcs = scope.getDirectionCodes();
wrapper.and(w -> {
boolean addedAny = false;
if (!ids.isEmpty()) {
w.in(ProductDO::getId, ids);
addedAny = true;
}
if (!dcs.isEmpty()) {
if (addedAny) {
w.or();
}
w.in(ProductDO::getDirectionCode, dcs);
}
});
}
// ALL 状态不加任何 scope 条件,直接查全部
return productMapper.selectPage(pageReqVO, wrapper);
}
@Override
public ProductOverviewSummaryRespVO getProductOverviewSummary() {
// 与列表对称:按当前用户的 ObjectDataScope 过滤统计数字(超管 ALL 走全表 SQL普通用户走 scope 过滤)
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
List<Map<String, Object>> rows = buildStatusCountRows(scope);
ProductOverviewSummaryRespVO respVO = new ProductOverviewSummaryRespVO();
respVO.setStatusCounts(buildProductStatusCounts(objectStatusModelMapper
.selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), productMapper.selectStatusCountList()));
.selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), rows));
return respVO;
}
/**
* 按 scope 算出 (statusCode, countValue) 行集,喂给 {@link #buildProductStatusCounts}。
* EMPTY 直接空集ALL 走原全表 GROUP BY SQLID_LIST 用 wrapper 取 status_codeJava 端 group + count。
*/
private List<Map<String, Object>> buildStatusCountRows(ObjectDataScope scope) {
if (scope.getState() == ObjectDataScope.State.EMPTY) {
return Collections.emptyList();
}
if (scope.getState() == ObjectDataScope.State.ALL) {
return productMapper.selectStatusCountList();
}
// ID_LIST
LambdaQueryWrapperX<ProductDO> wrapper = new LambdaQueryWrapperX<>();
wrapper.select(ProductDO::getStatusCode);
Set<Long> ids = scope.getIds();
Set<String> dcs = scope.getDirectionCodes();
wrapper.and(w -> {
boolean addedAny = false;
if (!ids.isEmpty()) {
w.in(ProductDO::getId, ids);
addedAny = true;
}
if (!dcs.isEmpty()) {
if (addedAny) {
w.or();
}
w.in(ProductDO::getDirectionCode, dcs);
}
});
return productMapper.selectList(wrapper).stream()
.filter(p -> p.getStatusCode() != null)
.collect(Collectors.groupingBy(ProductDO::getStatusCode, Collectors.counting()))
.entrySet().stream()
.map(e -> {
Map<String, Object> row = new HashMap<>();
row.put("statusCode", e.getKey());
row.put("countValue", e.getValue());
return row;
})
.collect(Collectors.toList());
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#reqVO.id",
@@ -520,13 +672,16 @@ public class ProductServiceImpl implements ProductService {
return BeanUtils.toBean(product, ProductContextProductRespVO.class);
}
private ProductContextRoleRespVO buildCurrentRole(UserObjectRoleDO currentMember, ObjectRoleRespDTO currentRole) {
private ProductContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag,
List<String> additionalRoleNames) {
ProductContextRoleRespVO roleRespVO = new ProductContextRoleRespVO();
roleRespVO.setRoleId(currentMember.getRoleId());
roleRespVO.setRoleId(roleId);
roleRespVO.setGuestFlag(guestFlag);
if (currentRole != null) {
roleRespVO.setRoleCode(currentRole.getCode());
roleRespVO.setRoleName(currentRole.getName());
}
roleRespVO.setAdditionalRoleNames(additionalRoleNames == null ? Collections.emptyList() : additionalRoleNames);
return roleRespVO;
}

View File

@@ -31,7 +31,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -69,7 +71,61 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId);
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
return members.stream().map(member -> {
// 拆分 ACTIVE / INACTIVE
// - ACTIVE 行按 userId 聚合同人多角色合并成一行manager 优先做主),非主角色名放 additionalRoleNames
// - INACTIVE 行保持独立(历史角色行各自留痕,便于审计)
List<UserObjectRoleDO> activeRows = new ArrayList<>();
List<UserObjectRoleDO> inactiveRows = new ArrayList<>();
for (UserObjectRoleDO m : members) {
if (Objects.equals(m.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
activeRows.add(m);
} else {
inactiveRows.add(m);
}
}
Map<Long, List<UserObjectRoleDO>> activeByUser = activeRows.stream()
.collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList()));
List<ProjectMemberRespVO> result = new ArrayList<>();
activeByUser.forEach((userId, rows) -> {
UserObjectRoleDO primary = pickPrimaryRow(rows, roleMap);
List<String> additionalRoleNames = rows.stream()
.filter(r -> !Objects.equals(r.getId(), primary.getId()))
.map(r -> {
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
return role == null ? null : role.getName();
})
.filter(Objects::nonNull)
.sorted()
.collect(Collectors.toList());
result.add(toRespVO(primary, roleMap, userMap, project, additionalRoleNames));
});
// INACTIVE 行各自独立成行
inactiveRows.forEach(m -> result.add(toRespVO(m, roleMap, userMap, project, Collections.emptyList())));
return result;
}
/**
* 同 userId 多角色时选主角色行MANAGER_ROLE_CODE 优先,否则按 roleId 升序兜底。
*/
private UserObjectRoleDO pickPrimaryRow(List<UserObjectRoleDO> rows, Map<Long, ObjectRoleRespDTO> roleMap) {
return rows.stream()
.filter(r -> {
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
return role != null && Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, role.getCode());
})
.findFirst()
.orElseGet(() -> rows.stream()
.min(Comparator.comparing(UserObjectRoleDO::getRoleId))
.orElseThrow());
}
private ProjectMemberRespVO toRespVO(UserObjectRoleDO member,
Map<Long, ObjectRoleRespDTO> roleMap,
Map<Long, AdminUserRespDTO> userMap,
ProjectDO project,
List<String> additionalRoleNames) {
ProjectMemberRespVO respVO = new ProjectMemberRespVO();
respVO.setId(member.getId());
respVO.setUserId(member.getUserId());
@@ -85,8 +141,8 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
respVO.setJoinedTime(member.getJoinedTime());
respVO.setLeftTime(member.getLeftTime());
respVO.setRemark(member.getRemark());
respVO.setAdditionalRoleNames(additionalRoleNames);
return respVO;
}).collect(Collectors.toList());
}
@Override
@@ -97,8 +153,10 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
ProjectDO project = validateProjectEditable(projectId);
validateMemberUser(reqVO.getUserId());
ObjectRoleRespDTO targetRole = validateProjectRole(reqVO.getRoleId());
// 多角色支持:按 (user, object, role) 三元组判存在;不带 status 过滤以便 INACTIVE 行复活(避免唯一索引 INSERT 冲突)
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, reqVO.getUserId());
.selectByObjectUserAndRole(ProjectObjectConstants.OBJECT_TYPE, projectId,
reqVO.getUserId(), targetRole.getId());
if (existingMember != null && Objects.equals(existingMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
throw exception(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS);
}
@@ -113,12 +171,16 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
member.setJoinedTime(LocalDateTime.now());
member.setLeftTime(null);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
// 物理 INSERT vs INACTIVE 复活的 audit 语义区分null=新增INACTIVE 复活=REACTIVATE
String actionType;
if (existingMember == null) {
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
userObjectRoleMapper.updateById(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_REACTIVATE;
}
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, before, member, null);
writeMemberAuditLog(member, actionType, before, member, null);
if (isManagerRole(targetRole)) {
transferManager(project, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
@@ -140,6 +202,16 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
UserObjectRoleDO before = cloneMember(member);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
// 多角色边界:避免 setRoleId 撞唯一索引——若 user 在该 project 已有 (targetRole) 的另一行(含 INACTIVE 历史行),
// 直接 update 会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突。业务侧明确报错,避免 SQL 异常透出。
if (!Objects.equals(member.getRoleId(), targetRole.getId())) {
UserObjectRoleDO conflict = userObjectRoleMapper.selectByObjectUserAndRole(
ProjectObjectConstants.OBJECT_TYPE, projectId, member.getUserId(), targetRole.getId());
if (conflict != null && !Objects.equals(conflict.getId(), member.getId())) {
throw exception(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS);
}
}
if (isManagerRole(targetRole)) {
// 项目经理交接只切换负责人并调整原经理角色,不再把原经理自动移出项目团队。
member.setRoleId(targetRole.getId());
@@ -302,13 +374,26 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
}
private void transferPreviousManager(Long projectId, Long previousManagerUserId, Long previousManagerRoleId, String reason) {
// 多角色边界校验:若 user 在 (project, previousManagerRoleId) 已有任意行ACTIVE 或 INACTIVE 历史行),
// 后续 update / INSERT 都会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突 ——
// 一刀切抛业务异常让用户先去人工清理历史行。selectByObjectUserAndRole 不带 status 过滤。
UserObjectRoleDO targetRoleExisting = userObjectRoleMapper.selectByObjectUserAndRole(
ProjectObjectConstants.OBJECT_TYPE, projectId, previousManagerUserId, previousManagerRoleId);
if (targetRoleExisting != null) {
throw exception(ErrorCodeConstants.PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE, previousManagerRoleId);
}
// 多角色支持:按 (user, object, manager_role_id) 三元组定位 manager 行user 的 creator/dev 等其他角色行不动
Long projectManagerRoleId = resolveProjectManagerRoleId();
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, previousManagerUserId);
.selectActiveByObjectUserAndRole(ProjectObjectConstants.OBJECT_TYPE, projectId,
previousManagerUserId, projectManagerRoleId);
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
LocalDateTime now = LocalDateTime.now();
UserObjectRoleDO member;
String actionType;
if (existingMember == null) {
// user 当前没有 manager 角色 ACTIVE 行(罕见,可能业务上不该走到这)—— 仍兼容老逻辑:插入 previousManagerRoleId 行
member = new UserObjectRoleDO();
member.setUserId(previousManagerUserId);
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
@@ -321,21 +406,30 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
// existingMember 是 manager 行 ACTIVEupdate 改 role_id 成 previousManagerRoleId"降级"该行)
member = existingMember;
member.setRoleId(previousManagerRoleId);
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
member.setLeftTime(null);
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
member.setJoinedTime(now);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
}
userObjectRoleMapper.updateById(member);
}
writeMemberAuditLog(member, actionType, before, member, reason);
}
private Long resolveProjectManagerRoleId() {
ObjectRoleRespDTO role = objectPermissionApi
.getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE,
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
if (role == null || role.getId() == null) {
throw exception(ErrorCodeConstants.PROJECT_INTERNAL_ROLE_NOT_CONFIGURED,
ProjectObjectConstants.MANAGER_ROLE_CODE);
}
return role.getId();
}
private boolean isManagerRole(ObjectRoleRespDTO role) {
return Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, role.getCode());
}

View File

@@ -39,6 +39,10 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO;
@@ -59,6 +63,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -102,6 +107,10 @@ class ProjectServiceImpl implements ProjectService {
private DictDataApi dictDataApi;
@Resource
private ProjectRequirementModuleMapper projectRequirementModuleMapper;
@Resource
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -132,6 +141,10 @@ class ProjectServiceImpl implements ProjectService {
projectMapper.insert(project);
initManagerMemberRelation(project);
// 多角色支持:自动落 creator 行(即使 creator == manager 也独立写一条,确保创建者身份不丢)
objectRoleAutoAssignService.assignCreator(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
SecurityFrameworkUtils.getLoginUserId(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_CREATOR.getCode());
initDefaultRequirementModule(project);
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
buildProjectFieldChanges(null, project), null);
@@ -195,7 +208,17 @@ class ProjectServiceImpl implements ProjectService {
// 5) 项目维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
writeManagerChangeAuditLog(project.getId(), null, project.getManagerUserId(), null);
// 6) 项目创建审计
// 6) 多角色支持:自动落 creator 行(不论 creator 是否在 members 列表里,都独立写一条 creator 角色)
objectRoleAutoAssignService.assignCreator(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
SecurityFrameworkUtils.getLoginUserId(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_CREATOR.getCode());
// 7) 关心人批量落地watcherUserIds 可空;可与 members 的 user 重叠 —— 多角色合法)
objectRoleAutoAssignService.assignWatchers(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
reqVO.getWatcherUserIds(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_WATCHER.getCode());
// 8) 项目创建审计
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
buildProjectFieldChanges(null, project), null);
return project.getId();
@@ -351,29 +374,46 @@ class ProjectServiceImpl implements ProjectService {
public ProjectContextRespVO getProjectContext(Long id) {
ProjectDO project = validateProjectExists(id);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
UserObjectRoleDO currentMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId);
if (currentMember != null) {
return buildProjectContext(project, currentMember.getRoleId(), false, null);
// 多角色支持:拿全部 ACTIVE 角色 ID 传聚合 API主角色按 sort 升序由 API 挑选 + 附 additionalRoleNames
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId);
if (!userRoles.isEmpty()) {
List<Long> roleIds = userRoles.stream().map(UserObjectRoleDO::getRoleId).distinct().toList();
return buildProjectContext(project, roleIds, false, null);
}
ObjectRoleRespDTO visitorRole = objectPermissionApi
.getObjectRoleByCode(ProjectObjectConstants.VISITOR_ROLE_CODE, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
// 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段)
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
if (!scope.contains(id, project.getDirectionCode())) {
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, "查看");
}
return buildImplicitObserverContext(project);
}
/**
* 隐式 observer 兜底上下文:用户无显式项目角色但在 scope 范围内,按 implicit_observer_project 角色渲染菜单/权限。
*/
private ProjectContextRespVO buildImplicitObserverContext(ProjectDO project) {
ObjectRoleRespDTO observerRole = objectPermissionApi
.getObjectRoleByCode(
com.njcn.rdms.module.system.enums.permission.ObjectRoleConstants.IMPLICIT_OBSERVER_PROJECT.getCode(),
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
if (visitorRole == null || visitorRole.getId() == null) {
if (observerRole == null || observerRole.getId() == null) {
// 角色未配置,降级为无菜单的 guest 上下文(不应发生,属配置缺失)
return buildProjectContextWithoutMenus(project, true);
}
return buildProjectContext(project, visitorRole.getId(), true, visitorRole);
return buildProjectContext(project, List.of(observerRole.getId()), true, observerRole);
}
private ProjectContextRespVO buildProjectContext(ProjectDO project, Long roleId, boolean guestFlag,
private ProjectContextRespVO buildProjectContext(ProjectDO project, List<Long> roleIds, boolean guestFlag,
ObjectRoleRespDTO fallbackRole) {
ProjectContextRespVO respVO = new ProjectContextRespVO();
respVO.setCurrentProject(buildCurrentProject(project));
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
.getObjectRolePermissionDetail(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
.getObjectRolePermissionDetailMerged(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
ObjectRoleRespDTO currentRole = permissionDetail == null || permissionDetail.getCurrentRole() == null
@@ -382,7 +422,11 @@ class ProjectServiceImpl implements ProjectService {
List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
? Collections.emptyList()
: permissionDetail.getMenus();
respVO.setCurrentRole(buildCurrentRole(roleId, currentRole, guestFlag));
List<String> additionalRoleNames = permissionDetail == null || permissionDetail.getAdditionalRoleNames() == null
? Collections.emptyList()
: permissionDetail.getAdditionalRoleNames();
Long primaryRoleId = currentRole != null ? currentRole.getId() : (roleIds.isEmpty() ? null : roleIds.get(0));
respVO.setCurrentRole(buildCurrentRole(primaryRoleId, currentRole, guestFlag, additionalRoleNames));
respVO.setNavs(buildContextNavs(menus));
respVO.setButtons(buildContextButtons(menus));
return respVO;
@@ -390,17 +434,106 @@ class ProjectServiceImpl implements ProjectService {
@Override
public PageResult<ProjectDO> getProjectPage(ProjectPageReqVO pageReqVO) {
return projectMapper.selectPage(pageReqVO);
// 计算当前用户在 project 域的数据权限范围
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
if (scope.getState() == ObjectDataScope.State.EMPTY) {
return PageResult.empty();
}
// 保留原有业务过滤条件(同 ProjectMapper.selectPage 默认方法)
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
if (StringUtils.hasText(pageReqVO.getKeyword())) {
wrapper.and(w -> w.like(ProjectDO::getProjectCode, pageReqVO.getKeyword())
.or()
.like(ProjectDO::getProjectName, pageReqVO.getKeyword()));
}
wrapper.eqIfPresent(ProjectDO::getProjectType, pageReqVO.getProjectType())
.eqIfPresent(ProjectDO::getDirectionCode, pageReqVO.getDirectionCode())
.eqIfPresent(ProjectDO::getProductId, pageReqVO.getProductId())
.eqIfPresent(ProjectDO::getManagerUserId, pageReqVO.getManagerUserId())
.eqIfPresent(ProjectDO::getStatusCode, pageReqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, pageReqVO.getUpdateTime())
.orderByDesc(BaseDO::getCreateTime);
// 注入 scope 数据权限过滤条件(在所有业务条件之后)
if (scope.getState() == ObjectDataScope.State.ID_LIST) {
Set<Long> ids = scope.getIds();
Set<String> dcs = scope.getDirectionCodes();
wrapper.and(w -> {
boolean addedAny = false;
if (!ids.isEmpty()) {
w.in(ProjectDO::getId, ids);
addedAny = true;
}
if (!dcs.isEmpty()) {
if (addedAny) {
w.or();
}
w.in(ProjectDO::getDirectionCode, dcs);
}
});
}
// ALL 状态不加任何 scope 条件,直接查全部
return projectMapper.selectPage(pageReqVO, wrapper);
}
@Override
public ProjectOverviewSummaryRespVO getProjectOverviewSummary() {
// 与列表对称:按当前用户的 ObjectDataScope 过滤统计数字(超管 ALL 走全表 SQL普通用户走 scope 过滤)
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
List<Map<String, Object>> rows = buildStatusCountRows(scope);
ProjectOverviewSummaryRespVO respVO = new ProjectOverviewSummaryRespVO();
respVO.setStatusCounts(buildProjectStatusCounts(objectStatusModelMapper
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), projectMapper.selectStatusCountList()));
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), rows));
return respVO;
}
/**
* 按 scope 算出 (statusCode, countValue) 行集,喂给 {@link #buildProjectStatusCounts}。
* EMPTY 直接空集ALL 走原全表 GROUP BY SQLID_LIST 用 wrapper 取 status_codeJava 端 group + count。
*/
private List<Map<String, Object>> buildStatusCountRows(ObjectDataScope scope) {
if (scope.getState() == ObjectDataScope.State.EMPTY) {
return Collections.emptyList();
}
if (scope.getState() == ObjectDataScope.State.ALL) {
return projectMapper.selectStatusCountList();
}
// ID_LIST
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
wrapper.select(ProjectDO::getStatusCode);
Set<Long> ids = scope.getIds();
Set<String> dcs = scope.getDirectionCodes();
wrapper.and(w -> {
boolean addedAny = false;
if (!ids.isEmpty()) {
w.in(ProjectDO::getId, ids);
addedAny = true;
}
if (!dcs.isEmpty()) {
if (addedAny) {
w.or();
}
w.in(ProjectDO::getDirectionCode, dcs);
}
});
return projectMapper.selectList(wrapper).stream()
.filter(p -> p.getStatusCode() != null)
.collect(Collectors.groupingBy(ProjectDO::getStatusCode, Collectors.counting()))
.entrySet().stream()
.map(e -> {
Map<String, Object> row = new HashMap<>();
row.put("statusCode", e.getKey());
row.put("countValue", e.getValue());
return row;
})
.collect(Collectors.toList());
}
private String getProductName(Long productId) {
if (productId == null) {
return null;
@@ -771,9 +904,11 @@ class ProjectServiceImpl implements ProjectService {
if (oldManagerUserId == null) {
return;
}
UserObjectRoleDO oldMember = userObjectRoleMapper.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE,
projectId, oldManagerUserId);
if (oldMember == null || !Objects.equals(oldMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
// 多角色支持:只 INACTIVATE manager 角色那一行user 在项目内的 creator/dev 等其他角色行不动
Long managerRoleId = resolveProjectManagerRoleId();
UserObjectRoleDO oldMember = userObjectRoleMapper.selectActiveByObjectUserAndRole(
ProjectObjectConstants.OBJECT_TYPE, projectId, oldManagerUserId, managerRoleId);
if (oldMember == null) {
return;
}
UserObjectRoleDO before = cloneMember(oldMember);
@@ -784,10 +919,13 @@ class ProjectServiceImpl implements ProjectService {
}
private void ensureManagerRelation(Long projectId, Long managerUserId, Long managerRoleId, String reason) {
UserObjectRoleDO existingMember = userObjectRoleMapper.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE,
projectId, managerUserId);
// 多角色支持:按 (user, object, manager_role_id) 三元组定位 manager 行;
// 用 selectByObjectUserAndRole不带 status 过滤)拿 INACTIVE 老行复活,避免 INSERT 冲突唯一索引
UserObjectRoleDO existingMember = userObjectRoleMapper.selectByObjectUserAndRole(
ProjectObjectConstants.OBJECT_TYPE, projectId, managerUserId, managerRoleId);
LocalDateTime now = LocalDateTime.now();
if (existingMember == null) {
// user 在项目内还没有 manager 角色行(可能已有 creator/dev 等其他角色,不影响)→ insert
UserObjectRoleDO member = new UserObjectRoleDO();
member.setUserId(managerUserId);
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
@@ -800,8 +938,8 @@ class ProjectServiceImpl implements ProjectService {
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, reason);
return;
}
// existingMember 已是 (user, object, manager_role_id) 行(可能 ACTIVE 或 INACTIVE→ 激活
UserObjectRoleDO before = cloneMember(existingMember);
existingMember.setRoleId(managerRoleId);
existingMember.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
existingMember.setLeftTime(null);
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
@@ -811,6 +949,18 @@ class ProjectServiceImpl implements ProjectService {
writeMemberAuditLog(existingMember, ObjectActivityConstants.MEMBER_ACTION_UPDATE, before, existingMember, reason);
}
private Long resolveProjectManagerRoleId() {
ObjectRoleRespDTO role = objectPermissionApi
.getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE,
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
if (role == null || role.getId() == null) {
throw new IllegalStateException("内置角色 " + ProjectObjectConstants.MANAGER_ROLE_CODE + " 未在 system_role 找到");
}
return role.getId();
}
private void changeStatus(ProjectDO project, String actionCode, String reason) {
String fromStatus = project.getStatusCode();
ObjectStatusTransitionDO transition = validateProjectTransition(fromStatus, actionCode, reason);
@@ -856,13 +1006,14 @@ class ProjectServiceImpl implements ProjectService {
private ProjectContextRespVO buildProjectContextWithoutMenus(ProjectDO project, boolean guestFlag) {
ProjectContextRespVO respVO = new ProjectContextRespVO();
respVO.setCurrentProject(buildCurrentProject(project));
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag));
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag, Collections.emptyList()));
respVO.setNavs(Collections.emptyList());
respVO.setButtons(Collections.emptyList());
return respVO;
}
private ProjectContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag) {
private ProjectContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag,
List<String> additionalRoleNames) {
ProjectContextRoleRespVO roleRespVO = new ProjectContextRoleRespVO();
roleRespVO.setRoleId(roleId);
roleRespVO.setGuestFlag(guestFlag);
@@ -870,6 +1021,7 @@ class ProjectServiceImpl implements ProjectService {
roleRespVO.setRoleCode(currentRole.getCode());
roleRespVO.setRoleName(currentRole.getName());
}
roleRespVO.setAdditionalRoleNames(additionalRoleNames == null ? Collections.emptyList() : additionalRoleNames);
return roleRespVO;
}

View File

@@ -2,6 +2,8 @@ package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
@@ -11,4 +13,12 @@ public interface ProjectStatusBoardService {
ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO);
/**
* 看板视图任务分页:一次请求返回若干状态列,每列附带当前页切片 + 该列总数。
* <p>statusCode 缺省=按状态字典返回全部列空列也回list=[]、total=0传若干个=只返回这些状态的列,
* 字典外的值静默忽略。pageNo / pageSize 应用到返回的所有列(每列各自分页但页码统一)。
* <p>list 元素结构与 /tasks/page 完全一致(共享 {@code assembleTaskRespVOPage} 装配方法)。
*/
ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO);
}

View File

@@ -1,24 +1,36 @@
package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService {
@@ -31,6 +43,8 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
private ProjectTaskMapper projectTaskMapper;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Resource
private ProjectTaskService projectTaskService;
@Override
public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) {
@@ -42,19 +56,97 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
@Override
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
// 执行 owner = 当前用户 → 看本执行下全部任务,等价于 seesAll。
if (!scope.seesAll()) {
ProjectExecutionDO exec = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
if (exec != null && Objects.equals(exec.getOwnerId(), userId)) {
scope = VisibilityScope.all();
}
}
VisibilityScope scope = resolveTaskScope(projectId, executionId);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return buildTaskStatusBoard(projectId, executionId, scope, reqVO, statusModels);
}
@Override
public ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO) {
VisibilityScope scope = resolveTaskScope(projectId, executionId);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
// 列选择:入参为空 → 全集;非空 → 与字典做交集(字典外 statusCode 静默忽略)
Set<String> selected = collectSelectedStatusCodes(reqVO.getStatusCode());
List<ObjectStatusModelDO> targetStatusModels = selected.isEmpty()
? statusModels
: statusModels.stream()
.filter(model -> selected.contains(model.getStatusCode()))
.collect(Collectors.toList());
ProjectTaskBoardPageRespVO respVO = new ProjectTaskBoardPageRespVO();
List<ProjectTaskBoardPageRespVO.ColumnItemVO> items = targetStatusModels.stream()
.map(statusModel -> buildBoardColumn(projectId, executionId, scope, reqVO, statusModel))
.collect(Collectors.toList());
respVO.setItems(items);
return respVO;
}
/**
* 把入参 statusCode 数组归一化成一个去重 Setnull / 空 / 全 blank 都视为"不选列 = 全集"。
*/
private Set<String> collectSelectedStatusCodes(String[] statusCodes) {
if (statusCodes == null || statusCodes.length == 0) {
return Collections.emptySet();
}
return Arrays.stream(statusCodes)
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toCollection(LinkedHashSet::new));
}
private ProjectTaskBoardPageRespVO.ColumnItemVO buildBoardColumn(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskBoardPageReqVO reqVO,
ObjectStatusModelDO statusModel) {
ProjectTaskPageReqVO innerReq = toInnerPageReq(reqVO, statusModel.getStatusCode());
PageResult<ProjectTaskDO> doPage = projectTaskMapper.selectPageByExecutionId(projectId, executionId, scope, innerReq);
PageResult<ProjectTaskRespVO> voPage = projectTaskService.assembleTaskRespVOPage(projectId, executionId, doPage);
ProjectTaskBoardPageRespVO.ColumnItemVO item = new ProjectTaskBoardPageRespVO.ColumnItemVO();
item.setStatusCode(statusModel.getStatusCode());
item.setStatusName(statusModel.getStatusName());
item.setSort(statusModel.getSort());
item.setTerminal(statusModel.getTerminalFlag());
item.setList(voPage.getList() == null ? Collections.emptyList() : voPage.getList());
item.setTotal(voPage.getTotal() == null ? 0L : voPage.getTotal());
return item;
}
/**
* 把看板分页入参翻译成单状态列的 /tasks/page 入参,复用现有 mapper 与装配逻辑。
*/
private ProjectTaskPageReqVO toInnerPageReq(ProjectTaskBoardPageReqVO reqVO, String statusCode) {
ProjectTaskPageReqVO innerReq = new ProjectTaskPageReqVO();
innerReq.setPageNo(reqVO.getPageNo());
innerReq.setPageSize(reqVO.getPageSize());
innerReq.setKeyword(reqVO.getKeyword());
innerReq.setParentTaskId(reqVO.getParentTaskId());
innerReq.setOwnerId(reqVO.getOwnerId());
innerReq.setUpdateTime(reqVO.getUpdateTime());
innerReq.setStatusCode(statusCode);
return innerReq;
}
/**
* 计算任务可见性 scope与 ProjectTaskServiceImpl#computeTaskScope 同款:
* 项目经理 → seesAll执行负责人 = 当前用户 → seesAll否则按 resolveForExecution 求并集。
*/
private VisibilityScope resolveTaskScope(Long projectId, Long executionId) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
if (scope.seesAll()) {
return scope;
}
ProjectExecutionDO exec = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
if (exec != null && Objects.equals(exec.getOwnerId(), userId)) {
return VisibilityScope.all();
}
return scope;
}
private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId,
VisibilityScope scope,
ProjectExecutionStatusBoardReqVO reqVO,

View File

@@ -207,9 +207,10 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
@VisibleForTesting
void validateProjectMember(Long projectId, Long userId) {
UserObjectRoleDO projectMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
if (projectMember == null) {
// 多角色支持user 在项目内有任意 ACTIVE 角色即可作为 assignee
if (userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId)
.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
}
}

View File

@@ -211,7 +211,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
respVO.setProgressRate(loadExecutionProgress(projectId, executionId));
applyLifecycle(respVO);
boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId);
applyLifecycle(respVO, rootTasksAllCompleted);
respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
return respVO;
}
@@ -225,6 +226,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return voPageResult;
}
fillExecutionProgress(projectId, list);
// 批量预聚合根任务完成态,避免列表 N+1。未命中执行 → 缺省 falsecomplete 按钮不下发。
Map<Long, Boolean> rootTasksAllCompletedMap = loadExecutionRootTasksAllCompletedMap(projectId, list);
// 批量补负责人昵称,避免 N+1
Set<Long> ownerIds = list.stream()
.map(ProjectExecutionRespVO::getOwnerId)
@@ -236,7 +239,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions与详情同款装配 lifecycle。
// 单行装配失败做兜底降级status_model 缺失等脏数据),避免影响整页返回。
try {
applyLifecycle(vo);
applyLifecycle(vo, rootTasksAllCompletedMap.getOrDefault(vo.getId(), false));
} catch (Exception e) {
log.warn("execution lifecycle apply failed in page assembly. executionId={}, statusCode={}, error={}",
vo.getId(), vo.getStatusCode(), e.getMessage());
@@ -392,9 +395,10 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@VisibleForTesting
void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) {
UserObjectRoleDO member = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, ownerId);
if (member == null) {
// 多角色支持user 在项目内有任意 ACTIVE 角色即视为项目成员
if (userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, ownerId)
.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_INVALID);
}
}
@@ -489,9 +493,10 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
private void validateExecutionAssigneeProjectScope(Long projectId, Long userId) {
UserObjectRoleDO member = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
if (member == null) {
// 多角色支持user 在项目内有任意 ACTIVE 角色即可作为 assignee
if (userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId)
.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
}
}
@@ -670,10 +675,12 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return StringUtils.hasText(value) ? value : "";
}
private void applyLifecycle(ProjectExecutionRespVO respVO) {
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤spec §7.1
private void applyLifecycle(ProjectExecutionRespVO respVO, boolean rootTasksAllCompleted) {
// 传入 ownerId / progressRate / rootTasksAllCompleted 用于 availableActions 的 owner-only、完成进度、
// 根任务完成态过滤。rootTasksAllCompleted=false 时不下发 complete避免任务仍进行中时执行被闭环。
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle =
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId(),
respVO.getProgressRate(), rootTasksAllCompleted);
respVO.setStatusName(lifecycle.statusName());
respVO.setTerminal(lifecycle.terminal());
respVO.setAllowEdit(lifecycle.allowEdit());
@@ -690,7 +697,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
private BigDecimal loadExecutionProgress(Long projectId, Long executionId) {
return normalizeProgress(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId));
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
return normalizeProgress(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId,
excludedStatusCodes));
}
private void fillExecutionProgress(Long projectId, List<ProjectExecutionRespVO> list) {
@@ -698,7 +707,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
.map(ProjectExecutionRespVO::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, BigDecimal> progressMap = loadExecutionProgressMap(projectId, executionIds);
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
Map<Long, BigDecimal> progressMap = loadExecutionProgressMap(projectId, executionIds, excludedStatusCodes);
list.forEach(vo -> vo.setProgressRate(progressMap.getOrDefault(vo.getId(), normalizeProgress(null))));
}
@@ -706,12 +716,13 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
* 列表口径批量聚合:一次 GROUP BY 取本页所有执行的一级任务平均进度。
* 未命中的 executionId执行下无一级任务不入 map由调用方 normalizeProgress 兜底为 0.00。
*/
private Map<Long, BigDecimal> loadExecutionProgressMap(Long projectId, Collection<Long> executionIds) {
private Map<Long, BigDecimal> loadExecutionProgressMap(Long projectId, Collection<Long> executionIds,
Collection<String> excludedStatusCodes) {
if (executionIds == null || executionIds.isEmpty()) {
return Collections.emptyMap();
}
List<Map<String, Object>> rows = projectTaskMapper
.selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds);
.selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds, excludedStatusCodes);
if (rows == null || rows.isEmpty()) {
return Collections.emptyMap();
}
@@ -729,6 +740,65 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return result;
}
private List<String> loadProgressExcludedTaskStatusCodes() {
List<String> statusCodes = objectStatusModelMapper
.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return statusCodes == null ? Collections.emptyList() : statusCodes;
}
/**
* 执行详情完成态:判断"参与聚合的根任务"是否全部为 completed。
* 筛选口径与 loadExecutionProgress 同源;空集(无参与聚合的根任务)返回 false禁止下发 complete。
*/
private boolean loadExecutionRootTasksAllCompleted(Long projectId, Long executionId) {
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
Map<String, Object> row = projectTaskMapper.selectRootTaskCompletionStateByExecutionId(projectId, executionId,
ProjectTaskConstants.STATUS_COMPLETED, excludedStatusCodes);
return isRootTasksAllCompleted(row);
}
/**
* 列表口径批量聚合:一次 GROUP BY 取本页所有执行的根任务完成态。
* 未命中的 executionId执行下无参与聚合的根任务不入 map由调用方按缺省 false 处理(不下发 complete
*/
private Map<Long, Boolean> loadExecutionRootTasksAllCompletedMap(Long projectId,
List<ProjectExecutionRespVO> list) {
Set<Long> executionIds = list.stream()
.map(ProjectExecutionRespVO::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (executionIds.isEmpty()) {
return Collections.emptyMap();
}
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
List<Map<String, Object>> rows = projectTaskMapper.selectRootTaskCompletionStateGroupByExecutionIds(
projectId, executionIds, ProjectTaskConstants.STATUS_COMPLETED, excludedStatusCodes);
if (rows == null || rows.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, Boolean> result = new HashMap<>(rows.size());
for (Map<String, Object> row : rows) {
if (row == null) {
continue;
}
Long executionId = toLong(row.getOrDefault("executionId", row.get("execution_id")));
if (executionId == null) {
continue;
}
result.put(executionId, isRootTasksAllCompleted(row));
}
return result;
}
private boolean isRootTasksAllCompleted(Map<String, Object> row) {
if (row == null) {
return false;
}
Long totals = toLong(row.get("totals"));
Long completedCount = toLong(row.getOrDefault("completedCount", row.get("completed_count")));
return totals != null && totals > 0 && completedCount != null && totals.equals(completedCount);
}
private Long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();

View File

@@ -12,6 +12,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -27,6 +28,10 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
* <li>剔除系统级动作 {@code auto_start}(不出现在 UI 按钮)。</li>
* <li>对 owner-only 状态推进动作complete / cancel / pause / resume按 {@code execution.ownerId == currentUserId} 字段硬卡过滤,
* 与 {@link ProjectExecutionServiceImpl} 中 {@code validateOwnerForAction} 同款判定。</li>
* <li>对 {@code complete} 动作叠加进度过滤,执行进度未达到 100 时不下发完成按钮。</li>
* <li>对 {@code complete} 动作再叠加根任务完成态过滤:要求该执行下"参与聚合的根任务"全部为 completed
* 才下发筛选口径与进度聚合同源execution_id + parent_task_id IS NULL + excludedStatusCodes
* 空集(无参与聚合的根任务)视为"未全部完成",不下发完成按钮,避免任务仍在进行中时执行被闭环。</li>
* </ol>
* 非状态动作delete / change-owner / update / assignee的权限码 / 字段过滤未纳入本字段,
* 前端按各动作对应权限码与 owner 字段独立判断spec §6.5 允许条件矩阵);
@@ -39,13 +44,16 @@ public class ProjectExecutionStatusViewService {
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectExecutionServiceImpl#validateOwnerForAction 一致。
*/
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
private static final String ACTION_COMPLETE = "complete";
private static final BigDecimal COMPLETE_PROGRESS_THRESHOLD = BigDecimal.valueOf(100);
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId) {
public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId, BigDecimal progressRate,
boolean rootTasksAllCompleted) {
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
if (statusModel == null) {
@@ -55,11 +63,13 @@ public class ProjectExecutionStatusViewService {
statusModel.getStatusName(),
statusModel.getTerminalFlag(),
statusModel.getAllowEdit(),
buildAvailableActions(statusCode, ownerId)
buildAvailableActions(statusCode, ownerId, progressRate, rootTasksAllCompleted)
);
}
private List<ProjectExecutionLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId) {
private List<ProjectExecutionLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId,
BigDecimal progressRate,
boolean rootTasksAllCompleted) {
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
.selectListByObjectTypeAndFromStatus(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
if (transitions == null || transitions.isEmpty()) {
@@ -72,6 +82,10 @@ public class ProjectExecutionStatusViewService {
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
// 完成动作额外要求:执行进度已达 100且参与聚合的根任务全部已完成避免任务仍进行中时执行被闭环
// 暂停、恢复、取消不受进度 / 根任务状态影响。
.filter(transition -> !ACTION_COMPLETE.equals(transition.getActionCode())
|| (isCompleteProgressSatisfied(progressRate) && rootTasksAllCompleted))
.map(transition -> {
ProjectExecutionLifecycleActionRespVO action = new ProjectExecutionLifecycleActionRespVO();
action.setActionCode(transition.getActionCode());
@@ -82,6 +96,10 @@ public class ProjectExecutionStatusViewService {
.toList();
}
private boolean isCompleteProgressSatisfied(BigDecimal progressRate) {
return progressRate != null && progressRate.compareTo(COMPLETE_PROGRESS_THRESHOLD) >= 0;
}
public record ProjectExecutionLifecycleView(String statusName,
Boolean terminal,
Boolean allowEdit,

View File

@@ -31,6 +31,14 @@ public interface ProjectTaskService {
*/
PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO);
/**
* 把任务 DO 分页结果整体装配成 RespVO 分页结果。
* <p>提供给"看板分页接口"复用同款装配口径,保证两个接口列元素结构 / 序列化完全一致;
* 看板分页内部按状态列循环时,应调用本方法装配每列,不要自行重复装配。
*/
PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId,
PageResult<ProjectTaskDO> doPage);
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
/**

View File

@@ -402,6 +402,17 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override
public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO);
return assembleTaskRespVOPage(projectId, executionId, pageResult);
}
/**
* 把 ProjectTaskDO 分页结果整体装配成 RespVO 分页结果(含 ownerNickname / assignees / 工时合计 / 父任务 owner / 执行 owner / 生命周期)。
* <p>提供给 /tasks/page 与 /tasks/board-page 共用,保证两个接口的列元素结构与序列化口径完全一致;
* /tasks/board-page 不应自行重复装配,避免字段演进时漂移。
*/
@Override
public PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId,
PageResult<ProjectTaskDO> pageResult) {
PageResult<ProjectTaskRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectTaskRespVO.class);
List<ProjectTaskRespVO> list = voPageResult.getList();
if (list == null || list.isEmpty()) {
@@ -708,6 +719,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算
if ("complete".equals(actionCode)) {
forceCompleteProgress(task);
} else if (task.getParentTaskId() != null) {
recalcParentProgressFrom(task.getParentTaskId());
}
// 取消 / 暂停 / 恢复:触发子任务级联(每个子任务的内部链路自身会再级联自己的子,链式实现整棵子树)
@@ -1024,21 +1037,22 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
*/
private void recalcParentProgressFrom(Long parentTaskId) {
Long current = parentTaskId;
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
while (current != null) {
ProjectTaskDO parent = projectTaskMapper.selectById(current);
if (parent == null) {
return;
}
List<ProjectTaskDO> children = projectTaskMapper.selectChildrenProgressByParentTaskId(current);
if (children.isEmpty()) {
return;
}
List<ProjectTaskDO> children = projectTaskMapper.selectChildrenProgressByParentTaskId(current,
excludedStatusCodes);
BigDecimal sum = BigDecimal.ZERO;
for (ProjectTaskDO child : children) {
BigDecimal cp = child.getProgressRate() == null ? BigDecimal.ZERO : child.getProgressRate();
sum = sum.add(cp);
}
BigDecimal avg = sum.divide(BigDecimal.valueOf(children.size()), 2, RoundingMode.HALF_UP);
BigDecimal avg = children.isEmpty()
? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
: sum.divide(BigDecimal.valueOf(children.size()), 2, RoundingMode.HALF_UP);
if (progressNumericallyEquals(avg, parent.getProgressRate())) {
return;
}
@@ -1047,6 +1061,12 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
}
private List<String> loadProgressExcludedTaskStatusCodes() {
List<String> statusCodes = objectStatusModelMapper
.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return statusCodes == null ? Collections.emptyList() : statusCodes;
}
private boolean progressNumericallyEquals(BigDecimal a, BigDecimal b) {
if (a == null && b == null) {
return true;
@@ -1083,9 +1103,10 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
private void applyLifecycle(ProjectTaskRespVO respVO) {
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤spec §7.1
// 传入 ownerId / progressRate 用于 availableActions 的 owner-only 与完成进度过滤。
ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle =
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId(),
respVO.getProgressRate());
respVO.setStatusName(lifecycle.statusName());
respVO.setTerminal(lifecycle.terminal());
respVO.setAllowEdit(lifecycle.allowEdit());

View File

@@ -12,6 +12,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -27,6 +28,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
* <li>剔除系统级动作 {@code auto_start}(由工时填报内部触发,不出现在 UI 按钮)。</li>
* <li>对 owner-only 状态推进动作complete / cancel / pause / resume按 {@code task.ownerId == currentUserId} 字段硬卡过滤,
* 与 {@link ProjectTaskServiceImpl#validateOwnerForAction} 同款判定。</li>
* <li>对 {@code complete} 动作叠加进度过滤,任务进度未达到 100 时不下发完成按钮。</li>
* </ol>
* 非状态动作delete / 加协办人 / 工时填报等)的权限码 / 字段过滤未纳入本字段,前端按各动作对应权限码与 owner 字段独立判断;
* 全量化纳入 {@code availableActions} 留待后续阶段统一改造。
@@ -38,13 +40,15 @@ public class ProjectTaskStatusViewService {
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectTaskServiceImpl#validateOwnerForAction 一致。
*/
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
private static final String ACTION_COMPLETE = "complete";
private static final BigDecimal COMPLETE_PROGRESS_THRESHOLD = BigDecimal.valueOf(100);
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId) {
public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId, BigDecimal progressRate) {
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, statusCode);
if (statusModel == null) {
@@ -54,11 +58,12 @@ public class ProjectTaskStatusViewService {
statusModel.getStatusName(),
statusModel.getTerminalFlag(),
statusModel.getAllowEdit(),
buildAvailableActions(statusCode, ownerId)
buildAvailableActions(statusCode, ownerId, progressRate)
);
}
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId) {
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId,
BigDecimal progressRate) {
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
if (transitions == null || transitions.isEmpty()) {
@@ -71,6 +76,9 @@ public class ProjectTaskStatusViewService {
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
// 完成动作额外要求任务进度已达到 100暂停、恢复、取消不受进度影响
.filter(transition -> !ACTION_COMPLETE.equals(transition.getActionCode())
|| isCompleteProgressSatisfied(progressRate))
.map(transition -> {
ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO();
action.setActionCode(transition.getActionCode());
@@ -81,6 +89,10 @@ public class ProjectTaskStatusViewService {
.toList();
}
private boolean isCompleteProgressSatisfied(BigDecimal progressRate) {
return progressRate != null && progressRate.compareTo(COMPLETE_PROGRESS_THRESHOLD) >= 0;
}
public record ProjectTaskLifecycleView(String statusName,
Boolean terminal,
Boolean allowEdit,

View File

@@ -13,6 +13,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@@ -36,8 +38,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenMemberOnlyAndCurrentUserIsMember_shouldPass() {
Long productId = 1001L;
Long loginUserId = 2001L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(createMember(productId, loginUserId, 3001L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(List.of(createMember(productId, loginUserId, 3001L)));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermission(productId, null, true));
@@ -50,8 +52,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenCurrentRolePermissionsContainTarget_shouldPass() {
Long productId = 1002L;
Long loginUserId = 2002L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(createMember(productId, loginUserId, 3002L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(List.of(createMember(productId, loginUserId, 3002L)));
when(objectPermissionApi.getObjectRolePermissions(3002L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
.thenReturn(success(Set.of("project:product:query")));
@@ -65,8 +67,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenCurrentRoleDoesNotContainPermission_shouldThrowException() {
Long productId = 1003L;
Long loginUserId = 2003L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(createMember(productId, loginUserId, 3003L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(List.of(createMember(productId, loginUserId, 3003L)));
when(objectPermissionApi.getObjectRolePermissions(3003L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
.thenReturn(success(Set.of("project:product:update")));
@@ -82,8 +84,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenCurrentUserIsNotMember_shouldThrowException() {
Long productId = 1004L;
Long loginUserId = 2004L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(null);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(Collections.emptyList());
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
ServiceException ex = assertThrows(ServiceException.class,

View File

@@ -13,6 +13,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@@ -36,8 +38,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() {
Long projectId = 1001L;
Long loginUserId = 2001L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(createMember(projectId, loginUserId, 3001L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(List.of(createMember(projectId, loginUserId, 3001L)));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermission(projectId, null, true));
@@ -50,8 +52,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenNoActiveMember_shouldThrowProjectPermissionDenied() {
Long projectId = 1002L;
Long loginUserId = 2002L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(null);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
ServiceException ex = assertThrows(ServiceException.class,
@@ -64,8 +66,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenPermissionPresent_shouldPass() {
Long projectId = 1003L;
Long loginUserId = 2003L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(createMember(projectId, loginUserId, 3003L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(List.of(createMember(projectId, loginUserId, 3003L)));
when(objectPermissionApi.getObjectRolePermissions(3003L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
.thenReturn(success(Set.of("project:project:update")));
@@ -79,8 +81,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenPermissionMissing_shouldThrowProjectPermissionDenied() {
Long projectId = 1004L;
Long loginUserId = 2004L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(createMember(projectId, loginUserId, 3004L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(List.of(createMember(projectId, loginUserId, 3004L)));
when(objectPermissionApi.getObjectRolePermissions(3004L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
.thenReturn(success(Set.of("project:project:query")));

View File

@@ -0,0 +1,151 @@
package com.njcn.rdms.module.project.service.datascope;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
import com.njcn.rdms.module.system.api.permission.PermissionApi;
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
class ObjectDataScopeServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ObjectDataScopeServiceImpl service;
@Mock
private PermissionApi permissionApi;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
private OrgLeaderApi orgLeaderApi;
@Mock
private UserVisibilityConfigApi userVisibilityConfigApi;
@Test
void compute_returnsAll_whenUserIsSuperAdmin() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(true));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ALL);
}
// ---- helper ----
private static UserObjectRoleDO row(Long objectId) {
UserObjectRoleDO r = new UserObjectRoleDO();
r.setObjectId(objectId);
return r;
}
private static UserVisibilityConfigRespDTO cfg(String type, Set<String> directionCodes) {
UserVisibilityConfigRespDTO dto = new UserVisibilityConfigRespDTO();
dto.setType(type);
dto.setDirectionCodes(directionCodes);
return dto;
}
// ---- task 0.4 旧 case补通道 2 + 通道 3 mockstrict mode 不报未预期调用)----
@Test
void compute_returnsEmpty_whenNonSuperAdminAndNoChannelMatch() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
}
@Test
void compute_returnsIdList_withChannel1Hits() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L))
.thenReturn(List.of(row(101L), row(102L)));
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ID_LIST);
assertThat(scope.getIds()).containsExactlyInAnyOrder(101L, 102L);
}
// ---- task 1.4 新 case ----
@Test
void compute_unionsChannel1AndChannel2_whenUserIsLeader() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
// 通道 1 命中 101
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L))
.thenReturn(List.of(row(101L)));
// 通道 2 命中 102 / 103
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of(2L, 3L)));
when(userObjectRoleMapper.selectListByUserIdsAndObjectType(Set.of(2L, 3L), "project"))
.thenReturn(List.of(row(102L), row(103L)));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getIds()).containsExactlyInAnyOrder(101L, 102L, 103L);
}
@Test
void compute_skipsChannel2_whenUserIsNotLeader() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L))
.thenReturn(List.of());
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
}
// ---- task 2.6 新 case通道 3 ----
@Test
void compute_returnsAll_whenChannel3IsAll() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(cfg("all", null)));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ALL);
}
@Test
void compute_addsDirectionCodes_whenChannel3IsDirections() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(cfg("directions", Set.of("system", "embedded"))));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ID_LIST);
assertThat(scope.getIds()).isEmpty();
assertThat(scope.getDirectionCodes()).containsExactlyInAnyOrder("system", "embedded");
}
@Test
void compute_ignoresChannel3_whenTypeIsProjects() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
UserVisibilityConfigRespDTO projectsCfg = new UserVisibilityConfigRespDTO();
projectsCfg.setType("projects");
projectsCfg.setProjectIds(Set.of(101L, 102L));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(projectsCfg));
ObjectDataScope scope = service.compute(1L, "project");
// 业务暂不消费 projects 类型 → ids/directionCodes 都空 → EMPTY
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
}
}

View File

@@ -0,0 +1,62 @@
package com.njcn.rdms.module.project.service.datascope;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class ObjectDataScopeTest {
@Test
void all_contains_anything() {
ObjectDataScope scope = ObjectDataScope.all();
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ALL);
assertThat(scope.contains(1L, "any")).isTrue();
assertThat(scope.contains(null, null)).isTrue();
}
@Test
void empty_contains_nothing() {
ObjectDataScope scope = ObjectDataScope.empty();
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
assertThat(scope.contains(1L, "any")).isFalse();
}
@Test
void idList_with_only_ids_matches_ids() {
ObjectDataScope scope = ObjectDataScope.idList(Set.of(1L, 2L), Set.of());
assertThat(scope.contains(1L, null)).isTrue();
assertThat(scope.contains(99L, null)).isFalse();
}
@Test
void idList_with_only_directionCodes_matches_directions() {
ObjectDataScope scope = ObjectDataScope.idList(Set.of(), Set.of("system"));
assertThat(scope.contains(99L, "system")).isTrue();
assertThat(scope.contains(99L, "embedded")).isFalse();
}
@Test
void idList_with_both_or_semantics() {
ObjectDataScope scope = ObjectDataScope.idList(Set.of(1L), Set.of("system"));
assertThat(scope.contains(1L, "embedded")).isTrue(); // id 命中
assertThat(scope.contains(99L, "system")).isTrue(); // direction 命中
assertThat(scope.contains(99L, "embedded")).isFalse(); // 都不命中
}
@Test
void idList_with_empty_inputs_returns_empty_state() {
ObjectDataScope scope = ObjectDataScope.idList(Set.of(), Set.of());
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
}
@Test
void ids_and_directions_are_unmodifiable() {
ObjectDataScope scope = ObjectDataScope.idList(Set.of(1L), Set.of("system"));
org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class,
() -> scope.getIds().add(99L));
org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class,
() -> scope.getDirectionCodes().add("embedded"));
}
}

View File

@@ -19,6 +19,7 @@ import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
@@ -30,6 +31,7 @@ import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
@@ -97,7 +99,7 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
() -> productMemberService.createProductMember(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_MEMBER_ROLE_INVALID.getCode(), ex.getCode());
verify(userObjectRoleMapper, never()).selectByObjectAndUserId(any(), any(), any());
verify(userObjectRoleMapper, never()).selectByObjectUserAndRole(any(), any(), any(), any());
}
@Test
@@ -153,6 +155,56 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class));
}
@Test
void createProductMember_sameUserDifferentRole_shouldAllow() {
Long productId = 1010L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3102L);
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "product"))
.thenReturn(success(createRole(3102L, "product_specialist", "产品专员")));
// user=2002L 在产品里 (2002L, 3102L) 这个 role 不存在 —— 即便 user 已有别的 role新 role 应允许
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3102L))
.thenReturn(null);
productMemberService.createProductMember(productId, reqVO);
// 不抛 ALREADY_EXISTS新加一行
verify(userObjectRoleMapper).insert(any(UserObjectRoleDO.class));
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
}
@Test
void createProductMember_inactiveSameRoleExists_shouldReactivate() {
Long productId = 1011L;
Long userId = 2002L;
Long roleId = 3102L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(userId);
reqVO.setRoleId(roleId);
UserObjectRoleDO inactiveOld = createMember(9099L, productId, userId, roleId, 1);
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(objectPermissionApi.getObjectRoleById(roleId, "object", "product"))
.thenReturn(success(createRole(roleId, "product_specialist", "产品专员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, userId, roleId))
.thenReturn(inactiveOld);
productMemberService.createProductMember(productId, reqVO);
// 复活updateById 被调status=ACTIVEleftTime=null不调 insert
ArgumentCaptor<UserObjectRoleDO> captor = ArgumentCaptor.forClass(UserObjectRoleDO.class);
verify(userObjectRoleMapper).updateById(captor.capture());
assertEquals(0, captor.getValue().getStatus());
assertNull(captor.getValue().getLeftTime());
verify(userObjectRoleMapper, never()).insert(any(UserObjectRoleDO.class));
}
private ProductDO createProduct(Long productId, Long managerUserId) {
ProductDO product = new ProductDO();
product.setId(productId);
@@ -199,4 +251,118 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
return statusModel;
}
// ============== review 遗留 4 条 issue 修复后的回归用例 ==============
/** Issue 1transferPreviousManager 时原经理在目标 role 上有 INACTIVE 历史行 → 抛 DUPLICATE 异常(不是撞 SQL。 */
@Test
void transferPreviousManager_targetRoleHasInactiveHistory_shouldThrowDuplicate() {
Long productId = 1020L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3201L);
reqVO.setPreviousManagerUserId(2001L);
reqVO.setPreviousManagerRoleId(3202L);
ProductDO product = createProduct(productId, 2001L);
UserObjectRoleDO inactiveOldRow = createMember(9077L, productId, 2001L, 3202L, 1);
inactiveOldRow.setLeftTime(LocalDateTime.now().minusDays(30));
when(productMapper.selectById(productId)).thenReturn(product);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(objectPermissionApi.getObjectRoleById(3201L, "object", "product"))
.thenReturn(success(createRole(3201L, "product_manager", "产品经理")));
when(objectPermissionApi.getObjectRoleById(3202L, "object", "product"))
.thenReturn(success(createRole(3202L, "product_specialist", "产品专员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3201L)).thenReturn(null);
// 关键:原经理 (2001L, 3202L) 上有 INACTIVE 历史行 —— pre-check 用不带 status 的 select 命中
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2001L, 3202L))
.thenReturn(inactiveOldRow);
ServiceException ex = assertThrows(ServiceException.class,
() -> productMemberService.createProductMember(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE.getCode(), ex.getCode());
}
/** Issue 2updateProductMember 改 roleId 时目标 role 已被该 user 的另一行占据 → 抛 ALREADY_EXISTS不是撞 SQL。 */
@Test
void updateProductMember_targetRoleHasOtherRow_shouldThrowAlreadyExists() {
Long productId = 1021L;
Long memberId = 9011L;
ProductMemberUpdateReqVO reqVO = new ProductMemberUpdateReqVO();
reqVO.setRoleId(3203L);
UserObjectRoleDO member = createMember(memberId, productId, 2002L, 3202L, 0);
// 该 user 已有另一行持有目标 role 3203L —— 直接 update 会撞唯一索引
UserObjectRoleDO conflictRow = createMember(9012L, productId, 2002L, 3203L, 0);
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(userObjectRoleMapper.selectByIdAndObject(memberId, "product", productId)).thenReturn(member);
when(objectPermissionApi.getObjectRoleById(3203L, "object", "product"))
.thenReturn(success(createRole(3203L, "product_dev", "产品开发")));
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3203L))
.thenReturn(conflictRow);
ServiceException ex = assertThrows(ServiceException.class,
() -> productMemberService.updateProductMember(productId, memberId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS.getCode(), ex.getCode());
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
}
/** Issue 3复活分支审计动作应为 REACTIVATE不是 ADD语义区分新增 vs 软失效后再激活)。 */
@Test
void createProductMember_inactiveSameRoleExists_auditActionShouldBeReactivate() {
Long productId = 1022L;
Long userId = 2002L;
Long roleId = 3202L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(userId);
reqVO.setRoleId(roleId);
UserObjectRoleDO inactiveOld = createMember(9088L, productId, userId, roleId, 1);
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(objectPermissionApi.getObjectRoleById(roleId, "object", "product"))
.thenReturn(success(createRole(roleId, "product_specialist", "产品专员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, userId, roleId))
.thenReturn(inactiveOld);
productMemberService.createProductMember(productId, reqVO);
ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
verify(bizAuditLogMapper).insert(auditCaptor.capture());
// reactivate_member 来自 ObjectActivityConstants.MEMBER_ACTION_REACTIVATE硬编码字面量避免引入额外 import
assertEquals("reactivate_member", auditCaptor.getValue().getActionType());
}
/** Issue 4内置 manager 角色未在 system_role 找到 → 抛业务 ServiceException不是 IllegalStateException 透出 500。 */
@Test
void resolveProductManagerRoleId_internalRoleMissing_shouldThrowBusinessException() {
Long productId = 1023L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3201L);
reqVO.setPreviousManagerUserId(2001L);
reqVO.setPreviousManagerRoleId(3202L);
ProductDO product = createProduct(productId, 2001L);
when(productMapper.selectById(productId)).thenReturn(product);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(objectPermissionApi.getObjectRoleById(3201L, "object", "product"))
.thenReturn(success(createRole(3201L, "product_manager", "产品经理")));
when(objectPermissionApi.getObjectRoleById(3202L, "object", "product"))
.thenReturn(success(createRole(3202L, "product_specialist", "产品专员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3201L)).thenReturn(null);
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2001L, 3202L)).thenReturn(null);
// 关键:内置 product_manager 角色查询返回 null —— resolveProductManagerRoleId 应抛业务异常
when(objectPermissionApi.getObjectRoleByCode("product_manager", "object", "product"))
.thenReturn(success(null));
ServiceException ex = assertThrows(ServiceException.class,
() -> productMemberService.createProductMember(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED.getCode(), ex.getCode());
}
}

View File

@@ -5,11 +5,15 @@ import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
@@ -35,11 +39,13 @@ import org.mockito.InjectMocks;
import org.mockito.MockedStatic;
import org.mockito.Mock;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -74,6 +80,10 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
private AdminUserApi adminUserApi;
@Mock
private com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper requirementModuleMapper;
@Mock
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Mock
private ObjectDataScopeService objectDataScopeService;
@Test
void createProduct_shouldCreateDefaultRequirementModule() {
@@ -460,9 +470,9 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
detail.setPermissions(Set.of("project:product:update"));
when(productMapper.selectById(productId)).thenReturn(product);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(currentMember);
when(objectPermissionApi.getObjectRolePermissionDetail(roleId, "object", "product"))
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(List.of(currentMember));
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(roleId), "object", "product"))
.thenReturn(success(detail));
ProductContextRespVO respVO;
@@ -479,6 +489,107 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
assertEquals(List.of("project:product:update"), respVO.getButtons());
}
@Test
void getProductContext_returnsImplicitObserver_whenNoExplicitRoleButScopeContains() {
Long productId = 1015L;
Long loginUserId = 3015L;
Long observerRoleId = 9201L;
ProductDO product = createProduct(productId, "direction_value", "隐式观察产品", 2018L, "描述", "active");
ObjectRoleRespDTO observerRole = createRole(observerRoleId, "implicit_observer_product", "产品隐式观察者");
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(observerRole);
detail.setMenus(List.of(
createMenu(9401L, "概览", null, 2, 10, "/product/overview", "mdi:view-dashboard-outline", true)
));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class)) {
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn("观察人");
when(productMapper.selectById(productId)).thenReturn(product);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(Collections.emptyList());
when(objectDataScopeService.compute(loginUserId, "product"))
.thenReturn(ObjectDataScope.idList(Set.of(productId), Set.of()));
when(objectPermissionApi.getObjectRoleByCode("implicit_observer_product", "object", "product"))
.thenReturn(success(observerRole));
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "product"))
.thenReturn(success(detail));
ProductContextRespVO respVO = productService.getProductContext(productId);
assertNotNull(respVO.getCurrentProduct());
assertEquals(observerRoleId, respVO.getCurrentRole().getRoleId());
assertEquals("implicit_observer_product", respVO.getCurrentRole().getRoleCode());
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
assertEquals(1, respVO.getNavs().size());
assertEquals(9401L, respVO.getNavs().get(0).getId());
}
}
@Test
void getProductContext_throws_whenNoExplicitRoleAndScopeMisses() {
Long productId = 1016L;
Long loginUserId = 3016L;
ProductDO product = createProduct(productId, "direction_value", "无权限产品", 2019L, "描述", "active");
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class)) {
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
when(productMapper.selectById(productId)).thenReturn(product);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(Collections.emptyList());
// scope 不包含该产品(空 ID 列表directionCode 也不匹配)
when(objectDataScopeService.compute(loginUserId, "product"))
.thenReturn(ObjectDataScope.idList(Set.of(9999L), Set.of("other_direction")));
ServiceException ex = assertThrows(ServiceException.class,
() -> productService.getProductContext(productId));
assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
}
}
@Test
void getProductPage_returnsEmpty_whenScopeIsEmpty() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.empty());
PageResult<ProductDO> result = productService.getProductPage(new ProductPageReqVO());
assertThat(result.getList()).isEmpty();
verify(productMapper, never()).selectPage(any(ProductPageReqVO.class), any());
}
}
@Test
void getProductPage_passesIdListIntoSql_whenScopeIsIdList() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "product"))
.thenReturn(ObjectDataScope.idList(Set.of(101L), Set.of()));
when(productMapper.selectPage(any(ProductPageReqVO.class), any())).thenReturn(new PageResult<>());
productService.getProductPage(new ProductPageReqVO());
verify(productMapper, times(1)).selectPage(any(ProductPageReqVO.class), any());
}
}
@Test
void getProductPage_skipsScopeFilter_whenScopeIsAll() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
when(productMapper.selectPage(any(ProductPageReqVO.class), any())).thenReturn(new PageResult<>());
productService.getProductPage(new ProductPageReqVO());
verify(productMapper, times(1)).selectPage(any(ProductPageReqVO.class), any());
}
}
private ProductDO createProduct(Long id, String directionCode, String name, Long managerUserId,
String description, String statusCode) {
ProductDO product = new ProductDO();

View File

@@ -99,7 +99,7 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2002L))
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3102L))
.thenReturn(createMember(9002L, projectId, 2002L, 3102L, 0));
ServiceException ex = assertThrows(ServiceException.class,
@@ -147,8 +147,15 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2002L)).thenReturn(null);
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2001L)).thenReturn(previousManager);
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3101L)).thenReturn(null);
// transferPreviousManager 内部:先校验目标 rolepreviousManagerRoleId=3102L无任何行冲突含 INACTIVE 历史)
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2001L, 3102L)).thenReturn(null);
// 再按 (user, object, manager_role_id=3101L) 三元组定位旧 manager 行
when(userObjectRoleMapper.selectActiveByObjectUserAndRole("project", projectId, 2001L, 3101L))
.thenReturn(previousManager);
// resolveProjectManagerRoleId() 需要拿 manager 内置角色
when(objectPermissionApi.getObjectRoleByCode("project_manager", "object", "project"))
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
projectMemberService.createProjectMember(projectId, reqVO);
@@ -226,6 +233,58 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED.getCode(), ex.getCode());
}
@Test
void createProjectMember_sameUserDifferentRole_shouldAllow() {
Long projectId = 1010L;
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3102L);
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
// user=2002L 在项目里 (2002L, 3102L) 这个 role 不存在 —— 即便 user 已有别的 role新 role 应允许
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3102L))
.thenReturn(null);
projectMemberService.createProjectMember(projectId, reqVO);
// 不抛 ALREADY_EXISTS新加一行
verify(userObjectRoleMapper).insert(any(UserObjectRoleDO.class));
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
}
@Test
void createProjectMember_inactiveSameRoleExists_shouldReactivate() {
Long projectId = 1011L;
Long userId = 2002L;
Long roleId = 3102L;
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
reqVO.setUserId(userId);
reqVO.setRoleId(roleId);
UserObjectRoleDO inactiveOld = createMember(9099L, projectId, userId, roleId, 1);
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(roleId, "object", "project"))
.thenReturn(success(createRole(roleId, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, userId, roleId))
.thenReturn(inactiveOld);
projectMemberService.createProjectMember(projectId, reqVO);
// 复活updateById 被调status=ACTIVEleftTime=null不调 insert
ArgumentCaptor<UserObjectRoleDO> captor = ArgumentCaptor.forClass(UserObjectRoleDO.class);
verify(userObjectRoleMapper).updateById(captor.capture());
assertEquals(0, captor.getValue().getStatus());
assertNull(captor.getValue().getLeftTime());
verify(userObjectRoleMapper, never()).insert(any(UserObjectRoleDO.class));
}
private ProjectDO createProject(Long projectId, Long managerUserId) {
ProjectDO project = new ProjectDO();
project.setId(projectId);
@@ -280,4 +339,121 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
return statusModel;
}
// ============== review 遗留 4 条 issue 修复后的回归用例 ==============
/** Issue 1transferPreviousManager 时原经理在目标 role 上有 INACTIVE 历史行 → 抛 DUPLICATE 异常(不是撞 SQL。 */
@Test
void transferPreviousManager_targetRoleHasInactiveHistory_shouldThrowDuplicate() {
Long projectId = 1020L;
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3101L);
reqVO.setPreviousManagerUserId(2001L);
reqVO.setPreviousManagerRoleId(3102L);
ProjectDO project = createProject(projectId, 2001L);
UserObjectRoleDO inactiveOldRow = createMember(9077L, projectId, 2001L, 3102L, 1);
inactiveOldRow.setLeftTime(LocalDateTime.now().minusDays(30));
when(projectMapper.selectById(projectId)).thenReturn(project);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(3101L, "object", "project"))
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3101L)).thenReturn(null);
// 关键:原经理 (2001L, 3102L) 上有 INACTIVE 历史行 —— pre-check 用不带 status 的 select 命中
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2001L, 3102L))
.thenReturn(inactiveOldRow);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectMemberService.createProjectMember(projectId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE.getCode(), ex.getCode());
}
/** Issue 2updateProjectMember 改 roleId 时目标 role 已被该 user 的另一行占据 → 抛 ALREADY_EXISTS不是撞 SQL。 */
@Test
void updateProjectMember_targetRoleHasOtherRow_shouldThrowAlreadyExists() {
Long projectId = 1021L;
Long memberId = 9011L;
ProjectMemberUpdateReqVO reqVO = new ProjectMemberUpdateReqVO();
reqVO.setRoleId(3103L);
UserObjectRoleDO member = createMember(memberId, projectId, 2002L, 3102L, 0);
// 该 user 已有另一行持有目标 role 3103L —— 直接 update 会撞唯一索引
UserObjectRoleDO conflictRow = createMember(9012L, projectId, 2002L, 3103L, 0);
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(userObjectRoleMapper.selectByIdAndObject(memberId, "project", projectId)).thenReturn(member);
when(objectPermissionApi.getObjectRoleById(3103L, "object", "project"))
.thenReturn(success(createRole(3103L, "project_dev", "项目开发")));
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3103L))
.thenReturn(conflictRow);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectMemberService.updateProjectMember(projectId, memberId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS.getCode(), ex.getCode());
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
}
/** Issue 3复活分支审计动作应为 REACTIVATE不是 ADD语义区分新增 vs 软失效后再激活)。 */
@Test
void createProjectMember_inactiveSameRoleExists_auditActionShouldBeReactivate() {
Long projectId = 1022L;
Long userId = 2002L;
Long roleId = 3102L;
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
reqVO.setUserId(userId);
reqVO.setRoleId(roleId);
UserObjectRoleDO inactiveOld = createMember(9088L, projectId, userId, roleId, 1);
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(roleId, "object", "project"))
.thenReturn(success(createRole(roleId, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, userId, roleId))
.thenReturn(inactiveOld);
projectMemberService.createProjectMember(projectId, reqVO);
ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
verify(bizAuditLogMapper).insert(auditCaptor.capture());
// reactivate_member 来自 ObjectActivityConstants.MEMBER_ACTION_REACTIVATE硬编码字面量避免引入额外 import
assertEquals("reactivate_member", auditCaptor.getValue().getActionType());
}
/** Issue 4内置 manager 角色未在 system_role 找到 → 抛业务 ServiceException不是 IllegalStateException 透出 500。 */
@Test
void resolveProjectManagerRoleId_internalRoleMissing_shouldThrowBusinessException() {
Long projectId = 1023L;
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3101L);
reqVO.setPreviousManagerUserId(2001L);
reqVO.setPreviousManagerRoleId(3102L);
ProjectDO project = createProject(projectId, 2001L);
when(projectMapper.selectById(projectId)).thenReturn(project);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(3101L, "object", "project"))
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3101L)).thenReturn(null);
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2001L, 3102L)).thenReturn(null);
// 关键:内置 project_manager 角色查询返回 null —— resolveProjectManagerRoleId 应抛业务异常
when(objectPermissionApi.getObjectRoleByCode("project_manager", "object", "project"))
.thenReturn(success(null));
ServiceException ex = assertThrows(ServiceException.class,
() -> projectMemberService.createProjectMember(projectId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_INTERNAL_ROLE_NOT_CONFIGURED.getCode(), ex.getCode());
}
}

View File

@@ -3,12 +3,16 @@ package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectLifecycleActionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
@@ -39,11 +43,13 @@ import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -81,6 +87,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
private AdminUserApi adminUserApi;
@Mock
private DictDataApi dictDataApi;
@Mock
private ObjectDataScopeService objectDataScopeService;
@Test
void getProjectDetail_shouldFillProductNameAndManagerNickname() {
@@ -322,22 +330,28 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
@Test
void getProjectContext_whenCreatorHasNoObjectRole_shouldReturnVisitorResources() {
// 改造后:无显式角色 + scope 命中 → 返回 implicit_observer_project 上下文(原 visitor 兜底已由 scope.contains 替代)
Long projectId = 1002L;
Long loginUserId = 3002L;
Long visitorRoleId = 9201L;
Long observerRoleId = 9202L;
ProjectDO project = createProject(projectId, null, "创建人项目", 2001L, "pending");
project.setCreator(String.valueOf(loginUserId));
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(createRole(visitorRoleId, "visitor", "游客"));
detail.setCurrentRole(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者"));
detail.setMenus(List.of(
createMenu(9301L, "概览", null, 2, 10, "/project/project/overview", "mdi:home-outline", true)
));
detail.setPermissions(Set.of());
detail.setAdditionalRoleNames(Collections.emptyList());
when(projectMapper.selectById(projectId)).thenReturn(project);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId)).thenReturn(null);
when(objectPermissionApi.getObjectRoleByCode("visitor", "object", "project"))
.thenReturn(success(createRole(visitorRoleId, "visitor", "游客")));
when(objectPermissionApi.getObjectRolePermissionDetail(visitorRoleId, "object", "project"))
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
// scope 命中该项目 id
when(objectDataScopeService.compute(loginUserId, "project"))
.thenReturn(ObjectDataScope.idList(Set.of(projectId), Set.of()));
when(objectPermissionApi.getObjectRoleByCode("implicit_observer_project", "object", "project"))
.thenReturn(success(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者")));
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "project"))
.thenReturn(success(detail));
when(projectStatusViewService.getLifecycle("pending"))
.thenReturn(new ProjectStatusViewService.ProjectLifecycleView(
@@ -357,14 +371,115 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
assertEquals(1, respVO.getCurrentProject().getAvailableActions().size());
assertEquals("cancel", respVO.getCurrentProject().getAvailableActions().get(0).getActionCode());
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
assertEquals(visitorRoleId, respVO.getCurrentRole().getRoleId());
assertEquals("visitor", respVO.getCurrentRole().getRoleCode());
assertEquals("游客", respVO.getCurrentRole().getRoleName());
assertEquals(observerRoleId, respVO.getCurrentRole().getRoleId());
assertEquals("implicit_observer_project", respVO.getCurrentRole().getRoleCode());
assertEquals("项目隐式观察者", respVO.getCurrentRole().getRoleName());
assertEquals(1, respVO.getNavs().size());
assertEquals(9301L, respVO.getNavs().get(0).getId());
assertEquals(List.of(), respVO.getButtons());
}
@Test
void getProjectContext_returnsImplicitObserver_whenNoExplicitRoleButScopeContains() {
Long projectId = 100L;
Long loginUserId = 1L;
Long observerRoleId = 9203L;
ProjectDO project = createProject(projectId, null, "观察者项目", 2001L, "active");
project.setDirectionCode("dc_obs");
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者"));
detail.setMenus(List.of(
createMenu(9401L, "任务", null, 2, 20, "/project/project/task", "mdi:format-list-checks", true)
));
detail.setPermissions(Set.of());
detail.setAdditionalRoleNames(Collections.emptyList());
when(projectMapper.selectById(projectId)).thenReturn(project);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
when(objectDataScopeService.compute(loginUserId, "project"))
.thenReturn(ObjectDataScope.idList(Set.of(projectId), Set.of()));
when(objectPermissionApi.getObjectRoleByCode("implicit_observer_project", "object", "project"))
.thenReturn(success(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者")));
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "project"))
.thenReturn(success(detail));
when(projectStatusViewService.getLifecycle("active"))
.thenReturn(new ProjectStatusViewService.ProjectLifecycleView(
"进行中", false, true, List.of()
));
ProjectContextRespVO respVO;
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "观察者")) {
respVO = projectService.getProjectContext(projectId);
}
assertNotNull(respVO);
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
assertEquals("项目隐式观察者", respVO.getCurrentRole().getRoleName());
assertEquals("implicit_observer_project", respVO.getCurrentRole().getRoleCode());
assertEquals(1, respVO.getNavs().size());
assertEquals(List.of(), respVO.getButtons());
}
@Test
void getProjectContext_throws_whenNoExplicitRoleAndScopeMisses() {
Long projectId = 100L;
Long loginUserId = 1L;
ProjectDO project = createProject(projectId, null, "无权限项目", 2001L, "active");
project.setDirectionCode("dc_miss");
when(projectMapper.selectById(projectId)).thenReturn(project);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
when(objectDataScopeService.compute(loginUserId, "project"))
.thenReturn(ObjectDataScope.empty());
ServiceException ex;
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "无关用户")) {
ex = assertThrows(ServiceException.class, () -> projectService.getProjectContext(projectId));
}
assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
}
@Test
void getProjectPage_returnsEmpty_whenScopeIsEmpty() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.empty());
PageResult<ProjectDO> result = projectService.getProjectPage(new ProjectPageReqVO());
assertThat(result.getList()).isEmpty();
verify(projectMapper, never()).selectPage(any(ProjectPageReqVO.class), any());
}
}
@Test
void getProjectPage_passesIdListIntoSql_whenScopeIsIdList() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "project"))
.thenReturn(ObjectDataScope.idList(Set.of(101L), Set.of()));
when(projectMapper.selectPage(any(ProjectPageReqVO.class), any())).thenReturn(new PageResult<>());
projectService.getProjectPage(new ProjectPageReqVO());
verify(projectMapper, times(1)).selectPage(any(ProjectPageReqVO.class), any());
}
}
@Test
void getProjectPage_skipsScopeFilter_whenScopeIsAll() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
when(projectMapper.selectPage(any(ProjectPageReqVO.class), any())).thenReturn(new PageResult<>());
projectService.getProjectPage(new ProjectPageReqVO());
verify(projectMapper, times(1)).selectPage(any(ProjectPageReqVO.class), any());
}
}
@Test
void changeProjectStatus_shouldUpdateStatusAndWriteStatusAndAuditLogs() {
Long projectId = 1003L;

View File

@@ -96,8 +96,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
@Test
void createExecutionAssignee_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() {
stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember());
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(List.of(createProjectMember()));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
ExecutionAssigneeDO m = inv.getArgument(0);
@@ -128,8 +128,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
void createExecutionAssignee_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() {
// B 模型:用户曾失效,重新加入新插一段,旧段不动
stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember());
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(List.of(createProjectMember()));
// 当前没有活跃段(旧段已失效),通过 active-only 校验
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
@@ -152,8 +152,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
@Test
void createExecutionAssignee_whenAlreadyActive_shouldThrowAlreadyExists() {
stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember());
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(List.of(createProjectMember()));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(createMember(7001L, USER_ID, null));

View File

@@ -51,6 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
@@ -119,12 +120,12 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createProjectStatus("pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("execution"))
.thenReturn(createExecutionStatus("pending", true));
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L))
.thenReturn(createProjectMember(projectId, 3002L, 3102L));
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3003L))
.thenReturn(createProjectMember(projectId, 3003L, 3102L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
.thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L)));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3003L))
.thenReturn(List.of(createProjectMember(projectId, 3003L, 3102L)));
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
.thenReturn(success(true));
@@ -180,8 +181,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createProjectStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("unknown")))
.thenThrow(new RuntimeException("invalid dict"));
@@ -204,8 +205,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createProjectStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
.thenReturn(success(true));
@@ -233,8 +234,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createExecutionStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调-修订")).thenReturn(null);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
.thenReturn(success(true));
@@ -255,8 +256,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createExecutionStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L))
.thenReturn(createProjectMember(projectId, 3002L, 3102L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
.thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L)));
ProjectExecutionOwnerChangeReqVO reqVO = new ProjectExecutionOwnerChangeReqVO();
reqVO.setNewOwnerId(3002L);
@@ -417,15 +418,17 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId))
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
.thenReturn(new BigDecimal("66.666"));
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("66.67")), anyBoolean()))
.thenReturn(createLifecycleView());
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
assertEquals(new BigDecimal("66.67"), result.getProgressRate());
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId);
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled"));
}
@Test
@@ -437,8 +440,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId)).thenReturn(null);
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
.thenReturn(null);
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("0.00")), anyBoolean()))
.thenReturn(createLifecycleView());
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
@@ -446,6 +452,27 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
assertEquals(new BigDecimal("0.00"), result.getProgressRate());
}
@Test
void getExecutionRespVO_shouldPassConfiguredExcludedStatusesToProgressAggregation() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
.thenReturn(new BigDecimal("100.00"));
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("100.00")), anyBoolean()))
.thenReturn(createLifecycleView());
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
assertEquals(new BigDecimal("100.00"), result.getProgressRate());
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled"));
}
@Test
void getExecutionRespVOPage_shouldBatchOverwriteCachedProgress() {
Long projectId = 2001L;
@@ -460,11 +487,13 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
.thenReturn(new PageResult<>(List.of(first, second), 2L));
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled"))))
.thenReturn(List.of(Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555"))));
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("25.56")), anyBoolean()))
.thenReturn(createLifecycleView());
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3002L), eq(new BigDecimal("0.00")), anyBoolean()))
.thenReturn(createLifecycleView());
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);
@@ -472,7 +501,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
assertEquals(new BigDecimal("25.56"), result.getList().get(0).getProgressRate());
assertEquals(new BigDecimal("0.00"), result.getList().get(1).getProgressRate());
ArgumentCaptor<Collection<Long>> executionIdsCaptor = ArgumentCaptor.forClass(Collection.class);
verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), executionIdsCaptor.capture());
verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), executionIdsCaptor.capture(),
eq(List.of("cancelled")));
assertEquals(List.of(5001L, 5002L), List.copyOf(executionIdsCaptor.getValue()));
}
@@ -493,11 +523,13 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
.thenReturn(new PageResult<>(List.of(first, second), 2L));
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled"))))
.thenReturn(rows);
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("25.56")), anyBoolean()))
.thenReturn(createLifecycleView());
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3002L), eq(new BigDecimal("10.00")), anyBoolean()))
.thenReturn(createLifecycleView());
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);

View File

@@ -3,6 +3,7 @@ package com.njcn.rdms.module.project.service.project.execution;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionLifecycleActionRespVO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
@@ -13,6 +14,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -45,7 +47,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId);
projectExecutionStatusViewService.getLifecycle("active", ownerId, BigDecimal.ZERO, false);
assertEquals("进行中", result.statusName());
assertFalse(result.terminal());
@@ -73,12 +75,83 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId);
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"), true);
assertTrue(result.availableActions().isEmpty());
}
}
@Test
void getLifecycle_whenOwnerButProgressBelow100_shouldFilterCompleteOnly() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
ObjectStatusTransitionDO cancel = createTransition("cancel", "取消", true);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
.thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
.thenReturn(List.of(complete, pause, cancel));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("99.99"), true);
assertEquals(List.of("pause", "cancel"), result.availableActions().stream()
.map(ProjectExecutionLifecycleActionRespVO::getActionCode)
.toList());
}
}
@Test
void getLifecycle_whenOwnerAndProgress100AndRootTasksAllCompleted_shouldReturnComplete() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
.thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
.thenReturn(List.of(complete));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"), true);
assertEquals(1, result.availableActions().size());
assertEquals("complete", result.availableActions().get(0).getActionCode());
}
}
@Test
void getLifecycle_whenOwnerAndProgress100ButRootTasksNotAllCompleted_shouldFilterComplete() {
// 任务进度算到 100 但任务状态尚未流转到 completed或空集complete 按钮不下发;
// 同时验证 pause / cancel 等不受根任务完成态影响。
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
ObjectStatusTransitionDO cancel = createTransition("cancel", "取消", true);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
.thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
.thenReturn(List.of(complete, pause, cancel));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"), false);
assertEquals(List.of("pause", "cancel"), result.availableActions().stream()
.map(ProjectExecutionLifecycleActionRespVO::getActionCode)
.toList());
}
}
@Test
void getLifecycle_shouldExcludeAutoStartAction() {
Long ownerId = 3001L;
@@ -98,7 +171,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("pending", ownerId);
projectExecutionStatusViewService.getLifecycle("pending", ownerId, BigDecimal.ZERO, false);
assertTrue(result.availableActions().isEmpty());
}
@@ -110,7 +183,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
.thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionStatusViewService.getLifecycle("active", 3001L));
() -> projectExecutionStatusViewService.getLifecycle("active", 3001L, BigDecimal.ZERO, false));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
}

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
@@ -32,8 +33,10 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -43,6 +46,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -319,6 +323,78 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
// -------------------- Phase 3 进度自动汇总 + 叶子转父限制 --------------------
@Test
void changeTaskStatus_whenCancelChild_shouldRecalculateParentProgressWithExcludedStatuses() {
Long projectId = 2001L;
Long executionId = 5001L;
Long parentTaskId = 8001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
task.setParentTaskId(parentTaskId);
ProjectTaskDO parent = createTask(projectId, executionId, parentTaskId, 3002L);
parent.setProgressRate(new BigDecimal("50.00"));
ProjectTaskDO remainingChild = createTask(projectId, executionId, 9002L, 3002L);
remainingChild.setProgressRate(new BigDecimal("100.00"));
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
reqVO.setActionCode("cancel");
reqVO.setReason("任务取消");
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel"))
.thenReturn(createTransition("cancel", "cancelled", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "cancelled"))
.thenReturn(createTerminalStatus("task", "cancelled"));
when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "cancelled", "任务取消")).thenReturn(1);
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parent);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled")))
.thenReturn(List.of(remainingChild));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3002L)) {
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
}
verify(projectTaskMapper).selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled"));
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
argThat(v -> new BigDecimal("100.00").compareTo(v) == 0));
}
@Test
void changeTaskStatus_whenAllChildrenExcluded_shouldResetParentProgressToZero() {
Long projectId = 2001L;
Long executionId = 5001L;
Long parentTaskId = 8001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
task.setParentTaskId(parentTaskId);
ProjectTaskDO parent = createTask(projectId, executionId, parentTaskId, 3002L);
parent.setProgressRate(new BigDecimal("80.00"));
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
reqVO.setActionCode("cancel");
reqVO.setReason("任务取消");
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel"))
.thenReturn(createTransition("cancel", "cancelled", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "cancelled"))
.thenReturn(createTerminalStatus("task", "cancelled"));
when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "cancelled", "任务取消")).thenReturn(1);
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parent);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled")))
.thenReturn(List.of());
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3002L)) {
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
}
verify(projectTaskMapper).selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled"));
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
argThat(v -> new BigDecimal("0.00").compareTo(v) == 0));
}
@Test
void createTask_whenParentIsLeafWithProgress_shouldThrowLeafToParentForbiddenProgress() {
Long projectId = 2001L;
@@ -418,7 +494,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
existingChild.setProgressRate(new BigDecimal("60.00"));
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
newChild.setProgressRate(BigDecimal.ZERO);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, Collections.emptyList()))
.thenReturn(List.of(existingChild, newChild));
projectTaskService.createTask(projectId, executionId, reqVO);
@@ -470,9 +546,9 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
when(projectTaskMapper.selectById(grandparentId)).thenReturn(grandparent);
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
newChild.setProgressRate(new BigDecimal("80.00"));
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, Collections.emptyList()))
.thenReturn(List.of(newChild));
when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId))
when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId, Collections.emptyList()))
.thenReturn(List.of(parentTask));
projectTaskService.createTask(projectId, executionId, reqVO);
@@ -526,16 +602,16 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
// 递归刷新两条链:旧父 8001 与 新父 8002
when(projectTaskMapper.selectById(oldParentId)).thenReturn(oldParent);
when(projectTaskMapper.selectById(newParentId)).thenReturn(newParent);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId))
.thenReturn(List.of()); // 旧父在迁移后无子,保留原值
when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId))
when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId, Collections.emptyList()))
.thenReturn(List.of()); // 旧父在迁移后无有效子任务,按新口径归零;当前值已为 0 不重复更新
when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId, Collections.emptyList()))
.thenReturn(List.of(task));
projectTaskService.updateTask(projectId, executionId, reqVO);
verify(projectTaskMapper).updateProgressRateById(eq(newParentId),
argThat(v -> new BigDecimal("70.00").compareTo(v) == 0));
// 旧父无recalcParentProgressFrom 早退,不调 updateProgressRateById
// 旧父无有效子任务且当前已为 0不重复更新
verify(projectTaskMapper, never()).updateProgressRateById(eq(oldParentId), any(BigDecimal.class));
}
@@ -591,6 +667,12 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
return status;
}
private ObjectStatusModelDO createTerminalStatus(String objectType, String statusCode) {
ObjectStatusModelDO status = createStatus(objectType, statusCode, false);
status.setTerminalFlag(true);
return status;
}
private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) {
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
transition.setActionCode(actionCode);
@@ -600,4 +682,10 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
return transition;
}
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
return mockedStatic;
}
}

View File

@@ -3,6 +3,7 @@ package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
@@ -13,6 +14,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -45,7 +47,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active", ownerId);
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
assertEquals("进行中", view.statusName());
assertFalse(view.terminal());
@@ -71,12 +73,55 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active", ownerId);
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
assertTrue(view.availableActions().isEmpty());
}
}
@Test
void getLifecycle_whenOwnerButProgressBelow100_shouldFilterCompleteOnly() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成");
ObjectStatusTransitionDO pause = createTransition("pause", "暂停");
ObjectStatusTransitionDO resume = createTransition("resume", "恢复");
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
.thenReturn(List.of(complete, pause, resume));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("99.99"));
assertEquals(List.of("pause", "resume"), view.availableActions().stream()
.map(ProjectTaskLifecycleActionRespVO::getActionCode)
.toList());
}
}
@Test
void getLifecycle_whenOwnerAndProgress100_shouldReturnComplete() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成");
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
.thenReturn(List.of(complete));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
assertEquals(1, view.availableActions().size());
assertEquals("complete", view.availableActions().get(0).getActionCode());
}
}
@Test
void getLifecycle_shouldExcludeAutoStartAction() {
Long ownerId = 3001L;
@@ -95,7 +140,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("pending", ownerId);
projectTaskStatusViewService.getLifecycle("pending", ownerId, BigDecimal.ZERO);
assertTrue(view.availableActions().isEmpty());
}
@@ -106,7 +151,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskStatusViewService.getLifecycle("missing", 3001L));
() -> projectTaskStatusViewService.getLifecycle("missing", 3001L, BigDecimal.ZERO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
}

View File

@@ -0,0 +1,27 @@
package com.njcn.rdms.module.system.api.dept;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.Set;
@FeignClient(name = ApiConstants.NAME)
@Tag(name = "RPC 服务 - 组织负责人")
public interface OrgLeaderApi {
String PREFIX = ApiConstants.PREFIX + "/org-leader";
/**
* 反推:当前 user 作为 leader 能"看到"的下属 user_id 集合(含递归子节点)。
* 自己默认不在结果里(自己看自己走通道 1
* 无 leader 关系返回空集。
*/
@GetMapping(PREFIX + "/get-reachable-user-ids")
@Operation(summary = "反推 leader 可见的下属 user 集合")
CommonResult<Set<Long>> getReachableUserIds(@RequestParam("currentUserId") Long currentUserId);
}

View File

@@ -70,6 +70,15 @@ public interface ObjectPermissionApi {
@RequestParam("scopeType") String scopeType,
@RequestParam("objectType") String objectType);
@GetMapping(PREFIX + "/role-permission-detail-merged")
@Operation(summary = "按 roleId 列表聚合菜单 + 权限码(多角色场景);主角色按 system_role.sort 升序取首个,菜单/权限取并集")
@Parameter(name = "roleIds", description = "角色 ID 集合", example = "1,2", required = true)
@Parameter(name = "scopeType", description = "权限作用域类型", example = "object", required = true)
@Parameter(name = "objectType", description = "对象类型", example = "product", required = true)
CommonResult<ObjectRolePermissionRespDTO> getObjectRolePermissionDetailMerged(@RequestParam("roleIds") Collection<Long> roleIds,
@RequestParam("scopeType") String scopeType,
@RequestParam("objectType") String objectType);
/**
* 按角色 ID 返回对象作用域角色摘要映射,便于业务模块批量对齐本地成员数据。
*

View File

@@ -24,4 +24,8 @@ public interface PermissionApi extends PermissionCommonApi {
@Parameter(name = "roleIds", description = "角色编号集合", example = "1,2", required = true)
CommonResult<Set<Long>> getUserRoleIdListByRoleIds(@RequestParam("roleIds") Collection<Long> roleIds);
@GetMapping(PREFIX + "/is-super-admin")
@Operation(summary = "判断用户是否超管")
CommonResult<Boolean> isSuperAdmin(@RequestParam("userId") Long userId);
}

View File

@@ -0,0 +1,25 @@
package com.njcn.rdms.module.system.api.permission;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
import com.njcn.rdms.module.system.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = ApiConstants.NAME)
@Tag(name = "RPC 服务 - 用户可见性配置")
public interface UserVisibilityConfigApi {
String PREFIX = ApiConstants.PREFIX + "/permission/user-visibility-config";
/**
* 拿用户的可见性配置(通道 3
* 无配置返回 null不抛异常
*/
@GetMapping(PREFIX + "/get-config")
@Operation(summary = "拿用户的可见性配置;无配置返回 null")
CommonResult<UserVisibilityConfigRespDTO> getConfig(@RequestParam("userId") Long userId);
}

View File

@@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@@ -20,4 +21,11 @@ public class ObjectRolePermissionRespDTO {
@ArraySchema(schema = @Schema(description = "基于同一批有效菜单资源归一化提取出的权限标识集合,供对象权限校验直接消费", example = "project:product:query"))
private Set<String> permissions;
/**
* 非主角色的中文名列表(多角色场景)。单角色或无角色时为空数组。
* 前端展示"创建者"等次要角色标签时读这个字段;权限判断仍按 currentRole.code。
*/
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用", example = "创建者"))
private List<String> additionalRoleNames = Collections.emptyList();
}

View File

@@ -0,0 +1,27 @@
package com.njcn.rdms.module.system.api.permission.dto;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Set;
/**
* 用户可见性配置(通道 3跨模块响应 DTO。
*
* directions 类型已由 API 端把 directionId → directionCode 转换好;业务侧不用再 join system_dept。
* projects 类型字段保留,当前业务不消费。
*/
@Schema(description = "RPC 服务 - 用户可见性配置 Response DTO")
@Data
public class UserVisibilityConfigRespDTO {
@Schema(description = "\"all\" / \"directions\" / \"projects\"", example = "all")
private String type;
@ArraySchema(schema = @Schema(description = "type=directions 时为方向 code 集合API 端已转换)", example = "direction_code_1"))
private Set<String> directionCodes;
@ArraySchema(schema = @Schema(description = "type=projects 时为项目 id 集合(保留位,当前业务不消费)", example = "1"))
private Set<Long> projectIds;
}

View File

@@ -152,4 +152,8 @@ public interface ErrorCodeConstants {
ErrorCode ORG_LEADER_EFFECTIVE_RANGE_INVALID = new ErrorCode(1_002_004_102, "负责人生效时间区间不合法");
ErrorCode ORG_LEADER_RELATION_OVERLAP = new ErrorCode(1_002_004_103, "同一组织下该用户的负责人时间区间存在重叠");
// ========== 用户可见性配置 1-002-003-200 ==========
ErrorCode USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH = new ErrorCode(1_002_003_200,
"可见性类型与字段不匹配type={}, 详情:{}");
}

View File

@@ -0,0 +1,40 @@
package com.njcn.rdms.module.system.enums.permission;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 内置对象角色清单。
*
* 业务代码按 code 查 system_role 拿 role_id创建者自动落地、关心人、隐式 observer 兜底等都用);
* 启动时按本枚举校验 system_role 必须存在;这些 code 同时已加入 {@link RoleCodeEnum#isBuiltIn(String)} 锁住改 code / 删除。
*
* 不含 product_manager / project_manager / visitor —— 已分别定义在
* ProductObjectConstants / ProjectObjectConstants避免重复维护 code 字面量。
*
* code / name 直接引用 {@link RoleCodeEnum},两处只有一份字符串。
*/
@Getter
@AllArgsConstructor
public enum ObjectRoleConstants {
PRODUCT_CREATOR(RoleCodeEnum.PRODUCT_CREATOR, "product"),
PRODUCT_WATCHER(RoleCodeEnum.PRODUCT_WATCHER, "product"),
IMPLICIT_OBSERVER_PRODUCT(RoleCodeEnum.IMPLICIT_OBSERVER_PRODUCT, "product"),
PROJECT_CREATOR(RoleCodeEnum.PROJECT_CREATOR, "project"),
PROJECT_WATCHER(RoleCodeEnum.PROJECT_WATCHER, "project"),
IMPLICIT_OBSERVER_PROJECT(RoleCodeEnum.IMPLICIT_OBSERVER_PROJECT, "project");
private final RoleCodeEnum roleCode;
private final String objectType;
public String getCode() {
return roleCode.getCode();
}
public String getName() {
return roleCode.getName();
}
}

View File

@@ -9,7 +9,18 @@ import lombok.Getter;
public enum RoleCodeEnum {
SUPER_ADMIN("super_admin", "超级管理员"),
CRM_ADMIN("crm_admin", "CRM 管理员");
CRM_ADMIN("crm_admin", "CRM 管理员"),
// 对象域内置角色:被业务代码硬编码引用(按 code 查 system_role改 code 或删除会让对应业务功能炸
PRODUCT_MANAGER("product_manager", "产品经理"),
PRODUCT_CREATOR("product_creator", "产品创建者"),
PRODUCT_WATCHER("product_watcher", "产品关心人"),
IMPLICIT_OBSERVER_PRODUCT("implicit_observer_product", "产品隐式观察者"),
PROJECT_MANAGER("project_manager", "项目经理"),
PROJECT_CREATOR("project_creator", "项目创建者"),
PROJECT_WATCHER("project_watcher", "项目关心人"),
IMPLICIT_OBSERVER_PROJECT("implicit_observer_project", "项目隐式观察者"),
VISITOR("visitor", "游客");
private final String code;
private final String name;
@@ -19,7 +30,13 @@ public enum RoleCodeEnum {
}
public static boolean isBuiltIn(String code) {
return ObjectUtils.equalsAny(code, SUPER_ADMIN.getCode(), CRM_ADMIN.getCode());
return ObjectUtils.equalsAny(code,
SUPER_ADMIN.getCode(), CRM_ADMIN.getCode(),
PRODUCT_MANAGER.getCode(), PRODUCT_CREATOR.getCode(),
PRODUCT_WATCHER.getCode(), IMPLICIT_OBSERVER_PRODUCT.getCode(),
PROJECT_MANAGER.getCode(), PROJECT_CREATOR.getCode(),
PROJECT_WATCHER.getCode(), IMPLICIT_OBSERVER_PROJECT.getCode(),
VISITOR.getCode());
}
}

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