From 79591e66be50a25a5e52d7b49495df2c10b5876a Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Thu, 11 Jun 2026 09:17:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor(project):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=B8=B8=E9=87=8F=E5=AE=9A=E4=B9=89=E5=B9=B6?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=9C=80=E6=B1=82=E8=BF=9B=E5=BA=A6=E8=81=9A?= =?UTF-8?q?=E5=90=88=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将产品和项目查询权限码统一提取到常量类中 - 移除需求进度聚合计算的相关实现代码 - 更新权限验证注解使用新的常量定义 - 清理相关的单元测试代码 - 更新错误码注释说明 --- CLAUDE.md | 9 + .../constant/ProjectObjectConstants.java | 13 + .../admin/project/ProjectController.java | 14 +- .../vo/project/ProjectGroupPageReqVO.java | 45 ++ .../vo/project/ProjectGroupPageRespVO.java | 27 ++ .../vo/project/ProjectGroupRespVO.java | 46 ++ .../project/ProjectOverviewSummaryRespVO.java | 3 + .../project/vo/project/ProjectPageReqVO.java | 14 + .../framework/notify/NotifySendEvent.java | 46 ++ .../notify/NotifySendEventListener.java | 55 +++ .../notify/NotifyTemplateCodeConstants.java | 16 + .../rpc/config/RpcConfiguration.java | 3 +- .../service/project/ProjectService.java | 10 +- .../service/project/ProjectServiceImpl.java | 421 ++++++++++++++++-- .../project/task/ProjectTaskServiceImpl.java | 27 ++ .../notify/NotifySendEventListenerTest.java | 52 +++ .../project/ProjectServiceImplTest.java | 234 +++++++++- .../task/ProjectTaskServiceImplTest.java | 68 +++ .../api/notify/NotifyMessageSendApi.java | 45 ++ .../notify/dto/NotifySingleSendReqDTO.java | 31 ++ .../api/notify/NotifyMessageSendApiImpl.java | 33 ++ .../admin/notice/NoticeController.java | 9 + .../vo/message/NotifyMessageMyPageReqVO.java | 3 + .../system/dal/mysql/notice/NoticeMapper.java | 13 + .../dal/mysql/notify/NotifyMessageMapper.java | 3 + .../system/service/notice/NoticeService.java | 9 + .../service/notice/NoticeServiceImpl.java | 6 + .../mysql/notify/NotifyMessageMapperTest.java | 80 ++++ .../service/notice/NoticeServiceImplTest.java | 38 ++ 项目列表产品分组-后端接口诉求-2026-06-10.html | 266 +++++++++++ 30 files changed, 1598 insertions(+), 41 deletions(-) create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupPageReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupPageRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifySendEvent.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifySendEventListener.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifyTemplateCodeConstants.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/notify/NotifySendEventListenerTest.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java create mode 100644 rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/NotifyMessageSendApi.java create mode 100644 rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifySingleSendReqDTO.java create mode 100644 rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/notify/NotifyMessageSendApiImpl.java create mode 100644 rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/dal/mysql/notify/NotifyMessageMapperTest.java create mode 100644 rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/notice/NoticeServiceImplTest.java create mode 100644 项目列表产品分组-后端接口诉求-2026-06-10.html diff --git a/CLAUDE.md b/CLAUDE.md index c04be5b..bdcdcc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,15 @@ 判定口诀:**URL 里有 `{projectId}` / `{productId}` 等对象 ID → 对象域;没有 → 全域**。 +### 站内信通知事件(project 域统一入口) + +业务动作要发站内信时,统一走 `framework/notify` 的 `NotifySendEvent`(`publishEvent(NotifySendEvent.of(userIds, templateCode, params))`),由全局唯一的 `NotifySendEventListener`(`@TransactionalEventListener(AFTER_COMMIT)`)做去重 / 兜底 / 调 `NotifyMessageSendApi`;不要绕过事件直接在业务 Service 里注入发送 API。 + +红线: + +- **`NotifySendEvent` 只能在 `@Transactional` 的业务方法里 publish**。`@TransactionalEventListener` 的语义是"只在有事务的上下文里 publish 才会被消费"——在无事务方法里 publish,通知会**静默不发、不报错**。新业务接入通知前先确认触发点方法有事务;确实无事务的场景不要"顺手 publish",要么补事务,要么找用户确认方案。 +- 模板场景码登记进 `NotifyTemplateCodeConstants`,并保证 `system_notify_template` 已配同 code 模板(模板缺失时监听器只 `log.warn`,该场景通知发不出但不影响业务)。 + ## 接口语义(HTTP 动词) 本仓库 update 类接口默认按 RESTful 标准用 HTTP 动词区分语义,前后端必须按下表对齐,避免"前端没传字段"和"前端想清空"在后端无法区分的歧义。 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java index b2539fd..3c51083 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java @@ -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 DEFAULT_QUERY_EXCLUDED_STATUS_CODES = Set.of(STATUS_CANCELLED, STATUS_ARCHIVED); + /** * 项目自动编码前缀。 */ diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/ProjectController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/ProjectController.java index 6f3ca18..f036c2a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/ProjectController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/ProjectController.java @@ -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> getProjectPage(@Valid ProjectPageReqVO pageReqVO) { - PageResult pageResult = projectService.getProjectPage(pageReqVO); - return success(BeanUtils.toBean(pageResult, ProjectRespVO.class)); + return success(projectService.getProjectPage(pageReqVO)); + } + + @GetMapping("/group-page") + @Operation(summary = "获取项目按产品分组分页") + public CommonResult getProjectGroupPage(@Valid ProjectGroupPageReqVO reqVO) { + // 与 page / overview-summary 一致:全域读路径不挂权限注解,可见性由 Service 内 ObjectDataScope 数据权限承载 + return success(projectService.getProjectGroupPage(reqVO)); } @GetMapping("/list-by-product") diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupPageReqVO.java new file mode 100644 index 0000000..57c2236 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupPageReqVO.java @@ -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; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupPageRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupPageRespVO.java new file mode 100644 index 0000000..ca3a524 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupPageRespVO.java @@ -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 list; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupRespVO.java new file mode 100644 index 0000000..73cb452 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectGroupRespVO.java @@ -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 projects; + + @Schema(description = "项目类型计数,恒按「全部」口径(不含已取消/已归档)统计,不随状态筛选变化") + private Map typeCounts; + + @Schema(description = "是否存在非已取消的主线(基线)项目,口径与创建唯一性校验一致") + private Boolean hasBaseline; + + @Schema(description = "是否游离组") + private Boolean orphan; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java index 43e4208..2eafaf6 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java @@ -12,4 +12,7 @@ public class ProjectOverviewSummaryRespVO { @Schema(description = "项目状态数量,按当前启用的项目状态模型返回") private Map statusCounts; + @Schema(description = "游离项目数:未挂产品且状态属于「全部」口径(状态机启用状态,不含已取消/已归档)") + private Long orphanCount; + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectPageReqVO.java index 5baaf11..d1f2556 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectPageReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectPageReqVO.java @@ -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 statusCodes; + + @AssertTrue(message = "游离项目筛选与产品筛选不能同时使用") + @Schema(hidden = true) + public boolean isOrphanFilterValid() { + return !Boolean.TRUE.equals(orphanOnly) || productId == null; + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifySendEvent.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifySendEvent.java new file mode 100644 index 0000000..bf12a86 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifySendEvent.java @@ -0,0 +1,46 @@ +package com.njcn.rdms.module.project.framework.notify; + +import java.util.Collection; +import java.util.Map; + +/** + * 通用站内信发送事件(project 域统一入口)。 + * + *

业务方在动作完成后 {@code publishEvent(NotifySendEvent.of(...))}, + * 由 {@link NotifySendEventListener} 在事务提交后统一发送。 + * 是否包含操作人自己,由业务方组织 userIds 时决定(统一入口不排除操作人)。

+ * + * @author hongawen + */ +public class NotifySendEvent { + + /** 接收人用户编号(可含重复,监听器负责去重) */ + private final Collection userIds; + /** 模板场景码,见 {@link NotifyTemplateCodeConstants} */ + private final String templateCode; + /** 模板参数 */ + private final Map params; + + private NotifySendEvent(Collection userIds, String templateCode, Map params) { + this.userIds = userIds; + this.templateCode = templateCode; + this.params = params; + } + + public static NotifySendEvent of(Collection userIds, String templateCode, Map params) { + return new NotifySendEvent(userIds, templateCode, params); + } + + public Collection getUserIds() { + return userIds; + } + + public String getTemplateCode() { + return templateCode; + } + + public Map getParams() { + return params; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifySendEventListener.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifySendEventListener.java new file mode 100644 index 0000000..2be4948 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifySendEventListener.java @@ -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 域统一入口,全局唯一)。 + * + *

在业务事务提交后(AFTER_COMMIT)统一发送:接收人去重 + 空短路 + 逐人调用, + * 单条失败 try-catch + log.warn,不抛出、不中断其余,绝不影响/回滚业务。

+ * + * @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 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); + } + } + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifyTemplateCodeConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifyTemplateCodeConstants.java new file mode 100644 index 0000000..428b39a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/notify/NotifyTemplateCodeConstants.java @@ -0,0 +1,16 @@ +package com.njcn.rdms.module.project.framework.notify; + +/** + * 站内信模板场景码常量。 + * + *

每个 code 对应 {@code system_notify_template} 一条模板(发送前模板必须已配置, + * 否则能力层抛错、被 {@link NotifySendEventListener} 兜底为 log.warn)。

+ * + * @author hongawen + */ +public class NotifyTemplateCodeConstants { + + /** 任务指派:创建任务后通知负责人 + 协办人 */ + public static final String TASK_ASSIGNED = "task_assigned"; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java index a750fdd..986acf8 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java @@ -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 { } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java index 3f40869..24abac1 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java @@ -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 getProjectPage(ProjectPageReqVO pageReqVO); + PageResult getProjectPage(ProjectPageReqVO pageReqVO); + + /** + * 项目按产品分组分页:组维度翻页,组内带 topN 条项目。 + * 口径见 docs/superpowers/specs/2026-06-10-项目列表产品分组-design.md 第三节。 + */ + ProjectGroupPageRespVO getProjectGroupPage(ProjectGroupPageReqVO reqVO); ProjectOverviewSummaryRespVO getProjectOverviewSummary(); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java index 02f5aab..317a597 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java @@ -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 getProjectPage(ProjectPageReqVO pageReqVO) { + public PageResult 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 ids = scope.getIds(); - Set 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 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 statusCodes = resolveDefaultProjectStatusCodes(); + if (statusCodes.isEmpty()) { + return 0L; + } + LambdaQueryWrapperX 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 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 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 defaultStatusCodes = resolveDefaultProjectStatusCodes(); + Collection statusCodes = StringUtils.hasText(reqVO.getStatusCode()) + ? List.of(reqVO.getStatusCode()) + : defaultStatusCodes; + + // 1. 命中项目全量加载(产品几十级、项目百级,内存分组成本可忽略;项目上千后再改 SQL 聚合分页) + List matched = selectGroupMatchedProjects(reqVO, statusCodes, projectScope); + + // 2. 按产品分组,productId 为空归入游离组 + Map> groupedByProduct = new LinkedHashMap<>(); + for (ProjectDO project : matched) { + groupedByProduct.computeIfAbsent(project.getProductId(), k -> new ArrayList<>()).add(project); + } + List orphanProjects = groupedByProduct.remove(null); + if (orphanProjects == null) { + orphanProjects = Collections.emptyList(); + } + + // 3. 可见产品(启用/暂停,产品域数据权限):directionCount 口径 + 零项目占位组候选 + List 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 productMap = loadGroupProducts(groupedByProduct.keySet(), visibleProducts); + List 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 pageProductIds = orderedProductIds.subList(fromIndex, Math.min(toIndex, orderedProductIds.size())); + boolean orphanOnPage = hasOrphanGroup && toIndex == totalGroups; + + // 8. typeCounts(恒按"全部"口径)与 hasBaseline(非已取消主线):产品级属性,不随筛选与数据权限变化 + Map> typeCountsMap = new HashMap<>(); + Map orphanTypeCounts = new HashMap<>(); + if ((!pageProductIds.isEmpty() || orphanOnPage) && !defaultStatusCodes.isEmpty()) { + LambdaQueryWrapperX typeWrapper = new LambdaQueryWrapperX() + .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 counts = project.getProductId() == null + ? orphanTypeCounts + : typeCountsMap.computeIfAbsent(project.getProductId(), k -> new HashMap<>()); + counts.merge(project.getProjectType(), 1L, Long::sum); + } + } + Set baselineProductIds = pageProductIds.isEmpty() ? Collections.emptySet() + : projectMapper.selectList(new LambdaQueryWrapperX() + .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> pageGroupProjects = new LinkedHashMap<>(); + for (Long productId : pageProductIds) { + pageGroupProjects.put(productId, topNByUpdateTimeDesc(groupedByProduct.get(productId), topN)); + } + List orphanTopN = orphanOnPage ? topNByUpdateTimeDesc(orphanProjects, topN) : Collections.emptyList(); + List allPageProjects = new ArrayList<>(); + pageGroupProjects.values().forEach(allPageProjects::addAll); + allPageProjects.addAll(orphanTopN); + Map convertedById = new HashMap<>(); + convertProjectListWithNames(allPageProjects).forEach(vo -> convertedById.put(vo.getId(), vo)); + + // 10. 组装组 VO 并批量回填产品负责人昵称 + List 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 selectGroupMatchedProjects(ProjectGroupPageReqVO reqVO, Collection statusCodes, + ObjectDataScope projectScope) { + // 状态集为空(状态机未配置)时不查库,空 IN 会生成非法 SQL + if (projectScope.getState() == ObjectDataScope.State.EMPTY || statusCodes.isEmpty()) { + return Collections.emptyList(); + } + LambdaQueryWrapperX 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 selectVisibleGroupProducts(ObjectDataScope productScope) { + if (productScope.getState() == ObjectDataScope.State.EMPTY) { + return Collections.emptyList(); + } + Set liveStatusCodes = resolveLiveProductStatusCodes(); + if (liveStatusCodes.isEmpty()) { + return Collections.emptyList(); + } + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .in(ProductDO::getStatusCode, liveStatusCodes) + .orderByDesc(BaseDO::getCreateTime); + if (productScope.getState() == ObjectDataScope.State.ID_LIST) { + Set ids = productScope.getIds(); + Set 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 loadGroupProducts(Set groupProductIds, List visibleProducts) { + Map productMap = new HashMap<>(); + visibleProducts.forEach(p -> productMap.put(p.getId(), p)); + Set 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 sortGroupProductIds(Set groupProductIds, Map productMap) { + Comparator comparator = Comparator + // 方向编码升序(同方向的组相邻),产品记录缺失/方向为空的组殿后 + .comparing((Long id) -> { + ProductDO product = productMap.get(id); + return product == null || !StringUtils.hasText(product.getDirectionCode()) + ? null : product.getDirectionCode(); + }, Comparator.nullsLast(Comparator.naturalOrder())) + // 方向内按产品创建时间倒序(与产品列表默认排序一致) + .thenComparing((Long id) -> { + ProductDO product = productMap.get(id); + return product == null ? null : product.getCreateTime(); + }, Comparator.nullsLast(Comparator.reverseOrder())); + return groupProductIds.stream().sorted(comparator).collect(Collectors.toList()); + } + + private List topNByUpdateTimeDesc(List 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 groupProjects, + List topProjects, Map typeCounts, + boolean hasBaseline, Map 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 orphanProjects, List topProjects, + Map typeCounts, + Map 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 groupList) { + Set managerUserIds = groupList.stream() + .map(ProjectGroupRespVO::getManagerUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (managerUserIds.isEmpty()) { + return; + } + Map 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 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 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 wrapper, ObjectDataScope scope) { + if (scope.getState() != ObjectDataScope.State.ID_LIST) { + return; + } Set ids = scope.getIds(); Set 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 row = new HashMap<>(); - row.put("statusCode", e.getKey()); - row.put("countValue", e.getValue()); - return row; - }) - .collect(Collectors.toList()); + } + + /** + * DO 批量转 RespVO 并回填产品名 / 负责人昵称(一次批量查产品、一次批量查用户,禁止逐条)。 + * page 接口与分组接口共用,保证两接口项目行字段一致。 + */ + private List convertProjectListWithNames(List projects) { + if (projects == null || projects.isEmpty()) { + return new ArrayList<>(); + } + Set productIds = projects.stream() + .map(ProjectDO::getProductId).filter(Objects::nonNull).collect(Collectors.toSet()); + Map productMap = new HashMap<>(); + if (!productIds.isEmpty()) { + productMapper.selectBatchIds(productIds).forEach(p -> productMap.put(p.getId(), p)); + } + Set userIds = projects.stream() + .map(ProjectDO::getManagerUserId).filter(Objects::nonNull).collect(Collectors.toSet()); + Map 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) { diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java index b06378a..d36c253 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java @@ -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 recipients = new ArrayList<>(); + recipients.add(ownerId); + taskAssigneeMapper.selectActiveListByTaskId(task.getId()) + .forEach(assignee -> recipients.add(assignee.getUserId())); + Map 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) { diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/notify/NotifySendEventListenerTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/notify/NotifySendEventListenerTest.java new file mode 100644 index 0000000..2258b74 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/notify/NotifySendEventListenerTest.java @@ -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()); + } +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java index eef2de5..f249247 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java @@ -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 result = projectService.getProjectPage(new ProjectPageReqVO()); + PageResult 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 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 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 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 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 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 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 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 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 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; + } + } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java new file mode 100644 index 0000000..4d8ad97 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java @@ -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 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")); + } +} diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/NotifyMessageSendApi.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/NotifyMessageSendApi.java new file mode 100644 index 0000000..afa8bf9 --- /dev/null +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/NotifyMessageSendApi.java @@ -0,0 +1,45 @@ +package com.njcn.rdms.module.system.api.notify; + +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.module.system.api.notify.dto.NotifySingleSendReqDTO; +import com.njcn.rdms.module.system.enums.ApiConstants; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.Map; + +/** + * 站内信发送 API(跨模块统一入口契约)。 + * + *

业务方(如 project)经此发送站内信,不依赖 system-boot 内部发送实现。 + * userType 在能力层固定 ADMIN,不暴露给业务方。

+ * + * @author hongawen + */ +@FeignClient(name = ApiConstants.NAME) +@Tag(name = "RPC 服务 - 站内信发送") // 对 NotifySendService 的封装,供其它模块统一入口调用 +public interface NotifyMessageSendApi { + + String PREFIX = ApiConstants.PREFIX + "/notify-message-send"; + + @PostMapping(PREFIX + "/send-single") + @Operation(summary = "发送单条站内信") + CommonResult sendSingleNotify(@Valid @RequestBody NotifySingleSendReqDTO reqDTO); + + /** + * 便捷方法:发送单条站内信给管理后台用户(userType 固定 ADMIN,由实现层处理)。 + * + * @param userId 接收用户编号 + * @param templateCode 模板编码(场景标识,发送前模板必须已配置) + * @param templateParams 模板参数 + */ + default void sendSingleNotifyToAdmin(Long userId, String templateCode, Map templateParams) { + sendSingleNotify(new NotifySingleSendReqDTO().setUserId(userId) + .setTemplateCode(templateCode).setTemplateParams(templateParams)).checkError(); + } + +} diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifySingleSendReqDTO.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifySingleSendReqDTO.java new file mode 100644 index 0000000..e32af6f --- /dev/null +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifySingleSendReqDTO.java @@ -0,0 +1,31 @@ +package com.njcn.rdms.module.system.api.notify.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.Map; + +/** + * 发送单条站内信 Request DTO(跨模块统一入口入参)。 + * + * @author hongawen + */ +@Schema(description = "RPC 服务 - 发送单条站内信 Request DTO") +@Data +@Accessors(chain = true) +public class NotifySingleSendReqDTO { + + @Schema(description = "接收用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "接收用户编号不能为空") + private Long userId; + + @Schema(description = "站内信模板编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "task_assigned") + @NotNull(message = "模板编码不能为空") + private String templateCode; + + @Schema(description = "模板参数(占位符 -> 值)", example = "{\"taskName\":\"联调\"}") + private Map templateParams; + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/notify/NotifyMessageSendApiImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/notify/NotifyMessageSendApiImpl.java new file mode 100644 index 0000000..b2f45bc --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/notify/NotifyMessageSendApiImpl.java @@ -0,0 +1,33 @@ +package com.njcn.rdms.module.system.api.notify; + +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.module.system.api.notify.dto.NotifySingleSendReqDTO; +import com.njcn.rdms.module.system.service.notify.NotifySendService; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +/** + * {@link NotifyMessageSendApi} 的实现:委托现有 {@link NotifySendService},不重写发送逻辑。 + * + * @author hongawen + */ +@RestController // 提供 RESTful API 接口,给 Feign 调用 +@Validated +@Hidden +public class NotifyMessageSendApiImpl implements NotifyMessageSendApi { + + @Resource + private NotifySendService notifySendService; + + @Override + public CommonResult sendSingleNotify(NotifySingleSendReqDTO reqDTO) { + Long logId = notifySendService.sendSingleNotifyToAdmin( + reqDTO.getUserId(), reqDTO.getTemplateCode(), reqDTO.getTemplateParams()); + return success(logId); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notice/NoticeController.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notice/NoticeController.java index 2874a52..fca824c 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notice/NoticeController.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notice/NoticeController.java @@ -99,4 +99,13 @@ public class NoticeController { return success(true); } + @GetMapping("/recent") + @Operation(summary = "获取最近公告", description = "工作台首页用,登录即可访问,仅返回正常状态公告") + public CommonResult> getRecentNotices( + @RequestParam(name = "size", defaultValue = "3") Integer size, + @RequestParam(name = "type", required = false) Integer type) { + List list = noticeService.getRecentNotices(size, type); + return success(BeanUtils.toBean(list, NoticeRespVO.class)); + } + } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notify/vo/message/NotifyMessageMyPageReqVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notify/vo/message/NotifyMessageMyPageReqVO.java index acc5871..9cf8e72 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notify/vo/message/NotifyMessageMyPageReqVO.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notify/vo/message/NotifyMessageMyPageReqVO.java @@ -20,6 +20,9 @@ public class NotifyMessageMyPageReqVO extends PageParam { @Schema(description = "是否已读", example = "true") private Boolean readStatus; + @Schema(description = "关键字,对消息正文模糊匹配;不传或空串 = 不过滤", example = "指派") + private String keyword; + @Schema(description = "创建时间") @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) private LocalDateTime[] createTime; diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/notice/NoticeMapper.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/notice/NoticeMapper.java index 5464dd6..d03205a 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/notice/NoticeMapper.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/notice/NoticeMapper.java @@ -17,4 +17,17 @@ public interface NoticeMapper extends BaseMapperX { .orderByDesc(NoticeDO::getId)); } + /** + * 查询最近 N 条公告:仅 status 正常(CommonStatusEnum.ENABLE=0),可按 type 过滤,按 id 倒序取 size 条。 + * type 为 null 时不过滤类型;type 动态透传,不做代码侧白名单校验。 + */ + default java.util.List selectRecentList(Integer size, Integer type) { + int limit = (size == null || size <= 0) ? 3 : size; + return selectList(new LambdaQueryWrapperX() + .eq(NoticeDO::getStatus, com.njcn.rdms.framework.common.enums.CommonStatusEnum.ENABLE.getStatus()) + .eqIfPresent(NoticeDO::getType, type) + .orderByDesc(NoticeDO::getId) + .last("LIMIT " + limit)); + } + } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/notify/NotifyMessageMapper.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/notify/NotifyMessageMapper.java index 8fcfb90..06b407e 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/notify/NotifyMessageMapper.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/notify/NotifyMessageMapper.java @@ -29,9 +29,12 @@ public interface NotifyMessageMapper extends BaseMapperX { default PageResult selectPage(NotifyMessageMyPageReqVO reqVO, Long userId, Integer userType) { return selectPage(reqVO, new LambdaQueryWrapperX() .eqIfPresent(NotifyMessageDO::getReadStatus, reqVO.getReadStatus()) + // 关键字对最终渲染正文模糊匹配;空串/不传由 likeIfPresent 跳过,不影响存量调用 + .likeIfPresent(NotifyMessageDO::getTemplateContent, reqVO.getKeyword()) .betweenIfPresent(NotifyMessageDO::getCreateTime, reqVO.getCreateTime()) .eq(NotifyMessageDO::getUserId, userId) .eq(NotifyMessageDO::getUserType, userType) + // 雪花 id 按时间单调递增,id 倒序 = 收到时间倒序且排序唯一稳定(与前端分页口径约定一致,勿改为按 read_status/read_time 排序) .orderByDesc(NotifyMessageDO::getId)); } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notice/NoticeService.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notice/NoticeService.java index ce00924..df392c4 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notice/NoticeService.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notice/NoticeService.java @@ -57,4 +57,13 @@ public interface NoticeService { */ NoticeDO getNotice(Long id); + /** + * 获取最近 N 条公告(工作台首页用):仅正常状态,可按 type 过滤。 + * + * @param size 条数,默认 3 + * @param type 公告类型({@link com.njcn.rdms.module.system.enums.notice.NoticeTypeEnum}),null 表示不过滤 + * @return 公告列表 + */ + List getRecentNotices(Integer size, Integer type); + } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notice/NoticeServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notice/NoticeServiceImpl.java index 2e65b33..72af9ed 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notice/NoticeServiceImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notice/NoticeServiceImpl.java @@ -65,6 +65,12 @@ public class NoticeServiceImpl implements NoticeService { return noticeMapper.selectById(id); } + @Override + public List getRecentNotices(Integer size, Integer type) { + int limit = (size == null || size <= 0) ? 3 : size; + return noticeMapper.selectRecentList(limit, type); + } + @VisibleForTesting public void validateNoticeExists(Long id) { if (id == null) { diff --git a/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/dal/mysql/notify/NotifyMessageMapperTest.java b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/dal/mysql/notify/NotifyMessageMapperTest.java new file mode 100644 index 0000000..0a66be4 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/dal/mysql/notify/NotifyMessageMapperTest.java @@ -0,0 +1,80 @@ +package com.njcn.rdms.module.system.dal.mysql.notify; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.system.controller.admin.notify.vo.message.NotifyMessageMyPageReqVO; +import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyMessageDO; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * {@link NotifyMessageMapper} 的单元测试 —— 我的站内信分页(keyword 检索 + 排序口径)。 + */ +class NotifyMessageMapperTest { + + @BeforeAll + static void initMyBatisPlusTableInfo() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), NotifyMessageDO.class); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private String captureMyPageSqlSegment(NotifyMessageMyPageReqVO reqVO) { + NotifyMessageMapper mapper = mock(NotifyMessageMapper.class, invocation -> invocation.callRealMethod()); + doReturn(PageResult.empty()).when(mapper).selectPage(eq(reqVO), any(Wrapper.class)); + + mapper.selectPage(reqVO, 1L, 2); + + ArgumentCaptor> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class); + verify(mapper).selectPage(eq(reqVO), wrapperCaptor.capture()); + return wrapperCaptor.getValue().getSqlSegment().toLowerCase(Locale.ROOT); + } + + @Test + void myPage_keywordShouldFilterTemplateContentByLike() { + NotifyMessageMyPageReqVO reqVO = new NotifyMessageMyPageReqVO(); + reqVO.setKeyword("指派"); + reqVO.setReadStatus(false); + + String sqlSegment = captureMyPageSqlSegment(reqVO); + + assertTrue(sqlSegment.contains("template_content like")); + assertTrue(sqlSegment.contains("read_status")); + assertTrue(sqlSegment.contains("user_id")); + assertTrue(sqlSegment.contains("user_type")); + } + + @Test + void myPage_blankKeywordShouldNotAddLikeCondition() { + NotifyMessageMyPageReqVO noKeyword = new NotifyMessageMyPageReqVO(); + assertFalse(captureMyPageSqlSegment(noKeyword).contains("like")); + + NotifyMessageMyPageReqVO blankKeyword = new NotifyMessageMyPageReqVO(); + blankKeyword.setKeyword(""); + assertFalse(captureMyPageSqlSegment(blankKeyword).contains("like")); + } + + @Test + void myPage_shouldOrderByIdDescOnly() { + String sqlSegment = captureMyPageSqlSegment(new NotifyMessageMyPageReqVO()); + + // 与前端约定的分页口径:id 倒序(= 收到时间倒序、唯一稳定),不随读状态重排 + assertTrue(sqlSegment.contains("order by id desc")); + assertFalse(sqlSegment.contains("read_status desc")); + assertFalse(sqlSegment.contains("read_time")); + } + +} diff --git a/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/notice/NoticeServiceImplTest.java b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/notice/NoticeServiceImplTest.java new file mode 100644 index 0000000..069b5b3 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/notice/NoticeServiceImplTest.java @@ -0,0 +1,38 @@ +package com.njcn.rdms.module.system.service.notice; + +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.system.dal.dataobject.notice.NoticeDO; +import com.njcn.rdms.module.system.dal.mysql.notice.NoticeMapper; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * {@link NoticeServiceImpl} 的单元测试 —— 工作台最近公告查询。 + */ +class NoticeServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private NoticeServiceImpl noticeService; + + @Mock + private NoticeMapper noticeMapper; + + @Test + void testGetRecentNotices_delegatesToMapper() { + NoticeDO notice = new NoticeDO(); + notice.setId(100L); + when(noticeMapper.selectRecentList(3, 2)).thenReturn(singletonList(notice)); + + List result = noticeService.getRecentNotices(3, 2); + + assertEquals(1, result.size()); + assertEquals(100L, result.get(0).getId()); + } +} diff --git a/项目列表产品分组-后端接口诉求-2026-06-10.html b/项目列表产品分组-后端接口诉求-2026-06-10.html new file mode 100644 index 0000000..7a6e3e8 --- /dev/null +++ b/项目列表产品分组-后端接口诉求-2026-06-10.html @@ -0,0 +1,266 @@ + + + + + +项目列表按产品分组:后端待确认事项与接口诉求 + + + +
+ +
+

项目列表按产品分组:后端待确认事项与接口诉求

+
2026-06-10 · 前端发起 · 涉及模块:project/project(项目)、system/dict(字典)
+
+ +
+

0. 背景

+
+

项目列表页将从"平铺分页"改为"按所属产品聚合展示":同一产品下的项目(基线/合同/技术支持等类型)需要聚拢在一起查看,不能被分页切散。现有 GET /project/project/page 平铺分页无法支撑该口径,需要后端补充以下能力。

+

前端已按本文档第 2~4 节的契约先行开发(过渡期用现有平铺接口在前端聚合模拟),后端接口就绪后联调切换,不阻塞后端排期,但请反馈预计时间点。

+
+
+ +
+

1. 待查询确认:rdms_project_type 字典数据 请尽快回复,阻塞前端一处常量

+
+

请提供字典 rdms_project_type(项目类型)的完整字典数据清单(每项的 value / label / 启用状态),特别是:

+
    +
  • "基线"类型对应的字典 value 是什么(前端需写入常量,用于"建立基线"入口预填与缺基线判定);
  • +
  • 除 基线 / 合同 / 技术支持 外是否还有其他在用类型值。
  • +
+

补充确认(口头已确认,烦请最终对齐):"基线项目每产品唯一"由后端在创建/更新项目时做唯一性校验兜底,前端仅做体验层提示。

+
+
+ +
+

2. 接口诉求一:项目按产品分组分页查询(新增) 核心依赖

+ +

2.1 建议路径

+
+

GET /project/project/group-page(路径可由后端按规范调整,前端只在 service 层适配一处)

+

数据权限口径与现有 /project/project/page 一致:只返回当前用户可见的项目,按其归属产品聚合。

+
+ +

2.2 入参

+ + + + + + + + + +
参数类型必填说明
pageNo / pageSizeint产品组维度分页:一页返回 pageSize 个产品组(含组内项目),不是项目行分页。前端当前 pageSize=10。
keywordstring项目编码 / 名称模糊匹配(口径同现有 page 接口)。
productIdstring限定单个产品。
projectTypestring项目类型字典 value。
statusCodestring单状态过滤。不传 = "全部"口径:pending / active / paused / completed 四种状态,不含 cancelled / archived。
orphanOnlybooleantrue = 仅返回"游离组"(productId 为空的项目聚合为一个特殊组)。
topNint每组返回的项目条数上限,建议默认 5(前端当前固定取 5)。
+ +

2.3 返回结构(示例)

+
{
+  "total": 12,            // 当前筛选口径下产品组总数(分页 total,含游离组)
+  "projectTotal": 47,     // 当前筛选口径下项目总数
+  "directionCount": 3,    // 可见产品去重后的方向(directionCode)数
+  "orphanTotal": 3,       // 游离项目数(productId 为空)
+  "list": [
+    {
+      "productId": "1923456789012345678",   // 字符串!见 4.1
+      "productName": "智能网关",
+      "productCode": "RDMS-P-001",
+      "directionCode": "platform",
+      "managerUserId": "1001",
+      "managerUserNickname": "张三",
+      "projectTotal": 6,                    // 当前筛选口径下该组项目总数
+      "projects": [ /* 前 topN 条,字段同现有 page 接口的项目行 */ ],
+      "typeCounts": { "baseline值": 1, "合同值": 3, "技术支持值": 2 },
+      "hasBaseline": true,
+      "orphan": false
+    },
+    {
+      "productId": null,                    // 游离组:未挂产品的项目聚合
+      "productName": "游离项目",
+      "productCode": null,
+      "directionCode": "",
+      "managerUserId": null,
+      "managerUserNickname": null,
+      "projectTotal": 3,
+      "projects": [ /* ... */ ],
+      "typeCounts": { "合同值": 2, "技术支持值": 1 },
+      "hasBaseline": false,
+      "orphan": true
+    }
+  ]
+}
+ +

2.4 口径约定(关键,请逐条确认)

+ + + + + + + + +
#约定说明
1组内项目排序updateTime 倒序,返回前 topN 条;projectTotal 为该口径下组内全量计数。
2无命中组不返回传了 statusCode / keyword / projectType 任一筛选时,组内无命中项目的产品组不返回。
3零项目产品组"全部"口径(statusCode 缺省)且无 keyword / projectType 筛选时,返回当前用户可见、状态为 active / paused 的零项目产品组(projectTotal=0、projects=[]),用于"该产品暂无项目"占位与新增引导。
4typeCounts / hasBaseline 统计口径这两个字段是"产品属性",恒按"全部"口径(四种状态)统计,不随 statusCode 入参变化——避免"基线项目已完成时被误判为缺基线"。
5游离组位置游离组(productId 为空)作为列表中最后一个组返回;状态筛选下同样适用约定 2(无命中则不返回该组)。
6组的排序同方向(directionCode)的产品组相邻返回(方向间顺序、方向内产品顺序由后端按现有产品列表默认排序即可)。
+ +

2.5 组内"展开剩余"的数据来源

+
+

不需要新接口:前端用现有 GET /project/project/pageproductId + statusCode 拉取该组剩余项目。前提是该接口的排序与本接口组内排序一致(updateTime 倒序)——若现有 page 接口默认排序不是 updateTime 倒序,请告知或支持排序参数。

+
+
+ +
+

3. 接口诉求二、三:存量接口小改 两处补充

+ +

3.1 平铺分页支持"未挂产品"筛选

+
+

GET /project/project/page 需要能表达 productId 为空的筛选语义(如新增 orphanOnly=true 参数,或约定 productId 传特殊值),用于查询"游离项目"(未关联任何产品的项目)。具体参数形式由后端定,前端适配。

+
+ +

3.2 概览统计补游离计数

+
+

GET /project/project/overview-summary 返回体增加:

+
{
+  "statusCounts": { "active": 10, "pending": 2, ... },  // 现有
+  "orphanCount": 3                                       // 新增:未挂产品的项目数(按"全部"口径:四种状态)
+}
+
+
+ +
+

4. 公共约定

+ + + + + +
#约定说明
4.1ID 一律字符串 必须所有 Long / 雪花 ID(productId、managerUserId、项目 id 等)在 JSON 中按字符串返回。Long 直接作为 JSON 数字返回会在前端丢精度。
4.2"全部"状态集合pending / active / paused / completed;cancelled / archived 仅在显式传对应 statusCode 时返回。
4.3项目行字段分组接口 projects[] 内的项目对象字段与现有 /project/project/page 返回行保持一致(含 productId / productName / managerUserNickname / progressRate / statusCode / updateTime 等),避免前端双口径。
+
+ +
+

5. 需要后端反馈的清单

+
+
    +
  1. 第 1 节rdms_project_type 字典数据清单 + "基线"的 value(最优先,一条查询即可,阻塞前端一处常量)。
  2. +
  3. 第 2 节:分组分页接口的可行性确认 + 2.4 六条口径逐条确认(有异议请直接批注替代方案)+ 排期。
  4. +
  5. 第 3 节:page 接口游离筛选的参数形式 + overview-summary 补 orphanCount 的排期。
  6. +
  7. 第 2.5 节:现有 page 接口的默认排序是什么;是否支持/计划支持按 updateTime 倒序。
  8. +
+
+
+ +
+ +