Compare commits
6 Commits
2026-06
...
0a2d5ba86c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a2d5ba86c | |||
| 2a5e90a4d4 | |||
| 796c79d58f | |||
| d65a7456db | |||
| 66c187f3de | |||
| 9e502e6a80 |
@@ -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 没注解 = 无鉴权"。
|
||||
|
||||
|
||||
79
docs/superpowers/specs/2026-07-07-执行编辑删除权限口径-design.md
Normal file
79
docs/superpowers/specs/2026-07-07-执行编辑删除权限口径-design.md
Normal 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 补丁。
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -76,6 +76,13 @@ public class ProjectRequirementController {
|
||||
return success(requirementService.getRequirementTree(pageReqVO));
|
||||
}
|
||||
|
||||
@GetMapping("/execution-options")
|
||||
@Operation(summary = "获取执行可关联的项目需求下拉选项")
|
||||
public CommonResult<PageResult<ProjectRequirementRespVO>> getExecutionRequirementOptions(
|
||||
@Valid ProjectRequirementPageReqVO pageReqVO) {
|
||||
return success(requirementService.getExecutionRequirementOptions(pageReqVO));
|
||||
}
|
||||
|
||||
@PostMapping("/change-status")
|
||||
@Operation(summary = "变更需求状态")
|
||||
public CommonResult<Boolean> changeRequirementStatus(@Valid @RequestBody ProjectRequirementStatusActionReqVO reqVO) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,6 @@ public class MonthlyReportApproveReqVO extends WorkReportStatusActionReqVO {
|
||||
|
||||
@Schema(description = "面谈时间")
|
||||
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
|
||||
@NotNull
|
||||
private LocalDate meetingDate;
|
||||
|
||||
@Schema(description = "优势描述")
|
||||
|
||||
@@ -114,6 +114,20 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
|
||||
.eq(ProjectDO::getId, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅更新实际开始/结束日期。null 字段依据全局 FieldStrategy 不会被覆盖。
|
||||
*/
|
||||
default int updateActualDatesById(Long id, LocalDate actualStartDate, LocalDate actualEndDate) {
|
||||
if (actualStartDate == null && actualEndDate == null) {
|
||||
return 0;
|
||||
}
|
||||
ProjectDO update = new ProjectDO();
|
||||
update.setActualStartDate(actualStartDate);
|
||||
update.setActualEndDate(actualEndDate);
|
||||
return update(update, new LambdaQueryWrapperX<ProjectDO>()
|
||||
.eq(ProjectDO::getId, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。
|
||||
* excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。
|
||||
|
||||
@@ -22,20 +22,28 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
|
||||
* 分页查询需求列表
|
||||
*/
|
||||
default PageResult<ProjectRequirementDO> selectPage(ProjectRequirementPageReqVO reqVO) {
|
||||
return selectPage(reqVO, buildQueryWrapper(reqVO));
|
||||
return selectPage(reqVO, buildQueryWrapper(reqVO, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询需求列表,并排除指定状态集合。
|
||||
*/
|
||||
default PageResult<ProjectRequirementDO> selectPage(ProjectRequirementPageReqVO reqVO, Collection<String> excludeStatusCodes) {
|
||||
return selectPage(reqVO, buildQueryWrapper(reqVO, excludeStatusCodes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有符合条件的需求列表,用于树查询
|
||||
*/
|
||||
default List<ProjectRequirementDO> selectList(ProjectRequirementPageReqVO reqVO) {
|
||||
return selectList(buildQueryWrapper(reqVO));
|
||||
return selectList(buildQueryWrapper(reqVO, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建查询条件
|
||||
*/
|
||||
private LambdaQueryWrapperX<ProjectRequirementDO> buildQueryWrapper(ProjectRequirementPageReqVO reqVO) {
|
||||
private LambdaQueryWrapperX<ProjectRequirementDO> buildQueryWrapper(ProjectRequirementPageReqVO reqVO,
|
||||
Collection<String> excludeStatusCodes) {
|
||||
LambdaQueryWrapperX<ProjectRequirementDO> queryWrapper = new LambdaQueryWrapperX<>();
|
||||
if (StringUtils.hasText(reqVO.getTitle())) {
|
||||
queryWrapper.like(ProjectRequirementDO::getTitle, reqVO.getTitle());
|
||||
@@ -49,8 +57,11 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
|
||||
.inIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleIds())
|
||||
.eqIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleId())
|
||||
.eqIfPresent(ProjectRequirementDO::getParentId, reqVO.getParentId())
|
||||
.eq(ProjectRequirementDO::getProjectId, reqVO.getProjectId())
|
||||
.orderByAsc(ProjectRequirementDO::getSort)
|
||||
.eq(ProjectRequirementDO::getProjectId, reqVO.getProjectId());
|
||||
if (excludeStatusCodes != null && !excludeStatusCodes.isEmpty()) {
|
||||
queryWrapper.notIn(ProjectRequirementDO::getStatusCode, excludeStatusCodes);
|
||||
}
|
||||
queryWrapper.orderByAsc(ProjectRequirementDO::getSort)
|
||||
.orderByDesc(ProjectRequirementDO::getCreateTime);
|
||||
return queryWrapper;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,19 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface ProjectStatusLogMapper extends BaseMapperX<ProjectStatusLogDO> {
|
||||
|
||||
default ProjectStatusLogDO selectEarliestByProjectIdAndActionType(Long projectId, String actionType) {
|
||||
if (projectId == null || actionType == null || actionType.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
List<ProjectStatusLogDO> list = selectList(new LambdaQueryWrapperX<ProjectStatusLogDO>()
|
||||
.eq(ProjectStatusLogDO::getProjectId, projectId)
|
||||
.eq(ProjectStatusLogDO::getActionType, actionType.trim())
|
||||
.orderByAsc(BaseDO::getCreateTime)
|
||||
.orderByAsc(ProjectStatusLogDO::getId)
|
||||
.last("LIMIT 1"));
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
default List<ProjectStatusLogDO> selectListByProjectId(Long projectId, String actionType, LocalDateTime[] operateTime) {
|
||||
return selectList(new LambdaQueryWrapperX<ProjectStatusLogDO>()
|
||||
.eq(ProjectStatusLogDO::getProjectId, projectId)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.njcn.rdms.module.project.dal.mysql.project.task;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
@@ -115,16 +116,15 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅更新实际开始/结束日期。null 字段依据全局 FieldStrategy 不会被覆盖。
|
||||
* 仅更新实际开始/结束日期。显式 set,允许把字段清空为 null。
|
||||
*/
|
||||
default int updateActualDatesById(Long id, LocalDate actualStartDate, LocalDate actualEndDate) {
|
||||
if (actualStartDate == null && actualEndDate == null) {
|
||||
if (id == null) {
|
||||
return 0;
|
||||
}
|
||||
ProjectTaskDO update = new ProjectTaskDO();
|
||||
update.setActualStartDate(actualStartDate);
|
||||
update.setActualEndDate(actualEndDate);
|
||||
return update(update, new LambdaQueryWrapperX<ProjectTaskDO>()
|
||||
return update(null, new LambdaUpdateWrapper<ProjectTaskDO>()
|
||||
.set(ProjectTaskDO::getActualStartDate, actualStartDate)
|
||||
.set(ProjectTaskDO::getActualEndDate, actualEndDate)
|
||||
.eq(ProjectTaskDO::getId, id));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.njcn.rdms.module.project.dal.mysql.project.task;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
||||
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogPageReqVO;
|
||||
@@ -58,6 +59,19 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
|
||||
""")
|
||||
BigDecimal sumDurationByTaskId(@Param("taskId") Long taskId);
|
||||
|
||||
/**
|
||||
* 项目下所有任务工作日志的最早开始日期。逻辑删除的任务 / 工作日志不参与统计。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT MIN(w.start_date)
|
||||
FROM rdms_task_worklog w
|
||||
INNER JOIN rdms_task t ON t.id = w.task_id
|
||||
WHERE w.deleted = b'0'
|
||||
AND t.deleted = b'0'
|
||||
AND t.project_id = #{projectId}
|
||||
""")
|
||||
LocalDate selectEarliestStartDateByProjectId(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 批量任务工时小时数汇总,返回 [{taskId, total}]。用于详情/分页装配避免 N+1。
|
||||
*/
|
||||
@@ -83,6 +97,22 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
|
||||
.eq(TaskWorklogDO::getTaskId, taskId)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务下最早一条工作日志(按 start_date asc, create_time asc, id asc)。
|
||||
*/
|
||||
default TaskWorklogDO selectEarliestByTaskId(Long taskId) {
|
||||
if (taskId == null) {
|
||||
return null;
|
||||
}
|
||||
List<TaskWorklogDO> list = selectList(new LambdaQueryWrapperX<TaskWorklogDO>()
|
||||
.eq(TaskWorklogDO::getTaskId, taskId)
|
||||
.orderByAsc(TaskWorklogDO::getStartDate)
|
||||
.orderByAsc(BaseDO::getCreateTime)
|
||||
.orderByAsc(TaskWorklogDO::getId)
|
||||
.last("LIMIT 1"));
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取指定用户在该任务下最新的一条工时(按 end_date desc, create_time desc, id desc)。
|
||||
* 用于 owner 填报后回查"本人最新一条"以同步任务进度。
|
||||
@@ -102,6 +132,24 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该任务下指定用户 progressRate=100 的最晚一条工作日志(按 end_date desc, create_time desc, id desc)。
|
||||
*/
|
||||
default TaskWorklogDO selectLatestCompletedByTaskIdAndUserId(Long taskId, Long userId) {
|
||||
if (taskId == null || userId == null) {
|
||||
return null;
|
||||
}
|
||||
List<TaskWorklogDO> list = selectList(new LambdaQueryWrapperX<TaskWorklogDO>()
|
||||
.eq(TaskWorklogDO::getTaskId, taskId)
|
||||
.eq(TaskWorklogDO::getUserId, userId)
|
||||
.eq(TaskWorklogDO::getProgressRate, new BigDecimal("100"))
|
||||
.orderByDesc(TaskWorklogDO::getEndDate)
|
||||
.orderByDesc(BaseDO::getCreateTime)
|
||||
.orderByDesc(TaskWorklogDO::getId)
|
||||
.last("LIMIT 1"));
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该 (taskId, userId) 下 endDate 严格早于给定值的"最近一条",按 end_date desc, id desc 取首行。
|
||||
* 用于进度单调性校验(与"前一段"比较)。excludeId 不为 null 时排除自身(更新场景)。
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
/**
|
||||
* 站内信跳转参数常量。
|
||||
*/
|
||||
public final class NotifyJumpConstants {
|
||||
|
||||
private NotifyJumpConstants() {
|
||||
}
|
||||
|
||||
public static final String PARAM_JUMP_TYPE = "jumpType";
|
||||
public static final String PARAM_PROJECT_ID = "projectId";
|
||||
|
||||
public static final String JUMP_TYPE_PROJECT = "project";
|
||||
public static final String JUMP_TYPE_EXECUTION = "execution";
|
||||
public static final String JUMP_TYPE_TASK = "task";
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ public interface ProjectRequirementService {
|
||||
|
||||
PageResult<ProjectRequirementRespVO> getRequirementTree(ProjectRequirementPageReqVO pageReqVO);
|
||||
|
||||
PageResult<ProjectRequirementRespVO> getExecutionRequirementOptions(ProjectRequirementPageReqVO pageReqVO);
|
||||
|
||||
void changeRequirementStatus(ProjectRequirementStatusActionReqVO reqVO);
|
||||
|
||||
void changeRequirementStatusForReview(ProjectRequirementStatusActionReqVO reqVO);
|
||||
|
||||
@@ -255,21 +255,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
|
||||
@Override
|
||||
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", accessible = true)
|
||||
public PageResult<ProjectRequirementRespVO> getRequirementPage(ProjectRequirementPageReqVO pageReqVO) {
|
||||
if (pageReqVO.getModuleId() != null) {
|
||||
if (isAllRequirementsModule(pageReqVO.getModuleId())) {
|
||||
pageReqVO.setModuleId(null);
|
||||
} else {
|
||||
List<Long> moduleIds = getAllModuleIdsWithChildren(pageReqVO.getModuleId(), pageReqVO.getProjectId());
|
||||
pageReqVO.setModuleIds(moduleIds);
|
||||
pageReqVO.setModuleId(null);
|
||||
}
|
||||
}
|
||||
normalizePageModuleFilter(pageReqVO);
|
||||
PageResult<ProjectRequirementDO> pageResult = requirementMapper.selectPage(pageReqVO);
|
||||
Map<String, ObjectStatusModelDO> statusModelMap = getStatusModelMap();
|
||||
List<ProjectRequirementRespVO> list = pageResult.getList().stream()
|
||||
.map(requirement -> buildRequirementRespVO(requirement, statusModelMap))
|
||||
.collect(Collectors.toList());
|
||||
return new PageResult<>(list, pageResult.getTotal());
|
||||
return toRequirementRespPage(pageResult);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -325,6 +313,16 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
|
||||
return new PageResult<>(list, (long) total);
|
||||
}
|
||||
|
||||
@Override
|
||||
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", accessible = true)
|
||||
public PageResult<ProjectRequirementRespVO> getExecutionRequirementOptions(ProjectRequirementPageReqVO pageReqVO) {
|
||||
normalizePageModuleFilter(pageReqVO);
|
||||
Set<String> excludedStatusCodes = new HashSet<>(getTerminalStatusCodes());
|
||||
excludedStatusCodes.add(STATUS_REVIEW_REJECTED);
|
||||
PageResult<ProjectRequirementDO> pageResult = requirementMapper.selectPage(pageReqVO, excludedStatusCodes);
|
||||
return toRequirementRespPage(pageResult);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId",
|
||||
@@ -749,6 +747,27 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
|
||||
return childrenMap;
|
||||
}
|
||||
|
||||
private void normalizePageModuleFilter(ProjectRequirementPageReqVO pageReqVO) {
|
||||
if (pageReqVO.getModuleId() == null) {
|
||||
return;
|
||||
}
|
||||
if (isAllRequirementsModule(pageReqVO.getModuleId())) {
|
||||
pageReqVO.setModuleId(null);
|
||||
return;
|
||||
}
|
||||
List<Long> moduleIds = getAllModuleIdsWithChildren(pageReqVO.getModuleId(), pageReqVO.getProjectId());
|
||||
pageReqVO.setModuleIds(moduleIds);
|
||||
pageReqVO.setModuleId(null);
|
||||
}
|
||||
|
||||
private PageResult<ProjectRequirementRespVO> toRequirementRespPage(PageResult<ProjectRequirementDO> pageResult) {
|
||||
Map<String, ObjectStatusModelDO> statusModelMap = getStatusModelMap();
|
||||
List<ProjectRequirementRespVO> list = pageResult.getList().stream()
|
||||
.map(requirement -> buildRequirementRespVO(requirement, statusModelMap))
|
||||
.collect(Collectors.toList());
|
||||
return new PageResult<>(list, pageResult.getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性加载当前对象类型下的状态模型,供列表和树查询复用。
|
||||
*/
|
||||
|
||||
@@ -68,6 +68,12 @@ public interface ProjectService {
|
||||
|
||||
void autoStartProjectIfPending(Long projectId, String triggerAction);
|
||||
|
||||
/**
|
||||
* 以项目下最早一条任务工作日志的 startDate 为优先口径,同步项目实际开始日期。
|
||||
* 若项目下尚无任务工作日志,则回退到首次 auto_start 状态日志日期;两者都没有时不更新。
|
||||
*/
|
||||
void syncActualStartDateFromWorklogs(Long projectId);
|
||||
|
||||
/**
|
||||
* 重算项目进度并落库:AVG(项目下所有根任务 progressRate),沿用 task 维度的 progress_excluded 排除集合。
|
||||
* 由任务侧在"任务进度变化 / 状态变更 / 创建删除 / 父子结构变化"等入口触发。
|
||||
|
||||
@@ -44,10 +44,12 @@ import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants;
|
||||
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
|
||||
@@ -122,6 +124,8 @@ class ProjectServiceImpl implements ProjectService {
|
||||
@Resource
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Resource
|
||||
private TaskWorklogMapper taskWorklogMapper;
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper projectExecutionMapper;
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper projectRequirementMapper;
|
||||
@@ -276,6 +280,8 @@ class ProjectServiceImpl implements ProjectService {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("projectName", project.getProjectName());
|
||||
params.put("roleName", r.roleName());
|
||||
params.put(NotifyJumpConstants.PARAM_JUMP_TYPE, NotifyJumpConstants.JUMP_TYPE_PROJECT);
|
||||
params.put(NotifyJumpConstants.PARAM_PROJECT_ID, String.valueOf(project.getId()));
|
||||
applicationEventPublisher.publishEvent(
|
||||
com.njcn.rdms.module.project.framework.notify.NotifySendEvent.of(
|
||||
java.util.List.of(r.userId()),
|
||||
@@ -1065,6 +1071,19 @@ class ProjectServiceImpl implements ProjectService {
|
||||
changeStatus(project, transition, ObjectActivityConstants.PROJECT_ACTION_AUTO_START, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void syncActualStartDateFromWorklogs(Long projectId) {
|
||||
if (projectId == null) {
|
||||
return;
|
||||
}
|
||||
ProjectDO project = projectMapper.selectById(projectId);
|
||||
if (project == null) {
|
||||
return;
|
||||
}
|
||||
syncActualStartDate(project);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recalcProgress(Long projectId) {
|
||||
if (projectId == null) {
|
||||
@@ -1494,6 +1513,40 @@ class ProjectServiceImpl implements ProjectService {
|
||||
|
||||
writeProjectStatusLog(project, actionCode, fromStatus, toStatus, reason);
|
||||
writeBizAuditLog(project, actionCode, fromStatus, toStatus, null, reason);
|
||||
if (ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(actionCode)) {
|
||||
syncActualStartDate(project);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目实际开始日期口径:
|
||||
* 1) 优先取项目下最早一条任务工作日志的 startDate;
|
||||
* 2) 若无工作日志,则回退到首次 auto_start 状态日志的 createTime 所在日期;
|
||||
* 3) 两者都没有时不更新。
|
||||
*/
|
||||
private void syncActualStartDate(ProjectDO project) {
|
||||
if (project == null || project.getId() == null) {
|
||||
return;
|
||||
}
|
||||
LocalDate targetDate = resolveActualStartDate(project.getId());
|
||||
if (targetDate == null || Objects.equals(targetDate, project.getActualStartDate())) {
|
||||
return;
|
||||
}
|
||||
projectMapper.updateActualDatesById(project.getId(), targetDate, null);
|
||||
project.setActualStartDate(targetDate);
|
||||
}
|
||||
|
||||
private LocalDate resolveActualStartDate(Long projectId) {
|
||||
LocalDate earliestWorklogDate = taskWorklogMapper.selectEarliestStartDateByProjectId(projectId);
|
||||
if (earliestWorklogDate != null) {
|
||||
return earliestWorklogDate;
|
||||
}
|
||||
ProjectStatusLogDO autoStartLog = projectStatusLogMapper
|
||||
.selectEarliestByProjectIdAndActionType(projectId, ObjectActivityConstants.PROJECT_ACTION_AUTO_START);
|
||||
if (autoStartLog == null || autoStartLog.getCreateTime() == null) {
|
||||
return null;
|
||||
}
|
||||
return autoStartLog.getCreateTime().toLocalDate();
|
||||
}
|
||||
|
||||
private ProjectContextProjectRespVO buildCurrentProject(ProjectDO project) {
|
||||
|
||||
@@ -43,10 +43,12 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants;
|
||||
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;
|
||||
@@ -127,6 +129,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
@Resource
|
||||
private ProjectRequirementMapper projectRequirementMapper;
|
||||
@Resource
|
||||
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
|
||||
@Resource
|
||||
private StatusActionTextResolver statusActionTextResolver;
|
||||
/** 站内信事件发布:创建执行后发「执行指派」通知,由 NotifySendEventListener 事务提交后统一发送。 */
|
||||
@Resource
|
||||
@@ -193,21 +197,23 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
ProjectDO project = projectMapper.selectById(execution.getProjectId());
|
||||
params.put("projectName", project == null ? "" : project.getProjectName());
|
||||
params.put("executionName", execution.getExecutionName());
|
||||
params.put(NotifyJumpConstants.PARAM_JUMP_TYPE, NotifyJumpConstants.JUMP_TYPE_EXECUTION);
|
||||
params.put(NotifyJumpConstants.PARAM_PROJECT_ID, String.valueOf(execution.getProjectId()));
|
||||
applicationEventPublisher.publishEvent(NotifySendEvent.of(recipients,
|
||||
NotifyTemplateCodeConstants.EXECUTION_ASSIGNED, params,
|
||||
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());
|
||||
@@ -445,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);
|
||||
@@ -479,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);
|
||||
@@ -492,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
|
||||
|
||||
@@ -9,8 +9,6 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTask
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 项目任务 Service 接口。
|
||||
*/
|
||||
@@ -57,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>级联范围:软删该任务及其所有子孙任务、所有相关工作日志、所有相关任务协办;
|
||||
@@ -89,7 +87,7 @@ public interface ProjectTaskService {
|
||||
/**
|
||||
* 由工时填报(5.11)触发的任务自动开始:当任务当前处于初始态(initialFlag=true)时,
|
||||
* 走一次 {@link com.njcn.rdms.module.project.constant.ObjectActivityConstants#TASK_ACTION_AUTO_START}
|
||||
* 动作把任务推进到下一状态,并复用 5.5 的钩子写入 actualStartDate / 状态日志 / 审计日志。
|
||||
* 动作把任务推进到下一状态,并写状态日志 / 审计日志。
|
||||
* <p>
|
||||
* 调用方必须已自行确认登录人是 task.ownerId(仅 owner 工时驱动状态推进,与 progressRate 同步口径一致)。
|
||||
* 本方法**不走** @CheckObjectPermission,因为 5.11 持有的是 PERMISSION_WORKLOG,
|
||||
@@ -101,16 +99,15 @@ public interface ProjectTaskService {
|
||||
void internalAutoStartByWorklog(ProjectTaskDO task);
|
||||
|
||||
/**
|
||||
* 工作日志填报触发:当任务"实际开始时间"为空时,用给定的开始日期回填,使"任务何时开始"
|
||||
* 以最早一条工作日志的开始日期为准。owner / 协办人共用,不限任务当前状态。
|
||||
* 按工作日志事实回算任务实际开始/结束日期:
|
||||
* <ul>
|
||||
* <li>actualStartDate = 该任务最早一条工作日志的 startDate(不区分填报人)</li>
|
||||
* <li>actualEndDate = 该任务负责人本人所填、progressRate=100 的最晚一条工作日志 endDate</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 与 {@link #internalAutoStartByWorklog} 解耦:本方法只回填 actualStartDate(不动实际结束日期、
|
||||
* 不推进任务状态)。调用顺序应在 internalAutoStartByWorklog 之前,确保后者的回填钩子见到非空值即跳过,
|
||||
* 不会再用"提交当天"覆盖工作日志的开始日期。
|
||||
* <p>
|
||||
* 任务实际开始时间已有值、或入参为空时静默 return。
|
||||
* 当日志被删除或改动后,本方法也负责把对应字段清空或改写为新的回算结果。
|
||||
*/
|
||||
void fillActualStartDateIfAbsent(Long taskId, LocalDate startDate);
|
||||
void syncActualDatesFromWorklogs(Long taskId);
|
||||
|
||||
/**
|
||||
* 由执行 cancel 触发的级联:取消指定执行下所有未终态的顶层任务(parentTaskId IS NULL)。
|
||||
|
||||
@@ -41,6 +41,7 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMappe
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
|
||||
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.notify.NotifyTemplateCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
@@ -65,7 +66,6 @@ import org.springframework.util.StringUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
@@ -90,8 +90,6 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
|
||||
@Slf4j
|
||||
public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
|
||||
private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
@Resource
|
||||
private ProjectMapper projectMapper;
|
||||
@Resource
|
||||
@@ -137,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) {
|
||||
@@ -210,29 +209,26 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
ProjectDO project = projectMapper.selectById(projectId);
|
||||
params.put("projectName", project == null ? "" : project.getProjectName());
|
||||
params.put("taskName", task.getTaskTitle());
|
||||
params.put(NotifyJumpConstants.PARAM_JUMP_TYPE, NotifyJumpConstants.JUMP_TYPE_TASK);
|
||||
params.put(NotifyJumpConstants.PARAM_PROJECT_ID, String.valueOf(projectId));
|
||||
applicationEventPublisher.publishEvent(NotifySendEvent.of(recipients,
|
||||
NotifyTemplateCodeConstants.TASK_ASSIGNED, params,
|
||||
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) {
|
||||
@@ -447,7 +450,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
|
||||
writeTaskStatusLog(current, actionCode, fromStatus, toStatus, reason);
|
||||
writeTaskAuditLog(current, actionCode, fromStatus, toStatus, null, reason);
|
||||
maybeFillActualDates(current, fromStatus, toStatus);
|
||||
// 通知执行:任务自动开始也属于"任务状态变更"事件,按 §6.4 触发表上报
|
||||
projectExecutionService.onTaskStatusChanged(
|
||||
current.getExecutionId(), current.getId(), fromStatus, toStatus, actionCode, reason);
|
||||
@@ -455,17 +457,25 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void fillActualStartDateIfAbsent(Long taskId, LocalDate startDate) {
|
||||
if (taskId == null || startDate == null) {
|
||||
public void syncActualDatesFromWorklogs(Long taskId) {
|
||||
if (taskId == null) {
|
||||
return;
|
||||
}
|
||||
ProjectTaskDO current = projectTaskMapper.selectById(taskId);
|
||||
// 仅在实际开始时间为空时回填;已有值(含 internalAutoStartByWorklog 已写过)则保持不变
|
||||
if (current == null || current.getActualStartDate() != null) {
|
||||
ProjectTaskDO task = projectTaskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
// 只动开始日期:实际结束日期传 null,依据全局 FieldStrategy 不会被覆盖
|
||||
projectTaskMapper.updateActualDatesById(taskId, startDate, null);
|
||||
TaskWorklogDO earliestWorklog = taskWorklogMapper.selectEarliestByTaskId(taskId);
|
||||
TaskWorklogDO latestOwnerCompletedWorklog = task.getOwnerId() == null
|
||||
? null
|
||||
: taskWorklogMapper.selectLatestCompletedByTaskIdAndUserId(taskId, task.getOwnerId());
|
||||
LocalDate actualStartDate = earliestWorklog == null ? null : earliestWorklog.getStartDate();
|
||||
LocalDate actualEndDate = latestOwnerCompletedWorklog == null ? null : latestOwnerCompletedWorklog.getEndDate();
|
||||
if (Objects.equals(task.getActualStartDate(), actualStartDate)
|
||||
&& Objects.equals(task.getActualEndDate(), actualEndDate)) {
|
||||
return;
|
||||
}
|
||||
projectTaskMapper.updateActualDatesById(taskId, actualStartDate, actualEndDate);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -728,41 +738,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
|| "resume".equals(actionCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据通用状态语义位推导实际开始/结束日期:
|
||||
* - 首次离开初始态(fromStatus.initialFlag=true)且未填写时,写入 actualStartDate
|
||||
* - 进入终态(toStatus.terminalFlag=true)且未填写时,写入 actualEndDate
|
||||
*/
|
||||
private void maybeFillActualDates(ProjectTaskDO task, String fromStatus, String toStatus) {
|
||||
LocalDate today = LocalDate.now(SERVER_ZONE);
|
||||
LocalDate newActualStart = null;
|
||||
LocalDate newActualEnd = null;
|
||||
if (task.getActualStartDate() == null) {
|
||||
ObjectStatusModelDO fromModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus);
|
||||
if (fromModel != null && Boolean.TRUE.equals(fromModel.getInitialFlag())) {
|
||||
newActualStart = today;
|
||||
}
|
||||
}
|
||||
if (task.getActualEndDate() == null) {
|
||||
ObjectStatusModelDO toModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, toStatus);
|
||||
if (toModel != null && Boolean.TRUE.equals(toModel.getTerminalFlag())) {
|
||||
newActualEnd = today;
|
||||
}
|
||||
}
|
||||
if (newActualStart == null && newActualEnd == null) {
|
||||
return;
|
||||
}
|
||||
projectTaskMapper.updateActualDatesById(task.getId(), newActualStart, newActualEnd);
|
||||
if (newActualStart != null) {
|
||||
task.setActualStartDate(newActualStart);
|
||||
}
|
||||
if (newActualEnd != null) {
|
||||
task.setActualEndDate(newActualEnd);
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectDO validateEditableProject(Long projectId) {
|
||||
ProjectDO project = validateProjectExists(projectId);
|
||||
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
||||
@@ -916,7 +891,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
|
||||
writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason);
|
||||
writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason);
|
||||
maybeFillActualDates(task, fromStatus, toStatus);
|
||||
|
||||
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算(forceCompleteProgress 内部触发项目刷新)
|
||||
if ("complete".equals(actionCode)) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
|
||||
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectService;
|
||||
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
|
||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||
@@ -70,6 +71,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
private DictDataApi dictDataApi;
|
||||
@Resource
|
||||
private AttachmentFileIdResolver attachmentFileIdResolver;
|
||||
@Resource
|
||||
private ProjectService projectService;
|
||||
/**
|
||||
* 与 ProjectTaskService 互相依赖(ProjectTaskService 也注入本类),用 @Lazy 打破循环。
|
||||
* 仅用于 owner 工时变更后同步任务自身进度并触发父任务汇总。
|
||||
@@ -118,10 +121,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
|
||||
TaskWorklogDO worklog = buildWorklog(taskId, loginUserId, reqVO);
|
||||
taskWorklogMapper.insert(worklog);
|
||||
// 实际开始时间为空时,用本条工作日志的开始日期回填(owner / 协办人一致,不限任务当前状态)。
|
||||
// 必须在 internalAutoStartByWorklog 之前:先把开始日期落库,自动开始里的回填钩子见到非空值即跳过,
|
||||
// 不会再用"提交当天"覆盖工作日志填写的开始日期。
|
||||
projectTaskService.fillActualStartDateIfAbsent(taskId, reqVO.getStartDate());
|
||||
projectTaskService.syncActualDatesFromWorklogs(taskId);
|
||||
projectService.syncActualStartDateFromWorklogs(projectId);
|
||||
// 任意填报人(owner / 协办人)都触发任务自动开始(仅当任务仍处初始态时生效),推进任务状态。
|
||||
// 任务"是否开始"是客观事实,协办人开工同样代表任务已开始,不应等 owner 补工时才反映。
|
||||
projectTaskService.internalAutoStartByWorklog(task);
|
||||
@@ -165,6 +166,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
update.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
|
||||
update.setAttachments(reqVO.getAttachments());
|
||||
taskWorklogMapper.updateById(update);
|
||||
projectTaskService.syncActualDatesFromWorklogs(taskId);
|
||||
projectService.syncActualStartDateFromWorklogs(projectId);
|
||||
// owner 改自己工时:按"owner 最新一条"重算任务进度
|
||||
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {
|
||||
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
|
||||
@@ -186,6 +189,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
|
||||
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN);
|
||||
}
|
||||
taskWorklogMapper.deleteById(worklog.getId());
|
||||
projectTaskService.syncActualDatesFromWorklogs(taskId);
|
||||
projectService.syncActualStartDateFromWorklogs(projectId);
|
||||
// 删的是 owner 自己的工时:按"owner 剩余最新一条"重算任务进度;
|
||||
// 若 owner 已无任何工时,syncOwnerProgressFromLatestWorklog 内部会保留原值不动(不归零)
|
||||
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {
|
||||
|
||||
@@ -67,6 +67,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
|
||||
public class WorkReportDefaultDraftService {
|
||||
|
||||
private static final String WORK_ITEM_MY_ITEMS = "我的事项";
|
||||
private static final String WORKLOG_DAY_SEPARATOR = "[[WR_DAY_SPLIT]]";
|
||||
|
||||
@Resource
|
||||
private ObjectStatusModelMapper objectStatusModelMapper;
|
||||
@@ -759,7 +760,7 @@ public class WorkReportDefaultDraftService {
|
||||
builder.append(lineTitle);
|
||||
appendBracket(builder, priority, latestProgressRate, totalWorkHours);
|
||||
if (!workContents.isEmpty()) {
|
||||
builder.append(":").append(String.join(";", workContents));
|
||||
builder.append(":").append(String.join(WORKLOG_DAY_SEPARATOR, workContents));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
@@ -860,7 +861,7 @@ public class WorkReportDefaultDraftService {
|
||||
builder.append("(").append(String.join(" / ", parts)).append(")");
|
||||
}
|
||||
if (weeklyMode && worklogAggregate.hasWorkContent()) {
|
||||
builder.append(":").append(String.join(";", worklogAggregate.workContents()));
|
||||
builder.append(":").append(String.join(WORKLOG_DAY_SEPARATOR, worklogAggregate.workContents()));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -811,6 +811,10 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
com.njcn.rdms.module.project.framework.notify.NotifySendEvent first = captor.getAllValues().get(0);
|
||||
assertEquals("project_created", first.getTemplateCode());
|
||||
assertEquals(9L, first.getOperatorUserId());
|
||||
assertEquals(com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants.JUMP_TYPE_PROJECT,
|
||||
first.getParams().get(com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants.PARAM_JUMP_TYPE));
|
||||
assertEquals("10",
|
||||
first.getParams().get(com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants.PARAM_PROJECT_ID));
|
||||
assertEquals("项目经理", first.getParams().get("roleName"));
|
||||
assertEquals("灿能项目", first.getParams().get("projectName"));
|
||||
assertTrue(first.getUserIds().contains(5L));
|
||||
|
||||
@@ -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;
|
||||
@@ -33,7 +35,9 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
||||
import com.njcn.rdms.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;
|
||||
@@ -106,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;
|
||||
@@ -118,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
|
||||
@@ -129,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"))
|
||||
@@ -202,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"))
|
||||
@@ -268,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));
|
||||
|
||||
@@ -298,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));
|
||||
|
||||
@@ -309,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;
|
||||
@@ -339,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;
|
||||
@@ -770,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));
|
||||
|
||||
@@ -876,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()))
|
||||
@@ -919,6 +996,9 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals("execution_assigned", ev.getTemplateCode());
|
||||
assertEquals(7L, ev.getOperatorUserId());
|
||||
assertTrue(ev.getUserIds().containsAll(List.of(5L, 6L)));
|
||||
assertEquals(NotifyJumpConstants.JUMP_TYPE_EXECUTION,
|
||||
ev.getParams().get(NotifyJumpConstants.PARAM_JUMP_TYPE));
|
||||
assertEquals("10", ev.getParams().get(NotifyJumpConstants.PARAM_PROJECT_ID));
|
||||
assertEquals("灿能项目", ev.getParams().get("projectName"));
|
||||
assertEquals("一阶段", ev.getParams().get("executionName"));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
|
||||
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.notify.NotifyTemplateCodeConstants;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -63,6 +64,9 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
ArgumentCaptor<NotifySendEvent> captor = ArgumentCaptor.forClass(NotifySendEvent.class);
|
||||
verify(applicationEventPublisher).publishEvent(captor.capture());
|
||||
NotifySendEvent event = captor.getValue();
|
||||
assertEquals(NotifyJumpConstants.JUMP_TYPE_TASK,
|
||||
event.getParams().get(NotifyJumpConstants.PARAM_JUMP_TYPE));
|
||||
assertEquals("50", event.getParams().get(NotifyJumpConstants.PARAM_PROJECT_ID));
|
||||
assertEquals(NotifyTemplateCodeConstants.TASK_ASSIGNED, event.getTemplateCode());
|
||||
assertTrue(event.getUserIds().contains(1L)); // 负责人
|
||||
assertTrue(event.getUserIds().contains(2L)); // 协办人
|
||||
|
||||
@@ -131,6 +131,13 @@ public class FileController {
|
||||
// https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/
|
||||
path = URLUtil.decode(path, StandardCharsets.UTF_8, false);
|
||||
|
||||
FileDO file = fileService.getFileByConfigIdAndPath(configId, path);
|
||||
if (file == null) {
|
||||
log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path);
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取内容
|
||||
byte[] content = fileService.getFileContent(configId, path);
|
||||
if (content == null) {
|
||||
@@ -138,7 +145,7 @@ public class FileController {
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
}
|
||||
writeAttachment(response, path, content);
|
||||
writeAttachment(response, StrUtil.blankToDefault(file.getName(), file.getPath()), content);
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
|
||||
@@ -114,9 +114,10 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
|
||||
@Override
|
||||
public String presignGetUrl(String url, Integer expirationSeconds) {
|
||||
// 1. 将 url 转换为 path
|
||||
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
|
||||
path = HttpUtils.decodeUtf8(HttpUtils.removeUrlQuery(path));
|
||||
// 1. 将入参转换为对象 key
|
||||
// - upload(...) 这里传进来的是原始 path(未编码),绝不能先 decode
|
||||
// - 历史记录 / 对外接口传进来的可能是完整 URL(可能已编码),这时才需要 decode
|
||||
String path = resolveObjectKey(url);
|
||||
|
||||
// 2.1 情况一:公开访问:无需签名
|
||||
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
|
||||
@@ -134,6 +135,23 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
return signedUrl.toString();
|
||||
}
|
||||
|
||||
private String resolveObjectKey(String urlOrPath) {
|
||||
if (!HttpUtil.isHttp(urlOrPath) && !HttpUtil.isHttps(urlOrPath)) {
|
||||
return urlOrPath;
|
||||
}
|
||||
|
||||
String cleaned = HttpUtils.removeUrlQuery(urlOrPath);
|
||||
String path = StrUtil.removePrefix(cleaned, config.getDomain() + "/");
|
||||
if (StrUtil.equals(path, cleaned)) {
|
||||
URI uri = URI.create(cleaned);
|
||||
path = StrUtil.removePrefix(StrUtil.nullToEmpty(uri.getPath()), "/");
|
||||
if (Boolean.TRUE.equals(config.getEnablePathStyleAccess())) {
|
||||
path = StrUtil.removePrefix(path, config.getBucket() + "/");
|
||||
}
|
||||
}
|
||||
return HttpUtils.decodeUtf8(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 bucket + endpoint 构建访问的 Domain 地址
|
||||
*
|
||||
|
||||
@@ -63,6 +63,15 @@ public interface FileService {
|
||||
Long createFile(FileCreateReqVO createReqVO);
|
||||
FileDO getFile(Long id);
|
||||
|
||||
/**
|
||||
* 按 (configId, path) 获取文件记录。
|
||||
*
|
||||
* @param configId 文件配置编号
|
||||
* @param path 文件存储路径
|
||||
* @return 文件记录,不存在返回 null
|
||||
*/
|
||||
FileDO getFileByConfigIdAndPath(Long configId, String path);
|
||||
|
||||
/**
|
||||
* 通过文件 URL 获得文件。
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.njcn.rdms.module.system.service.file;
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
@@ -42,14 +43,6 @@ public class FileServiceImpl implements FileService {
|
||||
* 目的:按照日期,进行分目录
|
||||
*/
|
||||
static boolean PATH_PREFIX_DATE_ENABLE = true;
|
||||
/**
|
||||
* 上传文件的后缀,是否包含时间戳
|
||||
* <p>
|
||||
* 目的:保证文件的唯一性,避免覆盖
|
||||
* 定制:可按需调整成 UUID、或者其他方式
|
||||
*/
|
||||
static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true;
|
||||
|
||||
@Resource
|
||||
private FileConfigService fileConfigService;
|
||||
|
||||
@@ -103,34 +96,27 @@ public class FileServiceImpl implements FileService {
|
||||
|
||||
@VisibleForTesting
|
||||
String generateUploadPath(String name, String directory) {
|
||||
// 1. 生成前缀、后缀
|
||||
// 1. 生成前缀,并将存储文件名切换为安全 key:
|
||||
// path 仅承担对象存储 key / 路径职责,不再复用原文件名,避免 %、&、中文等字符进入 URI 路径。
|
||||
String prefix = null;
|
||||
if (PATH_PREFIX_DATE_ENABLE) {
|
||||
prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN);
|
||||
}
|
||||
String suffix = null;
|
||||
if (PATH_SUFFIX_TIMESTAMP_ENABLE) {
|
||||
suffix = String.valueOf(System.currentTimeMillis());
|
||||
String ext = FileUtil.extName(name);
|
||||
String storageName = IdUtil.fastSimpleUUID();
|
||||
if (StrUtil.isNotEmpty(ext)) {
|
||||
storageName = storageName + StrUtil.DOT + ext;
|
||||
}
|
||||
|
||||
// 2.1 先拼接 suffix 后缀
|
||||
if (StrUtil.isNotEmpty(suffix)) {
|
||||
String ext = FileUtil.extName(name);
|
||||
if (StrUtil.isNotEmpty(ext)) {
|
||||
name = FileUtil.mainName(name) + StrUtil.C_UNDERLINE + suffix + StrUtil.DOT + ext;
|
||||
} else {
|
||||
name = name + StrUtil.C_UNDERLINE + suffix;
|
||||
}
|
||||
}
|
||||
// 2.2 再拼接 prefix 前缀
|
||||
// 2. 先拼接 prefix 前缀
|
||||
if (StrUtil.isNotEmpty(prefix)) {
|
||||
name = prefix + StrUtil.SLASH + name;
|
||||
storageName = prefix + StrUtil.SLASH + storageName;
|
||||
}
|
||||
// 2.3 最后拼接 directory 目录
|
||||
// 3. 最后拼接 directory 目录
|
||||
if (StrUtil.isNotEmpty(directory)) {
|
||||
name = directory + StrUtil.SLASH + name;
|
||||
storageName = directory + StrUtil.SLASH + storageName;
|
||||
}
|
||||
return name;
|
||||
return storageName;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -166,6 +152,14 @@ public class FileServiceImpl implements FileService {
|
||||
return validateFileExists(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileDO getFileByConfigIdAndPath(Long configId, String path) {
|
||||
if (configId == null || StrUtil.isBlank(path)) {
|
||||
return null;
|
||||
}
|
||||
return fileMapper.selectByConfigIdAndPath(configId, path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileDO getFileByUrl(String url) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
|
||||
Reference in New Issue
Block a user