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());
+ }
+}