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

@@ -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,