feat(project): 实现临期逾期告警功能
- 新增告警记录表 rdms_due_alert_record 用于去重控制 - 添加告警相关常量类 DueAlertConstants 和对象类型枚举 - 在各数据访问层增加告警候选查询方法 - 实现告警候选服务类和站内信等级功能 - 添加临期逾期告警模板常量定义 - 扩展站内信发送接口支持消息等级 - 新增未读消息批量查询功能用于重复发送判定
This commit is contained in:
@@ -25,4 +25,10 @@ public interface ProjectDictTypeConstants {
|
|||||||
*/
|
*/
|
||||||
String WORKLOG_DIFFICULTY = "rdms_worklog_difficulty";
|
String WORKLOG_DIFFICULTY = "rdms_worklog_difficulty";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临期告警提前量(天)。value=告警域对象类型 code,label=天数(受 RPC DictDataRespDTO 仅 label/value 限制)。
|
||||||
|
* 缺档或 label 非数字时该对象类型只停临期告警、逾期照发,代码不写默认天数兜底。
|
||||||
|
*/
|
||||||
|
String DUE_ALERT_ADVANCE_DAYS = "rdms_due_alert_advance_days";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.njcn.rdms.module.project.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临期/逾期告警常量。
|
||||||
|
*
|
||||||
|
* <p>设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。</p>
|
||||||
|
*/
|
||||||
|
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";
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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(去重凭证,只增不删)。
|
||||||
|
*
|
||||||
|
* <p>唯一索引 uk_due_alert(object_type, object_id, alert_type, planned_end_date, alert_date)
|
||||||
|
* 兜底并发/多实例同日双插。设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。</p>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<DueAlertRecordDO> {
|
||||||
|
|
||||||
|
/** 查某类对象一批 id 的临期档历史记录(不限日期),调用方按计划日快照内存判重 */
|
||||||
|
default List<DueAlertRecordDO> selectApproachingListByObjectIds(String objectType, Collection<Long> objectIds) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<DueAlertRecordDO>()
|
||||||
|
.eq(DueAlertRecordDO::getObjectType, objectType)
|
||||||
|
.eq(DueAlertRecordDO::getAlertType, DueAlertConstants.ALERT_TYPE_APPROACHING)
|
||||||
|
.in(DueAlertRecordDO::getObjectId, objectIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查某类对象一批 id 当日已发的逾期档记录(同日防重;昨天的记录自然不命中=每天可再发) */
|
||||||
|
default List<DueAlertRecordDO> selectOverdueListByObjectIdsAndAlertDate(String objectType,
|
||||||
|
Collection<Long> objectIds,
|
||||||
|
LocalDate alertDate) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<DueAlertRecordDO>()
|
||||||
|
.eq(DueAlertRecordDO::getObjectType, objectType)
|
||||||
|
.eq(DueAlertRecordDO::getAlertType, DueAlertConstants.ALERT_TYPE_OVERDUE)
|
||||||
|
.eq(DueAlertRecordDO::getAlertDate, alertDate)
|
||||||
|
.in(DueAlertRecordDO::getObjectId, objectIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -114,4 +114,18 @@ public interface PersonalItemMapper extends BaseMapperX<PersonalItemDO> {
|
|||||||
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
|
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
|
||||||
@Param("today") LocalDate today,
|
@Param("today") LocalDate today,
|
||||||
@Param("dueSoonEnd") LocalDate dueSoonEnd);
|
@Param("dueSoonEnd") LocalDate dueSoonEnd);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。
|
||||||
|
* excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。
|
||||||
|
* 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。
|
||||||
|
*/
|
||||||
|
default List<PersonalItemDO> selectDueAlertCandidateList(LocalDate maxPlannedEndDate,
|
||||||
|
Collection<String> excludeStatusCodes) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<PersonalItemDO>()
|
||||||
|
.isNotNull(PersonalItemDO::getPlannedEndDate)
|
||||||
|
.le(PersonalItemDO::getPlannedEndDate, maxPlannedEndDate)
|
||||||
|
.notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(),
|
||||||
|
PersonalItemDO::getStatusCode, excludeStatusCodes));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO;
|
|||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,4 +137,18 @@ public interface ProductRequirementMapper extends BaseMapperX<ProductRequirement
|
|||||||
.eq(ProductRequirementDO::getStatusCode, statusCode));
|
.eq(ProductRequirementDO::getStatusCode, statusCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临期/逾期告警候选:预计完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。
|
||||||
|
* excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。
|
||||||
|
* 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。
|
||||||
|
*/
|
||||||
|
default List<ProductRequirementDO> selectDueAlertCandidateList(LocalDate maxPlannedEndDate,
|
||||||
|
Collection<String> excludeStatusCodes) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<ProductRequirementDO>()
|
||||||
|
.isNotNull(ProductRequirementDO::getExpectedTime)
|
||||||
|
.le(ProductRequirementDO::getExpectedTime, maxPlannedEndDate)
|
||||||
|
.notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(),
|
||||||
|
ProductRequirementDO::getStatusCode, excludeStatusCodes));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Mapper;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -89,4 +90,18 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
|
|||||||
.eq(ProjectDO::getId, id));
|
.eq(ProjectDO::getId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。
|
||||||
|
* excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。
|
||||||
|
* 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。
|
||||||
|
*/
|
||||||
|
default List<ProjectDO> selectDueAlertCandidateList(LocalDate maxPlannedEndDate,
|
||||||
|
Collection<String> excludeStatusCodes) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<ProjectDO>()
|
||||||
|
.isNotNull(ProjectDO::getPlannedEndDate)
|
||||||
|
.le(ProjectDO::getPlannedEndDate, maxPlannedEndDate)
|
||||||
|
.notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(),
|
||||||
|
ProjectDO::getStatusCode, excludeStatusCodes));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO;
|
|||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,4 +107,18 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
|
|||||||
return Math.toIntExact(selectCount(queryWrapper));
|
return Math.toIntExact(selectCount(queryWrapper));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临期/逾期告警候选:预计完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。
|
||||||
|
* excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。
|
||||||
|
* 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。
|
||||||
|
*/
|
||||||
|
default List<ProjectRequirementDO> selectDueAlertCandidateList(LocalDate maxPlannedEndDate,
|
||||||
|
Collection<String> excludeStatusCodes) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<ProjectRequirementDO>()
|
||||||
|
.isNotNull(ProjectRequirementDO::getExpectedTime)
|
||||||
|
.le(ProjectRequirementDO::getExpectedTime, maxPlannedEndDate)
|
||||||
|
.notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(),
|
||||||
|
ProjectRequirementDO::getStatusCode, excludeStatusCodes));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
@@ -59,4 +60,11 @@ public interface ExecutionAssigneeMapper extends BaseMapperX<ExecutionAssigneeDO
|
|||||||
.eq(ExecutionAssigneeDO::getExecutionId, executionId));
|
.eq(ExecutionAssigneeDO::getExecutionId, executionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量查多个执行的生效中协办人(removed_at 为空),告警接收人组装用;调用方保证 executionIds 非空 */
|
||||||
|
default List<ExecutionAssigneeDO> selectActiveListByExecutionIds(Collection<Long> executionIds) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
|
||||||
|
.in(ExecutionAssigneeDO::getExecutionId, executionIds)
|
||||||
|
.isNull(ExecutionAssigneeDO::getRemovedAt));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,4 +354,18 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
|
|||||||
return selectList(queryWrapper);
|
return selectList(queryWrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。
|
||||||
|
* excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。
|
||||||
|
* 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。
|
||||||
|
*/
|
||||||
|
default List<ProjectExecutionDO> selectDueAlertCandidateList(LocalDate maxPlannedEndDate,
|
||||||
|
Collection<String> excludeStatusCodes) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<ProjectExecutionDO>()
|
||||||
|
.isNotNull(ProjectExecutionDO::getPlannedEndDate)
|
||||||
|
.le(ProjectExecutionDO::getPlannedEndDate, maxPlannedEndDate)
|
||||||
|
.notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(),
|
||||||
|
ProjectExecutionDO::getStatusCode, excludeStatusCodes));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -962,4 +962,18 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
|||||||
@Param("today") LocalDate today,
|
@Param("today") LocalDate today,
|
||||||
@Param("dueSoonEnd") LocalDate dueSoonEnd);
|
@Param("dueSoonEnd") LocalDate dueSoonEnd);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临期/逾期告警候选:计划完成日非空且 <= maxPlannedEndDate,状态不在排除集(终态+paused)。
|
||||||
|
* excludeStatusCodes 为空时不排除任何状态(空集守卫,与既有口径一致)。
|
||||||
|
* 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。
|
||||||
|
*/
|
||||||
|
default List<ProjectTaskDO> selectDueAlertCandidateList(LocalDate maxPlannedEndDate,
|
||||||
|
Collection<String> excludeStatusCodes) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
|
||||||
|
.isNotNull(ProjectTaskDO::getPlannedEndDate)
|
||||||
|
.le(ProjectTaskDO::getPlannedEndDate, maxPlannedEndDate)
|
||||||
|
.notIn(excludeStatusCodes != null && !excludeStatusCodes.isEmpty(),
|
||||||
|
ProjectTaskDO::getStatusCode, excludeStatusCodes));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临期/逾期告警的对象类型(告警域六档,代码写死的扫描清单)。
|
||||||
|
*
|
||||||
|
* <p>注意与状态机 object_type 区分:个人事项告警域是 personal_item、
|
||||||
|
* 查终态时映射到状态机的 task({@link PersonalItemConstants#STATUS_OBJECT_TYPE})。
|
||||||
|
* 告警记录表与提前量字典都用告警域 code,避免个人事项与任务互相污染去重记录。</p>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.njcn.rdms.module.project.framework.notify;
|
package com.njcn.rdms.module.project.framework.notify;
|
||||||
|
|
||||||
|
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -20,15 +22,26 @@ public class NotifySendEvent {
|
|||||||
private final String templateCode;
|
private final String templateCode;
|
||||||
/** 模板参数 */
|
/** 模板参数 */
|
||||||
private final Map<String, Object> params;
|
private final Map<String, Object> params;
|
||||||
|
/** 消息等级,见 {@link NotifyMessageLevelConstants}(默认普通) */
|
||||||
|
private final Integer level;
|
||||||
|
|
||||||
private NotifySendEvent(Collection<Long> userIds, String templateCode, Map<String, Object> params) {
|
private NotifySendEvent(Collection<Long> userIds, String templateCode,
|
||||||
|
Map<String, Object> params, Integer level) {
|
||||||
this.userIds = userIds;
|
this.userIds = userIds;
|
||||||
this.templateCode = templateCode;
|
this.templateCode = templateCode;
|
||||||
this.params = params;
|
this.params = params;
|
||||||
|
this.level = level;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 普通等级(兼容存量调用) */
|
||||||
public static NotifySendEvent of(Collection<Long> userIds, String templateCode, Map<String, Object> params) {
|
public static NotifySendEvent of(Collection<Long> userIds, String templateCode, Map<String, Object> params) {
|
||||||
return new NotifySendEvent(userIds, templateCode, params);
|
return new NotifySendEvent(userIds, templateCode, params, NotifyMessageLevelConstants.NORMAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定等级 */
|
||||||
|
public static NotifySendEvent of(Collection<Long> userIds, String templateCode,
|
||||||
|
Map<String, Object> params, Integer level) {
|
||||||
|
return new NotifySendEvent(userIds, templateCode, params, level);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Collection<Long> getUserIds() {
|
public Collection<Long> getUserIds() {
|
||||||
@@ -43,4 +56,8 @@ public class NotifySendEvent {
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Integer getLevel() {
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ public class NotifySendEventListener {
|
|||||||
}
|
}
|
||||||
for (Long userId : targets) {
|
for (Long userId : targets) {
|
||||||
try {
|
try {
|
||||||
notifyMessageSendApi.sendSingleNotifyToAdmin(userId, event.getTemplateCode(), event.getParams());
|
notifyMessageSendApi.sendSingleNotifyToAdmin(userId, event.getTemplateCode(),
|
||||||
|
event.getParams(), event.getLevel());
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
// 通知失败不影响业务:仅告警,继续发其余接收人
|
// 通知失败不影响业务:仅告警,继续发其余接收人
|
||||||
log.warn("[onNotifySend] 站内信发送失败 userId={}, templateCode={}",
|
log.warn("[onNotifySend] 站内信发送失败 userId={}, templateCode={}",
|
||||||
|
|||||||
@@ -13,4 +13,16 @@ public class NotifyTemplateCodeConstants {
|
|||||||
/** 任务指派:创建任务后通知负责人 + 协办人 */
|
/** 任务指派:创建任务后通知负责人 + 协办人 */
|
||||||
public static final String TASK_ASSIGNED = "task_assigned";
|
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";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 模块定时任务启用配置。
|
||||||
|
*
|
||||||
|
* <p>业务侧首个 @Scheduled 任务(DueAlertScanJob)需要调度开关;
|
||||||
|
* 不依赖 mq starter 消费端配置里的 @EnableScheduling(那是框架内部 job 的,且按条件装配)。</p>
|
||||||
|
*/
|
||||||
|
@Configuration(value = "projectScheduleConfiguration", proxyBeanMethods = false)
|
||||||
|
@EnableScheduling
|
||||||
|
public class ScheduleConfiguration {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临期/逾期告警每日扫描任务(业务侧首个定时任务)。
|
||||||
|
*
|
||||||
|
* <p>默认每日 05:00(用户定稿),可经配置项 rdms.due-alert.scan-cron 覆盖,不改环境配置文件。
|
||||||
|
* 逾期每天一条 / 临期窗口一次的去重在 service 层,重复触发幂等。
|
||||||
|
* 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。</p>
|
||||||
|
*/
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<Long> ownerUserIds;
|
||||||
|
|
||||||
|
/** 协办人组:仅任务/执行,已剔除与负责人组重叠者(收协办人版弱化模板) */
|
||||||
|
private List<Long> assigneeUserIds;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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} 上。
|
||||||
|
*
|
||||||
|
* <p>每类对象一次范围查询(计划日 <= maxDate、排除终态+paused),内存里按
|
||||||
|
* 「计划日 < today = 逾期 / >= today = 临期」分类,再按记录表两档判重。
|
||||||
|
* 逾期档叠加"未读不重发":接收人上一条逾期提醒未读则本轮对其跳过,已读后次日恢复。
|
||||||
|
* 设计见 docs/domains/project/2026-06-12-临期逾期告警-设计.html。</p>
|
||||||
|
*/
|
||||||
|
@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<String, Integer> 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<String, Integer> loadAdvanceDays() {
|
||||||
|
Map<String, Integer> result = new HashMap<>();
|
||||||
|
try {
|
||||||
|
CommonResult<List<DictDataRespDTO>> dictResult =
|
||||||
|
dictDataApi.getDictDataList(ProjectDictTypeConstants.DUE_ALERT_ADVANCE_DAYS);
|
||||||
|
List<DictDataRespDTO> 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<String> excludeStatusCodes = new ArrayList<>(
|
||||||
|
objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(type.getStatusObjectType()));
|
||||||
|
excludeStatusCodes.add(DueAlertConstants.STATUS_PAUSED);
|
||||||
|
// 缺档时 maxDate=昨天:候选只剩逾期,临期档自然停发
|
||||||
|
LocalDate maxDate = advanceDays != null ? today.plusDays(advanceDays) : today.minusDays(1);
|
||||||
|
|
||||||
|
List<DueAlertCandidate> candidates = loadCandidates(type, maxDate, excludeStatusCodes);
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<DueAlertCandidate> approachingList = candidates.stream()
|
||||||
|
.filter(c -> !c.getPlannedEndDate().isBefore(today))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
List<DueAlertCandidate> 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<DueAlertCandidate> list, LocalDate today) {
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
List<Long> ids = list.stream().map(DueAlertCandidate::getObjectId).collect(Collectors.toList());
|
||||||
|
Set<String> 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<DueAlertCandidate> list, LocalDate today) {
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
List<Long> ids = list.stream().map(DueAlertCandidate::getObjectId).collect(Collectors.toList());
|
||||||
|
Set<Long> sentTodayIds = dueAlertRecordMapper
|
||||||
|
.selectOverdueListByObjectIdsAndAlertDate(type.getCode(), ids, today).stream()
|
||||||
|
.map(DueAlertRecordDO::getObjectId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Set<String> 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<String> loadUnreadOverdueKeys(DueAlertObjectTypeEnum type, List<DueAlertCandidate> list) {
|
||||||
|
Set<Long> 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<List<NotifyUnreadMessageRespDTO>> result =
|
||||||
|
notifyMessageSendApi.getUnreadNotifyMessageListByTemplateCodes(
|
||||||
|
new NotifyUnreadMessageListReqDTO()
|
||||||
|
.setUserIds(new ArrayList<>(userIds))
|
||||||
|
.setTemplateCodes(List.of(NotifyTemplateCodeConstants.DUE_ALERT_OVERDUE_OWNER,
|
||||||
|
NotifyTemplateCodeConstants.DUE_ALERT_OVERDUE_ASSIGNEE)));
|
||||||
|
List<NotifyUnreadMessageRespDTO> messages = result == null ? null : result.getCheckedData();
|
||||||
|
if (messages == null) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
Set<String> keys = new HashSet<>();
|
||||||
|
for (NotifyUnreadMessageRespDTO message : messages) {
|
||||||
|
Map<String, Object> 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<String> 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<Long> excludeUnreadUsers(List<Long> userIds, String objectIdText, Set<String> 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<DueAlertCandidate> loadCandidates(DueAlertObjectTypeEnum type, LocalDate maxDate,
|
||||||
|
List<String> 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<DueAlertCandidate> toTaskCandidates(List<ProjectTaskDO> tasks) {
|
||||||
|
if (tasks.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<Long> taskIds = tasks.stream().map(ProjectTaskDO::getId).collect(Collectors.toList());
|
||||||
|
Map<Long, List<Long>> 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<DueAlertCandidate> toExecutionCandidates(List<ProjectExecutionDO> executions) {
|
||||||
|
if (executions.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<Long> executionIds = executions.stream().map(ProjectExecutionDO::getId).collect(Collectors.toList());
|
||||||
|
Map<Long, List<Long>> 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<Long> owners, List<Long> 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<Long> singletonOrEmpty(Long userId) {
|
||||||
|
return userId == null ? List.of() : List.of(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 协办人剔除负责人(双重身份只收负责人版)+ 去重去 null */
|
||||||
|
private List<Long> excludeOwner(List<Long> 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.njcn.rdms.module.project.service.duealert;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警发送服务:插去重记录 + publish 站内信事件。
|
||||||
|
*
|
||||||
|
* <p>独立于扫描编排的 bean——@Transactional 必须跨 bean 调用才走代理,
|
||||||
|
* 同 bean 自调用会绕过事务,导致 NotifySendEvent 静默不发(事务红线)。</p>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<String, Object> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 = '临期/逾期告警发送记录(去重凭证,只增不删)';
|
||||||
@@ -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.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi;
|
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.junit.jupiter.api.Test;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
@@ -31,22 +32,22 @@ class NotifySendEventListenerTest extends BaseMockitoUnitTest {
|
|||||||
void testOnNotifySend_dedupAndSend() {
|
void testOnNotifySend_dedupAndSend() {
|
||||||
// 1L 重复两次 + 2L:去重后只发 1L、2L 各一次
|
// 1L 重复两次 + 2L:去重后只发 1L、2L 各一次
|
||||||
listener.onNotifySend(NotifySendEvent.of(Arrays.asList(1L, 1L, 2L), "task_assigned", new HashMap<>()));
|
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(1L), eq("task_assigned"), any(), eq(NotifyMessageLevelConstants.NORMAL));
|
||||||
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), eq("task_assigned"), any());
|
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), eq("task_assigned"), any(), eq(NotifyMessageLevelConstants.NORMAL));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testOnNotifySend_emptyRecipients_noSend() {
|
void testOnNotifySend_emptyRecipients_noSend() {
|
||||||
listener.onNotifySend(NotifySendEvent.of(Collections.emptyList(), "task_assigned", new HashMap<>()));
|
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
|
@Test
|
||||||
void testOnNotifySend_singleFailure_doesNotInterrupt() {
|
void testOnNotifySend_singleFailure_doesNotInterrupt() {
|
||||||
// 第一个人发送抛异常,第二个人仍应被发送(兜底不中断)
|
// 第一个人发送抛异常,第二个人仍应被发送(兜底不中断)
|
||||||
doThrow(new RuntimeException("boom")).when(notifyMessageSendApi)
|
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<>()));
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Collection<String>> 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<DueAlertCandidate> candidateCaptor = ArgumentCaptor.forClass(DueAlertCandidate.class);
|
||||||
|
ArgumentCaptor<String> 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<DueAlertCandidate> 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<DueAlertCandidate> 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<DueAlertCandidate> 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<DueAlertCandidate> 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) 已隐含)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<Long> owners, List<Long> 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<DueAlertRecordDO> 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<NotifySendEvent> eventCaptor = ArgumentCaptor.forClass(NotifySendEvent.class);
|
||||||
|
verify(applicationEventPublisher, times(2)).publishEvent(eventCaptor.capture());
|
||||||
|
List<NotifySendEvent> 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<NotifySendEvent> 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<NotifySendEvent> eventCaptor = ArgumentCaptor.forClass(NotifySendEvent.class);
|
||||||
|
verify(applicationEventPublisher, times(2)).publishEvent(eventCaptor.capture());
|
||||||
|
List<NotifySendEvent> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.njcn.rdms.module.system.api.notify;
|
|||||||
|
|
||||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
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.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 com.njcn.rdms.module.system.enums.ApiConstants;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,4 +45,26 @@ public interface NotifyMessageSendApi {
|
|||||||
.setTemplateCode(templateCode).setTemplateParams(templateParams)).checkError();
|
.setTemplateCode(templateCode).setTemplateParams(templateParams)).checkError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 便捷方法:发送带等级的单条站内信给管理后台用户。
|
||||||
|
*
|
||||||
|
* @param level 消息等级,见 {@link com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants}
|
||||||
|
*/
|
||||||
|
default void sendSingleNotifyToAdmin(Long userId, String templateCode,
|
||||||
|
Map<String, Object> templateParams, Integer level) {
|
||||||
|
sendSingleNotify(new NotifySingleSendReqDTO().setUserId(userId)
|
||||||
|
.setTemplateCode(templateCode).setTemplateParams(templateParams).setLevel(level)).checkError();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询一批用户在指定模板下的未读站内信(userType 固定 ADMIN,由实现层处理)。
|
||||||
|
*
|
||||||
|
* <p>供业务方做"未读不重发"类判定(如临期/逾期告警);
|
||||||
|
* 与发送同挂在本 API:复用既有 Feign 注册,消息域的跨模块入口保持唯一。</p>
|
||||||
|
*/
|
||||||
|
@PostMapping(PREFIX + "/unread-list-by-template-codes")
|
||||||
|
@Operation(summary = "查询一批用户在指定模板下的未读站内信")
|
||||||
|
CommonResult<List<NotifyUnreadMessageRespDTO>> getUnreadNotifyMessageListByTemplateCodes(
|
||||||
|
@Valid @RequestBody NotifyUnreadMessageListReqDTO reqDTO);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.njcn.rdms.module.system.api.notify.dto;
|
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 io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -28,4 +29,7 @@ public class NotifySingleSendReqDTO {
|
|||||||
@Schema(description = "模板参数(占位符 -> 值)", example = "{\"taskName\":\"联调\"}")
|
@Schema(description = "模板参数(占位符 -> 值)", example = "{\"taskName\":\"联调\"}")
|
||||||
private Map<String, Object> templateParams;
|
private Map<String, Object> templateParams;
|
||||||
|
|
||||||
|
@Schema(description = "消息等级:1普通/2提醒/3警告/4严重,默认普通", example = "1")
|
||||||
|
private Integer level = NotifyMessageLevelConstants.NORMAL;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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。
|
||||||
|
*
|
||||||
|
* <p>业务方按模板维度查未读(如临期/逾期告警的"未读不重发"判定),
|
||||||
|
* userType 在能力层固定 ADMIN,不暴露给业务方(与发送口径一致)。</p>
|
||||||
|
*
|
||||||
|
* @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<Long> userIds;
|
||||||
|
|
||||||
|
@Schema(description = "站内信模板编码列表", requiredMode = Schema.RequiredMode.REQUIRED,
|
||||||
|
example = "[\"due_alert_overdue_owner\"]")
|
||||||
|
@NotEmpty(message = "模板编码列表不能为空")
|
||||||
|
private List<String> templateCodes;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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(按模板查询的精简视图)。
|
||||||
|
*
|
||||||
|
* <p>只回传业务判定需要的三个字段;正文等展示字段不在跨模块契约里暴露。</p>
|
||||||
|
*
|
||||||
|
* @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<String, Object> templateParams;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.njcn.rdms.module.system.enums.notify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站内信消息等级常量(值 1-4,数字越大越严重)。
|
||||||
|
*
|
||||||
|
* <p>等级「值」供代码逻辑引用(如告警按场景定级);等级「展示」(名字/颜色) 走字典
|
||||||
|
* {@link #DICT_TYPE},运维可调、不发版。本类不做合法集合校验、不做枚举全集
|
||||||
|
* (沿用本仓库「DB 动态配置字段不做代码侧校验」习惯)。</p>
|
||||||
|
*/
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
package com.njcn.rdms.module.system.api.notify;
|
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.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.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 com.njcn.rdms.module.system.service.notify.NotifySendService;
|
||||||
import io.swagger.v3.oas.annotations.Hidden;
|
import io.swagger.v3.oas.annotations.Hidden;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link NotifyMessageSendApi} 的实现:委托现有 {@link NotifySendService},不重写发送逻辑。
|
* {@link NotifyMessageSendApi} 的实现:委托现有 {@link NotifySendService},不重写发送逻辑。
|
||||||
|
* 未读查询同理委托 {@link NotifyMessageService},userType 固定 ADMIN(与发送口径一致)。
|
||||||
*
|
*
|
||||||
* @author hongawen
|
* @author hongawen
|
||||||
*/
|
*/
|
||||||
@@ -22,12 +31,22 @@ public class NotifyMessageSendApiImpl implements NotifyMessageSendApi {
|
|||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private NotifySendService notifySendService;
|
private NotifySendService notifySendService;
|
||||||
|
@Resource
|
||||||
|
private NotifyMessageService notifyMessageService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommonResult<Long> sendSingleNotify(NotifySingleSendReqDTO reqDTO) {
|
public CommonResult<Long> sendSingleNotify(NotifySingleSendReqDTO reqDTO) {
|
||||||
Long logId = notifySendService.sendSingleNotifyToAdmin(
|
Long logId = notifySendService.sendSingleNotify(reqDTO.getUserId(), UserTypeEnum.ADMIN.getValue(),
|
||||||
reqDTO.getUserId(), reqDTO.getTemplateCode(), reqDTO.getTemplateParams());
|
reqDTO.getTemplateCode(), reqDTO.getTemplateParams(), reqDTO.getLevel());
|
||||||
return success(logId);
|
return success(logId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<List<NotifyUnreadMessageRespDTO>> getUnreadNotifyMessageListByTemplateCodes(
|
||||||
|
NotifyUnreadMessageListReqDTO reqDTO) {
|
||||||
|
List<NotifyMessageDO> list = notifyMessageService.getUnreadNotifyMessageListByTemplateCodes(
|
||||||
|
reqDTO.getUserIds(), UserTypeEnum.ADMIN.getValue(), reqDTO.getTemplateCodes());
|
||||||
|
return success(BeanUtils.toBean(list, NotifyUnreadMessageRespDTO.class));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ public class NotifyMessageRespVO {
|
|||||||
@Schema(description = "阅读时间")
|
@Schema(description = "阅读时间")
|
||||||
private LocalDateTime readTime;
|
private LocalDateTime readTime;
|
||||||
|
|
||||||
|
@Schema(description = "消息等级:1普通/2提醒/3警告/4严重", example = "1")
|
||||||
|
private Integer level;
|
||||||
|
|
||||||
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@@ -93,5 +93,11 @@ public class NotifyMessageDO extends BaseDO {
|
|||||||
* 阅读时间
|
* 阅读时间
|
||||||
*/
|
*/
|
||||||
private LocalDateTime readTime;
|
private LocalDateTime readTime;
|
||||||
|
/**
|
||||||
|
* 消息等级:1普通/2提醒/3警告/4严重(默认普通)
|
||||||
|
*
|
||||||
|
* 值见 {@link com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants}
|
||||||
|
*/
|
||||||
|
private Integer level;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,4 +70,15 @@ public interface NotifyMessageMapper extends BaseMapperX<NotifyMessageDO> {
|
|||||||
.eq(NotifyMessageDO::getUserType, userType));
|
.eq(NotifyMessageDO::getUserType, userType));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一批用户在指定模板下的未读消息(调用方保证两个集合非空,IN 空集会报错) */
|
||||||
|
default List<NotifyMessageDO> selectUnreadListByUserIdsAndTemplateCodes(Collection<Long> userIds,
|
||||||
|
Integer userType,
|
||||||
|
Collection<String> templateCodes) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<NotifyMessageDO>()
|
||||||
|
.eq(NotifyMessageDO::getReadStatus, false)
|
||||||
|
.eq(NotifyMessageDO::getUserType, userType)
|
||||||
|
.in(NotifyMessageDO::getUserId, userIds)
|
||||||
|
.in(NotifyMessageDO::getTemplateCode, templateCodes));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ public interface NotifyMessageService {
|
|||||||
* @param template 模版信息
|
* @param template 模版信息
|
||||||
* @param templateContent 模版内容
|
* @param templateContent 模版内容
|
||||||
* @param templateParams 模版参数
|
* @param templateParams 模版参数
|
||||||
|
* @param level 消息等级(null 视为普通),见 NotifyMessageLevelConstants
|
||||||
* @return 站内信编号
|
* @return 站内信编号
|
||||||
*/
|
*/
|
||||||
Long createNotifyMessage(Long userId, Integer userType,
|
Long createNotifyMessage(Long userId, Integer userType,
|
||||||
NotifyTemplateDO template, String templateContent, Map<String, Object> templateParams);
|
NotifyTemplateDO template, String templateContent,
|
||||||
|
Map<String, Object> templateParams, Integer level);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获得站内信分页
|
* 获得站内信分页
|
||||||
@@ -75,6 +77,17 @@ public interface NotifyMessageService {
|
|||||||
*/
|
*/
|
||||||
Long getUnreadNotifyMessageCount(Long userId, Integer userType);
|
Long getUnreadNotifyMessageCount(Long userId, Integer userType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询一批用户在指定模板下的未读站内信(业务方"未读不重发"判定用)
|
||||||
|
*
|
||||||
|
* @param userIds 用户编号列表
|
||||||
|
* @param userType 用户类型
|
||||||
|
* @param templateCodes 模板编码列表
|
||||||
|
* @return 未读站内信列表(任一入参为空集时返回空列表)
|
||||||
|
*/
|
||||||
|
List<NotifyMessageDO> getUnreadNotifyMessageListByTemplateCodes(Collection<Long> userIds, Integer userType,
|
||||||
|
Collection<String> templateCodes);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记站内信为已读
|
* 标记站内信为已读
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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.NotifyMessageDO;
|
||||||
import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyTemplateDO;
|
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.dal.mysql.notify.NotifyMessageMapper;
|
||||||
|
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
@@ -28,11 +29,13 @@ public class NotifyMessageServiceImpl implements NotifyMessageService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Long createNotifyMessage(Long userId, Integer userType,
|
public Long createNotifyMessage(Long userId, Integer userType,
|
||||||
NotifyTemplateDO template, String templateContent, Map<String, Object> templateParams) {
|
NotifyTemplateDO template, String templateContent,
|
||||||
|
Map<String, Object> templateParams, Integer level) {
|
||||||
NotifyMessageDO message = new NotifyMessageDO().setUserId(userId).setUserType(userType)
|
NotifyMessageDO message = new NotifyMessageDO().setUserId(userId).setUserType(userType)
|
||||||
.setTemplateId(template.getId()).setTemplateCode(template.getCode())
|
.setTemplateId(template.getId()).setTemplateCode(template.getCode())
|
||||||
.setTemplateType(template.getType()).setTemplateNickname(template.getNickname())
|
.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);
|
notifyMessageMapper.insert(message);
|
||||||
return message.getId();
|
return message.getId();
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,16 @@ public class NotifyMessageServiceImpl implements NotifyMessageService {
|
|||||||
return notifyMessageMapper.selectUnreadCountByUserIdAndUserType(userId, userType);
|
return notifyMessageMapper.selectUnreadCountByUserIdAndUserType(userId, userType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<NotifyMessageDO> getUnreadNotifyMessageListByTemplateCodes(Collection<Long> userIds, Integer userType,
|
||||||
|
Collection<String> templateCodes) {
|
||||||
|
// 空集守卫:IN 空集合会生成非法 SQL
|
||||||
|
if (userIds == null || userIds.isEmpty() || templateCodes == null || templateCodes.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return notifyMessageMapper.selectUnreadListByUserIdsAndTemplateCodes(userIds, userType, templateCodes);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int updateNotifyMessageRead(Collection<Long> ids, Long userId, Integer userType) {
|
public int updateNotifyMessageRead(Collection<Long> ids, Long userId, Integer userType) {
|
||||||
return notifyMessageMapper.updateListRead(ids, userId, userType);
|
return notifyMessageMapper.updateListRead(ids, userId, userType);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.njcn.rdms.module.system.service.notify;
|
package com.njcn.rdms.module.system.service.notify;
|
||||||
|
|
||||||
|
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ public interface NotifySendService {
|
|||||||
String templateCode, Map<String, Object> templateParams);
|
String templateCode, Map<String, Object> templateParams);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送单条站内信给用户
|
* 发送单条站内信给用户(默认普通等级)
|
||||||
*
|
*
|
||||||
* @param userId 用户编号
|
* @param userId 用户编号
|
||||||
* @param userType 用户类型
|
* @param userType 用户类型
|
||||||
@@ -44,8 +46,24 @@ public interface NotifySendService {
|
|||||||
* @param templateParams 站内信模板参数
|
* @param templateParams 站内信模板参数
|
||||||
* @return 发送日志编号
|
* @return 发送日志编号
|
||||||
*/
|
*/
|
||||||
|
default Long sendSingleNotify(Long userId, Integer userType,
|
||||||
|
String templateCode, Map<String, Object> 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,
|
Long sendSingleNotify(Long userId, Integer userType,
|
||||||
String templateCode, Map<String, Object> templateParams);
|
String templateCode, Map<String, Object> templateParams, Integer level);
|
||||||
|
|
||||||
default void sendBatchNotify(List<String> mobiles, List<Long> userIds, Integer userType,
|
default void sendBatchNotify(List<String> mobiles, List<Long> userIds, Integer userType,
|
||||||
String templateCode, Map<String, Object> templateParams) {
|
String templateCode, Map<String, Object> templateParams) {
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ public class NotifySendServiceImpl implements NotifySendService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Long sendSingleNotify(Long userId, Integer userType, String templateCode, Map<String, Object> templateParams) {
|
public Long sendSingleNotify(Long userId, Integer userType, String templateCode,
|
||||||
|
Map<String, Object> templateParams, Integer level) {
|
||||||
// 校验模版
|
// 校验模版
|
||||||
NotifyTemplateDO template = validateNotifyTemplate(templateCode);
|
NotifyTemplateDO template = validateNotifyTemplate(templateCode);
|
||||||
if (Objects.equals(template.getStatus(), CommonStatusEnum.DISABLE.getStatus())) {
|
if (Objects.equals(template.getStatus(), CommonStatusEnum.DISABLE.getStatus())) {
|
||||||
@@ -55,7 +56,7 @@ public class NotifySendServiceImpl implements NotifySendService {
|
|||||||
|
|
||||||
// 发送站内信
|
// 发送站内信
|
||||||
String content = notifyTemplateService.formatNotifyTemplateContent(template.getContent(), templateParams);
|
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
|
@VisibleForTesting
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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<NotifyMessageDO> 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<NotifyMessageDO> captor = ArgumentCaptor.forClass(NotifyMessageDO.class);
|
||||||
|
verify(notifyMessageMapper).insert(captor.capture());
|
||||||
|
assertEquals(NotifyMessageLevelConstants.NORMAL, captor.getValue().getLevel());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user