docs: 删除工单需求规格文档并更新开发规范

- 删除了工单需求规格说明文档 2026-05-22-ticket-design.md
- 在安全注解 CheckObjectPermission 中新增 accessible 参数配置
- 更新 CLAUDE.md 开发规范文档,补充 MySQL 客户端使用说明
- 优化错误码常量中的错误消息格式,使用中文状态和操作名称
- 修复权限拒绝提示消息,提供更友好的用户提示
- 更新开发规范关于演示库同步补丁和文档输出格式的要求
This commit is contained in:
2026-06-04 18:46:41 +08:00
parent f23f1930e9
commit f13286aaff
50 changed files with 2072 additions and 450 deletions

View File

@@ -0,0 +1,39 @@
package com.njcn.rdms.module.project.controller.admin.project.execution;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageParam;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionRespVO;
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - 我负责的执行(跨项目)")
@RestController
@RequestMapping("/project/project/me/executions")
@Validated
public class MyExecutionController {
@Resource
private ProjectExecutionService projectExecutionService;
@GetMapping("/page")
@Operation(summary = "分页获取当前登录用户负责的执行(跨项目,默认排除终态与进度满)")
public CommonResult<PageResult<MyProjectExecutionRespVO>> getMyExecutionPage(@Valid MyProjectExecutionPageReqVO reqVO) {
// 前端固定传 pageSize=-1 拉全部;负数统一归一为 PAGE_SIZE_NONE(-1),与现有执行分页接口一致
if (reqVO.getPageSize() != null && reqVO.getPageSize() < 0) {
reqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
}
return success(projectExecutionService.getMyExecutionPage(reqVO));
}
}

View File

@@ -0,0 +1,19 @@
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Schema(description = "管理后台 - 我负责的执行(跨项目)分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class MyProjectExecutionPageReqVO extends PageParam {
@Schema(description = "执行状态编码(预留,单状态精确过滤)", example = "active")
private String statusCode;
@Schema(description = "执行名称模糊匹配关键字(预留)", example = "联调")
private String keyword;
}

View File

@@ -0,0 +1,41 @@
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
@Schema(description = "管理后台 - 我负责的执行跨项目Response VO")
@Data
public class MyProjectExecutionRespVO {
@Schema(description = "执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
private Long id;
@Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调")
private String executionName;
@Schema(description = "所属项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001")
private Long projectId;
@Schema(description = "所属项目名称", example = "商城 V2 升级")
private String projectName;
@Schema(description = "执行状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "active")
private String statusCode;
@Schema(description = "执行状态名称", example = "进行中")
private String statusName;
@Schema(description = "优先级编码(字典 rdms_req_priority0=P0(最高) ~ 3=P3(最低)", example = "0")
private String priority;
@Schema(description = "计划开始日期")
private LocalDate plannedStartDate;
@Schema(description = "计划结束日期")
private LocalDate plannedEndDate;
@Schema(description = "实际开始日期")
private LocalDate actualStartDate;
@Schema(description = "实际结束日期")
private LocalDate actualEndDate;
@Schema(description = "执行进度百分比 0-100", example = "68")
private Integer progressRate;
@Schema(description = "关联项目需求编号")
private Long projectRequirementId;
@Schema(description = "关联项目需求名称", example = "订单履约后端拆分(一期)")
private String projectRequirementName;
}

View File

@@ -0,0 +1,51 @@
package com.njcn.rdms.module.project.controller.admin.project.project;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageParam;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectOwnedRespVO;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectParticipatedRespVO;
import com.njcn.rdms.module.project.service.project.MyProjectService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - 工作台「我的项目」")
@RestController
@RequestMapping("/project/project/me")
@Validated
public class MyProjectController {
@Resource
private MyProjectService myProjectService;
@GetMapping("/participated/page")
@Operation(summary = "分页获取当前登录用户参与的项目(作为成员)")
public CommonResult<PageResult<MyProjectParticipatedRespVO>> getMyParticipatedPage(@Valid MyProjectPageReqVO reqVO) {
normalizePageSize(reqVO);
return success(myProjectService.getMyParticipatedPage(reqVO));
}
@GetMapping("/owned/page")
@Operation(summary = "分页获取当前登录用户负责的项目managerUserId=当前用户)")
public CommonResult<PageResult<MyProjectOwnedRespVO>> getMyOwnedPage(@Valid MyProjectPageReqVO reqVO) {
normalizePageSize(reqVO);
return success(myProjectService.getMyOwnedPage(reqVO));
}
/** 前端固定传 pageSize=-1 拉全部;负数统一归一为 PAGE_SIZE_NONE与 MyExecutionController 一致。 */
private void normalizePageSize(MyProjectPageReqVO reqVO) {
if (reqVO.getPageSize() != null && reqVO.getPageSize() < 0) {
reqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Schema(description = "管理后台 - 我负责的项目 Response VO")
@Data
public class MyProjectOwnedRespVO {
@Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001")
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
@Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "商城 V2 升级")
private String name;
@Schema(description = "项目编码", example = "MALL-V2")
private String code;
@Schema(description = "项目整体进度百分比 0-100", requiredMode = Schema.RequiredMode.REQUIRED, example = "70")
private Integer progress;
@Schema(description = "当前用户在该项目中的角色名(恒含负责人语义)", example = "项目负责人")
private String myRole;
@Schema(description = "项目计划结束日期 YYYY-MM-DD未设为 null")
private LocalDate plannedEndDate;
@Schema(description = "项目下进行中执行数", requiredMode = Schema.RequiredMode.REQUIRED, example = "6")
private Integer executionCount;
@Schema(description = "项目下进行中任务数", requiredMode = Schema.RequiredMode.REQUIRED, example = "24")
private Integer taskCount;
@Schema(description = "项目当前有效成员数", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
private Integer memberCount;
@Schema(description = "项目下逾期任务数", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private Integer overdueCount;
@Schema(description = "成员负载原始数据;无成员为 []", requiredMode = Schema.RequiredMode.REQUIRED)
private List<MemberLoadVO> members;
@Schema(description = "成员负载原始数据")
@Data
public static class MemberLoadVO {
@Schema(description = "成员用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "101")
@JsonSerialize(using = ToStringSerializer.class)
private Long userId;
@Schema(description = "成员姓名/昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
private String userName;
@Schema(description = "该成员在本项目下的进行中任务数", requiredMode = Schema.RequiredMode.REQUIRED, example = "6")
private Integer activeTaskCount;
}
}

View File

@@ -0,0 +1,16 @@
package com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Schema(description = "管理后台 - 工作台「我的项目」分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class MyProjectPageReqVO extends PageParam {
@Schema(description = "项目名称/编码模糊匹配关键字(预留,本期不过滤)", example = "商城")
private String keyword;
}

View File

@@ -0,0 +1,32 @@
package com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 我参与的项目 Response VO")
@Data
public class MyProjectParticipatedRespVO {
@Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001")
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
@Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "商城 V2 升级")
private String name;
@Schema(description = "项目编码", example = "MALL-V2")
private String code;
@Schema(description = "项目状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "active")
private String statusCode;
@Schema(description = "项目状态名称", example = "进行中")
private String statusName;
@Schema(description = "项目整体进度百分比 0-100", requiredMode = Schema.RequiredMode.REQUIRED, example = "70")
private Integer progress;
@Schema(description = "当前用户在该项目中的角色名(主角色 / 附加角色拼接)", example = "前端负责人")
private String myRole;
@Schema(description = "我负责的任务总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "8")
private Integer myTaskCount;
@Schema(description = "我负责的未完成任务数", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
private Integer myPendingTaskCount;
}

View File

@@ -111,4 +111,18 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
.eq(UserObjectRoleDO::getStatus, 0));
}
/**
* 工作台「我负责的项目」批量查一批对象下的活跃成员角色行status=0
* 一次拿全,内存按 objectId 分组,避免逐项目 N+1。
*/
default List<UserObjectRoleDO> selectActiveListByObjectTypeAndObjectIds(String objectType, Collection<Long> objectIds) {
if (objectIds == null || objectIds.isEmpty()) {
return Collections.emptyList();
}
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.in(UserObjectRoleDO::getObjectId, objectIds)
.eq(UserObjectRoleDO::getStatus, 0));
}
}

View File

@@ -277,4 +277,27 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
.eq(ProjectExecutionDO::getStatusCode, fromStatus));
}
/**
* 接口二:一批项目下的进行中执行数(按 project_id 分组,排除终态)。
* 返回 MapprojectId(Long) / executionCount(Long)。
*/
@Select("""
<script>
SELECT project_id AS projectId,
CAST(COUNT(*) AS SIGNED) AS executionCount
FROM rdms_project_execution
WHERE deleted = b'0'
AND project_id IN
<foreach collection="projectIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
GROUP BY project_id
</script>
""")
List<Map<String, Object>> selectExecutionCountGroupByProjectIds(
@Param("projectIds") Collection<Long> projectIds,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes);
}

View File

@@ -692,4 +692,91 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
private Long count;
}
// ======================== 工作台「我的项目」聚合计数 ========================
/**
* 接口一:当前用户(owner_id)在一批项目下的任务总数与未完成数(按 project_id 分组)。
* totalCount=全部我负责任务pendingCount=状态非终态(终态集为空则等于 totalCount
* 返回 MapprojectId(Long) / totalCount(Long) / pendingCount(Long)。
*/
@Select("""
<script>
SELECT project_id AS projectId,
CAST(COUNT(*) AS SIGNED) AS totalCount,
CAST(SUM(CASE WHEN 1 = 1
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS pendingCount
FROM rdms_task
WHERE deleted = b'0'
AND owner_id = #{ownerId}
AND project_id IN
<foreach collection="projectIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
GROUP BY project_id
</script>
""")
List<Map<String, Object>> selectMyTaskCountGroupByProjectIds(
@Param("ownerId") Long ownerId,
@Param("projectIds") Collection<Long> projectIds,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes);
/**
* 接口二:一批项目下的进行中任务数与逾期任务数(按 project_id 分组,一次扫表出两数)。
* taskCount=状态非终态overdueCount=planned_end_date &lt; today 且状态非终态。
* 返回 MapprojectId(Long) / taskCount(Long) / overdueCount(Long)。
*/
@Select("""
<script>
SELECT project_id AS projectId,
CAST(SUM(CASE WHEN 1 = 1
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS taskCount,
CAST(SUM(CASE WHEN planned_end_date &lt; #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS overdueCount
FROM rdms_task
WHERE deleted = b'0'
AND project_id IN
<foreach collection="projectIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
GROUP BY project_id
</script>
""")
List<Map<String, Object>> selectTaskAndOverdueCountGroupByProjectIds(
@Param("projectIds") Collection<Long> projectIds,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
@Param("today") LocalDate today);
/**
* 接口二 members一批项目下每个负责人(owner_id)的进行中任务数(按 project_id, owner_id 分组)。
* 排除 owner_id 为空的任务。返回 MapprojectId(Long) / ownerId(Long) / activeTaskCount(Long)。
*/
@Select("""
<script>
SELECT project_id AS projectId,
owner_id AS ownerId,
CAST(COUNT(*) AS SIGNED) AS activeTaskCount
FROM rdms_task
WHERE deleted = b'0'
AND owner_id IS NOT NULL
AND project_id IN
<foreach collection="projectIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
GROUP BY project_id, owner_id
</script>
""")
List<Map<String, Object>> selectActiveTaskCountGroupByProjectIdAndOwner(
@Param("projectIds") Collection<Long> projectIds,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes);
}

View File

@@ -79,6 +79,18 @@ public interface ObjectStatusTransitionMapper extends BaseMapperX<ObjectStatusTr
.eq(ObjectStatusTransitionDO::getToStatusCode, statusCode)));
}
/**
* 反查动作中文名:同 objectType + action_code 下 action_name 唯一一致(已核实),取任一行。
* 供错误提示等用户可见文案使用;查不到返回 null由上层回退到原 actionCode。
*/
default String selectActionNameByObjectTypeAndAction(String objectType, String actionCode) {
List<ObjectStatusTransitionDO> list = selectList(new LambdaQueryWrapperX<ObjectStatusTransitionDO>()
.eq(ObjectStatusTransitionDO::getObjectType, objectType)
.eq(ObjectStatusTransitionDO::getActionCode, actionCode)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0).getActionName();
}
/**
* 物理删除
*/

View File

@@ -32,4 +32,10 @@ public @interface CheckObjectPermission {
*/
boolean memberOnly() default false;
/**
* 是否走「可访问性门禁」:显式成员 OR 数据范围 scope 兜底(与 getXxxContext 入口口径一致)。
* 为 true 时切面调用 checkAccessible忽略 permission / memberOnly优先级 accessible > memberOnly > permission
*/
boolean accessible() default false;
}

View File

@@ -41,8 +41,13 @@ public class ObjectPermissionAspect {
throw invalidParamException("暂不支持对象类型:{}", checkObjectPermission.objectType());
}
Long objectId = resolveObjectId(joinPoint, checkObjectPermission.objectId());
permissionService.checkPermission(objectId, checkObjectPermission.permission(),
checkObjectPermission.memberOnly());
// 分发优先级accessible可访问性门禁> memberOnly / permission(权限码)
if (checkObjectPermission.accessible()) {
permissionService.checkAccessible(objectId);
} else {
permissionService.checkPermission(objectId, checkObjectPermission.permission(),
checkObjectPermission.memberOnly());
}
return joinPoint.proceed();
}

View File

@@ -31,4 +31,12 @@ public interface ObjectPermissionService {
*/
boolean hasPermission(Long objectId, String permission);
/**
* 可访问性门禁:当前登录用户是否「能进入」该对象(显式成员 OR 数据范围 scope 兜底)。
* 不可访问(含对象不存在)一律抛 ..._OBJECT_PERMISSION_DENIED不暴露对象是否存在见 spec §3.3)。
*
* @param objectId 对象编号
*/
void checkAccessible(Long objectId);
}

View File

@@ -4,8 +4,12 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
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.permission.ObjectPermissionApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -32,6 +36,10 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private ObjectPermissionApi objectPermissionApi;
@Resource
private ProductMapper productMapper;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Override
public String getObjectType() {
@@ -57,8 +65,9 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (userRoles.isEmpty()) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED,
buildDeniedPermission(permission, memberOnly));
// 权限码/成员标记仅作技术诊断,落日志不外泄给用户(见 用户可见错误文案规范)
log.warn("[checkPermission] 用户无对象角色objectId={}, permission={}, memberOnly={}", objectId, permission, memberOnly);
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED);
}
if (memberOnly) {
return;
@@ -70,7 +79,34 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
.distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
if (!allowed) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, normalizedPermission);
log.warn("[checkPermission] 缺少对象权限码objectId={}, permission={}", objectId, normalizedPermission);
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED);
}
}
@Override
public void checkAccessible(Long objectId) {
if (objectId == null) {
throw invalidParamException("对象编号不能为空");
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 显式成员:拥有任一 ACTIVE 对象角色即可访问
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (!userRoles.isEmpty()) {
return;
}
// 无显式角色:查对象拿 directionCode按数据范围 scope 兜底(隐式 observer / 超管 ALL
ProductDO product = productMapper.selectById(objectId);
if (product == null) {
// spec §3.3 定稿:对象不存在一律 DENIED不暴露存在性技术诊断落 log.warn
log.warn("[checkAccessible] 对象不存在或无访问权objectId={}", objectId);
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED);
}
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
if (!scope.contains(objectId, product.getDirectionCode())) {
log.warn("[checkAccessible] 无对象访问权objectId={}", objectId);
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED);
}
}
@@ -95,8 +131,4 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
return permission.trim();
}
private String buildDeniedPermission(String permission, boolean memberOnly) {
return memberOnly ? "member" : normalizePermission(permission);
}
}

View File

@@ -5,8 +5,12 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
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.permission.ObjectPermissionApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -33,6 +37,10 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private ObjectPermissionApi objectPermissionApi;
@Resource
private ProjectMapper projectMapper;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Override
public String getObjectType() {
@@ -49,8 +57,9 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (userRoles.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED,
buildDeniedPermission(permission, memberOnly));
// 权限码/成员标记仅作技术诊断,落日志不外泄给用户(见 用户可见错误文案规范)
log.warn("[checkPermission] 用户无对象角色objectId={}, permission={}, memberOnly={}", objectId, permission, memberOnly);
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED);
}
if (memberOnly) {
return;
@@ -62,7 +71,34 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
.distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
if (!allowed) {
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, normalizedPermission);
log.warn("[checkPermission] 缺少对象权限码objectId={}, permission={}", objectId, normalizedPermission);
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED);
}
}
@Override
public void checkAccessible(Long objectId) {
if (objectId == null) {
throw invalidParamException("对象编号不能为空");
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 显式成员:拥有任一 ACTIVE 对象角色即可访问
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (!userRoles.isEmpty()) {
return;
}
// 无显式角色:查对象拿 directionCode按数据范围 scope 兜底(隐式 observer / 超管 ALL
ProjectDO project = projectMapper.selectById(objectId);
if (project == null) {
// spec §3.3 定稿:对象不存在一律 DENIED不暴露存在性技术诊断落 log.warn
log.warn("[checkAccessible] 对象不存在或无访问权objectId={}", objectId);
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED);
}
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
if (!scope.contains(objectId, project.getDirectionCode())) {
log.warn("[checkAccessible] 无对象访问权objectId={}", objectId);
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED);
}
}
@@ -113,8 +149,4 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
return permission.trim();
}
private String buildDeniedPermission(String permission, boolean memberOnly) {
return memberOnly ? "member" : normalizePermission(permission);
}
}

View File

@@ -25,6 +25,7 @@ import com.njcn.rdms.module.project.dal.mysql.overtime.OvertimeApplicationStatus
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.service.status.StatusActionTextResolver;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@@ -58,6 +59,8 @@ public class OvertimeApplicationServiceImpl implements OvertimeApplicationServic
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Resource
private AdminUserApi adminUserApi;
@Override
@@ -266,10 +269,13 @@ public class OvertimeApplicationServiceImpl implements OvertimeApplicationServic
.selectByObjectTypeAndFromStatusAndAction(OvertimeApplicationConstants.STATUS_OBJECT_TYPE, fromStatus,
actionCode);
if (transition == null) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(OvertimeApplicationConstants.STATUS_OBJECT_TYPE, fromStatus),
statusActionTextResolver.actionName(OvertimeApplicationConstants.STATUS_OBJECT_TYPE, actionCode));
}
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_ACTION_REASON_REQUIRED, actionCode);
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_ACTION_REASON_REQUIRED,
statusActionTextResolver.actionName(OvertimeApplicationConstants.STATUS_OBJECT_TYPE, actionCode));
}
ObjectStatusModelDO toModel = objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled(
OvertimeApplicationConstants.STATUS_OBJECT_TYPE, transition.getToStatusCode());

View File

@@ -30,6 +30,7 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMappe
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@@ -83,6 +84,8 @@ public class PersonalItemServiceImpl implements PersonalItemService {
private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource
private AdminUserApi adminUserApi;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -170,11 +173,13 @@ public class PersonalItemServiceImpl implements PersonalItemService {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus, actionCode);
if (transition == null) {
throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus),
statusActionTextResolver.actionName(PersonalItemConstants.STATUS_OBJECT_TYPE, actionCode));
}
String reason = normalizeNullableText(reqVO.getReason());
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_ACTION_REASON_REQUIRED, actionCode);
throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_ACTION_REASON_REQUIRED, transition.getActionName());
}
String toStatus = transition.getToStatusCode();
int updateCount = personalItemMapper.updateStatusByIdAndStatus(item.getId(), fromStatus, toStatus, reason);

View File

@@ -65,6 +65,7 @@ public class ProductMemberServiceImpl implements ProductMemberService {
private AdminUserApi adminUserApi;
@Override
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true)
public List<ProductMemberRespVO> getProductMemberList(Long productId) {
ProductDO product = validateProductExists(productId);
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId);

View File

@@ -125,6 +125,8 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
// ========== 需求增删改查 ==========
@@ -1229,7 +1231,9 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
*/
private void validateReviewRejectedActionAllowed(ProductRequirementDO requirement, String actionCode) {
if (!isReviewRejectedActionAllowed(requirement, actionCode)) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(REQUIREMENT_OBJECT_TYPE, requirement.getStatusCode()),
statusActionTextResolver.actionName(REQUIREMENT_OBJECT_TYPE, actionCode));
}
}
@@ -1533,7 +1537,9 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
ObjectStatusTransitionDO transition = statusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(REQUIREMENT_OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(REQUIREMENT_OBJECT_TYPE, fromStatusCode),
statusActionTextResolver.actionName(REQUIREMENT_OBJECT_TYPE, actionCode));
}
return transition;
}
@@ -1544,7 +1550,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
@VisibleForTesting
void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) {
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode());
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_REASON_REQUIRED, transition.getActionName());
}
}

View File

@@ -89,6 +89,8 @@ public class ProductServiceImpl implements ProductService {
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Resource
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -274,7 +276,7 @@ public class ProductServiceImpl implements ProductService {
// 显式角色为空:走 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, "查看");
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED);
}
return buildImplicitObserverContext(product);
}
@@ -568,7 +570,9 @@ public class ProductServiceImpl implements ProductService {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProductObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) {
throw exception(ErrorCodeConstants.PRODUCT_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.PRODUCT_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(ProductObjectConstants.OBJECT_TYPE, fromStatusCode),
statusActionTextResolver.actionName(ProductObjectConstants.OBJECT_TYPE, actionCode));
}
return transition;
}
@@ -576,7 +580,7 @@ public class ProductServiceImpl implements ProductService {
@VisibleForTesting
void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) {
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
throw exception(ErrorCodeConstants.PRODUCT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode());
throw exception(ErrorCodeConstants.PRODUCT_STATUS_ACTION_REASON_REQUIRED, transition.getActionName());
}
}

View File

@@ -11,7 +11,9 @@ import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductS
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingRespVO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@@ -34,6 +36,7 @@ public class ProductSettingServiceImpl implements ProductSettingService {
private ProductActivityTimelineQueryService productActivityTimelineQueryService;
@Override
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true)
public ProductSettingRespVO getProductSettings(Long productId) {
ProductDO product = validateProductExists(productId);
ProductSettingRespVO respVO = new ProductSettingRespVO();
@@ -43,12 +46,14 @@ public class ProductSettingServiceImpl implements ProductSettingService {
}
@Override
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true)
public PageResult<ProductActivityRespVO> getProductActivities(Long productId, ProductActivityPageReqVO reqVO) {
validateProductExists(productId);
return productActivityQueryService.getProductActivities(productId, reqVO);
}
@Override
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true)
public PageResult<ProductActivityTimelineRespVO> getProductActivityTimelinePage(
Long productId, ProductActivityTimelinePageReqVO reqVO) {
validateProductExists(productId);

View File

@@ -0,0 +1,19 @@
package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectOwnedRespVO;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectParticipatedRespVO;
/**
* 工作台「我的项目」Service按登录用户隐式聚合无权限注解。
*/
public interface MyProjectService {
/** 我参与的项目(作为成员) */
PageResult<MyProjectParticipatedRespVO> getMyParticipatedPage(MyProjectPageReqVO reqVO);
/** 我负责的项目managerUserId = 登录用户) */
PageResult<MyProjectOwnedRespVO> getMyOwnedPage(MyProjectPageReqVO reqVO);
}

View File

@@ -0,0 +1,344 @@
package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.framework.common.pojo.PageParam;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectOwnedRespVO;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectParticipatedRespVO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
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.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class MyProjectServiceImpl implements MyProjectService {
@Resource
private ProjectMapper projectMapper;
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private ProjectExecutionMapper projectExecutionMapper;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private ObjectPermissionApi objectPermissionApi;
@Resource
private AdminUserApi adminUserApi;
/** 工作台「我的项目」列表统一排序按项目创建时间升序先创建的在前id 兜底保证稳定。 */
private static final Comparator<ProjectDO> PROJECT_CREATE_TIME_ASC =
Comparator.comparing(ProjectDO::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(ProjectDO::getId);
@Override
public PageResult<MyProjectParticipatedRespVO> getMyParticipatedPage(MyProjectPageReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 1. 我参与的所有 active 角色行(含 manager/dev 等多角色objectId=项目id
List<UserObjectRoleDO> myRoles = userObjectRoleMapper
.selectActiveListByObjectTypeAndUserId(ProjectObjectConstants.OBJECT_TYPE, loginUserId);
if (myRoles.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 2. 按项目分组我的角色行
Map<Long, List<UserObjectRoleDO>> rolesByProject = myRoles.stream()
.filter(r -> r.getObjectId() != null)
.collect(Collectors.groupingBy(UserObjectRoleDO::getObjectId, LinkedHashMap::new, Collectors.toList()));
Set<Long> projectIds = new LinkedHashSet<>(rolesByProject.keySet());
// 3. 项目基本信息
List<ProjectDO> projects = projectMapper.selectBatchIds(projectIds);
if (projects.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 3.1 仅保留非终态项目(终态项目不在工作台「我的项目」体现),并按创建时间升序(先创建的在前)
List<String> projectTerminal = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE);
projects = projects.stream()
.filter(p -> !projectTerminal.contains(p.getStatusCode()))
.sorted(PROJECT_CREATE_TIME_ASC)
.collect(Collectors.toList());
if (projects.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 4. statusName 批量回填
Map<String, String> statusNameMap = loadStatusNameMap(ProjectObjectConstants.OBJECT_TYPE);
// 5. 角色名 map一次性拉全部涉及 roleId
Map<Long, ObjectRoleRespDTO> roleMap = loadRoleMap(myRoles.stream()
.map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
// 5.1 每个项目下"我的可见角色行":剔除 visible=0 的隐式角色(创建者 / 隐式观察者等业务自动赋予角色)。
// 若某项目下我没有任何可见角色,则不算"我参与的项目",整项剔除——与 ProjectMemberServiceImpl 团队列表口径一致。
Map<Long, List<UserObjectRoleDO>> visibleRolesByProject = new LinkedHashMap<>();
rolesByProject.forEach((pid, rows) -> {
List<UserObjectRoleDO> visible = filterVisibleRoleRows(rows, roleMap);
if (!visible.isEmpty()) {
visibleRolesByProject.put(pid, visible);
}
});
// 6. 我负责的任务计数owner=me按项目分组 total + pending
List<String> taskTerminal = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
Map<Long, long[]> taskCountMap = new LinkedHashMap<>();
for (Map<String, Object> row : projectTaskMapper
.selectMyTaskCountGroupByProjectIds(loginUserId, projectIds, taskTerminal)) {
taskCountMap.put(asLong(row.get("projectId")),
new long[]{asLong(row.get("totalCount")), asLong(row.get("pendingCount"))});
}
// 7. 组装(仅保留我有可见角色的项目)
List<MyProjectParticipatedRespVO> all = projects.stream()
.filter(p -> visibleRolesByProject.containsKey(p.getId()))
.map(p -> {
MyProjectParticipatedRespVO vo = new MyProjectParticipatedRespVO();
vo.setId(p.getId());
vo.setName(p.getProjectName());
vo.setCode(p.getProjectCode());
vo.setStatusCode(p.getStatusCode());
vo.setStatusName(statusNameMap.get(p.getStatusCode()));
vo.setProgress(toProgressInt(p.getProgressRate()));
vo.setMyRole(buildMyRole(visibleRolesByProject.get(p.getId()), roleMap));
long[] c = taskCountMap.getOrDefault(p.getId(), new long[]{0L, 0L});
vo.setMyTaskCount((int) c[0]);
vo.setMyPendingTaskCount((int) c[1]);
return vo;
}).collect(Collectors.toList());
return paginate(all, reqVO);
}
// ======================== 共用私有方法 ========================
private Map<String, String> loadStatusNameMap(String objectType) {
return objectStatusModelMapper.selectListByObjectTypeEnabled(objectType).stream()
.collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode,
ObjectStatusModelDO::getStatusName, (a, b) -> a));
}
private Map<Long, ObjectRoleRespDTO> loadRoleMap(Set<Long> roleIds) {
if (roleIds.isEmpty()) {
return Collections.emptyMap();
}
List<ObjectRoleRespDTO> roles = objectPermissionApi
.getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
if (roles == null || roles.isEmpty()) {
return Collections.emptyMap();
}
return roles.stream().collect(Collectors.toMap(ObjectRoleRespDTO::getId, Function.identity(), (a, b) -> a));
}
/** 可见角色行:剔除 visible=0 的隐式角色(创建者 / 隐式观察者等业务自动赋予角色visible=null 或 roleMap 缺失视同可见。 */
private List<UserObjectRoleDO> filterVisibleRoleRows(List<UserObjectRoleDO> rows, Map<Long, ObjectRoleRespDTO> roleMap) {
if (rows == null || rows.isEmpty()) {
return Collections.emptyList();
}
return rows.stream()
.filter(r -> {
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
return role == null || !Integer.valueOf(0).equals(role.getVisible());
})
.collect(Collectors.toList());
}
/**
* 主角色 + 附加角色名拼接。入参为已过滤的可见角色行visible=0 隐式角色已在上游剔除)。
* 主角色挑选与 ProjectMemberServiceImpl 一致MANAGER 优先,否则 roleId 最小。
*/
private String buildMyRole(List<UserObjectRoleDO> rowsVisible, Map<Long, ObjectRoleRespDTO> roleMap) {
if (rowsVisible == null || rowsVisible.isEmpty()) {
return null;
}
UserObjectRoleDO primary = rowsVisible.stream()
.filter(r -> {
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
return role != null && Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, role.getCode());
})
.findFirst()
.orElseGet(() -> rowsVisible.stream()
.min(Comparator.comparing(UserObjectRoleDO::getRoleId))
.orElse(rowsVisible.get(0)));
String primaryName = roleName(roleMap, primary.getRoleId());
List<String> additional = rowsVisible.stream()
.filter(r -> !Objects.equals(r.getId(), primary.getId()))
.map(r -> roleName(roleMap, r.getRoleId()))
.filter(Objects::nonNull)
.sorted()
.collect(Collectors.toList());
StringBuilder sb = new StringBuilder(primaryName == null ? "" : primaryName);
for (String n : additional) {
if (sb.length() > 0) {
sb.append(" / ");
}
sb.append(n);
}
return sb.length() == 0 ? null : sb.toString();
}
private String roleName(Map<Long, ObjectRoleRespDTO> roleMap, Long roleId) {
ObjectRoleRespDTO role = roleMap.get(roleId);
return role == null ? null : role.getName();
}
private Integer toProgressInt(BigDecimal v) {
return v == null ? 0 : v.setScale(0, RoundingMode.HALF_UP).intValue();
}
private long asLong(Object v) {
return v == null ? 0L : ((Number) v).longValue();
}
private <T> PageResult<T> paginate(List<T> all, PageParam reqVO) {
long total = all.size();
Integer pageSize = reqVO.getPageSize();
if (pageSize == null || pageSize < 0) {
return new PageResult<>(all, total);
}
int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo();
int fromIndex = Math.min((pageNo - 1) * pageSize, all.size());
int toIndex = Math.min(fromIndex + pageSize, all.size());
return new PageResult<>(all.subList(fromIndex, toIndex), total);
}
@Override
public PageResult<MyProjectOwnedRespVO> getMyOwnedPage(MyProjectPageReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 1. 我负责的项目managerUserId = 登录用户)
List<ProjectDO> projects = projectMapper.selectList(new LambdaQueryWrapperX<ProjectDO>()
.eq(ProjectDO::getManagerUserId, loginUserId));
if (projects.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 1.1 仅保留非终态项目(终态项目不在工作台「我的项目」体现),并按创建时间升序(先创建的在前)
List<String> projectTerminal = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE);
projects = projects.stream()
.filter(p -> !projectTerminal.contains(p.getStatusCode()))
.sorted(PROJECT_CREATE_TIME_ASC)
.collect(Collectors.toList());
if (projects.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
Set<Long> projectIds = projects.stream()
.map(ProjectDO::getId).collect(Collectors.toCollection(LinkedHashSet::new));
// 2. 终态集
List<String> taskTerminal = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
List<String> execTerminal = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
LocalDate today = LocalDate.now();
// 3. 任务数 + 逾期数(一次扫表)
Map<Long, long[]> taskMap = new LinkedHashMap<>();
for (Map<String, Object> row : projectTaskMapper
.selectTaskAndOverdueCountGroupByProjectIds(projectIds, taskTerminal, today)) {
taskMap.put(asLong(row.get("projectId")),
new long[]{asLong(row.get("taskCount")), asLong(row.get("overdueCount"))});
}
// 4. 执行数
Map<Long, Long> execMap = new LinkedHashMap<>();
for (Map<String, Object> row : projectExecutionMapper
.selectExecutionCountGroupByProjectIds(projectIds, execTerminal)) {
execMap.put(asLong(row.get("projectId")), asLong(row.get("executionCount")));
}
// 5. 每个负责人(owner)的进行中任务数projectId -> (ownerId -> count)
Map<Long, Map<Long, Long>> activeTaskMap = new LinkedHashMap<>();
for (Map<String, Object> row : projectTaskMapper
.selectActiveTaskCountGroupByProjectIdAndOwner(projectIds, taskTerminal)) {
Long pid = asLong(row.get("projectId"));
Long ownerId = asLong(row.get("ownerId"));
activeTaskMap.computeIfAbsent(pid, k -> new LinkedHashMap<>())
.put(ownerId, asLong(row.get("activeTaskCount")));
}
// 6. 成员清单(批量一次拿全,内存按项目分组;同 user 多角色去重为一个成员)
List<UserObjectRoleDO> memberRows = userObjectRoleMapper
.selectActiveListByObjectTypeAndObjectIds(ProjectObjectConstants.OBJECT_TYPE, projectIds);
Map<Long, List<Long>> memberUserIdsByProject = new LinkedHashMap<>();
for (UserObjectRoleDO m : memberRows) {
if (m.getObjectId() == null || m.getUserId() == null) {
continue;
}
List<Long> users = memberUserIdsByProject.computeIfAbsent(m.getObjectId(), k -> new ArrayList<>());
if (!users.contains(m.getUserId())) {
users.add(m.getUserId());
}
}
// 7. 成员昵称批量回填
Set<Long> allUserIds = memberUserIdsByProject.values().stream()
.flatMap(List::stream).collect(Collectors.toSet());
Map<Long, AdminUserRespDTO> userMap = allUserIds.isEmpty()
? Collections.emptyMap() : adminUserApi.getUserMap(allUserIds);
// 8. myRole 恒为负责人角色名(一次性解析)
String managerRoleName = resolveManagerRoleName();
// 9. 组装
List<MyProjectOwnedRespVO> all = projects.stream().map(p -> {
MyProjectOwnedRespVO vo = new MyProjectOwnedRespVO();
vo.setId(p.getId());
vo.setName(p.getProjectName());
vo.setCode(p.getProjectCode());
vo.setProgress(toProgressInt(p.getProgressRate()));
vo.setMyRole(managerRoleName);
vo.setPlannedEndDate(p.getPlannedEndDate());
long[] tc = taskMap.getOrDefault(p.getId(), new long[]{0L, 0L});
vo.setTaskCount((int) tc[0]);
vo.setOverdueCount((int) tc[1]);
vo.setExecutionCount(execMap.getOrDefault(p.getId(), 0L).intValue());
List<Long> memberUserIds = memberUserIdsByProject.getOrDefault(p.getId(), Collections.emptyList());
vo.setMemberCount(memberUserIds.size());
Map<Long, Long> ownerCounts = activeTaskMap.getOrDefault(p.getId(), Collections.emptyMap());
List<MyProjectOwnedRespVO.MemberLoadVO> members = memberUserIds.stream().map(uid -> {
MyProjectOwnedRespVO.MemberLoadVO mv = new MyProjectOwnedRespVO.MemberLoadVO();
mv.setUserId(uid);
AdminUserRespDTO user = userMap.get(uid);
mv.setUserName(user == null ? null : user.getNickname());
mv.setActiveTaskCount(ownerCounts.getOrDefault(uid, 0L).intValue());
return mv;
}).collect(Collectors.toList());
vo.setMembers(members);
return vo;
}).collect(Collectors.toList());
return paginate(all, reqVO);
}
/** 项目负责人角色名(对象域 MANAGER_ROLE_CODE解析失败返回 null不阻断列表。 */
private String resolveManagerRoleName() {
try {
ObjectRoleRespDTO role = objectPermissionApi
.getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE,
ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
return role == null ? null : role.getName();
} catch (RuntimeException ex) {
return null;
}
}
}

View File

@@ -69,6 +69,7 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
private ProjectExecutionMapper projectExecutionMapper;
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public List<ProjectMemberRespVO> getProjectMemberList(Long projectId) {
ProjectDO project = validateProjectExists(projectId);
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId);

View File

@@ -41,6 +41,7 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMappe
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -131,6 +132,8 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource
private ProjectExecutionMapper projectExecutionMapper;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -882,7 +885,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
*/
private void validateReviewRejectedActionAllowed(ProjectRequirementDO requirement, String actionCode) {
if (!isReviewRejectedActionAllowed(requirement, actionCode)) {
throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(REQUIREMENT_OBJECT_TYPE, requirement.getStatusCode()),
statusActionTextResolver.actionName(REQUIREMENT_OBJECT_TYPE, actionCode));
}
}
@@ -1297,7 +1302,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
ObjectStatusTransitionDO transition = statusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(REQUIREMENT_OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) {
throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(REQUIREMENT_OBJECT_TYPE, fromStatusCode),
statusActionTextResolver.actionName(REQUIREMENT_OBJECT_TYPE, actionCode));
}
return transition;
}
@@ -1305,7 +1312,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
@VisibleForTesting
void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) {
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode());
throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_REASON_REQUIRED, transition.getActionName());
}
}

View File

@@ -45,6 +45,7 @@ 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.project.service.status.StatusActionTextResolver;
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;
@@ -116,6 +117,8 @@ class ProjectServiceImpl implements ProjectService {
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -393,7 +396,7 @@ class ProjectServiceImpl implements ProjectService {
// 显式角色为空:走 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, "查看");
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED);
}
return buildImplicitObserverContext(project);
}
@@ -580,7 +583,9 @@ class ProjectServiceImpl implements ProjectService {
ProjectDO project = validateProjectExists(reqVO.getId());
String actionCode = reqVO.getActionCode().trim();
if (ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(actionCode)) {
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()),
statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, actionCode));
}
changeStatus(project, actionCode, normalizeNullableText(reqVO.getReason()));
}
@@ -613,10 +618,12 @@ class ProjectServiceImpl implements ProjectService {
@Transactional(rollbackFor = Exception.class)
public void autoStartProjectIfPending(Long projectId, String triggerAction) {
// auto_start 只允许由后端业务动作内部触发,前端不应直接透传该动作。
if (!ProjectObjectConstants.AUTO_START_TRIGGERS.contains(triggerAction)) {
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, triggerAction);
}
ProjectDO project = validateProjectExists(projectId);
if (!ProjectObjectConstants.AUTO_START_TRIGGERS.contains(triggerAction)) {
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()),
statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, triggerAction));
}
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode(),
ObjectActivityConstants.PROJECT_ACTION_AUTO_START);
@@ -625,7 +632,9 @@ class ProjectServiceImpl implements ProjectService {
ObjectStatusModelDO statusModel = validateEnabledStatusModel(project.getStatusCode());
if (Boolean.TRUE.equals(statusModel.getInitialFlag())) {
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED,
ObjectActivityConstants.PROJECT_ACTION_AUTO_START);
statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()),
statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE,
ObjectActivityConstants.PROJECT_ACTION_AUTO_START));
}
if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) {
throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT);
@@ -772,10 +781,12 @@ class ProjectServiceImpl implements ProjectService {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) {
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, fromStatusCode),
statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, actionCode));
}
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode());
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_REASON_REQUIRED, transition.getActionName());
}
return transition;
}

View File

@@ -78,6 +78,8 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
private AdminUserApi adminUserApi;
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId) {
validateProjectExists(projectId);
validateExecutionExists(projectId, executionId);
@@ -150,6 +152,8 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId,
ExecutionAssigneeLogPageReqVO reqVO) {
validateProjectExists(projectId);

View File

@@ -1,6 +1,8 @@
package com.njcn.rdms.module.project.service.project.execution;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
@@ -38,6 +40,13 @@ public interface ProjectExecutionService {
List<ProjectExecutionRespVO> getCurrentUserExecutionList();
/**
* 分页查询当前登录用户作为负责人owner的执行跨所有项目聚合。
* 默认口径排除终态状态completed/cancelled且排除进度已满progressRate >= 100的执行。
* pageSize 传 -1PageParam.PAGE_SIZE_NONE= 返回全部、不切片。
*/
PageResult<MyProjectExecutionRespVO> getMyExecutionPage(MyProjectExecutionPageReqVO reqVO);
void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO);
/**

View File

@@ -9,6 +9,8 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
@@ -46,6 +48,7 @@ import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPer
import com.njcn.rdms.module.project.service.project.ProjectRequirementService;
import com.njcn.rdms.module.project.service.project.ProjectService;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
@@ -118,6 +121,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
private ProjectRequirementService projectRequirementService;
@Resource
private ProjectRequirementMapper projectRequirementMapper;
@Resource
private StatusActionTextResolver statusActionTextResolver;
/**
* 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。
* 与 ProjectTaskService 互相依赖(任务侧已注入 ProjectExecutionService 用于通知执行),用 @Lazy 打破循环。
@@ -302,6 +307,91 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return list;
}
@Override
public PageResult<MyProjectExecutionRespVO> getMyExecutionPage(MyProjectExecutionPageReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
List<ProjectExecutionDO> executions = projectExecutionMapper.selectListByOwnerId(loginUserId);
if (executions == null || executions.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 1. 排除终态状态completed/cancelledDB 权威,不硬编码)
List<String> terminalStatusCodes =
objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
List<ProjectExecutionDO> nonTerminal = executions.stream()
.filter(e -> terminalStatusCodes == null || !terminalStatusCodes.contains(e.getStatusCode()))
.collect(Collectors.toList());
if (nonTerminal.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 2. 按 projectId 分组,逐组聚合一级任务真实进度(复用 loadExecutionProgressMap
List<String> excludedTaskStatusCodes = loadProgressExcludedTaskStatusCodes();
Map<Long, BigDecimal> progressMap = new HashMap<>();
nonTerminal.stream()
.filter(e -> e.getProjectId() != null)
.collect(Collectors.groupingBy(ProjectExecutionDO::getProjectId, LinkedHashMap::new, Collectors.toList()))
.forEach((groupProjectId, groupList) -> {
Set<Long> executionIds = groupList.stream()
.map(ProjectExecutionDO::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
progressMap.putAll(loadExecutionProgressMap(groupProjectId, executionIds, excludedTaskStatusCodes));
});
// 3. 排除进度已满progressRate >= 100缺失进度按 0 处理
BigDecimal full = BigDecimal.valueOf(100);
List<ProjectExecutionDO> filtered = nonTerminal.stream()
.filter(e -> progressMap.getOrDefault(e.getId(), normalizeProgress(null)).compareTo(full) < 0)
.collect(Collectors.toList());
if (filtered.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 4. 批量回填 projectName / statusName / projectRequirementName
Set<Long> projectIds = filtered.stream()
.map(ProjectExecutionDO::getProjectId).filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, String> projectNameMap = projectIds.isEmpty() ? Collections.emptyMap()
: projectMapper.selectBatchIds(projectIds).stream()
.collect(Collectors.toMap(ProjectDO::getId, ProjectDO::getProjectName, (a, b) -> a));
Map<String, String> statusNameMap = objectStatusModelMapper
.selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE).stream()
.collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode, ObjectStatusModelDO::getStatusName, (a, b) -> a));
Set<Long> requirementIds = filtered.stream()
.map(ProjectExecutionDO::getProjectRequirementId).filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, String> requirementNameMap = requirementIds.isEmpty() ? Collections.emptyMap()
: projectRequirementMapper.selectBatchIds(requirementIds).stream()
.collect(Collectors.toMap(ProjectRequirementDO::getId, ProjectRequirementDO::getTitle, (a, b) -> a));
// 5. 组装精简 VOprogressRate BigDecimal → Integer 四舍五入)
List<MyProjectExecutionRespVO> all = filtered.stream().map(e -> {
MyProjectExecutionRespVO vo = new MyProjectExecutionRespVO();
vo.setId(e.getId());
vo.setExecutionName(e.getExecutionName());
vo.setProjectId(e.getProjectId());
vo.setProjectName(projectNameMap.get(e.getProjectId()));
vo.setStatusCode(e.getStatusCode());
vo.setStatusName(statusNameMap.get(e.getStatusCode()));
vo.setPriority(e.getPriority());
vo.setPlannedStartDate(e.getPlannedStartDate());
vo.setPlannedEndDate(e.getPlannedEndDate());
vo.setActualStartDate(e.getActualStartDate());
vo.setActualEndDate(e.getActualEndDate());
BigDecimal progress = progressMap.getOrDefault(e.getId(), normalizeProgress(null));
vo.setProgressRate(progress.setScale(0, RoundingMode.HALF_UP).intValue());
vo.setProjectRequirementId(e.getProjectRequirementId());
vo.setProjectRequirementName(requirementNameMap.get(e.getProjectRequirementId()));
return vo;
}).collect(Collectors.toList());
// 6. 分页pageSize<0PAGE_SIZE_NONE= 全部;否则内存切片(兼容,前端本期不使用)
long total = all.size();
Integer pageSize = reqVO.getPageSize();
if (pageSize == null || pageSize < 0) {
return new PageResult<>(all, total);
}
int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo();
int fromIndex = Math.min((pageNo - 1) * pageSize, all.size());
int toIndex = Math.min(fromIndex + pageSize, all.size());
return new PageResult<>(all.subList(fromIndex, toIndex), total);
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
@@ -489,10 +579,12 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProjectExecutionConstants.OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(ProjectExecutionConstants.OBJECT_TYPE, fromStatusCode),
statusActionTextResolver.actionName(ProjectExecutionConstants.OBJECT_TYPE, actionCode));
}
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode());
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED, transition.getActionName());
}
return transition;
}
@@ -1017,7 +1109,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(loginUserId, execution.getOwnerId())) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_OWNER_ONLY,
resolveActionDisplayName(actionCode));
statusActionTextResolver.actionName(ProjectExecutionConstants.OBJECT_TYPE, actionCode));
}
}
@@ -1028,16 +1120,6 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|| "resume".equals(actionCode);
}
private String resolveActionDisplayName(String actionCode) {
return switch (actionCode) {
case "complete" -> "完成";
case "cancel" -> "取消";
case "pause" -> "暂停";
case "resume" -> "恢复";
default -> actionCode;
};
}
/**
* 完成执行前置校验执行下所有任务必须已经进入终态completed / cancelled
*/

View File

@@ -47,6 +47,7 @@ import com.njcn.rdms.module.project.service.project.ProjectService;
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService;
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService;
import com.njcn.rdms.module.project.service.project.task.worklog.TaskWorklogService;
import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
@@ -127,6 +128,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Resource
private DictDataApi dictDataApi;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -673,7 +676,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(loginUserId, task.getOwnerId())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_OWNER_ONLY,
resolveActionDisplayName(actionCode));
statusActionTextResolver.actionName(ProjectTaskConstants.OBJECT_TYPE, actionCode));
}
}
@@ -684,16 +687,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|| "resume".equals(actionCode);
}
private String resolveActionDisplayName(String actionCode) {
return switch (actionCode) {
case "complete" -> "完成";
case "cancel" -> "取消";
case "pause" -> "暂停";
case "resume" -> "恢复";
default -> actionCode;
};
}
/**
* 根据通用状态语义位推导实际开始/结束日期:
* - 首次离开初始态fromStatus.initialFlag=true且未填写时写入 actualStartDate
@@ -828,10 +821,12 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProjectTaskConstants.OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) {
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_NOT_ALLOWED, actionCode);
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_NOT_ALLOWED,
statusActionTextResolver.statusName(ProjectTaskConstants.OBJECT_TYPE, fromStatusCode),
statusActionTextResolver.actionName(ProjectTaskConstants.OBJECT_TYPE, actionCode));
}
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode());
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED, transition.getActionName());
}
return transition;
}

View File

@@ -69,6 +69,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
private AdminUserApi adminUserApi;
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public List<TaskAssigneeRespVO> getAssigneeList(Long projectId, Long executionId, Long taskId) {
validateExecutionAndTaskExists(projectId, executionId, taskId);
List<TaskAssigneeDO> activeList = taskAssigneeMapper.selectActiveListByTaskId(taskId);
@@ -121,6 +123,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public PageResult<TaskAssigneeLogRespVO> getAssigneeLogPage(Long projectId, Long executionId, Long taskId,
TaskAssigneeLogPageReqVO reqVO) {
validateExecutionAndTaskExists(projectId, executionId, taskId);

View File

@@ -79,6 +79,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
private ProjectTaskService projectTaskService;
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId,
TaskWorklogPageReqVO reqVO) {
validateExecutionAndTaskExists(projectId, executionId, taskId);

View File

@@ -0,0 +1,41 @@
package com.njcn.rdms.module.project.service.status;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 状态机文案解析器:把动作 code / 状态 code 翻成 DB 状态机里的中文展示名,
* 供错误提示等用户可见文案使用。查不到时回退原 code不抛错
* 权威源rdms_object_status_transition.action_name / rdms_object_status_model.status_name。
* 背景TD-012给用户看的 message 不外泄技术 token技术诊断由 infra_api_access_log 承载)。
*/
@Component
public class StatusActionTextResolver {
@Resource
private ObjectStatusTransitionMapper transitionMapper;
@Resource
private ObjectStatusModelMapper statusModelMapper;
/** 动作中文名;空入参或查不到时回退原 actionCode。 */
public String actionName(String objectType, String actionCode) {
if (!StringUtils.hasText(actionCode)) {
return actionCode;
}
String name = transitionMapper.selectActionNameByObjectTypeAndAction(objectType, actionCode);
return StringUtils.hasText(name) ? name : actionCode;
}
/** 状态中文名;空入参或查不到时回退原 statusCode。 */
public String statusName(String objectType, String statusCode) {
if (!StringUtils.hasText(statusCode)) {
return statusCode;
}
ObjectStatusModelDO model = statusModelMapper.selectByObjectTypeAndStatusCode(objectType, statusCode);
return model != null && StringUtils.hasText(model.getStatusName()) ? model.getStatusName() : statusCode;
}
}