docs(guide): 更新对象域权限文档与数据库连接配置

- 修改开发环境和本地环境的数据库连接从 rdms_v3 切换到 rdms_view
- 更新 CLAUDE.md 中的对象域权限校验说明,统一在 Service 层使用 @CheckObjectPermission
- 移除 ExecutionAssigneeMapper 中的废弃查询方法和相关注解
- 优化 ObjectPermissionService 中的权限码描述信息
- 新增执行查询权限常量 PERMISSION_QUERY
- 重构 ProjectExecutionMapper 分页查询逻辑,使用 @Select 注解替代 LambdaQueryWrapper
- 添加执行状态面板和截止时间范围过滤功能
- 在 ProjectExecutionServiceImpl 中集成对象域权限检查
- 更新状态面板服务中的权限校验注解配置
This commit is contained in:
2026-05-29 16:24:09 +08:00
parent 9f03dc27cc
commit 5c7dbf7286
34 changed files with 524 additions and 719 deletions

View File

@@ -80,7 +80,7 @@
- **对象内接口绝不能挂 `@PreAuthorize("@ss.hasPermission(...)")`**。该注解走的链路在 `PermissionServiceImpl` 里强制按 GLOBAL 取角色line 343-347+ 强制按 GLOBAL 查菜单line 92-94对象域角色与对象域菜单都进不来即使授权配置完全正确也必然 403。
- **对象域权限校验必须落在 Service 层 `@CheckObjectPermission`**,原因:路径里 `objectId` 通常以 `#projectId`/`#productId` 等 SpEL 解析Controller 的参数校验前置阶段不便复用;与同模块(`ProjectMemberServiceImpl` / `ProjectExecutionServiceImpl` / `ProjectExecutionAssigneeServiceImpl` / `ProjectTaskServiceImpl`)保持一致。
- **同一接口不要两条通道叠加**。要么全域,要么对象域;叠加只会让对象域用户被全域那条卡死。
- 列表/详情这类对象内**读路径**目前未`@CheckObjectPermission`(属已识别负债,台账 TD-001新增读接口暂沿用现状即可不要顺手改造等独立立项
- 对象内**读路径**(列表 / 详情 / 状态看板 / 聚合)已统一在 **Service 层**挂 `@CheckObjectPermission(objectType=PROJECT, permission=...PERMISSION_QUERY)`——查询同样要扫库耗资源,必须按对象域鉴权(原台账 TD-001 所述"读路径未挂"已不成立)。**Controller 方法层一律不挂权限注解**,对象域鉴权全部落 Service新增读接口照此在 Service 层挂对象域权限,不要只在 Controller 留空、更不要误判"Controller 没注解 = 无鉴权"
判定口诀:**URL 里有 `{projectId}` / `{productId}` 等对象 ID → 对象域;没有 → 全域**。

View File

@@ -20,6 +20,13 @@ public final class ProjectExecutionConstants {
*/
public static final String BIZ_TYPE = "project_execution";
/**
* 执行读路径查询权限码对象域object_type='project')。
* 覆盖执行对象所有读路径page / status-board / detail。
* "我参与 / 所有"视角由前端发不发 involveUserId 决定;进得来 = 看项目下全部,无此权限码直接 403。
*/
public static final String PERMISSION_QUERY = "project:execution:query";
/**
* 创建执行权限码。
*/

View File

@@ -26,6 +26,13 @@ public final class ProjectTaskConstants {
*/
public static final String BIZ_TYPE = "project_task";
/**
* 任务读路径查询权限码对象域object_type='project')。
* 覆盖任务对象所有读路径page / status-board / board-page / detail / summary含跨执行 aggregate
* "我参与 / 所有"视角由前端发不发 involveUserId 决定;进得来 = 看项目下全部,无此权限码直接 403。
*/
public static final String PERMISSION_QUERY = "project:task:query";
/**
* 创建任务权限码。
*/
@@ -59,18 +66,6 @@ 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 后比对)。

View File

@@ -23,7 +23,10 @@ public class ProjectExecutionPageReqVO extends PageParam {
@Size(max = 32, message = "执行类型长度不能超过32个字符")
private String executionType;
@Schema(description = "执行负责人用户编号", example = "3001")
@Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一;不传 = 项目内全部执行")
private Long involveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一", example = "3001")
private Long ownerId;
@Schema(description = "执行状态编码", example = "pending")
@@ -38,4 +41,8 @@ public class ProjectExecutionPageReqVO extends PageParam {
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime;
@Schema(description = "截止时间范围 chipoverdue逾期/ today今天到期/ thisWeek本周到期" +
"基于 plannedEndDate 且排除终态执行;不传 = 不按截止时间过滤", example = "overdue")
private String dueRange;
}

View File

@@ -22,11 +22,18 @@ public class ProjectExecutionStatusBoardReqVO {
@Size(max = 32, message = "执行类型长度不能超过32个字符")
private String executionType;
@Schema(description = "执行负责人用户编号", example = "3001")
@Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一;不传 = 项目内全部执行")
private Long involveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一", example = "3001")
private Long ownerId;
@Schema(description = "更新时间", example = "[2026-05-01 00:00:00, 2026-05-31 23:59:59]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime;
@Schema(description = "截止时间范围 chipoverdue逾期/ today今天到期/ thisWeek本周到期" +
"基于 plannedEndDate 且排除终态执行;不传 = 不按截止时间过滤", example = "overdue")
private String dueRange;
}

View File

@@ -57,7 +57,7 @@ public class ProjectTaskAggregateController {
}
@GetMapping("/summary")
@Operation(summary = "获取项目任务今日小条(支持 scope=mine|all)")
@Operation(summary = "获取项目任务今日小条involveUserId 控制是否限定 owner / 活跃协办)")
public CommonResult<ProjectTaskSummaryRespVO> getTaskSummary(
@PathVariable("projectId") Long projectId,
@Valid ProjectTaskSummaryReqVO reqVO) {

View File

@@ -36,7 +36,10 @@ public class ProjectTaskBoardPageReqVO extends PageParam {
@Schema(description = "父任务编号", example = "9001")
private Long parentTaskId;
@Schema(description = "任务负责人用户编号", example = "3002")
@Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一;不传 = 执行下全部任务")
private Long involveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一", example = "3002")
private Long ownerId;
@Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")

View File

@@ -22,7 +22,10 @@ public class ProjectTaskPageReqVO extends PageParam {
@Schema(description = "父任务编号")
private Long parentTaskId;
@Schema(description = "任务负责人用户编号", example = "3002")
@Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一;不传 = 执行下全部任务")
private Long involveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一", example = "3002")
private Long ownerId;
@Schema(description = "任务状态编码", example = "pending")

View File

@@ -21,7 +21,10 @@ public class ProjectTaskStatusBoardReqVO {
@Schema(description = "父任务编号", example = "9001")
private Long parentTaskId;
@Schema(description = "任务负责人用户编号", example = "3002")
@Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一;不传 = 执行下全部任务")
private Long involveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一", example = "3002")
private Long ownerId;
@Schema(description = "更新时间", example = "[2026-05-01 00:00:00, 2026-05-31 23:59:59]")

View File

@@ -20,7 +20,7 @@ public class ProjectTaskAggregateBoardPageReqVO extends PageParam {
@Schema(description = "任务名称模糊匹配关键字")
private String keyword;
@Schema(description = "限定执行 id 列表;不传 = 项目内全部执行")
@Schema(description = "限定执行 id 列表;空数组 = 明确返空;不传 = 项目内全部执行")
private List<Long> executionIds;
@Schema(description = "限定任务所属执行的状态码多选;空数组 = 明确返空;不传 = 不按执行状态过滤")
@@ -29,6 +29,9 @@ public class ProjectTaskAggregateBoardPageReqVO extends PageParam {
@Schema(description = "我参与语义;与 ownerId 二选一")
private Long involveUserId;
@Schema(description = "执行成员语义:该 userId 是执行 owner 或活跃执行协办;过滤其参与的执行下的任务。与 involveUserId(任务成员)正交,可同传;用户未参与任何执行时返空")
private Long executionInvolveUserId;
@Schema(description = "仅作为 owner 匹配;与 involveUserId 二选一")
private Long ownerId;

View File

@@ -20,7 +20,7 @@ public class ProjectTaskAggregatePageReqVO extends PageParam {
@Schema(description = "任务名称模糊匹配关键字")
private String keyword;
@Schema(description = "限定执行 id 列表;不传 = 项目内全部执行")
@Schema(description = "限定执行 id 列表;空数组 = 明确返空;不传 = 项目内全部执行")
private List<Long> executionIds;
@Schema(description = "限定任务所属执行的状态码多选;空数组 = 明确返空;不传 = 不按执行状态过滤")
@@ -29,6 +29,9 @@ public class ProjectTaskAggregatePageReqVO extends PageParam {
@Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一")
private Long involveUserId;
@Schema(description = "执行成员语义:该 userId 是执行 owner 或活跃执行协办;过滤其参与的执行下的任务。与 involveUserId(任务成员)正交,可同传;用户未参与任何执行时返空")
private Long executionInvolveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一")
private Long ownerId;

View File

@@ -17,7 +17,7 @@ public class ProjectTaskAggregateStatusBoardReqVO {
@Schema(description = "任务名称模糊匹配关键字")
private String keyword;
@Schema(description = "限定执行 id 列表;不传 = 项目内全部执行")
@Schema(description = "限定执行 id 列表;空数组 = 明确返空;不传 = 项目内全部执行")
private List<Long> executionIds;
@Schema(description = "限定任务所属执行的状态码多选;空数组 = 明确返空;不传 = 不按执行状态过滤")
@@ -26,6 +26,9 @@ public class ProjectTaskAggregateStatusBoardReqVO {
@Schema(description = "我参与语义;与 ownerId 二选一")
private Long involveUserId;
@Schema(description = "执行成员语义:该 userId 是执行 owner 或活跃执行协办;过滤其参与的执行下的任务。与 involveUserId(任务成员)正交,可同传;用户未参与任何执行时返空")
private Long executionInvolveUserId;
@Schema(description = "仅作为 owner 匹配;与 involveUserId 二选一")
private Long ownerId;

View File

@@ -8,12 +8,9 @@ import lombok.Data;
public class ProjectTaskSummaryReqVO {
/**
* 数字汇总作用域
* <ul>
* <li>{@code mine}(默认):统计当前登录人 owner 或活跃协办的任务</li>
* <li>{@code all}:统计项目内全部任务,要求 {@code project:task:list-all} 权限码</li>
* </ul>
* 我参与语义:传入的 userId 是 owner 或活跃协办;不传 = 项目内全部任务
* 切换"我参与 / 所有"由前端直接控制此字段是否携带与其他读接口page / status-board / board-page契约一致。
*/
@Schema(description = "作用域:mine(默认) / all", example = "mine")
private String scope;
@Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;不传 = 项目内全部")
private Long involveUserId;
}

View File

@@ -4,8 +4,6 @@ import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@@ -50,23 +48,6 @@ public interface ExecutionAssigneeMapper extends BaseMapperX<ExecutionAssigneeDO
.isNull(ExecutionAssigneeDO::getRemovedAt));
}
/**
* 查 userId 当前在指定项目下,活跃协办的所有执行 IDremoved_at IS NULL
* 走 JOIN 是因为 execution_assignee 表没有 project_id 冗余字段。
* 用于 VisibilityScopeResolver 收集"我是执行协办人"的 scope 来源。
*/
@Select("""
SELECT a.execution_id
FROM rdms_execution_assignee a
JOIN rdms_project_execution e ON e.id = a.execution_id AND e.deleted = b'0'
WHERE a.deleted = b'0'
AND a.removed_at IS NULL
AND e.project_id = #{projectId}
AND a.user_id = #{userId}
""")
List<Long> selectActiveExecutionIdsByProjectIdAndUserId(@Param("projectId") Long projectId,
@Param("userId") Long userId);
/**
* 按 execution_id 批量软删执行协办(含已 removed 的历史段)。
*/

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.project.dal.mysql.project.execution;
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;
@@ -7,12 +9,11 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -33,44 +34,97 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
}
default PageResult<ProjectExecutionDO> selectPageByProjectId(Long projectId,
VisibilityScope scope,
ProjectExecutionPageReqVO reqVO) {
// 可见性短路:非 seesAll 且无任何可见执行 → 空页,避免后续 IN () SQL
if (!scope.seesAll() && scope.executionIds().isEmpty()) {
return PageResult.empty();
}
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.eq(ProjectExecutionDO::getProjectId, projectId);
queryWrapper.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType());
queryWrapper.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId());
queryWrapper.eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode());
queryWrapper.eqIfPresent(ProjectExecutionDO::getPriority, reqVO.getPriority());
queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
queryWrapper.orderByAsc(ProjectExecutionDO::getPriority);
queryWrapper.orderByDesc(BaseDO::getCreateTime);
queryWrapper.orderByDesc(ProjectExecutionDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectExecutionDO::getId, scope.executionIds());
}
return selectPage(reqVO, queryWrapper);
ProjectExecutionPageReqVO reqVO,
List<String> terminalStatusCodes,
LocalDate today,
LocalDate weekStart,
LocalDate weekEnd) {
Page<ProjectExecutionDO> page = new Page<>(reqVO.getPageNo(), reqVO.getPageSize());
IPage<ProjectExecutionDO> ipage = doSelectPageByProjectId(
projectId, reqVO, terminalStatusCodes, today, weekStart, weekEnd, page);
return new PageResult<>(ipage.getRecords(), ipage.getTotal());
}
/**
* 查 userId 在指定项目下,作为 owner 的所有执行 ID
* 用于 VisibilityScopeResolver 收集"我是执行负责人"的 scope 来源。
* 项目下执行分页查询
* <p>SQL 用 @Select 直接控制,主表以别名 t 暴露EXISTS 子查询用 t.id 关联;
* 避免 LambdaWrapper + .apply 嵌入裸 SQL 时依赖 "MyBatis-Plus 不给主表加别名" 这一实现细节。
* 与任务侧 {@code ProjectTaskMapper.selectAggregatePageByProjectId} 同款风格。
*
* <p>involveUserId 非空时附加 (owner_id = involveUserId OR 活跃协办含 involveUserId)
* 否则该过滤分支跳过("看项目下全部")。
*/
default List<Long> selectIdsByProjectIdAndOwnerId(Long projectId, Long userId) {
return selectList(new LambdaQueryWrapperX<ProjectExecutionDO>()
.select(ProjectExecutionDO::getId)
.eq(ProjectExecutionDO::getProjectId, projectId)
.eq(ProjectExecutionDO::getOwnerId, userId))
.stream()
.map(ProjectExecutionDO::getId)
.toList();
}
@Select("""
<script>
SELECT t.*
FROM rdms_project_execution t
<where>
t.deleted = b'0'
AND t.project_id = #{projectId}
<if test="reqVO.executionType != null and reqVO.executionType != ''">
AND t.execution_type = #{reqVO.executionType}
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.statusCode != null and reqVO.statusCode != ''">
AND t.status_code = #{reqVO.statusCode}
</if>
<if test="reqVO.priority != null and reqVO.priority != ''">
AND t.priority = #{reqVO.priority}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.execution_name LIKE CONCAT('%', #{reqVO.keyword}, '%')
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_execution_assignee a
WHERE a.execution_id = t.id
AND a.user_id = #{reqVO.involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
<!-- 截止时间范围 chip基于 planned_end_date三个桶均排除终态对齐任务 summary 口径) -->
<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 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 == 'thisWeek'">
AND 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>
</if>
</where>
ORDER BY t.priority ASC, t.create_time DESC, t.id DESC
</script>
""")
IPage<ProjectExecutionDO> doSelectPageByProjectId(
@Param("projectId") Long projectId,
@Param("reqVO") ProjectExecutionPageReqVO reqVO,
@Param("terminalStatusCodes") List<String> terminalStatusCodes,
@Param("today") LocalDate today,
@Param("weekStart") LocalDate weekStart,
@Param("weekEnd") LocalDate weekEnd,
Page<ProjectExecutionDO> page);
default List<ProjectExecutionDO> selectListByOwnerId(Long ownerId) {
return selectList(new LambdaQueryWrapperX<ProjectExecutionDO>()
@@ -90,28 +144,86 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
}
default Integer countByProjectIdAndStatusCode(Long projectId,
VisibilityScope scope,
ProjectExecutionStatusBoardReqVO reqVO,
String statusCode) {
// 可见性短路:非 seesAll 且无任何可见执行 → 计数 0
if (!scope.seesAll() && scope.executionIds().isEmpty()) {
return 0;
}
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>()
.eq(ProjectExecutionDO::getProjectId, projectId)
.eq(ProjectExecutionDO::getStatusCode, statusCode)
.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType())
.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectExecutionDO::getId, scope.executionIds());
}
return Math.toIntExact(selectCount(queryWrapper));
String statusCode,
List<String> terminalStatusCodes,
LocalDate today,
LocalDate weekStart,
LocalDate weekEnd) {
return Math.toIntExact(doCountByProjectIdAndStatusCode(
projectId, reqVO, statusCode, terminalStatusCodes, today, weekStart, weekEnd));
}
/**
* 项目下指定状态的执行计数(与 doSelectPageByProjectId 同款过滤口径)。
* 同上:用 @Select 显式表别名 t 替代 LambdaWrapper + .apply EXISTS 写法。
*/
@Select("""
<script>
SELECT COUNT(*)
FROM rdms_project_execution t
<where>
t.deleted = b'0'
AND t.project_id = #{projectId}
AND t.status_code = #{statusCode}
<if test="reqVO.executionType != null and reqVO.executionType != ''">
AND t.execution_type = #{reqVO.executionType}
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.execution_name LIKE CONCAT('%', #{reqVO.keyword}, '%')
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_execution_assignee a
WHERE a.execution_id = t.id
AND a.user_id = #{reqVO.involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
<!-- 截止时间范围 chip基于 planned_end_date三个桶均排除终态对齐任务 summary 口径) -->
<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 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 == 'thisWeek'">
AND 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>
</if>
</where>
</script>
""")
Long doCountByProjectIdAndStatusCode(
@Param("projectId") Long projectId,
@Param("reqVO") ProjectExecutionStatusBoardReqVO reqVO,
@Param("statusCode") String statusCode,
@Param("terminalStatusCodes") List<String> terminalStatusCodes,
@Param("today") LocalDate today,
@Param("weekStart") LocalDate weekStart,
@Param("weekEnd") LocalDate weekEnd);
/**
* TD-016按 projectRequirementIds 批量聚合承接执行的平均进度,避免列表 N+1。
* <p>口径:仅统计 deleted = 0 + status_code NOT IN(excludedStatusCodes) 的执行;

View File

@@ -3,7 +3,6 @@ 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;
@@ -11,12 +10,10 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTask
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;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -36,33 +33,68 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
}
default PageResult<ProjectTaskDO> selectPageByExecutionId(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskPageReqVO reqVO) {
// 可见性短路:非 seesAll 且无任何可见任务 → 空页
if (!scope.seesAll() && scope.taskIds().isEmpty()) {
return PageResult.empty();
}
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.eq(ProjectTaskDO::getProjectId, projectId);
queryWrapper.eq(ProjectTaskDO::getExecutionId, executionId);
queryWrapper.eqIfPresent(ProjectTaskDO::getParentTaskId, reqVO.getParentTaskId());
queryWrapper.eqIfPresent(ProjectTaskDO::getOwnerId, reqVO.getOwnerId());
queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode());
queryWrapper.eqIfPresent(ProjectTaskDO::getPriority, reqVO.getPriority());
queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId);
queryWrapper.orderByAsc(ProjectTaskDO::getPriority);
queryWrapper.orderByDesc(BaseDO::getCreateTime);
queryWrapper.orderByDesc(ProjectTaskDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectTaskDO::getId, scope.taskIds());
}
return selectPage(reqVO, queryWrapper);
Page<ProjectTaskDO> page = new Page<>(reqVO.getPageNo(), reqVO.getPageSize());
IPage<ProjectTaskDO> ipage = doSelectPageByExecutionId(projectId, executionId, reqVO, page);
return new PageResult<>(ipage.getRecords(), ipage.getTotal());
}
/**
* 执行内任务分页查询。
* <p>SQL 用 @Select 显式表别名 tEXISTS 子查询用 t.id 关联 rdms_task_assignee
* 与项目级 aggregate page 同款风格。
*
* <p>involveUserId 非空时附加 (owner_id = involveUserId OR 活跃协办含 involveUserId)
* 与 ownerId 文档标注「二选一」由前端保证(不做后端互斥校验)。
*/
@Select("""
<script>
SELECT t.*
FROM rdms_task t
<where>
t.deleted = b'0'
AND t.project_id = #{projectId}
AND t.execution_id = #{executionId}
<if test="reqVO.parentTaskId != null">
AND t.parent_task_id = #{reqVO.parentTaskId}
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.statusCode != null and reqVO.statusCode != ''">
AND t.status_code = #{reqVO.statusCode}
</if>
<if test="reqVO.priority != null and reqVO.priority != ''">
AND t.priority = #{reqVO.priority}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.task_title LIKE CONCAT('%', #{reqVO.keyword}, '%')
</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>
</where>
ORDER BY t.parent_task_id ASC, t.priority ASC, t.create_time DESC, t.id DESC
</script>
""")
IPage<ProjectTaskDO> doSelectPageByExecutionId(
@Param("projectId") Long projectId,
@Param("executionId") Long executionId,
@Param("reqVO") ProjectTaskPageReqVO reqVO,
Page<ProjectTaskDO> page);
default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) {
ProjectTaskDO update = new ProjectTaskDO();
update.setStatusCode(toStatus);
@@ -274,78 +306,57 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
.eq(ProjectTaskDO::getId, id));
}
/**
* 递归 CTE从"userId 在指定项目下作为 owner_id 的全部任务"出发,向下展开包含所有子孙的任务 ID。
* 用于 VisibilityScopeResolver 中"任务负责人 → 自己 + 全部子孙"规则的 scope 收集。
*
* 任务表已逻辑删除的行不参与递归WHERE 子句过滤 deleted
* 单棵子树最大深度受 MySQL `cte_max_recursion_depth`(默认 1000限制业务实际任务树远低于此。
*/
@Select("""
WITH RECURSIVE owned (id) AS (
SELECT id FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND owner_id = #{userId}
UNION ALL
SELECT t.id FROM rdms_task t
JOIN owned o ON t.parent_task_id = o.id
WHERE t.deleted = b'0'
)
SELECT id FROM owned
""")
List<Long> selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(
@Param("projectId") Long projectId,
@Param("userId") Long userId);
/**
* 同 selectOwnedTaskAndDescendantIdsByProjectIdAndUserId 但再加 execution_id 维度。
* 注意:递归向下展开只跟着 parent_task_id子任务必然与父任务在同一 execution 下,
* 因此 execution_id 过滤仅作用于种子owned那一步即可。
*/
@Select("""
WITH RECURSIVE owned (id) AS (
SELECT id FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND execution_id = #{executionId}
AND owner_id = #{userId}
UNION ALL
SELECT t.id FROM rdms_task t
JOIN owned o ON t.parent_task_id = o.id
WHERE t.deleted = b'0'
)
SELECT id FROM owned
""")
List<Long> selectOwnedTaskAndDescendantIdsByExecutionIdAndUserId(
@Param("projectId") Long projectId,
@Param("executionId") Long executionId,
@Param("userId") Long userId);
default Integer countByProjectIdAndExecutionIdAndStatusCode(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskStatusBoardReqVO reqVO,
String statusCode) {
// 可见性短路:非 seesAll 且无任何可见任务 → 0
if (!scope.seesAll() && scope.taskIds().isEmpty()) {
return 0;
}
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<ProjectTaskDO>()
.eq(ProjectTaskDO::getProjectId, projectId)
.eq(ProjectTaskDO::getExecutionId, executionId)
.eq(ProjectTaskDO::getStatusCode, statusCode)
.eqIfPresent(ProjectTaskDO::getParentTaskId, reqVO.getParentTaskId())
.eqIfPresent(ProjectTaskDO::getOwnerId, reqVO.getOwnerId())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectTaskDO::getId, scope.taskIds());
}
return Math.toIntExact(selectCount(queryWrapper));
return Math.toIntExact(doCountByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, reqVO, statusCode));
}
/**
* 执行内任务按状态计数(与 doSelectPageByExecutionId 同款过滤口径,含 involveUserId 协办分支)。
*/
@Select("""
<script>
SELECT COUNT(*)
FROM rdms_task t
<where>
t.deleted = b'0'
AND t.project_id = #{projectId}
AND t.execution_id = #{executionId}
AND t.status_code = #{statusCode}
<if test="reqVO.parentTaskId != null">
AND t.parent_task_id = #{reqVO.parentTaskId}
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.task_title LIKE CONCAT('%', #{reqVO.keyword}, '%')
</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>
</where>
</script>
""")
Long doCountByProjectIdAndExecutionIdAndStatusCode(
@Param("projectId") Long projectId,
@Param("executionId") Long executionId,
@Param("reqVO") ProjectTaskStatusBoardReqVO reqVO,
@Param("statusCode") String statusCode);
/**
* 收集执行下的所有任务 id含子孙——子孙必然同 execution_id所以一把抓即可
* 用于"删除执行"时的级联软删。
@@ -364,7 +375,7 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
/**
* 从给定任务出发,递归向下收集自身 + 所有子孙任务 id递归 CTE
* 用于"删除任务"时的级联软删。复用与 selectOwnedTaskAndDescendantIdsByProjectIdAndUserId 同款 CTE 模式。
* 用于"删除任务"时的级联软删。
*
* 任务表已逻辑删除的行不参与递归。
*/
@@ -410,8 +421,6 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
* 项目级跨执行任务分页查询。
*
* 语义:
* - 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 非空 → 排除终态
@@ -423,13 +432,6 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
<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>
@@ -446,6 +448,23 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
<foreach collection="reqVO.executionStatusCodes" item="esc" open="(" separator="," close=")">#{esc}</foreach>
)
</if>
<if test="reqVO.executionInvolveUserId != null">
AND EXISTS (
SELECT 1 FROM rdms_project_execution e
WHERE e.id = t.execution_id
AND e.deleted = b'0'
AND (
e.owner_id = #{reqVO.executionInvolveUserId}
OR EXISTS (
SELECT 1 FROM rdms_execution_assignee ea
WHERE ea.execution_id = e.id
AND ea.user_id = #{reqVO.executionInvolveUserId}
AND ea.removed_at IS NULL
AND ea.deleted = b'0'
)
)
)
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
@@ -503,8 +522,6 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
""")
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,
@@ -523,13 +540,6 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
<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>
@@ -546,6 +556,23 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
<foreach collection="reqVO.executionStatusCodes" item="esc" open="(" separator="," close=")">#{esc}</foreach>
)
</if>
<if test="reqVO.executionInvolveUserId != null">
AND EXISTS (
SELECT 1 FROM rdms_project_execution e
WHERE e.id = t.execution_id
AND e.deleted = b'0'
AND (
e.owner_id = #{reqVO.executionInvolveUserId}
OR EXISTS (
SELECT 1 FROM rdms_execution_assignee ea
WHERE ea.execution_id = e.id
AND ea.user_id = #{reqVO.executionInvolveUserId}
AND ea.removed_at IS NULL
AND ea.deleted = b'0'
)
)
)
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
@@ -589,8 +616,6 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
""")
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,
@@ -634,13 +659,6 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
<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}
@@ -658,8 +676,6 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
""")
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,

View File

@@ -4,8 +4,6 @@ import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.Collections;
@@ -50,41 +48,6 @@ public interface TaskAssigneeMapper extends BaseMapperX<TaskAssigneeDO> {
.orderByAsc(TaskAssigneeDO::getId));
}
/**
* 查 userId 在指定项目下,当前活跃协办的所有任务 IDremoved_at IS NULL
* 走 JOIN 是因为 task_assignee 表没有 project_id 冗余字段。
* 用于 VisibilityScopeResolver 收集"我是任务协办人"的 scope 来源(项目维度)。
*/
@Select("""
SELECT a.task_id
FROM rdms_task_assignee a
JOIN rdms_task t ON t.id = a.task_id AND t.deleted = b'0'
WHERE a.deleted = b'0'
AND a.removed_at IS NULL
AND t.project_id = #{projectId}
AND a.user_id = #{userId}
""")
List<Long> selectActiveTaskIdsByProjectIdAndUserId(@Param("projectId") Long projectId,
@Param("userId") Long userId);
/**
* 同上,但再加 execution_id 维度,用于"任务分页(执行内)"的 scope。
*/
@Select("""
SELECT a.task_id
FROM rdms_task_assignee a
JOIN rdms_task t ON t.id = a.task_id AND t.deleted = b'0'
WHERE a.deleted = b'0'
AND a.removed_at IS NULL
AND t.project_id = #{projectId}
AND t.execution_id = #{executionId}
AND a.user_id = #{userId}
""")
List<Long> selectActiveTaskIdsByProjectIdAndExecutionIdAndUserId(
@Param("projectId") Long projectId,
@Param("executionId") Long executionId,
@Param("userId") Long userId);
/**
* 按主键 + 任务 ID 双键查返回的记录可能已失效removed_at != null由调用方判断。
*/

View File

@@ -26,7 +26,7 @@ public interface ObjectPermissionService {
* 本方法不抛异常,纯返回 boolean用于"无权限就走降级路径"而非"无权限就 403"的场景。
*
* @param objectId 对象 ID如 projectId
* @param permission 权限码,如 {@code project:task:list-all}
* @param permission 权限码,如 {@code project:task:query}
* @return true=具备false=不具备
*/
boolean hasPermission(Long objectId, String permission);

View File

@@ -1,9 +1,10 @@
package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO;
@@ -12,18 +13,17 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTask
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
import com.njcn.rdms.module.project.util.DueRangeSupport;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
@@ -42,28 +42,28 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Resource
private ProjectTaskService projectTaskService;
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForProject(projectId, userId);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
return buildExecutionStatusBoard(projectId, scope, reqVO, statusModels);
return buildExecutionStatusBoard(projectId, reqVO, statusModels);
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
VisibilityScope scope = resolveTaskScope(projectId, executionId);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return buildTaskStatusBoard(projectId, executionId, scope, reqVO, statusModels);
return buildTaskStatusBoard(projectId, executionId, reqVO, statusModels);
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO) {
VisibilityScope scope = resolveTaskScope(projectId, executionId);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
@@ -77,7 +77,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
ProjectTaskBoardPageRespVO respVO = new ProjectTaskBoardPageRespVO();
List<ProjectTaskBoardPageRespVO.ColumnItemVO> items = targetStatusModels.stream()
.map(statusModel -> buildBoardColumn(projectId, executionId, scope, reqVO, statusModel))
.map(statusModel -> buildBoardColumn(projectId, executionId, reqVO, statusModel))
.collect(Collectors.toList());
respVO.setItems(items);
return respVO;
@@ -98,11 +98,10 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
}
private ProjectTaskBoardPageRespVO.ColumnItemVO buildBoardColumn(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskBoardPageReqVO reqVO,
ObjectStatusModelDO statusModel) {
ProjectTaskPageReqVO innerReq = toInnerPageReq(reqVO, statusModel.getStatusCode());
PageResult<ProjectTaskDO> doPage = projectTaskMapper.selectPageByExecutionId(projectId, executionId, scope, innerReq);
PageResult<ProjectTaskDO> doPage = projectTaskMapper.selectPageByExecutionId(projectId, executionId, innerReq);
PageResult<ProjectTaskRespVO> voPage = projectTaskService.assembleTaskRespVOPage(projectId, executionId, doPage);
ProjectTaskBoardPageRespVO.ColumnItemVO item = new ProjectTaskBoardPageRespVO.ColumnItemVO();
@@ -124,6 +123,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
innerReq.setPageSize(reqVO.getPageSize());
innerReq.setKeyword(reqVO.getKeyword());
innerReq.setParentTaskId(reqVO.getParentTaskId());
innerReq.setInvolveUserId(reqVO.getInvolveUserId());
innerReq.setOwnerId(reqVO.getOwnerId());
innerReq.setPriority(reqVO.getPriority());
innerReq.setUpdateTime(reqVO.getUpdateTime());
@@ -131,34 +131,24 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
return innerReq;
}
/**
* 计算任务可见性 scope与 ProjectTaskServiceImpl#computeTaskScope 同款:
* 项目经理 → seesAll执行负责人 = 当前用户 → seesAll否则按 resolveForExecution 求并集。
*/
private VisibilityScope resolveTaskScope(Long projectId, Long executionId) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
if (scope.seesAll()) {
return scope;
}
ProjectExecutionDO exec = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
if (exec != null && Objects.equals(exec.getOwnerId(), userId)) {
return VisibilityScope.all();
}
return scope;
}
private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId,
VisibilityScope scope,
ProjectExecutionStatusBoardReqVO reqVO,
List<ObjectStatusModelDO> statusModels) {
// dueRange 截止时间过滤所需的日期边界与执行终态码(终态排除口径对齐任务 summary
LocalDate today = DueRangeSupport.today();
LocalDate weekStart = DueRangeSupport.weekStart(today);
LocalDate weekEnd = DueRangeSupport.weekEnd(today);
List<String> terminalStatusCodes = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
ProjectExecutionStatusBoardRespVO respVO = new ProjectExecutionStatusBoardRespVO();
List<ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> {
ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO item =
new ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO();
item.setStatusCode(statusModel.getStatusCode());
item.setStatusName(statusModel.getStatusName());
item.setCount(projectExecutionMapper.countByProjectIdAndStatusCode(projectId, scope, reqVO, statusModel.getStatusCode()).longValue());
item.setCount(projectExecutionMapper.countByProjectIdAndStatusCode(projectId, reqVO,
statusModel.getStatusCode(), terminalStatusCodes, today, weekStart, weekEnd).longValue());
item.setSort(statusModel.getSort());
item.setTerminal(statusModel.getTerminalFlag());
return item;
@@ -169,7 +159,6 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
}
private ProjectTaskStatusBoardRespVO buildTaskStatusBoard(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskStatusBoardReqVO reqVO,
List<ObjectStatusModelDO> statusModels) {
ProjectTaskStatusBoardRespVO respVO = new ProjectTaskStatusBoardRespVO();
@@ -177,7 +166,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO item = new ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO();
item.setStatusCode(statusModel.getStatusCode());
item.setStatusName(statusModel.getStatusName());
item.setCount(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, scope, reqVO,
item.setCount(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, reqVO,
statusModel.getStatusCode()).longValue());
item.setSort(statusModel.getSort());
item.setTerminal(statusModel.getTerminalFlag());

View File

@@ -41,11 +41,10 @@ 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.util.DueRangeSupport;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
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.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
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;
@@ -119,8 +118,6 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
private ProjectRequirementService projectRequirementService;
@Resource
private ProjectRequirementMapper projectRequirementMapper;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
/**
* 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。
* 与 ProjectTaskService 互相依赖(任务侧已注入 ProjectExecutionService 用于通知执行),用 @Lazy 打破循环。
@@ -209,24 +206,25 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
validateProjectExists(projectId);
// 数据可见性:项目经理看全部;非经理按"我 owner 的执行 我活跃协办的执行"过滤
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForProject(projectId, userId);
return projectExecutionMapper.selectPageByProjectId(projectId, scope, reqVO);
// "我参与 / 所有"视角完全由前端发不发 reqVO.involveUserId 决定。
// 注getExecutionRespVOPage 内部 this.getExecutionPage() 自调用不触发 AOP但外层注解已守门
// 此处独立挂注解是为了堵跨 service 直调 ProjectExecutionService.getExecutionPage 的鉴权后门。
// dueRange 截止时间过滤所需的日期边界与执行终态码(终态排除口径对齐任务 summary
LocalDate today = DueRangeSupport.today();
return projectExecutionMapper.selectPageByProjectId(projectId, reqVO,
loadExecutionTerminalStatusCodes(), today,
DueRangeSupport.weekStart(today), DueRangeSupport.weekEnd(today));
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long executionId) {
ProjectExecutionDO execution = getExecution(projectId, executionId);
// 可见性卡断:项目经理放行;否则 executionId 必须在 scope.executionIds 中。
// 未命中按"执行不存在"语义返回,不暴露存在性。
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForProject(projectId, userId);
if (!scope.seesAll() && !scope.executionIds().contains(executionId)) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS);
}
ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
respVO.setProgressRate(loadExecutionProgress(projectId, executionId));
boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId);
@@ -237,6 +235,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public PageResult<ProjectExecutionRespVO> getExecutionRespVOPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
PageResult<ProjectExecutionDO> pageResult = getExecutionPage(projectId, reqVO);
PageResult<ProjectExecutionRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectExecutionRespVO.class);
@@ -859,6 +859,13 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return statusCodes == null ? Collections.emptyList() : statusCodes;
}
/** dueRange 终态排除用:执行对象域的终态码(动态查,不硬编码)。 */
private List<String> loadExecutionTerminalStatusCodes() {
List<String> statusCodes = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
return statusCodes == null ? Collections.emptyList() : statusCodes;
}
/**
* 执行详情完成态:判断"参与聚合的根任务"是否全部为 completed。
* 筛选口径与 loadExecutionProgress 同源;空集(无参与聚合的根任务)返回 false禁止下发 complete。

View File

@@ -1,31 +0,0 @@
package com.njcn.rdms.module.project.service.project.permission;
import java.util.Set;
/**
* 数据可见性 scope由 VisibilityScopeResolver 计算得出。
* - seesAll=true项目经理等"看全部"角色,分页/计数 SQL 跳过任何 ID 过滤
* - seesAll=false仅命中 executionIds / taskIds 的数据可见;集合为空 = 完全不可见
*
* 实例不可变;空集合用 Set.of() 表达,调用方不得修改。
*/
public record VisibilityScope(
boolean seesAll,
Set<Long> executionIds,
Set<Long> taskIds
) {
public static VisibilityScope all() {
return new VisibilityScope(true, Set.of(), Set.of());
}
public static VisibilityScope of(Set<Long> executionIds, Set<Long> taskIds) {
return new VisibilityScope(false,
executionIds == null ? Set.of() : Set.copyOf(executionIds),
taskIds == null ? Set.of() : Set.copyOf(taskIds));
}
public static VisibilityScope empty() {
return new VisibilityScope(false, Set.of(), Set.of());
}
}

View File

@@ -1,28 +0,0 @@
package com.njcn.rdms.module.project.service.project.permission;
/**
* 计算当前登录用户在某项目 / 某执行下的数据可见性 scope。
*
* 规则:
* - 项目经理project.manager_user_id == userId→ seesAll=true
* - 非项目经理 → 取以下 4 项的并集,构成 (executionIds, taskIds)
* 1. 我作为 execution.owner_id 的执行 ID
* 2. 我作为 execution_assignee 活跃协办的执行 IDremoved_at IS NULL
* 3. 我作为 task.owner_id 的任务 ID 及其全部子孙 ID递归 CTE 一次展开)
* 4. 我作为 task_assignee 活跃协办的任务 IDremoved_at IS NULL
*
* 任务参与者集合 ⊆ 执行参与者集合(业务约束:任务负责人/协办人必须从执行团队挑选)。
*/
public interface VisibilityScopeResolver {
/**
* 项目维度 scope用于执行分页 / 执行看板)。
*/
VisibilityScope resolveForProject(Long projectId, Long userId);
/**
* 执行维度 scope用于任务分页 / 任务看板 / 任务详情)。
* 调用方需先保证 executionId 属于 projectId由 URL 路径约束)。
*/
VisibilityScope resolveForExecution(Long projectId, Long executionId, Long userId);
}

View File

@@ -1,76 +0,0 @@
package com.njcn.rdms.module.project.service.project.permission;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* VisibilityScopeResolver 实现。
*
* 短路project.manager_user_id == userId → seesAll=true跳过任何 ID 过滤。
* 非项目经理:并集 4 个 Mapper 来源得到可见的 executionIds / taskIds。
*
* 任务的"执行 owner 看执行下所有任务"短路不在此处实现,
* 由 ProjectTaskServiceImpl / ProjectStatusBoardServiceImpl 在调用本 Resolver 前自行判定。
* 本 Resolver 仅负责"参与者 → 可见 ID 集合"的纯查询。
*/
@Service
public class VisibilityScopeResolverImpl implements VisibilityScopeResolver {
@Resource
private ProjectMapper projectMapper;
@Resource
private ProjectExecutionMapper projectExecutionMapper;
@Resource
private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private TaskAssigneeMapper taskAssigneeMapper;
@Override
public VisibilityScope resolveForProject(Long projectId, Long userId) {
if (isProjectManager(projectId, userId)) {
return VisibilityScope.all();
}
Set<Long> executionIds = new LinkedHashSet<>();
executionIds.addAll(projectExecutionMapper.selectIdsByProjectIdAndOwnerId(projectId, userId));
executionIds.addAll(executionAssigneeMapper.selectActiveExecutionIdsByProjectIdAndUserId(projectId, userId));
Set<Long> taskIds = new LinkedHashSet<>();
taskIds.addAll(projectTaskMapper.selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(projectId, userId));
taskIds.addAll(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndUserId(projectId, userId));
return VisibilityScope.of(executionIds, taskIds);
}
@Override
public VisibilityScope resolveForExecution(Long projectId, Long executionId, Long userId) {
if (isProjectManager(projectId, userId)) {
return VisibilityScope.all();
}
// executionIds 在执行维度无用,统一传空集;调用方靠 taskIds 过滤分页/计数。
Set<Long> taskIds = new LinkedHashSet<>();
taskIds.addAll(projectTaskMapper.selectOwnedTaskAndDescendantIdsByExecutionIdAndUserId(projectId, executionId, userId));
taskIds.addAll(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndExecutionIdAndUserId(projectId, executionId, userId));
return VisibilityScope.of(Set.of(), taskIds);
}
private boolean isProjectManager(Long projectId, Long userId) {
if (projectId == null || userId == null) {
return false;
}
ProjectDO project = projectMapper.selectById(projectId);
return project != null && Objects.equals(project.getManagerUserId(), userId);
}
}

View File

@@ -23,8 +23,10 @@ public interface ProjectTaskAggregateService {
ProjectTaskBoardPageRespVO getAggregateTaskBoardPage(Long projectId, ProjectTaskAggregateBoardPageReqVO reqVO);
/**
* scope=mine默认: 当前用户 owner 或活跃协办;
* scope=all: 全项目任务,要求 project:task:list-all 权限码,否则抛 PROJECT_OBJECT_PERMISSION_DENIED。
* 跨执行任务今日小条:
* 入参 involveUserId 为 null → 项目内全部任务;
* involveUserId 不为 null → 限定 owner 或活跃协办为该用户。
* 由 @CheckObjectPermission(project:task:query) 守门,无权限直接 403。
*/
ProjectTaskSummaryRespVO getAggregateTaskSummary(Long projectId, ProjectTaskSummaryReqVO reqVO);
}

View File

@@ -3,7 +3,7 @@ package com.njcn.rdms.module.project.service.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.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
@@ -17,9 +17,7 @@ import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectPermissionService;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -47,10 +45,6 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
private ProjectTaskService projectTaskService;
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Resource
private ProjectObjectPermissionService projectObjectPermissionService;
// ========= 公共 helper =========
@@ -70,38 +64,17 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
return objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
}
/**
* 判定"项目全部"语义:既无 involveUserId 又无 ownerId。
*/
private boolean isAggregateAllScope(Long involveUserId, Long ownerId) {
return involveUserId == null && ownerId == null;
}
/**
* page / board-page / status-board 的 scope 解析(读路径"宽容降级"
* 入参组合 = allScopeIntent=true + 有 list-all → seesAll否则 → resolveForProject 过滤(不抛 403
*/
private VisibilityScope resolveScopeForRead(Long projectId, boolean allScopeIntent) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
if (allScopeIntent) {
boolean hasListAll = projectObjectPermissionService.hasPermission(projectId, ProjectTaskConstants.PERMISSION_LIST_ALL);
if (hasListAll) {
return VisibilityScope.all();
}
}
return visibilityScopeResolver.resolveForProject(projectId, userId);
}
// ========= page =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public PageResult<ProjectTaskRespVO> getAggregateTaskPage(Long projectId, ProjectTaskAggregatePageReqVO reqVO) {
// 空数组语义短路:前端明确"按 0 个执行状态过滤" → 返空集合,不让 MyBatis 退化成"不过滤"
if (reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty()) {
// 空数组语义短路:前端明确"按 0 个执行状态/执行 id 过滤" → 返空集合,不让 MyBatis 退化成"不过滤"
if ((reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
|| (reqVO.getExecutionIds() != null && reqVO.getExecutionIds().isEmpty())) {
return PageResult.empty();
}
boolean allScope = isAggregateAllScope(reqVO.getInvolveUserId(), reqVO.getOwnerId());
VisibilityScope scope = resolveScopeForRead(projectId, allScope);
LocalDate today = today();
LocalDate weekStart = weekStart(today);
LocalDate weekEnd = weekEnd(today);
@@ -109,7 +82,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
Page<ProjectTaskDO> page = new Page<>(reqVO.getPageNo(), reqVO.getPageSize());
IPage<ProjectTaskDO> ipage = projectTaskMapper.selectAggregatePageByProjectId(
projectId, scope.seesAll(), scope.taskIds(), reqVO, terminalStatusCodes, weekStart, weekEnd, today, page);
projectId, reqVO, terminalStatusCodes, weekStart, weekEnd, today, page);
PageResult<ProjectTaskDO> doPage = new PageResult<>(ipage.getRecords(), ipage.getTotal());
return projectTaskService.assembleTaskRespVOPageCrossExecution(projectId, doPage);
}
@@ -117,15 +90,16 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
// ========= status-board =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskStatusBoardRespVO getAggregateTaskStatusBoard(Long projectId, ProjectTaskAggregateStatusBoardReqVO reqVO) {
// 空数组语义短路:跳过 SQL,但保留状态列骨架(前端看板依赖全列表渲染),count 全部 0
if (reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty()) {
if ((reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
|| (reqVO.getExecutionIds() != null && reqVO.getExecutionIds().isEmpty())) {
List<ObjectStatusModelDO> emptyStatusModels =
objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return buildStatusBoardResponse(emptyStatusModels, Collections.emptyMap());
}
boolean allScope = isAggregateAllScope(reqVO.getInvolveUserId(), reqVO.getOwnerId());
VisibilityScope scope = resolveScopeForRead(projectId, allScope);
LocalDate today = today();
LocalDate weekStart = weekStart(today);
LocalDate weekEnd = weekEnd(today);
@@ -135,7 +109,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
List<ProjectTaskMapper.StatusCountRow> rows = projectTaskMapper.selectAggregateStatusCount(
projectId, scope.seesAll(), scope.taskIds(), reqVO, terminalStatusCodes, weekStart, weekEnd, today);
projectId, reqVO, terminalStatusCodes, weekStart, weekEnd, today);
Map<String, Long> countMap = rows.stream()
.collect(Collectors.toMap(
ProjectTaskMapper.StatusCountRow::getStatusCode,
@@ -173,9 +147,12 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
// ========= board-page =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskBoardPageRespVO getAggregateTaskBoardPage(Long projectId, ProjectTaskAggregateBoardPageReqVO reqVO) {
// 空数组语义短路:跳过 SQL,保留按 statusCodes 过滤后的列骨架,每列 list=[] / total=0
boolean emptyExecStatusCodes = reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty();
// 空数组语义短路:executionStatusCodes 或 executionIds 为空数组 → 该范围明确为空,跳过 SQL,保留列骨架(每列 list=[] / total=0)
boolean emptyExecScope = (reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
|| (reqVO.getExecutionIds() != null && reqVO.getExecutionIds().isEmpty());
List<ObjectStatusModelDO> allStatusModels =
objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
@@ -189,7 +166,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
.collect(Collectors.toList());
}
if (emptyExecStatusCodes) {
if (emptyExecScope) {
List<ProjectTaskBoardPageRespVO.ColumnItemVO> emptyItems = targetStatusModels.stream()
.map(sm -> buildColumnItemVO(sm, PageResult.empty()))
.collect(Collectors.toList());
@@ -198,15 +175,13 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
return emptyResp;
}
boolean allScope = isAggregateAllScope(reqVO.getInvolveUserId(), reqVO.getOwnerId());
VisibilityScope scope = resolveScopeForRead(projectId, allScope);
LocalDate today = today();
LocalDate weekStart = weekStart(today);
LocalDate weekEnd = weekEnd(today);
List<String> terminalStatusCodes = loadTerminalStatusCodes();
List<ProjectTaskBoardPageRespVO.ColumnItemVO> items = targetStatusModels.stream()
.map(sm -> buildAggregateBoardColumn(projectId, scope, reqVO, sm,
.map(sm -> buildAggregateBoardColumn(projectId, reqVO, sm,
terminalStatusCodes, today, weekStart, weekEnd))
.collect(Collectors.toList());
@@ -216,7 +191,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
}
private ProjectTaskBoardPageRespVO.ColumnItemVO buildAggregateBoardColumn(
Long projectId, VisibilityScope scope,
Long projectId,
ProjectTaskAggregateBoardPageReqVO reqVO, ObjectStatusModelDO sm,
List<String> terminalStatusCodes, LocalDate today, LocalDate weekStart, LocalDate weekEnd) {
@@ -228,6 +203,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
innerReq.setExecutionIds(reqVO.getExecutionIds());
innerReq.setExecutionStatusCodes(reqVO.getExecutionStatusCodes());
innerReq.setInvolveUserId(reqVO.getInvolveUserId());
innerReq.setExecutionInvolveUserId(reqVO.getExecutionInvolveUserId());
innerReq.setOwnerId(reqVO.getOwnerId());
innerReq.setStatusCodes(Collections.singletonList(sm.getStatusCode()));
innerReq.setPriority(reqVO.getPriority());
@@ -238,7 +214,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
Page<ProjectTaskDO> page = new Page<>(innerReq.getPageNo(), innerReq.getPageSize());
IPage<ProjectTaskDO> ipage = projectTaskMapper.selectAggregatePageByProjectId(
projectId, scope.seesAll(), scope.taskIds(), innerReq, terminalStatusCodes, weekStart, weekEnd, today, page);
projectId, innerReq, terminalStatusCodes, weekStart, weekEnd, today, page);
PageResult<ProjectTaskDO> doPage = new PageResult<>(ipage.getRecords(), ipage.getTotal());
PageResult<ProjectTaskRespVO> voPage =
projectTaskService.assembleTaskRespVOPageCrossExecution(projectId, doPage);
@@ -261,26 +237,17 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
// ========= summary =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskSummaryRespVO getAggregateTaskSummary(Long projectId, ProjectTaskSummaryReqVO reqVO) {
boolean isAll = "all".equalsIgnoreCase(reqVO.getScope());
if (isAll) {
// scope=all 强校验 list-all无权抛 403与 page/board-page/status-board 的"宽容降级"不同)
projectObjectPermissionService.checkPermission(projectId, ProjectTaskConstants.PERMISSION_LIST_ALL, false);
}
LocalDate today = today();
LocalDate weekStart = weekStart(today);
LocalDate weekEnd = weekEnd(today);
List<String> terminalStatusCodes = loadTerminalStatusCodes();
Long userId = SecurityFrameworkUtils.getLoginUserId();
Long involveUserId = isAll ? null : userId;
VisibilityScope scope = isAll
? VisibilityScope.all()
: visibilityScopeResolver.resolveForProject(projectId, userId);
Map<String, Long> counts = projectTaskMapper.selectAggregateSummaryCounts(
projectId, scope.seesAll(), scope.taskIds(), involveUserId,
projectId,
reqVO.getInvolveUserId(),
terminalStatusCodes, ProjectTaskConstants.STATUS_COMPLETED,
today, weekStart, weekEnd);

View File

@@ -45,8 +45,6 @@ import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPer
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
import com.njcn.rdms.module.project.service.project.ProjectService;
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService;
import com.njcn.rdms.module.project.service.project.task.worklog.TaskWorklogService;
import com.google.common.annotations.VisibleForTesting;
@@ -128,8 +126,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Resource
private DictDataApi dictDataApi;
@Override
@@ -432,44 +428,23 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
VisibilityScope scope = computeTaskScope(projectId, executionId, execution);
return projectTaskMapper.selectPageByExecutionId(projectId, executionId, scope, reqVO);
}
/**
* 任务可见性计算:
* - 项目经理 → seesAll由 Resolver 内置判定)
* - 执行负责人 = 当前用户 → seesAll看本执行下全部任务
* - 否则 → resolveForExecution 求并集(我 owner 的任务及子孙 我活跃协办的任务)
*/
private VisibilityScope computeTaskScope(Long projectId, Long executionId, ProjectExecutionDO execution) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
if (scope.seesAll()) {
return scope;
}
if (execution != null && Objects.equals(execution.getOwnerId(), userId)) {
return VisibilityScope.all();
}
return scope;
validateExecutionExists(projectId, executionId);
// 进得来 = 看执行下全部任务,"我参与 / 所有"由前端发不发 reqVO.ownerId / involveUserId 决定。
// 注getTaskRespVOPage 内部 this.getTaskPage() 自调用不触发 AOP但外层注解已守门
// 此处独立挂注解是为了堵跨 service 直调 ProjectTaskService.getTaskPage 的鉴权后门。
return projectTaskMapper.selectPageByExecutionId(projectId, executionId, reqVO);
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) {
// 内联 validate便于接住 execution 供前端 executionOwnerId 字段使用
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
// 可见性卡断:执行 owner / 项目经理直接放行;否则 taskId 必须在 scope.taskIds 中。
// 未命中按"任务不存在"语义返回,不暴露存在性。
Long userId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(execution.getOwnerId(), userId)) {
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
if (!scope.seesAll() && !scope.taskIds().contains(taskId)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS);
}
}
ProjectTaskRespVO respVO = BeanUtils.toBean(task, ProjectTaskRespVO.class);
applyLifecycle(respVO);
respVO.setOwnerNickname(loadOwnerNickname(task.getOwnerId()));
@@ -488,6 +463,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO);
return assembleTaskRespVOPage(projectId, executionId, pageResult);

View File

@@ -0,0 +1,38 @@
package com.njcn.rdms.module.project.util;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
/**
* dueRange 截止时间范围筛选的日期边界 helper。
*
* <p>口径统一:服务器时区 {@code Asia/Shanghai},本周按周一~周日。
* 供执行分页查询与执行状态看板计数共用,避免在多个 service 里重复同一段日期计算。</p>
*
* <p>终态排除不在此处:终态码由各对象域自行通过
* {@code ObjectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(objectType)} 动态查。</p>
*/
public final class DueRangeSupport {
private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Shanghai");
private DueRangeSupport() {
}
/** 服务器当天。 */
public static LocalDate today() {
return LocalDate.now(SERVER_ZONE);
}
/** 本周一(含当天)。 */
public static LocalDate weekStart(LocalDate today) {
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
}
/** 本周日(含当天)。 */
public static LocalDate weekEnd(LocalDate today) {
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY));
}
}

View File

@@ -56,7 +56,7 @@ spring:
primary: master
datasource:
master:
url: jdbc:mysql://192.168.1.22:13306/rdms_v3?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
url: jdbc:mysql://192.168.1.22:13306/rdms_view?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: root
password: njcnpqs

View File

@@ -55,7 +55,7 @@ spring:
primary: master
datasource:
master:
url: jdbc:mysql://192.168.1.22:13306/rdms_v3?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
url: jdbc:mysql://192.168.1.22:13306/rdms_view?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: root
password: njcnpqs

View File

@@ -9,9 +9,6 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
@@ -24,7 +21,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
@@ -37,17 +33,6 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
private ProjectExecutionMapper projectExecutionMapper;
@Mock
private ProjectTaskMapper projectTaskMapper;
@Mock
private VisibilityScopeResolver visibilityScopeResolver;
/**
* 默认让 VisibilityScopeResolver 放行seesAll=true既有看板用例不关心 scope。
*/
@BeforeEach
void setupVisibilityScopeAll() {
lenient().when(visibilityScopeResolver.resolveForProject(any(), any())).thenReturn(VisibilityScope.all());
lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any())).thenReturn(VisibilityScope.all());
}
@Test
void getExecutionStatusBoard_shouldReturnEnabledStatusesInSortOrderAndSumCounts() {
@@ -59,16 +44,21 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
createStatus("cancelled", "已取消", 50, true),
createStatus("disabled", "已停用", 60, false, 1)
));
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
any(ProjectExecutionStatusBoardReqVO.class), eq("pending"))).thenReturn(3);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
any(ProjectExecutionStatusBoardReqVO.class), eq("active"))).thenReturn(8);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
any(ProjectExecutionStatusBoardReqVO.class), eq("paused"))).thenReturn(2);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
any(ProjectExecutionStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
any(ProjectExecutionStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L),
any(ProjectExecutionStatusBoardReqVO.class), eq("pending"),
any(), any(), any(), any())).thenReturn(3);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L),
any(ProjectExecutionStatusBoardReqVO.class), eq("active"),
any(), any(), any(), any())).thenReturn(8);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L),
any(ProjectExecutionStatusBoardReqVO.class), eq("paused"),
any(), any(), any(), any())).thenReturn(2);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L),
any(ProjectExecutionStatusBoardReqVO.class), eq("completed"),
any(), any(), any(), any())).thenReturn(4);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L),
any(ProjectExecutionStatusBoardReqVO.class), eq("cancelled"),
any(), any(), any(), any())).thenReturn(1);
ProjectExecutionStatusBoardReqVO reqVO = new ProjectExecutionStatusBoardReqVO();
reqVO.setKeyword("接口");
@@ -100,15 +90,15 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
createStatus("cancelled", "已取消", 50, true)
));
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("pending"))).thenReturn(5);
any(ProjectTaskStatusBoardReqVO.class), eq("pending"))).thenReturn(5);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12);
any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("paused"))).thenReturn(2);
any(ProjectTaskStatusBoardReqVO.class), eq("paused"))).thenReturn(2);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1);
any(ProjectTaskStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1);
ProjectTaskStatusBoardReqVO reqVO = new ProjectTaskStatusBoardReqVO();
reqVO.setKeyword("任务");

View File

@@ -98,23 +98,16 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Mock
private ProjectExecutionAssigneeService projectExecutionAssigneeService;
@Mock
private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver;
@Mock
private ProjectRequirementService projectRequirementService;
@Mock
private ProjectRequirementMapper projectRequirementMapper;
/**
* 默认让 VisibilityScopeResolver 放行seesAll=true既有测试无需关心 scope。
* 默认让 dictDataApi.validateDictDataList 对 REQ_PRIORITY 返回 true既有测试不因 priority 校验失败。
* 真正需要测试 scope 行为的用例可在方法内显式覆盖
* 读路径鉴权由 @CheckObjectPermission 的 AOP 处理,单测 @InjectMocks 不走 AOP无须在此 mock
*/
@BeforeEach
void setupVisibilityScopeAll() {
lenient().when(visibilityScopeResolver.resolveForProject(any(), any()))
.thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any()))
.thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
void setupDefaultPriorityValidation() {
lenient().when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
.thenReturn(success(true));
}
@@ -557,7 +550,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
second.setProgressRate(new BigDecimal("100.00"));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), eq(reqVO), any(), any(), any(), any()))
.thenReturn(new PageResult<>(List.of(first, second), 2L));
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
@@ -593,7 +586,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
rows.add(Map.of("executionId", 5002L, "progressRate", 10));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), eq(reqVO), any(), any(), any(), any()))
.thenReturn(new PageResult<>(List.of(first, second), 2L));
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
@@ -654,7 +647,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
reqVO.setPageNo(1);
reqVO.setPageSize(20);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), eq(reqVO), any(), any(), any(), any()))
.thenReturn(new PageResult<>(List.of(), 0L));
PageResult<ProjectExecutionDO> result = projectExecutionService.getExecutionPage(projectId, reqVO);

View File

@@ -1,126 +0,0 @@
package com.njcn.rdms.module.project.service.project.permission;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
/**
* VisibilityScopeResolverImpl 单元测试。覆盖角色矩阵:
* - 项目经理 → seesAll
* - 非项目经理 → 4 源并集
* - 非项目经理且无任何参与 → 空集合
* - 执行维度同上
*/
class VisibilityScopeResolverImplTest extends BaseMockitoUnitTest {
@InjectMocks
private VisibilityScopeResolverImpl resolver;
@Mock private ProjectMapper projectMapper;
@Mock private ProjectExecutionMapper projectExecutionMapper;
@Mock private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock private ProjectTaskMapper projectTaskMapper;
@Mock private TaskAssigneeMapper taskAssigneeMapper;
@Test
void resolveForProject_managerShouldSeeAll() {
Long projectId = 2001L, userId = 3001L;
ProjectDO project = new ProjectDO();
project.setId(projectId);
project.setManagerUserId(userId);
when(projectMapper.selectById(projectId)).thenReturn(project);
VisibilityScope scope = resolver.resolveForProject(projectId, userId);
assertTrue(scope.seesAll());
}
@Test
void resolveForProject_nonManagerUnionsFourSources() {
Long projectId = 2001L, userId = 3002L;
ProjectDO project = new ProjectDO();
project.setId(projectId);
project.setManagerUserId(9999L);
when(projectMapper.selectById(projectId)).thenReturn(project);
when(projectExecutionMapper.selectIdsByProjectIdAndOwnerId(projectId, userId))
.thenReturn(List.of(5001L));
when(executionAssigneeMapper.selectActiveExecutionIdsByProjectIdAndUserId(projectId, userId))
.thenReturn(List.of(5002L));
when(projectTaskMapper.selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(projectId, userId))
.thenReturn(List.of(9001L, 9002L));
when(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndUserId(projectId, userId))
.thenReturn(List.of(9003L));
VisibilityScope scope = resolver.resolveForProject(projectId, userId);
assertFalse(scope.seesAll());
assertEquals(Set.of(5001L, 5002L), scope.executionIds());
assertEquals(Set.of(9001L, 9002L, 9003L), scope.taskIds());
}
@Test
void resolveForProject_nonParticipantReturnsEmpty() {
Long projectId = 2001L, userId = 3099L;
ProjectDO project = new ProjectDO();
project.setId(projectId);
project.setManagerUserId(9999L);
when(projectMapper.selectById(projectId)).thenReturn(project);
when(projectExecutionMapper.selectIdsByProjectIdAndOwnerId(projectId, userId)).thenReturn(List.of());
when(executionAssigneeMapper.selectActiveExecutionIdsByProjectIdAndUserId(projectId, userId)).thenReturn(List.of());
when(projectTaskMapper.selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(projectId, userId)).thenReturn(List.of());
when(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndUserId(projectId, userId)).thenReturn(List.of());
VisibilityScope scope = resolver.resolveForProject(projectId, userId);
assertFalse(scope.seesAll());
assertTrue(scope.executionIds().isEmpty());
assertTrue(scope.taskIds().isEmpty());
}
@Test
void resolveForExecution_managerShouldSeeAll() {
Long projectId = 2001L, executionId = 5001L, userId = 3001L;
ProjectDO project = new ProjectDO();
project.setManagerUserId(userId);
when(projectMapper.selectById(projectId)).thenReturn(project);
VisibilityScope scope = resolver.resolveForExecution(projectId, executionId, userId);
assertTrue(scope.seesAll());
}
@Test
void resolveForExecution_nonManagerScopedToThatExecution() {
Long projectId = 2001L, executionId = 5001L, userId = 3002L;
ProjectDO project = new ProjectDO();
project.setManagerUserId(9999L);
when(projectMapper.selectById(projectId)).thenReturn(project);
when(projectTaskMapper.selectOwnedTaskAndDescendantIdsByExecutionIdAndUserId(projectId, executionId, userId))
.thenReturn(List.of(9001L));
when(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndExecutionIdAndUserId(projectId, executionId, userId))
.thenReturn(List.of(9002L));
VisibilityScope scope = resolver.resolveForExecution(projectId, executionId, userId);
assertFalse(scope.seesAll());
assertTrue(scope.executionIds().isEmpty());
assertEquals(Set.of(9001L, 9002L), scope.taskIds());
}
}

View File

@@ -56,7 +56,7 @@ spring:
primary: master
datasource:
master:
url: jdbc:mysql://192.168.1.22:13306/rdms_v3?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
url: jdbc:mysql://192.168.1.22:13306/rdms_view?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: root
password: njcnpqs
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优

View File

@@ -55,7 +55,7 @@ spring:
primary: master
datasource:
master:
url: jdbc:mysql://192.168.1.22:13306/rdms_v3?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
url: jdbc:mysql://192.168.1.22:13306/rdms_view?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: root
password: njcnpqs