refactor(project): 重构权限常量定义并移除需求进度聚合功能
- 将产品和项目查询权限码统一提取到常量类中 - 移除需求进度聚合计算的相关实现代码 - 更新权限验证注解使用新的常量定义 - 清理相关的单元测试代码 - 更新错误码注释说明
This commit is contained in:
@@ -35,6 +35,19 @@ public final class ProjectObjectConstants {
|
||||
*/
|
||||
public static final String STATUS_CANCELLED = "cancelled";
|
||||
|
||||
/**
|
||||
* 已归档项目状态编码。
|
||||
*/
|
||||
public static final String STATUS_ARCHIVED = "archived";
|
||||
|
||||
/**
|
||||
* "全部"口径的排除锚点:列表/统计缺省不含 已取消、已归档。
|
||||
* 状态全集以 rdms_object_status_model 为权威源运行时推导(DB 新增状态自动进入"全部"口径),
|
||||
* 代码只锚定排除项,与主线唯一性校验的 STATUS_CANCELLED 同属最小硬编码。
|
||||
* 设计来源:docs/superpowers/specs/2026-06-10-项目列表产品分组-design.md 第二节。
|
||||
*/
|
||||
public static final Set<String> DEFAULT_QUERY_EXCLUDED_STATUS_CODES = Set.of(STATUS_CANCELLED, STATUS_ARCHIVED);
|
||||
|
||||
/**
|
||||
* 项目自动编码前缀。
|
||||
*/
|
||||
|
||||
@@ -2,16 +2,16 @@ package com.njcn.rdms.module.project.controller.admin.project;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -73,8 +73,14 @@ public class ProjectController {
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "获取项目分页")
|
||||
public CommonResult<PageResult<ProjectRespVO>> getProjectPage(@Valid ProjectPageReqVO pageReqVO) {
|
||||
PageResult<ProjectDO> pageResult = projectService.getProjectPage(pageReqVO);
|
||||
return success(BeanUtils.toBean(pageResult, ProjectRespVO.class));
|
||||
return success(projectService.getProjectPage(pageReqVO));
|
||||
}
|
||||
|
||||
@GetMapping("/group-page")
|
||||
@Operation(summary = "获取项目按产品分组分页")
|
||||
public CommonResult<ProjectGroupPageRespVO> getProjectGroupPage(@Valid ProjectGroupPageReqVO reqVO) {
|
||||
// 与 page / overview-summary 一致:全域读路径不挂权限注解,可见性由 Service 内 ObjectDataScope 数据权限承载
|
||||
return success(projectService.getProjectGroupPage(reqVO));
|
||||
}
|
||||
|
||||
@GetMapping("/list-by-product")
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Schema(description = "管理后台 - 项目按产品分组分页 Request VO")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ProjectGroupPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "关键词,匹配项目编码或项目名称", example = "CNPJ2026001")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "项目类型字典值", example = "main")
|
||||
@Size(max = 32, message = "项目类型长度不能超过32个字符")
|
||||
private String projectType;
|
||||
|
||||
@Schema(description = "所属产品编号", example = "1001")
|
||||
private Long productId;
|
||||
|
||||
@Schema(description = "项目状态编码;缺省 = 「全部」口径(状态机启用状态,不含已取消/已归档)", example = "active")
|
||||
@Size(max = 32, message = "项目状态编码长度不能超过32个字符")
|
||||
private String statusCode;
|
||||
|
||||
@Schema(description = "仅返回游离组(未挂产品的项目),不可与 productId 同时使用", example = "true")
|
||||
private Boolean orphanOnly;
|
||||
|
||||
@Schema(description = "每组返回项目条数上限,默认 5", example = "5")
|
||||
@Min(value = 1, message = "每组条数最小值为 1")
|
||||
@Max(value = 50, message = "每组条数最大值为 50")
|
||||
private Integer topN = 5;
|
||||
|
||||
@AssertTrue(message = "游离组筛选与产品筛选不能同时使用")
|
||||
@Schema(hidden = true)
|
||||
public boolean isOrphanFilterValid() {
|
||||
return !Boolean.TRUE.equals(orphanOnly) || productId == null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 项目按产品分组分页 Response VO")
|
||||
@Data
|
||||
public class ProjectGroupPageRespVO {
|
||||
|
||||
@Schema(description = "产品组总数(分页 total,含游离组)")
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "当前筛选口径下项目总数(含游离项目)")
|
||||
private Long projectTotal;
|
||||
|
||||
@Schema(description = "当前用户可见、启用/暂停状态产品去重后的方向数")
|
||||
private Integer directionCount;
|
||||
|
||||
@Schema(description = "当前筛选口径下游离项目数")
|
||||
private Long orphanTotal;
|
||||
|
||||
@Schema(description = "产品组列表:同方向相邻,游离组固定最后")
|
||||
private List<ProjectGroupRespVO> list;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Schema(description = "管理后台 - 项目产品分组 Response VO")
|
||||
@Data
|
||||
public class ProjectGroupRespVO {
|
||||
|
||||
@Schema(description = "产品编号,游离组为 null", example = "1001")
|
||||
private Long productId;
|
||||
|
||||
@Schema(description = "产品名称,游离组固定为「游离项目」", example = "智能网关")
|
||||
private String productName;
|
||||
|
||||
@Schema(description = "产品编码,游离组为 null", example = "CNPD2026001")
|
||||
private String productCode;
|
||||
|
||||
@Schema(description = "产品方向字典值,游离组为空串", example = "platform")
|
||||
private String directionCode;
|
||||
|
||||
@Schema(description = "产品负责人用户编号,游离组为 null", example = "1024")
|
||||
private Long managerUserId;
|
||||
|
||||
@Schema(description = "产品负责人昵称,游离组为 null", example = "张三")
|
||||
private String managerUserNickname;
|
||||
|
||||
@Schema(description = "当前筛选口径下该组项目总数")
|
||||
private Long projectTotal;
|
||||
|
||||
@Schema(description = "组内前 topN 条项目(更新时间倒序),字段与项目分页接口行一致")
|
||||
private List<ProjectRespVO> projects;
|
||||
|
||||
@Schema(description = "项目类型计数,恒按「全部」口径(不含已取消/已归档)统计,不随状态筛选变化")
|
||||
private Map<String, Long> typeCounts;
|
||||
|
||||
@Schema(description = "是否存在非已取消的主线(基线)项目,口径与创建唯一性校验一致")
|
||||
private Boolean hasBaseline;
|
||||
|
||||
@Schema(description = "是否游离组")
|
||||
private Boolean orphan;
|
||||
|
||||
}
|
||||
@@ -12,4 +12,7 @@ public class ProjectOverviewSummaryRespVO {
|
||||
@Schema(description = "项目状态数量,按当前启用的项目状态模型返回")
|
||||
private Map<String, Long> statusCounts;
|
||||
|
||||
@Schema(description = "游离项目数:未挂产品且状态属于「全部」口径(状态机启用状态,不含已取消/已归档)")
|
||||
private Long orphanCount;
|
||||
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import com.njcn.rdms.framework.common.pojo.PageParam;
|
||||
import com.njcn.rdms.framework.dict.validation.InDict;
|
||||
import com.njcn.rdms.module.system.enums.DictTypeConstants;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
|
||||
|
||||
@@ -43,4 +45,16 @@ public class ProjectPageReqVO extends PageParam {
|
||||
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
|
||||
private LocalDateTime[] updateTime;
|
||||
|
||||
@Schema(description = "仅查询游离项目(未挂产品),不可与 productId 同时使用", example = "true")
|
||||
private Boolean orphanOnly;
|
||||
|
||||
@Schema(description = "项目状态编码集合,存在时优先于 statusCode 生效", example = "[\"pending\", \"active\"]")
|
||||
private List<String> statusCodes;
|
||||
|
||||
@AssertTrue(message = "游离项目筛选与产品筛选不能同时使用")
|
||||
@Schema(hidden = true)
|
||||
public boolean isOrphanFilterValid() {
|
||||
return !Boolean.TRUE.equals(orphanOnly) || productId == null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通用站内信发送事件(project 域统一入口)。
|
||||
*
|
||||
* <p>业务方在动作完成后 {@code publishEvent(NotifySendEvent.of(...))},
|
||||
* 由 {@link NotifySendEventListener} 在事务提交后统一发送。
|
||||
* 是否包含操作人自己,由业务方组织 userIds 时决定(统一入口不排除操作人)。</p>
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class NotifySendEvent {
|
||||
|
||||
/** 接收人用户编号(可含重复,监听器负责去重) */
|
||||
private final Collection<Long> userIds;
|
||||
/** 模板场景码,见 {@link NotifyTemplateCodeConstants} */
|
||||
private final String templateCode;
|
||||
/** 模板参数 */
|
||||
private final Map<String, Object> params;
|
||||
|
||||
private NotifySendEvent(Collection<Long> userIds, String templateCode, Map<String, Object> params) {
|
||||
this.userIds = userIds;
|
||||
this.templateCode = templateCode;
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
public static NotifySendEvent of(Collection<Long> userIds, String templateCode, Map<String, Object> params) {
|
||||
return new NotifySendEvent(userIds, templateCode, params);
|
||||
}
|
||||
|
||||
public Collection<Long> getUserIds() {
|
||||
return userIds;
|
||||
}
|
||||
|
||||
public String getTemplateCode() {
|
||||
return templateCode;
|
||||
}
|
||||
|
||||
public Map<String, Object> getParams() {
|
||||
return params;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 站内信发送事件监听器(project 域统一入口,全局唯一)。
|
||||
*
|
||||
* <p>在业务事务提交后(AFTER_COMMIT)统一发送:接收人去重 + 空短路 + 逐人调用,
|
||||
* 单条失败 try-catch + log.warn,不抛出、不中断其余,绝不影响/回滚业务。</p>
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class NotifySendEventListener {
|
||||
|
||||
@Resource
|
||||
private NotifyMessageSendApi notifyMessageSendApi;
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void onNotifySend(NotifySendEvent event) {
|
||||
if (event == null || event.getUserIds() == null) {
|
||||
return;
|
||||
}
|
||||
// 去重(保留首次出现顺序),过滤 null
|
||||
Set<Long> targets = new LinkedHashSet<>();
|
||||
for (Long userId : event.getUserIds()) {
|
||||
if (Objects.nonNull(userId)) {
|
||||
targets.add(userId);
|
||||
}
|
||||
}
|
||||
if (targets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Long userId : targets) {
|
||||
try {
|
||||
notifyMessageSendApi.sendSingleNotifyToAdmin(userId, event.getTemplateCode(), event.getParams());
|
||||
} catch (Exception ex) {
|
||||
// 通知失败不影响业务:仅告警,继续发其余接收人
|
||||
log.warn("[onNotifySend] 站内信发送失败 userId={}, templateCode={}",
|
||||
userId, event.getTemplateCode(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
/**
|
||||
* 站内信模板场景码常量。
|
||||
*
|
||||
* <p>每个 code 对应 {@code system_notify_template} 一条模板(发送前模板必须已配置,
|
||||
* 否则能力层抛错、被 {@link NotifySendEventListener} 兜底为 log.warn)。</p>
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class NotifyTemplateCodeConstants {
|
||||
|
||||
/** 任务指派:创建任务后通知负责人 + 协办人 */
|
||||
public static final String TASK_ASSIGNED = "task_assigned";
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.njcn.rdms.module.project.framework.rpc.config;
|
||||
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
|
||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||
import com.njcn.rdms.module.system.api.file.FileApi;
|
||||
import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi;
|
||||
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
||||
import com.njcn.rdms.module.system.api.permission.PermissionApi;
|
||||
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
|
||||
@@ -14,6 +15,6 @@ import org.springframework.context.annotation.Configuration;
|
||||
* Project 模块的 RPC 配置
|
||||
*/
|
||||
@Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false)
|
||||
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class, PermissionApi.class, OrgLeaderApi.class, UserVisibilityConfigApi.class})
|
||||
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class, PermissionApi.class, OrgLeaderApi.class, UserVisibilityConfigApi.class, NotifyMessageSendApi.class})
|
||||
public class RpcConfiguration {
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
|
||||
@@ -50,7 +52,13 @@ public interface ProjectService {
|
||||
|
||||
ProjectContextRespVO getProjectContext(Long id);
|
||||
|
||||
PageResult<ProjectDO> getProjectPage(ProjectPageReqVO pageReqVO);
|
||||
PageResult<ProjectRespVO> getProjectPage(ProjectPageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 项目按产品分组分页:组维度翻页,组内带 topN 条项目。
|
||||
* 口径见 docs/superpowers/specs/2026-06-10-项目列表产品分组-design.md 第三节。
|
||||
*/
|
||||
ProjectGroupPageRespVO getProjectGroupPage(ProjectGroupPageReqVO reqVO);
|
||||
|
||||
ProjectOverviewSummaryRespVO getProjectOverviewSummary();
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectC
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRoleRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
|
||||
@@ -68,6 +71,7 @@ import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
@@ -454,7 +458,7 @@ class ProjectServiceImpl implements ProjectService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<ProjectDO> getProjectPage(ProjectPageReqVO pageReqVO) {
|
||||
public PageResult<ProjectRespVO> getProjectPage(ProjectPageReqVO pageReqVO) {
|
||||
// 计算当前用户在 project 域的数据权限范围
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
|
||||
@@ -473,31 +477,24 @@ class ProjectServiceImpl implements ProjectService {
|
||||
.eqIfPresent(ProjectDO::getDirectionCode, pageReqVO.getDirectionCode())
|
||||
.eqIfPresent(ProjectDO::getProductId, pageReqVO.getProductId())
|
||||
.eqIfPresent(ProjectDO::getManagerUserId, pageReqVO.getManagerUserId())
|
||||
.eqIfPresent(ProjectDO::getStatusCode, pageReqVO.getStatusCode())
|
||||
.betweenIfPresent(BaseDO::getUpdateTime, pageReqVO.getUpdateTime())
|
||||
.orderByDesc(BaseDO::getCreateTime);
|
||||
|
||||
// 注入 scope 数据权限过滤条件(在所有业务条件之后)
|
||||
if (scope.getState() == ObjectDataScope.State.ID_LIST) {
|
||||
Set<Long> ids = scope.getIds();
|
||||
Set<String> dcs = scope.getDirectionCodes();
|
||||
wrapper.and(w -> {
|
||||
boolean addedAny = false;
|
||||
if (!ids.isEmpty()) {
|
||||
w.in(ProjectDO::getId, ids);
|
||||
addedAny = true;
|
||||
}
|
||||
if (!dcs.isEmpty()) {
|
||||
if (addedAny) {
|
||||
w.or();
|
||||
}
|
||||
w.in(ProjectDO::getDirectionCode, dcs);
|
||||
}
|
||||
});
|
||||
// 列表按更新时间倒序,与分组接口组内排序一致(2026-06-10 由创建时间倒序调整)
|
||||
.orderByDesc(BaseDO::getUpdateTime);
|
||||
// 状态筛选:多值集合优先于单值
|
||||
if (pageReqVO.getStatusCodes() != null && !pageReqVO.getStatusCodes().isEmpty()) {
|
||||
wrapper.in(ProjectDO::getStatusCode, pageReqVO.getStatusCodes());
|
||||
} else {
|
||||
wrapper.eqIfPresent(ProjectDO::getStatusCode, pageReqVO.getStatusCode());
|
||||
}
|
||||
// ALL 状态不加任何 scope 条件,直接查全部
|
||||
// 游离项目筛选:仅未挂产品
|
||||
if (Boolean.TRUE.equals(pageReqVO.getOrphanOnly())) {
|
||||
wrapper.isNull(ProjectDO::getProductId);
|
||||
}
|
||||
// 注入 scope 数据权限过滤条件(在所有业务条件之后;ALL 状态不加条件)
|
||||
applyProjectScopeCondition(wrapper, scope);
|
||||
|
||||
return projectMapper.selectPage(pageReqVO, wrapper);
|
||||
PageResult<ProjectDO> pageResult = projectMapper.selectPage(pageReqVO, wrapper);
|
||||
return new PageResult<>(convertProjectListWithNames(pageResult.getList()), pageResult.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -510,9 +507,326 @@ class ProjectServiceImpl implements ProjectService {
|
||||
ProjectOverviewSummaryRespVO respVO = new ProjectOverviewSummaryRespVO();
|
||||
respVO.setStatusCounts(buildProjectStatusCounts(objectStatusModelMapper
|
||||
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), rows));
|
||||
respVO.setOrphanCount(countOrphanProjects(scope));
|
||||
return respVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游离项目计数:未挂产品 + "全部"口径(与项目分组列表对齐)。
|
||||
*/
|
||||
private Long countOrphanProjects(ObjectDataScope scope) {
|
||||
if (scope.getState() == ObjectDataScope.State.EMPTY) {
|
||||
return 0L;
|
||||
}
|
||||
Set<String> statusCodes = resolveDefaultProjectStatusCodes();
|
||||
if (statusCodes.isEmpty()) {
|
||||
return 0L;
|
||||
}
|
||||
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
|
||||
wrapper.isNull(ProjectDO::getProductId)
|
||||
.in(ProjectDO::getStatusCode, statusCodes);
|
||||
applyProjectScopeCondition(wrapper, scope);
|
||||
return projectMapper.selectCount(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目"全部"口径状态集:状态机启用状态全集 - {已取消, 已归档}。
|
||||
* 权威源 rdms_object_status_model,DB 新增状态自动纳入,代码不维护正向清单。
|
||||
*/
|
||||
private Set<String> resolveDefaultProjectStatusCodes() {
|
||||
return objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE).stream()
|
||||
.map(ObjectStatusModelDO::getStatusCode)
|
||||
.filter(StringUtils::hasText)
|
||||
.filter(code -> !ProjectObjectConstants.DEFAULT_QUERY_EXCLUDED_STATUS_CODES.contains(code))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品"存续"状态集:状态机启用且非终态的状态(当前为 启用/暂停),
|
||||
* 用于零项目占位组与方向数口径。权威源 rdms_object_status_model,零硬编码。
|
||||
*/
|
||||
private Set<String> resolveLiveProductStatusCodes() {
|
||||
return objectStatusModelMapper.selectListByObjectTypeEnabled(ProductObjectConstants.OBJECT_TYPE).stream()
|
||||
.filter(model -> !Boolean.TRUE.equals(model.getTerminalFlag()))
|
||||
.map(ObjectStatusModelDO::getStatusCode)
|
||||
.filter(StringUtils::hasText)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectGroupPageRespVO getProjectGroupPage(ProjectGroupPageReqVO reqVO) {
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
ObjectDataScope projectScope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
|
||||
ObjectDataScope productScope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
|
||||
|
||||
// 状态口径:"全部"集合由状态机推导(typeCounts 恒按该口径,无论是否显式传状态都需要)
|
||||
Set<String> defaultStatusCodes = resolveDefaultProjectStatusCodes();
|
||||
Collection<String> statusCodes = StringUtils.hasText(reqVO.getStatusCode())
|
||||
? List.of(reqVO.getStatusCode())
|
||||
: defaultStatusCodes;
|
||||
|
||||
// 1. 命中项目全量加载(产品几十级、项目百级,内存分组成本可忽略;项目上千后再改 SQL 聚合分页)
|
||||
List<ProjectDO> matched = selectGroupMatchedProjects(reqVO, statusCodes, projectScope);
|
||||
|
||||
// 2. 按产品分组,productId 为空归入游离组
|
||||
Map<Long, List<ProjectDO>> groupedByProduct = new LinkedHashMap<>();
|
||||
for (ProjectDO project : matched) {
|
||||
groupedByProduct.computeIfAbsent(project.getProductId(), k -> new ArrayList<>()).add(project);
|
||||
}
|
||||
List<ProjectDO> orphanProjects = groupedByProduct.remove(null);
|
||||
if (orphanProjects == null) {
|
||||
orphanProjects = Collections.emptyList();
|
||||
}
|
||||
|
||||
// 3. 可见产品(启用/暂停,产品域数据权限):directionCount 口径 + 零项目占位组候选
|
||||
List<ProductDO> visibleProducts = selectVisibleGroupProducts(productScope);
|
||||
|
||||
// 4. "全部"口径且无其他筛选时,补零项目产品占位组
|
||||
boolean defaultScope = !StringUtils.hasText(reqVO.getStatusCode()) && !StringUtils.hasText(reqVO.getKeyword())
|
||||
&& !StringUtils.hasText(reqVO.getProjectType()) && !Boolean.TRUE.equals(reqVO.getOrphanOnly());
|
||||
if (defaultScope) {
|
||||
visibleProducts.stream()
|
||||
.filter(p -> reqVO.getProductId() == null || p.getId().equals(reqVO.getProductId()))
|
||||
.forEach(p -> groupedByProduct.computeIfAbsent(p.getId(), k -> new ArrayList<>()));
|
||||
}
|
||||
|
||||
// 5. 组排序:方向编码升序(同方向相邻)→ 方向内产品创建时间倒序;产品记录缺失的组殿后;游离组固定最后
|
||||
Map<Long, ProductDO> productMap = loadGroupProducts(groupedByProduct.keySet(), visibleProducts);
|
||||
List<Long> orderedProductIds = sortGroupProductIds(groupedByProduct.keySet(), productMap);
|
||||
|
||||
// 6. 组维度分页(游离组算最后一个组)
|
||||
boolean hasOrphanGroup = !orphanProjects.isEmpty();
|
||||
int totalGroups = orderedProductIds.size() + (hasOrphanGroup ? 1 : 0);
|
||||
int fromIndex = (reqVO.getPageNo() - 1) * reqVO.getPageSize();
|
||||
int toIndex = Math.min(fromIndex + reqVO.getPageSize(), totalGroups);
|
||||
|
||||
ProjectGroupPageRespVO respVO = new ProjectGroupPageRespVO();
|
||||
respVO.setTotal((long) totalGroups);
|
||||
respVO.setProjectTotal((long) matched.size());
|
||||
respVO.setOrphanTotal((long) orphanProjects.size());
|
||||
respVO.setDirectionCount((int) visibleProducts.stream()
|
||||
.map(ProductDO::getDirectionCode)
|
||||
.filter(StringUtils::hasText)
|
||||
.distinct()
|
||||
.count());
|
||||
if (fromIndex >= totalGroups) {
|
||||
respVO.setList(Collections.emptyList());
|
||||
return respVO;
|
||||
}
|
||||
|
||||
// 7. 当页组:产品组在前,游离组若落在当页区间则最后
|
||||
List<Long> pageProductIds = orderedProductIds.subList(fromIndex, Math.min(toIndex, orderedProductIds.size()));
|
||||
boolean orphanOnPage = hasOrphanGroup && toIndex == totalGroups;
|
||||
|
||||
// 8. typeCounts(恒按"全部"口径)与 hasBaseline(非已取消主线):产品级属性,不随筛选与数据权限变化
|
||||
Map<Long, Map<String, Long>> typeCountsMap = new HashMap<>();
|
||||
Map<String, Long> orphanTypeCounts = new HashMap<>();
|
||||
if ((!pageProductIds.isEmpty() || orphanOnPage) && !defaultStatusCodes.isEmpty()) {
|
||||
LambdaQueryWrapperX<ProjectDO> typeWrapper = new LambdaQueryWrapperX<ProjectDO>()
|
||||
.in(ProjectDO::getStatusCode, defaultStatusCodes);
|
||||
boolean withOrphan = orphanOnPage;
|
||||
typeWrapper.and(w -> {
|
||||
if (!pageProductIds.isEmpty()) {
|
||||
w.in(ProjectDO::getProductId, pageProductIds);
|
||||
}
|
||||
if (withOrphan) {
|
||||
if (!pageProductIds.isEmpty()) {
|
||||
w.or();
|
||||
}
|
||||
w.isNull(ProjectDO::getProductId);
|
||||
}
|
||||
});
|
||||
for (ProjectDO project : projectMapper.selectList(typeWrapper)) {
|
||||
if (project.getProjectType() == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Long> counts = project.getProductId() == null
|
||||
? orphanTypeCounts
|
||||
: typeCountsMap.computeIfAbsent(project.getProductId(), k -> new HashMap<>());
|
||||
counts.merge(project.getProjectType(), 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
Set<Long> baselineProductIds = pageProductIds.isEmpty() ? Collections.emptySet()
|
||||
: projectMapper.selectList(new LambdaQueryWrapperX<ProjectDO>()
|
||||
.in(ProjectDO::getProductId, pageProductIds)
|
||||
.in(ProjectDO::getProjectType, ProjectObjectConstants.MAINLINE_PROJECT_TYPE_CODES)
|
||||
.ne(ProjectDO::getStatusCode, ProjectObjectConstants.STATUS_CANCELLED))
|
||||
.stream().map(ProjectDO::getProductId).collect(Collectors.toSet());
|
||||
|
||||
// 9. 当页组内 topN 项目(更新时间倒序),统一批量转换回填
|
||||
int topN = reqVO.getTopN() == null ? 5 : reqVO.getTopN();
|
||||
Map<Long, List<ProjectDO>> pageGroupProjects = new LinkedHashMap<>();
|
||||
for (Long productId : pageProductIds) {
|
||||
pageGroupProjects.put(productId, topNByUpdateTimeDesc(groupedByProduct.get(productId), topN));
|
||||
}
|
||||
List<ProjectDO> orphanTopN = orphanOnPage ? topNByUpdateTimeDesc(orphanProjects, topN) : Collections.emptyList();
|
||||
List<ProjectDO> allPageProjects = new ArrayList<>();
|
||||
pageGroupProjects.values().forEach(allPageProjects::addAll);
|
||||
allPageProjects.addAll(orphanTopN);
|
||||
Map<Long, ProjectRespVO> convertedById = new HashMap<>();
|
||||
convertProjectListWithNames(allPageProjects).forEach(vo -> convertedById.put(vo.getId(), vo));
|
||||
|
||||
// 10. 组装组 VO 并批量回填产品负责人昵称
|
||||
List<ProjectGroupRespVO> groupList = new ArrayList<>();
|
||||
for (Long productId : pageProductIds) {
|
||||
groupList.add(buildGroupRespVO(productMap.get(productId), productId,
|
||||
groupedByProduct.get(productId), pageGroupProjects.get(productId),
|
||||
typeCountsMap.getOrDefault(productId, Collections.emptyMap()),
|
||||
baselineProductIds.contains(productId), convertedById));
|
||||
}
|
||||
if (orphanOnPage) {
|
||||
groupList.add(buildOrphanGroupRespVO(orphanProjects, orphanTopN, orphanTypeCounts, convertedById));
|
||||
}
|
||||
fillGroupManagerNicknames(groupList);
|
||||
respVO.setList(groupList);
|
||||
return respVO;
|
||||
}
|
||||
|
||||
private List<ProjectDO> selectGroupMatchedProjects(ProjectGroupPageReqVO reqVO, Collection<String> statusCodes,
|
||||
ObjectDataScope projectScope) {
|
||||
// 状态集为空(状态机未配置)时不查库,空 IN 会生成非法 SQL
|
||||
if (projectScope.getState() == ObjectDataScope.State.EMPTY || statusCodes.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
|
||||
if (StringUtils.hasText(reqVO.getKeyword())) {
|
||||
wrapper.and(w -> w.like(ProjectDO::getProjectCode, reqVO.getKeyword())
|
||||
.or()
|
||||
.like(ProjectDO::getProjectName, reqVO.getKeyword()));
|
||||
}
|
||||
wrapper.eqIfPresent(ProjectDO::getProjectType, reqVO.getProjectType())
|
||||
.eqIfPresent(ProjectDO::getProductId, reqVO.getProductId())
|
||||
.in(ProjectDO::getStatusCode, statusCodes);
|
||||
if (Boolean.TRUE.equals(reqVO.getOrphanOnly())) {
|
||||
wrapper.isNull(ProjectDO::getProductId);
|
||||
}
|
||||
applyProjectScopeCondition(wrapper, projectScope);
|
||||
return projectMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户可见的启用/暂停产品:零项目占位组候选 + directionCount 口径(产品域数据权限)。
|
||||
*/
|
||||
private List<ProductDO> selectVisibleGroupProducts(ObjectDataScope productScope) {
|
||||
if (productScope.getState() == ObjectDataScope.State.EMPTY) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Set<String> liveStatusCodes = resolveLiveProductStatusCodes();
|
||||
if (liveStatusCodes.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
LambdaQueryWrapperX<ProductDO> wrapper = new LambdaQueryWrapperX<ProductDO>()
|
||||
.in(ProductDO::getStatusCode, liveStatusCodes)
|
||||
.orderByDesc(BaseDO::getCreateTime);
|
||||
if (productScope.getState() == ObjectDataScope.State.ID_LIST) {
|
||||
Set<Long> ids = productScope.getIds();
|
||||
Set<String> dcs = productScope.getDirectionCodes();
|
||||
wrapper.and(w -> {
|
||||
boolean addedAny = false;
|
||||
if (!ids.isEmpty()) {
|
||||
w.in(ProductDO::getId, ids);
|
||||
addedAny = true;
|
||||
}
|
||||
if (!dcs.isEmpty()) {
|
||||
if (addedAny) {
|
||||
w.or();
|
||||
}
|
||||
w.in(ProductDO::getDirectionCode, dcs);
|
||||
}
|
||||
});
|
||||
}
|
||||
return productMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组的产品信息:优先用可见产品列表,缺的(如归档产品但仍有命中项目)批量补查。
|
||||
*/
|
||||
private Map<Long, ProductDO> loadGroupProducts(Set<Long> groupProductIds, List<ProductDO> visibleProducts) {
|
||||
Map<Long, ProductDO> productMap = new HashMap<>();
|
||||
visibleProducts.forEach(p -> productMap.put(p.getId(), p));
|
||||
Set<Long> missingIds = groupProductIds.stream()
|
||||
.filter(id -> !productMap.containsKey(id))
|
||||
.collect(Collectors.toSet());
|
||||
if (!missingIds.isEmpty()) {
|
||||
productMapper.selectBatchIds(missingIds).forEach(p -> productMap.put(p.getId(), p));
|
||||
}
|
||||
return productMap;
|
||||
}
|
||||
|
||||
private List<Long> sortGroupProductIds(Set<Long> groupProductIds, Map<Long, ProductDO> productMap) {
|
||||
Comparator<Long> comparator = Comparator
|
||||
// 方向编码升序(同方向的组相邻),产品记录缺失/方向为空的组殿后
|
||||
.comparing((Long id) -> {
|
||||
ProductDO product = productMap.get(id);
|
||||
return product == null || !StringUtils.hasText(product.getDirectionCode())
|
||||
? null : product.getDirectionCode();
|
||||
}, Comparator.nullsLast(Comparator.<String>naturalOrder()))
|
||||
// 方向内按产品创建时间倒序(与产品列表默认排序一致)
|
||||
.thenComparing((Long id) -> {
|
||||
ProductDO product = productMap.get(id);
|
||||
return product == null ? null : product.getCreateTime();
|
||||
}, Comparator.nullsLast(Comparator.<LocalDateTime>reverseOrder()));
|
||||
return groupProductIds.stream().sorted(comparator).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<ProjectDO> topNByUpdateTimeDesc(List<ProjectDO> projects, int topN) {
|
||||
return projects.stream()
|
||||
.sorted(Comparator.comparing(ProjectDO::getUpdateTime, Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.limit(topN)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ProjectGroupRespVO buildGroupRespVO(ProductDO product, Long productId, List<ProjectDO> groupProjects,
|
||||
List<ProjectDO> topProjects, Map<String, Long> typeCounts,
|
||||
boolean hasBaseline, Map<Long, ProjectRespVO> convertedById) {
|
||||
ProjectGroupRespVO group = new ProjectGroupRespVO();
|
||||
group.setProductId(productId);
|
||||
if (product != null) {
|
||||
group.setProductName(product.getName());
|
||||
group.setProductCode(product.getCode());
|
||||
group.setDirectionCode(product.getDirectionCode());
|
||||
group.setManagerUserId(product.getManagerUserId());
|
||||
}
|
||||
group.setProjectTotal((long) groupProjects.size());
|
||||
group.setProjects(topProjects.stream().map(p -> convertedById.get(p.getId())).collect(Collectors.toList()));
|
||||
group.setTypeCounts(typeCounts);
|
||||
group.setHasBaseline(hasBaseline);
|
||||
group.setOrphan(false);
|
||||
return group;
|
||||
}
|
||||
|
||||
private ProjectGroupRespVO buildOrphanGroupRespVO(List<ProjectDO> orphanProjects, List<ProjectDO> topProjects,
|
||||
Map<String, Long> typeCounts,
|
||||
Map<Long, ProjectRespVO> convertedById) {
|
||||
ProjectGroupRespVO group = new ProjectGroupRespVO();
|
||||
group.setProductName("游离项目");
|
||||
group.setDirectionCode("");
|
||||
group.setProjectTotal((long) orphanProjects.size());
|
||||
group.setProjects(topProjects.stream().map(p -> convertedById.get(p.getId())).collect(Collectors.toList()));
|
||||
group.setTypeCounts(typeCounts);
|
||||
group.setHasBaseline(false);
|
||||
group.setOrphan(true);
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量回填组的产品负责人昵称(一次用户 API 调用)。
|
||||
*/
|
||||
private void fillGroupManagerNicknames(List<ProjectGroupRespVO> groupList) {
|
||||
Set<Long> managerUserIds = groupList.stream()
|
||||
.map(ProjectGroupRespVO::getManagerUserId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (managerUserIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(managerUserIds);
|
||||
groupList.forEach(group -> {
|
||||
AdminUserRespDTO manager = group.getManagerUserId() == null ? null : userMap.get(group.getManagerUserId());
|
||||
group.setManagerUserNickname(manager == null ? null : manager.getNickname());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 scope 算出 (statusCode, countValue) 行集,喂给 {@link #buildProjectStatusCounts}。
|
||||
* EMPTY 直接空集;ALL 走原全表 GROUP BY SQL;ID_LIST 用 wrapper 取 status_code,Java 端 group + count。
|
||||
@@ -527,6 +841,27 @@ class ProjectServiceImpl implements ProjectService {
|
||||
// ID_LIST
|
||||
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
|
||||
wrapper.select(ProjectDO::getStatusCode);
|
||||
applyProjectScopeCondition(wrapper, scope);
|
||||
return projectMapper.selectList(wrapper).stream()
|
||||
.filter(p -> p.getStatusCode() != null)
|
||||
.collect(Collectors.groupingBy(ProjectDO::getStatusCode, Collectors.counting()))
|
||||
.entrySet().stream()
|
||||
.map(e -> {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("statusCode", e.getKey());
|
||||
row.put("countValue", e.getValue());
|
||||
return row;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* ID_LIST 数据权限注入:项目 id 集合 OR 方向集合(与历史 getProjectPage 行为一致)。
|
||||
*/
|
||||
private void applyProjectScopeCondition(LambdaQueryWrapperX<ProjectDO> wrapper, ObjectDataScope scope) {
|
||||
if (scope.getState() != ObjectDataScope.State.ID_LIST) {
|
||||
return;
|
||||
}
|
||||
Set<Long> ids = scope.getIds();
|
||||
Set<String> dcs = scope.getDirectionCodes();
|
||||
wrapper.and(w -> {
|
||||
@@ -542,17 +877,35 @@ class ProjectServiceImpl implements ProjectService {
|
||||
w.in(ProjectDO::getDirectionCode, dcs);
|
||||
}
|
||||
});
|
||||
return projectMapper.selectList(wrapper).stream()
|
||||
.filter(p -> p.getStatusCode() != null)
|
||||
.collect(Collectors.groupingBy(ProjectDO::getStatusCode, Collectors.counting()))
|
||||
.entrySet().stream()
|
||||
.map(e -> {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("statusCode", e.getKey());
|
||||
row.put("countValue", e.getValue());
|
||||
return row;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* DO 批量转 RespVO 并回填产品名 / 负责人昵称(一次批量查产品、一次批量查用户,禁止逐条)。
|
||||
* page 接口与分组接口共用,保证两接口项目行字段一致。
|
||||
*/
|
||||
private List<ProjectRespVO> convertProjectListWithNames(List<ProjectDO> projects) {
|
||||
if (projects == null || projects.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Set<Long> productIds = projects.stream()
|
||||
.map(ProjectDO::getProductId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
Map<Long, ProductDO> productMap = new HashMap<>();
|
||||
if (!productIds.isEmpty()) {
|
||||
productMapper.selectBatchIds(productIds).forEach(p -> productMap.put(p.getId(), p));
|
||||
}
|
||||
Set<Long> userIds = projects.stream()
|
||||
.map(ProjectDO::getManagerUserId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
Map<Long, AdminUserRespDTO> userMap = userIds.isEmpty()
|
||||
? Collections.emptyMap()
|
||||
: adminUserApi.getUserMap(userIds);
|
||||
return projects.stream().map(project -> {
|
||||
ProjectRespVO respVO = BeanUtils.toBean(project, ProjectRespVO.class);
|
||||
ProductDO product = project.getProductId() == null ? null : productMap.get(project.getProductId());
|
||||
respVO.setProductName(product == null ? null : product.getName());
|
||||
AdminUserRespDTO manager = project.getManagerUserId() == null ? null : userMap.get(project.getManagerUserId());
|
||||
respVO.setManagerUserNickname(manager == null ? null : manager.getNickname());
|
||||
return respVO;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private String getProductName(Long productId) {
|
||||
|
||||
@@ -41,6 +41,8 @@ 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.framework.notify.NotifySendEvent;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
|
||||
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
|
||||
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
|
||||
import com.njcn.rdms.module.project.service.project.ProjectService;
|
||||
@@ -63,6 +65,7 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
@@ -130,6 +133,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
private DictDataApi dictDataApi;
|
||||
@Resource
|
||||
private StatusActionTextResolver statusActionTextResolver;
|
||||
@Resource
|
||||
private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -172,6 +177,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
// 创建任务时初始化协办人列表(同事务,任一项失败整笔回滚;列表为空跳过)
|
||||
taskAssigneeService.initializeAssignees(task.getId(), ownerId, executionId, reqVO.getAssigneeUserIds());
|
||||
|
||||
// 站内信通知:创建任务后通知负责人 + 活跃协办人(含操作人自己;通知失败不影响任务创建)
|
||||
publishTaskAssignedNotify(projectId, task, ownerId);
|
||||
|
||||
// 父任务(含祖先)进度按子任务平均自动汇总
|
||||
if (task.getParentTaskId() != null) {
|
||||
recalcParentProgressFrom(task.getParentTaskId());
|
||||
@@ -186,6 +194,25 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
return task.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务后发送「任务指派」站内信事件:接收人 = 负责人 + 活跃协办人(含操作人自己)。
|
||||
* 仅 publish 事件,真正发送由 {@link com.njcn.rdms.module.project.framework.notify.NotifySendEventListener}
|
||||
* 在事务提交后处理;通知失败不影响任务创建。
|
||||
*/
|
||||
@VisibleForTesting
|
||||
void publishTaskAssignedNotify(Long projectId, ProjectTaskDO task, Long ownerId) {
|
||||
List<Long> recipients = new ArrayList<>();
|
||||
recipients.add(ownerId);
|
||||
taskAssigneeMapper.selectActiveListByTaskId(task.getId())
|
||||
.forEach(assignee -> recipients.add(assignee.getUserId()));
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
ProjectDO project = projectMapper.selectById(projectId);
|
||||
params.put("projectName", project == null ? "" : project.getProjectName());
|
||||
params.put("taskName", task.getTaskTitle());
|
||||
applicationEventPublisher.publishEvent(
|
||||
NotifySendEvent.of(recipients, NotifyTemplateCodeConstants.TASK_ASSIGNED, params));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.njcn.rdms.module.project.framework.notify;
|
||||
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* {@link NotifySendEventListener} 的单元测试:去重 / 空短路 / 单条失败不中断。
|
||||
*/
|
||||
class NotifySendEventListenerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private NotifySendEventListener listener;
|
||||
|
||||
@Mock
|
||||
private NotifyMessageSendApi notifyMessageSendApi;
|
||||
|
||||
@Test
|
||||
void testOnNotifySend_dedupAndSend() {
|
||||
// 1L 重复两次 + 2L:去重后只发 1L、2L 各一次
|
||||
listener.onNotifySend(NotifySendEvent.of(Arrays.asList(1L, 1L, 2L), "task_assigned", new HashMap<>()));
|
||||
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(1L), eq("task_assigned"), any());
|
||||
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), eq("task_assigned"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOnNotifySend_emptyRecipients_noSend() {
|
||||
listener.onNotifySend(NotifySendEvent.of(Collections.emptyList(), "task_assigned", new HashMap<>()));
|
||||
verify(notifyMessageSendApi, times(0)).sendSingleNotifyToAdmin(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOnNotifySend_singleFailure_doesNotInterrupt() {
|
||||
// 第一个人发送抛异常,第二个人仍应被发送(兜底不中断)
|
||||
doThrow(new RuntimeException("boom")).when(notifyMessageSendApi)
|
||||
.sendSingleNotifyToAdmin(eq(1L), any(), any());
|
||||
listener.onNotifySend(NotifySendEvent.of(Arrays.asList(1L, 2L), "task_assigned", new HashMap<>()));
|
||||
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), any(), any());
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupPageRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectGroupRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectLifecycleActionRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||
@@ -43,6 +47,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -455,7 +460,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.empty());
|
||||
|
||||
PageResult<ProjectDO> result = projectService.getProjectPage(new ProjectPageReqVO());
|
||||
PageResult<ProjectRespVO> result = projectService.getProjectPage(new ProjectPageReqVO());
|
||||
|
||||
assertThat(result.getList()).isEmpty();
|
||||
verify(projectMapper, never()).selectPage(any(ProjectPageReqVO.class), any());
|
||||
@@ -489,6 +494,180 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectPage_fillsProductNameAndManagerNicknameInBatch() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||
|
||||
ProjectDO p1 = new ProjectDO();
|
||||
p1.setId(101L);
|
||||
p1.setProductId(11L);
|
||||
p1.setManagerUserId(21L);
|
||||
ProjectDO p2 = new ProjectDO();
|
||||
p2.setId(102L); // 游离且无负责人,回填应安全跳过
|
||||
when(projectMapper.selectPage(any(ProjectPageReqVO.class), any()))
|
||||
.thenReturn(new PageResult<>(List.of(p1, p2), 2L));
|
||||
|
||||
ProductDO product = new ProductDO();
|
||||
product.setId(11L);
|
||||
product.setName("智能网关");
|
||||
when(productMapper.selectBatchIds(Set.of(11L))).thenReturn(List.of(product));
|
||||
|
||||
AdminUserRespDTO manager = new AdminUserRespDTO();
|
||||
manager.setId(21L);
|
||||
manager.setNickname("张三");
|
||||
when(adminUserApi.getUserMap(Set.of(21L))).thenReturn(Map.of(21L, manager));
|
||||
|
||||
PageResult<ProjectRespVO> result = projectService.getProjectPage(new ProjectPageReqVO());
|
||||
|
||||
assertEquals(2, result.getList().size());
|
||||
assertEquals("智能网关", result.getList().get(0).getProductName());
|
||||
assertEquals("张三", result.getList().get(0).getManagerUserNickname());
|
||||
assertEquals(102L, result.getList().get(1).getId());
|
||||
// 批量回填:用户 API 只允许调用一次
|
||||
verify(adminUserApi, times(1)).getUserMap(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectGroupPage_returnsEmpty_whenScopesEmpty() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.empty());
|
||||
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.empty());
|
||||
|
||||
ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(new ProjectGroupPageReqVO());
|
||||
|
||||
assertEquals(0L, respVO.getTotal());
|
||||
assertEquals(0L, respVO.getProjectTotal());
|
||||
assertEquals(0L, respVO.getOrphanTotal());
|
||||
assertEquals(0, respVO.getDirectionCount());
|
||||
assertThat(respVO.getList()).isEmpty();
|
||||
verify(projectMapper, never()).selectList(any(LambdaQueryWrapperX.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectGroupPage_groupsByProduct_orphanLast_topNTruncated() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("product")).thenReturn(productStatusModels());
|
||||
|
||||
// 产品 11(方向 dir_a)3 个项目、产品 12(方向 dir_b)1 个项目、游离 1 个
|
||||
ProjectDO p1 = createGroupProject(101L, 11L, "main", LocalDateTime.of(2026, 6, 1, 10, 0));
|
||||
ProjectDO p2 = createGroupProject(102L, 11L, "contract", LocalDateTime.of(2026, 6, 3, 10, 0));
|
||||
ProjectDO p3 = createGroupProject(103L, 11L, "contract", LocalDateTime.of(2026, 6, 2, 10, 0));
|
||||
ProjectDO p4 = createGroupProject(104L, 12L, "tech_support", LocalDateTime.of(2026, 6, 1, 10, 0));
|
||||
ProjectDO p5 = createGroupProject(105L, null, "contract", LocalDateTime.of(2026, 6, 1, 10, 0));
|
||||
// selectList 调用次序与实现一致:① 命中项目 ② typeCounts(四状态) ③ hasBaseline(主线)
|
||||
when(projectMapper.selectList(any(LambdaQueryWrapperX.class)))
|
||||
.thenReturn(List.of(p1, p2, p3, p4, p5),
|
||||
List.of(p1, p2, p3, p4, p5),
|
||||
List.of(p1));
|
||||
ProductDO prod11 = createGroupProduct(11L, "智能网关", "dir_a", 21L);
|
||||
ProductDO prod12 = createGroupProduct(12L, "边缘平台", "dir_b", null);
|
||||
when(productMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(prod11, prod12));
|
||||
when(adminUserApi.getUserMap(any())).thenReturn(Collections.emptyMap());
|
||||
|
||||
ProjectGroupPageReqVO reqVO = new ProjectGroupPageReqVO();
|
||||
reqVO.setTopN(2);
|
||||
ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(reqVO);
|
||||
|
||||
assertEquals(3L, respVO.getTotal()); // 两个产品组 + 游离组
|
||||
assertEquals(5L, respVO.getProjectTotal());
|
||||
assertEquals(1L, respVO.getOrphanTotal());
|
||||
assertEquals(2, respVO.getDirectionCount());
|
||||
assertEquals(3, respVO.getList().size());
|
||||
|
||||
ProjectGroupRespVO group11 = respVO.getList().get(0); // dir_a 在 dir_b 前
|
||||
assertEquals(11L, group11.getProductId());
|
||||
assertEquals(3L, group11.getProjectTotal());
|
||||
assertEquals(2, group11.getProjects().size()); // topN 截断
|
||||
assertEquals(102L, group11.getProjects().get(0).getId()); // 更新时间倒序
|
||||
assertEquals(103L, group11.getProjects().get(1).getId());
|
||||
assertEquals(Boolean.TRUE, group11.getHasBaseline());
|
||||
assertEquals(Map.of("main", 1L, "contract", 2L), group11.getTypeCounts());
|
||||
|
||||
ProjectGroupRespVO orphanGroup = respVO.getList().get(2); // 游离组殿后
|
||||
assertEquals(Boolean.TRUE, orphanGroup.getOrphan());
|
||||
assertEquals("游离项目", orphanGroup.getProductName());
|
||||
assertEquals(1L, orphanGroup.getProjectTotal());
|
||||
assertEquals(Map.of("contract", 1L), orphanGroup.getTypeCounts());
|
||||
assertEquals(Boolean.FALSE, orphanGroup.getHasBaseline());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectGroupPage_appendsZeroProjectGroups_onDefaultScope() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("product")).thenReturn(productStatusModels());
|
||||
|
||||
ProjectDO p1 = createGroupProject(101L, 11L, "contract", LocalDateTime.of(2026, 6, 1, 10, 0));
|
||||
when(projectMapper.selectList(any(LambdaQueryWrapperX.class)))
|
||||
.thenReturn(List.of(p1), List.of(p1), Collections.emptyList());
|
||||
ProductDO prod11 = createGroupProduct(11L, "智能网关", "dir_a", null);
|
||||
ProductDO prod12 = createGroupProduct(12L, "边缘平台", "dir_b", null);
|
||||
when(productMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(prod11, prod12));
|
||||
|
||||
ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(new ProjectGroupPageReqVO());
|
||||
|
||||
assertEquals(2L, respVO.getTotal()); // 产品 11 命中组 + 产品 12 零项目占位组,无游离组
|
||||
ProjectGroupRespVO zeroGroup = respVO.getList().get(1);
|
||||
assertEquals(12L, zeroGroup.getProductId());
|
||||
assertEquals(0L, zeroGroup.getProjectTotal());
|
||||
assertThat(zeroGroup.getProjects()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectGroupPage_skipsZeroGroupsAndMisses_whenStatusFiltered() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("product")).thenReturn(productStatusModels());
|
||||
|
||||
ProjectDO p1 = createGroupProject(101L, 11L, "contract", LocalDateTime.of(2026, 6, 1, 10, 0));
|
||||
when(projectMapper.selectList(any(LambdaQueryWrapperX.class)))
|
||||
.thenReturn(List.of(p1), List.of(p1), Collections.emptyList());
|
||||
ProductDO prod11 = createGroupProduct(11L, "智能网关", "dir_a", null);
|
||||
ProductDO prod12 = createGroupProduct(12L, "边缘平台", "dir_b", null);
|
||||
when(productMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(prod11, prod12));
|
||||
|
||||
ProjectGroupPageReqVO reqVO = new ProjectGroupPageReqVO();
|
||||
reqVO.setStatusCode("active");
|
||||
ProjectGroupPageRespVO respVO = projectService.getProjectGroupPage(reqVO);
|
||||
|
||||
assertEquals(1L, respVO.getTotal()); // 仅产品 11;产品 12 不补占位;游离无命中不返回
|
||||
assertEquals(11L, respVO.getList().get(0).getProductId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProjectOverviewSummary_includesOrphanCount() {
|
||||
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||
when(projectMapper.selectStatusCountList()).thenReturn(Collections.emptyList());
|
||||
when(objectStatusModelMapper.selectListByObjectType("project")).thenReturn(Collections.emptyList());
|
||||
when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels());
|
||||
when(projectMapper.selectCount(any(LambdaQueryWrapperX.class))).thenReturn(3L);
|
||||
|
||||
ProjectOverviewSummaryRespVO respVO = projectService.getProjectOverviewSummary();
|
||||
|
||||
assertEquals(3L, respVO.getOrphanCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void changeProjectStatus_shouldUpdateStatusAndWriteStatusAndAuditLogs() {
|
||||
Long projectId = 1003L;
|
||||
@@ -663,4 +842,57 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
return mockedStatic;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组接口测试用状态机行:status=0(启用)。"全部"口径与产品存续状态均由状态机推导,测试必须配齐。
|
||||
*/
|
||||
private ObjectStatusModelDO createGroupStatusModel(String objectType, String statusCode, boolean terminalFlag) {
|
||||
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
|
||||
statusModel.setObjectType(objectType);
|
||||
statusModel.setStatusCode(statusCode);
|
||||
statusModel.setStatus(0);
|
||||
statusModel.setTerminalFlag(terminalFlag);
|
||||
return statusModel;
|
||||
}
|
||||
|
||||
/** 项目域状态机 stub:启用状态全集(含 cancelled/archived,推导逻辑应将其排除)。 */
|
||||
private List<ObjectStatusModelDO> projectStatusModels() {
|
||||
return List.of(
|
||||
createGroupStatusModel("project", "pending", false),
|
||||
createGroupStatusModel("project", "active", false),
|
||||
createGroupStatusModel("project", "paused", false),
|
||||
createGroupStatusModel("project", "completed", true),
|
||||
createGroupStatusModel("project", "cancelled", true),
|
||||
createGroupStatusModel("project", "archived", true));
|
||||
}
|
||||
|
||||
/** 产品域状态机 stub:启用/暂停为非终态,归档/废弃为终态(推导应只取非终态)。 */
|
||||
private List<ObjectStatusModelDO> productStatusModels() {
|
||||
return List.of(
|
||||
createGroupStatusModel("product", "active", false),
|
||||
createGroupStatusModel("product", "paused", false),
|
||||
createGroupStatusModel("product", "archived", true),
|
||||
createGroupStatusModel("product", "abandoned", true));
|
||||
}
|
||||
|
||||
private ProjectDO createGroupProject(Long id, Long productId, String projectType, LocalDateTime updateTime) {
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(id);
|
||||
project.setProductId(productId);
|
||||
project.setProjectType(projectType);
|
||||
project.setStatusCode("active");
|
||||
project.setUpdateTime(updateTime);
|
||||
return project;
|
||||
}
|
||||
|
||||
private ProductDO createGroupProduct(Long id, String name, String directionCode, Long managerUserId) {
|
||||
ProductDO product = new ProductDO();
|
||||
product.setId(id);
|
||||
product.setName(name);
|
||||
product.setDirectionCode(directionCode);
|
||||
product.setStatusCode("active");
|
||||
product.setManagerUserId(managerUserId);
|
||||
product.setCreateTime(LocalDateTime.of(2026, 5, 1, 0, 0));
|
||||
return product;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.njcn.rdms.module.project.service.project.task;
|
||||
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
|
||||
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link ProjectTaskServiceImpl} 的单元测试 —— 任务指派站内信埋点。
|
||||
* 仅验证埋点方法 {@code publishTaskAssignedNotify} 的收件人/参数组织与事件发布;
|
||||
* createTask 端到端正确性由运行态联调验证。
|
||||
*/
|
||||
class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private ProjectTaskServiceImpl projectTaskService;
|
||||
|
||||
@Mock
|
||||
private ProjectMapper projectMapper;
|
||||
@Mock
|
||||
private TaskAssigneeMapper taskAssigneeMapper;
|
||||
@Mock
|
||||
private ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Test
|
||||
void testPublishTaskAssignedNotify_recipientsAndParams() {
|
||||
ProjectTaskDO task = new ProjectTaskDO();
|
||||
task.setId(1000L);
|
||||
task.setTaskTitle("联调任务");
|
||||
// 活跃协办人 2L、3L
|
||||
TaskAssigneeDO a2 = new TaskAssigneeDO();
|
||||
a2.setUserId(2L);
|
||||
TaskAssigneeDO a3 = new TaskAssigneeDO();
|
||||
a3.setUserId(3L);
|
||||
when(taskAssigneeMapper.selectActiveListByTaskId(1000L)).thenReturn(Arrays.asList(a2, a3));
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setProjectName("演示项目");
|
||||
when(projectMapper.selectById(50L)).thenReturn(project);
|
||||
|
||||
projectTaskService.publishTaskAssignedNotify(50L, task, 1L);
|
||||
|
||||
ArgumentCaptor<NotifySendEvent> captor = ArgumentCaptor.forClass(NotifySendEvent.class);
|
||||
verify(applicationEventPublisher).publishEvent(captor.capture());
|
||||
NotifySendEvent event = captor.getValue();
|
||||
assertEquals(NotifyTemplateCodeConstants.TASK_ASSIGNED, event.getTemplateCode());
|
||||
assertTrue(event.getUserIds().contains(1L)); // 负责人
|
||||
assertTrue(event.getUserIds().contains(2L)); // 协办人
|
||||
assertTrue(event.getUserIds().contains(3L)); // 协办人
|
||||
assertEquals("演示项目", event.getParams().get("projectName"));
|
||||
assertEquals("联调任务", event.getParams().get("taskName"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user