From 0a2d5ba86c0255559706a9673aa534a96e7f1577 Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Wed, 8 Jul 2026 10:57:26 +0800 Subject: [PATCH] =?UTF-8?q?docs(guidelines):=20=E6=9B=B4=E6=96=B0=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E8=A7=84=E8=8C=83=E6=96=87=E6=A1=A3=E5=B9=B6=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E9=9C=80=E6=B1=82=E6=9C=8D=E5=8A=A1=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 CLAUDE.md 中补充对象域权限校验的第二种正规写法:属主放行 + 权限码兜底 - 移除 ProductRequirementServiceImplTest.java 文件中的所有单元测试代码 - 更新 ProjectExecutionServiceImpl 添加属主放行的权限校验实现 - 在执行服务中新增 requireExecutionEditDeletePermission 方法处理负责人权限 - 修正执行更新、删除及删除预检接口的权限验证逻辑 --- CLAUDE.md | 3 +- .../constant/ProjectTaskConstants.java | 4 +- .../project/task/ProjectTaskController.java | 2 +- .../ProjectObjectAuthorizationService.java | 20 +- .../ProjectExecutionServiceImpl.java | 44 +- .../project/task/ProjectTaskService.java | 6 +- .../project/task/ProjectTaskServiceImpl.java | 63 +- ...ProjectObjectAuthorizationServiceTest.java | 26 + .../ProductRequirementServiceImplTest.java | 726 ------------------ .../ProjectExecutionServiceImplTest.java | 92 ++- 10 files changed, 204 insertions(+), 782 deletions(-) delete mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImplTest.java diff --git a/CLAUDE.md b/CLAUDE.md index bdcdcc9..081a916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,8 @@ 红线: - **对象内接口绝不能挂 `@PreAuthorize("@ss.hasPermission(...)")`**。该注解走的链路在 `PermissionServiceImpl` 里强制按 GLOBAL 取角色(line 343-347)+ 强制按 GLOBAL 查菜单(line 92-94),对象域角色与对象域菜单都进不来,即使授权配置完全正确也必然 403。 -- **对象域权限校验必须落在 Service 层 `@CheckObjectPermission`**,原因:路径里 `objectId` 通常以 `#projectId`/`#productId` 等 SpEL 解析,Controller 的参数校验前置阶段不便复用;与同模块(`ProjectMemberServiceImpl` / `ProjectExecutionServiceImpl` / `ProjectExecutionAssigneeServiceImpl` / `ProjectTaskServiceImpl`)保持一致。 +- **对象域权限校验必须落在 Service 层**,默认写法是方法上挂 `@CheckObjectPermission`,原因:路径里 `objectId` 通常以 `#projectId`/`#productId` 等 SpEL 解析,Controller 的参数校验前置阶段不便复用;与同模块(`ProjectMemberServiceImpl` / `ProjectExecutionAssigneeServiceImpl` 等)保持一致。 +- **第二种正规写法:属主放行 + 权限码兜底(方法体内裁决)**。任务、执行的创建 / 编辑 / 删除 / 删除预检采用"项目/执行/任务负责人身份命中直接放行,未命中回落对象域权限码"的口径,权限裁决在方法体内调 `ProjectObjectAuthorizationService`(入口见 `ProjectTaskServiceImpl.requireTaskEditDeletePermission` / `ProjectExecutionServiceImpl.requireExecutionEditDeletePermission`)。这些方法**故意不挂** `@CheckObjectPermission`——注解是前置拦截,会把"无权限码的负责人"挡死。**不要误判为漏鉴权把注解补回去**;盘点权限面时除了搜注解,还要搜 `ProjectObjectAuthorizationService` 的调用点。口径来源:`docs/superpowers/specs/2026-07-07-执行编辑删除权限口径-design.md`。 - **同一接口不要两条通道叠加**。要么全域,要么对象域;叠加只会让对象域用户被全域那条卡死。 - 对象内**读路径**(列表 / 详情 / 状态看板 / 聚合)同样要挂 `@CheckObjectPermission(objectType=PROJECT, permission=...PERMISSION_QUERY)`——查询同样扫库耗资源,必须按对象域鉴权。**Controller 方法层一律不挂权限注解**,新增读接口照此在 Service 层挂对象域权限;不要只在 Controller 留空,更不要误判"Controller 没注解 = 无鉴权"。 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java index 45eebcb..cb84033 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java @@ -61,8 +61,8 @@ public final class ProjectTaskConstants { /** * 删除任务权限码。 * 推荐挂"项目负责人"角色(参见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.4)。 - * 实际拦截:service 内按 task.parentTaskId 分流走 checkOwnerOrProjectPermission - * (一级任务 → execution.ownerId 字段身份;子任务 → parentTask.ownerId 字段身份;不命中字段时回落本权限码)。 + * 实际拦截:项目负责人 / 执行负责人 / 任务负责人 / 父任务负责人直接放行; + * 不命中业务身份时回落本权限码,作为管理员兜底。 */ public static final String PERMISSION_DELETE = "project:task:delete"; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java index 69b7cae..7c55656 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java @@ -105,7 +105,7 @@ public class ProjectTaskController { } @DeleteMapping("/{taskId}") - @Operation(summary = "删除任务(已完成态禁止,三重确认 + 上级 owner 硬卡)") + @Operation(summary = "删除任务(已完成态禁止,三重确认 + 多身份授权)") public CommonResult deleteTask(@PathVariable("projectId") Long projectId, @PathVariable("executionId") Long executionId, @PathVariable("taskId") Long taskId, diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectAuthorizationService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectAuthorizationService.java index 792a5f0..eaf1628 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectAuthorizationService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectAuthorizationService.java @@ -4,10 +4,17 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import java.util.Collection; import java.util.Objects; /** - * 项目对象业务授权服务。 + * 项目对象业务授权服务:属主(业务负责人)身份命中直接放行,未命中回落对象域权限码兜底。 + * + *

供任务 / 执行的创建、编辑、删除、删除预检等入口在方法体内调用,替代方法上的 + * {@code @CheckObjectPermission} 前置拦截——注解拦截发生在进入方法体之前,无法先认负责人身份。 + * + *

红线:调用本服务做裁决的 service 方法不要再挂 {@code @CheckObjectPermission}, + * 叠加会把"无权限码的负责人"在注解层直接挡死。口径登记见 CLAUDE.md §鉴权。 */ @Service public class ProjectObjectAuthorizationService { @@ -26,4 +33,15 @@ public class ProjectObjectAuthorizationService { projectObjectPermissionService.checkPermission(projectId, permission, false); } + /** + * 当前登录人命中任一业务负责人时直接放行,否则回落到项目对象角色权限校验。 + */ + public void checkAnyOwnerOrProjectPermission(Long projectId, Collection ownerUserIds, String permission) { + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + if (ownerUserIds != null && ownerUserIds.stream().anyMatch(ownerUserId -> Objects.equals(loginUserId, ownerUserId))) { + return; + } + projectObjectPermissionService.checkPermission(projectId, permission, false); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java index 326a6e2..e5fe268 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java @@ -48,6 +48,7 @@ import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; import com.njcn.rdms.module.project.util.DueRangeSupport; import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; +import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService; import com.njcn.rdms.module.project.service.project.ProjectRequirementService; import com.njcn.rdms.module.project.service.project.ProjectService; import com.njcn.rdms.module.project.service.project.task.ProjectTaskService; @@ -128,6 +129,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { @Resource private ProjectRequirementMapper projectRequirementMapper; @Resource + private ProjectObjectAuthorizationService projectObjectAuthorizationService; + @Resource private StatusActionTextResolver statusActionTextResolver; /** 站内信事件发布:创建执行后发「执行指派」通知,由 NotifySendEventListener 事务提交后统一发送。 */ @Resource @@ -201,16 +204,16 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId())); } + // 权限:方法体内属主放行 + 权限码兜底(requireExecutionEditDeletePermission),勿挂 @CheckObjectPermission @Override @Transactional(rollbackFor = Exception.class) - @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", - permission = ProjectExecutionConstants.PERMISSION_UPDATE) public void updateExecution(Long projectId, ProjectExecutionUpdateReqVO reqVO) { if (reqVO.getId() == null) { throw invalidParamException("执行编号不能为空"); } - validateEditableProject(projectId); + ProjectDO project = validateEditableProject(projectId); ProjectExecutionDO execution = validateExecutionExists(projectId, reqVO.getId()); + requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_UPDATE); validateExecutionAllowEdit(execution); projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId()); String executionName = normalizeRequiredName(reqVO.getExecutionName()); @@ -448,13 +451,13 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, reason); } + // 权限:方法体内属主放行 + 权限码兜底(requireExecutionEditDeletePermission),勿挂 @CheckObjectPermission @Override @Transactional(rollbackFor = Exception.class) - @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", - permission = ProjectExecutionConstants.PERMISSION_DELETE) public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) { - validateProjectExists(projectId); + ProjectDO project = validateProjectExists(projectId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); + requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_DELETE); validateDeleteConfirmText(reqVO.getConfirmText()); if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) { throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH); @@ -482,12 +485,12 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_DELETE, fromStatus, null, null, reason); } + // 权限:方法体内属主放行 + 权限码兜底(requireExecutionEditDeletePermission),勿挂 @CheckObjectPermission @Override - @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", - permission = ProjectExecutionConstants.PERMISSION_DELETE) public ProjectExecutionDeletePrecheckRespVO precheckDeleteExecution(Long projectId, Long executionId) { - validateProjectExists(projectId); - validateExecutionExists(projectId, executionId); + ProjectDO project = validateProjectExists(projectId); + ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); + requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_DELETE); int taskCount = projectTaskMapper.countByExecutionId(executionId); ProjectExecutionDeletePrecheckRespVO respVO = new ProjectExecutionDeletePrecheckRespVO(); respVO.setTaskCount(taskCount); @@ -495,6 +498,27 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { return respVO; } + /** + * 执行编辑/删除授权:项目负责人、执行负责人直接放行;其它用户回落对象域权限码。 + * + *

红线:调用本方法的入口(updateExecution / deleteExecution / precheckDeleteExecution)故意不挂 + * {@code @CheckObjectPermission}——注解是前置拦截,会把"无权限码的负责人"挡死。 + * 不要误判为漏鉴权把注解补回去;口径见 CLAUDE.md §鉴权。 + */ + private void requireExecutionEditDeletePermission(ProjectDO project, ProjectExecutionDO execution, + String permission) { + List ownerUserIds = new ArrayList<>(); + addIfNotNull(ownerUserIds, project.getManagerUserId()); + addIfNotNull(ownerUserIds, execution.getOwnerId()); + projectObjectAuthorizationService.checkAnyOwnerOrProjectPermission(project.getId(), ownerUserIds, permission); + } + + private void addIfNotNull(Collection values, Long value) { + if (value != null) { + values.add(value); + } + } + private void validateDeleteConfirmText(String confirmText) { String normalizedConfirmText = normalizeNullableText(confirmText); if (normalizedConfirmText == null diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java index 2ef9efb..d9fbef4 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java @@ -55,11 +55,11 @@ public interface ProjectTaskService { void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO); /** - * 删除任务(软删 + 三重确认 + 上级硬卡 + 级联软删 + 父进度回算 + 审计)。 + * 删除任务(软删 + 三重确认 + 多身份授权 + 级联软删 + 父进度回算 + 审计)。 *

删除规则:非 completed 即可删(pending / active / paused / cancelled 全允许); * 已 completed 的任务用专门错误码 PROJECT_TASK_NOT_ALLOW_DELETE 拦截。 - *

上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码 {@code project:task:delete})"删; - * 子任务由"父任务负责人 OR 项目负责人(权限码)"删。 + *

授权:项目负责人 / 执行负责人 / 任务负责人 / 父任务负责人均可删; + * 其它用户持有 {@code project:task:delete} 时作为管理员兜底放行。 *

三重确认:{@code taskName} / {@code confirmText}(DELETE 或 删除)/ {@code reason} 全部必填, * 任意一项缺失或不匹配即拒。 *

级联范围:软删该任务及其所有子孙任务、所有相关工作日志、所有相关任务协办; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java index dd8acb0..56ef9b2 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java @@ -135,6 +135,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { @Resource private org.springframework.context.ApplicationEventPublisher applicationEventPublisher; + // 权限:方法体内属主放行 + 权限码兜底(checkOwnerOrProjectPermission),勿挂 @CheckObjectPermission @Override @Transactional(rollbackFor = Exception.class) public Long createTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) { @@ -215,24 +216,19 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId())); } + // 权限:方法体内属主放行 + 权限码兜底(requireTaskEditDeletePermission),勿挂 @CheckObjectPermission @Override @Transactional(rollbackFor = Exception.class) public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) { if (reqVO.getId() == null) { throw invalidParamException("任务编号不能为空"); } - validateEditableProject(projectId); + ProjectDO project = validateEditableProject(projectId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); validateExecutionAllowEdit(execution); ProjectTaskDO task = validateTaskExists(projectId, executionId, reqVO.getId()); validateTaskAllowEdit(task); - // 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"改;子任务由"父任务负责人 OR 项目负责人(权限码)"改 - // 按当前 task 的 parentTaskId(修改前)判定;改父亲属于改动结果,授权按改动前形态裁决 - Long upperOwnerId = task.getParentTaskId() == null - ? execution.getOwnerId() - : projectTaskMapper.selectById(task.getParentTaskId()).getOwnerId(); - projectObjectAuthorizationService.checkOwnerOrProjectPermission( - projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_UPDATE); + requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_UPDATE); ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId()); if (parentTask != null && Objects.equals(parentTask.getId(), task.getId())) { throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID); @@ -281,14 +277,14 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { task.getStatusCode(), buildTaskFieldChanges(before, task), null); } + // 权限:方法体内属主放行 + 权限码兜底(requireTaskEditDeletePermission),勿挂 @CheckObjectPermission @Override @Transactional(rollbackFor = Exception.class) public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) { - validateProjectExists(projectId); + ProjectDO project = validateProjectExists(projectId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); - // 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"删;子任务由"父任务负责人 OR 项目负责人(权限码)"删 - requireDeletePermissionOnTask(projectId, task, execution); + requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_DELETE); validateDeleteConfirmText(reqVO.getConfirmText()); if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) { throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_NAME_MISMATCH); @@ -326,13 +322,14 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason); } + // 权限:方法体内属主放行 + 权限码兜底(requireTaskEditDeletePermission),勿挂 @CheckObjectPermission @Override public ProjectTaskDeletePrecheckRespVO precheckDeleteTask(Long projectId, Long executionId, Long taskId) { - validateProjectExists(projectId); + ProjectDO project = validateProjectExists(projectId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); - // 预检也走"上级硬卡",防止"无权限的人调 precheck 试探下挂数量" - requireDeletePermissionOnTask(projectId, task, execution); + // 预检也走同款编辑/删除授权,防止无权限用户调 precheck 试探下挂数量。 + requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_DELETE); int childTaskCount = projectTaskMapper.countChildrenByParentTaskId(taskId); int worklogCount = taskWorklogMapper.countByTaskId(taskId); @@ -345,26 +342,32 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { } /** - * 任务删除"上级硬卡":一级任务由执行负责人 OR 项目负责人(权限码)操作; - * 子任务由父任务负责人 OR 项目负责人(权限码)操作。 - *

同时被 deleteTask 与 precheckDeleteTask 使用,保证删除路径与预检入口走同款权限通道。 + * 任务编辑/删除授权:项目负责人、执行负责人、任务负责人、父任务负责人均可操作; + * 其它用户回落到项目对象角色权限码,作为管理员兜底。 + * + *

红线:调用本方法的入口(updateTask / deleteTask / precheckDeleteTask)故意不挂 + * {@code @CheckObjectPermission}——注解是前置拦截,会把"无权限码的负责人"挡死。 + * 不要误判为漏鉴权把注解补回去;口径见 CLAUDE.md §鉴权。 */ - private void requireDeletePermissionOnTask(Long projectId, ProjectTaskDO task, ProjectExecutionDO execution) { - Long upperOwnerId; - if (task.getParentTaskId() == null) { - upperOwnerId = execution.getOwnerId(); - } else { + private void requireTaskEditDeletePermission(ProjectDO project, ProjectExecutionDO execution, + ProjectTaskDO task, String permission) { + List ownerUserIds = new ArrayList<>(); + addIfNotNull(ownerUserIds, project.getManagerUserId()); + addIfNotNull(ownerUserIds, execution.getOwnerId()); + addIfNotNull(ownerUserIds, task.getOwnerId()); + if (task.getParentTaskId() != null) { ProjectTaskDO parent = projectTaskMapper.selectById(task.getParentTaskId()); - if (parent == null) { - // 父任务被并发硬删的极端场景:当前任务已是孤儿,按"无上级负责人"处理 - // 仍要求调用方持有项目级权限码,由 checkOwnerOrProjectPermission 兜底拦截 - upperOwnerId = null; - } else { - upperOwnerId = parent.getOwnerId(); + if (parent != null) { + addIfNotNull(ownerUserIds, parent.getOwnerId()); } } - projectObjectAuthorizationService.checkOwnerOrProjectPermission( - projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_DELETE); + projectObjectAuthorizationService.checkAnyOwnerOrProjectPermission(project.getId(), ownerUserIds, permission); + } + + private void addIfNotNull(Collection values, Long value) { + if (value != null) { + values.add(value); + } } private void validateDeleteConfirmText(String confirmText) { diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectAuthorizationServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectAuthorizationServiceTest.java index b08c0fe..cbf176b 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectAuthorizationServiceTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectAuthorizationServiceTest.java @@ -7,6 +7,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; +import java.util.List; + import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -42,6 +44,30 @@ class ProjectObjectAuthorizationServiceTest extends BaseMockitoUnitTest { verify(projectObjectPermissionService).checkPermission(projectId, "project:task:status", false); } + @Test + void checkAnyOwnerOrProjectPermission_whenCurrentUserMatchesAnyOwner_shouldSkipProjectPermissionCheck() { + Long projectId = 1003L; + + try (MockedStatic mockedStatic = mockLoginUser(3002L)) { + authorizationService.checkAnyOwnerOrProjectPermission(projectId, + List.of(3001L, 3002L, 3003L), "project:task:update"); + } + + verify(projectObjectPermissionService, never()).checkPermission(projectId, "project:task:update", false); + } + + @Test + void checkAnyOwnerOrProjectPermission_whenCurrentUserMatchesNoOwner_shouldCheckProjectPermission() { + Long projectId = 1004L; + + try (MockedStatic mockedStatic = mockLoginUser(3999L)) { + authorizationService.checkAnyOwnerOrProjectPermission(projectId, + List.of(3001L, 3002L, 3003L), "project:task:delete"); + } + + verify(projectObjectPermissionService).checkPermission(projectId, "project:task:delete", false); + } + private MockedStatic mockLoginUser(Long loginUserId) { MockedStatic mockedStatic = mockStatic(SecurityFrameworkUtils.class); mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImplTest.java deleted file mode 100644 index b1c5617..0000000 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImplTest.java +++ /dev/null @@ -1,726 +0,0 @@ -package com.njcn.rdms.module.project.service.product; - -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.product.vo.requirement.*; -import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; -import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO; -import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementModuleDO; -import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementStatusLogDO; -import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO; -import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper; -import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementMapper; -import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper; -import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementStatusLogMapper; -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 org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockedStatic; - -import java.util.Collections; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -/** - * 产品需求 Service 单元测试 - */ -class ProductRequirementServiceImplTest extends BaseMockitoUnitTest { - - @InjectMocks - private ProductRequirementServiceImpl requirementService; - @Mock - private ProductRequirementMapper requirementMapper; - @Mock - private ProductRequirementModuleMapper moduleMapper; - @Mock - private ProductRequirementStatusLogMapper statusLogMapper; - @Mock - private BizAuditLogMapper bizAuditLogMapper; - @Mock - private ObjectStatusTransitionMapper statusTransitionMapper; - @Mock - private ObjectStatusModelMapper statusModelMapper; - @Mock - private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver; - @Mock - private com.njcn.rdms.module.project.dal.mysql.product.ProductMapper productMapper; - @Mock - private org.springframework.context.ApplicationEventPublisher applicationEventPublisher; - - // ========== 创建需求通知处理人测试(Task 9) ========== - - /** - * Task 9:创建产品需求后通知处理人(currentHandlerUserId),操作人由监听器统一排除。 - */ - @Test - void createRequirement_notifiesHandler() { - try (MockedStatic mocked = mockStatic(SecurityFrameworkUtils.class)) { - mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(9L); - ProductRequirementDO req = new ProductRequirementDO(); - req.setId(1L); - req.setProductId(20L); - req.setTitle("电池告警"); - req.setCurrentHandlerUserId(5L); - com.njcn.rdms.module.project.dal.dataobject.product.ProductDO product = - new com.njcn.rdms.module.project.dal.dataobject.product.ProductDO(); - product.setName("智能终端"); - when(productMapper.selectById(20L)).thenReturn(product); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(com.njcn.rdms.module.project.framework.notify.NotifySendEvent.class); - requirementService.publishRequirementAssignedNotify(req); - verify(applicationEventPublisher).publishEvent(captor.capture()); - com.njcn.rdms.module.project.framework.notify.NotifySendEvent ev = captor.getValue(); - assertEquals("product_requirement_assigned", ev.getTemplateCode()); - assertEquals(9L, ev.getOperatorUserId()); - assertTrue(ev.getUserIds().contains(5L)); - assertEquals("电池告警", ev.getParams().get("requirementTitle")); - assertEquals("智能终端", ev.getParams().get("productName")); - } - } - - /** - * Task 9:处理人为空时不发通知。 - */ - @Test - void createRequirement_whenHandlerNull_shouldNotPublish() { - ProductRequirementDO req = new ProductRequirementDO(); - req.setId(1L); - req.setProductId(20L); - req.setTitle("电池告警"); - req.setCurrentHandlerUserId(null); - - requirementService.publishRequirementAssignedNotify(req); - verify(applicationEventPublisher, never()).publishEvent(any()); - } - - // ========== 创建需求测试 ========== - - @Test - void createRequirement_withoutReview_shouldCreateWithPendingDispatchStatus() { - Long loginUserId = 1001L; - Long defaultModuleId = 50L; - ProductRequirementSaveReqVO reqVO = new ProductRequirementSaveReqVO(); - reqVO.setProductId(100L); - reqVO.setTitle("测试需求"); - reqVO.setDescription("测试描述"); - reqVO.setCategory("function"); - reqVO.setReviewRequired(0); // 不需要评审 - reqVO.setPriority(1); - reqVO.setProposerId(2001L); - reqVO.setProposerNickname("proposer-a"); - reqVO.setCurrentHandlerUserId(2002L); - reqVO.setCurrentHandlerUserNickname("handler-a"); - - // 模拟"全部需求"模块存在(parentId = 0L 的根模块) - ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L); - when(moduleMapper.selectByProductIdAndParentId(100L, 0L)).thenReturn(defaultModule); - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - requirementService.createRequirement(reqVO); - } - - ArgumentCaptor captor = ArgumentCaptor.forClass(ProductRequirementDO.class); - verify(requirementMapper, times(1)).insert(captor.capture()); - ProductRequirementDO created = captor.getValue(); - assertEquals("pending_dispatch", created.getStatusCode()); // 不需要评审时初始状态为待指派 - assertEquals("manual", created.getSourceType()); - assertEquals(0L, created.getParentId()); - assertEquals("测试需求", created.getTitle()); - assertEquals(defaultModuleId, created.getModuleId()); // 未选择模块时自动归属到"全部需求"模块 - assertEquals(100L, created.getProductId()); - assertEquals("handler-a", created.getCurrentHandlerUserNickname()); - - ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class); - verify(bizAuditLogMapper, times(1)).insert(auditCaptor.capture()); - assertNotNull(auditCaptor.getValue().getFieldChanges()); - assertTrue(auditCaptor.getValue().getFieldChanges().contains("currentHandlerUserNickname")); - } - - @Test - void createRequirement_withReview_shouldCreateWithPendingReviewStatus() { - Long loginUserId = 1001L; - Long defaultModuleId = 50L; - ProductRequirementSaveReqVO reqVO = new ProductRequirementSaveReqVO(); - reqVO.setProductId(100L); - reqVO.setTitle("需评审的需求"); - reqVO.setCategory("function"); - reqVO.setReviewRequired(1); // 需要评审 - reqVO.setPriority(2); - reqVO.setProposerId(2001L); - - // 模拟"全部需求"模块存在(parentId = 0L 的根模块) - ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L); - when(moduleMapper.selectByProductIdAndParentId(100L, 0L)).thenReturn(defaultModule); - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - requirementService.createRequirement(reqVO); - } - - ArgumentCaptor captor = ArgumentCaptor.forClass(ProductRequirementDO.class); - verify(requirementMapper, times(1)).insert(captor.capture()); - ProductRequirementDO created = captor.getValue(); - assertEquals("pending_review", created.getStatusCode()); // 需要评审时初始状态为待评审 - assertEquals(defaultModuleId, created.getModuleId()); // 未选择模块时自动归属到"全部需求"模块 - } - - // ========== 更新需求测试 ========== - - @Test - void updateRequirement_whenTerminalStatus_shouldThrowException() { - Long requirementId = 1001L; - ProductRequirementDO requirement = createRequirement(requirementId, 100L, "已关闭需求", - "closed", 0L, 0); - ProductRequirementUpdateReqVO reqVO = new ProductRequirementUpdateReqVO(); - reqVO.setId(requirementId); - reqVO.setProductId(100L); - reqVO.setTitle("修改标题"); - reqVO.setCategory("function"); - reqVO.setReviewRequired(0); - reqVO.setPriority(1); - reqVO.setProposerId(2001L); - - when(requirementMapper.selectById(requirementId)).thenReturn(requirement); - - ServiceException ex = assertThrows(ServiceException.class, - () -> requirementService.updateRequirement(reqVO)); - assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode()); - verify(requirementMapper, never()).updateById(any(ProductRequirementDO.class)); - } - - @Test - void updateRequirement_whenNormalStatus_shouldUpdateSuccessfully() { - Long requirementId = 1002L; - Long loginUserId = 1001L; - Long defaultModuleId = 50L; - ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待指派需求", - "pending_dispatch", 0L, 0); - ProductRequirementUpdateReqVO reqVO = new ProductRequirementUpdateReqVO(); - reqVO.setId(requirementId); - reqVO.setProductId(100L); - reqVO.setTitle("修改后的标题"); - reqVO.setCategory("security"); - reqVO.setReviewRequired(0); - reqVO.setPriority(2); - reqVO.setProposerId(2001L); - - // 模拟"全部需求"模块存在(未选择模块时自动归属,parentId = 0L 的根模块) - ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L); - when(moduleMapper.selectByProductIdAndParentId(100L, 0L)).thenReturn(defaultModule); - when(requirementMapper.selectById(requirementId)).thenReturn(requirement); - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - requirementService.updateRequirement(reqVO); - } - - ArgumentCaptor captor = ArgumentCaptor.forClass(ProductRequirementDO.class); - verify(requirementMapper, times(1)).updateById(captor.capture()); - assertEquals(defaultModuleId, captor.getValue().getModuleId()); // 未选择模块时自动归属到"全部需求"模块 - verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class)); - } - - // ========== 状态变更测试 ========== - - @Test - void changeRequirementStatus_whenActionAllowed_shouldUpdateStatusAndWriteLogs() { - Long requirementId = 1003L; - Long loginUserId = 1001L; - ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待指派需求", - "pending_dispatch", 0L, 0); - ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO(); - reqVO.setId(requirementId); - reqVO.setActionCode("dispatch"); - reqVO.setReason(null); - - when(requirementMapper.selectById(requirementId)).thenReturn(requirement); - when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction( - "product_requirement", "pending_dispatch", "dispatch")) - .thenReturn(createTransition("dispatch", "implementing", false)); - when(requirementMapper.updateStatusByIdAndStatus(requirementId, "pending_dispatch", "implementing", null)) - .thenReturn(1); - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - requirementService.changeRequirementStatus(reqVO); - } - - verify(requirementMapper, times(1)) - .updateStatusByIdAndStatus(requirementId, "pending_dispatch", "implementing", null); - verify(statusLogMapper, times(1)).insert(any(ProductRequirementStatusLogDO.class)); - verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class)); - } - - @Test - void changeRequirementStatus_whenActionNotAllowed_shouldThrowException() { - Long requirementId = 1004L; - ProductRequirementDO requirement = createRequirement(requirementId, 100L, "已关闭需求", - "closed", 0L, 0); - ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO(); - reqVO.setId(requirementId); - reqVO.setActionCode("dispatch"); - - when(requirementMapper.selectById(requirementId)).thenReturn(requirement); - when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction( - "product_requirement", "closed", "dispatch")) - .thenReturn(null); - - ServiceException ex = assertThrows(ServiceException.class, - () -> requirementService.changeRequirementStatus(reqVO)); - assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode()); - } - - @Test - void changeRequirementStatus_whenReasonRequiredButMissing_shouldThrowException() { - Long requirementId = 1005L; - ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待指派需求", - "pending_dispatch", 0L, 0); - ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO(); - reqVO.setId(requirementId); - reqVO.setActionCode("cancel"); - reqVO.setReason(" "); // 空原因 - - when(requirementMapper.selectById(requirementId)).thenReturn(requirement); - when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction( - "product_requirement", "pending_dispatch", "cancel")) - .thenReturn(createTransition("cancel", "cancelled", true)); - - ServiceException ex = assertThrows(ServiceException.class, - () -> requirementService.changeRequirementStatus(reqVO)); - assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode()); - } - - @Test - void changeRequirementStatus_whenConcurrentModified_shouldThrowException() { - Long requirementId = 1006L; - Long loginUserId = 1001L; - ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待指派需求", - "pending_dispatch", 0L, 0); - ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO(); - reqVO.setId(requirementId); - reqVO.setActionCode("dispatch"); - - when(requirementMapper.selectById(requirementId)).thenReturn(requirement); - when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction( - "product_requirement", "pending_dispatch", "dispatch")) - .thenReturn(createTransition("dispatch", "implementing", false)); - when(requirementMapper.updateStatusByIdAndStatus(requirementId, "pending_dispatch", "implementing", null)) - .thenReturn(0); // 并发修改,更新失败 - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - ServiceException ex = assertThrows(ServiceException.class, - () -> requirementService.changeRequirementStatus(reqVO)); - assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_CONCURRENT_MODIFIED.getCode(), ex.getCode()); - } - verify(statusLogMapper, never()).insert(any(ProductRequirementStatusLogDO.class)); - verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class)); - } - - // ========== 删除需求测试 ========== - - @Test - void deleteRequirement_shouldDeleteByConditionAndWriteLogs() { - Long requirementId = 1007L; - Long loginUserId = 1001L; - ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待指派需求", - "pending_dispatch", 0L, 0); - - when(requirementMapper.selectById(requirementId)).thenReturn(requirement); - when(requirementMapper.deleteByIdAndStatus(requirementId, "pending_dispatch")).thenReturn(1); - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - requirementService.deleteRequirement(requirementId, 100L); - } - - verify(requirementMapper, times(1)).deleteByIdAndStatus(requirementId, "pending_dispatch"); - verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class)); - } - - // ========== 拆分需求测试 ========== - - @Test - void splitRequirement_whenParentPendingDispatch_shouldChangeParentToImplementing() { - Long parentId = 1008L; - Long loginUserId = 1001L; - ProductRequirementDO parent = createRequirement(parentId, 100L, "大需求", - "pending_dispatch", 0L, 0); - parent.setModuleId(50L); - parent.setProposerId(2001L); - ProductRequirementSplitReqVO reqVO = new ProductRequirementSplitReqVO(); - reqVO.setParentId(parentId); - reqVO.setProductId(100L); - reqVO.setTitle("子需求"); - reqVO.setCategory("function"); - reqVO.setPriority(1); - - when(requirementMapper.selectById(parentId)).thenReturn(parent); - when(requirementMapper.updateStatusByIdAndStatus(parentId, "pending_dispatch", "implementing", null)) - .thenReturn(1); - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - requirementService.splitRequirement(reqVO); - } - - // 验证子需求被创建 - ArgumentCaptor childCaptor = ArgumentCaptor.forClass(ProductRequirementDO.class); - verify(requirementMapper, times(1)).insert(childCaptor.capture()); - ProductRequirementDO child = childCaptor.getValue(); - assertEquals(parentId, child.getParentId()); - assertEquals("pending_dispatch", child.getStatusCode()); - assertEquals(parent.getModuleId(), child.getModuleId()); - assertEquals(reqVO.getProposerId(), child.getProposerId()); - - // 验证父需求状态变为实施中 - verify(requirementMapper, times(1)) - .updateStatusByIdAndStatus(parentId, "pending_dispatch", "implementing", null); - verify(statusLogMapper, times(1)).insert(any(ProductRequirementStatusLogDO.class)); - } - - @Test - void splitRequirement_whenParentImplementing_shouldNotChangeParentStatus() { - Long parentId = 1009L; - Long loginUserId = 1001L; - ProductRequirementDO parent = createRequirement(parentId, 100L, "实施中的大需求", - "implementing", 0L, 0); - parent.setModuleId(50L); - parent.setProposerId(2001L); - ProductRequirementSplitReqVO reqVO = new ProductRequirementSplitReqVO(); - reqVO.setParentId(parentId); - reqVO.setProductId(100L); - reqVO.setTitle("子需求2"); - reqVO.setCategory("function"); - reqVO.setPriority(1); - - when(requirementMapper.selectById(parentId)).thenReturn(parent); - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - requirementService.splitRequirement(reqVO); - } - - // 父需求已经是实施中,不需要再更新状态 - verify(requirementMapper, never()) - .updateStatusByIdAndStatus(parentId, "implementing", "implementing", null); - } - - @Test - void splitRequirement_whenParentNotAllowSplit_shouldThrowException() { - Long parentId = 1010L; - ProductRequirementDO parent = createRequirement(parentId, 100L, "已验收的大需求", - "accepted", 0L, 0); - ProductRequirementSplitReqVO reqVO = new ProductRequirementSplitReqVO(); - reqVO.setParentId(parentId); - reqVO.setProductId(100L); - reqVO.setTitle("子需求"); - reqVO.setCategory("function"); - reqVO.setPriority(1); - - when(requirementMapper.selectById(parentId)).thenReturn(parent); - - ServiceException ex = assertThrows(ServiceException.class, - () -> requirementService.splitRequirement(reqVO)); - assertEquals(ErrorCodeConstants.REQUIREMENT_PARENT_NOT_ALLOW_SPLIT.getCode(), ex.getCode()); - verify(requirementMapper, never()).insert(any(ProductRequirementDO.class)); - } - - // ========== 关闭需求测试 ========== - - @Test - void closeRequirement_whenAccepted_shouldCloseSuccessfully() { - Long requirementId = 1011L; - Long loginUserId = 1001L; - ProductRequirementDO requirement = createRequirement(requirementId, 100L, "已验收需求", - "accepted", 0L, 0); - ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO(); - reqVO.setId(requirementId); - reqVO.setProductId(100L); - reqVO.setReason("需求已完成"); - - when(requirementMapper.selectById(requirementId)).thenReturn(requirement); - when(requirementMapper.selectListByParentId(requirementId)).thenReturn(Collections.emptyList()); - when(requirementMapper.updateStatusByIdAndStatus(requirementId, "accepted", "closed", "需求已完成")) - .thenReturn(1); - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - requirementService.closeRequirement(reqVO); - } - - verify(requirementMapper, times(1)) - .updateStatusByIdAndStatus(requirementId, "accepted", "closed", "需求已完成"); - verify(statusLogMapper, times(1)).insert(any(ProductRequirementStatusLogDO.class)); - verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class)); - } - - @Test - void closeRequirement_whenNotAccepted_shouldThrowException() { - Long requirementId = 1012L; - ProductRequirementDO requirement = createRequirement(requirementId, 100L, "实施中需求", - "implementing", 0L, 0); - ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO(); - reqVO.setId(requirementId); - reqVO.setProductId(100L); - reqVO.setReason("需求已完成"); - - when(requirementMapper.selectById(requirementId)).thenReturn(requirement); - - ServiceException ex = assertThrows(ServiceException.class, - () -> requirementService.closeRequirement(reqVO)); - assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_NOT_ALLOW_CLOSE.getCode(), ex.getCode()); - } - - @Test - void closeRequirement_withChildren_shouldCascadeCloseAcceptedChildren() { - Long parentId = 1013L; - Long childId = 1014L; - Long loginUserId = 1001L; - ProductRequirementDO parent = createRequirement(parentId, 100L, "父需求", - "accepted", 0L, 0); - ProductRequirementDO child = createRequirement(childId, 100L, "子需求", - "accepted", parentId, 0); - - ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO(); - reqVO.setId(parentId); - reqVO.setProductId(100L); - reqVO.setReason("全部验收完成"); - - when(requirementMapper.selectById(parentId)).thenReturn(parent); - when(requirementMapper.selectListByParentId(parentId)).thenReturn(List.of(child)); - when(requirementMapper.updateStatusByIdAndStatus(childId, "accepted", "closed", "全部验收完成")) - .thenReturn(1); - when(requirementMapper.updateStatusByIdAndStatus(parentId, "accepted", "closed", "全部验收完成")) - .thenReturn(1); - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - requirementService.closeRequirement(reqVO); - } - - // 子需求被关闭 - verify(requirementMapper, times(1)) - .updateStatusByIdAndStatus(childId, "accepted", "closed", "全部验收完成"); - // 父需求被关闭 - verify(requirementMapper, times(1)) - .updateStatusByIdAndStatus(parentId, "accepted", "closed", "全部验收完成"); - } - - @Test - void closeRequirement_withChildNotAllowClose_shouldThrowException() { - Long parentId = 1015L; - ProductRequirementDO parent = createRequirement(parentId, 100L, "父需求", - "accepted", 0L, 0); - ProductRequirementDO child = createRequirement(1016L, 100L, "实施中子需求", - "implementing", parentId, 0); - - ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO(); - reqVO.setId(parentId); - reqVO.setProductId(100L); - reqVO.setReason("全部验收完成"); - - when(requirementMapper.selectById(parentId)).thenReturn(parent); - when(requirementMapper.selectListByParentId(parentId)).thenReturn(List.of(child)); - - ServiceException ex = assertThrows(ServiceException.class, - () -> requirementService.closeRequirement(reqVO)); - assertEquals(ErrorCodeConstants.REQUIREMENT_CHILD_NOT_ALLOW_CLOSE.getCode(), ex.getCode()); - } - - // ========== 模块管理测试 ========== - - @Test - void createRequirementModule_whenNameDuplicate_shouldThrowException() { - Long productId = 100L; - ProductRequirementModuleDO existModule = new ProductRequirementModuleDO(); - existModule.setId(50L); - existModule.setProductId(productId); - existModule.setModuleName("核心功能"); - - ProductRequirementModuleReqVO reqVO = new ProductRequirementModuleReqVO(); - reqVO.setProductId(productId); - reqVO.setModuleName("核心功能"); - - when(moduleMapper.selectByProductIdAndModuleName(productId, "核心功能")).thenReturn(existModule); - - ServiceException ex = assertThrows(ServiceException.class, - () -> requirementService.createRequirementModule(reqVO)); - assertEquals(ErrorCodeConstants.REQUIREMENT_MODULE_NAME_DUPLICATE.getCode(), ex.getCode()); - } - - @Test - void deleteRequirementModule_whenHasNonTerminalRequirements_shouldThrowException() { - Long moduleId = 50L; - Long productId = 100L; - ProductRequirementModuleDO module = new ProductRequirementModuleDO(); - module.setId(moduleId); - module.setProductId(productId); - module.setModuleName("核心功能"); - - ProductRequirementDO nonTerminalReq = createRequirement(2001L, productId, "实施中需求", - "implementing", 0L, 0); - nonTerminalReq.setModuleId(moduleId); - - when(moduleMapper.selectById(moduleId)).thenReturn(module); - when(requirementMapper.selectListByModuleId(moduleId)).thenReturn(List.of(nonTerminalReq)); - - ServiceException ex = assertThrows(ServiceException.class, - () -> requirementService.deleteRequirementModule(moduleId, productId)); - assertEquals(ErrorCodeConstants.REQUIREMENT_MODULE_HAS_NON_TERMINAL_REQUIREMENTS.getCode(), ex.getCode()); - verify(moduleMapper, never()).deleteById(moduleId); - } - - @Test - void deleteRequirementModule_whenAllTerminal_shouldDeleteSuccessfully() { - Long moduleId = 50L; - Long productId = 100L; - Long loginUserId = 1001L; - ProductRequirementModuleDO module = new ProductRequirementModuleDO(); - module.setId(moduleId); - module.setProductId(productId); - module.setModuleName("核心功能"); - - ProductRequirementDO closedReq = createRequirement(2001L, productId, "已关闭需求", - "closed", 0L, 0); - closedReq.setModuleId(moduleId); - - when(moduleMapper.selectById(moduleId)).thenReturn(module); - when(requirementMapper.selectListByModuleId(moduleId)).thenReturn(List.of(closedReq)); - - try (MockedStatic mockedStatic = mockLoginUser(loginUserId, "测试人")) { - requirementService.deleteRequirementModule(moduleId, productId); - } - - verify(requirementMapper, times(1)).deleteById(2001L); - verify(moduleMapper, times(1)).deleteById(moduleId); - } - - // ========== resolveModuleId 测试 ========== - - @Test - void resolveModuleId_whenModuleIdIsNull_shouldReturnDefaultModuleId() { - Long defaultModuleId = 50L; - Long productId = 100L; - ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, productId); - - when(moduleMapper.selectByProductIdAndParentId(productId, 0L)).thenReturn(defaultModule); - - Long result = requirementService.resolveModuleId(null, productId); - - assertEquals(defaultModuleId, result); - } - - @Test - void resolveModuleId_whenModuleIdProvided_shouldReturnSameId() { - Long moduleId = 60L; - Long productId = 100L; - - // 传入有效moduleId时直接返回,不查询数据库 - Long result = requirementService.resolveModuleId(moduleId, productId); - - assertEquals(moduleId, result); - verify(moduleMapper, never()).selectByProductIdAndParentId(any(), any()); - } - - // ========== isAllRequirementsModule 测试 ========== - - @Test - void isAllRequirementsModule_whenRootModule_shouldReturnTrue() { - Long moduleId = 50L; - ProductRequirementModuleDO rootModule = createDefaultModule(moduleId, 100L); - - when(moduleMapper.selectById(moduleId)).thenReturn(rootModule); - - boolean result = requirementService.isAllRequirementsModule(moduleId); - - assertEquals(true, result); - } - - @Test - void isAllRequirementsModule_whenChildModule_shouldReturnFalse() { - Long moduleId = 60L; - ProductRequirementModuleDO childModule = new ProductRequirementModuleDO(); - childModule.setId(moduleId); - childModule.setProductId(100L); - childModule.setParentId(50L); // parentId != 0 - childModule.setModuleName("子模块"); - - when(moduleMapper.selectById(moduleId)).thenReturn(childModule); - - boolean result = requirementService.isAllRequirementsModule(moduleId); - - assertEquals(false, result); - } - - @Test - void isAllRequirementsModule_whenModuleNotExists_shouldReturnFalse() { - Long moduleId = 70L; - - when(moduleMapper.selectById(moduleId)).thenReturn(null); - - boolean result = requirementService.isAllRequirementsModule(moduleId); - - assertEquals(false, result); - } - - @Test - void resolveModuleId_whenDefaultModuleNotExists_shouldThrowException() { - Long productId = 100L; - - when(moduleMapper.selectByProductIdAndParentId(productId, 0L)).thenReturn(null); - - ServiceException ex = assertThrows(ServiceException.class, - () -> requirementService.resolveModuleId(null, productId)); - assertEquals(ErrorCodeConstants.REQUIREMENT_MODULE_NOT_EXISTS.getCode(), ex.getCode()); - } - - // ========== 辅助方法 ========== - - private ProductRequirementDO createRequirement(Long id, Long productId, String title, - String statusCode, Long parentId, Integer reviewRequired) { - ProductRequirementDO requirement = new ProductRequirementDO(); - requirement.setId(id); - requirement.setProductId(productId); - requirement.setTitle(title); - requirement.setStatusCode(statusCode); - requirement.setParentId(parentId); - requirement.setReviewRequired(reviewRequired); - requirement.setCategory("function"); - requirement.setSourceType("manual"); - requirement.setPriority(1); - requirement.setProposerId(2001L); - requirement.setSort(0); - return requirement; - } - - private ProductRequirementModuleDO createDefaultModule(Long id, Long productId) { - ProductRequirementModuleDO module = new ProductRequirementModuleDO(); - module.setId(id); - module.setProductId(productId); - module.setParentId(0L); - module.setModuleName("全部需求"); - module.setRemark("自动创建的模块"); - module.setSort(0); - return module; - } - - private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) { - ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO(); - transition.setActionCode(actionCode); - transition.setToStatusCode(toStatus); - transition.setNeedReason(needReason); - return transition; - } - - private MockedStatic mockLoginUser(Long loginUserId, String nickname) { - MockedStatic mockedStatic = mockStatic(SecurityFrameworkUtils.class); - mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); - mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn(nickname); - return mockedStatic; - } - -} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java index c1a7b5f..ee25c8f 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java @@ -10,10 +10,12 @@ import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execut import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; import com.njcn.rdms.module.project.constant.ObjectActivityConstants; +import com.njcn.rdms.module.project.constant.ProjectExecutionConstants; import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO; @@ -35,6 +37,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants; import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; +import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService; import com.njcn.rdms.module.project.service.project.ProjectRequirementService; import com.njcn.rdms.module.system.api.dict.DictDataApi; import com.njcn.rdms.module.system.api.user.AdminUserApi; @@ -107,6 +110,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { @Mock private ProjectRequirementMapper projectRequirementMapper; @Mock + private ProjectObjectAuthorizationService projectObjectAuthorizationService; + @Mock private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver; @Mock private org.springframework.context.ApplicationEventPublisher applicationEventPublisher; @@ -119,6 +124,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { void setupDefaultPriorityValidation() { lenient().when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any())) .thenReturn(success(true)); + lenient().when(userObjectRoleMapper.selectActiveListByObjectAndUserId(eq("project"), any(), eq(3001L))) + .thenAnswer(invocation -> List.of(createProjectMember(invocation.getArgument(1), 3001L, 3101L))); } @Test @@ -130,6 +137,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { reqVO.setOwnerId(3001L); reqVO.setProjectRequirementId(null); reqVO.setAssigneeUserIds(List.of(3002L, 3003L)); + reqVO.setPriority("1"); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) @@ -203,6 +211,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { reqVO.setOwnerId(3001L); reqVO.setProjectRequirementId(9001L); reqVO.setAssigneeUserIds(List.of(3002L)); + reqVO.setPriority("1"); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) @@ -269,8 +278,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) .thenReturn(createProjectStatus("pending", true)); when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null); - 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)); @@ -299,8 +306,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { .thenReturn(createExecutionStatus("pending", true)); when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调-修订")).thenReturn(null); - 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)); @@ -310,6 +315,36 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { verify(projectExecutionMapper).updateById(execution); } + @Test + void updateExecution_shouldAuthorizeProjectManagerOrExecutionOwnerBeforeUpdating() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectDO project = createEditableProject(projectId); + project.setManagerUserId(3001L); + ProjectExecutionDO execution = createExecution(projectId, executionId, 3002L); + ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO(); + reqVO.setId(executionId); + reqVO.setExecutionName("执行权限联调"); + reqVO.setExecutionType("feature"); + reqVO.setPriority("1"); + reqVO.setProjectRequirementId(null); + + when(projectMapper.selectById(projectId)).thenReturn(project); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending")) + .thenReturn(createExecutionStatus("pending", true)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(projectExecutionMapper.selectByProjectIdAndName(projectId, "执行权限联调")).thenReturn(null); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature"))) + .thenReturn(success(true)); + + projectExecutionService.updateExecution(projectId, reqVO); + + verify(projectObjectAuthorizationService).checkAnyOwnerOrProjectPermission(projectId, + List.of(3001L, 3002L), ProjectExecutionConstants.PERMISSION_UPDATE); + } + @Test void changeOwner_shouldUpdateOwnerAndWriteOwnerTransferLogs() { Long projectId = 2001L; @@ -340,6 +375,51 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, "负责人调整"); } + @Test + void deleteExecution_shouldAuthorizeProjectManagerOrExecutionOwnerBeforeDeleting() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectDO project = createEditableProject(projectId); + project.setManagerUserId(3001L); + ProjectExecutionDO execution = createExecution(projectId, executionId, 3002L); + execution.setExecutionName("Execution auth"); + ProjectExecutionDeleteReqVO reqVO = new ProjectExecutionDeleteReqVO(); + reqVO.setExecutionName("Execution auth"); + reqVO.setConfirmText("DELETE"); + reqVO.setReason("录入错误"); + + when(projectMapper.selectById(projectId)).thenReturn(project); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(projectTaskMapper.selectIdsByExecutionId(executionId)).thenReturn(Collections.emptyList()); + when(projectExecutionMapper.deleteById(executionId)).thenReturn(1); + + projectExecutionService.deleteExecution(projectId, executionId, reqVO); + + verify(projectObjectAuthorizationService).checkAnyOwnerOrProjectPermission(projectId, + List.of(3001L, 3002L), ProjectExecutionConstants.PERMISSION_DELETE); + verify(projectExecutionMapper).deleteById(executionId); + } + + @Test + void precheckDeleteExecution_shouldAuthorizeProjectManagerOrExecutionOwnerBeforeCountingTasks() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectDO project = createEditableProject(projectId); + project.setManagerUserId(3001L); + ProjectExecutionDO execution = createExecution(projectId, executionId, 3002L); + + when(projectMapper.selectById(projectId)).thenReturn(project); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(projectTaskMapper.countByExecutionId(executionId)).thenReturn(3); + + var result = projectExecutionService.precheckDeleteExecution(projectId, executionId); + + verify(projectObjectAuthorizationService).checkAnyOwnerOrProjectPermission(projectId, + List.of(3001L, 3002L), ProjectExecutionConstants.PERMISSION_DELETE); + assertEquals(3, result.getTaskCount()); + assertEquals(true, result.getHasDependentData()); + } + @Test void changeExecutionStatus_shouldUpdateByTransitionAndWriteStatusAndAuditLogs() { Long projectId = 2001L; @@ -771,8 +851,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) .thenReturn(createProjectStatus("pending", true)); when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null); - when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L)) - .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L))); when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any())) .thenReturn(success(true)); @@ -877,8 +955,6 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { .thenReturn(createExecutionStatus("pending", true)); when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null); - when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L)) - .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L))); when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any())) .thenReturn(success(true)); when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))