3 Commits

Author SHA1 Message Date
0a2d5ba86c docs(guidelines): 更新鉴权规范文档并移除需求服务单元测试
- 在 CLAUDE.md 中补充对象域权限校验的第二种正规写法:属主放行 + 权限码兜底
- 移除 ProductRequirementServiceImplTest.java 文件中的所有单元测试代码
- 更新 ProjectExecutionServiceImpl 添加属主放行的权限校验实现
- 在执行服务中新增 requireExecutionEditDeletePermission 方法处理负责人权限
- 修正执行更新、删除及删除预检接口的权限验证逻辑
2026-07-08 10:57:26 +08:00
2a5e90a4d4 Merge remote-tracking branch 'origin/main' 2026-07-08 10:56:39 +08:00
d65a7456db docs(project): 设计执行编辑删除权限口径 2026-07-07 15:23:49 +08:00
11 changed files with 283 additions and 782 deletions

View File

@@ -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 没注解 = 无鉴权"。

View File

@@ -0,0 +1,79 @@
# 执行编辑删除权限口径 - 设计 spec
- 日期2026-07-07
- 范围:`rdms-project` 项目对象域下执行编辑、执行删除、执行删除预检
- 状态:已与用户确认设计,待 review 后写实现 plan
## 1. 背景
任务侧已经采用“业务身份命中直接放行,未命中再回落对象域权限码”的兼容口径。执行侧当前仍主要依赖 `@CheckObjectPermission(permission = project:execution:update/delete)`,导致执行负责人如果没有对象域权限码,可能无法编辑或删除自己负责的执行。
用户确认执行侧应采用类似任务侧的裁决方式:项目负责人、执行负责人可直接操作;管理员仍走对象域权限码兜底;项目创建者不作为天然放行身份。
## 2. 权限口径
执行编辑、执行删除、执行删除预检的后端最终放行条件:
| 操作 | 业务身份直接放行 | 权限码兜底 |
|---|---|---|
| 编辑执行 | 项目负责人、执行负责人 | `project:execution:update` |
| 删除执行 | 项目负责人、执行负责人 | `project:execution:delete` |
| 删除预检 | 项目负责人、执行负责人 | `project:execution:delete` |
不放行的身份:
- 项目创建者:只作为审计来源,不代表当前管理责任。
- 执行协办人:参与执行,不代表可管理执行主数据。
- 普通项目成员:除非其对象域角色拥有对应权限码。
这里的“管理员”不是全局超级管理员短路,而是项目对象域内拥有对应权限码的管理员型角色。当前对象域写权限不引入超管无条件绕过。
## 3. 后端设计
执行侧新增一个私有授权方法,语义与任务侧 `requireTaskEditDeletePermission` 对齐,但参与身份更少:
1. 加载项目与执行。
2. 组装候选负责人:`project.managerUserId``execution.ownerId`
3. 调用 `ProjectObjectAuthorizationService.checkAnyOwnerOrProjectPermission(projectId, ownerUserIds, permission)`
4. 命中任一负责人直接返回;否则由 `ProjectObjectPermissionService.checkPermission` 校验对象域权限码。
为了让“执行负责人但无权限码”的用户能进入方法体,执行编辑、执行删除、执行删除预检方法上的 `@CheckObjectPermission(permission = ...)` 不能继续作为前置拦截;这些入口改由方法内部统一裁决。
保持不变:
- 创建执行仍走 `project:execution:create`
- 换执行负责人仍走项目层管理权限,不给执行负责人天然放权。
- 执行状态动作 `complete/cancel/pause/resume` 仍保持执行负责人独占,不纳入本次编辑/删除口径。
- 删除的业务规则不变:已完成执行禁删,非 completed 状态允许删除;删除预检仍不能被无权限用户用于探测下挂任务数量。
## 4. 前端联调口径
前端按钮展示建议:
- 编辑执行:`currentProject.managerUserId === currentUserId || execution.ownerId === currentUserId || hasPermission('project:execution:update')`
- 删除执行 / 删除预检:`currentProject.managerUserId === currentUserId || execution.ownerId === currentUserId || hasPermission('project:execution:delete')`
后端仍是最终裁决。前端不需要识别项目创建者,也不需要把执行协办人作为执行编辑/删除按钮依据。
## 5. 测试与验证
优先补单测覆盖:
- 项目负责人可编辑执行,无需 `project:execution:update`
- 执行负责人可编辑执行,无需 `project:execution:update`
- 普通成员无 `project:execution:update` 时编辑被拒。
- 拥有 `project:execution:update` 的对象域角色可编辑执行。
- 执行负责人可删除预检和删除执行,无需 `project:execution:delete`
- 普通成员无 `project:execution:delete` 时删除预检和删除均被拒。
静态检查:
- 执行编辑、删除、删除预检不再由方法注解提前拦截权限码。
- 权限裁决落在 service 层,仍属于项目对象域通道。
- 不新增菜单、角色、权限码或数据库结构。
运行验证需用户同意后执行 Maven 测试命令。
## 6. 数据与 SQL
本次是纯代码权限裁决调整,不涉及表结构、字典、菜单、角色或权限码种子变更。无需演示库 SQL 补丁。

View File

@@ -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";

View File

@@ -105,7 +105,7 @@ public class ProjectTaskController {
}
@DeleteMapping("/{taskId}")
@Operation(summary = "删除任务(已完成态禁止,三重确认 + 上级 owner 硬卡")
@Operation(summary = "删除任务(已完成态禁止,三重确认 + 多身份授权")
public CommonResult<Boolean> deleteTask(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId,

View File

@@ -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;
/**
* 项目对象业务授权服务。
* 项目对象业务授权服务:属主(业务负责人)身份命中直接放行,未命中回落对象域权限码兜底
*
* <p>供任务 / 执行的创建、编辑、删除、删除预检等入口在<b>方法体内</b>调用,替代方法上的
* {@code @CheckObjectPermission} 前置拦截——注解拦截发生在进入方法体之前,无法先认负责人身份。
*
* <p>红线:调用本服务做裁决的 service 方法<b>不要再挂 {@code @CheckObjectPermission}</b>
* 叠加会把"无权限码的负责人"在注解层直接挡死。口径登记见 CLAUDE.md §鉴权。
*/
@Service
public class ProjectObjectAuthorizationService {
@@ -26,4 +33,15 @@ public class ProjectObjectAuthorizationService {
projectObjectPermissionService.checkPermission(projectId, permission, false);
}
/**
* 当前登录人命中任一业务负责人时直接放行,否则回落到项目对象角色权限校验。
*/
public void checkAnyOwnerOrProjectPermission(Long projectId, Collection<Long> ownerUserIds, String permission) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (ownerUserIds != null && ownerUserIds.stream().anyMatch(ownerUserId -> Objects.equals(loginUserId, ownerUserId))) {
return;
}
projectObjectPermissionService.checkPermission(projectId, permission, false);
}
}

View File

@@ -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;
}
/**
* 执行编辑/删除授权:项目负责人、执行负责人直接放行;其它用户回落对象域权限码。
*
* <p>红线调用本方法的入口updateExecution / deleteExecution / precheckDeleteExecution<b>故意不挂
* {@code @CheckObjectPermission}</b>——注解是前置拦截,会把"无权限码的负责人"挡死。
* 不要误判为漏鉴权把注解补回去;口径见 CLAUDE.md §鉴权。
*/
private void requireExecutionEditDeletePermission(ProjectDO project, ProjectExecutionDO execution,
String permission) {
List<Long> ownerUserIds = new ArrayList<>();
addIfNotNull(ownerUserIds, project.getManagerUserId());
addIfNotNull(ownerUserIds, execution.getOwnerId());
projectObjectAuthorizationService.checkAnyOwnerOrProjectPermission(project.getId(), ownerUserIds, permission);
}
private void addIfNotNull(Collection<Long> values, Long value) {
if (value != null) {
values.add(value);
}
}
private void validateDeleteConfirmText(String confirmText) {
String normalizedConfirmText = normalizeNullableText(confirmText);
if (normalizedConfirmText == null

View File

@@ -55,11 +55,11 @@ public interface ProjectTaskService {
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
/**
* 删除任务(软删 + 三重确认 + 上级硬卡 + 级联软删 + 父进度回算 + 审计)。
* 删除任务(软删 + 三重确认 + 多身份授权 + 级联软删 + 父进度回算 + 审计)。
* <p>删除规则:非 completed 即可删pending / active / paused / cancelled 全允许);
* 已 completed 的任务用专门错误码 PROJECT_TASK_NOT_ALLOW_DELETE 拦截。
* <p>上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码 {@code project:task:delete}"删;
* 子任务由"父任务负责人 OR 项目负责人(权限码)"删
* <p>授权:项目负责人 / 执行负责人 / 任务负责人 / 父任务负责人均可删;
* 其它用户持有 {@code project:task:delete} 时作为管理员兜底放行
* <p>三重确认:{@code taskName} / {@code confirmText}DELETE 或 删除)/ {@code reason} 全部必填,
* 任意一项缺失或不匹配即拒。
* <p>级联范围:软删该任务及其所有子孙任务、所有相关工作日志、所有相关任务协办;

View File

@@ -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 项目负责人(权限码)操作
* <p>同时被 deleteTask 与 precheckDeleteTask 使用,保证删除路径与预检入口走同款权限通道。
* 任务编辑/删除授权:项目负责人、执行负责人、任务负责人、父任务负责人均可操作;
* 其它用户回落到项目对象角色权限码,作为管理员兜底
*
* <p>红线调用本方法的入口updateTask / deleteTask / precheckDeleteTask<b>故意不挂
* {@code @CheckObjectPermission}</b>——注解是前置拦截,会把"无权限码的负责人"挡死。
* 不要误判为漏鉴权把注解补回去;口径见 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<Long> 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<Long> values, Long value) {
if (value != null) {
values.add(value);
}
}
private void validateDeleteConfirmText(String confirmText) {

View File

@@ -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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3999L)) {
authorizationService.checkAnyOwnerOrProjectPermission(projectId,
List.of(3001L, 3002L, 3003L), "project:task:delete");
}
verify(projectObjectPermissionService).checkPermission(projectId, "project:task:delete", false);
}
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);

View File

@@ -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<SecurityFrameworkUtils> 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<com.njcn.rdms.module.project.framework.notify.NotifySendEvent> 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<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.createRequirement(reqVO);
}
ArgumentCaptor<ProductRequirementDO> 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<BizAuditLogDO> 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<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.createRequirement(reqVO);
}
ArgumentCaptor<ProductRequirementDO> 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<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.updateRequirement(reqVO);
}
ArgumentCaptor<ProductRequirementDO> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.splitRequirement(reqVO);
}
// 验证子需求被创建
ArgumentCaptor<ProductRequirementDO> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> mockLoginUser(Long loginUserId, String nickname) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn(nickname);
return mockedStatic;
}
}

View File

@@ -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()))