feat(system): 扩展用户部门权限功能

- 在 AdminUserService 中新增 listEnabledUserIdsByDeptIds 方法获取指定部门集合下启用且未离职的用户 ID 集合
- 在 DeptService 中新增 listDescendantDeptIds 方法获得指定部门集合及其所有子孙部门的 ID 集合
- 在 DeptService 中新增 listCodesByIds 方法按 id 集合批量查询部门 code 集合
- 在 OrgLeaderRelationService 中新增 listEffectiveDeptIdsByUserId 方法查询指定用户当前生效的负责人关系所对应的 dept_id 集合
- 在 PermissionApi 中新增 isSuperAdmin 接口判断用户是否超管
- 在 ObjectPermissionApi 中新增 getObjectRolePermissionDetailMerged 接口按 roleId 列表聚合菜单 + 权限码
- 扩展 ProductContextRoleRespVO 添加多角色场景的附加角色名称列表
- 扩展 ProductCreateWithTeamReqVO 支持创建时添加关心人用户 ID 列表
- 优化 ProductMemberServiceImpl 支持同一用户多角色显示,区分主角色和附加角色
- 新增 MEMBER_ACTION_REACTIVATE 复活动作类型用于处理 INACTIVE 成员行重新激活场景
- 在 ObjectStatusModelDO 中新增 progressExcludedFlag 字段控制是否参与上层进度统计
- 更新 AGENTS.md 和 CLAUDE.md 添加 Git 操作纪律规范
- 在 rdms-project-api 中新增多个错误码常量支持角色转移和内置角色配置验证
This commit is contained in:
2026-05-14 13:58:40 +08:00
parent 3946c0a0aa
commit 8f6b762bf3
85 changed files with 3908 additions and 277 deletions

View File

@@ -37,6 +37,8 @@ public interface ErrorCodeConstants {
ErrorCode PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED = new ErrorCode(1_008_001_024, "初始团队必须包含产品经理");
ErrorCode PRODUCT_INITIAL_TEAM_MEMBER_DUPLICATE = new ErrorCode(1_008_001_025, "初始团队成员存在重复");
ErrorCode PRODUCT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_001_026, "初始团队中存在非法角色");
ErrorCode PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_001_027, "原产品经理在该产品已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试");
ErrorCode PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_001_028, "内置产品角色【{}】未在 system_role 找到,请联系管理员");
// ========== 产品需求 1-008-002-000 ==========
ErrorCode REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_002_000, "产品需求不存在");
@@ -98,6 +100,8 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_INITIAL_TEAM_MEMBER_DUPLICATE = new ErrorCode(1_008_002_030, "初始团队成员存在重复");
ErrorCode PROJECT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_002_031, "初始团队中存在非法角色");
ErrorCode PROJECT_DIRECTION_NOT_MATCH_PRODUCT = new ErrorCode(1_008_002_032, "项目方向与所属产品方向不一致");
ErrorCode PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_002_033, "原项目经理在该项目已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试");
ErrorCode PROJECT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_002_034, "内置项目角色【{}】未在 system_role 找到,请联系管理员");
// ========== 执行管理 1-008-003-000 ==========
ErrorCode PROJECT_EXECUTION_NOT_EXISTS = new ErrorCode(1_008_003_000, "执行不存在");

View File

@@ -71,6 +71,11 @@ public final class ObjectActivityConstants {
public static final String MEMBER_ACTION_ADD = "add_member";
public static final String MEMBER_ACTION_UPDATE = "update_member";
public static final String MEMBER_ACTION_REMOVE = "remove_member";
/**
* 复活动作:原 INACTIVE 成员行被重新激活status: 1 → 0用于把"再次新增 / update 改 role 命中老 INACTIVE 行"路径
* 跟物理"新增 / 更新"的 audit 语义区分开。createXxxMember 命中 INACTIVE 三元组复活老行时使用本动作,避免 ADD 语义误用。
*/
public static final String MEMBER_ACTION_REACTIVATE = "reactivate_member";
public static final String EXECUTION_ACTION_CREATE = "create_execution_entity";
public static final String EXECUTION_ACTION_UPDATE = "update_execution_entity";
public static final String EXECUTION_ACTION_DELETE = "delete_execution_entity";
@@ -98,7 +103,7 @@ public final class ObjectActivityConstants {
PRODUCT_ACTION_CREATE, PRODUCT_ACTION_CHANGE_MANAGER);
public static final List<String> MEMBER_TIMELINE_ACTION_TYPES = List.of(
MEMBER_ACTION_ADD, MEMBER_ACTION_UPDATE, MEMBER_ACTION_REMOVE);
MEMBER_ACTION_ADD, MEMBER_ACTION_UPDATE, MEMBER_ACTION_REMOVE, MEMBER_ACTION_REACTIVATE);
private static final Set<String> STATUS_ACTION_TYPE_SET = Set.copyOf(STATUS_ACTION_TYPES);
@@ -145,6 +150,7 @@ public final class ObjectActivityConstants {
case MEMBER_ACTION_ADD -> "新增成员";
case MEMBER_ACTION_UPDATE -> "调整成员";
case MEMBER_ACTION_REMOVE -> "移出成员";
case MEMBER_ACTION_REACTIVATE -> "重新激活成员";
default -> normalizedActionType;
};
}

View File

@@ -15,6 +15,12 @@ public final class ProjectTaskConstants {
*/
public static final String OBJECT_TYPE = "task";
/**
* 任务"已完成"状态码,对应 rdms_object_status_model 中 object_type='task' 且 status_code='completed' 的状态。
* 用于 execution 的 complete 按钮可见性判定:要求根任务在排除排除集后全部为该状态。
*/
public static final String STATUS_COMPLETED = "completed";
/**
* 任务业务类型。
*/

View File

@@ -1,9 +1,12 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@Schema(description = "管理后台 - 产品团队成员 Response VO")
@Data
@@ -42,4 +45,7 @@ public class ProductMemberRespVO {
@Schema(description = "备注", example = "当前负责需求收敛")
private String remark;
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用(如同人 manager + creator单角色时为空数组", example = "产品创建者"))
private List<String> additionalRoleNames = Collections.emptyList();
}

View File

@@ -1,8 +1,12 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Collections;
import java.util.List;
@Schema(description = "管理后台 - 产品上下文中的当前角色 Response VO")
@Data
public class ProductContextRoleRespVO {
@@ -10,10 +14,16 @@ public class ProductContextRoleRespVO {
@Schema(description = "对象角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3201")
private Long roleId;
@Schema(description = "对象角色编码", example = "product_manager")
@Schema(description = "对象角色编码(主角色 code权限判断兼容字段", example = "product_manager")
private String roleCode;
@Schema(description = "对象角色名称", example = "产品经理")
@Schema(description = "对象角色名称(主角色 name", example = "产品经理")
private String roleName;
@Schema(description = "是否游客上下文(隐式 observer 兜底时为 true", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
private Boolean guestFlag;
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用;单角色时为空数组", example = "创建者"))
private List<String> additionalRoleNames = Collections.emptyList();
}

View File

@@ -39,4 +39,14 @@ public class ProductCreateWithTeamReqVO {
@Valid
private List<ProductMemberSaveReqVO> members;
/**
* 关心人 user ID 列表(创建时手动添加,可选)。
*
* <p>跟 members 是平行字段watcher 不参与团队管理,只是被授予"产品关心人"角色product_watcher
* 数据可见性。允许跟 members 的 user 重叠(多角色合法);后端按 (user, object, role) 三元组写入,
* 重复跳过 / INACTIVE 复活,业务侧不强校验。
*/
@Schema(description = "关心人用户ID列表可选可与团队成员重叠", example = "101,102")
private List<Long> watcherUserIds;
}

View File

@@ -2,6 +2,8 @@ package com.njcn.rdms.module.project.controller.admin.project.task;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
@@ -74,6 +76,14 @@ public class ProjectTaskController {
return success(projectStatusBoardService.getTaskStatusBoard(projectId, executionId, reqVO));
}
@GetMapping("/board-page")
@Operation(summary = "获取任务看板分页(按状态分列 + 每列分页 + 各列总数)")
public CommonResult<ProjectTaskBoardPageRespVO> getTaskBoardPage(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@Valid ProjectTaskBoardPageReqVO reqVO) {
return success(projectStatusBoardService.getTaskBoardPage(projectId, executionId, reqVO));
}
@PostMapping("/{taskId}/change-status")
@Operation(summary = "变更任务状态")
public CommonResult<Boolean> changeStatus(@PathVariable("projectId") Long projectId,

View File

@@ -0,0 +1,41 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
/**
* 看板视图任务分页 Request VO。
* <p>过滤口径keyword / parentTaskId / ownerId / updateTime与 {@link ProjectTaskPageReqVO} 严格一致;
* statusCode 升级为数组:缺省=返回该执行下任务状态字典的全部列;传若干个=只返回这些状态的列;
* 字典外的 statusCode 静默忽略。pageNo / pageSize 应用到返回的所有列(每列各自分页但页码统一)。
*/
@Schema(description = "管理后台 - 任务看板分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProjectTaskBoardPageReqVO extends PageParam {
@Schema(description = "列选择;缺省返回全部状态列,传若干个只返回这些状态的列;字典外的值静默忽略",
example = "[\"pending\",\"active\"]")
private String[] statusCode;
@Schema(description = "关键词,匹配任务标题", example = "联调")
private String keyword;
@Schema(description = "父任务编号", example = "9001")
private Long parentTaskId;
@Schema(description = "任务负责人用户编号", example = "3002")
private Long ownerId;
@Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime;
}

View File

@@ -0,0 +1,46 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 看板视图任务分页 Response VO。
* <p>每个 item 表示一列列定义statusCode/statusName/sort/terminal+ 当前页切片list+ 该列在当前过滤条件下的总数total
* list 元素结构与 {@link ProjectTaskRespVO} 完全一致。
*/
@Schema(description = "管理后台 - 任务看板分页 Response VO")
@Data
public class ProjectTaskBoardPageRespVO {
@Schema(description = "列数组(按 sort 升序)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<ColumnItemVO> items;
@Schema(description = "任务看板单列分页")
@Data
public static class ColumnItemVO {
@Schema(description = "状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending")
private String statusCode;
@Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "待开始")
private String statusName;
@Schema(description = "排序权重(与 /status-board.items[].sort 同源)", requiredMode = Schema.RequiredMode.REQUIRED,
example = "10")
private Integer sort;
@Schema(description = "是否终态", example = "false")
private Boolean terminal;
@Schema(description = "该列当前页切片;元素结构与 /tasks/page 的 list 元素一致",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<ProjectTaskRespVO> list;
@Schema(description = "该列在当前过滤条件下的总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "12")
private Long total;
}
}

View File

@@ -1,19 +1,25 @@
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Collections;
import java.util.List;
@Schema(description = "管理后台 - 项目上下文中的当前角色 Response VO")
@Data
public class ProjectContextRoleRespVO {
@Schema(description = "对象角色编号", example = "3201")
private Long roleId;
@Schema(description = "对象角色编码", example = "project_manager")
@Schema(description = "对象角色编码(主角色 code权限判断兼容字段", example = "project_manager")
private String roleCode;
@Schema(description = "对象角色名称", example = "项目经理")
@Schema(description = "对象角色名称(主角色 name", example = "项目经理")
private String roleName;
@Schema(description = "是否游客上下文", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
private Boolean guestFlag;
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用;单角色时为空数组", example = "创建者"))
private List<String> additionalRoleNames = Collections.emptyList();
}

View File

@@ -40,4 +40,14 @@ public class ProjectCreateWithTeamReqVO {
@Valid
private List<ProjectMemberSaveReqVO> members;
/**
* 关心人 user ID 列表(创建时手动添加,可选)。
*
* <p>跟 members 是平行字段watcher 不参与团队管理,只是被授予"项目关心人"角色project_watcher
* 数据可见性。允许跟 members 的 user 重叠(多角色合法);后端按 (user, object, role) 三元组写入,
* 重复跳过 / INACTIVE 复活,业务侧不强校验。
*/
@Schema(description = "关心人用户ID列表可选可与团队成员重叠", example = "101,102")
private List<Long> watcherUserIds;
}

View File

@@ -51,6 +51,10 @@ public class ObjectStatusModelDO extends BaseDO {
* 是否允许编辑对象主数据
*/
private Boolean allowEdit;
/**
* 是否不参与上层进度统计。
*/
private Boolean progressExcludedFlag;
/**
* 备注
*/

View File

@@ -5,6 +5,7 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@@ -42,6 +43,19 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
.eq(UserObjectRoleDO::getStatus, 0));
}
/**
* multi-role拿 user 在某对象内全部 ACTIVE 角色行(含 manager + creator + watcher 等所有显式角色)。
* 用于对象域鉴权ProductObjectPermissionService / ProjectObjectPermissionService 的 anyMatch 权限聚合)
* 与 Context 主角色挑选(按 sort 升序排,主角色 + additionalRoleNames
*/
default List<UserObjectRoleDO> selectActiveListByObjectAndUserId(String objectType, Long objectId, Long userId) {
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId)
.eq(UserObjectRoleDO::getUserId, userId)
.eq(UserObjectRoleDO::getStatus, 0));
}
default List<UserObjectRoleDO> selectListByIdsAndObject(List<Long> ids, String objectType, Long objectId) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
@@ -59,4 +73,42 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
.eq(UserObjectRoleDO::getStatus, 0));
}
/**
* multi-role 唯一索引精确命中:按 (object_type, object_id, user_id, role_id) 单查任一记录。
* 不带 status —— ACTIVE / INACTIVE 都要返回,用于 insertOrReactivate / pre-check 撞索引场景。
*/
default UserObjectRoleDO selectByObjectUserAndRole(String objectType, Long objectId, Long userId, Long roleId) {
return selectOne(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId)
.eq(UserObjectRoleDO::getUserId, userId)
.eq(UserObjectRoleDO::getRoleId, roleId));
}
/**
* 同 {@link #selectByObjectUserAndRole},但仅返回 ACTIVE 行status=0
* 用于 manager 转岗:按 (user, object, manager_role_id) 三元组定位 manager ACTIVE 行后改 role_id 降级。
*/
default UserObjectRoleDO selectActiveByObjectUserAndRole(String objectType, Long objectId, Long userId, Long roleId) {
return selectOne(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId)
.eq(UserObjectRoleDO::getUserId, userId)
.eq(UserObjectRoleDO::getRoleId, roleId)
.eq(UserObjectRoleDO::getStatus, 0));
}
/**
* 通道 2 用:批量按 userIds 反查指定 objectType 下的活跃记录status=0用于"组织负责人 → 下属参与的对象"反推。
*/
default List<UserObjectRoleDO> selectListByUserIdsAndObjectType(Collection<Long> userIds, String objectType) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyList();
}
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
.in(UserObjectRoleDO::getUserId, userIds)
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getStatus, 0));
}
}

View File

@@ -1,14 +1,10 @@
package com.njcn.rdms.module.project.dal.mysql.product;
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.product.vo.product.ProductPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
@@ -16,21 +12,6 @@ import java.util.Map;
@Mapper
public interface ProductMapper extends BaseMapperX<ProductDO> {
default PageResult<ProductDO> selectPage(ProductPageReqVO reqVO) {
LambdaQueryWrapperX<ProductDO> queryWrapper = new LambdaQueryWrapperX<>();
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProductDO::getCode, reqVO.getKeyword())
.or()
.like(ProductDO::getName, reqVO.getKeyword()));
}
queryWrapper.eqIfPresent(ProductDO::getDirectionCode, reqVO.getDirectionCode())
.eqIfPresent(ProductDO::getManagerUserId, reqVO.getManagerUserId())
.eqIfPresent(ProductDO::getStatusCode, reqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime())
.orderByDesc(BaseDO::getCreateTime);
return selectPage(reqVO, queryWrapper);
}
default ProductDO selectByCode(String code) {
return selectOne(ProductDO::getCode, code);
}

View File

@@ -1,14 +1,11 @@
package com.njcn.rdms.module.project.dal.mysql.project;
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.vo.project.ProjectPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.List;
@@ -17,23 +14,6 @@ import java.util.Map;
@Mapper
public interface ProjectMapper extends BaseMapperX<ProjectDO> {
default PageResult<ProjectDO> selectPage(ProjectPageReqVO reqVO) {
LambdaQueryWrapperX<ProjectDO> queryWrapper = new LambdaQueryWrapperX<>();
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectDO::getProjectCode, reqVO.getKeyword())
.or()
.like(ProjectDO::getProjectName, reqVO.getKeyword()));
}
queryWrapper.eqIfPresent(ProjectDO::getProjectType, reqVO.getProjectType())
.eqIfPresent(ProjectDO::getDirectionCode, reqVO.getDirectionCode())
.eqIfPresent(ProjectDO::getProductId, reqVO.getProductId())
.eqIfPresent(ProjectDO::getManagerUserId, reqVO.getManagerUserId())
.eqIfPresent(ProjectDO::getStatusCode, reqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime())
.orderByDesc(BaseDO::getCreateTime);
return selectPage(reqVO, queryWrapper);
}
default ProjectDO selectByCode(String projectCode) {
return selectOne(ProjectDO::getProjectCode, projectCode);
}

View File

@@ -125,28 +125,40 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
* 取直接子任务的 progressRate 列表(用于父任务进度简单平均汇总)。
* 只读取必要字段,避免拉回整行;逻辑删除的子任务由 BaseMapper 自动过滤。
*/
default List<ProjectTaskDO> selectChildrenProgressByParentTaskId(Long parentTaskId) {
default List<ProjectTaskDO> selectChildrenProgressByParentTaskId(Long parentTaskId,
Collection<String> excludedStatusCodes) {
if (parentTaskId == null) {
return Collections.emptyList();
}
return selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
.select(ProjectTaskDO::getId, ProjectTaskDO::getProgressRate)
.eq(ProjectTaskDO::getParentTaskId, parentTaskId));
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.select(ProjectTaskDO::getId, ProjectTaskDO::getProgressRate);
queryWrapper.eq(ProjectTaskDO::getParentTaskId, parentTaskId);
if (excludedStatusCodes != null && !excludedStatusCodes.isEmpty()) {
queryWrapper.notIn(ProjectTaskDO::getStatusCode, excludedStatusCodes);
}
return selectList(queryWrapper);
}
/**
* 执行详情进度:按当前执行下一级任务 progressRate 简单平均;无一级任务时 SQL 返回 null。
*/
@Select("""
<script>
SELECT AVG(COALESCE(progress_rate, 0))
FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND execution_id = #{executionId}
AND parent_task_id IS NULL
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
AND status_code NOT IN
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
</if>
</script>
""")
BigDecimal selectRootTaskAvgProgressByExecutionId(@Param("projectId") Long projectId,
@Param("executionId") Long executionId);
@Param("executionId") Long executionId,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/**
* 执行分页进度:按当前页 executionId 批量聚合一级任务 progressRate避免列表 N+1。
@@ -160,12 +172,70 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
AND execution_id IN
<foreach collection="executionIds" item="id" open="(" separator="," close=")">#{id}</foreach>
AND parent_task_id IS NULL
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
AND status_code NOT IN
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
</if>
GROUP BY execution_id
</script>
""")
List<Map<String, Object>> selectRootTaskAvgProgressGroupByExecutionIds(
@Param("projectId") Long projectId,
@Param("executionIds") Collection<Long> executionIds);
@Param("executionIds") Collection<Long> executionIds,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/**
* 执行详情完成态聚合:返回根任务总数与"已完成"数,用于 execution complete 按钮判定。
* 与 selectRootTaskAvgProgressByExecutionId 共用同一根任务筛选口径execution_id + parent_task_id IS NULL + excludedStatusCodes
* 业务侧判定 totals > 0 && totals == completedCount 即视为"根任务全部已完成"空集totals = 0按"不全部完成"处理。
*/
@Select("""
<script>
SELECT COUNT(*) AS totals,
SUM(CASE WHEN status_code = #{completedStatusCode} THEN 1 ELSE 0 END) AS completedCount
FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND execution_id = #{executionId}
AND parent_task_id IS NULL
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
AND status_code NOT IN
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
</if>
</script>
""")
Map<String, Object> selectRootTaskCompletionStateByExecutionId(@Param("projectId") Long projectId,
@Param("executionId") Long executionId,
@Param("completedStatusCode") String completedStatusCode,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/**
* 执行分页完成态批量聚合:按 executionId 一次性返回 (totals, completedCount),避免列表 N+1。
* 筛选口径与 selectRootTaskCompletionStateByExecutionId 同源。
*/
@Select("""
<script>
SELECT execution_id AS executionId,
COUNT(*) AS totals,
SUM(CASE WHEN status_code = #{completedStatusCode} THEN 1 ELSE 0 END) AS completedCount
FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND execution_id IN
<foreach collection="executionIds" item="id" open="(" separator="," close=")">#{id}</foreach>
AND parent_task_id IS NULL
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
AND status_code NOT IN
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
</if>
GROUP BY execution_id
</script>
""")
List<Map<String, Object>> selectRootTaskCompletionStateGroupByExecutionIds(
@Param("projectId") Long projectId,
@Param("executionIds") Collection<Long> executionIds,
@Param("completedStatusCode") String completedStatusCode,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/**
* 仅更新单个任务的 progressRate不动其他字段避免污染 lastStatusReason 等)。

View File

@@ -57,4 +57,17 @@ public interface ObjectStatusModelMapper extends BaseMapperX<ObjectStatusModelDO
.collect(Collectors.toList());
}
/**
* 查询某对象类型下所有已启用、且不参与上层进度统计的状态码。
*/
default List<String> selectProgressExcludedStatusCodesByObjectTypeEnabled(String objectType) {
return selectList(new LambdaQueryWrapperX<ObjectStatusModelDO>()
.eq(ObjectStatusModelDO::getObjectType, objectType)
.eq(ObjectStatusModelDO::getStatus, 0)
.eq(ObjectStatusModelDO::getProgressExcludedFlag, true))
.stream()
.map(ObjectStatusModelDO::getStatusCode)
.collect(Collectors.toList());
}
}

View File

@@ -1,8 +1,11 @@
package com.njcn.rdms.module.project.framework.rpc.config;
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.file.FileApi;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.PermissionApi;
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;
@@ -11,6 +14,6 @@ import org.springframework.context.annotation.Configuration;
* Project 模块的 RPC 配置
*/
@Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false)
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class})
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class, PermissionApi.class, OrgLeaderApi.class, UserVisibilityConfigApi.class})
public class RpcConfiguration {
}

View File

@@ -13,6 +13,7 @@ import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@@ -41,9 +42,10 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
throw invalidParamException("对象编号不能为空");
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
UserObjectRoleDO currentMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (currentMember == null) {
// 多角色支持:拿 user 在对象内全部 ACTIVE 角色行
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (userRoles.isEmpty()) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED,
buildDeniedPermission(permission, memberOnly));
}
@@ -51,7 +53,12 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
return;
}
String normalizedPermission = normalizePermission(permission);
if (!getRolePermissions(currentMember.getRoleId()).contains(normalizedPermission)) {
// 任一角色含该权限码即放行(等价于多角色 union短路求值
boolean allowed = userRoles.stream()
.map(UserObjectRoleDO::getRoleId)
.distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
if (!allowed) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, normalizedPermission);
}
}

View File

@@ -13,6 +13,7 @@ import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@@ -41,9 +42,10 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
throw invalidParamException("对象编号不能为空");
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
UserObjectRoleDO currentMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (currentMember == null) {
// 多角色支持:拿 user 在对象内全部 ACTIVE 角色行
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (userRoles.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED,
buildDeniedPermission(permission, memberOnly));
}
@@ -51,7 +53,12 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
return;
}
String normalizedPermission = normalizePermission(permission);
if (!getRolePermissions(currentMember.getRoleId()).contains(normalizedPermission)) {
// 任一角色含该权限码即放行(等价于多角色 union短路求值权限码命中早 return
boolean allowed = userRoles.stream()
.map(UserObjectRoleDO::getRoleId)
.distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
if (!allowed) {
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, normalizedPermission);
}
}

View File

@@ -0,0 +1,63 @@
package com.njcn.rdms.module.project.service.datascope;
import lombok.Getter;
import java.util.Collections;
import java.util.Set;
/**
* 数据权限范围:用户在某 objectTypeproject/product下能看到哪些对象。
* 不可变。三态ALL看全部不加 SQL 条件)/ ID_LIST看具体集合/ EMPTY看不到任何
*
* 设计来源docs/superpowers/specs/2026-05-14-object-data-scope-design.md 第 3.1 节
*/
@Getter
public final class ObjectDataScope {
public enum State { ALL, ID_LIST, EMPTY }
private final State state;
private final Set<Long> ids; // 仅 ID_LIST 时有值
private final Set<String> directionCodes; // 仅 ID_LIST 时有值
private ObjectDataScope(State state, Set<Long> ids, Set<String> directionCodes) {
this.state = state;
this.ids = ids == null ? Collections.emptySet() : Collections.unmodifiableSet(ids);
this.directionCodes = directionCodes == null ? Collections.emptySet() : Collections.unmodifiableSet(directionCodes);
}
public static ObjectDataScope all() {
return new ObjectDataScope(State.ALL, null, null);
}
public static ObjectDataScope empty() {
return new ObjectDataScope(State.EMPTY, null, null);
}
public static ObjectDataScope idList(Set<Long> ids, Set<String> directionCodes) {
boolean idsEmpty = ids == null || ids.isEmpty();
boolean dcEmpty = directionCodes == null || directionCodes.isEmpty();
if (idsEmpty && dcEmpty) {
return empty();
}
return new ObjectDataScope(State.ID_LIST, ids, directionCodes);
}
/**
* 详情入口判定:当前 user 是否能"看到" (objectId, directionCode)。
* - ALL → true
* - ID_LIST → ids.contains(objectId) || directionCodes.contains(directionCode)
* - EMPTY → false
*/
public boolean contains(Long objectId, String objectDirectionCode) {
switch (state) {
case ALL: return true;
case EMPTY: return false;
case ID_LIST:
if (objectId != null && ids.contains(objectId)) return true;
if (objectDirectionCode != null && directionCodes.contains(objectDirectionCode)) return true;
return false;
default: return false;
}
}
}

View File

@@ -0,0 +1,12 @@
package com.njcn.rdms.module.project.service.datascope;
public interface ObjectDataScopeService {
/**
* 计算 user 在某 objectType 下能看到的对象范围。
*
* @param userId 登录用户 id
* @param objectType "product" 或 "project"
*/
ObjectDataScope compute(Long userId, String objectType);
}

View File

@@ -0,0 +1,94 @@
package com.njcn.rdms.module.project.service.datascope;
import cn.hutool.core.collection.CollUtil;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
import com.njcn.rdms.module.system.api.permission.PermissionApi;
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 数据权限 scope 计算实现。3 通道并集 + 超管短路。
*
* 设计docs/superpowers/specs/2026-05-14-object-data-scope-design.md 第 3.3 节
*
* 3 通道全接通 + 超管短路:通道 1自己参与+ 通道 2组织负责人反推+ 通道 3用户可见性配置
*/
@Service
@Slf4j
public class ObjectDataScopeServiceImpl implements ObjectDataScopeService {
@Resource
private PermissionApi permissionApi;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
// channel1 用 Mapper 直接查同模块channel3 在阶段 2 注入跨模块 API
@Resource
private OrgLeaderApi orgLeaderApi;
@Resource
private UserVisibilityConfigApi userVisibilityConfigApi;
@Override
public ObjectDataScope compute(Long userId, String objectType) {
if (Boolean.TRUE.equals(permissionApi.isSuperAdmin(userId).getCheckedData())) {
return ObjectDataScope.all();
}
Set<Long> ids = new HashSet<>();
Set<String> directionCodes = new HashSet<>();
ids.addAll(computeChannel1(userId, objectType));
ids.addAll(computeChannel2(userId, objectType));
UserVisibilityConfigRespDTO cfg = userVisibilityConfigApi.getConfig(userId).getCheckedData();
if (cfg != null) {
if ("all".equals(cfg.getType())) {
return ObjectDataScope.all(); // 短路
}
if ("directions".equals(cfg.getType()) && CollUtil.isNotEmpty(cfg.getDirectionCodes())) {
directionCodes.addAll(cfg.getDirectionCodes());
}
// projects 当前不消费(设计文档明示)
}
log.info("[ObjectDataScope] user={} type={} ids.size={} directions.size={}",
userId, objectType, ids.size(), directionCodes.size());
if (ids.isEmpty() && directionCodes.isEmpty()) {
return ObjectDataScope.empty();
}
return ObjectDataScope.idList(ids, directionCodes);
}
Set<Long> computeChannel1(Long userId, String objectType) {
return userObjectRoleMapper.selectActiveListByObjectTypeAndUserId(objectType, userId).stream()
.map(UserObjectRoleDO::getObjectId)
.collect(Collectors.toSet());
}
/**
* 通道 2组织负责人反推。
* 通过 OrgLeaderApi 拿到当前用户作为负责人可覆盖的下属 userId 集合,
* 再查这批下属参与了哪些同类型对象,合并进 ids。
*/
Set<Long> computeChannel2(Long userId, String objectType) {
Set<Long> reachableUserIds = orgLeaderApi.getReachableUserIds(userId).getCheckedData();
if (CollUtil.isEmpty(reachableUserIds)) {
return Set.of();
}
return userObjectRoleMapper.selectListByUserIdsAndObjectType(reachableUserIds, objectType).stream()
.map(UserObjectRoleDO::getObjectId)
.collect(Collectors.toSet());
}
}

View File

@@ -0,0 +1,54 @@
package com.njcn.rdms.module.project.service.member;
import java.util.List;
/**
* 对象角色自动分配服务:新建产品 / 项目时按规则自动写 rdms_user_object_role。
*
* 写入规则(参 spec 7.1 节):
* - 创建者 = 责任人时,仍写 2 条 (user 同, role 不同),让 creator 信息不丢
* - 创建者 ≠ 责任人时,写 2 条 (user 不同, role 不同)
*
* watcher 批量写入参 spec 7.2 节,允许 watcher 跟 manager 是同一 user。
*
* 使用复活语义:(user, object, role) 三元组若存在 INACTIVE 行 → update 复活;
* 不存在 → INSERT已 ACTIVE → 跳过。
*/
public interface ObjectRoleAutoAssignService {
/**
* 自动落地 creator + manager 双角色记录(一次性写两条,给 fresh 创建流程使用)。
*
* @param objectType "product" 或 "project"
* @param objectId 新建对象 ID
* @param creatorUserId 创建者 user ID一般取 LoginUser
* @param managerUserId 责任人 user ID
* @param creatorRoleCode creator 角色 codeproduct_creator / project_creator
* @param managerRoleCode manager 角色 codeproduct_manager / project_manager
*/
void assignCreatorAndManager(String objectType, Long objectId,
Long creatorUserId, Long managerUserId,
String creatorRoleCode, String managerRoleCode);
/**
* 自动落地 creator 单角色记录manager 由现有业务流程已经写好的场景使用)。
*
* @param objectType "product" 或 "project"
* @param objectId 新建对象 ID
* @param creatorUserId 创建者 user ID
* @param creatorRoleCode creator 角色 code
*/
void assignCreator(String objectType, Long objectId, Long creatorUserId, String creatorRoleCode);
/**
* 自动落地 watcher 角色记录(批量、自动去重;空列表直接返回)。
*
* @param objectType "product" 或 "project"
* @param objectId 对象 ID
* @param watcherUserIds 关心人 user ID 列表(可空 / 重复,会去重)
* @param watcherRoleCode watcher 角色 codeproduct_watcher / project_watcher
*/
void assignWatchers(String objectType, Long objectId,
List<Long> watcherUserIds, String watcherRoleCode);
}

View File

@@ -0,0 +1,116 @@
package com.njcn.rdms.module.project.service.member;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* ObjectRoleAutoAssignService 实现。
*
* 写入分支(参 spec 7.1 / 7.2 节):
* - (user, object, role) 三元组不存在 → INSERT
* - 存在 ACTIVE → 跳过(防御性,正常流程不会走到)
* - 存在 INACTIVE → 复活status=ACTIVE, leftTime=null, joinedTime=now
*
* 用 selectByObjectUserAndRole不带 status 过滤)查老行,避免 INACTIVE 占索引位导致 INSERT 冲突。
*/
@Service
@Slf4j
public class ObjectRoleAutoAssignServiceImpl implements ObjectRoleAutoAssignService {
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private ObjectPermissionApi objectPermissionApi;
@Override
public void assignCreatorAndManager(String objectType, Long objectId,
Long creatorUserId, Long managerUserId,
String creatorRoleCode, String managerRoleCode) {
Long creatorRoleId = resolveRoleId(creatorRoleCode, objectType);
Long managerRoleId = resolveRoleId(managerRoleCode, objectType);
insertOrReactivate(objectType, objectId, managerUserId, managerRoleId, "auto: manager");
insertOrReactivate(objectType, objectId, creatorUserId, creatorRoleId, "auto: creator");
}
@Override
public void assignCreator(String objectType, Long objectId, Long creatorUserId, String creatorRoleCode) {
Long creatorRoleId = resolveRoleId(creatorRoleCode, objectType);
insertOrReactivate(objectType, objectId, creatorUserId, creatorRoleId, "auto: creator");
}
@Override
public void assignWatchers(String objectType, Long objectId,
List<Long> watcherUserIds, String watcherRoleCode) {
if (watcherUserIds == null || watcherUserIds.isEmpty()) {
return;
}
Long watcherRoleId = resolveRoleId(watcherRoleCode, objectType);
watcherUserIds.stream()
.filter(Objects::nonNull)
.distinct()
.forEach(uid -> insertOrReactivate(objectType, objectId, uid, watcherRoleId, "auto: watcher"));
}
private Long resolveRoleId(String roleCode, String objectType) {
ObjectRoleRespDTO role = objectPermissionApi
.getObjectRoleByCode(roleCode, ObjectRoleConstants.ROLE_SCOPE_OBJECT, objectType)
.getCheckedData();
if (role == null || role.getId() == null) {
// 按 objectType 派发到对应业务错误码,避免 IllegalStateException 透出 500
if (ProductObjectConstants.OBJECT_TYPE.equals(objectType)) {
throw exception(ErrorCodeConstants.PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED, roleCode);
}
if (ProjectObjectConstants.OBJECT_TYPE.equals(objectType)) {
throw exception(ErrorCodeConstants.PROJECT_INTERNAL_ROLE_NOT_CONFIGURED, roleCode);
}
// 未知 objectType 兜底(理论不会走到——调用方都用 ProductObjectConstants / ProjectObjectConstants
throw new IllegalStateException(
"内置对象角色未在 system_role 找到: code=" + roleCode + ", object_type=" + objectType);
}
return role.getId();
}
private void insertOrReactivate(String objectType, Long objectId, Long userId, Long roleId, String remark) {
UserObjectRoleDO existing = userObjectRoleMapper
.selectByObjectUserAndRole(objectType, objectId, userId, roleId);
if (existing != null && Objects.equals(existing.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
return;
}
LocalDateTime now = LocalDateTime.now();
if (existing == null) {
UserObjectRoleDO row = new UserObjectRoleDO();
row.setUserId(userId);
row.setObjectType(objectType);
row.setObjectId(objectId);
row.setRoleId(roleId);
row.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
row.setJoinedTime(now);
row.setLeftTime(null);
row.setRemark(remark);
userObjectRoleMapper.insert(row);
} else {
existing.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
existing.setLeftTime(null);
existing.setJoinedTime(now);
existing.setRemark(remark);
userObjectRoleMapper.updateById(existing);
}
}
}

View File

@@ -29,7 +29,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -65,24 +67,78 @@ public class ProductMemberServiceImpl implements ProductMemberService {
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId);
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
return members.stream().map(member -> {
ProductMemberRespVO respVO = new ProductMemberRespVO();
respVO.setId(member.getId());
respVO.setUserId(member.getUserId());
AdminUserRespDTO user = userMap.get(member.getUserId());
respVO.setUserNickname(user == null ? null : user.getNickname());
respVO.setRoleId(member.getRoleId());
ObjectRoleRespDTO role = roleMap.get(member.getRoleId());
respVO.setRoleName(role == null ? null : role.getName());
respVO.setRoleCode(role == null ? null : role.getCode());
respVO.setManagerFlag(Objects.equals(member.getUserId(), product.getManagerUserId())
&& Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE));
respVO.setStatus(member.getStatus());
respVO.setJoinedTime(member.getJoinedTime());
respVO.setLeftTime(member.getLeftTime());
respVO.setRemark(member.getRemark());
return respVO;
}).collect(Collectors.toList());
// 拆分 ACTIVE / INACTIVE
// - ACTIVE 行按 userId 聚合同人多角色合并成一行manager 优先做主),非主角色名放 additionalRoleNames
// - INACTIVE 行保持独立(历史角色行各自留痕,便于审计)
List<UserObjectRoleDO> activeRows = new ArrayList<>();
List<UserObjectRoleDO> inactiveRows = new ArrayList<>();
for (UserObjectRoleDO m : members) {
if (Objects.equals(m.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
activeRows.add(m);
} else {
inactiveRows.add(m);
}
}
Map<Long, List<UserObjectRoleDO>> activeByUser = activeRows.stream()
.collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList()));
List<ProductMemberRespVO> result = new ArrayList<>();
activeByUser.forEach((userId, rows) -> {
UserObjectRoleDO primary = pickPrimaryRow(rows, roleMap);
List<String> additionalRoleNames = rows.stream()
.filter(r -> !Objects.equals(r.getId(), primary.getId()))
.map(r -> {
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
return role == null ? null : role.getName();
})
.filter(Objects::nonNull)
.sorted()
.collect(Collectors.toList());
result.add(toRespVO(primary, roleMap, userMap, product, additionalRoleNames));
});
// INACTIVE 行各自独立成行
inactiveRows.forEach(m -> result.add(toRespVO(m, roleMap, userMap, product, Collections.emptyList())));
return result;
}
/**
* 同 userId 多角色时选主角色行MANAGER_ROLE_CODE 优先,否则按 roleId 升序兜底。
*/
private UserObjectRoleDO pickPrimaryRow(List<UserObjectRoleDO> rows, Map<Long, ObjectRoleRespDTO> roleMap) {
return rows.stream()
.filter(r -> {
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
return role != null && Objects.equals(ProductObjectConstants.MANAGER_ROLE_CODE, role.getCode());
})
.findFirst()
.orElseGet(() -> rows.stream()
.min(Comparator.comparing(UserObjectRoleDO::getRoleId))
.orElseThrow());
}
private ProductMemberRespVO toRespVO(UserObjectRoleDO member,
Map<Long, ObjectRoleRespDTO> roleMap,
Map<Long, AdminUserRespDTO> userMap,
ProductDO product,
List<String> additionalRoleNames) {
ProductMemberRespVO respVO = new ProductMemberRespVO();
respVO.setId(member.getId());
respVO.setUserId(member.getUserId());
AdminUserRespDTO user = userMap.get(member.getUserId());
respVO.setUserNickname(user == null ? null : user.getNickname());
respVO.setRoleId(member.getRoleId());
ObjectRoleRespDTO role = roleMap.get(member.getRoleId());
respVO.setRoleName(role == null ? null : role.getName());
respVO.setRoleCode(role == null ? null : role.getCode());
respVO.setManagerFlag(Objects.equals(member.getUserId(), product.getManagerUserId())
&& Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE));
respVO.setStatus(member.getStatus());
respVO.setJoinedTime(member.getJoinedTime());
respVO.setLeftTime(member.getLeftTime());
respVO.setRemark(member.getRemark());
respVO.setAdditionalRoleNames(additionalRoleNames);
return respVO;
}
@Override
@@ -92,8 +148,10 @@ public class ProductMemberServiceImpl implements ProductMemberService {
public Long createProductMember(Long productId, ProductMemberSaveReqVO reqVO) {
ProductDO product = validateProductEditable(productId);
ObjectRoleRespDTO targetRole = validateProductRole(reqVO.getRoleId());
// 多角色支持:按 (user, object, role) 三元组判存在;不带 status 过滤以便 INACTIVE 行复活(避免唯一索引 INSERT 冲突)
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, productId, reqVO.getUserId());
.selectByObjectUserAndRole(ProductObjectConstants.OBJECT_TYPE, productId,
reqVO.getUserId(), targetRole.getId());
if (existingMember != null && Objects.equals(existingMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS);
}
@@ -101,6 +159,8 @@ public class ProductMemberServiceImpl implements ProductMemberService {
UserObjectRoleDO member;
UserObjectRoleDO before = null;
LocalDateTime now = LocalDateTime.now();
// 物理 INSERT vs INACTIVE 复活的 audit 语义区分null=新增INACTIVE 复活=REACTIVATE
String actionType;
if (existingMember == null) {
member = new UserObjectRoleDO();
member.setUserId(reqVO.getUserId());
@@ -112,6 +172,7 @@ public class ProductMemberServiceImpl implements ProductMemberService {
member.setLeftTime(null);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
before = cloneMember(existingMember);
member = existingMember;
@@ -121,9 +182,10 @@ public class ProductMemberServiceImpl implements ProductMemberService {
member.setLeftTime(null);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
userObjectRoleMapper.updateById(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_REACTIVATE;
}
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, before, member, null);
writeMemberAuditLog(member, actionType, before, member, null);
if (isManagerRole(targetRole)) {
transferManager(product, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
}
@@ -145,6 +207,16 @@ public class ProductMemberServiceImpl implements ProductMemberService {
UserObjectRoleDO before = cloneMember(member);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
// 多角色边界:避免 setRoleId 撞唯一索引——若 user 在该 product 已有 (targetRole) 的另一行(含 INACTIVE 历史行),
// 直接 update 会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突。业务侧明确报错。
if (!Objects.equals(member.getRoleId(), targetRole.getId())) {
UserObjectRoleDO conflict = userObjectRoleMapper.selectByObjectUserAndRole(
ProductObjectConstants.OBJECT_TYPE, productId, member.getUserId(), targetRole.getId());
if (conflict != null && !Objects.equals(conflict.getId(), member.getId())) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS);
}
}
if (isManagerRole(targetRole)) {
member.setRoleId(targetRole.getId());
userObjectRoleMapper.updateById(member);
@@ -268,13 +340,26 @@ public class ProductMemberServiceImpl implements ProductMemberService {
}
private void transferPreviousManager(Long productId, Long previousManagerUserId, Long previousManagerRoleId, String reason) {
// 多角色边界校验:若 user 在 (product, previousManagerRoleId) 已有任意行ACTIVE 或 INACTIVE 历史行),
// 后续 update / INSERT 都会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突 ——
// 一刀切抛业务异常让用户先去人工清理历史行。selectByObjectUserAndRole 不带 status 过滤。
UserObjectRoleDO targetRoleExisting = userObjectRoleMapper.selectByObjectUserAndRole(
ProductObjectConstants.OBJECT_TYPE, productId, previousManagerUserId, previousManagerRoleId);
if (targetRoleExisting != null) {
throw exception(ErrorCodeConstants.PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE, previousManagerRoleId);
}
// 多角色支持:按 (user, object, manager_role_id) 三元组定位 manager 行user 的 creator/specialist 等其他角色行不动
Long productManagerRoleId = resolveProductManagerRoleId();
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, productId, previousManagerUserId);
.selectActiveByObjectUserAndRole(ProductObjectConstants.OBJECT_TYPE, productId,
previousManagerUserId, productManagerRoleId);
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
LocalDateTime now = LocalDateTime.now();
UserObjectRoleDO member;
String actionType;
if (existingMember == null) {
// user 当前没有 manager 角色 ACTIVE 行 —— 兼容老逻辑:插入 previousManagerRoleId 行
member = new UserObjectRoleDO();
member.setUserId(previousManagerUserId);
member.setObjectType(ProductObjectConstants.OBJECT_TYPE);
@@ -287,21 +372,30 @@ public class ProductMemberServiceImpl implements ProductMemberService {
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
// existingMember 是 manager 行 ACTIVEupdate 改 role_id 成 previousManagerRoleId"降级"该行)
member = existingMember;
member.setRoleId(previousManagerRoleId);
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
member.setLeftTime(null);
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
member.setJoinedTime(now);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
}
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
userObjectRoleMapper.updateById(member);
}
writeMemberAuditLog(member, actionType, before, member, reason);
}
private Long resolveProductManagerRoleId() {
ObjectRoleRespDTO role = objectPermissionApi
.getObjectRoleByCode(ProductObjectConstants.MANAGER_ROLE_CODE,
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProductObjectConstants.OBJECT_TYPE)
.getCheckedData();
if (role == null || role.getId() == null) {
throw exception(ErrorCodeConstants.PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED,
ProductObjectConstants.MANAGER_ROLE_CODE);
}
return role.getId();
}
private boolean isManagerRole(ObjectRoleRespDTO role) {
return Objects.equals(ProductObjectConstants.MANAGER_ROLE_CODE, role.getCode());
}

View File

@@ -4,7 +4,11 @@ import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
@@ -81,6 +85,10 @@ public class ProductServiceImpl implements ProductService {
private AdminUserApi adminUserApi;
@Resource
private ProductRequirementModuleMapper requirementModuleMapper;
@Resource
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -99,6 +107,10 @@ public class ProductServiceImpl implements ProductService {
productMapper.insert(product);
initManagerMemberRelation(product);
// 多角色支持:自动落 creator 行(即使 creator == manager 也独立写一条,确保创建者身份不丢)
objectRoleAutoAssignService.assignCreator(ProductObjectConstants.OBJECT_TYPE, product.getId(),
SecurityFrameworkUtils.getLoginUserId(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PRODUCT_CREATOR.getCode());
initDefaultRequirementModule(product);
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
buildProductFieldChanges(null, product), null);
@@ -150,7 +162,17 @@ public class ProductServiceImpl implements ProductService {
// 5) 产品维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
writeManagerInitAuditLog(product.getId(), product.getManagerUserId());
// 6) 产品创建审计
// 6) 多角色支持:自动落 creator 行(不论 creator 是否在 members 列表里,都独立写一条 creator 角色)
objectRoleAutoAssignService.assignCreator(ProductObjectConstants.OBJECT_TYPE, product.getId(),
SecurityFrameworkUtils.getLoginUserId(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PRODUCT_CREATOR.getCode());
// 7) 关心人批量落地watcherUserIds 可空;可与 members 的 user 重叠 —— 多角色合法)
objectRoleAutoAssignService.assignWatchers(ProductObjectConstants.OBJECT_TYPE, product.getId(),
reqVO.getWatcherUserIds(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PRODUCT_WATCHER.getCode());
// 8) 产品创建审计
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
buildProductFieldChanges(null, product), null);
return product.getId();
@@ -241,43 +263,173 @@ public class ProductServiceImpl implements ProductService {
ProductDO product = validateProductExists(id);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
UserObjectRoleDO currentMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, id, loginUserId);
ProductContextRespVO respVO = new ProductContextRespVO();
respVO.setCurrentProduct(buildCurrentProduct(product));
if (currentMember == null) {
respVO.setNavs(Collections.emptyList());
respVO.setButtons(Collections.emptyList());
return respVO;
// 多角色支持:拿全部 ACTIVE 角色 ID 传聚合 API主角色由 API 端按 sort 升序挑选 + 附 additionalRoleNames
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, id, loginUserId);
if (!userRoles.isEmpty()) {
List<Long> roleIds = userRoles.stream().map(UserObjectRoleDO::getRoleId).distinct().toList();
return buildProductContext(product, roleIds, false, null);
}
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
.getObjectRolePermissionDetail(currentMember.getRoleId(), ObjectRoleConstants.ROLE_SCOPE_OBJECT,
// 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段)
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
if (!scope.contains(id, product.getDirectionCode())) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, "查看");
}
return buildImplicitObserverContext(product);
}
/**
* 隐式 observer 兜底上下文:用户无显式产品角色但在 scope 范围内,按 implicit_observer_product 角色渲染菜单/权限。
*/
private ProductContextRespVO buildImplicitObserverContext(ProductDO product) {
ObjectRoleRespDTO observerRole = objectPermissionApi
.getObjectRoleByCode(
com.njcn.rdms.module.system.enums.permission.ObjectRoleConstants.IMPLICIT_OBSERVER_PRODUCT.getCode(),
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProductObjectConstants.OBJECT_TYPE)
.getCheckedData();
ObjectRoleRespDTO currentRole = permissionDetail == null ? null : permissionDetail.getCurrentRole();
if (observerRole == null || observerRole.getId() == null) {
// 角色未配置,降级为无菜单的 guest 上下文(不应发生,属配置缺失)
return buildProductContextWithoutMenus(product, true);
}
return buildProductContext(product, List.of(observerRole.getId()), true, observerRole);
}
private ProductContextRespVO buildProductContext(ProductDO product, List<Long> roleIds, boolean guestFlag,
ObjectRoleRespDTO fallbackRole) {
ProductContextRespVO respVO = new ProductContextRespVO();
respVO.setCurrentProduct(buildCurrentProduct(product));
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
.getObjectRolePermissionDetailMerged(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProductObjectConstants.OBJECT_TYPE)
.getCheckedData();
ObjectRoleRespDTO currentRole = permissionDetail == null || permissionDetail.getCurrentRole() == null
? fallbackRole
: permissionDetail.getCurrentRole();
List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
? Collections.emptyList()
: permissionDetail.getMenus();
respVO.setCurrentRole(buildCurrentRole(currentMember, currentRole));
List<String> additionalRoleNames = permissionDetail == null || permissionDetail.getAdditionalRoleNames() == null
? Collections.emptyList()
: permissionDetail.getAdditionalRoleNames();
Long primaryRoleId = currentRole != null ? currentRole.getId() : (roleIds.isEmpty() ? null : roleIds.get(0));
respVO.setCurrentRole(buildCurrentRole(primaryRoleId, currentRole, guestFlag, additionalRoleNames));
respVO.setNavs(buildContextNavs(menus));
respVO.setButtons(buildContextButtons(menus));
return respVO;
}
private ProductContextRespVO buildProductContextWithoutMenus(ProductDO product, boolean guestFlag) {
ProductContextRespVO respVO = new ProductContextRespVO();
respVO.setCurrentProduct(buildCurrentProduct(product));
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag, Collections.emptyList()));
respVO.setNavs(Collections.emptyList());
respVO.setButtons(Collections.emptyList());
return respVO;
}
@Override
public PageResult<ProductDO> getProductPage(ProductPageReqVO pageReqVO) {
return productMapper.selectPage(pageReqVO);
// 计算当前用户在 product 域的数据权限范围
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
if (scope.getState() == ObjectDataScope.State.EMPTY) {
return PageResult.empty();
}
// 保留原有业务过滤条件(同 ProductMapper.selectPage 默认方法)
LambdaQueryWrapperX<ProductDO> wrapper = new LambdaQueryWrapperX<>();
if (StringUtils.hasText(pageReqVO.getKeyword())) {
wrapper.and(w -> w.like(ProductDO::getCode, pageReqVO.getKeyword())
.or()
.like(ProductDO::getName, pageReqVO.getKeyword()));
}
wrapper.eqIfPresent(ProductDO::getDirectionCode, pageReqVO.getDirectionCode())
.eqIfPresent(ProductDO::getManagerUserId, pageReqVO.getManagerUserId())
.eqIfPresent(ProductDO::getStatusCode, pageReqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, pageReqVO.getUpdateTime())
.orderByDesc(BaseDO::getCreateTime);
// 注入 scope 数据权限过滤条件(在所有业务条件之后)
if (scope.getState() == ObjectDataScope.State.ID_LIST) {
Set<Long> ids = scope.getIds();
Set<String> dcs = scope.getDirectionCodes();
wrapper.and(w -> {
boolean addedAny = false;
if (!ids.isEmpty()) {
w.in(ProductDO::getId, ids);
addedAny = true;
}
if (!dcs.isEmpty()) {
if (addedAny) {
w.or();
}
w.in(ProductDO::getDirectionCode, dcs);
}
});
}
// ALL 状态不加任何 scope 条件,直接查全部
return productMapper.selectPage(pageReqVO, wrapper);
}
@Override
public ProductOverviewSummaryRespVO getProductOverviewSummary() {
// 与列表对称:按当前用户的 ObjectDataScope 过滤统计数字(超管 ALL 走全表 SQL普通用户走 scope 过滤)
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
List<Map<String, Object>> rows = buildStatusCountRows(scope);
ProductOverviewSummaryRespVO respVO = new ProductOverviewSummaryRespVO();
respVO.setStatusCounts(buildProductStatusCounts(objectStatusModelMapper
.selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), productMapper.selectStatusCountList()));
.selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), rows));
return respVO;
}
/**
* 按 scope 算出 (statusCode, countValue) 行集,喂给 {@link #buildProductStatusCounts}。
* EMPTY 直接空集ALL 走原全表 GROUP BY SQLID_LIST 用 wrapper 取 status_codeJava 端 group + count。
*/
private List<Map<String, Object>> buildStatusCountRows(ObjectDataScope scope) {
if (scope.getState() == ObjectDataScope.State.EMPTY) {
return Collections.emptyList();
}
if (scope.getState() == ObjectDataScope.State.ALL) {
return productMapper.selectStatusCountList();
}
// ID_LIST
LambdaQueryWrapperX<ProductDO> wrapper = new LambdaQueryWrapperX<>();
wrapper.select(ProductDO::getStatusCode);
Set<Long> ids = scope.getIds();
Set<String> dcs = scope.getDirectionCodes();
wrapper.and(w -> {
boolean addedAny = false;
if (!ids.isEmpty()) {
w.in(ProductDO::getId, ids);
addedAny = true;
}
if (!dcs.isEmpty()) {
if (addedAny) {
w.or();
}
w.in(ProductDO::getDirectionCode, dcs);
}
});
return productMapper.selectList(wrapper).stream()
.filter(p -> p.getStatusCode() != null)
.collect(Collectors.groupingBy(ProductDO::getStatusCode, Collectors.counting()))
.entrySet().stream()
.map(e -> {
Map<String, Object> row = new HashMap<>();
row.put("statusCode", e.getKey());
row.put("countValue", e.getValue());
return row;
})
.collect(Collectors.toList());
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#reqVO.id",
@@ -520,13 +672,16 @@ public class ProductServiceImpl implements ProductService {
return BeanUtils.toBean(product, ProductContextProductRespVO.class);
}
private ProductContextRoleRespVO buildCurrentRole(UserObjectRoleDO currentMember, ObjectRoleRespDTO currentRole) {
private ProductContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag,
List<String> additionalRoleNames) {
ProductContextRoleRespVO roleRespVO = new ProductContextRoleRespVO();
roleRespVO.setRoleId(currentMember.getRoleId());
roleRespVO.setRoleId(roleId);
roleRespVO.setGuestFlag(guestFlag);
if (currentRole != null) {
roleRespVO.setRoleCode(currentRole.getCode());
roleRespVO.setRoleName(currentRole.getName());
}
roleRespVO.setAdditionalRoleNames(additionalRoleNames == null ? Collections.emptyList() : additionalRoleNames);
return roleRespVO;
}

View File

@@ -31,7 +31,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -69,24 +71,78 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId);
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
return members.stream().map(member -> {
ProjectMemberRespVO respVO = new ProjectMemberRespVO();
respVO.setId(member.getId());
respVO.setUserId(member.getUserId());
AdminUserRespDTO user = userMap.get(member.getUserId());
respVO.setUserNickname(user == null ? null : user.getNickname());
respVO.setRoleId(member.getRoleId());
ObjectRoleRespDTO role = roleMap.get(member.getRoleId());
respVO.setRoleName(role == null ? null : role.getName());
respVO.setRoleCode(role == null ? null : role.getCode());
respVO.setManagerFlag(Objects.equals(member.getUserId(), project.getManagerUserId())
&& Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE));
respVO.setStatus(member.getStatus());
respVO.setJoinedTime(member.getJoinedTime());
respVO.setLeftTime(member.getLeftTime());
respVO.setRemark(member.getRemark());
return respVO;
}).collect(Collectors.toList());
// 拆分 ACTIVE / INACTIVE
// - ACTIVE 行按 userId 聚合同人多角色合并成一行manager 优先做主),非主角色名放 additionalRoleNames
// - INACTIVE 行保持独立(历史角色行各自留痕,便于审计)
List<UserObjectRoleDO> activeRows = new ArrayList<>();
List<UserObjectRoleDO> inactiveRows = new ArrayList<>();
for (UserObjectRoleDO m : members) {
if (Objects.equals(m.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
activeRows.add(m);
} else {
inactiveRows.add(m);
}
}
Map<Long, List<UserObjectRoleDO>> activeByUser = activeRows.stream()
.collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList()));
List<ProjectMemberRespVO> result = new ArrayList<>();
activeByUser.forEach((userId, rows) -> {
UserObjectRoleDO primary = pickPrimaryRow(rows, roleMap);
List<String> additionalRoleNames = rows.stream()
.filter(r -> !Objects.equals(r.getId(), primary.getId()))
.map(r -> {
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
return role == null ? null : role.getName();
})
.filter(Objects::nonNull)
.sorted()
.collect(Collectors.toList());
result.add(toRespVO(primary, roleMap, userMap, project, additionalRoleNames));
});
// INACTIVE 行各自独立成行
inactiveRows.forEach(m -> result.add(toRespVO(m, roleMap, userMap, project, Collections.emptyList())));
return result;
}
/**
* 同 userId 多角色时选主角色行MANAGER_ROLE_CODE 优先,否则按 roleId 升序兜底。
*/
private UserObjectRoleDO pickPrimaryRow(List<UserObjectRoleDO> rows, Map<Long, ObjectRoleRespDTO> roleMap) {
return rows.stream()
.filter(r -> {
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
return role != null && Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, role.getCode());
})
.findFirst()
.orElseGet(() -> rows.stream()
.min(Comparator.comparing(UserObjectRoleDO::getRoleId))
.orElseThrow());
}
private ProjectMemberRespVO toRespVO(UserObjectRoleDO member,
Map<Long, ObjectRoleRespDTO> roleMap,
Map<Long, AdminUserRespDTO> userMap,
ProjectDO project,
List<String> additionalRoleNames) {
ProjectMemberRespVO respVO = new ProjectMemberRespVO();
respVO.setId(member.getId());
respVO.setUserId(member.getUserId());
AdminUserRespDTO user = userMap.get(member.getUserId());
respVO.setUserNickname(user == null ? null : user.getNickname());
respVO.setRoleId(member.getRoleId());
ObjectRoleRespDTO role = roleMap.get(member.getRoleId());
respVO.setRoleName(role == null ? null : role.getName());
respVO.setRoleCode(role == null ? null : role.getCode());
respVO.setManagerFlag(Objects.equals(member.getUserId(), project.getManagerUserId())
&& Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE));
respVO.setStatus(member.getStatus());
respVO.setJoinedTime(member.getJoinedTime());
respVO.setLeftTime(member.getLeftTime());
respVO.setRemark(member.getRemark());
respVO.setAdditionalRoleNames(additionalRoleNames);
return respVO;
}
@Override
@@ -97,8 +153,10 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
ProjectDO project = validateProjectEditable(projectId);
validateMemberUser(reqVO.getUserId());
ObjectRoleRespDTO targetRole = validateProjectRole(reqVO.getRoleId());
// 多角色支持:按 (user, object, role) 三元组判存在;不带 status 过滤以便 INACTIVE 行复活(避免唯一索引 INSERT 冲突)
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, reqVO.getUserId());
.selectByObjectUserAndRole(ProjectObjectConstants.OBJECT_TYPE, projectId,
reqVO.getUserId(), targetRole.getId());
if (existingMember != null && Objects.equals(existingMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
throw exception(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS);
}
@@ -113,12 +171,16 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
member.setJoinedTime(LocalDateTime.now());
member.setLeftTime(null);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
// 物理 INSERT vs INACTIVE 复活的 audit 语义区分null=新增INACTIVE 复活=REACTIVATE
String actionType;
if (existingMember == null) {
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
userObjectRoleMapper.updateById(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_REACTIVATE;
}
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, before, member, null);
writeMemberAuditLog(member, actionType, before, member, null);
if (isManagerRole(targetRole)) {
transferManager(project, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
@@ -140,6 +202,16 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
UserObjectRoleDO before = cloneMember(member);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
// 多角色边界:避免 setRoleId 撞唯一索引——若 user 在该 project 已有 (targetRole) 的另一行(含 INACTIVE 历史行),
// 直接 update 会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突。业务侧明确报错,避免 SQL 异常透出。
if (!Objects.equals(member.getRoleId(), targetRole.getId())) {
UserObjectRoleDO conflict = userObjectRoleMapper.selectByObjectUserAndRole(
ProjectObjectConstants.OBJECT_TYPE, projectId, member.getUserId(), targetRole.getId());
if (conflict != null && !Objects.equals(conflict.getId(), member.getId())) {
throw exception(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS);
}
}
if (isManagerRole(targetRole)) {
// 项目经理交接只切换负责人并调整原经理角色,不再把原经理自动移出项目团队。
member.setRoleId(targetRole.getId());
@@ -302,13 +374,26 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
}
private void transferPreviousManager(Long projectId, Long previousManagerUserId, Long previousManagerRoleId, String reason) {
// 多角色边界校验:若 user 在 (project, previousManagerRoleId) 已有任意行ACTIVE 或 INACTIVE 历史行),
// 后续 update / INSERT 都会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突 ——
// 一刀切抛业务异常让用户先去人工清理历史行。selectByObjectUserAndRole 不带 status 过滤。
UserObjectRoleDO targetRoleExisting = userObjectRoleMapper.selectByObjectUserAndRole(
ProjectObjectConstants.OBJECT_TYPE, projectId, previousManagerUserId, previousManagerRoleId);
if (targetRoleExisting != null) {
throw exception(ErrorCodeConstants.PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE, previousManagerRoleId);
}
// 多角色支持:按 (user, object, manager_role_id) 三元组定位 manager 行user 的 creator/dev 等其他角色行不动
Long projectManagerRoleId = resolveProjectManagerRoleId();
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, previousManagerUserId);
.selectActiveByObjectUserAndRole(ProjectObjectConstants.OBJECT_TYPE, projectId,
previousManagerUserId, projectManagerRoleId);
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
LocalDateTime now = LocalDateTime.now();
UserObjectRoleDO member;
String actionType;
if (existingMember == null) {
// user 当前没有 manager 角色 ACTIVE 行(罕见,可能业务上不该走到这)—— 仍兼容老逻辑:插入 previousManagerRoleId 行
member = new UserObjectRoleDO();
member.setUserId(previousManagerUserId);
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
@@ -321,21 +406,30 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
// existingMember 是 manager 行 ACTIVEupdate 改 role_id 成 previousManagerRoleId"降级"该行)
member = existingMember;
member.setRoleId(previousManagerRoleId);
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
member.setLeftTime(null);
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
member.setJoinedTime(now);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
}
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
userObjectRoleMapper.updateById(member);
}
writeMemberAuditLog(member, actionType, before, member, reason);
}
private Long resolveProjectManagerRoleId() {
ObjectRoleRespDTO role = objectPermissionApi
.getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE,
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
if (role == null || role.getId() == null) {
throw exception(ErrorCodeConstants.PROJECT_INTERNAL_ROLE_NOT_CONFIGURED,
ProjectObjectConstants.MANAGER_ROLE_CODE);
}
return role.getId();
}
private boolean isManagerRole(ObjectRoleRespDTO role) {
return Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, role.getCode());
}

View File

@@ -39,6 +39,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.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO;
@@ -59,6 +63,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -102,6 +107,10 @@ class ProjectServiceImpl implements ProjectService {
private DictDataApi dictDataApi;
@Resource
private ProjectRequirementModuleMapper projectRequirementModuleMapper;
@Resource
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -132,6 +141,10 @@ class ProjectServiceImpl implements ProjectService {
projectMapper.insert(project);
initManagerMemberRelation(project);
// 多角色支持:自动落 creator 行(即使 creator == manager 也独立写一条,确保创建者身份不丢)
objectRoleAutoAssignService.assignCreator(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
SecurityFrameworkUtils.getLoginUserId(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_CREATOR.getCode());
initDefaultRequirementModule(project);
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
buildProjectFieldChanges(null, project), null);
@@ -195,7 +208,17 @@ class ProjectServiceImpl implements ProjectService {
// 5) 项目维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
writeManagerChangeAuditLog(project.getId(), null, project.getManagerUserId(), null);
// 6) 项目创建审计
// 6) 多角色支持:自动落 creator 行(不论 creator 是否在 members 列表里,都独立写一条 creator 角色)
objectRoleAutoAssignService.assignCreator(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
SecurityFrameworkUtils.getLoginUserId(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_CREATOR.getCode());
// 7) 关心人批量落地watcherUserIds 可空;可与 members 的 user 重叠 —— 多角色合法)
objectRoleAutoAssignService.assignWatchers(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
reqVO.getWatcherUserIds(),
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_WATCHER.getCode());
// 8) 项目创建审计
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
buildProjectFieldChanges(null, project), null);
return project.getId();
@@ -351,29 +374,46 @@ class ProjectServiceImpl implements ProjectService {
public ProjectContextRespVO getProjectContext(Long id) {
ProjectDO project = validateProjectExists(id);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
UserObjectRoleDO currentMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId);
if (currentMember != null) {
return buildProjectContext(project, currentMember.getRoleId(), false, null);
// 多角色支持:拿全部 ACTIVE 角色 ID 传聚合 API主角色按 sort 升序由 API 挑选 + 附 additionalRoleNames
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId);
if (!userRoles.isEmpty()) {
List<Long> roleIds = userRoles.stream().map(UserObjectRoleDO::getRoleId).distinct().toList();
return buildProjectContext(project, roleIds, false, null);
}
ObjectRoleRespDTO visitorRole = objectPermissionApi
.getObjectRoleByCode(ProjectObjectConstants.VISITOR_ROLE_CODE, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
if (visitorRole == null || visitorRole.getId() == null) {
return buildProjectContextWithoutMenus(project, true);
// 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段)
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
if (!scope.contains(id, project.getDirectionCode())) {
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, "查看");
}
return buildProjectContext(project, visitorRole.getId(), true, visitorRole);
return buildImplicitObserverContext(project);
}
private ProjectContextRespVO buildProjectContext(ProjectDO project, Long roleId, boolean guestFlag,
/**
* 隐式 observer 兜底上下文:用户无显式项目角色但在 scope 范围内,按 implicit_observer_project 角色渲染菜单/权限。
*/
private ProjectContextRespVO buildImplicitObserverContext(ProjectDO project) {
ObjectRoleRespDTO observerRole = objectPermissionApi
.getObjectRoleByCode(
com.njcn.rdms.module.system.enums.permission.ObjectRoleConstants.IMPLICIT_OBSERVER_PROJECT.getCode(),
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
if (observerRole == null || observerRole.getId() == null) {
// 角色未配置,降级为无菜单的 guest 上下文(不应发生,属配置缺失)
return buildProjectContextWithoutMenus(project, true);
}
return buildProjectContext(project, List.of(observerRole.getId()), true, observerRole);
}
private ProjectContextRespVO buildProjectContext(ProjectDO project, List<Long> roleIds, boolean guestFlag,
ObjectRoleRespDTO fallbackRole) {
ProjectContextRespVO respVO = new ProjectContextRespVO();
respVO.setCurrentProject(buildCurrentProject(project));
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
.getObjectRolePermissionDetail(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
.getObjectRolePermissionDetailMerged(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
ObjectRoleRespDTO currentRole = permissionDetail == null || permissionDetail.getCurrentRole() == null
@@ -382,7 +422,11 @@ class ProjectServiceImpl implements ProjectService {
List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
? Collections.emptyList()
: permissionDetail.getMenus();
respVO.setCurrentRole(buildCurrentRole(roleId, currentRole, guestFlag));
List<String> additionalRoleNames = permissionDetail == null || permissionDetail.getAdditionalRoleNames() == null
? Collections.emptyList()
: permissionDetail.getAdditionalRoleNames();
Long primaryRoleId = currentRole != null ? currentRole.getId() : (roleIds.isEmpty() ? null : roleIds.get(0));
respVO.setCurrentRole(buildCurrentRole(primaryRoleId, currentRole, guestFlag, additionalRoleNames));
respVO.setNavs(buildContextNavs(menus));
respVO.setButtons(buildContextButtons(menus));
return respVO;
@@ -390,17 +434,106 @@ class ProjectServiceImpl implements ProjectService {
@Override
public PageResult<ProjectDO> getProjectPage(ProjectPageReqVO pageReqVO) {
return projectMapper.selectPage(pageReqVO);
// 计算当前用户在 project 域的数据权限范围
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
if (scope.getState() == ObjectDataScope.State.EMPTY) {
return PageResult.empty();
}
// 保留原有业务过滤条件(同 ProjectMapper.selectPage 默认方法)
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
if (StringUtils.hasText(pageReqVO.getKeyword())) {
wrapper.and(w -> w.like(ProjectDO::getProjectCode, pageReqVO.getKeyword())
.or()
.like(ProjectDO::getProjectName, pageReqVO.getKeyword()));
}
wrapper.eqIfPresent(ProjectDO::getProjectType, pageReqVO.getProjectType())
.eqIfPresent(ProjectDO::getDirectionCode, pageReqVO.getDirectionCode())
.eqIfPresent(ProjectDO::getProductId, pageReqVO.getProductId())
.eqIfPresent(ProjectDO::getManagerUserId, pageReqVO.getManagerUserId())
.eqIfPresent(ProjectDO::getStatusCode, pageReqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, pageReqVO.getUpdateTime())
.orderByDesc(BaseDO::getCreateTime);
// 注入 scope 数据权限过滤条件(在所有业务条件之后)
if (scope.getState() == ObjectDataScope.State.ID_LIST) {
Set<Long> ids = scope.getIds();
Set<String> dcs = scope.getDirectionCodes();
wrapper.and(w -> {
boolean addedAny = false;
if (!ids.isEmpty()) {
w.in(ProjectDO::getId, ids);
addedAny = true;
}
if (!dcs.isEmpty()) {
if (addedAny) {
w.or();
}
w.in(ProjectDO::getDirectionCode, dcs);
}
});
}
// ALL 状态不加任何 scope 条件,直接查全部
return projectMapper.selectPage(pageReqVO, wrapper);
}
@Override
public ProjectOverviewSummaryRespVO getProjectOverviewSummary() {
// 与列表对称:按当前用户的 ObjectDataScope 过滤统计数字(超管 ALL 走全表 SQL普通用户走 scope 过滤)
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
List<Map<String, Object>> rows = buildStatusCountRows(scope);
ProjectOverviewSummaryRespVO respVO = new ProjectOverviewSummaryRespVO();
respVO.setStatusCounts(buildProjectStatusCounts(objectStatusModelMapper
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), projectMapper.selectStatusCountList()));
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), rows));
return respVO;
}
/**
* 按 scope 算出 (statusCode, countValue) 行集,喂给 {@link #buildProjectStatusCounts}。
* EMPTY 直接空集ALL 走原全表 GROUP BY SQLID_LIST 用 wrapper 取 status_codeJava 端 group + count。
*/
private List<Map<String, Object>> buildStatusCountRows(ObjectDataScope scope) {
if (scope.getState() == ObjectDataScope.State.EMPTY) {
return Collections.emptyList();
}
if (scope.getState() == ObjectDataScope.State.ALL) {
return projectMapper.selectStatusCountList();
}
// ID_LIST
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
wrapper.select(ProjectDO::getStatusCode);
Set<Long> ids = scope.getIds();
Set<String> dcs = scope.getDirectionCodes();
wrapper.and(w -> {
boolean addedAny = false;
if (!ids.isEmpty()) {
w.in(ProjectDO::getId, ids);
addedAny = true;
}
if (!dcs.isEmpty()) {
if (addedAny) {
w.or();
}
w.in(ProjectDO::getDirectionCode, dcs);
}
});
return projectMapper.selectList(wrapper).stream()
.filter(p -> p.getStatusCode() != null)
.collect(Collectors.groupingBy(ProjectDO::getStatusCode, Collectors.counting()))
.entrySet().stream()
.map(e -> {
Map<String, Object> row = new HashMap<>();
row.put("statusCode", e.getKey());
row.put("countValue", e.getValue());
return row;
})
.collect(Collectors.toList());
}
private String getProductName(Long productId) {
if (productId == null) {
return null;
@@ -771,9 +904,11 @@ class ProjectServiceImpl implements ProjectService {
if (oldManagerUserId == null) {
return;
}
UserObjectRoleDO oldMember = userObjectRoleMapper.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE,
projectId, oldManagerUserId);
if (oldMember == null || !Objects.equals(oldMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
// 多角色支持:只 INACTIVATE manager 角色那一行user 在项目内的 creator/dev 等其他角色行不动
Long managerRoleId = resolveProjectManagerRoleId();
UserObjectRoleDO oldMember = userObjectRoleMapper.selectActiveByObjectUserAndRole(
ProjectObjectConstants.OBJECT_TYPE, projectId, oldManagerUserId, managerRoleId);
if (oldMember == null) {
return;
}
UserObjectRoleDO before = cloneMember(oldMember);
@@ -784,10 +919,13 @@ class ProjectServiceImpl implements ProjectService {
}
private void ensureManagerRelation(Long projectId, Long managerUserId, Long managerRoleId, String reason) {
UserObjectRoleDO existingMember = userObjectRoleMapper.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE,
projectId, managerUserId);
// 多角色支持:按 (user, object, manager_role_id) 三元组定位 manager 行;
// 用 selectByObjectUserAndRole不带 status 过滤)拿 INACTIVE 老行复活,避免 INSERT 冲突唯一索引
UserObjectRoleDO existingMember = userObjectRoleMapper.selectByObjectUserAndRole(
ProjectObjectConstants.OBJECT_TYPE, projectId, managerUserId, managerRoleId);
LocalDateTime now = LocalDateTime.now();
if (existingMember == null) {
// user 在项目内还没有 manager 角色行(可能已有 creator/dev 等其他角色,不影响)→ insert
UserObjectRoleDO member = new UserObjectRoleDO();
member.setUserId(managerUserId);
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
@@ -800,8 +938,8 @@ class ProjectServiceImpl implements ProjectService {
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, reason);
return;
}
// existingMember 已是 (user, object, manager_role_id) 行(可能 ACTIVE 或 INACTIVE→ 激活
UserObjectRoleDO before = cloneMember(existingMember);
existingMember.setRoleId(managerRoleId);
existingMember.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
existingMember.setLeftTime(null);
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
@@ -811,6 +949,18 @@ class ProjectServiceImpl implements ProjectService {
writeMemberAuditLog(existingMember, ObjectActivityConstants.MEMBER_ACTION_UPDATE, before, existingMember, reason);
}
private Long resolveProjectManagerRoleId() {
ObjectRoleRespDTO role = objectPermissionApi
.getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE,
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
if (role == null || role.getId() == null) {
throw new IllegalStateException("内置角色 " + ProjectObjectConstants.MANAGER_ROLE_CODE + " 未在 system_role 找到");
}
return role.getId();
}
private void changeStatus(ProjectDO project, String actionCode, String reason) {
String fromStatus = project.getStatusCode();
ObjectStatusTransitionDO transition = validateProjectTransition(fromStatus, actionCode, reason);
@@ -856,13 +1006,14 @@ class ProjectServiceImpl implements ProjectService {
private ProjectContextRespVO buildProjectContextWithoutMenus(ProjectDO project, boolean guestFlag) {
ProjectContextRespVO respVO = new ProjectContextRespVO();
respVO.setCurrentProject(buildCurrentProject(project));
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag));
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag, Collections.emptyList()));
respVO.setNavs(Collections.emptyList());
respVO.setButtons(Collections.emptyList());
return respVO;
}
private ProjectContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag) {
private ProjectContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag,
List<String> additionalRoleNames) {
ProjectContextRoleRespVO roleRespVO = new ProjectContextRoleRespVO();
roleRespVO.setRoleId(roleId);
roleRespVO.setGuestFlag(guestFlag);
@@ -870,6 +1021,7 @@ class ProjectServiceImpl implements ProjectService {
roleRespVO.setRoleCode(currentRole.getCode());
roleRespVO.setRoleName(currentRole.getName());
}
roleRespVO.setAdditionalRoleNames(additionalRoleNames == null ? Collections.emptyList() : additionalRoleNames);
return roleRespVO;
}

View File

@@ -2,6 +2,8 @@ package com.njcn.rdms.module.project.service.project;
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;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
@@ -11,4 +13,12 @@ public interface ProjectStatusBoardService {
ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO);
/**
* 看板视图任务分页:一次请求返回若干状态列,每列附带当前页切片 + 该列总数。
* <p>statusCode 缺省=按状态字典返回全部列空列也回list=[]、total=0传若干个=只返回这些状态的列,
* 字典外的值静默忽略。pageNo / pageSize 应用到返回的所有列(每列各自分页但页码统一)。
* <p>list 元素结构与 /tasks/page 完全一致(共享 {@code assembleTaskRespVOPage} 装配方法)。
*/
ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO);
}

View File

@@ -1,24 +1,36 @@
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.ProjectTaskConstants;
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;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
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 jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService {
@@ -31,6 +43,8 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
private ProjectTaskMapper projectTaskMapper;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Resource
private ProjectTaskService projectTaskService;
@Override
public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) {
@@ -42,19 +56,97 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
@Override
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
// 执行 owner = 当前用户 → 看本执行下全部任务,等价于 seesAll。
if (!scope.seesAll()) {
ProjectExecutionDO exec = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
if (exec != null && Objects.equals(exec.getOwnerId(), userId)) {
scope = VisibilityScope.all();
}
}
VisibilityScope scope = resolveTaskScope(projectId, executionId);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return buildTaskStatusBoard(projectId, executionId, scope, reqVO, statusModels);
}
@Override
public ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO) {
VisibilityScope scope = resolveTaskScope(projectId, executionId);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
// 列选择:入参为空 → 全集;非空 → 与字典做交集(字典外 statusCode 静默忽略)
Set<String> selected = collectSelectedStatusCodes(reqVO.getStatusCode());
List<ObjectStatusModelDO> targetStatusModels = selected.isEmpty()
? statusModels
: statusModels.stream()
.filter(model -> selected.contains(model.getStatusCode()))
.collect(Collectors.toList());
ProjectTaskBoardPageRespVO respVO = new ProjectTaskBoardPageRespVO();
List<ProjectTaskBoardPageRespVO.ColumnItemVO> items = targetStatusModels.stream()
.map(statusModel -> buildBoardColumn(projectId, executionId, scope, reqVO, statusModel))
.collect(Collectors.toList());
respVO.setItems(items);
return respVO;
}
/**
* 把入参 statusCode 数组归一化成一个去重 Setnull / 空 / 全 blank 都视为"不选列 = 全集"。
*/
private Set<String> collectSelectedStatusCodes(String[] statusCodes) {
if (statusCodes == null || statusCodes.length == 0) {
return Collections.emptySet();
}
return Arrays.stream(statusCodes)
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toCollection(LinkedHashSet::new));
}
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<ProjectTaskRespVO> voPage = projectTaskService.assembleTaskRespVOPage(projectId, executionId, doPage);
ProjectTaskBoardPageRespVO.ColumnItemVO item = new ProjectTaskBoardPageRespVO.ColumnItemVO();
item.setStatusCode(statusModel.getStatusCode());
item.setStatusName(statusModel.getStatusName());
item.setSort(statusModel.getSort());
item.setTerminal(statusModel.getTerminalFlag());
item.setList(voPage.getList() == null ? Collections.emptyList() : voPage.getList());
item.setTotal(voPage.getTotal() == null ? 0L : voPage.getTotal());
return item;
}
/**
* 把看板分页入参翻译成单状态列的 /tasks/page 入参,复用现有 mapper 与装配逻辑。
*/
private ProjectTaskPageReqVO toInnerPageReq(ProjectTaskBoardPageReqVO reqVO, String statusCode) {
ProjectTaskPageReqVO innerReq = new ProjectTaskPageReqVO();
innerReq.setPageNo(reqVO.getPageNo());
innerReq.setPageSize(reqVO.getPageSize());
innerReq.setKeyword(reqVO.getKeyword());
innerReq.setParentTaskId(reqVO.getParentTaskId());
innerReq.setOwnerId(reqVO.getOwnerId());
innerReq.setUpdateTime(reqVO.getUpdateTime());
innerReq.setStatusCode(statusCode);
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,

View File

@@ -207,9 +207,10 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
@VisibleForTesting
void validateProjectMember(Long projectId, Long userId) {
UserObjectRoleDO projectMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
if (projectMember == null) {
// 多角色支持user 在项目内有任意 ACTIVE 角色即可作为 assignee
if (userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId)
.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
}
}

View File

@@ -211,7 +211,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
respVO.setProgressRate(loadExecutionProgress(projectId, executionId));
applyLifecycle(respVO);
boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId);
applyLifecycle(respVO, rootTasksAllCompleted);
respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
return respVO;
}
@@ -225,6 +226,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return voPageResult;
}
fillExecutionProgress(projectId, list);
// 批量预聚合根任务完成态,避免列表 N+1。未命中执行 → 缺省 falsecomplete 按钮不下发。
Map<Long, Boolean> rootTasksAllCompletedMap = loadExecutionRootTasksAllCompletedMap(projectId, list);
// 批量补负责人昵称,避免 N+1
Set<Long> ownerIds = list.stream()
.map(ProjectExecutionRespVO::getOwnerId)
@@ -236,7 +239,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions与详情同款装配 lifecycle。
// 单行装配失败做兜底降级status_model 缺失等脏数据),避免影响整页返回。
try {
applyLifecycle(vo);
applyLifecycle(vo, rootTasksAllCompletedMap.getOrDefault(vo.getId(), false));
} catch (Exception e) {
log.warn("execution lifecycle apply failed in page assembly. executionId={}, statusCode={}, error={}",
vo.getId(), vo.getStatusCode(), e.getMessage());
@@ -392,9 +395,10 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@VisibleForTesting
void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) {
UserObjectRoleDO member = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, ownerId);
if (member == null) {
// 多角色支持user 在项目内有任意 ACTIVE 角色即视为项目成员
if (userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, ownerId)
.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_INVALID);
}
}
@@ -489,9 +493,10 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
private void validateExecutionAssigneeProjectScope(Long projectId, Long userId) {
UserObjectRoleDO member = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
if (member == null) {
// 多角色支持user 在项目内有任意 ACTIVE 角色即可作为 assignee
if (userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId)
.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
}
}
@@ -670,10 +675,12 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return StringUtils.hasText(value) ? value : "";
}
private void applyLifecycle(ProjectExecutionRespVO respVO) {
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤spec §7.1
private void applyLifecycle(ProjectExecutionRespVO respVO, boolean rootTasksAllCompleted) {
// 传入 ownerId / progressRate / rootTasksAllCompleted 用于 availableActions 的 owner-only、完成进度、
// 根任务完成态过滤。rootTasksAllCompleted=false 时不下发 complete避免任务仍进行中时执行被闭环。
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle =
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId(),
respVO.getProgressRate(), rootTasksAllCompleted);
respVO.setStatusName(lifecycle.statusName());
respVO.setTerminal(lifecycle.terminal());
respVO.setAllowEdit(lifecycle.allowEdit());
@@ -690,7 +697,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
private BigDecimal loadExecutionProgress(Long projectId, Long executionId) {
return normalizeProgress(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId));
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
return normalizeProgress(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId,
excludedStatusCodes));
}
private void fillExecutionProgress(Long projectId, List<ProjectExecutionRespVO> list) {
@@ -698,7 +707,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
.map(ProjectExecutionRespVO::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, BigDecimal> progressMap = loadExecutionProgressMap(projectId, executionIds);
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
Map<Long, BigDecimal> progressMap = loadExecutionProgressMap(projectId, executionIds, excludedStatusCodes);
list.forEach(vo -> vo.setProgressRate(progressMap.getOrDefault(vo.getId(), normalizeProgress(null))));
}
@@ -706,12 +716,13 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
* 列表口径批量聚合:一次 GROUP BY 取本页所有执行的一级任务平均进度。
* 未命中的 executionId执行下无一级任务不入 map由调用方 normalizeProgress 兜底为 0.00。
*/
private Map<Long, BigDecimal> loadExecutionProgressMap(Long projectId, Collection<Long> executionIds) {
private Map<Long, BigDecimal> loadExecutionProgressMap(Long projectId, Collection<Long> executionIds,
Collection<String> excludedStatusCodes) {
if (executionIds == null || executionIds.isEmpty()) {
return Collections.emptyMap();
}
List<Map<String, Object>> rows = projectTaskMapper
.selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds);
.selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds, excludedStatusCodes);
if (rows == null || rows.isEmpty()) {
return Collections.emptyMap();
}
@@ -729,6 +740,65 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return result;
}
private List<String> loadProgressExcludedTaskStatusCodes() {
List<String> statusCodes = objectStatusModelMapper
.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return statusCodes == null ? Collections.emptyList() : statusCodes;
}
/**
* 执行详情完成态:判断"参与聚合的根任务"是否全部为 completed。
* 筛选口径与 loadExecutionProgress 同源;空集(无参与聚合的根任务)返回 false禁止下发 complete。
*/
private boolean loadExecutionRootTasksAllCompleted(Long projectId, Long executionId) {
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
Map<String, Object> row = projectTaskMapper.selectRootTaskCompletionStateByExecutionId(projectId, executionId,
ProjectTaskConstants.STATUS_COMPLETED, excludedStatusCodes);
return isRootTasksAllCompleted(row);
}
/**
* 列表口径批量聚合:一次 GROUP BY 取本页所有执行的根任务完成态。
* 未命中的 executionId执行下无参与聚合的根任务不入 map由调用方按缺省 false 处理(不下发 complete
*/
private Map<Long, Boolean> loadExecutionRootTasksAllCompletedMap(Long projectId,
List<ProjectExecutionRespVO> list) {
Set<Long> executionIds = list.stream()
.map(ProjectExecutionRespVO::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (executionIds.isEmpty()) {
return Collections.emptyMap();
}
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
List<Map<String, Object>> rows = projectTaskMapper.selectRootTaskCompletionStateGroupByExecutionIds(
projectId, executionIds, ProjectTaskConstants.STATUS_COMPLETED, excludedStatusCodes);
if (rows == null || rows.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, Boolean> result = new HashMap<>(rows.size());
for (Map<String, Object> row : rows) {
if (row == null) {
continue;
}
Long executionId = toLong(row.getOrDefault("executionId", row.get("execution_id")));
if (executionId == null) {
continue;
}
result.put(executionId, isRootTasksAllCompleted(row));
}
return result;
}
private boolean isRootTasksAllCompleted(Map<String, Object> row) {
if (row == null) {
return false;
}
Long totals = toLong(row.get("totals"));
Long completedCount = toLong(row.getOrDefault("completedCount", row.get("completed_count")));
return totals != null && totals > 0 && completedCount != null && totals.equals(completedCount);
}
private Long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();

View File

@@ -12,6 +12,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -27,6 +28,10 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
* <li>剔除系统级动作 {@code auto_start}(不出现在 UI 按钮)。</li>
* <li>对 owner-only 状态推进动作complete / cancel / pause / resume按 {@code execution.ownerId == currentUserId} 字段硬卡过滤,
* 与 {@link ProjectExecutionServiceImpl} 中 {@code validateOwnerForAction} 同款判定。</li>
* <li>对 {@code complete} 动作叠加进度过滤,执行进度未达到 100 时不下发完成按钮。</li>
* <li>对 {@code complete} 动作再叠加根任务完成态过滤:要求该执行下"参与聚合的根任务"全部为 completed
* 才下发筛选口径与进度聚合同源execution_id + parent_task_id IS NULL + excludedStatusCodes
* 空集(无参与聚合的根任务)视为"未全部完成",不下发完成按钮,避免任务仍在进行中时执行被闭环。</li>
* </ol>
* 非状态动作delete / change-owner / update / assignee的权限码 / 字段过滤未纳入本字段,
* 前端按各动作对应权限码与 owner 字段独立判断spec §6.5 允许条件矩阵);
@@ -39,13 +44,16 @@ public class ProjectExecutionStatusViewService {
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectExecutionServiceImpl#validateOwnerForAction 一致。
*/
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
private static final String ACTION_COMPLETE = "complete";
private static final BigDecimal COMPLETE_PROGRESS_THRESHOLD = BigDecimal.valueOf(100);
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId) {
public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId, BigDecimal progressRate,
boolean rootTasksAllCompleted) {
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
if (statusModel == null) {
@@ -55,11 +63,13 @@ public class ProjectExecutionStatusViewService {
statusModel.getStatusName(),
statusModel.getTerminalFlag(),
statusModel.getAllowEdit(),
buildAvailableActions(statusCode, ownerId)
buildAvailableActions(statusCode, ownerId, progressRate, rootTasksAllCompleted)
);
}
private List<ProjectExecutionLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId) {
private List<ProjectExecutionLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId,
BigDecimal progressRate,
boolean rootTasksAllCompleted) {
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
.selectListByObjectTypeAndFromStatus(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
if (transitions == null || transitions.isEmpty()) {
@@ -72,6 +82,10 @@ public class ProjectExecutionStatusViewService {
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
// 完成动作额外要求:执行进度已达 100且参与聚合的根任务全部已完成避免任务仍进行中时执行被闭环
// 暂停、恢复、取消不受进度 / 根任务状态影响。
.filter(transition -> !ACTION_COMPLETE.equals(transition.getActionCode())
|| (isCompleteProgressSatisfied(progressRate) && rootTasksAllCompleted))
.map(transition -> {
ProjectExecutionLifecycleActionRespVO action = new ProjectExecutionLifecycleActionRespVO();
action.setActionCode(transition.getActionCode());
@@ -82,6 +96,10 @@ public class ProjectExecutionStatusViewService {
.toList();
}
private boolean isCompleteProgressSatisfied(BigDecimal progressRate) {
return progressRate != null && progressRate.compareTo(COMPLETE_PROGRESS_THRESHOLD) >= 0;
}
public record ProjectExecutionLifecycleView(String statusName,
Boolean terminal,
Boolean allowEdit,

View File

@@ -31,6 +31,14 @@ public interface ProjectTaskService {
*/
PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO);
/**
* 把任务 DO 分页结果整体装配成 RespVO 分页结果。
* <p>提供给"看板分页接口"复用同款装配口径,保证两个接口列元素结构 / 序列化完全一致;
* 看板分页内部按状态列循环时,应调用本方法装配每列,不要自行重复装配。
*/
PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId,
PageResult<ProjectTaskDO> doPage);
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
/**

View File

@@ -402,6 +402,17 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override
public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO);
return assembleTaskRespVOPage(projectId, executionId, pageResult);
}
/**
* 把 ProjectTaskDO 分页结果整体装配成 RespVO 分页结果(含 ownerNickname / assignees / 工时合计 / 父任务 owner / 执行 owner / 生命周期)。
* <p>提供给 /tasks/page 与 /tasks/board-page 共用,保证两个接口的列元素结构与序列化口径完全一致;
* /tasks/board-page 不应自行重复装配,避免字段演进时漂移。
*/
@Override
public PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId,
PageResult<ProjectTaskDO> pageResult) {
PageResult<ProjectTaskRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectTaskRespVO.class);
List<ProjectTaskRespVO> list = voPageResult.getList();
if (list == null || list.isEmpty()) {
@@ -708,6 +719,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算
if ("complete".equals(actionCode)) {
forceCompleteProgress(task);
} else if (task.getParentTaskId() != null) {
recalcParentProgressFrom(task.getParentTaskId());
}
// 取消 / 暂停 / 恢复:触发子任务级联(每个子任务的内部链路自身会再级联自己的子,链式实现整棵子树)
@@ -1024,21 +1037,22 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
*/
private void recalcParentProgressFrom(Long parentTaskId) {
Long current = parentTaskId;
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
while (current != null) {
ProjectTaskDO parent = projectTaskMapper.selectById(current);
if (parent == null) {
return;
}
List<ProjectTaskDO> children = projectTaskMapper.selectChildrenProgressByParentTaskId(current);
if (children.isEmpty()) {
return;
}
List<ProjectTaskDO> children = projectTaskMapper.selectChildrenProgressByParentTaskId(current,
excludedStatusCodes);
BigDecimal sum = BigDecimal.ZERO;
for (ProjectTaskDO child : children) {
BigDecimal cp = child.getProgressRate() == null ? BigDecimal.ZERO : child.getProgressRate();
sum = sum.add(cp);
}
BigDecimal avg = sum.divide(BigDecimal.valueOf(children.size()), 2, RoundingMode.HALF_UP);
BigDecimal avg = children.isEmpty()
? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
: sum.divide(BigDecimal.valueOf(children.size()), 2, RoundingMode.HALF_UP);
if (progressNumericallyEquals(avg, parent.getProgressRate())) {
return;
}
@@ -1047,6 +1061,12 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
}
private List<String> loadProgressExcludedTaskStatusCodes() {
List<String> statusCodes = objectStatusModelMapper
.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return statusCodes == null ? Collections.emptyList() : statusCodes;
}
private boolean progressNumericallyEquals(BigDecimal a, BigDecimal b) {
if (a == null && b == null) {
return true;
@@ -1083,9 +1103,10 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
private void applyLifecycle(ProjectTaskRespVO respVO) {
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤spec §7.1
// 传入 ownerId / progressRate 用于 availableActions 的 owner-only 与完成进度过滤。
ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle =
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId(),
respVO.getProgressRate());
respVO.setStatusName(lifecycle.statusName());
respVO.setTerminal(lifecycle.terminal());
respVO.setAllowEdit(lifecycle.allowEdit());

View File

@@ -12,6 +12,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -27,6 +28,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
* <li>剔除系统级动作 {@code auto_start}(由工时填报内部触发,不出现在 UI 按钮)。</li>
* <li>对 owner-only 状态推进动作complete / cancel / pause / resume按 {@code task.ownerId == currentUserId} 字段硬卡过滤,
* 与 {@link ProjectTaskServiceImpl#validateOwnerForAction} 同款判定。</li>
* <li>对 {@code complete} 动作叠加进度过滤,任务进度未达到 100 时不下发完成按钮。</li>
* </ol>
* 非状态动作delete / 加协办人 / 工时填报等)的权限码 / 字段过滤未纳入本字段,前端按各动作对应权限码与 owner 字段独立判断;
* 全量化纳入 {@code availableActions} 留待后续阶段统一改造。
@@ -38,13 +40,15 @@ public class ProjectTaskStatusViewService {
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectTaskServiceImpl#validateOwnerForAction 一致。
*/
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
private static final String ACTION_COMPLETE = "complete";
private static final BigDecimal COMPLETE_PROGRESS_THRESHOLD = BigDecimal.valueOf(100);
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId) {
public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId, BigDecimal progressRate) {
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, statusCode);
if (statusModel == null) {
@@ -54,11 +58,12 @@ public class ProjectTaskStatusViewService {
statusModel.getStatusName(),
statusModel.getTerminalFlag(),
statusModel.getAllowEdit(),
buildAvailableActions(statusCode, ownerId)
buildAvailableActions(statusCode, ownerId, progressRate)
);
}
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId) {
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId,
BigDecimal progressRate) {
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
if (transitions == null || transitions.isEmpty()) {
@@ -71,6 +76,9 @@ public class ProjectTaskStatusViewService {
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
// 完成动作额外要求任务进度已达到 100暂停、恢复、取消不受进度影响
.filter(transition -> !ACTION_COMPLETE.equals(transition.getActionCode())
|| isCompleteProgressSatisfied(progressRate))
.map(transition -> {
ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO();
action.setActionCode(transition.getActionCode());
@@ -81,6 +89,10 @@ public class ProjectTaskStatusViewService {
.toList();
}
private boolean isCompleteProgressSatisfied(BigDecimal progressRate) {
return progressRate != null && progressRate.compareTo(COMPLETE_PROGRESS_THRESHOLD) >= 0;
}
public record ProjectTaskLifecycleView(String statusName,
Boolean terminal,
Boolean allowEdit,

View File

@@ -13,6 +13,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@@ -36,8 +38,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenMemberOnlyAndCurrentUserIsMember_shouldPass() {
Long productId = 1001L;
Long loginUserId = 2001L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(createMember(productId, loginUserId, 3001L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(List.of(createMember(productId, loginUserId, 3001L)));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermission(productId, null, true));
@@ -50,8 +52,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenCurrentRolePermissionsContainTarget_shouldPass() {
Long productId = 1002L;
Long loginUserId = 2002L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(createMember(productId, loginUserId, 3002L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(List.of(createMember(productId, loginUserId, 3002L)));
when(objectPermissionApi.getObjectRolePermissions(3002L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
.thenReturn(success(Set.of("project:product:query")));
@@ -65,8 +67,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenCurrentRoleDoesNotContainPermission_shouldThrowException() {
Long productId = 1003L;
Long loginUserId = 2003L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(createMember(productId, loginUserId, 3003L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(List.of(createMember(productId, loginUserId, 3003L)));
when(objectPermissionApi.getObjectRolePermissions(3003L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
.thenReturn(success(Set.of("project:product:update")));
@@ -82,8 +84,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenCurrentUserIsNotMember_shouldThrowException() {
Long productId = 1004L;
Long loginUserId = 2004L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(null);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(Collections.emptyList());
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
ServiceException ex = assertThrows(ServiceException.class,

View File

@@ -13,6 +13,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@@ -36,8 +38,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() {
Long projectId = 1001L;
Long loginUserId = 2001L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(createMember(projectId, loginUserId, 3001L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(List.of(createMember(projectId, loginUserId, 3001L)));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermission(projectId, null, true));
@@ -50,8 +52,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenNoActiveMember_shouldThrowProjectPermissionDenied() {
Long projectId = 1002L;
Long loginUserId = 2002L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(null);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
ServiceException ex = assertThrows(ServiceException.class,
@@ -64,8 +66,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenPermissionPresent_shouldPass() {
Long projectId = 1003L;
Long loginUserId = 2003L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(createMember(projectId, loginUserId, 3003L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(List.of(createMember(projectId, loginUserId, 3003L)));
when(objectPermissionApi.getObjectRolePermissions(3003L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
.thenReturn(success(Set.of("project:project:update")));
@@ -79,8 +81,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
void checkPermission_whenPermissionMissing_shouldThrowProjectPermissionDenied() {
Long projectId = 1004L;
Long loginUserId = 2004L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(createMember(projectId, loginUserId, 3004L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(List.of(createMember(projectId, loginUserId, 3004L)));
when(objectPermissionApi.getObjectRolePermissions(3004L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
.thenReturn(success(Set.of("project:project:query")));

View File

@@ -0,0 +1,151 @@
package com.njcn.rdms.module.project.service.datascope;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
import com.njcn.rdms.module.system.api.permission.PermissionApi;
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
class ObjectDataScopeServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ObjectDataScopeServiceImpl service;
@Mock
private PermissionApi permissionApi;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
private OrgLeaderApi orgLeaderApi;
@Mock
private UserVisibilityConfigApi userVisibilityConfigApi;
@Test
void compute_returnsAll_whenUserIsSuperAdmin() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(true));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ALL);
}
// ---- helper ----
private static UserObjectRoleDO row(Long objectId) {
UserObjectRoleDO r = new UserObjectRoleDO();
r.setObjectId(objectId);
return r;
}
private static UserVisibilityConfigRespDTO cfg(String type, Set<String> directionCodes) {
UserVisibilityConfigRespDTO dto = new UserVisibilityConfigRespDTO();
dto.setType(type);
dto.setDirectionCodes(directionCodes);
return dto;
}
// ---- task 0.4 旧 case补通道 2 + 通道 3 mockstrict mode 不报未预期调用)----
@Test
void compute_returnsEmpty_whenNonSuperAdminAndNoChannelMatch() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
}
@Test
void compute_returnsIdList_withChannel1Hits() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L))
.thenReturn(List.of(row(101L), row(102L)));
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ID_LIST);
assertThat(scope.getIds()).containsExactlyInAnyOrder(101L, 102L);
}
// ---- task 1.4 新 case ----
@Test
void compute_unionsChannel1AndChannel2_whenUserIsLeader() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
// 通道 1 命中 101
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L))
.thenReturn(List.of(row(101L)));
// 通道 2 命中 102 / 103
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of(2L, 3L)));
when(userObjectRoleMapper.selectListByUserIdsAndObjectType(Set.of(2L, 3L), "project"))
.thenReturn(List.of(row(102L), row(103L)));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getIds()).containsExactlyInAnyOrder(101L, 102L, 103L);
}
@Test
void compute_skipsChannel2_whenUserIsNotLeader() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L))
.thenReturn(List.of());
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
}
// ---- task 2.6 新 case通道 3 ----
@Test
void compute_returnsAll_whenChannel3IsAll() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(cfg("all", null)));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ALL);
}
@Test
void compute_addsDirectionCodes_whenChannel3IsDirections() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(cfg("directions", Set.of("system", "embedded"))));
ObjectDataScope scope = service.compute(1L, "project");
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ID_LIST);
assertThat(scope.getIds()).isEmpty();
assertThat(scope.getDirectionCodes()).containsExactlyInAnyOrder("system", "embedded");
}
@Test
void compute_ignoresChannel3_whenTypeIsProjects() {
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
UserVisibilityConfigRespDTO projectsCfg = new UserVisibilityConfigRespDTO();
projectsCfg.setType("projects");
projectsCfg.setProjectIds(Set.of(101L, 102L));
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(projectsCfg));
ObjectDataScope scope = service.compute(1L, "project");
// 业务暂不消费 projects 类型 → ids/directionCodes 都空 → EMPTY
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
}
}

View File

@@ -0,0 +1,62 @@
package com.njcn.rdms.module.project.service.datascope;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class ObjectDataScopeTest {
@Test
void all_contains_anything() {
ObjectDataScope scope = ObjectDataScope.all();
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ALL);
assertThat(scope.contains(1L, "any")).isTrue();
assertThat(scope.contains(null, null)).isTrue();
}
@Test
void empty_contains_nothing() {
ObjectDataScope scope = ObjectDataScope.empty();
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
assertThat(scope.contains(1L, "any")).isFalse();
}
@Test
void idList_with_only_ids_matches_ids() {
ObjectDataScope scope = ObjectDataScope.idList(Set.of(1L, 2L), Set.of());
assertThat(scope.contains(1L, null)).isTrue();
assertThat(scope.contains(99L, null)).isFalse();
}
@Test
void idList_with_only_directionCodes_matches_directions() {
ObjectDataScope scope = ObjectDataScope.idList(Set.of(), Set.of("system"));
assertThat(scope.contains(99L, "system")).isTrue();
assertThat(scope.contains(99L, "embedded")).isFalse();
}
@Test
void idList_with_both_or_semantics() {
ObjectDataScope scope = ObjectDataScope.idList(Set.of(1L), Set.of("system"));
assertThat(scope.contains(1L, "embedded")).isTrue(); // id 命中
assertThat(scope.contains(99L, "system")).isTrue(); // direction 命中
assertThat(scope.contains(99L, "embedded")).isFalse(); // 都不命中
}
@Test
void idList_with_empty_inputs_returns_empty_state() {
ObjectDataScope scope = ObjectDataScope.idList(Set.of(), Set.of());
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
}
@Test
void ids_and_directions_are_unmodifiable() {
ObjectDataScope scope = ObjectDataScope.idList(Set.of(1L), Set.of("system"));
org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class,
() -> scope.getIds().add(99L));
org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class,
() -> scope.getDirectionCodes().add("embedded"));
}
}

View File

@@ -19,6 +19,7 @@ import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
@@ -30,6 +31,7 @@ import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
@@ -97,7 +99,7 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
() -> productMemberService.createProductMember(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_MEMBER_ROLE_INVALID.getCode(), ex.getCode());
verify(userObjectRoleMapper, never()).selectByObjectAndUserId(any(), any(), any());
verify(userObjectRoleMapper, never()).selectByObjectUserAndRole(any(), any(), any(), any());
}
@Test
@@ -153,6 +155,56 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class));
}
@Test
void createProductMember_sameUserDifferentRole_shouldAllow() {
Long productId = 1010L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3102L);
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "product"))
.thenReturn(success(createRole(3102L, "product_specialist", "产品专员")));
// user=2002L 在产品里 (2002L, 3102L) 这个 role 不存在 —— 即便 user 已有别的 role新 role 应允许
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3102L))
.thenReturn(null);
productMemberService.createProductMember(productId, reqVO);
// 不抛 ALREADY_EXISTS新加一行
verify(userObjectRoleMapper).insert(any(UserObjectRoleDO.class));
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
}
@Test
void createProductMember_inactiveSameRoleExists_shouldReactivate() {
Long productId = 1011L;
Long userId = 2002L;
Long roleId = 3102L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(userId);
reqVO.setRoleId(roleId);
UserObjectRoleDO inactiveOld = createMember(9099L, productId, userId, roleId, 1);
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(objectPermissionApi.getObjectRoleById(roleId, "object", "product"))
.thenReturn(success(createRole(roleId, "product_specialist", "产品专员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, userId, roleId))
.thenReturn(inactiveOld);
productMemberService.createProductMember(productId, reqVO);
// 复活updateById 被调status=ACTIVEleftTime=null不调 insert
ArgumentCaptor<UserObjectRoleDO> captor = ArgumentCaptor.forClass(UserObjectRoleDO.class);
verify(userObjectRoleMapper).updateById(captor.capture());
assertEquals(0, captor.getValue().getStatus());
assertNull(captor.getValue().getLeftTime());
verify(userObjectRoleMapper, never()).insert(any(UserObjectRoleDO.class));
}
private ProductDO createProduct(Long productId, Long managerUserId) {
ProductDO product = new ProductDO();
product.setId(productId);
@@ -199,4 +251,118 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
return statusModel;
}
// ============== review 遗留 4 条 issue 修复后的回归用例 ==============
/** Issue 1transferPreviousManager 时原经理在目标 role 上有 INACTIVE 历史行 → 抛 DUPLICATE 异常(不是撞 SQL。 */
@Test
void transferPreviousManager_targetRoleHasInactiveHistory_shouldThrowDuplicate() {
Long productId = 1020L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3201L);
reqVO.setPreviousManagerUserId(2001L);
reqVO.setPreviousManagerRoleId(3202L);
ProductDO product = createProduct(productId, 2001L);
UserObjectRoleDO inactiveOldRow = createMember(9077L, productId, 2001L, 3202L, 1);
inactiveOldRow.setLeftTime(LocalDateTime.now().minusDays(30));
when(productMapper.selectById(productId)).thenReturn(product);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(objectPermissionApi.getObjectRoleById(3201L, "object", "product"))
.thenReturn(success(createRole(3201L, "product_manager", "产品经理")));
when(objectPermissionApi.getObjectRoleById(3202L, "object", "product"))
.thenReturn(success(createRole(3202L, "product_specialist", "产品专员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3201L)).thenReturn(null);
// 关键:原经理 (2001L, 3202L) 上有 INACTIVE 历史行 —— pre-check 用不带 status 的 select 命中
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2001L, 3202L))
.thenReturn(inactiveOldRow);
ServiceException ex = assertThrows(ServiceException.class,
() -> productMemberService.createProductMember(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE.getCode(), ex.getCode());
}
/** Issue 2updateProductMember 改 roleId 时目标 role 已被该 user 的另一行占据 → 抛 ALREADY_EXISTS不是撞 SQL。 */
@Test
void updateProductMember_targetRoleHasOtherRow_shouldThrowAlreadyExists() {
Long productId = 1021L;
Long memberId = 9011L;
ProductMemberUpdateReqVO reqVO = new ProductMemberUpdateReqVO();
reqVO.setRoleId(3203L);
UserObjectRoleDO member = createMember(memberId, productId, 2002L, 3202L, 0);
// 该 user 已有另一行持有目标 role 3203L —— 直接 update 会撞唯一索引
UserObjectRoleDO conflictRow = createMember(9012L, productId, 2002L, 3203L, 0);
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(userObjectRoleMapper.selectByIdAndObject(memberId, "product", productId)).thenReturn(member);
when(objectPermissionApi.getObjectRoleById(3203L, "object", "product"))
.thenReturn(success(createRole(3203L, "product_dev", "产品开发")));
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3203L))
.thenReturn(conflictRow);
ServiceException ex = assertThrows(ServiceException.class,
() -> productMemberService.updateProductMember(productId, memberId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS.getCode(), ex.getCode());
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
}
/** Issue 3复活分支审计动作应为 REACTIVATE不是 ADD语义区分新增 vs 软失效后再激活)。 */
@Test
void createProductMember_inactiveSameRoleExists_auditActionShouldBeReactivate() {
Long productId = 1022L;
Long userId = 2002L;
Long roleId = 3202L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(userId);
reqVO.setRoleId(roleId);
UserObjectRoleDO inactiveOld = createMember(9088L, productId, userId, roleId, 1);
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(objectPermissionApi.getObjectRoleById(roleId, "object", "product"))
.thenReturn(success(createRole(roleId, "product_specialist", "产品专员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, userId, roleId))
.thenReturn(inactiveOld);
productMemberService.createProductMember(productId, reqVO);
ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
verify(bizAuditLogMapper).insert(auditCaptor.capture());
// reactivate_member 来自 ObjectActivityConstants.MEMBER_ACTION_REACTIVATE硬编码字面量避免引入额外 import
assertEquals("reactivate_member", auditCaptor.getValue().getActionType());
}
/** Issue 4内置 manager 角色未在 system_role 找到 → 抛业务 ServiceException不是 IllegalStateException 透出 500。 */
@Test
void resolveProductManagerRoleId_internalRoleMissing_shouldThrowBusinessException() {
Long productId = 1023L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3201L);
reqVO.setPreviousManagerUserId(2001L);
reqVO.setPreviousManagerRoleId(3202L);
ProductDO product = createProduct(productId, 2001L);
when(productMapper.selectById(productId)).thenReturn(product);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(createStatus("active", true));
when(objectPermissionApi.getObjectRoleById(3201L, "object", "product"))
.thenReturn(success(createRole(3201L, "product_manager", "产品经理")));
when(objectPermissionApi.getObjectRoleById(3202L, "object", "product"))
.thenReturn(success(createRole(3202L, "product_specialist", "产品专员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3201L)).thenReturn(null);
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2001L, 3202L)).thenReturn(null);
// 关键:内置 product_manager 角色查询返回 null —— resolveProductManagerRoleId 应抛业务异常
when(objectPermissionApi.getObjectRoleByCode("product_manager", "object", "product"))
.thenReturn(success(null));
ServiceException ex = assertThrows(ServiceException.class,
() -> productMemberService.createProductMember(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED.getCode(), ex.getCode());
}
}

View File

@@ -5,11 +5,15 @@ import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
@@ -35,11 +39,13 @@ import org.mockito.InjectMocks;
import org.mockito.MockedStatic;
import org.mockito.Mock;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -74,6 +80,10 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
private AdminUserApi adminUserApi;
@Mock
private com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper requirementModuleMapper;
@Mock
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Mock
private ObjectDataScopeService objectDataScopeService;
@Test
void createProduct_shouldCreateDefaultRequirementModule() {
@@ -460,9 +470,9 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
detail.setPermissions(Set.of("project:product:update"));
when(productMapper.selectById(productId)).thenReturn(product);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(currentMember);
when(objectPermissionApi.getObjectRolePermissionDetail(roleId, "object", "product"))
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(List.of(currentMember));
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(roleId), "object", "product"))
.thenReturn(success(detail));
ProductContextRespVO respVO;
@@ -479,6 +489,107 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
assertEquals(List.of("project:product:update"), respVO.getButtons());
}
@Test
void getProductContext_returnsImplicitObserver_whenNoExplicitRoleButScopeContains() {
Long productId = 1015L;
Long loginUserId = 3015L;
Long observerRoleId = 9201L;
ProductDO product = createProduct(productId, "direction_value", "隐式观察产品", 2018L, "描述", "active");
ObjectRoleRespDTO observerRole = createRole(observerRoleId, "implicit_observer_product", "产品隐式观察者");
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(observerRole);
detail.setMenus(List.of(
createMenu(9401L, "概览", null, 2, 10, "/product/overview", "mdi:view-dashboard-outline", true)
));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class)) {
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn("观察人");
when(productMapper.selectById(productId)).thenReturn(product);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(Collections.emptyList());
when(objectDataScopeService.compute(loginUserId, "product"))
.thenReturn(ObjectDataScope.idList(Set.of(productId), Set.of()));
when(objectPermissionApi.getObjectRoleByCode("implicit_observer_product", "object", "product"))
.thenReturn(success(observerRole));
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "product"))
.thenReturn(success(detail));
ProductContextRespVO respVO = productService.getProductContext(productId);
assertNotNull(respVO.getCurrentProduct());
assertEquals(observerRoleId, respVO.getCurrentRole().getRoleId());
assertEquals("implicit_observer_product", respVO.getCurrentRole().getRoleCode());
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
assertEquals(1, respVO.getNavs().size());
assertEquals(9401L, respVO.getNavs().get(0).getId());
}
}
@Test
void getProductContext_throws_whenNoExplicitRoleAndScopeMisses() {
Long productId = 1016L;
Long loginUserId = 3016L;
ProductDO product = createProduct(productId, "direction_value", "无权限产品", 2019L, "描述", "active");
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class)) {
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
when(productMapper.selectById(productId)).thenReturn(product);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(Collections.emptyList());
// scope 不包含该产品(空 ID 列表directionCode 也不匹配)
when(objectDataScopeService.compute(loginUserId, "product"))
.thenReturn(ObjectDataScope.idList(Set.of(9999L), Set.of("other_direction")));
ServiceException ex = assertThrows(ServiceException.class,
() -> productService.getProductContext(productId));
assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
}
}
@Test
void getProductPage_returnsEmpty_whenScopeIsEmpty() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.empty());
PageResult<ProductDO> result = productService.getProductPage(new ProductPageReqVO());
assertThat(result.getList()).isEmpty();
verify(productMapper, never()).selectPage(any(ProductPageReqVO.class), any());
}
}
@Test
void getProductPage_passesIdListIntoSql_whenScopeIsIdList() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "product"))
.thenReturn(ObjectDataScope.idList(Set.of(101L), Set.of()));
when(productMapper.selectPage(any(ProductPageReqVO.class), any())).thenReturn(new PageResult<>());
productService.getProductPage(new ProductPageReqVO());
verify(productMapper, times(1)).selectPage(any(ProductPageReqVO.class), any());
}
}
@Test
void getProductPage_skipsScopeFilter_whenScopeIsAll() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
when(productMapper.selectPage(any(ProductPageReqVO.class), any())).thenReturn(new PageResult<>());
productService.getProductPage(new ProductPageReqVO());
verify(productMapper, times(1)).selectPage(any(ProductPageReqVO.class), any());
}
}
private ProductDO createProduct(Long id, String directionCode, String name, Long managerUserId,
String description, String statusCode) {
ProductDO product = new ProductDO();

View File

@@ -99,7 +99,7 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2002L))
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3102L))
.thenReturn(createMember(9002L, projectId, 2002L, 3102L, 0));
ServiceException ex = assertThrows(ServiceException.class,
@@ -147,8 +147,15 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2002L)).thenReturn(null);
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2001L)).thenReturn(previousManager);
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3101L)).thenReturn(null);
// transferPreviousManager 内部:先校验目标 rolepreviousManagerRoleId=3102L无任何行冲突含 INACTIVE 历史)
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2001L, 3102L)).thenReturn(null);
// 再按 (user, object, manager_role_id=3101L) 三元组定位旧 manager 行
when(userObjectRoleMapper.selectActiveByObjectUserAndRole("project", projectId, 2001L, 3101L))
.thenReturn(previousManager);
// resolveProjectManagerRoleId() 需要拿 manager 内置角色
when(objectPermissionApi.getObjectRoleByCode("project_manager", "object", "project"))
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
projectMemberService.createProjectMember(projectId, reqVO);
@@ -226,6 +233,58 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED.getCode(), ex.getCode());
}
@Test
void createProjectMember_sameUserDifferentRole_shouldAllow() {
Long projectId = 1010L;
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3102L);
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
// user=2002L 在项目里 (2002L, 3102L) 这个 role 不存在 —— 即便 user 已有别的 role新 role 应允许
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3102L))
.thenReturn(null);
projectMemberService.createProjectMember(projectId, reqVO);
// 不抛 ALREADY_EXISTS新加一行
verify(userObjectRoleMapper).insert(any(UserObjectRoleDO.class));
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
}
@Test
void createProjectMember_inactiveSameRoleExists_shouldReactivate() {
Long projectId = 1011L;
Long userId = 2002L;
Long roleId = 3102L;
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
reqVO.setUserId(userId);
reqVO.setRoleId(roleId);
UserObjectRoleDO inactiveOld = createMember(9099L, projectId, userId, roleId, 1);
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(roleId, "object", "project"))
.thenReturn(success(createRole(roleId, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, userId, roleId))
.thenReturn(inactiveOld);
projectMemberService.createProjectMember(projectId, reqVO);
// 复活updateById 被调status=ACTIVEleftTime=null不调 insert
ArgumentCaptor<UserObjectRoleDO> captor = ArgumentCaptor.forClass(UserObjectRoleDO.class);
verify(userObjectRoleMapper).updateById(captor.capture());
assertEquals(0, captor.getValue().getStatus());
assertNull(captor.getValue().getLeftTime());
verify(userObjectRoleMapper, never()).insert(any(UserObjectRoleDO.class));
}
private ProjectDO createProject(Long projectId, Long managerUserId) {
ProjectDO project = new ProjectDO();
project.setId(projectId);
@@ -280,4 +339,121 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
return statusModel;
}
// ============== review 遗留 4 条 issue 修复后的回归用例 ==============
/** Issue 1transferPreviousManager 时原经理在目标 role 上有 INACTIVE 历史行 → 抛 DUPLICATE 异常(不是撞 SQL。 */
@Test
void transferPreviousManager_targetRoleHasInactiveHistory_shouldThrowDuplicate() {
Long projectId = 1020L;
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3101L);
reqVO.setPreviousManagerUserId(2001L);
reqVO.setPreviousManagerRoleId(3102L);
ProjectDO project = createProject(projectId, 2001L);
UserObjectRoleDO inactiveOldRow = createMember(9077L, projectId, 2001L, 3102L, 1);
inactiveOldRow.setLeftTime(LocalDateTime.now().minusDays(30));
when(projectMapper.selectById(projectId)).thenReturn(project);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(3101L, "object", "project"))
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3101L)).thenReturn(null);
// 关键:原经理 (2001L, 3102L) 上有 INACTIVE 历史行 —— pre-check 用不带 status 的 select 命中
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2001L, 3102L))
.thenReturn(inactiveOldRow);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectMemberService.createProjectMember(projectId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE.getCode(), ex.getCode());
}
/** Issue 2updateProjectMember 改 roleId 时目标 role 已被该 user 的另一行占据 → 抛 ALREADY_EXISTS不是撞 SQL。 */
@Test
void updateProjectMember_targetRoleHasOtherRow_shouldThrowAlreadyExists() {
Long projectId = 1021L;
Long memberId = 9011L;
ProjectMemberUpdateReqVO reqVO = new ProjectMemberUpdateReqVO();
reqVO.setRoleId(3103L);
UserObjectRoleDO member = createMember(memberId, projectId, 2002L, 3102L, 0);
// 该 user 已有另一行持有目标 role 3103L —— 直接 update 会撞唯一索引
UserObjectRoleDO conflictRow = createMember(9012L, projectId, 2002L, 3103L, 0);
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(userObjectRoleMapper.selectByIdAndObject(memberId, "project", projectId)).thenReturn(member);
when(objectPermissionApi.getObjectRoleById(3103L, "object", "project"))
.thenReturn(success(createRole(3103L, "project_dev", "项目开发")));
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3103L))
.thenReturn(conflictRow);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectMemberService.updateProjectMember(projectId, memberId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS.getCode(), ex.getCode());
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
}
/** Issue 3复活分支审计动作应为 REACTIVATE不是 ADD语义区分新增 vs 软失效后再激活)。 */
@Test
void createProjectMember_inactiveSameRoleExists_auditActionShouldBeReactivate() {
Long projectId = 1022L;
Long userId = 2002L;
Long roleId = 3102L;
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
reqVO.setUserId(userId);
reqVO.setRoleId(roleId);
UserObjectRoleDO inactiveOld = createMember(9088L, projectId, userId, roleId, 1);
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(roleId, "object", "project"))
.thenReturn(success(createRole(roleId, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, userId, roleId))
.thenReturn(inactiveOld);
projectMemberService.createProjectMember(projectId, reqVO);
ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
verify(bizAuditLogMapper).insert(auditCaptor.capture());
// reactivate_member 来自 ObjectActivityConstants.MEMBER_ACTION_REACTIVATE硬编码字面量避免引入额外 import
assertEquals("reactivate_member", auditCaptor.getValue().getActionType());
}
/** Issue 4内置 manager 角色未在 system_role 找到 → 抛业务 ServiceException不是 IllegalStateException 透出 500。 */
@Test
void resolveProjectManagerRoleId_internalRoleMissing_shouldThrowBusinessException() {
Long projectId = 1023L;
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
reqVO.setUserId(2002L);
reqVO.setRoleId(3101L);
reqVO.setPreviousManagerUserId(2001L);
reqVO.setPreviousManagerRoleId(3102L);
ProjectDO project = createProject(projectId, 2001L);
when(projectMapper.selectById(projectId)).thenReturn(project);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
.thenReturn(createStatus("active", true));
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
when(objectPermissionApi.getObjectRoleById(3101L, "object", "project"))
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3101L)).thenReturn(null);
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2001L, 3102L)).thenReturn(null);
// 关键:内置 project_manager 角色查询返回 null —— resolveProjectManagerRoleId 应抛业务异常
when(objectPermissionApi.getObjectRoleByCode("project_manager", "object", "project"))
.thenReturn(success(null));
ServiceException ex = assertThrows(ServiceException.class,
() -> projectMemberService.createProjectMember(projectId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_INTERNAL_ROLE_NOT_CONFIGURED.getCode(), ex.getCode());
}
}

View File

@@ -3,12 +3,16 @@ package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectLifecycleActionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
@@ -39,11 +43,13 @@ import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -81,6 +87,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
private AdminUserApi adminUserApi;
@Mock
private DictDataApi dictDataApi;
@Mock
private ObjectDataScopeService objectDataScopeService;
@Test
void getProjectDetail_shouldFillProductNameAndManagerNickname() {
@@ -322,22 +330,28 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
@Test
void getProjectContext_whenCreatorHasNoObjectRole_shouldReturnVisitorResources() {
// 改造后:无显式角色 + scope 命中 → 返回 implicit_observer_project 上下文(原 visitor 兜底已由 scope.contains 替代)
Long projectId = 1002L;
Long loginUserId = 3002L;
Long visitorRoleId = 9201L;
Long observerRoleId = 9202L;
ProjectDO project = createProject(projectId, null, "创建人项目", 2001L, "pending");
project.setCreator(String.valueOf(loginUserId));
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(createRole(visitorRoleId, "visitor", "游客"));
detail.setCurrentRole(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者"));
detail.setMenus(List.of(
createMenu(9301L, "概览", null, 2, 10, "/project/project/overview", "mdi:home-outline", true)
));
detail.setPermissions(Set.of());
detail.setAdditionalRoleNames(Collections.emptyList());
when(projectMapper.selectById(projectId)).thenReturn(project);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId)).thenReturn(null);
when(objectPermissionApi.getObjectRoleByCode("visitor", "object", "project"))
.thenReturn(success(createRole(visitorRoleId, "visitor", "游客")));
when(objectPermissionApi.getObjectRolePermissionDetail(visitorRoleId, "object", "project"))
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
// scope 命中该项目 id
when(objectDataScopeService.compute(loginUserId, "project"))
.thenReturn(ObjectDataScope.idList(Set.of(projectId), Set.of()));
when(objectPermissionApi.getObjectRoleByCode("implicit_observer_project", "object", "project"))
.thenReturn(success(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者")));
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "project"))
.thenReturn(success(detail));
when(projectStatusViewService.getLifecycle("pending"))
.thenReturn(new ProjectStatusViewService.ProjectLifecycleView(
@@ -357,14 +371,115 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
assertEquals(1, respVO.getCurrentProject().getAvailableActions().size());
assertEquals("cancel", respVO.getCurrentProject().getAvailableActions().get(0).getActionCode());
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
assertEquals(visitorRoleId, respVO.getCurrentRole().getRoleId());
assertEquals("visitor", respVO.getCurrentRole().getRoleCode());
assertEquals("游客", respVO.getCurrentRole().getRoleName());
assertEquals(observerRoleId, respVO.getCurrentRole().getRoleId());
assertEquals("implicit_observer_project", respVO.getCurrentRole().getRoleCode());
assertEquals("项目隐式观察者", respVO.getCurrentRole().getRoleName());
assertEquals(1, respVO.getNavs().size());
assertEquals(9301L, respVO.getNavs().get(0).getId());
assertEquals(List.of(), respVO.getButtons());
}
@Test
void getProjectContext_returnsImplicitObserver_whenNoExplicitRoleButScopeContains() {
Long projectId = 100L;
Long loginUserId = 1L;
Long observerRoleId = 9203L;
ProjectDO project = createProject(projectId, null, "观察者项目", 2001L, "active");
project.setDirectionCode("dc_obs");
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者"));
detail.setMenus(List.of(
createMenu(9401L, "任务", null, 2, 20, "/project/project/task", "mdi:format-list-checks", true)
));
detail.setPermissions(Set.of());
detail.setAdditionalRoleNames(Collections.emptyList());
when(projectMapper.selectById(projectId)).thenReturn(project);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
when(objectDataScopeService.compute(loginUserId, "project"))
.thenReturn(ObjectDataScope.idList(Set.of(projectId), Set.of()));
when(objectPermissionApi.getObjectRoleByCode("implicit_observer_project", "object", "project"))
.thenReturn(success(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者")));
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "project"))
.thenReturn(success(detail));
when(projectStatusViewService.getLifecycle("active"))
.thenReturn(new ProjectStatusViewService.ProjectLifecycleView(
"进行中", false, true, List.of()
));
ProjectContextRespVO respVO;
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "观察者")) {
respVO = projectService.getProjectContext(projectId);
}
assertNotNull(respVO);
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
assertEquals("项目隐式观察者", respVO.getCurrentRole().getRoleName());
assertEquals("implicit_observer_project", respVO.getCurrentRole().getRoleCode());
assertEquals(1, respVO.getNavs().size());
assertEquals(List.of(), respVO.getButtons());
}
@Test
void getProjectContext_throws_whenNoExplicitRoleAndScopeMisses() {
Long projectId = 100L;
Long loginUserId = 1L;
ProjectDO project = createProject(projectId, null, "无权限项目", 2001L, "active");
project.setDirectionCode("dc_miss");
when(projectMapper.selectById(projectId)).thenReturn(project);
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
when(objectDataScopeService.compute(loginUserId, "project"))
.thenReturn(ObjectDataScope.empty());
ServiceException ex;
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "无关用户")) {
ex = assertThrows(ServiceException.class, () -> projectService.getProjectContext(projectId));
}
assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
}
@Test
void getProjectPage_returnsEmpty_whenScopeIsEmpty() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.empty());
PageResult<ProjectDO> result = projectService.getProjectPage(new ProjectPageReqVO());
assertThat(result.getList()).isEmpty();
verify(projectMapper, never()).selectPage(any(ProjectPageReqVO.class), any());
}
}
@Test
void getProjectPage_passesIdListIntoSql_whenScopeIsIdList() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "project"))
.thenReturn(ObjectDataScope.idList(Set.of(101L), Set.of()));
when(projectMapper.selectPage(any(ProjectPageReqVO.class), any())).thenReturn(new PageResult<>());
projectService.getProjectPage(new ProjectPageReqVO());
verify(projectMapper, times(1)).selectPage(any(ProjectPageReqVO.class), any());
}
}
@Test
void getProjectPage_skipsScopeFilter_whenScopeIsAll() {
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
when(projectMapper.selectPage(any(ProjectPageReqVO.class), any())).thenReturn(new PageResult<>());
projectService.getProjectPage(new ProjectPageReqVO());
verify(projectMapper, times(1)).selectPage(any(ProjectPageReqVO.class), any());
}
}
@Test
void changeProjectStatus_shouldUpdateStatusAndWriteStatusAndAuditLogs() {
Long projectId = 1003L;

View File

@@ -96,8 +96,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
@Test
void createExecutionAssignee_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() {
stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember());
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(List.of(createProjectMember()));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
ExecutionAssigneeDO m = inv.getArgument(0);
@@ -128,8 +128,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
void createExecutionAssignee_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() {
// B 模型:用户曾失效,重新加入新插一段,旧段不动
stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember());
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(List.of(createProjectMember()));
// 当前没有活跃段(旧段已失效),通过 active-only 校验
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
@@ -152,8 +152,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
@Test
void createExecutionAssignee_whenAlreadyActive_shouldThrowAlreadyExists() {
stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember());
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(List.of(createProjectMember()));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(createMember(7001L, USER_ID, null));

View File

@@ -51,6 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
@@ -119,12 +120,12 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createProjectStatus("pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("execution"))
.thenReturn(createExecutionStatus("pending", true));
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L))
.thenReturn(createProjectMember(projectId, 3002L, 3102L));
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3003L))
.thenReturn(createProjectMember(projectId, 3003L, 3102L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
.thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L)));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3003L))
.thenReturn(List.of(createProjectMember(projectId, 3003L, 3102L)));
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
.thenReturn(success(true));
@@ -180,8 +181,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createProjectStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("unknown")))
.thenThrow(new RuntimeException("invalid dict"));
@@ -204,8 +205,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createProjectStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
.thenReturn(success(true));
@@ -233,8 +234,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createExecutionStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调-修订")).thenReturn(null);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
.thenReturn(success(true));
@@ -255,8 +256,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createExecutionStatus("pending", true));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L))
.thenReturn(createProjectMember(projectId, 3002L, 3102L));
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
.thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L)));
ProjectExecutionOwnerChangeReqVO reqVO = new ProjectExecutionOwnerChangeReqVO();
reqVO.setNewOwnerId(3002L);
@@ -417,15 +418,17 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId))
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
.thenReturn(new BigDecimal("66.666"));
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("66.67")), anyBoolean()))
.thenReturn(createLifecycleView());
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
assertEquals(new BigDecimal("66.67"), result.getProgressRate());
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId);
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled"));
}
@Test
@@ -437,8 +440,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId)).thenReturn(null);
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
.thenReturn(null);
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("0.00")), anyBoolean()))
.thenReturn(createLifecycleView());
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
@@ -446,6 +452,27 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
assertEquals(new BigDecimal("0.00"), result.getProgressRate());
}
@Test
void getExecutionRespVO_shouldPassConfiguredExcludedStatusesToProgressAggregation() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
.thenReturn(new BigDecimal("100.00"));
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("100.00")), anyBoolean()))
.thenReturn(createLifecycleView());
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
assertEquals(new BigDecimal("100.00"), result.getProgressRate());
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled"));
}
@Test
void getExecutionRespVOPage_shouldBatchOverwriteCachedProgress() {
Long projectId = 2001L;
@@ -460,11 +487,13 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
.thenReturn(new PageResult<>(List.of(first, second), 2L));
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled"))))
.thenReturn(List.of(Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555"))));
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("25.56")), anyBoolean()))
.thenReturn(createLifecycleView());
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3002L), eq(new BigDecimal("0.00")), anyBoolean()))
.thenReturn(createLifecycleView());
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);
@@ -472,7 +501,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
assertEquals(new BigDecimal("25.56"), result.getList().get(0).getProgressRate());
assertEquals(new BigDecimal("0.00"), result.getList().get(1).getProgressRate());
ArgumentCaptor<Collection<Long>> executionIdsCaptor = ArgumentCaptor.forClass(Collection.class);
verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), executionIdsCaptor.capture());
verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), executionIdsCaptor.capture(),
eq(List.of("cancelled")));
assertEquals(List.of(5001L, 5002L), List.copyOf(executionIdsCaptor.getValue()));
}
@@ -493,11 +523,13 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
.thenReturn(new PageResult<>(List.of(first, second), 2L));
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled"))))
.thenReturn(rows);
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("25.56")), anyBoolean()))
.thenReturn(createLifecycleView());
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3002L), eq(new BigDecimal("10.00")), anyBoolean()))
.thenReturn(createLifecycleView());
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);

View File

@@ -3,6 +3,7 @@ package com.njcn.rdms.module.project.service.project.execution;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionLifecycleActionRespVO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
@@ -13,6 +14,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -45,7 +47,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId);
projectExecutionStatusViewService.getLifecycle("active", ownerId, BigDecimal.ZERO, false);
assertEquals("进行中", result.statusName());
assertFalse(result.terminal());
@@ -73,12 +75,83 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId);
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"), true);
assertTrue(result.availableActions().isEmpty());
}
}
@Test
void getLifecycle_whenOwnerButProgressBelow100_shouldFilterCompleteOnly() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
ObjectStatusTransitionDO cancel = createTransition("cancel", "取消", true);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
.thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
.thenReturn(List.of(complete, pause, cancel));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("99.99"), true);
assertEquals(List.of("pause", "cancel"), result.availableActions().stream()
.map(ProjectExecutionLifecycleActionRespVO::getActionCode)
.toList());
}
}
@Test
void getLifecycle_whenOwnerAndProgress100AndRootTasksAllCompleted_shouldReturnComplete() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
.thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
.thenReturn(List.of(complete));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"), true);
assertEquals(1, result.availableActions().size());
assertEquals("complete", result.availableActions().get(0).getActionCode());
}
}
@Test
void getLifecycle_whenOwnerAndProgress100ButRootTasksNotAllCompleted_shouldFilterComplete() {
// 任务进度算到 100 但任务状态尚未流转到 completed或空集complete 按钮不下发;
// 同时验证 pause / cancel 等不受根任务完成态影响。
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
ObjectStatusTransitionDO cancel = createTransition("cancel", "取消", true);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
.thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
.thenReturn(List.of(complete, pause, cancel));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"), false);
assertEquals(List.of("pause", "cancel"), result.availableActions().stream()
.map(ProjectExecutionLifecycleActionRespVO::getActionCode)
.toList());
}
}
@Test
void getLifecycle_shouldExcludeAutoStartAction() {
Long ownerId = 3001L;
@@ -98,7 +171,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("pending", ownerId);
projectExecutionStatusViewService.getLifecycle("pending", ownerId, BigDecimal.ZERO, false);
assertTrue(result.availableActions().isEmpty());
}
@@ -110,7 +183,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
.thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionStatusViewService.getLifecycle("active", 3001L));
() -> projectExecutionStatusViewService.getLifecycle("active", 3001L, BigDecimal.ZERO, false));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
}

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
@@ -32,8 +33,10 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -43,6 +46,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -319,6 +323,78 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
// -------------------- Phase 3 进度自动汇总 + 叶子转父限制 --------------------
@Test
void changeTaskStatus_whenCancelChild_shouldRecalculateParentProgressWithExcludedStatuses() {
Long projectId = 2001L;
Long executionId = 5001L;
Long parentTaskId = 8001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
task.setParentTaskId(parentTaskId);
ProjectTaskDO parent = createTask(projectId, executionId, parentTaskId, 3002L);
parent.setProgressRate(new BigDecimal("50.00"));
ProjectTaskDO remainingChild = createTask(projectId, executionId, 9002L, 3002L);
remainingChild.setProgressRate(new BigDecimal("100.00"));
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
reqVO.setActionCode("cancel");
reqVO.setReason("任务取消");
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel"))
.thenReturn(createTransition("cancel", "cancelled", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "cancelled"))
.thenReturn(createTerminalStatus("task", "cancelled"));
when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "cancelled", "任务取消")).thenReturn(1);
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parent);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled")))
.thenReturn(List.of(remainingChild));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3002L)) {
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
}
verify(projectTaskMapper).selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled"));
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
argThat(v -> new BigDecimal("100.00").compareTo(v) == 0));
}
@Test
void changeTaskStatus_whenAllChildrenExcluded_shouldResetParentProgressToZero() {
Long projectId = 2001L;
Long executionId = 5001L;
Long parentTaskId = 8001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
task.setParentTaskId(parentTaskId);
ProjectTaskDO parent = createTask(projectId, executionId, parentTaskId, 3002L);
parent.setProgressRate(new BigDecimal("80.00"));
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
reqVO.setActionCode("cancel");
reqVO.setReason("任务取消");
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel"))
.thenReturn(createTransition("cancel", "cancelled", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "cancelled"))
.thenReturn(createTerminalStatus("task", "cancelled"));
when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "cancelled", "任务取消")).thenReturn(1);
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parent);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled")))
.thenReturn(List.of());
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3002L)) {
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
}
verify(projectTaskMapper).selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled"));
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
argThat(v -> new BigDecimal("0.00").compareTo(v) == 0));
}
@Test
void createTask_whenParentIsLeafWithProgress_shouldThrowLeafToParentForbiddenProgress() {
Long projectId = 2001L;
@@ -418,7 +494,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
existingChild.setProgressRate(new BigDecimal("60.00"));
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
newChild.setProgressRate(BigDecimal.ZERO);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, Collections.emptyList()))
.thenReturn(List.of(existingChild, newChild));
projectTaskService.createTask(projectId, executionId, reqVO);
@@ -470,9 +546,9 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
when(projectTaskMapper.selectById(grandparentId)).thenReturn(grandparent);
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
newChild.setProgressRate(new BigDecimal("80.00"));
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, Collections.emptyList()))
.thenReturn(List.of(newChild));
when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId))
when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId, Collections.emptyList()))
.thenReturn(List.of(parentTask));
projectTaskService.createTask(projectId, executionId, reqVO);
@@ -526,16 +602,16 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
// 递归刷新两条链:旧父 8001 与 新父 8002
when(projectTaskMapper.selectById(oldParentId)).thenReturn(oldParent);
when(projectTaskMapper.selectById(newParentId)).thenReturn(newParent);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId))
.thenReturn(List.of()); // 旧父在迁移后无子,保留原值
when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId))
when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId, Collections.emptyList()))
.thenReturn(List.of()); // 旧父在迁移后无有效子任务,按新口径归零;当前值已为 0 不重复更新
when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId, Collections.emptyList()))
.thenReturn(List.of(task));
projectTaskService.updateTask(projectId, executionId, reqVO);
verify(projectTaskMapper).updateProgressRateById(eq(newParentId),
argThat(v -> new BigDecimal("70.00").compareTo(v) == 0));
// 旧父无recalcParentProgressFrom 早退,不调 updateProgressRateById
// 旧父无有效子任务且当前已为 0不重复更新
verify(projectTaskMapper, never()).updateProgressRateById(eq(oldParentId), any(BigDecimal.class));
}
@@ -591,6 +667,12 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
return status;
}
private ObjectStatusModelDO createTerminalStatus(String objectType, String statusCode) {
ObjectStatusModelDO status = createStatus(objectType, statusCode, false);
status.setTerminalFlag(true);
return status;
}
private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) {
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
transition.setActionCode(actionCode);
@@ -600,4 +682,10 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
return transition;
}
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
return mockedStatic;
}
}

View File

@@ -3,6 +3,7 @@ package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
@@ -13,6 +14,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -45,7 +47,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active", ownerId);
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
assertEquals("进行中", view.statusName());
assertFalse(view.terminal());
@@ -71,12 +73,55 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active", ownerId);
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
assertTrue(view.availableActions().isEmpty());
}
}
@Test
void getLifecycle_whenOwnerButProgressBelow100_shouldFilterCompleteOnly() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成");
ObjectStatusTransitionDO pause = createTransition("pause", "暂停");
ObjectStatusTransitionDO resume = createTransition("resume", "恢复");
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
.thenReturn(List.of(complete, pause, resume));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("99.99"));
assertEquals(List.of("pause", "resume"), view.availableActions().stream()
.map(ProjectTaskLifecycleActionRespVO::getActionCode)
.toList());
}
}
@Test
void getLifecycle_whenOwnerAndProgress100_shouldReturnComplete() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成");
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
.thenReturn(List.of(complete));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
assertEquals(1, view.availableActions().size());
assertEquals("complete", view.availableActions().get(0).getActionCode());
}
}
@Test
void getLifecycle_shouldExcludeAutoStartAction() {
Long ownerId = 3001L;
@@ -95,7 +140,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("pending", ownerId);
projectTaskStatusViewService.getLifecycle("pending", ownerId, BigDecimal.ZERO);
assertTrue(view.availableActions().isEmpty());
}
@@ -106,7 +151,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskStatusViewService.getLifecycle("missing", 3001L));
() -> projectTaskStatusViewService.getLifecycle("missing", 3001L, BigDecimal.ZERO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
}