diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 72489a0..57353c3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -105,7 +105,10 @@ "PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; $env:PATH = \"$env:JAVA_HOME\\\\bin;$env:PATH\"; $out = & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am compile -DskipTests 2>&1; $code = $LASTEXITCODE; $out | Select-Object -Last 15; Write-Output \"EXIT=$code\")", "Bash(set \"JAVA_HOME=C:\\\\Program Files\\\\Java\\\\jdk-17\")", "Bash(\"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests -q)", - "Bash(\"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests)" + "Bash(\"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests)", + "Bash(grep -E \"\\\\.\\(sql|java|md\\)$\")", + "Bash(xargs grep -l \"INSERT INTO.*system_menu\")", + "Bash(Get-ChildItem *)" ] } } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java index 8bc269d..0bf93ed 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java @@ -59,6 +59,18 @@ public final class ProjectTaskConstants { */ public static final String PERMISSION_DELETE = "project:task:delete"; + /** + * 项目任务"查看全部"权限码(对象域,object_type='project')。 + * + * 用于跨执行视角下"项目全部任务"语义的接口/视图(page / status-board / + * board-page / summary 的 scope=all 分支)。无此权限码时,只能看到自己作为 + * owner 或活跃协办的任务(走 VisibilityScopeResolver.resolveForProject 过滤)。 + * + * 种子绑定:默认绑给项目负责人 + 项目创建人;普通成员、执行负责人默认不绑。 + * 实际项目中由运维在角色管理界面调整。 + */ + public static final String PERMISSION_LIST_ALL = "project:task:list-all"; + /** * 删除确认口令合法值集合;兼容大写英文 "DELETE" 与中文 "删除",前端可纯中文文案。 * 校验时精确匹配(trim 后比对)。 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java index 53e5377..3483d0a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java @@ -1,6 +1,7 @@ package com.njcn.rdms.module.project.controller.admin.project.execution; import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.framework.common.pojo.PageParam; import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO; @@ -65,6 +66,11 @@ public class ProjectExecutionController { @Operation(summary = "获取执行分页") public CommonResult> getExecutionPage(@PathVariable("projectId") Long projectId, @Valid ProjectExecutionPageReqVO reqVO) { + // 前端用负数 pageSize 表示"查询全部",统一归一为框架约定的 PAGE_SIZE_NONE(-1), + // 走 BaseMapperX.selectPage 的不分页短路;@Max(200) 仅拦上界,负数不会被 validator 卡。 + if (reqVO.getPageSize() != null && reqVO.getPageSize() < 0) { + reqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + } return success(projectExecutionService.getExecutionRespVOPage(projectId, reqVO)); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java index d612eeb..418574d 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java @@ -21,6 +21,10 @@ public class ProjectTaskRespVO { private Long projectId; @Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001") private Long executionId; + @Schema(description = "所属执行名称", example = "迭代 V1.0") + private String executionName; + @Schema(description = "所属执行状态码") + private String executionStatusCode; @Schema(description = "所属执行关联的项目需求编号(service 层批量回填)") private Long projectRequirementId; @Schema(description = "项目需求名称(service 层批量回填,避免 N+1)", example = "前端联调-需求A") diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java index 4fe0285..89efb30 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java @@ -1,13 +1,18 @@ package com.njcn.rdms.module.project.dal.mysql.project.task; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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.ProjectTaskStatusBoardReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregatePageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregateStatusBoardReqVO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; import com.njcn.rdms.module.project.service.project.permission.VisibilityScope; +import lombok.Data; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -379,4 +384,276 @@ public interface ProjectTaskMapper extends BaseMapperX { .eq(ProjectTaskDO::getExecutionId, executionId))); } + // ======================== 项目级跨执行聚合查询 ======================== + + /** + * 项目级跨执行任务分页查询。 + * + * 语义: + * - scope.seesAll() = true → 不附加 taskIds 过滤 + * - scope.seesAll() = false → 附加 t.id IN (scope.taskIds()) 短路过滤 + * - involveUserId 不为 null → 附加 (t.owner_id = ? OR exists active assignee user_id = ?) + * - statusCodes 非空 → t.status_code IN (...) + * - dueRange='overdue' 且 terminalStatusCodes 非空 → 排除终态 + */ + @Select(""" + + """) + IPage selectAggregatePageByProjectId( + @Param("projectId") Long projectId, + @Param("seesAll") boolean seesAll, + @Param("visibleTaskIds") java.util.Collection visibleTaskIds, + @Param("reqVO") ProjectTaskAggregatePageReqVO reqVO, + @Param("terminalStatusCodes") Collection terminalStatusCodes, + @Param("weekStart") LocalDate weekStart, + @Param("weekEnd") LocalDate weekEnd, + @Param("today") LocalDate today, + Page page); + + /** + * 项目级跨执行任务按状态分组计数(status-board)。 + * 入参同 page 但去除分页 / sort / statusCodes 筛选。 + */ + @Select(""" + + """) + List selectAggregateStatusCount( + @Param("projectId") Long projectId, + @Param("seesAll") boolean seesAll, + @Param("visibleTaskIds") java.util.Collection visibleTaskIds, + @Param("reqVO") ProjectTaskAggregateStatusBoardReqVO reqVO, + @Param("terminalStatusCodes") Collection terminalStatusCodes, + @Param("weekStart") LocalDate weekStart, + @Param("weekEnd") LocalDate weekEnd, + @Param("today") LocalDate today); + + /** + * summary 的 4 个数字一次查出来,避免 4 次扫表。 + * + * 返回 Map 结构: + * overdue → Long + * dueToday → Long + * dueThisWeek → Long + * doneThisWeek → Long + */ + @Select(""" + + """) + Map selectAggregateSummaryCounts( + @Param("projectId") Long projectId, + @Param("seesAll") boolean seesAll, + @Param("visibleTaskIds") java.util.Collection visibleTaskIds, + @Param("involveUserId") Long involveUserId, + @Param("terminalStatusCodes") Collection terminalStatusCodes, + @Param("completedStatusCode") String completedStatusCode, + @Param("today") LocalDate today, + @Param("weekStart") LocalDate weekStart, + @Param("weekEnd") LocalDate weekEnd); + + /** + * status-board 计数行 — 内嵌静态类,与 mapper 共享生命周期。 + */ + @Data + class StatusCountRow { + private String statusCode; + private Long count; + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ObjectPermissionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ObjectPermissionService.java index 67a09ed..c63afad 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ObjectPermissionService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ObjectPermissionService.java @@ -19,4 +19,16 @@ public interface ObjectPermissionService { */ void checkPermission(Long objectId, String permission, boolean memberOnly); + /** + * 判断当前登录用户是否具备指定对象上的指定权限码(非抛模式)。 + * + * 与 {@link #checkPermission(Long, String, boolean)} 区别: + * 本方法不抛异常,纯返回 boolean,用于"无权限就走降级路径"而非"无权限就 403"的场景。 + * + * @param objectId 对象 ID(如 projectId) + * @param permission 权限码,如 {@code project:task:list-all} + * @return true=具备,false=不具备 + */ + boolean hasPermission(Long objectId, String permission); + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java index 9941621..074fef1 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java @@ -8,6 +8,7 @@ import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -23,6 +24,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil /** * 产品对象权限服务。 */ +@Slf4j @Service public class ProductObjectPermissionService implements ObjectPermissionService { @@ -36,6 +38,15 @@ public class ProductObjectPermissionService implements ObjectPermissionService { return ProductObjectConstants.OBJECT_TYPE; } + @Override + public boolean hasPermission(Long objectId, String permission) { + // 当前产品域无 hasPermission 非抛模式调用场景,预留空实现。 + // 启用时参考 ProjectObjectPermissionService.hasPermission 同款实现。 + log.warn("[ProductObjectPermissionService.hasPermission] 未实现,默认返回 false;objectId={}, permission={}", + objectId, permission); + return false; + } + @Override public void checkPermission(Long objectId, String permission, boolean memberOnly) { if (objectId == null) { diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java index 2d9ee43..56f9845 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java @@ -1,5 +1,6 @@ package com.njcn.rdms.module.project.framework.security.service; +import cn.hutool.core.util.StrUtil; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.module.project.constant.ObjectRoleConstants; import com.njcn.rdms.module.project.constant.ProjectObjectConstants; @@ -8,6 +9,7 @@ import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -23,6 +25,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil /** * 项目对象权限服务。 */ +@Slf4j @Service public class ProjectObjectPermissionService implements ObjectPermissionService { @@ -53,7 +56,7 @@ public class ProjectObjectPermissionService implements ObjectPermissionService { return; } String normalizedPermission = normalizePermission(permission); - // 任一角色含该权限码即放行(等价于多角色 union;短路求值,权限码命中早 return) + // 任一角色含该权限码即放行(等价于多角色 union;短路求值) boolean allowed = userRoles.stream() .map(UserObjectRoleDO::getRoleId) .distinct() @@ -63,6 +66,33 @@ public class ProjectObjectPermissionService implements ObjectPermissionService { } } + @Override + public boolean hasPermission(Long objectId, String permission) { + if (objectId == null || StrUtil.isBlank(permission)) { + return false; + } + Long userId = SecurityFrameworkUtils.getLoginUserId(); + if (userId == null) { + return false; + } + try { + // 先确认是对象成员,非成员直接返回 false + List userRoles = userObjectRoleMapper + .selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, userId); + if (userRoles.isEmpty()) { + return false; + } + String normalizedPermission = permission.trim(); + return userRoles.stream() + .map(UserObjectRoleDO::getRoleId) + .distinct() + .anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission)); + } catch (Exception e) { + log.warn("[hasPermission] objectId={}, permission={} 查询权限失败", objectId, permission, e); + return false; + } + } + private Set getRolePermissions(Long roleId) { Set permissions = objectPermissionApi .getObjectRolePermissions(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE) diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java index 6acd474..9d2a368 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java @@ -40,6 +40,18 @@ public interface ProjectTaskService { PageResult assembleTaskRespVOPage(Long projectId, Long executionId, PageResult doPage); + /** + * 跨执行装配 ProjectTaskRespVO 分页结果。 + * + * 与 {@link #assembleTaskRespVOPage(Long, Long, PageResult)} 区别: + * 本方法不绑单个 executionId,允许 page 内任务来自项目下任意多个执行; + * 装配时按 task.executionId 分组批量回填 executionName / executionStatusCode + * (走 enrichExecutionInfo helper)。 + *

不填充字段:executionOwnerId / projectRequirementName / projectRequirementStatusCode + * (跨多 execution 场景无法共享单 execution 上下文)。前端跨执行视图按需处理。

+ */ + PageResult assembleTaskRespVOPageCrossExecution(Long projectId, PageResult doPage); + void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO); /** diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java index 13696f7..eb7ee05 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java @@ -1,5 +1,6 @@ package com.njcn.rdms.module.project.service.project.task; +import cn.hutool.core.collection.CollUtil; import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.util.json.JsonUtils; @@ -62,6 +63,7 @@ import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; +import java.time.ZoneId; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -85,6 +87,8 @@ 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 @@ -462,6 +466,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { .loadActiveAssigneesGroupedByTaskId(List.of(task.getId())).getOrDefault(task.getId(), List.of()))); respVO.setTotalSpentHours(taskWorklogService.sumDurationByTaskId(task.getId())); respVO.setExecutionOwnerId(execution.getOwnerId()); + respVO.setExecutionName(execution.getExecutionName()); + respVO.setExecutionStatusCode(execution.getStatusCode()); if (task.getParentTaskId() != null) { ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId()); respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId()); @@ -489,40 +495,15 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { if (list == null || list.isEmpty()) { return voPageResult; } - // 批量装配 ownerNickname + assignees,统一收集所有需要的 userId 一次性查 nickname,避免 N+1 - Set taskIds = list.stream().map(ProjectTaskRespVO::getId) - .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); - Map> assigneeMap = taskAssigneeService - .loadActiveAssigneesGroupedByTaskId(taskIds); - Map spentHoursMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds); - Set userIdsToResolve = list.stream() - .map(ProjectTaskRespVO::getOwnerId) - .filter(Objects::nonNull) - .collect(Collectors.toCollection(LinkedHashSet::new)); - assigneeMap.values().forEach(items -> items.forEach(a -> userIdsToResolve.add(a.getUserId()))); - Map nicknameMap = loadOwnerNicknameMap(userIdsToResolve); - // 批量查父任务 owner,避免按 list 循环 N+1 - Set parentTaskIds = list.stream().map(ProjectTaskRespVO::getParentTaskId) - .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); - Map parentTaskOwnerMap = parentTaskIds.isEmpty() - ? Map.of() - : projectTaskMapper.selectBatchIds(parentTaskIds).stream() - .collect(Collectors.toMap(ProjectTaskDO::getId, ProjectTaskDO::getOwnerId)); + List taskList = pageResult.getList(); // 执行 owner 单条查询,整页共享(URL 路径定 executionId) ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId); Long executionOwnerId = execution == null ? null : execution.getOwnerId(); fillProjectRequirementInfo(list, execution); + // 批量回填 ownerNickname / assignees / totalSpentHours / parentTaskOwnerId(与 executionId 无关) + enrichTaskRelations(taskList, list); list.forEach(vo -> { - vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId())); - List activeList = assigneeMap.getOrDefault(vo.getId(), List.of()); - vo.setAssignees(activeList.stream() - .map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId()))) - .collect(Collectors.toList())); - vo.setTotalSpentHours(spentHoursMap.getOrDefault(vo.getId(), BigDecimal.ZERO)); vo.setExecutionOwnerId(executionOwnerId); - if (vo.getParentTaskId() != null) { - vo.setParentTaskOwnerId(parentTaskOwnerMap.get(vo.getParentTaskId())); - } // 列表行 cancel/pause/resume/complete 按钮依赖 availableActions,与详情同款装配 lifecycle。 // 单行装配失败做兜底降级(status_model 缺失等脏数据),避免影响整页返回。 try { @@ -532,9 +513,112 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { vo.getId(), vo.getStatusCode(), e.getMessage()); } }); + enrichExecutionInfo(voPageResult.getList()); return voPageResult; } + /** + * 批量回填与 executionId 无关的任务关联字段:ownerNickname / assignees / totalSpentHours / parentTaskOwnerId。 + *

由 {@link #assembleTaskRespVOPage} 与 {@link #assembleTaskRespVOPageCrossExecution} 共享, + * 抽取后两条装配路径保证字段口径一致,避免演进漂移。 + * + * @param taskList 原始 DO 列表(用于 enrichTaskRelations 内部如有需要直接访问 DO 字段) + * @param voList 已 BeanUtils.toBean 转换好的 VO 列表,本方法在其上 set 字段 + */ + private void enrichTaskRelations(List taskList, List voList) { + if (CollUtil.isEmpty(voList)) { + return; + } + // 批量装配 ownerNickname + assignees,统一收集所有需要的 userId 一次性查 nickname,避免 N+1 + Set taskIds = voList.stream().map(ProjectTaskRespVO::getId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + Map> assigneeMap = taskAssigneeService + .loadActiveAssigneesGroupedByTaskId(taskIds); + Map spentHoursMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds); + Set userIdsToResolve = voList.stream() + .map(ProjectTaskRespVO::getOwnerId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + assigneeMap.values().forEach(items -> items.forEach(a -> userIdsToResolve.add(a.getUserId()))); + Map nicknameMap = loadOwnerNicknameMap(userIdsToResolve); + // 批量查父任务 owner,避免按 list 循环 N+1 + Set parentTaskIds = voList.stream().map(ProjectTaskRespVO::getParentTaskId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + Map parentTaskOwnerMap; + if (parentTaskIds.isEmpty()) { + parentTaskOwnerMap = Map.of(); + } else { + parentTaskOwnerMap = new HashMap<>(); + for (ProjectTaskDO p : projectTaskMapper.selectBatchIds(parentTaskIds)) { + parentTaskOwnerMap.put(p.getId(), p.getOwnerId()); + } + } + voList.forEach(vo -> { + vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId())); + List activeList = assigneeMap.getOrDefault(vo.getId(), List.of()); + vo.setAssignees(activeList.stream() + .map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId()))) + .collect(Collectors.toList())); + vo.setTotalSpentHours(spentHoursMap.getOrDefault(vo.getId(), BigDecimal.ZERO)); + if (vo.getParentTaskId() != null) { + vo.setParentTaskOwnerId(parentTaskOwnerMap.get(vo.getParentTaskId())); + } + }); + } + + @Override + public PageResult assembleTaskRespVOPageCrossExecution(Long projectId, PageResult doPage) { + if (doPage == null || CollUtil.isEmpty(doPage.getList())) { + return new PageResult<>(Collections.emptyList(), doPage == null ? 0L : doPage.getTotal()); + } + List taskList = doPage.getList(); + List voList = BeanUtils.toBean(taskList, ProjectTaskRespVO.class); + + // 批量回填 ownerNickname / assignees / totalSpentHours / parentTaskOwnerId(与 executionId 无关) + enrichTaskRelations(taskList, voList); + + voList.forEach(vo -> { + // executionOwnerId 在跨执行场景不设置(涉及多个 execution,无"该执行的 ownerId"概念) + try { + applyLifecycle(vo); + } catch (Exception e) { + log.warn("[assembleTaskRespVOPageCrossExecution] applyLifecycle 装配失败 taskId={}", vo.getId(), e); + } + }); + + // 按 task.executionId 分组批量回填 executionName / executionStatusCode + enrichExecutionInfo(voList); + + return new PageResult<>(voList, doPage.getTotal()); + } + + /** + * 给一批 ProjectTaskRespVO 批量回填 executionName / executionStatusCode。 + * 一次查询所有涉及的 executionId,避免 N+1。 + */ + private void enrichExecutionInfo(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + Set executionIds = list.stream() + .map(ProjectTaskRespVO::getExecutionId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (executionIds.isEmpty()) { + return; + } + List executions = projectExecutionMapper.selectBatchIds(executionIds); + Map executionMap = executions.stream() + .collect(Collectors.toMap(ProjectExecutionDO::getId, e -> e, (a, b) -> a)); + list.forEach(vo -> { + ProjectExecutionDO exec = executionMap.get(vo.getExecutionId()); + if (exec != null) { + vo.setExecutionName(exec.getExecutionName()); + vo.setExecutionStatusCode(exec.getStatusCode()); + } + }); + } + /** * 把任务 RespVO 上的项目需求信息(TD-013)回填。 *

本服务的 task 查询入口均以 executionId 为收口(详情 / page / board-page 共用 assembleTaskRespVOPage), @@ -628,7 +712,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { * - 进入终态(toStatus.terminalFlag=true)且未填写时,写入 actualEndDate */ private void maybeFillActualDates(ProjectTaskDO task, String fromStatus, String toStatus) { - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.now(SERVER_ZONE); LocalDate newActualStart = null; LocalDate newActualEnd = null; if (task.getActualStartDate() == null) {