feat(project): 添加项目任务跨执行聚合查询功能

- 新增 ObjectPermissionService.hasPermission 非抛模式权限检查方法
- 实现 ProjectObjectPermissionService.hasPermission 权限验证逻辑
- 为 ProductObjectPermissionService 预留空实现并添加日志警告
- 在 ProjectExecutionController 中支持负数 pageSize 查询全部功能
- 添加 ProjectTaskConstants.PERMISSION_LIST_ALL 权限码常量定义
- 扩展 ProjectTaskMapper 支持跨执行聚合分页、状态统计和摘要查询
- 更新 ProjectTaskRespVO 包含执行名称和状态码字段
- 实现 ProjectTaskService.assembleTaskRespVOPageCrossExecution 跨执行装配方法
- 优化任务服务中的执行信息批量回填和生命周期应用逻辑
- 统一使用服务器时区 Asia/Shanghai 处理日期时间操作
- 为 .claude 设置添加新的代码搜索和分析命令
This commit is contained in:
2026-05-23 14:18:04 +08:00
parent c9549bed46
commit 8a36b49128
10 changed files with 482 additions and 31 deletions

View File

@@ -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\")", "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(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 -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 *)"
] ]
} }
} }

View File

@@ -59,6 +59,18 @@ public final class ProjectTaskConstants {
*/ */
public static final String PERMISSION_DELETE = "project:task:delete"; 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" 与中文 "删除",前端可纯中文文案。 * 删除确认口令合法值集合;兼容大写英文 "DELETE" 与中文 "删除",前端可纯中文文案。
* 校验时精确匹配trim 后比对)。 * 校验时精确匹配trim 后比对)。

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.controller.admin.project.execution; 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.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageParam;
import com.njcn.rdms.framework.common.pojo.PageResult; 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.ProjectExecutionOwnerChangeReqVO;
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.ProjectExecutionPageReqVO;
@@ -65,6 +66,11 @@ public class ProjectExecutionController {
@Operation(summary = "获取执行分页") @Operation(summary = "获取执行分页")
public CommonResult<PageResult<ProjectExecutionRespVO>> getExecutionPage(@PathVariable("projectId") Long projectId, public CommonResult<PageResult<ProjectExecutionRespVO>> getExecutionPage(@PathVariable("projectId") Long projectId,
@Valid ProjectExecutionPageReqVO reqVO) { @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)); return success(projectExecutionService.getExecutionRespVOPage(projectId, reqVO));
} }

View File

@@ -21,6 +21,10 @@ public class ProjectTaskRespVO {
private Long projectId; private Long projectId;
@Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001") @Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
private Long executionId; private Long executionId;
@Schema(description = "所属执行名称", example = "迭代 V1.0")
private String executionName;
@Schema(description = "所属执行状态码")
private String executionStatusCode;
@Schema(description = "所属执行关联的项目需求编号service 层批量回填)") @Schema(description = "所属执行关联的项目需求编号service 层批量回填)")
private Long projectRequirementId; private Long projectRequirementId;
@Schema(description = "项目需求名称service 层批量回填,避免 N+1", example = "前端联调-需求A") @Schema(description = "项目需求名称service 层批量回填,避免 N+1", example = "前端联调-需求A")

View File

@@ -1,13 +1,18 @@
package com.njcn.rdms.module.project.dal.mysql.project.task; 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.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; 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.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; 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.ProjectTaskStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO; 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.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope; import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import lombok.Data;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
@@ -379,4 +384,276 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
.eq(ProjectTaskDO::getExecutionId, executionId))); .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("""
<script>
SELECT t.*
FROM rdms_task t
<where>
t.project_id = #{projectId}
AND t.deleted = b'0'
<if test="!seesAll and visibleTaskIds != null and !visibleTaskIds.isEmpty()">
AND t.id IN
<foreach collection="visibleTaskIds" item="tid" open="(" separator="," close=")">#{tid}</foreach>
</if>
<if test="!seesAll and (visibleTaskIds == null or visibleTaskIds.isEmpty())">
AND 1=0
</if>
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.task_title LIKE CONCAT('%', #{reqVO.keyword}, '%')
</if>
<if test="reqVO.executionIds != null and !reqVO.executionIds.isEmpty()">
AND t.execution_id IN
<foreach collection="reqVO.executionIds" item="eid" open="(" separator="," close=")">#{eid}</foreach>
</if>
<if test="reqVO.executionStatusCodes != null and !reqVO.executionStatusCodes.isEmpty()">
AND EXISTS (
SELECT 1 FROM rdms_project_execution e
WHERE e.id = t.execution_id
AND e.deleted = b'0'
AND e.status_code IN
<foreach collection="reqVO.executionStatusCodes" item="esc" open="(" separator="," close=")">#{esc}</foreach>
)
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_task_assignee a
WHERE a.task_id = t.id
AND a.user_id = #{reqVO.involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.statusCodes != null and !reqVO.statusCodes.isEmpty()">
AND t.status_code IN
<foreach collection="reqVO.statusCodes" item="sc" open="(" separator="," close=")">#{sc}</foreach>
</if>
<if test="reqVO.priority != null and reqVO.priority != ''">
AND t.priority = #{reqVO.priority}
</if>
<if test="reqVO.parentTaskId != null">
AND t.parent_task_id = #{reqVO.parentTaskId}
</if>
<if test="reqVO.dueRange == 'overdue'">
AND t.planned_end_date &lt; #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</if>
<if test="reqVO.dueRange == 'today'">
AND t.planned_end_date = #{today}
</if>
<if test="reqVO.dueRange == 'thisWeek'">
AND t.planned_end_date BETWEEN #{weekStart} AND #{weekEnd}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
</where>
<choose>
<when test="reqVO.sortBy == 'priority'">ORDER BY t.priority</when>
<when test="reqVO.sortBy == 'updateTime'">ORDER BY t.update_time</when>
<when test="reqVO.sortBy == 'createTime'">ORDER BY t.create_time</when>
<otherwise>ORDER BY t.planned_end_date</otherwise>
</choose>
<choose>
<when test="reqVO.sortOrder == 'desc'">DESC</when>
<otherwise>ASC</otherwise>
</choose>
, t.id DESC
</script>
""")
IPage<ProjectTaskDO> selectAggregatePageByProjectId(
@Param("projectId") Long projectId,
@Param("seesAll") boolean seesAll,
@Param("visibleTaskIds") java.util.Collection<Long> visibleTaskIds,
@Param("reqVO") ProjectTaskAggregatePageReqVO reqVO,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
@Param("weekStart") LocalDate weekStart,
@Param("weekEnd") LocalDate weekEnd,
@Param("today") LocalDate today,
Page<ProjectTaskDO> page);
/**
* 项目级跨执行任务按状态分组计数(status-board)。
* 入参同 page 但去除分页 / sort / statusCodes 筛选。
*/
@Select("""
<script>
SELECT t.status_code AS statusCode, COUNT(*) AS count
FROM rdms_task t
<where>
t.project_id = #{projectId}
AND t.deleted = b'0'
<if test="!seesAll and visibleTaskIds != null and !visibleTaskIds.isEmpty()">
AND t.id IN
<foreach collection="visibleTaskIds" item="tid" open="(" separator="," close=")">#{tid}</foreach>
</if>
<if test="!seesAll and (visibleTaskIds == null or visibleTaskIds.isEmpty())">
AND 1=0
</if>
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.task_title LIKE CONCAT('%', #{reqVO.keyword}, '%')
</if>
<if test="reqVO.executionIds != null and !reqVO.executionIds.isEmpty()">
AND t.execution_id IN
<foreach collection="reqVO.executionIds" item="eid" open="(" separator="," close=")">#{eid}</foreach>
</if>
<if test="reqVO.executionStatusCodes != null and !reqVO.executionStatusCodes.isEmpty()">
AND EXISTS (
SELECT 1 FROM rdms_project_execution e
WHERE e.id = t.execution_id
AND e.deleted = b'0'
AND e.status_code IN
<foreach collection="reqVO.executionStatusCodes" item="esc" open="(" separator="," close=")">#{esc}</foreach>
)
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_task_assignee a
WHERE a.task_id = t.id
AND a.user_id = #{reqVO.involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.priority != null and reqVO.priority != ''">
AND t.priority = #{reqVO.priority}
</if>
<if test="reqVO.parentTaskId != null">
AND t.parent_task_id = #{reqVO.parentTaskId}
</if>
<if test="reqVO.dueRange == 'overdue'">
AND t.planned_end_date &lt; #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</if>
<if test="reqVO.dueRange == 'today'">
AND t.planned_end_date = #{today}
</if>
<if test="reqVO.dueRange == 'thisWeek'">
AND t.planned_end_date BETWEEN #{weekStart} AND #{weekEnd}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
</where>
GROUP BY t.status_code
</script>
""")
List<StatusCountRow> selectAggregateStatusCount(
@Param("projectId") Long projectId,
@Param("seesAll") boolean seesAll,
@Param("visibleTaskIds") java.util.Collection<Long> visibleTaskIds,
@Param("reqVO") ProjectTaskAggregateStatusBoardReqVO reqVO,
@Param("terminalStatusCodes") Collection<String> 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("""
<script>
SELECT
CAST(SUM(CASE WHEN t.planned_end_date &lt; #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS overdue,
CAST(SUM(CASE WHEN t.planned_end_date = #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS dueToday,
CAST(SUM(CASE WHEN t.planned_end_date BETWEEN #{weekStart} AND #{weekEnd}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS dueThisWeek,
CAST(SUM(CASE WHEN t.actual_end_date BETWEEN #{weekStart} AND #{today}
AND t.status_code = #{completedStatusCode}
THEN 1 ELSE 0 END) AS SIGNED) AS doneThisWeek
FROM rdms_task t
<where>
t.project_id = #{projectId}
AND t.deleted = b'0'
<if test="!seesAll and visibleTaskIds != null and !visibleTaskIds.isEmpty()">
AND t.id IN
<foreach collection="visibleTaskIds" item="tid" open="(" separator="," close=")">#{tid}</foreach>
</if>
<if test="!seesAll and (visibleTaskIds == null or visibleTaskIds.isEmpty())">
AND 1=0
</if>
<if test="involveUserId != null">
AND (
t.owner_id = #{involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_task_assignee a
WHERE a.task_id = t.id
AND a.user_id = #{involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
</where>
</script>
""")
Map<String, Long> selectAggregateSummaryCounts(
@Param("projectId") Long projectId,
@Param("seesAll") boolean seesAll,
@Param("visibleTaskIds") java.util.Collection<Long> visibleTaskIds,
@Param("involveUserId") Long involveUserId,
@Param("terminalStatusCodes") Collection<String> 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;
}
} }

View File

@@ -19,4 +19,16 @@ public interface ObjectPermissionService {
*/ */
void checkPermission(Long objectId, String permission, boolean memberOnly); 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);
} }

View File

@@ -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.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -23,6 +24,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
/** /**
* 产品对象权限服务。 * 产品对象权限服务。
*/ */
@Slf4j
@Service @Service
public class ProductObjectPermissionService implements ObjectPermissionService { public class ProductObjectPermissionService implements ObjectPermissionService {
@@ -36,6 +38,15 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
return ProductObjectConstants.OBJECT_TYPE; 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 @Override
public void checkPermission(Long objectId, String permission, boolean memberOnly) { public void checkPermission(Long objectId, String permission, boolean memberOnly) {
if (objectId == null) { if (objectId == null) {

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.framework.security.service; 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.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants; import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants; 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.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -23,6 +25,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
/** /**
* 项目对象权限服务。 * 项目对象权限服务。
*/ */
@Slf4j
@Service @Service
public class ProjectObjectPermissionService implements ObjectPermissionService { public class ProjectObjectPermissionService implements ObjectPermissionService {
@@ -53,7 +56,7 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
return; return;
} }
String normalizedPermission = normalizePermission(permission); String normalizedPermission = normalizePermission(permission);
// 任一角色含该权限码即放行(等价于多角色 union短路求值,权限码命中早 return // 任一角色含该权限码即放行(等价于多角色 union短路求值
boolean allowed = userRoles.stream() boolean allowed = userRoles.stream()
.map(UserObjectRoleDO::getRoleId) .map(UserObjectRoleDO::getRoleId)
.distinct() .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<UserObjectRoleDO> 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<String> getRolePermissions(Long roleId) { private Set<String> getRolePermissions(Long roleId) {
Set<String> permissions = objectPermissionApi Set<String> permissions = objectPermissionApi
.getObjectRolePermissions(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE) .getObjectRolePermissions(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE)

View File

@@ -40,6 +40,18 @@ public interface ProjectTaskService {
PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId, PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId,
PageResult<ProjectTaskDO> doPage); PageResult<ProjectTaskDO> doPage);
/**
* 跨执行装配 ProjectTaskRespVO 分页结果。
*
* 与 {@link #assembleTaskRespVOPage(Long, Long, PageResult)} 区别:
* 本方法不绑单个 executionId,允许 page 内任务来自项目下任意多个执行;
* 装配时按 task.executionId 分组批量回填 executionName / executionStatusCode
* (走 enrichExecutionInfo helper)。
* <p><b>不填充字段</b>:executionOwnerId / projectRequirementName / projectRequirementStatusCode
* (跨多 execution 场景无法共享单 execution 上下文)。前端跨执行视图按需处理。</p>
*/
PageResult<ProjectTaskRespVO> assembleTaskRespVOPageCrossExecution(Long projectId, PageResult<ProjectTaskDO> doPage);
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO); void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
/** /**

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.service.project.task; 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.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils; 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.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@@ -85,6 +87,8 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
@Slf4j @Slf4j
public class ProjectTaskServiceImpl implements ProjectTaskService { public class ProjectTaskServiceImpl implements ProjectTaskService {
private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Shanghai");
@Resource @Resource
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@Resource @Resource
@@ -462,6 +466,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
.loadActiveAssigneesGroupedByTaskId(List.of(task.getId())).getOrDefault(task.getId(), List.of()))); .loadActiveAssigneesGroupedByTaskId(List.of(task.getId())).getOrDefault(task.getId(), List.of())));
respVO.setTotalSpentHours(taskWorklogService.sumDurationByTaskId(task.getId())); respVO.setTotalSpentHours(taskWorklogService.sumDurationByTaskId(task.getId()));
respVO.setExecutionOwnerId(execution.getOwnerId()); respVO.setExecutionOwnerId(execution.getOwnerId());
respVO.setExecutionName(execution.getExecutionName());
respVO.setExecutionStatusCode(execution.getStatusCode());
if (task.getParentTaskId() != null) { if (task.getParentTaskId() != null) {
ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId()); ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId());
respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId()); respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId());
@@ -489,40 +495,15 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
if (list == null || list.isEmpty()) { if (list == null || list.isEmpty()) {
return voPageResult; return voPageResult;
} }
// 批量装配 ownerNickname + assignees统一收集所有需要的 userId 一次性查 nickname避免 N+1 List<ProjectTaskDO> taskList = pageResult.getList();
Set<Long> taskIds = list.stream().map(ProjectTaskRespVO::getId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, List<TaskAssigneeDO>> assigneeMap = taskAssigneeService
.loadActiveAssigneesGroupedByTaskId(taskIds);
Map<Long, BigDecimal> spentHoursMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds);
Set<Long> 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<Long, String> nicknameMap = loadOwnerNicknameMap(userIdsToResolve);
// 批量查父任务 owner避免按 list 循环 N+1
Set<Long> parentTaskIds = list.stream().map(ProjectTaskRespVO::getParentTaskId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, Long> parentTaskOwnerMap = parentTaskIds.isEmpty()
? Map.of()
: projectTaskMapper.selectBatchIds(parentTaskIds).stream()
.collect(Collectors.toMap(ProjectTaskDO::getId, ProjectTaskDO::getOwnerId));
// 执行 owner 单条查询整页共享URL 路径定 executionId // 执行 owner 单条查询整页共享URL 路径定 executionId
ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId); ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
Long executionOwnerId = execution == null ? null : execution.getOwnerId(); Long executionOwnerId = execution == null ? null : execution.getOwnerId();
fillProjectRequirementInfo(list, execution); fillProjectRequirementInfo(list, execution);
// 批量回填 ownerNickname / assignees / totalSpentHours / parentTaskOwnerId与 executionId 无关)
enrichTaskRelations(taskList, list);
list.forEach(vo -> { list.forEach(vo -> {
vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
List<TaskAssigneeDO> 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); vo.setExecutionOwnerId(executionOwnerId);
if (vo.getParentTaskId() != null) {
vo.setParentTaskOwnerId(parentTaskOwnerMap.get(vo.getParentTaskId()));
}
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions与详情同款装配 lifecycle。 // 列表行 cancel/pause/resume/complete 按钮依赖 availableActions与详情同款装配 lifecycle。
// 单行装配失败做兜底降级status_model 缺失等脏数据),避免影响整页返回。 // 单行装配失败做兜底降级status_model 缺失等脏数据),避免影响整页返回。
try { try {
@@ -532,9 +513,112 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
vo.getId(), vo.getStatusCode(), e.getMessage()); vo.getId(), vo.getStatusCode(), e.getMessage());
} }
}); });
enrichExecutionInfo(voPageResult.getList());
return voPageResult; return voPageResult;
} }
/**
* 批量回填与 executionId 无关的任务关联字段:ownerNickname / assignees / totalSpentHours / parentTaskOwnerId。
* <p>由 {@link #assembleTaskRespVOPage} 与 {@link #assembleTaskRespVOPageCrossExecution} 共享,
* 抽取后两条装配路径保证字段口径一致,避免演进漂移。
*
* @param taskList 原始 DO 列表(用于 enrichTaskRelations 内部如有需要直接访问 DO 字段)
* @param voList 已 BeanUtils.toBean 转换好的 VO 列表,本方法在其上 set 字段
*/
private void enrichTaskRelations(List<ProjectTaskDO> taskList, List<ProjectTaskRespVO> voList) {
if (CollUtil.isEmpty(voList)) {
return;
}
// 批量装配 ownerNickname + assignees统一收集所有需要的 userId 一次性查 nickname避免 N+1
Set<Long> taskIds = voList.stream().map(ProjectTaskRespVO::getId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, List<TaskAssigneeDO>> assigneeMap = taskAssigneeService
.loadActiveAssigneesGroupedByTaskId(taskIds);
Map<Long, BigDecimal> spentHoursMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds);
Set<Long> 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<Long, String> nicknameMap = loadOwnerNicknameMap(userIdsToResolve);
// 批量查父任务 owner避免按 list 循环 N+1
Set<Long> parentTaskIds = voList.stream().map(ProjectTaskRespVO::getParentTaskId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, Long> 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<TaskAssigneeDO> 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<ProjectTaskRespVO> assembleTaskRespVOPageCrossExecution(Long projectId, PageResult<ProjectTaskDO> doPage) {
if (doPage == null || CollUtil.isEmpty(doPage.getList())) {
return new PageResult<>(Collections.emptyList(), doPage == null ? 0L : doPage.getTotal());
}
List<ProjectTaskDO> taskList = doPage.getList();
List<ProjectTaskRespVO> 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<ProjectTaskRespVO> list) {
if (CollUtil.isEmpty(list)) {
return;
}
Set<Long> executionIds = list.stream()
.map(ProjectTaskRespVO::getExecutionId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (executionIds.isEmpty()) {
return;
}
List<ProjectExecutionDO> executions = projectExecutionMapper.selectBatchIds(executionIds);
Map<Long, ProjectExecutionDO> 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回填。 * 把任务 RespVO 上的项目需求信息TD-013回填。
* <p>本服务的 task 查询入口均以 executionId 为收口(详情 / page / board-page 共用 assembleTaskRespVOPage * <p>本服务的 task 查询入口均以 executionId 为收口(详情 / page / board-page 共用 assembleTaskRespVOPage
@@ -628,7 +712,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
* - 进入终态toStatus.terminalFlag=true且未填写时写入 actualEndDate * - 进入终态toStatus.terminalFlag=true且未填写时写入 actualEndDate
*/ */
private void maybeFillActualDates(ProjectTaskDO task, String fromStatus, String toStatus) { private void maybeFillActualDates(ProjectTaskDO task, String fromStatus, String toStatus) {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now(SERVER_ZONE);
LocalDate newActualStart = null; LocalDate newActualStart = null;
LocalDate newActualEnd = null; LocalDate newActualEnd = null;
if (task.getActualStartDate() == null) { if (task.getActualStartDate() == null) {