From 896ef0f1279a7c09cc7c176ab2d2c68a2ffc4f41 Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Sat, 13 Jun 2026 15:00:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(project):=20=E5=AE=9E=E7=8E=B0=E4=B8=B4?= =?UTF-8?q?=E6=9C=9F=E9=80=BE=E6=9C=9F=E5=91=8A=E8=AD=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增告警记录表 rdms_due_alert_record 用于去重控制 - 添加告警相关常量类 DueAlertConstants 和对象类型枚举 - 在各数据访问层增加告警候选查询方法 - 实现告警候选服务类和站内信等级功能 - 添加临期逾期告警模板常量定义 - 扩展站内信发送接口支持消息等级 - 新增未读消息批量查询功能用于重复发送判定 --- .../enums/ProjectDictTypeConstants.java | 6 + .../project/constant/DueAlertConstants.java | 32 ++ .../dataobject/duealert/DueAlertRecordDO.java | 40 ++ .../mysql/duealert/DueAlertRecordMapper.java | 38 ++ .../mysql/personal/PersonalItemMapper.java | 14 + .../product/ProductRequirementMapper.java | 16 + .../dal/mysql/project/ProjectMapper.java | 15 + .../project/ProjectRequirementMapper.java | 16 + .../execution/ExecutionAssigneeMapper.java | 8 + .../execution/ProjectExecutionMapper.java | 14 + .../mysql/project/task/ProjectTaskMapper.java | 14 + .../project/enums/DueAlertObjectTypeEnum.java | 51 +++ .../framework/notify/NotifySendEvent.java | 21 +- .../notify/NotifySendEventListener.java | 3 +- .../notify/NotifyTemplateCodeConstants.java | 12 + .../config/ScheduleConfiguration.java | 15 + .../module/project/job/DueAlertScanJob.java | 34 ++ .../service/duealert/DueAlertCandidate.java | 31 ++ .../service/duealert/DueAlertScanService.java | 14 + .../duealert/DueAlertScanServiceImpl.java | 393 ++++++++++++++++++ .../service/duealert/DueAlertSendService.java | 23 + .../duealert/DueAlertSendServiceImpl.java | 80 ++++ .../main/resources/sql/due_alert_record.sql | 19 + .../notify/NotifySendEventListenerTest.java | 11 +- .../duealert/DueAlertScanServiceImplTest.java | 331 +++++++++++++++ .../duealert/DueAlertSendServiceImplTest.java | 132 ++++++ .../api/notify/NotifyMessageSendApi.java | 25 ++ .../notify/dto/NotifySingleSendReqDTO.java | 4 + .../dto/NotifyUnreadMessageListReqDTO.java | 32 ++ .../dto/NotifyUnreadMessageRespDTO.java | 30 ++ .../notify/NotifyMessageLevelConstants.java | 26 ++ .../api/notify/NotifyMessageSendApiImpl.java | 23 +- .../vo/message/NotifyMessageRespVO.java | 3 + .../dataobject/notify/NotifyMessageDO.java | 6 + .../dal/mysql/notify/NotifyMessageMapper.java | 11 + .../service/notify/NotifyMessageService.java | 15 +- .../notify/NotifyMessageServiceImpl.java | 17 +- .../service/notify/NotifySendService.java | 24 +- .../service/notify/NotifySendServiceImpl.java | 5 +- .../resources/sql/notify_message_level.sql | 10 + .../notify/NotifyMessageServiceImplTest.java | 54 +++ 41 files changed, 1650 insertions(+), 18 deletions(-) create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/DueAlertConstants.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/duealert/DueAlertRecordDO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/duealert/DueAlertRecordMapper.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/enums/DueAlertObjectTypeEnum.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/schedule/config/ScheduleConfiguration.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/job/DueAlertScanJob.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertCandidate.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanService.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanServiceImpl.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendService.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendServiceImpl.java create mode 100644 rdms-project/rdms-project-boot/src/main/resources/sql/due_alert_record.sql create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanServiceImplTest.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendServiceImplTest.java create mode 100644 rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifyUnreadMessageListReqDTO.java create mode 100644 rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifyUnreadMessageRespDTO.java create mode 100644 rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/notify/NotifyMessageLevelConstants.java create mode 100644 rdms-system/rdms-system-boot/src/main/resources/sql/notify_message_level.sql create mode 100644 rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/notify/NotifyMessageServiceImplTest.java diff --git a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java index 97ef582..ca158df 100644 --- a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java +++ b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java @@ -25,4 +25,10 @@ public interface ProjectDictTypeConstants { */ String WORKLOG_DIFFICULTY = "rdms_worklog_difficulty"; + /** + * 临期告警提前量(天)。value=告警域对象类型 code,label=天数(受 RPC DictDataRespDTO 仅 label/value 限制)。 + * 缺档或 label 非数字时该对象类型只停临期告警、逾期照发,代码不写默认天数兜底。 + */ + String DUE_ALERT_ADVANCE_DAYS = "rdms_due_alert_advance_days"; + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/DueAlertConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/DueAlertConstants.java new file mode 100644 index 0000000..0f85c54 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/DueAlertConstants.java @@ -0,0 +1,32 @@ +package com.njcn.rdms.module.project.constant; + +/** + * 临期/逾期告警常量。 + * + *

设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。

+ */ +public final class DueAlertConstants { + + private DueAlertConstants() { + } + + /** 告警档:临期(进入提前量窗口,同一计划日期快照只发一次) */ + public static final String ALERT_TYPE_APPROACHING = "approaching"; + + /** 告警档:逾期(每天发一条,按 alert_date 当日去重) */ + public static final String ALERT_TYPE_OVERDUE = "overdue"; + + /** + * 暂停状态排除锚点:状态为「已暂停」的对象临期/逾期都不告警(用户主动静音)。 + * 最小排除锚点,不写正向状态清单;终态仍走状态机表动态查。 + */ + public static final String STATUS_PAUSED = "paused"; + + /** + * 附加进消息模板参数的对象标识键(不参与正文渲染,仅供"未读不重发"判定按对象匹配)。 + * objectId 以字符串存(JSON 数字反序列化类型不稳,统一字符串比较)。 + */ + public static final String PARAM_OBJECT_TYPE = "objectType"; + public static final String PARAM_OBJECT_ID = "objectId"; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/duealert/DueAlertRecordDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/duealert/DueAlertRecordDO.java new file mode 100644 index 0000000..e10fbf1 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/duealert/DueAlertRecordDO.java @@ -0,0 +1,40 @@ +package com.njcn.rdms.module.project.dal.dataobject.duealert; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; + +/** + * 临期/逾期告警发送记录 DO(去重凭证,只增不删)。 + * + *

唯一索引 uk_due_alert(object_type, object_id, alert_type, planned_end_date, alert_date) + * 兜底并发/多实例同日双插。设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。

+ */ +@TableName("rdms_due_alert_record") +@Data +@EqualsAndHashCode(callSuper = true) +public class DueAlertRecordDO extends BaseDO { + + @TableId + private Long id; + + /** 告警域对象类型,见 DueAlertObjectTypeEnum(个人事项=personal_item,区别于状态机 task) */ + private String objectType; + + /** 对象主键 */ + private Long objectId; + + /** 告警档:approaching=临期 / overdue=逾期,见 DueAlertConstants */ + private String alertType; + + /** 判定时计划完成日快照(临期档去重键,改期后快照不匹配=重新告警) */ + private LocalDate plannedEndDate; + + /** 告警发送日(逾期档每天一条的去重键) */ + private LocalDate alertDate; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/duealert/DueAlertRecordMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/duealert/DueAlertRecordMapper.java new file mode 100644 index 0000000..70919db --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/duealert/DueAlertRecordMapper.java @@ -0,0 +1,38 @@ +package com.njcn.rdms.module.project.dal.mysql.duealert; + +import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX; +import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.njcn.rdms.module.project.constant.DueAlertConstants; +import com.njcn.rdms.module.project.dal.dataobject.duealert.DueAlertRecordDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; + +/** + * 告警发送记录 Mapper。调用方保证 objectIds 非空(空集合不要调用,IN 空集会报错)。 + */ +@Mapper +public interface DueAlertRecordMapper extends BaseMapperX { + + /** 查某类对象一批 id 的临期档历史记录(不限日期),调用方按计划日快照内存判重 */ + default List selectApproachingListByObjectIds(String objectType, Collection objectIds) { + return selectList(new LambdaQueryWrapperX() + .eq(DueAlertRecordDO::getObjectType, objectType) + .eq(DueAlertRecordDO::getAlertType, DueAlertConstants.ALERT_TYPE_APPROACHING) + .in(DueAlertRecordDO::getObjectId, objectIds)); + } + + /** 查某类对象一批 id 当日已发的逾期档记录(同日防重;昨天的记录自然不命中=每天可再发) */ + default List selectOverdueListByObjectIdsAndAlertDate(String objectType, + Collection objectIds, + LocalDate alertDate) { + return selectList(new LambdaQueryWrapperX() + .eq(DueAlertRecordDO::getObjectType, objectType) + .eq(DueAlertRecordDO::getAlertType, DueAlertConstants.ALERT_TYPE_OVERDUE) + .eq(DueAlertRecordDO::getAlertDate, alertDate) + .in(DueAlertRecordDO::getObjectId, objectIds)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/personal/PersonalItemMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/personal/PersonalItemMapper.java index d0a1bae..ee76274 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/personal/PersonalItemMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/personal/PersonalItemMapper.java @@ -114,4 +114,18 @@ public interface PersonalItemMapper extends BaseMapperX { @Param("terminalStatusCodes") Collection terminalStatusCodes, @Param("today") LocalDate today, @Param("dueSoonEnd") LocalDate dueSoonEnd); + + /** + * 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。 + * excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。 + * 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。 + */ + default List selectDueAlertCandidateList(LocalDate maxPlannedEndDate, + Collection excludeStatusCodes) { + return selectList(new LambdaQueryWrapperX() + .isNotNull(PersonalItemDO::getPlannedEndDate) + .le(PersonalItemDO::getPlannedEndDate, maxPlannedEndDate) + .notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(), + PersonalItemDO::getStatusCode, excludeStatusCodes)); + } } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductRequirementMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductRequirementMapper.java index 929a782..ed73660 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductRequirementMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductRequirementMapper.java @@ -8,6 +8,8 @@ import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO; import org.apache.ibatis.annotations.Mapper; import org.springframework.util.StringUtils; +import java.time.LocalDate; +import java.util.Collection; import java.util.List; /** @@ -135,4 +137,18 @@ public interface ProductRequirementMapper extends BaseMapperX selectDueAlertCandidateList(LocalDate maxPlannedEndDate, + Collection excludeStatusCodes) { + return selectList(new LambdaQueryWrapperX() + .isNotNull(ProductRequirementDO::getExpectedTime) + .le(ProductRequirementDO::getExpectedTime, maxPlannedEndDate) + .notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(), + ProductRequirementDO::getStatusCode, excludeStatusCodes)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java index 8d8c0f5..4c5bba6 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java @@ -7,6 +7,7 @@ import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Select; +import java.time.LocalDate; import java.util.Collection; import java.util.List; import java.util.Map; @@ -89,4 +90,18 @@ public interface ProjectMapper extends BaseMapperX { .eq(ProjectDO::getId, id)); } + /** + * 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。 + * excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。 + * 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。 + */ + default List selectDueAlertCandidateList(LocalDate maxPlannedEndDate, + Collection excludeStatusCodes) { + return selectList(new LambdaQueryWrapperX() + .isNotNull(ProjectDO::getPlannedEndDate) + .le(ProjectDO::getPlannedEndDate, maxPlannedEndDate) + .notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(), + ProjectDO::getStatusCode, excludeStatusCodes)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectRequirementMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectRequirementMapper.java index 28d3cfb..36afd49 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectRequirementMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectRequirementMapper.java @@ -8,6 +8,8 @@ import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO; import org.apache.ibatis.annotations.Mapper; import org.springframework.util.StringUtils; +import java.time.LocalDate; +import java.util.Collection; import java.util.List; /** @@ -105,4 +107,18 @@ public interface ProjectRequirementMapper extends BaseMapperX selectDueAlertCandidateList(LocalDate maxPlannedEndDate, + Collection excludeStatusCodes) { + return selectList(new LambdaQueryWrapperX() + .isNotNull(ProjectRequirementDO::getExpectedTime) + .le(ProjectRequirementDO::getExpectedTime, maxPlannedEndDate) + .notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(), + ProjectRequirementDO::getStatusCode, excludeStatusCodes)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionAssigneeMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionAssigneeMapper.java index 1d34d8e..a53576e 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionAssigneeMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionAssigneeMapper.java @@ -5,6 +5,7 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO; import org.apache.ibatis.annotations.Mapper; +import java.util.Collection; import java.util.List; @Mapper @@ -59,4 +60,11 @@ public interface ExecutionAssigneeMapper extends BaseMapperX selectActiveListByExecutionIds(Collection executionIds) { + return selectList(new LambdaQueryWrapperX() + .in(ExecutionAssigneeDO::getExecutionId, executionIds) + .isNull(ExecutionAssigneeDO::getRemovedAt)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java index a9d80ce..d851d59 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java @@ -354,4 +354,18 @@ public interface ProjectExecutionMapper extends BaseMapperX return selectList(queryWrapper); } + /** + * 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。 + * excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。 + * 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。 + */ + default List selectDueAlertCandidateList(LocalDate maxPlannedEndDate, + Collection excludeStatusCodes) { + return selectList(new LambdaQueryWrapperX() + .isNotNull(ProjectExecutionDO::getPlannedEndDate) + .le(ProjectExecutionDO::getPlannedEndDate, maxPlannedEndDate) + .notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(), + ProjectExecutionDO::getStatusCode, excludeStatusCodes)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java index ac09eb3..39d968d 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java @@ -962,4 +962,18 @@ public interface ProjectTaskMapper extends BaseMapperX { @Param("today") LocalDate today, @Param("dueSoonEnd") LocalDate dueSoonEnd); + /** + * 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。 + * excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。 + * 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。 + */ + default List selectDueAlertCandidateList(LocalDate maxPlannedEndDate, + Collection excludeStatusCodes) { + return selectList(new LambdaQueryWrapperX() + .isNotNull(ProjectTaskDO::getPlannedEndDate) + .le(ProjectTaskDO::getPlannedEndDate, maxPlannedEndDate) + .notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(), + ProjectTaskDO::getStatusCode, excludeStatusCodes)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/enums/DueAlertObjectTypeEnum.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/enums/DueAlertObjectTypeEnum.java new file mode 100644 index 0000000..20cdb1f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/enums/DueAlertObjectTypeEnum.java @@ -0,0 +1,51 @@ +package com.njcn.rdms.module.project.enums; + +import com.njcn.rdms.module.project.constant.PersonalItemConstants; +import com.njcn.rdms.module.project.constant.ProjectExecutionConstants; +import com.njcn.rdms.module.project.constant.ProjectObjectConstants; +import com.njcn.rdms.module.project.constant.ProjectRequirementConstants; +import com.njcn.rdms.module.project.constant.ProjectTaskConstants; + +/** + * 临期/逾期告警的对象类型(告警域六档,代码写死的扫描清单)。 + * + *

注意与状态机 object_type 区分:个人事项告警域是 personal_item、 + * 查终态时映射到状态机的 task({@link PersonalItemConstants#STATUS_OBJECT_TYPE})。 + * 告警记录表与提前量字典都用告警域 code,避免个人事项与任务互相污染去重记录。

+ */ +public enum DueAlertObjectTypeEnum { + + PROJECT("project", "项目", ProjectObjectConstants.OBJECT_TYPE), + PROJECT_REQUIREMENT("project_requirement", "项目需求", ProjectRequirementConstants.OBJECT_TYPE), + /** 产品需求状态机 object_type 仓库无公开常量(ProductRequirementServiceImpl 内为 private),用字面值 */ + PRODUCT_REQUIREMENT("product_requirement", "产品需求", "product_requirement"), + EXECUTION("execution", "执行", ProjectExecutionConstants.OBJECT_TYPE), + TASK("task", "任务", ProjectTaskConstants.OBJECT_TYPE), + PERSONAL_ITEM("personal_item", "个人事项", PersonalItemConstants.STATUS_OBJECT_TYPE); + + /** 告警域 code(告警记录表 object_type 列、提前量字典 value) */ + private final String code; + /** 中文展示名(通知模板 objectTypeName 参数) */ + private final String displayName; + /** 查终态用的状态机 object_type */ + private final String statusObjectType; + + DueAlertObjectTypeEnum(String code, String displayName, String statusObjectType) { + this.code = code; + this.displayName = displayName; + this.statusObjectType = statusObjectType; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public String getStatusObjectType() { + return statusObjectType; + } + +} 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 index bf12a86..a0988f4 100644 --- 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 @@ -1,5 +1,7 @@ package com.njcn.rdms.module.project.framework.notify; +import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; + import java.util.Collection; import java.util.Map; @@ -20,15 +22,26 @@ public class NotifySendEvent { private final String templateCode; /** 模板参数 */ private final Map params; + /** 消息等级,见 {@link NotifyMessageLevelConstants}(默认普通) */ + private final Integer level; - private NotifySendEvent(Collection userIds, String templateCode, Map params) { + private NotifySendEvent(Collection userIds, String templateCode, + Map params, Integer level) { this.userIds = userIds; this.templateCode = templateCode; this.params = params; + this.level = level; } + /** 普通等级(兼容存量调用) */ public static NotifySendEvent of(Collection userIds, String templateCode, Map params) { - return new NotifySendEvent(userIds, templateCode, params); + return new NotifySendEvent(userIds, templateCode, params, NotifyMessageLevelConstants.NORMAL); + } + + /** 指定等级 */ + public static NotifySendEvent of(Collection userIds, String templateCode, + Map params, Integer level) { + return new NotifySendEvent(userIds, templateCode, params, level); } public Collection getUserIds() { @@ -43,4 +56,8 @@ public class NotifySendEvent { return params; } + public Integer getLevel() { + return level; + } + } 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 index 2be4948..b49063f 100644 --- 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 @@ -43,7 +43,8 @@ public class NotifySendEventListener { } for (Long userId : targets) { try { - notifyMessageSendApi.sendSingleNotifyToAdmin(userId, event.getTemplateCode(), event.getParams()); + notifyMessageSendApi.sendSingleNotifyToAdmin(userId, event.getTemplateCode(), + event.getParams(), event.getLevel()); } catch (Exception ex) { // 通知失败不影响业务:仅告警,继续发其余接收人 log.warn("[onNotifySend] 站内信发送失败 userId={}, templateCode={}", 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 index 428b39a..bde7bbe 100644 --- 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 @@ -13,4 +13,16 @@ public class NotifyTemplateCodeConstants { /** 任务指派:创建任务后通知负责人 + 协办人 */ public static final String TASK_ASSIGNED = "task_assigned"; + /** 临期提醒-负责人:对象进入临期窗口时通知负责人(项目负责人/owner/需求处理人→提出人) */ + public static final String DUE_ALERT_APPROACHING_OWNER = "due_alert_approaching_owner"; + + /** 临期提醒-协办人:等级低一等的弱化文案(仅任务/执行有协办人) */ + public static final String DUE_ALERT_APPROACHING_ASSIGNEE = "due_alert_approaching_assignee"; + + /** 逾期提醒-负责人:每天一条持续提醒,直到终态或暂停 */ + public static final String DUE_ALERT_OVERDUE_OWNER = "due_alert_overdue_owner"; + + /** 逾期提醒-协办人:等级低一等的弱化文案 */ + public static final String DUE_ALERT_OVERDUE_ASSIGNEE = "due_alert_overdue_assignee"; + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/schedule/config/ScheduleConfiguration.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/schedule/config/ScheduleConfiguration.java new file mode 100644 index 0000000..b9fad6f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/schedule/config/ScheduleConfiguration.java @@ -0,0 +1,15 @@ +package com.njcn.rdms.module.project.framework.schedule.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Project 模块定时任务启用配置。 + * + *

业务侧首个 @Scheduled 任务(DueAlertScanJob)需要调度开关; + * 不依赖 mq starter 消费端配置里的 @EnableScheduling(那是框架内部 job 的,且按条件装配)。

+ */ +@Configuration(value = "projectScheduleConfiguration", proxyBeanMethods = false) +@EnableScheduling +public class ScheduleConfiguration { +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/job/DueAlertScanJob.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/job/DueAlertScanJob.java new file mode 100644 index 0000000..85f70dd --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/job/DueAlertScanJob.java @@ -0,0 +1,34 @@ +package com.njcn.rdms.module.project.job; + +import com.njcn.rdms.module.project.service.duealert.DueAlertScanService; +import com.njcn.rdms.module.project.util.DueRangeSupport; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +/** + * 临期/逾期告警每日扫描任务(业务侧首个定时任务)。 + * + *

默认每日 05:00(用户定稿),可经配置项 rdms.due-alert.scan-cron 覆盖,不改环境配置文件。 + * 逾期每天一条 / 临期窗口一次的去重在 service 层,重复触发幂等。 + * 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。

+ */ +@Component +@Slf4j +public class DueAlertScanJob { + + @Resource + private DueAlertScanService dueAlertScanService; + + @Scheduled(cron = "${rdms.due-alert.scan-cron:0 0 5 * * ?}") + public void scan() { + LocalDate today = DueRangeSupport.today(); + log.info("[scan][临期/逾期告警扫描开始 today({})]", today); + dueAlertScanService.scan(today); + log.info("[scan][临期/逾期告警扫描结束 today({})]", today); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertCandidate.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertCandidate.java new file mode 100644 index 0000000..bc0bdf3 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertCandidate.java @@ -0,0 +1,31 @@ +package com.njcn.rdms.module.project.service.duealert; + +import com.njcn.rdms.module.project.enums.DueAlertObjectTypeEnum; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +/** + * 一条待告警对象(扫描层组装、发送层消费的内部载体,不对外暴露)。 + */ +@Data +public class DueAlertCandidate { + + private DueAlertObjectTypeEnum objectType; + + private Long objectId; + + /** 对象名称/标题(通知模板 objectName 参数) */ + private String objectName; + + /** 计划完成日(需求为预期完成时间 expectedTime) */ + private LocalDate plannedEndDate; + + /** 负责人组:项目负责人 / owner / 需求处理人→提出人(收负责人版模板) */ + private List ownerUserIds; + + /** 协办人组:仅任务/执行,已剔除与负责人组重叠者(收协办人版弱化模板) */ + private List assigneeUserIds; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanService.java new file mode 100644 index 0000000..5e7915c --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanService.java @@ -0,0 +1,14 @@ +package com.njcn.rdms.module.project.service.duealert; + +import java.time.LocalDate; + +/** + * 临期/逾期告警扫描编排:拉提前量字典 → 六类对象逐一查候选 → 判重 → 组装接收人 → 调发送服务。 + * 无事务(事务在 DueAlertSendService.sendAlert 单条粒度上)。 + */ +public interface DueAlertScanService { + + /** 执行一轮扫描。today 由调用方传入(Job 传 DueRangeSupport.today()),便于单测固定日期。 */ + void scan(LocalDate today); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanServiceImpl.java new file mode 100644 index 0000000..451f1fb --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanServiceImpl.java @@ -0,0 +1,393 @@ +package com.njcn.rdms.module.project.service.duealert; + +import com.njcn.rdms.framework.common.biz.system.dict.dto.DictDataRespDTO; +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.module.project.constant.DueAlertConstants; +import com.njcn.rdms.module.project.dal.dataobject.duealert.DueAlertRecordDO; +import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO; +import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO; +import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO; +import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; +import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO; +import com.njcn.rdms.module.project.dal.mysql.duealert.DueAlertRecordMapper; +import com.njcn.rdms.module.project.dal.mysql.personal.PersonalItemMapper; +import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import com.njcn.rdms.module.project.enums.DueAlertObjectTypeEnum; +import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; +import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; +import com.njcn.rdms.module.system.api.dict.DictDataApi; +import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi; +import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageListReqDTO; +import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageRespDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 临期/逾期告警扫描编排。无事务,单条发送的事务在 {@link DueAlertSendService} 上。 + * + *

每类对象一次范围查询(计划日 <= maxDate、排除终态+paused),内存里按 + * 「计划日 < today = 逾期 / >= today = 临期」分类,再按记录表两档判重。 + * 逾期档叠加"未读不重发":接收人上一条逾期提醒未读则本轮对其跳过,已读后次日恢复。 + * 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。

+ */ +@Service +@Slf4j +public class DueAlertScanServiceImpl implements DueAlertScanService { + + @Resource + private ProjectMapper projectMapper; + @Resource + private ProjectRequirementMapper projectRequirementMapper; + @Resource + private ProductRequirementMapper productRequirementMapper; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + @Resource + private ProjectTaskMapper projectTaskMapper; + @Resource + private PersonalItemMapper personalItemMapper; + @Resource + private TaskAssigneeMapper taskAssigneeMapper; + @Resource + private ExecutionAssigneeMapper executionAssigneeMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private DueAlertRecordMapper dueAlertRecordMapper; + @Resource + private DictDataApi dictDataApi; + @Resource + private NotifyMessageSendApi notifyMessageSendApi; + @Resource + private DueAlertSendService dueAlertSendService; + + @Override + public void scan(LocalDate today) { + Map advanceDays = loadAdvanceDays(); + for (DueAlertObjectTypeEnum type : DueAlertObjectTypeEnum.values()) { + try { + scanObjectType(type, today, advanceDays.get(type.getCode())); + } catch (Exception e) { + // 单类失败不影响其余类型 + log.error("[scan][对象类型({}) 扫描失败]", type.getCode(), e); + } + } + } + + /** 提前量字典:value=对象类型 code,label=天数;label 非数字按缺档处理(只停临期不停逾期) */ + private Map loadAdvanceDays() { + Map result = new HashMap<>(); + try { + CommonResult> dictResult = + dictDataApi.getDictDataList(ProjectDictTypeConstants.DUE_ALERT_ADVANCE_DAYS); + List dataList = dictResult == null ? null : dictResult.getCheckedData(); + if (dataList == null) { + return result; + } + for (DictDataRespDTO item : dataList) { + if (item == null || item.getValue() == null || item.getLabel() == null) { + continue; + } + try { + result.put(item.getValue(), Integer.parseInt(item.getLabel().trim())); + } catch (NumberFormatException e) { + log.warn("[loadAdvanceDays][提前量字典 label 非数字,按缺档处理 value({}) label({})]", + item.getValue(), item.getLabel()); + } + } + } catch (Exception e) { + // 字典拉取失败整体降级为"全部缺档":本轮只发逾期,不中断扫描 + log.warn("[loadAdvanceDays][提前量字典拉取失败,本轮仅扫逾期]", e); + } + return result; + } + + private void scanObjectType(DueAlertObjectTypeEnum type, LocalDate today, Integer advanceDays) { + // 终态(动态查,空集=不排除)+ paused 暂停豁免 + List excludeStatusCodes = new ArrayList<>( + objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(type.getStatusObjectType())); + excludeStatusCodes.add(DueAlertConstants.STATUS_PAUSED); + // 缺档时 maxDate=昨天:候选只剩逾期,临期档自然停发 + LocalDate maxDate = advanceDays != null ? today.plusDays(advanceDays) : today.minusDays(1); + + List candidates = loadCandidates(type, maxDate, excludeStatusCodes); + if (candidates.isEmpty()) { + return; + } + List approachingList = candidates.stream() + .filter(c -> !c.getPlannedEndDate().isBefore(today)) + .collect(Collectors.toList()); + List overdueList = candidates.stream() + .filter(c -> c.getPlannedEndDate().isBefore(today)) + .collect(Collectors.toList()); + + int approachingSent = sendApproaching(type, approachingList, today); + int overdueSent = sendOverdue(type, overdueList, today); + log.info("[scanObjectType][{} 候选({}) 临期新发({}) 逾期新发({})]", + type.getCode(), candidates.size(), approachingSent, overdueSent); + } + + /** 临期判重:同对象+同计划日快照发过即跳过(改期=新快照=重发) */ + private int sendApproaching(DueAlertObjectTypeEnum type, List list, LocalDate today) { + if (list.isEmpty()) { + return 0; + } + List ids = list.stream().map(DueAlertCandidate::getObjectId).collect(Collectors.toList()); + Set sentKeys = dueAlertRecordMapper.selectApproachingListByObjectIds(type.getCode(), ids).stream() + .map(r -> r.getObjectId() + "#" + r.getPlannedEndDate()) + .collect(Collectors.toSet()); + int sent = 0; + for (DueAlertCandidate candidate : list) { + if (sentKeys.contains(candidate.getObjectId() + "#" + candidate.getPlannedEndDate())) { + continue; + } + if (hasNoRecipient(candidate)) { + continue; + } + dueAlertSendService.sendAlert(candidate, DueAlertConstants.ALERT_TYPE_APPROACHING, today); + sent++; + } + return sent; + } + + /** + * 逾期判重:当日发过即跳过(昨天的记录不命中=每天一条)。 + * 叠加"未读不重发":某接收人对该对象的逾期提醒还未读时,本轮跳过该人; + * 全部接收人都被跳过则整条不发、不写记录(读完后次日自然恢复)。 + */ + private int sendOverdue(DueAlertObjectTypeEnum type, List list, LocalDate today) { + if (list.isEmpty()) { + return 0; + } + List ids = list.stream().map(DueAlertCandidate::getObjectId).collect(Collectors.toList()); + Set sentTodayIds = dueAlertRecordMapper + .selectOverdueListByObjectIdsAndAlertDate(type.getCode(), ids, today).stream() + .map(DueAlertRecordDO::getObjectId) + .collect(Collectors.toSet()); + Set unreadKeys = loadUnreadOverdueKeys(type, list); + int sent = 0; + for (DueAlertCandidate candidate : list) { + if (sentTodayIds.contains(candidate.getObjectId())) { + continue; + } + DueAlertCandidate filtered = excludeUnreadRecipients(candidate, unreadKeys); + if (hasNoRecipient(filtered)) { + continue; + } + dueAlertSendService.sendAlert(filtered, DueAlertConstants.ALERT_TYPE_OVERDUE, today); + sent++; + } + return sent; + } + + /** + * 拉取本批接收人对这批对象的未读逾期消息,组装 "userId#objectId" 键集。 + * 按消息参数里的对象标识匹配(历史消息无该参数 → 不拦截,照发一次后进入新口径); + * 查询失败整体降级为"无未读"(照发,最坏退回旧的每天一条行为,不丢催办)。 + */ + private Set loadUnreadOverdueKeys(DueAlertObjectTypeEnum type, List list) { + Set userIds = new HashSet<>(); + for (DueAlertCandidate candidate : list) { + if (candidate.getOwnerUserIds() != null) { + userIds.addAll(candidate.getOwnerUserIds()); + } + if (candidate.getAssigneeUserIds() != null) { + userIds.addAll(candidate.getAssigneeUserIds()); + } + } + if (userIds.isEmpty()) { + return Set.of(); + } + try { + CommonResult> result = + notifyMessageSendApi.getUnreadNotifyMessageListByTemplateCodes( + new NotifyUnreadMessageListReqDTO() + .setUserIds(new ArrayList<>(userIds)) + .setTemplateCodes(List.of(NotifyTemplateCodeConstants.DUE_ALERT_OVERDUE_OWNER, + NotifyTemplateCodeConstants.DUE_ALERT_OVERDUE_ASSIGNEE))); + List messages = result == null ? null : result.getCheckedData(); + if (messages == null) { + return Set.of(); + } + Set keys = new HashSet<>(); + for (NotifyUnreadMessageRespDTO message : messages) { + Map params = message.getTemplateParams(); + if (message.getUserId() == null || params == null) { + continue; + } + Object objectId = params.get(DueAlertConstants.PARAM_OBJECT_ID); + // 同一模板编码跨六类对象共用,必须连对象类型一起匹配 + if (objectId == null + || !type.getCode().equals(params.get(DueAlertConstants.PARAM_OBJECT_TYPE))) { + continue; + } + keys.add(message.getUserId() + "#" + objectId); + } + return keys; + } catch (Exception e) { + log.warn("[loadUnreadOverdueKeys][未读消息查询失败,本轮按无未读处理(照发)objectType({})]", + type.getCode(), e); + return Set.of(); + } + } + + /** 按 "userId#objectId" 键剔除还压着未读逾期提醒的接收人(不改原 candidate) */ + private DueAlertCandidate excludeUnreadRecipients(DueAlertCandidate candidate, Set unreadKeys) { + if (unreadKeys.isEmpty()) { + return candidate; + } + String objectIdText = String.valueOf(candidate.getObjectId()); + return buildCandidate(candidate.getObjectType(), candidate.getObjectId(), candidate.getObjectName(), + candidate.getPlannedEndDate(), + excludeUnreadUsers(candidate.getOwnerUserIds(), objectIdText, unreadKeys), + excludeUnreadUsers(candidate.getAssigneeUserIds(), objectIdText, unreadKeys)); + } + + private List excludeUnreadUsers(List userIds, String objectIdText, Set unreadKeys) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + return userIds.stream() + .filter(userId -> !unreadKeys.contains(userId + "#" + objectIdText)) + .collect(Collectors.toList()); + } + + /** 两组接收人都为空:跳过且不写记录(人补上后次日还能发) */ + private boolean hasNoRecipient(DueAlertCandidate candidate) { + boolean noOwner = candidate.getOwnerUserIds() == null || candidate.getOwnerUserIds().isEmpty(); + boolean noAssignee = candidate.getAssigneeUserIds() == null || candidate.getAssigneeUserIds().isEmpty(); + return noOwner && noAssignee; + } + + private List loadCandidates(DueAlertObjectTypeEnum type, LocalDate maxDate, + List excludeStatusCodes) { + switch (type) { + case PROJECT: + return projectMapper.selectDueAlertCandidateList(maxDate, excludeStatusCodes).stream() + .map(this::toCandidate).collect(Collectors.toList()); + case PROJECT_REQUIREMENT: + return projectRequirementMapper.selectDueAlertCandidateList(maxDate, excludeStatusCodes).stream() + .map(this::toCandidate).collect(Collectors.toList()); + case PRODUCT_REQUIREMENT: + return productRequirementMapper.selectDueAlertCandidateList(maxDate, excludeStatusCodes).stream() + .map(this::toCandidate).collect(Collectors.toList()); + case EXECUTION: + return toExecutionCandidates( + projectExecutionMapper.selectDueAlertCandidateList(maxDate, excludeStatusCodes)); + case TASK: + return toTaskCandidates( + projectTaskMapper.selectDueAlertCandidateList(maxDate, excludeStatusCodes)); + case PERSONAL_ITEM: + return personalItemMapper.selectDueAlertCandidateList(maxDate, excludeStatusCodes).stream() + .map(this::toCandidate).collect(Collectors.toList()); + default: + return List.of(); + } + } + + private DueAlertCandidate toCandidate(ProjectDO project) { + return buildCandidate(DueAlertObjectTypeEnum.PROJECT, project.getId(), project.getProjectName(), + project.getPlannedEndDate(), singletonOrEmpty(project.getManagerUserId()), List.of()); + } + + /** 需求接收人:当前处理人优先,空则提出人(设计定稿) */ + private DueAlertCandidate toCandidate(ProjectRequirementDO requirement) { + Long recipient = requirement.getCurrentHandlerUserId() != null + ? requirement.getCurrentHandlerUserId() : requirement.getProposerId(); + return buildCandidate(DueAlertObjectTypeEnum.PROJECT_REQUIREMENT, requirement.getId(), + requirement.getTitle(), requirement.getExpectedTime(), singletonOrEmpty(recipient), List.of()); + } + + private DueAlertCandidate toCandidate(ProductRequirementDO requirement) { + Long recipient = requirement.getCurrentHandlerUserId() != null + ? requirement.getCurrentHandlerUserId() : requirement.getProposerId(); + return buildCandidate(DueAlertObjectTypeEnum.PRODUCT_REQUIREMENT, requirement.getId(), + requirement.getTitle(), requirement.getExpectedTime(), singletonOrEmpty(recipient), List.of()); + } + + private DueAlertCandidate toCandidate(PersonalItemDO item) { + return buildCandidate(DueAlertObjectTypeEnum.PERSONAL_ITEM, item.getId(), item.getTaskTitle(), + item.getPlannedEndDate(), singletonOrEmpty(item.getOwnerId()), List.of()); + } + + /** 任务:负责人 + 生效协办人(批量查,剔除与负责人重叠者) */ + private List toTaskCandidates(List tasks) { + if (tasks.isEmpty()) { + return List.of(); + } + List taskIds = tasks.stream().map(ProjectTaskDO::getId).collect(Collectors.toList()); + Map> assigneeMap = taskAssigneeMapper.selectActiveListByTaskIds(taskIds).stream() + .collect(Collectors.groupingBy(TaskAssigneeDO::getTaskId, + Collectors.mapping(TaskAssigneeDO::getUserId, Collectors.toList()))); + return tasks.stream().map(t -> buildCandidate(DueAlertObjectTypeEnum.TASK, t.getId(), t.getTaskTitle(), + t.getPlannedEndDate(), singletonOrEmpty(t.getOwnerId()), + excludeOwner(assigneeMap.get(t.getId()), t.getOwnerId()))) + .collect(Collectors.toList()); + } + + /** 执行:同任务结构 */ + private List toExecutionCandidates(List executions) { + if (executions.isEmpty()) { + return List.of(); + } + List executionIds = executions.stream().map(ProjectExecutionDO::getId).collect(Collectors.toList()); + Map> assigneeMap = executionAssigneeMapper.selectActiveListByExecutionIds(executionIds) + .stream() + .collect(Collectors.groupingBy(ExecutionAssigneeDO::getExecutionId, + Collectors.mapping(ExecutionAssigneeDO::getUserId, Collectors.toList()))); + return executions.stream().map(e -> buildCandidate(DueAlertObjectTypeEnum.EXECUTION, e.getId(), + e.getExecutionName(), e.getPlannedEndDate(), singletonOrEmpty(e.getOwnerId()), + excludeOwner(assigneeMap.get(e.getId()), e.getOwnerId()))) + .collect(Collectors.toList()); + } + + private DueAlertCandidate buildCandidate(DueAlertObjectTypeEnum type, Long objectId, String objectName, + LocalDate plannedEndDate, List owners, List assignees) { + DueAlertCandidate candidate = new DueAlertCandidate(); + candidate.setObjectType(type); + candidate.setObjectId(objectId); + candidate.setObjectName(objectName); + candidate.setPlannedEndDate(plannedEndDate); + candidate.setOwnerUserIds(owners); + candidate.setAssigneeUserIds(assignees); + return candidate; + } + + private List singletonOrEmpty(Long userId) { + return userId == null ? List.of() : List.of(userId); + } + + /** 协办人剔除负责人(双重身份只收负责人版)+ 去重去 null */ + private List excludeOwner(List assignees, Long ownerId) { + if (assignees == null || assignees.isEmpty()) { + return List.of(); + } + return assignees.stream() + .filter(Objects::nonNull) + .filter(userId -> !userId.equals(ownerId)) + .distinct() + .collect(Collectors.toList()); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendService.java new file mode 100644 index 0000000..dee2eec --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendService.java @@ -0,0 +1,23 @@ +package com.njcn.rdms.module.project.service.duealert; + +import java.time.LocalDate; + +/** + * 告警发送服务:插去重记录 + publish 站内信事件。 + * + *

独立于扫描编排的 bean——@Transactional 必须跨 bean 调用才走代理, + * 同 bean 自调用会绕过事务,导致 NotifySendEvent 静默不发(事务红线)。

+ */ +public interface DueAlertSendService { + + /** + * 发送一条告警。插入去重记录与事件发布在同一事务内; + * 撞唯一索引(并发/多实例/重复执行)静默跳过不发。 + * + * @param candidate 待告警对象(接收人已组装好) + * @param alertType DueAlertConstants.ALERT_TYPE_APPROACHING / ALERT_TYPE_OVERDUE + * @param today 扫描日(写入 alert_date;临期档同时用于算剩余天数) + */ + void sendAlert(DueAlertCandidate candidate, String alertType, LocalDate today); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendServiceImpl.java new file mode 100644 index 0000000..c01bd5f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendServiceImpl.java @@ -0,0 +1,80 @@ +package com.njcn.rdms.module.project.service.duealert; + +import com.njcn.rdms.module.project.constant.DueAlertConstants; +import com.njcn.rdms.module.project.dal.dataobject.duealert.DueAlertRecordDO; +import com.njcn.rdms.module.project.dal.mysql.duealert.DueAlertRecordMapper; +import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; +import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; +import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; + +/** + * 告警发送实现:去重记录与事件发布同事务(NotifySendEvent 事务红线), + * 唯一索引冲突 = 已发过(并发/多实例/同日重复执行),静默跳过。 + */ +@Service +@Slf4j +public class DueAlertSendServiceImpl implements DueAlertSendService { + + @Resource + private DueAlertRecordMapper dueAlertRecordMapper; + @Resource + private ApplicationEventPublisher applicationEventPublisher; + + @Override + @Transactional(rollbackFor = Exception.class) + public void sendAlert(DueAlertCandidate candidate, String alertType, LocalDate today) { + DueAlertRecordDO record = new DueAlertRecordDO(); + record.setObjectType(candidate.getObjectType().getCode()); + record.setObjectId(candidate.getObjectId()); + record.setAlertType(alertType); + record.setPlannedEndDate(candidate.getPlannedEndDate()); + record.setAlertDate(today); + try { + dueAlertRecordMapper.insert(record); + } catch (DuplicateKeyException e) { + log.info("[sendAlert][告警记录已存在,跳过 objectType({}) objectId({}) alertType({})]", + record.getObjectType(), record.getObjectId(), alertType); + return; + } + + boolean approaching = DueAlertConstants.ALERT_TYPE_APPROACHING.equals(alertType); + // 按「身份 + 紧急度」定级:逾期高于临期,同紧急度内负责人高于协办人 + int ownerLevel = approaching ? NotifyMessageLevelConstants.REMIND : NotifyMessageLevelConstants.SEVERE; + int assigneeLevel = approaching ? NotifyMessageLevelConstants.NORMAL : NotifyMessageLevelConstants.WARN; + Map params = new HashMap<>(); + params.put("objectTypeName", candidate.getObjectType().getDisplayName()); + params.put("objectName", candidate.getObjectName()); + params.put("plannedEndDate", candidate.getPlannedEndDate().toString()); + // 对象标识附加参数:不在模板正文渲染,存进消息参数 JSON,供逾期"未读不重发"按对象匹配 + params.put(DueAlertConstants.PARAM_OBJECT_TYPE, candidate.getObjectType().getCode()); + params.put(DueAlertConstants.PARAM_OBJECT_ID, String.valueOf(candidate.getObjectId())); + if (approaching) { + params.put("remainingDays", ChronoUnit.DAYS.between(today, candidate.getPlannedEndDate())); + } + + if (candidate.getOwnerUserIds() != null && !candidate.getOwnerUserIds().isEmpty()) { + applicationEventPublisher.publishEvent(NotifySendEvent.of(candidate.getOwnerUserIds(), + approaching ? NotifyTemplateCodeConstants.DUE_ALERT_APPROACHING_OWNER + : NotifyTemplateCodeConstants.DUE_ALERT_OVERDUE_OWNER, + params, ownerLevel)); + } + if (candidate.getAssigneeUserIds() != null && !candidate.getAssigneeUserIds().isEmpty()) { + applicationEventPublisher.publishEvent(NotifySendEvent.of(candidate.getAssigneeUserIds(), + approaching ? NotifyTemplateCodeConstants.DUE_ALERT_APPROACHING_ASSIGNEE + : NotifyTemplateCodeConstants.DUE_ALERT_OVERDUE_ASSIGNEE, + params, assigneeLevel)); + } + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/resources/sql/due_alert_record.sql b/rdms-project/rdms-project-boot/src/main/resources/sql/due_alert_record.sql new file mode 100644 index 0000000..178e02c --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/resources/sql/due_alert_record.sql @@ -0,0 +1,19 @@ +-- 临期/逾期告警发送记录表(去重凭证) +-- 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html +-- 临期档:同(object_type,object_id,planned_end_date快照)只发一次,改期=新快照=重新告警 +-- 逾期档:按(object_type,object_id,alert_date)每天一条 +CREATE TABLE IF NOT EXISTS rdms_due_alert_record ( + id BIGINT NOT NULL COMMENT '主键(Java 侧 MyBatis-Plus 雪花生成,无 AUTO_INCREMENT)', + object_type VARCHAR(32) NOT NULL COMMENT '告警域对象类型:project/project_requirement/product_requirement/execution/task/personal_item', + object_id BIGINT NOT NULL COMMENT '对象主键', + alert_type VARCHAR(16) NOT NULL COMMENT '告警档:approaching=临期/overdue=逾期', + planned_end_date DATE NOT NULL COMMENT '判定时计划完成日快照(临期档去重键)', + alert_date DATE NOT NULL COMMENT '告警发送日(逾期档每天一条的去重键)', + creator VARCHAR(64) DEFAULT '' COMMENT '创建者', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updater VARCHAR(64) DEFAULT '' COMMENT '更新者', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (id), + UNIQUE KEY uk_due_alert (object_type, object_id, alert_type, planned_end_date, alert_date) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '临期/逾期告警发送记录(去重凭证,只增不删)'; 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 index 2258b74..55f1b36 100644 --- 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 @@ -2,6 +2,7 @@ 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 com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -31,22 +32,22 @@ class NotifySendEventListenerTest extends BaseMockitoUnitTest { 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()); + verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(1L), eq("task_assigned"), any(), eq(NotifyMessageLevelConstants.NORMAL)); + verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), eq("task_assigned"), any(), eq(NotifyMessageLevelConstants.NORMAL)); } @Test void testOnNotifySend_emptyRecipients_noSend() { listener.onNotifySend(NotifySendEvent.of(Collections.emptyList(), "task_assigned", new HashMap<>())); - verify(notifyMessageSendApi, times(0)).sendSingleNotifyToAdmin(any(), any(), any()); + verify(notifyMessageSendApi, times(0)).sendSingleNotifyToAdmin(any(), any(), any(), any()); } @Test void testOnNotifySend_singleFailure_doesNotInterrupt() { // 第一个人发送抛异常,第二个人仍应被发送(兜底不中断) doThrow(new RuntimeException("boom")).when(notifyMessageSendApi) - .sendSingleNotifyToAdmin(eq(1L), any(), any()); + .sendSingleNotifyToAdmin(eq(1L), any(), any(), any()); listener.onNotifySend(NotifySendEvent.of(Arrays.asList(1L, 2L), "task_assigned", new HashMap<>())); - verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), any(), any()); + verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), any(), any(), any()); } } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanServiceImplTest.java new file mode 100644 index 0000000..23b707d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/duealert/DueAlertScanServiceImplTest.java @@ -0,0 +1,331 @@ +package com.njcn.rdms.module.project.service.duealert; + +import com.njcn.rdms.framework.common.biz.system.dict.dto.DictDataRespDTO; +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.constant.DueAlertConstants; +import com.njcn.rdms.module.project.constant.ProjectTaskConstants; +import com.njcn.rdms.module.project.dal.dataobject.duealert.DueAlertRecordDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO; +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.duealert.DueAlertRecordMapper; +import com.njcn.rdms.module.project.dal.mysql.personal.PersonalItemMapper; +import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; +import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; +import com.njcn.rdms.module.system.api.dict.DictDataApi; +import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi; +import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageRespDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 扫描编排单测:边界分类 / 暂停与终态排除入参 / 判重 / 接收人组装 / 字典缺档降级 / 逾期未读不重发。 + */ +class DueAlertScanServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private DueAlertScanServiceImpl dueAlertScanService; + + @Mock + private ProjectMapper projectMapper; + @Mock + private ProjectRequirementMapper projectRequirementMapper; + @Mock + private ProductRequirementMapper productRequirementMapper; + @Mock + private ProjectExecutionMapper projectExecutionMapper; + @Mock + private ProjectTaskMapper projectTaskMapper; + @Mock + private PersonalItemMapper personalItemMapper; + @Mock + private TaskAssigneeMapper taskAssigneeMapper; + @Mock + private ExecutionAssigneeMapper executionAssigneeMapper; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private DueAlertRecordMapper dueAlertRecordMapper; + @Mock + private DictDataApi dictDataApi; + @Mock + private NotifyMessageSendApi notifyMessageSendApi; + @Mock + private DueAlertSendService dueAlertSendService; + + private static final LocalDate TODAY = LocalDate.of(2026, 6, 15); + + private DictDataRespDTO dict(String objectTypeCode, String days) { + DictDataRespDTO dto = new DictDataRespDTO(); + dto.setValue(objectTypeCode); + dto.setLabel(days); + return dto; + } + + private void stubDict(DictDataRespDTO... items) { + when(dictDataApi.getDictDataList(ProjectDictTypeConstants.DUE_ALERT_ADVANCE_DAYS)) + .thenReturn(CommonResult.success(List.of(items))); + } + + private ProjectTaskDO task(Long id, Long ownerId, LocalDate plannedEndDate) { + ProjectTaskDO t = new ProjectTaskDO(); + t.setId(id); + t.setOwnerId(ownerId); + t.setTaskTitle("任务" + id); + t.setPlannedEndDate(plannedEndDate); + return t; + } + + @Test + void scan_taskBoundary_shouldClassifyApproachingAndOverdue() { + stubDict(dict("task", "1")); + // scan 遍历全部 6 种对象类型;用 lenient 通配,避免 strict stub 参数不匹配报错 + lenient().when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(anyString())) + .thenReturn(List.of("completed", "cancelled")); + // 候选:今天(临期)、今天+1(临期上界)、昨天(逾期) + when(projectTaskMapper.selectDueAlertCandidateList(eq(TODAY.plusDays(1)), anyCollection())) + .thenReturn(List.of(task(1L, 11L, TODAY), task(2L, 12L, TODAY.plusDays(1)), task(3L, 13L, TODAY.minusDays(1)))); + + dueAlertScanService.scan(TODAY); + + // 排除集 = 终态 + paused + @SuppressWarnings("unchecked") + ArgumentCaptor> excludeCaptor = ArgumentCaptor.forClass(Collection.class); + verify(projectTaskMapper).selectDueAlertCandidateList(eq(TODAY.plusDays(1)), excludeCaptor.capture()); + assertTrue(excludeCaptor.getValue().contains(DueAlertConstants.STATUS_PAUSED)); + assertTrue(excludeCaptor.getValue().contains("completed")); + + // 1、2 临期,3 逾期 + ArgumentCaptor candidateCaptor = ArgumentCaptor.forClass(DueAlertCandidate.class); + ArgumentCaptor alertTypeCaptor = ArgumentCaptor.forClass(String.class); + verify(dueAlertSendService, times(3)) + .sendAlert(candidateCaptor.capture(), alertTypeCaptor.capture(), eq(TODAY)); + assertEquals(DueAlertConstants.ALERT_TYPE_APPROACHING, alertTypeCaptor.getAllValues().get(0)); + assertEquals(DueAlertConstants.ALERT_TYPE_APPROACHING, alertTypeCaptor.getAllValues().get(1)); + assertEquals(DueAlertConstants.ALERT_TYPE_OVERDUE, alertTypeCaptor.getAllValues().get(2)); + assertEquals(3L, candidateCaptor.getAllValues().get(2).getObjectId()); + } + + @Test + void scan_dictMissing_shouldScanOverdueOnly() { + stubDict(); // 全部缺档 + when(projectTaskMapper.selectDueAlertCandidateList(eq(TODAY.minusDays(1)), anyCollection())) + .thenReturn(List.of(task(3L, 13L, TODAY.minusDays(1)))); + + dueAlertScanService.scan(TODAY); + + // maxDate = 今天-1(只够到逾期),且发的是逾期档 + verify(projectTaskMapper).selectDueAlertCandidateList(eq(TODAY.minusDays(1)), anyCollection()); + verify(dueAlertSendService).sendAlert(any(DueAlertCandidate.class), + eq(DueAlertConstants.ALERT_TYPE_OVERDUE), eq(TODAY)); + } + + @Test + void scan_dictLabelNotNumber_shouldTreatAsMissing() { + stubDict(dict("task", "abc")); + when(projectTaskMapper.selectDueAlertCandidateList(eq(TODAY.minusDays(1)), anyCollection())) + .thenReturn(List.of()); + + dueAlertScanService.scan(TODAY); + + verify(projectTaskMapper).selectDueAlertCandidateList(eq(TODAY.minusDays(1)), anyCollection()); + } + + @Test + void scan_approachingDedup_sameSnapshotSkipped_changedDateResent() { + stubDict(dict("task", "1")); + ProjectTaskDO sameSnapshot = task(1L, 11L, TODAY); // 已发过同快照 → 跳过 + ProjectTaskDO changedDate = task(2L, 12L, TODAY.plusDays(1)); // 记录是旧快照 → 改期重发 + when(projectTaskMapper.selectDueAlertCandidateList(eq(TODAY.plusDays(1)), anyCollection())) + .thenReturn(List.of(sameSnapshot, changedDate)); + DueAlertRecordDO sent1 = new DueAlertRecordDO(); + sent1.setObjectId(1L); + sent1.setPlannedEndDate(TODAY); // 与候选 1 快照一致 + DueAlertRecordDO sent2 = new DueAlertRecordDO(); + sent2.setObjectId(2L); + sent2.setPlannedEndDate(TODAY.minusDays(3)); // 旧快照,与候选 2 不一致 + when(dueAlertRecordMapper.selectApproachingListByObjectIds(eq("task"), anyCollection())) + .thenReturn(List.of(sent1, sent2)); + + dueAlertScanService.scan(TODAY); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DueAlertCandidate.class); + verify(dueAlertSendService).sendAlert(captor.capture(), + eq(DueAlertConstants.ALERT_TYPE_APPROACHING), eq(TODAY)); + assertEquals(2L, captor.getValue().getObjectId()); + } + + @Test + void scan_overdueDedup_sameDaySkipped() { + stubDict(dict("task", "1")); + when(projectTaskMapper.selectDueAlertCandidateList(eq(TODAY.plusDays(1)), anyCollection())) + .thenReturn(List.of(task(3L, 13L, TODAY.minusDays(1)))); + DueAlertRecordDO sentToday = new DueAlertRecordDO(); + sentToday.setObjectId(3L); + when(dueAlertRecordMapper.selectOverdueListByObjectIdsAndAlertDate(eq("task"), anyCollection(), eq(TODAY))) + .thenReturn(List.of(sentToday)); + + dueAlertScanService.scan(TODAY); + + verify(dueAlertSendService, never()).sendAlert(any(), any(), any()); + } + + @Test + void scan_taskAssigneeOverlapOwner_shouldBeExcludedFromAssigneeGroup() { + stubDict(dict("task", "1")); + when(projectTaskMapper.selectDueAlertCandidateList(eq(TODAY.plusDays(1)), anyCollection())) + .thenReturn(List.of(task(1L, 11L, TODAY))); + TaskAssigneeDO sameAsOwner = new TaskAssigneeDO(); + sameAsOwner.setTaskId(1L); + sameAsOwner.setUserId(11L); // 与负责人重叠 → 剔除 + TaskAssigneeDO other = new TaskAssigneeDO(); + other.setTaskId(1L); + other.setUserId(22L); + when(taskAssigneeMapper.selectActiveListByTaskIds(anyCollection())) + .thenReturn(List.of(sameAsOwner, other)); + + dueAlertScanService.scan(TODAY); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DueAlertCandidate.class); + verify(dueAlertSendService).sendAlert(captor.capture(), any(), eq(TODAY)); + assertEquals(List.of(11L), captor.getValue().getOwnerUserIds()); + assertEquals(List.of(22L), captor.getValue().getAssigneeUserIds()); + } + + private NotifyUnreadMessageRespDTO unreadOverdueMsg(Long userId, String objectType, String objectId) { + return new NotifyUnreadMessageRespDTO() + .setUserId(userId) + .setTemplateCode(NotifyTemplateCodeConstants.DUE_ALERT_OVERDUE_OWNER) + .setTemplateParams(Map.of(DueAlertConstants.PARAM_OBJECT_TYPE, objectType, + DueAlertConstants.PARAM_OBJECT_ID, objectId)); + } + + @Test + void scan_overdueUnread_ownerSkippedAssigneeStillSent() { + stubDict(dict("task", "1")); + when(projectTaskMapper.selectDueAlertCandidateList(eq(TODAY.plusDays(1)), anyCollection())) + .thenReturn(List.of(task(3L, 13L, TODAY.minusDays(1)))); + TaskAssigneeDO assignee = new TaskAssigneeDO(); + assignee.setTaskId(3L); + assignee.setUserId(22L); + when(taskAssigneeMapper.selectActiveListByTaskIds(anyCollection())).thenReturn(List.of(assignee)); + // 负责人 13 压着该对象的未读逾期提醒 → 本轮只发协办人 + when(notifyMessageSendApi.getUnreadNotifyMessageListByTemplateCodes(any())) + .thenReturn(CommonResult.success(List.of(unreadOverdueMsg(13L, "task", "3")))); + + dueAlertScanService.scan(TODAY); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DueAlertCandidate.class); + verify(dueAlertSendService).sendAlert(captor.capture(), + eq(DueAlertConstants.ALERT_TYPE_OVERDUE), eq(TODAY)); + assertTrue(captor.getValue().getOwnerUserIds().isEmpty()); + assertEquals(List.of(22L), captor.getValue().getAssigneeUserIds()); + } + + @Test + void scan_overdueUnread_allRecipientsUnread_skipWithoutRecord() { + stubDict(dict("task", "1")); + when(projectTaskMapper.selectDueAlertCandidateList(eq(TODAY.plusDays(1)), anyCollection())) + .thenReturn(List.of(task(3L, 13L, TODAY.minusDays(1)))); + when(notifyMessageSendApi.getUnreadNotifyMessageListByTemplateCodes(any())) + .thenReturn(CommonResult.success(List.of(unreadOverdueMsg(13L, "task", "3")))); + + dueAlertScanService.scan(TODAY); + + // 唯一接收人未读 → 整条跳过(不写记录,读完后次日恢复) + verify(dueAlertSendService, never()).sendAlert(any(), any(), any()); + } + + @Test + void scan_overdueUnread_legacyOrOtherObjectMessage_notBlocked() { + stubDict(dict("task", "1")); + when(projectTaskMapper.selectDueAlertCandidateList(eq(TODAY.plusDays(1)), anyCollection())) + .thenReturn(List.of(task(3L, 13L, TODAY.minusDays(1)))); + // 历史消息无对象标识参数 + 其他对象(999)的未读:都不拦本对象 + NotifyUnreadMessageRespDTO legacy = new NotifyUnreadMessageRespDTO() + .setUserId(13L) + .setTemplateCode(NotifyTemplateCodeConstants.DUE_ALERT_OVERDUE_OWNER) + .setTemplateParams(Map.of("objectName", "旧消息")); + when(notifyMessageSendApi.getUnreadNotifyMessageListByTemplateCodes(any())) + .thenReturn(CommonResult.success(List.of(legacy, unreadOverdueMsg(13L, "task", "999")))); + + dueAlertScanService.scan(TODAY); + + verify(dueAlertSendService).sendAlert(any(DueAlertCandidate.class), + eq(DueAlertConstants.ALERT_TYPE_OVERDUE), eq(TODAY)); + } + + @Test + void scan_overdueUnreadQueryFail_shouldDegradeAndSend() { + stubDict(dict("task", "1")); + when(projectTaskMapper.selectDueAlertCandidateList(eq(TODAY.plusDays(1)), anyCollection())) + .thenReturn(List.of(task(3L, 13L, TODAY.minusDays(1)))); + when(notifyMessageSendApi.getUnreadNotifyMessageListByTemplateCodes(any())) + .thenThrow(new RuntimeException("rpc down")); + + dueAlertScanService.scan(TODAY); + + // 查询失败按"无未读"降级:照发(最坏退回旧的每天一条,不丢催办) + verify(dueAlertSendService).sendAlert(any(DueAlertCandidate.class), + eq(DueAlertConstants.ALERT_TYPE_OVERDUE), eq(TODAY)); + } + + @Test + void scan_requirementRecipient_handlerFallbackToProposer_bothNullSkipped() { + stubDict(dict("project_requirement", "5")); + ProjectRequirementDO withHandler = new ProjectRequirementDO(); + withHandler.setId(1L); + withHandler.setTitle("需求1"); + withHandler.setExpectedTime(TODAY); + withHandler.setCurrentHandlerUserId(31L); + withHandler.setProposerId(32L); + ProjectRequirementDO proposerOnly = new ProjectRequirementDO(); + proposerOnly.setId(2L); + proposerOnly.setTitle("需求2"); + proposerOnly.setExpectedTime(TODAY); + proposerOnly.setProposerId(32L); + ProjectRequirementDO bothNull = new ProjectRequirementDO(); + bothNull.setId(3L); + bothNull.setTitle("需求3"); + bothNull.setExpectedTime(TODAY); + when(projectRequirementMapper.selectDueAlertCandidateList(eq(TODAY.plusDays(5)), anyCollection())) + .thenReturn(List.of(withHandler, proposerOnly, bothNull)); + + dueAlertScanService.scan(TODAY); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DueAlertCandidate.class); + verify(dueAlertSendService, times(2)).sendAlert(captor.capture(), any(), eq(TODAY)); + assertEquals(List.of(31L), captor.getAllValues().get(0).getOwnerUserIds()); // 处理人优先 + assertEquals(List.of(32L), captor.getAllValues().get(1).getOwnerUserIds()); // 处理人空→提出人 + // 需求3 双空:未发送(times(2) 已隐含) + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendServiceImplTest.java new file mode 100644 index 0000000..ae56374 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/duealert/DueAlertSendServiceImplTest.java @@ -0,0 +1,132 @@ +package com.njcn.rdms.module.project.service.duealert; + +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.constant.DueAlertConstants; +import com.njcn.rdms.module.project.dal.dataobject.duealert.DueAlertRecordDO; +import com.njcn.rdms.module.project.dal.mysql.duealert.DueAlertRecordMapper; +import com.njcn.rdms.module.project.enums.DueAlertObjectTypeEnum; +import com.njcn.rdms.module.project.framework.notify.NotifySendEvent; +import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants; +import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DuplicateKeyException; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 告警发送服务单测:去重记录落库 + 两组事件(负责人/协办人模板)+ 唯一索引冲突幂等。 + */ +class DueAlertSendServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private DueAlertSendServiceImpl dueAlertSendService; + + @Mock + private DueAlertRecordMapper dueAlertRecordMapper; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + private static final LocalDate TODAY = LocalDate.of(2026, 6, 15); + + private DueAlertCandidate candidate(List owners, List assignees, LocalDate plannedEndDate) { + DueAlertCandidate c = new DueAlertCandidate(); + c.setObjectType(DueAlertObjectTypeEnum.TASK); + c.setObjectId(100L); + c.setObjectName("测试任务"); + c.setPlannedEndDate(plannedEndDate); + c.setOwnerUserIds(owners); + c.setAssigneeUserIds(assignees); + return c; + } + + @Test + void sendAlert_approaching_shouldInsertRecordAndPublishBothGroups() { + DueAlertCandidate c = candidate(List.of(1L), List.of(2L, 3L), TODAY.plusDays(1)); + + dueAlertSendService.sendAlert(c, DueAlertConstants.ALERT_TYPE_APPROACHING, TODAY); + + // 去重记录:快照=计划日,alert_date=今天 + ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(DueAlertRecordDO.class); + verify(dueAlertRecordMapper).insert(recordCaptor.capture()); + DueAlertRecordDO record = recordCaptor.getValue(); + assertEquals("task", record.getObjectType()); + assertEquals(100L, record.getObjectId()); + assertEquals(DueAlertConstants.ALERT_TYPE_APPROACHING, record.getAlertType()); + assertEquals(TODAY.plusDays(1), record.getPlannedEndDate()); + assertEquals(TODAY, record.getAlertDate()); + + // 两组事件:负责人版 + 协办人版模板,剩余天数=1 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(NotifySendEvent.class); + verify(applicationEventPublisher, times(2)).publishEvent(eventCaptor.capture()); + List events = eventCaptor.getAllValues(); + assertEquals(NotifyTemplateCodeConstants.DUE_ALERT_APPROACHING_OWNER, events.get(0).getTemplateCode()); + assertTrue(events.get(0).getUserIds().contains(1L)); + assertEquals(NotifyTemplateCodeConstants.DUE_ALERT_APPROACHING_ASSIGNEE, events.get(1).getTemplateCode()); + assertTrue(events.get(1).getUserIds().contains(2L)); + assertEquals("任务", events.get(0).getParams().get("objectTypeName")); + assertEquals("测试任务", events.get(0).getParams().get("objectName")); + assertEquals("2026-06-16", events.get(0).getParams().get("plannedEndDate")); + assertEquals(1L, events.get(0).getParams().get("remainingDays")); + // 对象标识附加参数(未读不重发的匹配键),objectId 为字符串 + assertEquals("task", events.get(0).getParams().get(DueAlertConstants.PARAM_OBJECT_TYPE)); + assertEquals("100", events.get(0).getParams().get(DueAlertConstants.PARAM_OBJECT_ID)); + // 临期:负责人=提醒、协办人=普通 + assertEquals(NotifyMessageLevelConstants.REMIND, events.get(0).getLevel()); + assertEquals(NotifyMessageLevelConstants.NORMAL, events.get(1).getLevel()); + } + + @Test + void sendAlert_overdue_shouldUseOverdueTemplatesWithoutRemainingDays() { + DueAlertCandidate c = candidate(List.of(1L), List.of(), TODAY.minusDays(2)); + + dueAlertSendService.sendAlert(c, DueAlertConstants.ALERT_TYPE_OVERDUE, TODAY); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(NotifySendEvent.class); + // 协办人组为空 → 只发负责人版一条 + verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture()); + NotifySendEvent event = eventCaptor.getValue(); + assertEquals(NotifyTemplateCodeConstants.DUE_ALERT_OVERDUE_OWNER, event.getTemplateCode()); + assertFalse(event.getParams().containsKey("remainingDays"), "逾期模板无剩余天数参数"); + // 逾期负责人=严重 + assertEquals(NotifyMessageLevelConstants.SEVERE, event.getLevel()); + } + + @Test + void sendAlert_overdue_assignee_shouldBeWarnLevel() { + DueAlertCandidate c = candidate(List.of(1L), List.of(2L), TODAY.minusDays(1)); + + dueAlertSendService.sendAlert(c, DueAlertConstants.ALERT_TYPE_OVERDUE, TODAY); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(NotifySendEvent.class); + verify(applicationEventPublisher, times(2)).publishEvent(eventCaptor.capture()); + List events = eventCaptor.getAllValues(); + assertEquals(NotifyMessageLevelConstants.SEVERE, events.get(0).getLevel()); // 负责人 + assertEquals(NotifyMessageLevelConstants.WARN, events.get(1).getLevel()); // 协办人 + } + + @Test + void sendAlert_duplicateKey_shouldSkipPublishSilently() { + DueAlertCandidate c = candidate(List.of(1L), List.of(2L), TODAY.minusDays(1)); + when(dueAlertRecordMapper.insert(any(DueAlertRecordDO.class))) + .thenThrow(new DuplicateKeyException("uk_due_alert")); + + dueAlertSendService.sendAlert(c, DueAlertConstants.ALERT_TYPE_OVERDUE, TODAY); + + verify(applicationEventPublisher, never()).publishEvent(any(Object.class)); + } + +} 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 index afa8bf9..1d26d80 100644 --- 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 @@ -2,6 +2,8 @@ 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.api.notify.dto.NotifyUnreadMessageListReqDTO; +import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageRespDTO; import com.njcn.rdms.module.system.enums.ApiConstants; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -10,6 +12,7 @@ import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import java.util.List; import java.util.Map; /** @@ -42,4 +45,26 @@ public interface NotifyMessageSendApi { .setTemplateCode(templateCode).setTemplateParams(templateParams)).checkError(); } + /** + * 便捷方法:发送带等级的单条站内信给管理后台用户。 + * + * @param level 消息等级,见 {@link com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants} + */ + default void sendSingleNotifyToAdmin(Long userId, String templateCode, + Map templateParams, Integer level) { + sendSingleNotify(new NotifySingleSendReqDTO().setUserId(userId) + .setTemplateCode(templateCode).setTemplateParams(templateParams).setLevel(level)).checkError(); + } + + /** + * 查询一批用户在指定模板下的未读站内信(userType 固定 ADMIN,由实现层处理)。 + * + *

供业务方做"未读不重发"类判定(如临期/逾期告警); + * 与发送同挂在本 API:复用既有 Feign 注册,消息域的跨模块入口保持唯一。

+ */ + @PostMapping(PREFIX + "/unread-list-by-template-codes") + @Operation(summary = "查询一批用户在指定模板下的未读站内信") + CommonResult> getUnreadNotifyMessageListByTemplateCodes( + @Valid @RequestBody NotifyUnreadMessageListReqDTO reqDTO); + } 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 index e32af6f..2c30924 100644 --- 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 @@ -1,5 +1,6 @@ package com.njcn.rdms.module.system.api.notify.dto; +import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -28,4 +29,7 @@ public class NotifySingleSendReqDTO { @Schema(description = "模板参数(占位符 -> 值)", example = "{\"taskName\":\"联调\"}") private Map templateParams; + @Schema(description = "消息等级:1普通/2提醒/3警告/4严重,默认普通", example = "1") + private Integer level = NotifyMessageLevelConstants.NORMAL; + } diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifyUnreadMessageListReqDTO.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifyUnreadMessageListReqDTO.java new file mode 100644 index 0000000..da117a1 --- /dev/null +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifyUnreadMessageListReqDTO.java @@ -0,0 +1,32 @@ +package com.njcn.rdms.module.system.api.notify.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; + +/** + * 查询一批用户在指定模板下的未读站内信 Request DTO。 + * + *

业务方按模板维度查未读(如临期/逾期告警的"未读不重发"判定), + * userType 在能力层固定 ADMIN,不暴露给业务方(与发送口径一致)。

+ * + * @author hongawen + */ +@Schema(description = "RPC 服务 - 查询未读站内信 Request DTO") +@Data +@Accessors(chain = true) +public class NotifyUnreadMessageListReqDTO { + + @Schema(description = "接收用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1024,2048]") + @NotEmpty(message = "用户编号列表不能为空") + private List userIds; + + @Schema(description = "站内信模板编码列表", requiredMode = Schema.RequiredMode.REQUIRED, + example = "[\"due_alert_overdue_owner\"]") + @NotEmpty(message = "模板编码列表不能为空") + private List templateCodes; + +} diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifyUnreadMessageRespDTO.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifyUnreadMessageRespDTO.java new file mode 100644 index 0000000..1c24913 --- /dev/null +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/notify/dto/NotifyUnreadMessageRespDTO.java @@ -0,0 +1,30 @@ +package com.njcn.rdms.module.system.api.notify.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.Map; + +/** + * 未读站内信 Response DTO(按模板查询的精简视图)。 + * + *

只回传业务判定需要的三个字段;正文等展示字段不在跨模块契约里暴露。

+ * + * @author hongawen + */ +@Schema(description = "RPC 服务 - 未读站内信 Response DTO") +@Data +@Accessors(chain = true) +public class NotifyUnreadMessageRespDTO { + + @Schema(description = "接收用户编号", example = "1024") + private Long userId; + + @Schema(description = "模板编码", example = "due_alert_overdue_owner") + private String templateCode; + + @Schema(description = "发送时的模板参数(含业务附加参数,如 objectType/objectId)") + private Map templateParams; + +} diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/notify/NotifyMessageLevelConstants.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/notify/NotifyMessageLevelConstants.java new file mode 100644 index 0000000..804626f --- /dev/null +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/notify/NotifyMessageLevelConstants.java @@ -0,0 +1,26 @@ +package com.njcn.rdms.module.system.enums.notify; + +/** + * 站内信消息等级常量(值 1-4,数字越大越严重)。 + * + *

等级「值」供代码逻辑引用(如告警按场景定级);等级「展示」(名字/颜色) 走字典 + * {@link #DICT_TYPE},运维可调、不发版。本类不做合法集合校验、不做枚举全集 + * (沿用本仓库「DB 动态配置字段不做代码侧校验」习惯)。

+ */ +public final class NotifyMessageLevelConstants { + + private NotifyMessageLevelConstants() { + } + + /** 普通(默认,灰) */ + public static final int NORMAL = 1; + /** 提醒(黄) */ + public static final int REMIND = 2; + /** 警告(橙) */ + public static final int WARN = 3; + /** 严重(红) */ + public static final int SEVERE = 4; + + /** 等级展示字典类型(前端拉取渲染徽标) */ + public static final String DICT_TYPE = "notify_message_level"; +} 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 index b2f45bc..651af70 100644 --- 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 @@ -1,17 +1,26 @@ package com.njcn.rdms.module.system.api.notify; +import com.njcn.rdms.framework.common.enums.UserTypeEnum; import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.framework.common.util.object.BeanUtils; import com.njcn.rdms.module.system.api.notify.dto.NotifySingleSendReqDTO; +import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageListReqDTO; +import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageRespDTO; +import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyMessageDO; +import com.njcn.rdms.module.system.service.notify.NotifyMessageService; 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 java.util.List; + import static com.njcn.rdms.framework.common.pojo.CommonResult.success; /** * {@link NotifyMessageSendApi} 的实现:委托现有 {@link NotifySendService},不重写发送逻辑。 + * 未读查询同理委托 {@link NotifyMessageService},userType 固定 ADMIN(与发送口径一致)。 * * @author hongawen */ @@ -22,12 +31,22 @@ public class NotifyMessageSendApiImpl implements NotifyMessageSendApi { @Resource private NotifySendService notifySendService; + @Resource + private NotifyMessageService notifyMessageService; @Override public CommonResult sendSingleNotify(NotifySingleSendReqDTO reqDTO) { - Long logId = notifySendService.sendSingleNotifyToAdmin( - reqDTO.getUserId(), reqDTO.getTemplateCode(), reqDTO.getTemplateParams()); + Long logId = notifySendService.sendSingleNotify(reqDTO.getUserId(), UserTypeEnum.ADMIN.getValue(), + reqDTO.getTemplateCode(), reqDTO.getTemplateParams(), reqDTO.getLevel()); return success(logId); } + @Override + public CommonResult> getUnreadNotifyMessageListByTemplateCodes( + NotifyUnreadMessageListReqDTO reqDTO) { + List list = notifyMessageService.getUnreadNotifyMessageListByTemplateCodes( + reqDTO.getUserIds(), UserTypeEnum.ADMIN.getValue(), reqDTO.getTemplateCodes()); + return success(BeanUtils.toBean(list, NotifyUnreadMessageRespDTO.class)); + } + } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notify/vo/message/NotifyMessageRespVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notify/vo/message/NotifyMessageRespVO.java index 99a2c96..f39271b 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notify/vo/message/NotifyMessageRespVO.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/notify/vo/message/NotifyMessageRespVO.java @@ -43,6 +43,9 @@ public class NotifyMessageRespVO { @Schema(description = "阅读时间") private LocalDateTime readTime; + @Schema(description = "消息等级:1普通/2提醒/3警告/4严重", example = "1") + private Integer level; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createTime; diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/notify/NotifyMessageDO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/notify/NotifyMessageDO.java index 2f6da1b..1b36466 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/notify/NotifyMessageDO.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/notify/NotifyMessageDO.java @@ -93,5 +93,11 @@ public class NotifyMessageDO extends BaseDO { * 阅读时间 */ private LocalDateTime readTime; + /** + * 消息等级:1普通/2提醒/3警告/4严重(默认普通) + * + * 值见 {@link com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants} + */ + private Integer level; } 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 06b407e..7468537 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 @@ -70,4 +70,15 @@ public interface NotifyMessageMapper extends BaseMapperX { .eq(NotifyMessageDO::getUserType, userType)); } + /** 一批用户在指定模板下的未读消息(调用方保证两个集合非空,IN 空集会报错) */ + default List selectUnreadListByUserIdsAndTemplateCodes(Collection userIds, + Integer userType, + Collection templateCodes) { + return selectList(new LambdaQueryWrapperX() + .eq(NotifyMessageDO::getReadStatus, false) + .eq(NotifyMessageDO::getUserType, userType) + .in(NotifyMessageDO::getUserId, userIds) + .in(NotifyMessageDO::getTemplateCode, templateCodes)); + } + } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifyMessageService.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifyMessageService.java index ecd2b17..e0ddbd6 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifyMessageService.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifyMessageService.java @@ -25,10 +25,12 @@ public interface NotifyMessageService { * @param template 模版信息 * @param templateContent 模版内容 * @param templateParams 模版参数 + * @param level 消息等级(null 视为普通),见 NotifyMessageLevelConstants * @return 站内信编号 */ Long createNotifyMessage(Long userId, Integer userType, - NotifyTemplateDO template, String templateContent, Map templateParams); + NotifyTemplateDO template, String templateContent, + Map templateParams, Integer level); /** * 获得站内信分页 @@ -75,6 +77,17 @@ public interface NotifyMessageService { */ Long getUnreadNotifyMessageCount(Long userId, Integer userType); + /** + * 查询一批用户在指定模板下的未读站内信(业务方"未读不重发"判定用) + * + * @param userIds 用户编号列表 + * @param userType 用户类型 + * @param templateCodes 模板编码列表 + * @return 未读站内信列表(任一入参为空集时返回空列表) + */ + List getUnreadNotifyMessageListByTemplateCodes(Collection userIds, Integer userType, + Collection templateCodes); + /** * 标记站内信为已读 * diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifyMessageServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifyMessageServiceImpl.java index a26edd3..8144eda 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifyMessageServiceImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifyMessageServiceImpl.java @@ -6,6 +6,7 @@ import com.njcn.rdms.module.system.controller.admin.notify.vo.message.NotifyMess import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyMessageDO; import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyTemplateDO; import com.njcn.rdms.module.system.dal.mysql.notify.NotifyMessageMapper; +import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -28,11 +29,13 @@ public class NotifyMessageServiceImpl implements NotifyMessageService { @Override public Long createNotifyMessage(Long userId, Integer userType, - NotifyTemplateDO template, String templateContent, Map templateParams) { + NotifyTemplateDO template, String templateContent, + Map templateParams, Integer level) { NotifyMessageDO message = new NotifyMessageDO().setUserId(userId).setUserType(userType) .setTemplateId(template.getId()).setTemplateCode(template.getCode()) .setTemplateType(template.getType()).setTemplateNickname(template.getNickname()) - .setTemplateContent(templateContent).setTemplateParams(templateParams).setReadStatus(false); + .setTemplateContent(templateContent).setTemplateParams(templateParams).setReadStatus(false) + .setLevel(level == null ? NotifyMessageLevelConstants.NORMAL : level); notifyMessageMapper.insert(message); return message.getId(); } @@ -62,6 +65,16 @@ public class NotifyMessageServiceImpl implements NotifyMessageService { return notifyMessageMapper.selectUnreadCountByUserIdAndUserType(userId, userType); } + @Override + public List getUnreadNotifyMessageListByTemplateCodes(Collection userIds, Integer userType, + Collection templateCodes) { + // 空集守卫:IN 空集合会生成非法 SQL + if (userIds == null || userIds.isEmpty() || templateCodes == null || templateCodes.isEmpty()) { + return List.of(); + } + return notifyMessageMapper.selectUnreadListByUserIdsAndTemplateCodes(userIds, userType, templateCodes); + } + @Override public int updateNotifyMessageRead(Collection ids, Long userId, Integer userType) { return notifyMessageMapper.updateListRead(ids, userId, userType); diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifySendService.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifySendService.java index 4cf05fe..5b90c22 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifySendService.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifySendService.java @@ -1,5 +1,7 @@ package com.njcn.rdms.module.system.service.notify; +import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; + import java.util.List; import java.util.Map; @@ -36,7 +38,7 @@ public interface NotifySendService { String templateCode, Map templateParams); /** - * 发送单条站内信给用户 + * 发送单条站内信给用户(默认普通等级) * * @param userId 用户编号 * @param userType 用户类型 @@ -44,8 +46,24 @@ public interface NotifySendService { * @param templateParams 站内信模板参数 * @return 发送日志编号 */ - Long sendSingleNotify( Long userId, Integer userType, - String templateCode, Map templateParams); + default Long sendSingleNotify(Long userId, Integer userType, + String templateCode, Map templateParams) { + return sendSingleNotify(userId, userType, templateCode, templateParams, + NotifyMessageLevelConstants.NORMAL); + } + + /** + * 发送单条带等级的站内信给用户 + * + * @param userId 用户编号 + * @param userType 用户类型 + * @param templateCode 站内信模板编号 + * @param templateParams 站内信模板参数 + * @param level 消息等级,可为 null(由落库层兜底为普通),见 NotifyMessageLevelConstants + * @return 发送日志编号 + */ + Long sendSingleNotify(Long userId, Integer userType, + String templateCode, Map templateParams, Integer level); default void sendBatchNotify(List mobiles, List userIds, Integer userType, String templateCode, Map templateParams) { diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifySendServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifySendServiceImpl.java index 7bba85c..f3b394c 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifySendServiceImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/notify/NotifySendServiceImpl.java @@ -43,7 +43,8 @@ public class NotifySendServiceImpl implements NotifySendService { } @Override - public Long sendSingleNotify(Long userId, Integer userType, String templateCode, Map templateParams) { + public Long sendSingleNotify(Long userId, Integer userType, String templateCode, + Map templateParams, Integer level) { // 校验模版 NotifyTemplateDO template = validateNotifyTemplate(templateCode); if (Objects.equals(template.getStatus(), CommonStatusEnum.DISABLE.getStatus())) { @@ -55,7 +56,7 @@ public class NotifySendServiceImpl implements NotifySendService { // 发送站内信 String content = notifyTemplateService.formatNotifyTemplateContent(template.getContent(), templateParams); - return notifyMessageService.createNotifyMessage(userId, userType, template, content, templateParams); + return notifyMessageService.createNotifyMessage(userId, userType, template, content, templateParams, level); } @VisibleForTesting diff --git a/rdms-system/rdms-system-boot/src/main/resources/sql/notify_message_level.sql b/rdms-system/rdms-system-boot/src/main/resources/sql/notify_message_level.sql new file mode 100644 index 0000000..4e54358 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/resources/sql/notify_message_level.sql @@ -0,0 +1,10 @@ +-- 站内信消息等级:通用消息表加 level 列(1普通/2提醒/3警告/4严重,默认普通) +-- 设计见 docs/superpowers/specs/2026-06-13-告警消息等级-design.md +-- 幂等:MySQL 不支持 ADD COLUMN IF NOT EXISTS,用 information_schema 判断后 PREPARE 执行 +-- 列定义/注释须与演示库补丁 docs/sql/patches/2026-06-13-告警消息等级-01.sql 块1 保持一致 +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'system_notify_message' AND column_name = 'level'); +SET @ddl = IF(@col_exists = 0, + 'ALTER TABLE system_notify_message ADD COLUMN level TINYINT NOT NULL DEFAULT 1 COMMENT ''消息等级:1普通/2提醒/3警告/4严重'' AFTER read_time', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/notify/NotifyMessageServiceImplTest.java b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/notify/NotifyMessageServiceImplTest.java new file mode 100644 index 0000000..2dace63 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/notify/NotifyMessageServiceImplTest.java @@ -0,0 +1,54 @@ +package com.njcn.rdms.module.system.service.notify; + +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyMessageDO; +import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyTemplateDO; +import com.njcn.rdms.module.system.dal.mysql.notify.NotifyMessageMapper; +import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; + +/** + * 站内信落库单测:消息等级 level 正确落库(指定值 + null 兜底普通)。 + */ +class NotifyMessageServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private NotifyMessageServiceImpl notifyMessageService; + + @Mock + private NotifyMessageMapper notifyMessageMapper; + + private NotifyTemplateDO template() { + NotifyTemplateDO t = new NotifyTemplateDO(); + t.setId(1L); + t.setCode("due_alert_overdue_owner"); + t.setType(3); + t.setNickname("系统通知"); + return t; + } + + @Test + void createNotifyMessage_shouldPersistGivenLevel() { + notifyMessageService.createNotifyMessage(100L, 2, template(), "正文", null, + NotifyMessageLevelConstants.SEVERE); + + ArgumentCaptor captor = ArgumentCaptor.forClass(NotifyMessageDO.class); + verify(notifyMessageMapper).insert(captor.capture()); + assertEquals(NotifyMessageLevelConstants.SEVERE, captor.getValue().getLevel()); + } + + @Test + void createNotifyMessage_nullLevel_shouldFallbackToNormal() { + notifyMessageService.createNotifyMessage(100L, 2, template(), "正文", null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(NotifyMessageDO.class); + verify(notifyMessageMapper).insert(captor.capture()); + assertEquals(NotifyMessageLevelConstants.NORMAL, captor.getValue().getLevel()); + } +}