fix(产品需求): 解决测试后存在的一些问题。

This commit is contained in:
dk
2026-05-09 13:44:38 +08:00
parent 7575784c01
commit 604bf61981
10 changed files with 365 additions and 182 deletions

View File

@@ -16,19 +16,19 @@ public class ProductRequirementRespVO {
@Schema(description = "需求ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long id;
@Schema(description = "父需求ID0表示顶级需求", example = "0")
@Schema(description = "父需求ID0 表示顶级需求", example = "0")
private Long parentId;
@Schema(description = "所属模块ID", example = "1024")
private Long moduleId;
@Schema(description = "是否需要评审0不需要1需要", example = "0")
@Schema(description = "是否需要评审0不需要1需要", example = "0")
private Integer reviewRequired;
@Schema(description = "需求标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "支持需求模块化管理")
private String title;
@Schema(description = "需求描述富文本", example = "<p>详细描述需求内容</p>")
@Schema(description = "需求描述,支持富文本", example = "<p>详细描述需求内容</p>")
private String description;
@Schema(description = "需求分类字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "function")
@@ -37,13 +37,13 @@ public class ProductRequirementRespVO {
@Schema(description = "需求分类名称", example = "功能需求")
private String categoryName;
@Schema(description = "来源类型manual:手工新增, work_order:工单流转", requiredMode = Schema.RequiredMode.REQUIRED, example = "manual")
@Schema(description = "需求来源类型manual 表示手工新增work_order 表示工单流转", requiredMode = Schema.RequiredMode.REQUIRED, example = "manual")
private String sourceType;
@Schema(description = "来源业务ID", example = "1024")
private Long sourceBizId;
@Schema(description = "优先级0低 1中 2高 3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@Schema(description = "优先级0低1中2高3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer priority;
@Schema(description = "优先级名称", example = "")
@@ -58,13 +58,16 @@ public class ProductRequirementRespVO {
@Schema(description = "最近一次状态动作原因", example = "评审通过")
private String lastStatusReason;
@Schema(description = "提出人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@Schema(description = "提出人用户ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long proposerId;
@Schema(description = "提出人用户姓名", example = "张三")
private String proposerNickname;
@Schema(description = "当前处理人用户编号", example = "1024")
@Schema(description = "所需工时", example = "8")
private Double workHours;
@Schema(description = "当前处理人用户ID", example = "1024")
private Long currentHandlerUserId;
@Schema(description = "当前处理人姓名", example = "李四")
@@ -76,9 +79,6 @@ public class ProductRequirementRespVO {
@Schema(description = "实现项目名称", example = "NPQS-10086")
private String implementProjectName;
@Schema(description = "预期完成时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime completionDate;
@Schema(description = "排序值", example = "0")
private Integer sort;
@@ -88,10 +88,10 @@ public class ProductRequirementRespVO {
@Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updateTime;
@Schema(description = "子需求列表树形结构")
@Schema(description = "子需求列表树形结构")
private List<ProductRequirementRespVO> children;
@Schema(description = "是否为终态已拒绝、已取消、已关闭", example = "false")
@Schema(description = "是否为终态已拒绝、已取消、已关闭都算终态", example = "false")
private Boolean terminal;
}

View File

@@ -6,8 +6,6 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 管理后台 - 产品需求保存 Request VO
*/
@@ -22,45 +20,51 @@ public class ProductRequirementSaveReqVO {
@NotNull(message = "产品编号不能为空")
private Long productId;
@Schema(description = "所属模块ID为空时归入全部需求", example = "1024")
@Schema(description = "所属模块ID为空时归入全部需求模块", example = "1024")
private Long moduleId;
@Schema(description = "是否需要评审0不需要1需要", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@Schema(description = "是否需要评审0不需要1需要", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "是否需要评审不能为空")
private Integer reviewRequired;
@Schema(description = "需求标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "支持需求模块化管理")
@NotBlank(message = "需求标题不能为空")
@Size(max = 200, message = "需求标题长度不能超过200个字符")
@Size(max = 200, message = "需求标题长度不能超过 200 个字符")
private String title;
@Schema(description = "需求描述富文本", example = "<p>详细描述需求内容</p>")
@Schema(description = "需求描述,支持富文本", example = "<p>详细描述需求内容</p>")
private String description;
@Schema(description = "需求分类字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "function")
@NotBlank(message = "需求分类不能为空")
@Size(max = 64, message = "需求分类长度不能超过64个字符")
@Size(max = 64, message = "需求分类长度不能超过 64 个字符")
private String category;
@Schema(description = "优先级0低 1中 2高 3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@Schema(description = "优先级0低1中2高3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "优先级不能为空")
private Integer priority;
@Schema(description = "提出人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@Schema(description = "提出人用户ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "提出人不能为空")
private Long proposerId;
@Schema(description = "当前处理人用户编号", example = "1024")
@Schema(description = "提出人姓名", example = "张三")
private String proposerNickname;
@Schema(description = "所需工时", example = "8")
@NotNull(message = "所需工时不能为空")
private Double workHours;
@Schema(description = "当前处理人用户ID", example = "1024")
private Long currentHandlerUserId;
@Schema(description = "当前处理人姓名", example = "李四")
private String currentHandlerUserNickname;
@Schema(description = "默认实现项目编号", example = "1024")
private Long implementProjectId;
@Schema(description = "预期完成时间", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "预期完成时间不能为空")
private LocalDateTime completionDate;
@Schema(description = "排序值(越小越靠前)", example = "0")
@Schema(description = "排序值,越小越靠前", example = "0")
private Integer sort;
}

View File

@@ -6,8 +6,6 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 管理后台 - 产品需求拆分 Request VO
*/
@@ -23,45 +21,51 @@ public class ProductRequirementSplitReqVO {
@NotNull(message = "产品编号不能为空")
private Long productId;
@Schema(description = "所属模块ID为空时归入全部需求", example = "1024")
@Schema(description = "所属模块ID为空时归入全部需求模块", example = "1024")
private Long moduleId;
@Schema(description = "是否需要评审0不需要1需要", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@Schema(description = "是否需要评审0不需要1需要", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "是否需要评审不能为空")
private Integer reviewRequired;
@Schema(description = "需求标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "支持需求模块化管理")
@NotBlank(message = "需求标题不能为空")
@Size(max = 200, message = "需求标题长度不能超过200个字符")
@Size(max = 200, message = "需求标题长度不能超过 200 个字符")
private String title;
@Schema(description = "需求描述富文本", example = "<p>详细描述需求内容</p>")
@Schema(description = "需求描述,支持富文本", example = "<p>详细描述需求内容</p>")
private String description;
@Schema(description = "需求分类字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "function")
@NotBlank(message = "需求分类不能为空")
@Size(max = 64, message = "需求分类长度不能超过64个字符")
@Size(max = 64, message = "需求分类长度不能超过 64 个字符")
private String category;
@Schema(description = "优先级0低 1中 2高 3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@Schema(description = "优先级0低1中2高3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "优先级不能为空")
private Integer priority;
@Schema(description = "提出人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@Schema(description = "提出人用户ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "提出人不能为空")
private Long proposerId;
@Schema(description = "当前处理人用户编号", example = "1024")
@Schema(description = "提出人姓名", example = "张三")
private String proposerNickname;
@Schema(description = "所需工时", example = "8")
@NotNull(message = "所需工时不能为空")
private Double workHours;
@Schema(description = "当前处理人用户ID", example = "1024")
private Long currentHandlerUserId;
@Schema(description = "当前处理人姓名", example = "李四")
private String currentHandlerUserNickname;
@Schema(description = "默认实现项目编号", example = "1024")
private Long implementProjectId;
@Schema(description = "预期完成时间", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "预期完成时间不能为空")
private LocalDateTime completionDate;
@Schema(description = "排序值(越小越靠前)", example = "0")
@Schema(description = "排序值,越小越靠前", example = "0")
private Integer sort;
}

View File

@@ -6,8 +6,6 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 管理后台 - 产品需求编辑 Request VO
*/
@@ -23,45 +21,51 @@ public class ProductRequirementUpdateReqVO {
@NotNull(message = "产品ID不能为空")
private Long productId;
@Schema(description = "所属模块ID为空时归入全部需求", example = "1024")
@Schema(description = "所属模块ID为空时归入全部需求模块", example = "1024")
private Long moduleId;
@Schema(description = "是否需要评审0不需要1需要", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@Schema(description = "是否需要评审0不需要1需要", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "是否需要评审不能为空")
private Integer reviewRequired;
@Schema(description = "需求标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "支持需求模块化管理")
@NotBlank(message = "需求标题不能为空")
@Size(max = 200, message = "需求标题长度不能超过200个字符")
@Size(max = 200, message = "需求标题长度不能超过 200 个字符")
private String title;
@Schema(description = "需求描述富文本", example = "<p>详细描述需求内容</p>")
@Schema(description = "需求描述,支持富文本", example = "<p>详细描述需求内容</p>")
private String description;
@Schema(description = "需求分类字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "function")
@NotBlank(message = "需求分类不能为空")
@Size(max = 64, message = "需求分类长度不能超过64个字符")
@Size(max = 64, message = "需求分类长度不能超过 64 个字符")
private String category;
@Schema(description = "优先级0低 1中 2高 3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@Schema(description = "优先级0低1中2高3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "优先级不能为空")
private Integer priority;
@Schema(description = "提出人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@Schema(description = "提出人用户ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "提出人不能为空")
private Long proposerId;
@Schema(description = "当前处理人用户编号", example = "1024")
@Schema(description = "提出人姓名", example = "张三")
private String proposerNickname;
@Schema(description = "所需工时", example = "8")
@NotNull(message = "所需工时不能为空")
private Double workHours;
@Schema(description = "当前处理人用户ID", example = "1024")
private Long currentHandlerUserId;
@Schema(description = "当前处理人姓名", example = "李四")
private String currentHandlerUserNickname;
@Schema(description = "默认实现项目编号", example = "1024")
private Long implementProjectId;
@Schema(description = "预期完成时间", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "预期完成时间不能为空")
private LocalDateTime completionDate;
@Schema(description = "排序值(越小越靠前)", example = "0")
@Schema(description = "排序值,越小越靠前", example = "0")
private Integer sort;
}

View File

@@ -6,8 +6,6 @@ import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 产品需求主表
*/
@@ -22,11 +20,11 @@ public class ProductRequirementDO extends BaseDO {
@TableId
private Long id;
/**
* 父需求ID0表示顶级需求
* 父需求ID0 表示顶级需求
*/
private Long parentId;
/**
* 所属模块ID0表示全部需求
* 所属模块ID,空表示全部需求
*/
private Long moduleId;
/**
@@ -34,7 +32,7 @@ public class ProductRequirementDO extends BaseDO {
*/
private Long productId;
/**
* 是否需要评审0不需要1需要
* 是否需要评审0不需要1需要
*/
private Integer reviewRequired;
/**
@@ -42,7 +40,7 @@ public class ProductRequirementDO extends BaseDO {
*/
private String title;
/**
* 需求描述富文本
* 需求描述,支持富文本
*/
private String description;
/**
@@ -50,7 +48,7 @@ public class ProductRequirementDO extends BaseDO {
*/
private String category;
/**
* 来源类型manual:手工新增, work_order:工单流转
* 来源类型manual 表示手工新增work_order 表示工单流转
*/
private String sourceType;
/**
@@ -58,7 +56,7 @@ public class ProductRequirementDO extends BaseDO {
*/
private Long sourceBizId;
/**
* 优先级0低 1中 2高 3紧急
* 优先级0低1中2高3紧急
*/
private Integer priority;
/**
@@ -74,9 +72,13 @@ public class ProductRequirementDO extends BaseDO {
*/
private Long proposerId;
/**
* 提出人用户姓名快照
* 提出人姓名快照
*/
private String proposerNickname;
/**
* 所需工时
*/
private Double workHours;
/**
* 当前处理人用户ID
*/
@@ -86,15 +88,11 @@ public class ProductRequirementDO extends BaseDO {
*/
private String currentHandlerUserNickname;
/**
* 默认实现项目ID分流后填写)
* 默认实现项目ID分流后可回
*/
private Long implementProjectId;
/**
* 预期完成时间
*/
private LocalDateTime completionDate;
/**
* 排序值(越小越靠前)
* 排序值,越小越靠前
*/
private Integer sort;

View File

@@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.service.product;
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.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.controller.admin.product.vo.requirement.*;
@@ -25,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -56,6 +58,8 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
private static final List<String> CHILD_ALLOW_CLOSE_STATUSES = List.of(STATUS_CLOSED, STATUS_CANCELLED, STATUS_REJECTED, STATUS_ACCEPTED);
// 允许删除的状态集合(实施中之前的状态)
private static final List<String> ALLOW_DELETE_STATUSES = List.of(STATUS_PENDING_CONFIRM, STATUS_PENDING_REVIEW, STATUS_PENDING_DISPATCH);
// 父需求取消时,子需求允许的状态集合(仅已拒绝和已取消)
private static final List<String> CHILD_ALLOW_CANCEL_STATUSES = List.of(STATUS_REJECTED, STATUS_CANCELLED);
// 权限常量
private static final String PRODUCT_CREATE_PERMISSION = "project:product:create";
@@ -113,14 +117,17 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
? STATUS_PENDING_REVIEW : STATUS_PENDING_DISPATCH;
requirement.setStatusCode(initialStatus);
requirement.setProposerId(createReqVO.getProposerId());
requirement.setProposerNickname(normalizeNullableText(createReqVO.getProposerNickname()));
requirement.setWorkHours(createReqVO.getWorkHours());
requirement.setCurrentHandlerUserId(createReqVO.getCurrentHandlerUserId());
requirement.setCurrentHandlerUserNickname(normalizeNullableText(createReqVO.getCurrentHandlerUserNickname()));
requirement.setImplementProjectId(createReqVO.getImplementProjectId());
requirement.setCompletionDate(createReqVO.getCompletionDate());
requirement.setSort(createReqVO.getSort() != null ? createReqVO.getSort() : 0);
requirementMapper.insert(requirement);
// 写入业务审计日志
writeBizAuditLog(requirement, ACTION_CREATE, null, initialStatus, null, null);
writeBizAuditLog(requirement, ACTION_CREATE, null, initialStatus,
buildRequirementFieldChanges(null, requirement), null);
return requirement.getId();
}
@@ -137,6 +144,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
// 校验模块是否属于当前产品
validateModuleBelongsToProduct(moduleId, updateReqVO.getProductId());
ProductRequirementDO before = cloneRequirement(requirement);
String fromStatus = requirement.getStatusCode();
requirement.setModuleId(moduleId);
requirement.setReviewRequired(updateReqVO.getReviewRequired());
@@ -145,13 +153,16 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
requirement.setCategory(updateReqVO.getCategory());
requirement.setPriority(updateReqVO.getPriority());
requirement.setProposerId(updateReqVO.getProposerId());
requirement.setProposerNickname(normalizeNullableText(updateReqVO.getProposerNickname()));
requirement.setWorkHours(updateReqVO.getWorkHours());
requirement.setCurrentHandlerUserId(updateReqVO.getCurrentHandlerUserId());
requirement.setCurrentHandlerUserNickname(normalizeNullableText(updateReqVO.getCurrentHandlerUserNickname()));
requirement.setImplementProjectId(updateReqVO.getImplementProjectId());
requirement.setCompletionDate(updateReqVO.getCompletionDate());
requirement.setSort(updateReqVO.getSort() != null ? updateReqVO.getSort() : 0);
requirementMapper.updateById(requirement);
writeBizAuditLog(requirement, ACTION_UPDATE, fromStatus, requirement.getStatusCode(), null, null);
writeBizAuditLog(requirement, ACTION_UPDATE, fromStatus, requirement.getStatusCode(),
buildRequirementFieldChanges(before, requirement), null);
}
@Override
@@ -370,6 +381,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
String actionCode = reqVO.getActionCode().trim();
Long implementProjectId = reqVO.getImplementProjectId();
String fromStatus = requirement.getStatusCode();
ProductRequirementDO before = cloneRequirement(requirement);
// 校验状态流转是否合法
ObjectStatusTransitionDO transition = validateRequirementTransition(fromStatus, actionCode);
@@ -383,6 +395,10 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
if ("accept".equals(actionCode) || "close".equals(actionCode)) {
validateAllChildrenAllowCloseOrAccept(reqVO.getId());
}
// cancel动作时如果是父需求则校验所有子需求是否处于已拒绝或已取消
if ("cancel".equals(actionCode) && Objects.equals(requirement.getParentId(), 0L)) {
validateParentCancelAllowed(reqVO.getId());
}
// close动作时递归关闭所有已验收的子需求包括子子需求
if ("close".equals(actionCode)) {
closeAllAcceptedChildren(reqVO.getId(), reason);
@@ -394,11 +410,15 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
}
requirement.setStatusCode(toStatus);
requirement.setLastStatusReason(reason);
if (implementProjectId != null) {
requirement.setImplementProjectId(implementProjectId);
}
// 写入状态变更日志
writeRequirementStatusLog(requirement, actionCode, fromStatus, toStatus, reason);
// 写入业务审计日志
writeBizAuditLog(requirement, actionCode, fromStatus, toStatus, null, reason);
writeBizAuditLog(requirement, actionCode, fromStatus, toStatus,
buildRequirementFieldChanges(before, requirement), reason);
}
/**
@@ -417,6 +437,23 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
}
}
/**
* 校验父需求取消时,所有子需求(包括子子需求)是否处于允许取消的状态
* 只有子需求全部为已拒绝或已取消时,父需求才允许取消
*/
@VisibleForTesting
void validateParentCancelAllowed(Long requirementId) {
List<ProductRequirementDO> allChildren = getAllRequirementsWithChildren(requirementId);
// 排除自身,只校验子需求
for (ProductRequirementDO req : allChildren) {
if (!Objects.equals(req.getId(), requirementId)) {
if (!CHILD_ALLOW_CANCEL_STATUSES.contains(req.getStatusCode())) {
throw exception(ErrorCodeConstants.REQUIREMENT_CHILD_NOT_ALLOW_CANCEL);
}
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
@@ -442,7 +479,8 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_CONCURRENT_MODIFIED);
}
writeBizAuditLog(requirement, ACTION_DELETE, fromStatus, null, null, null);
writeBizAuditLog(requirement, ACTION_DELETE, fromStatus, null,
buildRequirementFieldChanges(requirement, null), null);
}
@Override
@@ -471,27 +509,32 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
String initialStatus = Objects.equals(reqVO.getReviewRequired(), 1)
? STATUS_PENDING_REVIEW : STATUS_PENDING_DISPATCH;
childRequirement.setStatusCode(initialStatus);
childRequirement.setProposerId(parentRequirement.getProposerId()); // 继承父需求提出人
childRequirement.setProposerId(reqVO.getProposerId());
childRequirement.setProposerNickname(normalizeNullableText(reqVO.getProposerNickname()));
childRequirement.setWorkHours(reqVO.getWorkHours());
childRequirement.setCurrentHandlerUserId(reqVO.getCurrentHandlerUserId());
childRequirement.setCurrentHandlerUserNickname(normalizeNullableText(reqVO.getCurrentHandlerUserNickname()));
childRequirement.setImplementProjectId(reqVO.getImplementProjectId());
childRequirement.setCompletionDate(reqVO.getCompletionDate());
childRequirement.setSort(reqVO.getSort() != null ? reqVO.getSort() : 0);
requirementMapper.insert(childRequirement);
// 父需求状态从待分流变为实施中
if (STATUS_PENDING_DISPATCH.equals(parentRequirement.getStatusCode())) {
ProductRequirementDO before = cloneRequirement(parentRequirement);
String fromStatus = parentRequirement.getStatusCode();
int updateCount = requirementMapper.updateStatusByIdAndStatus(
parentRequirement.getId(), fromStatus, STATUS_IMPLEMENTING, null);
if (updateCount == 1) {
parentRequirement.setStatusCode(STATUS_IMPLEMENTING);
writeRequirementStatusLog(parentRequirement, ACTION_SPLIT, fromStatus, STATUS_IMPLEMENTING, null);
writeBizAuditLog(parentRequirement, ACTION_SPLIT, fromStatus, STATUS_IMPLEMENTING, null, null);
writeBizAuditLog(parentRequirement, ACTION_SPLIT, fromStatus, STATUS_IMPLEMENTING,
buildRequirementFieldChanges(before, parentRequirement), null);
}
}
// 写入子需求的业务审计日志
writeBizAuditLog(childRequirement, ACTION_CREATE, null, initialStatus, null, null);
writeBizAuditLog(childRequirement, ACTION_CREATE, null, initialStatus,
buildRequirementFieldChanges(null, childRequirement), null);
return childRequirement.getId();
}
@@ -503,6 +546,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
ProductRequirementDO requirement = validateRequirementExists(reqVO.getId());
String fromStatus = requirement.getStatusCode();
String reason = reqVO.getReason().trim();
ProductRequirementDO before = cloneRequirement(requirement);
// 校验当前状态是否为已验收(只有已验收才能关闭)
if (!STATUS_ACCEPTED.equals(fromStatus)) {
@@ -525,7 +569,8 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
requirement.setLastStatusReason(reason);
writeRequirementStatusLog(requirement, ACTION_CLOSE, fromStatus, STATUS_CLOSED, reason);
writeBizAuditLog(requirement, ACTION_CLOSE, fromStatus, STATUS_CLOSED, null, reason);
writeBizAuditLog(requirement, ACTION_CLOSE, fromStatus, STATUS_CLOSED,
buildRequirementFieldChanges(before, requirement), reason);
}
/**
@@ -538,11 +583,15 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
closeAllAcceptedChildren(child.getId(), reason);
// 如果子需求已验收,则关闭
if (STATUS_ACCEPTED.equals(child.getStatusCode())) {
ProductRequirementDO before = cloneRequirement(child);
int updateCount = requirementMapper.updateStatusByIdAndStatus(
child.getId(), STATUS_ACCEPTED, STATUS_CLOSED, reason);
if (updateCount == 1) {
child.setStatusCode(STATUS_CLOSED);
child.setLastStatusReason(reason);
writeRequirementStatusLog(child, ACTION_CLOSE, STATUS_ACCEPTED, STATUS_CLOSED, reason);
writeBizAuditLog(child, ACTION_CLOSE, STATUS_ACCEPTED, STATUS_CLOSED, null, reason);
writeBizAuditLog(child, ACTION_CLOSE, STATUS_ACCEPTED, STATUS_CLOSED,
buildRequirementFieldChanges(before, child), reason);
}
}
}
@@ -894,6 +943,98 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
bizAuditLogMapper.insert(auditLog);
}
/**
* 克隆需求快照,避免更新前后的对象引用互相污染。
*/
private ProductRequirementDO cloneRequirement(ProductRequirementDO source) {
ProductRequirementDO target = new ProductRequirementDO();
target.setId(source.getId());
target.setParentId(source.getParentId());
target.setModuleId(source.getModuleId());
target.setProductId(source.getProductId());
target.setReviewRequired(source.getReviewRequired());
target.setTitle(source.getTitle());
target.setDescription(source.getDescription());
target.setCategory(source.getCategory());
target.setSourceType(source.getSourceType());
target.setSourceBizId(source.getSourceBizId());
target.setPriority(source.getPriority());
target.setStatusCode(source.getStatusCode());
target.setLastStatusReason(source.getLastStatusReason());
target.setProposerId(source.getProposerId());
target.setProposerNickname(source.getProposerNickname());
target.setWorkHours(source.getWorkHours());
target.setCurrentHandlerUserId(source.getCurrentHandlerUserId());
target.setCurrentHandlerUserNickname(source.getCurrentHandlerUserNickname());
target.setImplementProjectId(source.getImplementProjectId());
target.setSort(source.getSort());
return target;
}
/**
* 构建需求字段变更内容,供业务审计日志落库。
*/
private String buildRequirementFieldChanges(ProductRequirementDO before, ProductRequirementDO after) {
Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "parentId", valueOf(before, ProductRequirementDO::getParentId),
valueOf(after, ProductRequirementDO::getParentId));
appendFieldChange(fieldChanges, "moduleId", valueOf(before, ProductRequirementDO::getModuleId),
valueOf(after, ProductRequirementDO::getModuleId));
appendFieldChange(fieldChanges, "productId", valueOf(before, ProductRequirementDO::getProductId),
valueOf(after, ProductRequirementDO::getProductId));
appendFieldChange(fieldChanges, "reviewRequired", valueOf(before, ProductRequirementDO::getReviewRequired),
valueOf(after, ProductRequirementDO::getReviewRequired));
appendFieldChange(fieldChanges, "title", valueOf(before, ProductRequirementDO::getTitle),
valueOf(after, ProductRequirementDO::getTitle));
appendFieldChange(fieldChanges, "description", valueOf(before, ProductRequirementDO::getDescription),
valueOf(after, ProductRequirementDO::getDescription));
appendFieldChange(fieldChanges, "category", valueOf(before, ProductRequirementDO::getCategory),
valueOf(after, ProductRequirementDO::getCategory));
appendFieldChange(fieldChanges, "sourceType", valueOf(before, ProductRequirementDO::getSourceType),
valueOf(after, ProductRequirementDO::getSourceType));
appendFieldChange(fieldChanges, "sourceBizId", valueOf(before, ProductRequirementDO::getSourceBizId),
valueOf(after, ProductRequirementDO::getSourceBizId));
appendFieldChange(fieldChanges, "priority", valueOf(before, ProductRequirementDO::getPriority),
valueOf(after, ProductRequirementDO::getPriority));
appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProductRequirementDO::getStatusCode),
valueOf(after, ProductRequirementDO::getStatusCode));
appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, ProductRequirementDO::getLastStatusReason),
valueOf(after, ProductRequirementDO::getLastStatusReason));
appendFieldChange(fieldChanges, "proposerId", valueOf(before, ProductRequirementDO::getProposerId),
valueOf(after, ProductRequirementDO::getProposerId));
appendFieldChange(fieldChanges, "proposerNickname", valueOf(before, ProductRequirementDO::getProposerNickname),
valueOf(after, ProductRequirementDO::getProposerNickname));
appendFieldChange(fieldChanges, "workHours", valueOf(before, ProductRequirementDO::getWorkHours),
valueOf(after, ProductRequirementDO::getWorkHours));
appendFieldChange(fieldChanges, "currentHandlerUserId", valueOf(before, ProductRequirementDO::getCurrentHandlerUserId),
valueOf(after, ProductRequirementDO::getCurrentHandlerUserId));
appendFieldChange(fieldChanges, "currentHandlerUserNickname",
valueOf(before, ProductRequirementDO::getCurrentHandlerUserNickname),
valueOf(after, ProductRequirementDO::getCurrentHandlerUserNickname));
appendFieldChange(fieldChanges, "implementProjectId", valueOf(before, ProductRequirementDO::getImplementProjectId),
valueOf(after, ProductRequirementDO::getImplementProjectId));
appendFieldChange(fieldChanges, "sort", valueOf(before, ProductRequirementDO::getSort),
valueOf(after, ProductRequirementDO::getSort));
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
}
private <T> T valueOf(ProductRequirementDO requirement, Function<ProductRequirementDO, T> getter) {
return requirement == null ? null : getter.apply(requirement);
}
/**
* 仅记录真实发生变化的字段,避免审计日志写入冗余内容。
*/
private void appendFieldChange(Map<String, Object> fieldChanges, String fieldName, Object before, Object after) {
if (Objects.equals(before, after)) {
return;
}
Map<String, Object> value = new LinkedHashMap<>();
value.put("before", before);
value.put("after", after);
fieldChanges.put(fieldName, value);
}
/**
* 处理可能为空的文本去除首尾空格后若为空则返回null
*/

View File

@@ -22,12 +22,13 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@@ -65,8 +66,9 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
reqVO.setReviewRequired(0); // 不需要评审
reqVO.setPriority(1);
reqVO.setProposerId(2001L);
reqVO.setProposerNickname("proposer-a");
reqVO.setCurrentHandlerUserId(2002L);
reqVO.setCompletionDate(LocalDateTime.now());
reqVO.setCurrentHandlerUserNickname("handler-a");
// 模拟"全部需求"模块存在parentId = 0L 的根模块)
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L);
@@ -85,6 +87,12 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
assertEquals("测试需求", created.getTitle());
assertEquals(defaultModuleId, created.getModuleId()); // 未选择模块时自动归属到"全部需求"模块
assertEquals(100L, created.getProductId());
assertEquals("handler-a", created.getCurrentHandlerUserNickname());
ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
verify(bizAuditLogMapper, times(1)).insert(auditCaptor.capture());
assertNotNull(auditCaptor.getValue().getFieldChanges());
assertTrue(auditCaptor.getValue().getFieldChanges().contains("currentHandlerUserNickname"));
}
@Test
@@ -98,7 +106,6 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
reqVO.setReviewRequired(1); // 需要评审
reqVO.setPriority(2);
reqVO.setProposerId(2001L);
reqVO.setCompletionDate(LocalDateTime.now());
// 模拟"全部需求"模块存在parentId = 0L 的根模块)
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L);
@@ -130,7 +137,6 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
reqVO.setReviewRequired(0);
reqVO.setPriority(1);
reqVO.setProposerId(2001L);
reqVO.setCompletionDate(LocalDateTime.now());
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
@@ -155,7 +161,6 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
reqVO.setReviewRequired(0);
reqVO.setPriority(2);
reqVO.setProposerId(2001L);
reqVO.setCompletionDate(LocalDateTime.now());
// 模拟"全部需求"模块存在未选择模块时自动归属parentId = 0L 的根模块)
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L);
@@ -303,7 +308,6 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
reqVO.setTitle("子需求");
reqVO.setCategory("function");
reqVO.setPriority(1);
reqVO.setCompletionDate(LocalDateTime.now());
when(requirementMapper.selectById(parentId)).thenReturn(parent);
when(requirementMapper.updateStatusByIdAndStatus(parentId, "pending_dispatch", "implementing", null))
@@ -320,7 +324,7 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
assertEquals(parentId, child.getParentId());
assertEquals("pending_dispatch", child.getStatusCode());
assertEquals(parent.getModuleId(), child.getModuleId());
assertEquals(parent.getProposerId(), child.getProposerId());
assertEquals(reqVO.getProposerId(), child.getProposerId());
// 验证父需求状态变为实施中
verify(requirementMapper, times(1))
@@ -342,7 +346,6 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
reqVO.setTitle("子需求2");
reqVO.setCategory("function");
reqVO.setPriority(1);
reqVO.setCompletionDate(LocalDateTime.now());
when(requirementMapper.selectById(parentId)).thenReturn(parent);
@@ -366,7 +369,6 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
reqVO.setTitle("子需求");
reqVO.setCategory("function");
reqVO.setPriority(1);
reqVO.setCompletionDate(LocalDateTime.now());
when(requirementMapper.selectById(parentId)).thenReturn(parent);