feat(project): 实现临期逾期告警功能

- 新增告警记录表 rdms_due_alert_record 用于去重控制
- 添加告警相关常量类 DueAlertConstants 和对象类型枚举
- 在各数据访问层增加告警候选查询方法
- 实现告警候选服务类和站内信等级功能
- 添加临期逾期告警模板常量定义
- 扩展站内信发送接口支持消息等级
- 新增未读消息批量查询功能用于重复发送判定
This commit is contained in:
2026-06-13 15:00:36 +08:00
parent 635c18767e
commit 896ef0f127
41 changed files with 1650 additions and 18 deletions

View File

@@ -25,4 +25,10 @@ public interface ProjectDictTypeConstants {
*/
String WORKLOG_DIFFICULTY = "rdms_worklog_difficulty";
/**
* 临期告警提前量。value=告警域对象类型 codelabel=天数(受 RPC DictDataRespDTO 仅 label/value 限制)。
* 缺档或 label 非数字时该对象类型只停临期告警、逾期照发,代码不写默认天数兜底。
*/
String DUE_ALERT_ADVANCE_DAYS = "rdms_due_alert_advance_days";
}

View File

@@ -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";
}

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -114,4 +114,18 @@ public interface PersonalItemMapper extends BaseMapperX<PersonalItemDO> {
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
@Param("today") LocalDate today,
@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));
}
}

View File

@@ -8,6 +8,8 @@ import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
/**
@@ -135,4 +137,18 @@ public interface ProductRequirementMapper extends BaseMapperX<ProductRequirement
.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));
}
}

View File

@@ -7,6 +7,7 @@ import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -89,4 +90,18 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
.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));
}
}

View File

@@ -8,6 +8,8 @@ import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
/**
@@ -105,4 +107,18 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
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));
}
}

View File

@@ -5,6 +5,7 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
@Mapper
@@ -59,4 +60,11 @@ public interface ExecutionAssigneeMapper extends BaseMapperX<ExecutionAssigneeDO
.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));
}
}

View File

@@ -354,4 +354,18 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
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));
}
}

View File

@@ -962,4 +962,18 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
@Param("today") LocalDate today,
@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));
}
}

View File

@@ -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;
}
}

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.project.framework.notify;
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
import java.util.Collection;
import java.util.Map;
@@ -20,15 +22,26 @@ public class NotifySendEvent {
private final String templateCode;
/** 模板参数 */
private final Map<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.templateCode = templateCode;
this.params = params;
this.level = level;
}
/** 普通等级(兼容存量调用) */
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() {
@@ -43,4 +56,8 @@ public class NotifySendEvent {
return params;
}
public Integer getLevel() {
return level;
}
}

View File

@@ -43,7 +43,8 @@ public class NotifySendEventListener {
}
for (Long userId : targets) {
try {
notifyMessageSendApi.sendSingleNotifyToAdmin(userId, event.getTemplateCode(), event.getParams());
notifyMessageSendApi.sendSingleNotifyToAdmin(userId, event.getTemplateCode(),
event.getParams(), event.getLevel());
} catch (Exception ex) {
// 通知失败不影响业务:仅告警,继续发其余接收人
log.warn("[onNotifySend] 站内信发送失败 userId={}, templateCode={}",

View File

@@ -13,4 +13,16 @@ public class NotifyTemplateCodeConstants {
/** 任务指派:创建任务后通知负责人 + 协办人 */
public static final String TASK_ASSIGNED = "task_assigned";
/** 临期提醒-负责人:对象进入临期窗口时通知负责人(项目负责人/owner/需求处理人→提出人) */
public static final String DUE_ALERT_APPROACHING_OWNER = "due_alert_approaching_owner";
/** 临期提醒-协办人:等级低一等的弱化文案(仅任务/执行有协办人) */
public static final String DUE_ALERT_APPROACHING_ASSIGNEE = "due_alert_approaching_assignee";
/** 逾期提醒-负责人:每天一条持续提醒,直到终态或暂停 */
public static final String DUE_ALERT_OVERDUE_OWNER = "due_alert_overdue_owner";
/** 逾期提醒-协办人:等级低一等的弱化文案 */
public static final String DUE_ALERT_OVERDUE_ASSIGNEE = "due_alert_overdue_assignee";
}

View File

@@ -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 {
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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>每类对象一次范围查询(计划日 &lt;= maxDate、排除终态+paused内存里按
* 「计划日 &lt; today = 逾期 / &gt;= 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=对象类型 codelabel=天数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());
}
}

View File

@@ -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);
}

View File

@@ -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));
}
}
}

View File

@@ -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 = '临期/逾期告警发送记录(去重凭证,只增不删)';

View File

@@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.framework.notify;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi;
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
@@ -31,22 +32,22 @@ class NotifySendEventListenerTest extends BaseMockitoUnitTest {
void testOnNotifySend_dedupAndSend() {
// 1L 重复两次 + 2L去重后只发 1L、2L 各一次
listener.onNotifySend(NotifySendEvent.of(Arrays.asList(1L, 1L, 2L), "task_assigned", new HashMap<>()));
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(1L), eq("task_assigned"), any());
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), eq("task_assigned"), any());
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(1L), eq("task_assigned"), any(), eq(NotifyMessageLevelConstants.NORMAL));
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), eq("task_assigned"), any(), eq(NotifyMessageLevelConstants.NORMAL));
}
@Test
void testOnNotifySend_emptyRecipients_noSend() {
listener.onNotifySend(NotifySendEvent.of(Collections.emptyList(), "task_assigned", new HashMap<>()));
verify(notifyMessageSendApi, times(0)).sendSingleNotifyToAdmin(any(), any(), any());
verify(notifyMessageSendApi, times(0)).sendSingleNotifyToAdmin(any(), any(), any(), any());
}
@Test
void testOnNotifySend_singleFailure_doesNotInterrupt() {
// 第一个人发送抛异常,第二个人仍应被发送(兜底不中断)
doThrow(new RuntimeException("boom")).when(notifyMessageSendApi)
.sendSingleNotifyToAdmin(eq(1L), any(), any());
.sendSingleNotifyToAdmin(eq(1L), any(), any(), any());
listener.onNotifySend(NotifySendEvent.of(Arrays.asList(1L, 2L), "task_assigned", new HashMap<>()));
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), any(), any());
verify(notifyMessageSendApi, times(1)).sendSingleNotifyToAdmin(eq(2L), any(), any(), any());
}
}

View File

@@ -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) 已隐含)
}
}

View File

@@ -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));
}
}

View File

@@ -2,6 +2,8 @@ package com.njcn.rdms.module.system.api.notify;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.api.notify.dto.NotifySingleSendReqDTO;
import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageListReqDTO;
import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageRespDTO;
import com.njcn.rdms.module.system.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -10,6 +12,7 @@ import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import java.util.List;
import java.util.Map;
/**
@@ -42,4 +45,26 @@ public interface NotifyMessageSendApi {
.setTemplateCode(templateCode).setTemplateParams(templateParams)).checkError();
}
/**
* 便捷方法:发送带等级的单条站内信给管理后台用户。
*
* @param level 消息等级,见 {@link com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants}
*/
default void sendSingleNotifyToAdmin(Long userId, String templateCode,
Map<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);
}

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.system.api.notify.dto;
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@@ -28,4 +29,7 @@ public class NotifySingleSendReqDTO {
@Schema(description = "模板参数(占位符 -> 值)", example = "{\"taskName\":\"联调\"}")
private Map<String, Object> templateParams;
@Schema(description = "消息等级1普通/2提醒/3警告/4严重默认普通", example = "1")
private Integer level = NotifyMessageLevelConstants.NORMAL;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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";
}

View File

@@ -1,17 +1,26 @@
package com.njcn.rdms.module.system.api.notify;
import com.njcn.rdms.framework.common.enums.UserTypeEnum;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.system.api.notify.dto.NotifySingleSendReqDTO;
import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageListReqDTO;
import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageRespDTO;
import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyMessageDO;
import com.njcn.rdms.module.system.service.notify.NotifyMessageService;
import com.njcn.rdms.module.system.service.notify.NotifySendService;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
/**
* {@link NotifyMessageSendApi} 的实现:委托现有 {@link NotifySendService},不重写发送逻辑。
* 未读查询同理委托 {@link NotifyMessageService}userType 固定 ADMIN与发送口径一致
*
* @author hongawen
*/
@@ -22,12 +31,22 @@ public class NotifyMessageSendApiImpl implements NotifyMessageSendApi {
@Resource
private NotifySendService notifySendService;
@Resource
private NotifyMessageService notifyMessageService;
@Override
public CommonResult<Long> sendSingleNotify(NotifySingleSendReqDTO reqDTO) {
Long logId = notifySendService.sendSingleNotifyToAdmin(
reqDTO.getUserId(), reqDTO.getTemplateCode(), reqDTO.getTemplateParams());
Long logId = notifySendService.sendSingleNotify(reqDTO.getUserId(), UserTypeEnum.ADMIN.getValue(),
reqDTO.getTemplateCode(), reqDTO.getTemplateParams(), reqDTO.getLevel());
return success(logId);
}
@Override
public CommonResult<List<NotifyUnreadMessageRespDTO>> getUnreadNotifyMessageListByTemplateCodes(
NotifyUnreadMessageListReqDTO reqDTO) {
List<NotifyMessageDO> list = notifyMessageService.getUnreadNotifyMessageListByTemplateCodes(
reqDTO.getUserIds(), UserTypeEnum.ADMIN.getValue(), reqDTO.getTemplateCodes());
return success(BeanUtils.toBean(list, NotifyUnreadMessageRespDTO.class));
}
}

View File

@@ -43,6 +43,9 @@ public class NotifyMessageRespVO {
@Schema(description = "阅读时间")
private LocalDateTime readTime;
@Schema(description = "消息等级1普通/2提醒/3警告/4严重", example = "1")
private Integer level;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;

View File

@@ -93,5 +93,11 @@ public class NotifyMessageDO extends BaseDO {
* 阅读时间
*/
private LocalDateTime readTime;
/**
* 消息等级1普通/2提醒/3警告/4严重默认普通
*
* 值见 {@link com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants}
*/
private Integer level;
}

View File

@@ -70,4 +70,15 @@ public interface NotifyMessageMapper extends BaseMapperX<NotifyMessageDO> {
.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));
}
}

View File

@@ -25,10 +25,12 @@ public interface NotifyMessageService {
* @param template 模版信息
* @param templateContent 模版内容
* @param templateParams 模版参数
* @param level 消息等级null 视为普通),见 NotifyMessageLevelConstants
* @return 站内信编号
*/
Long createNotifyMessage(Long userId, Integer userType,
NotifyTemplateDO template, String templateContent, Map<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);
/**
* 查询一批用户在指定模板下的未读站内信(业务方"未读不重发"判定用)
*
* @param userIds 用户编号列表
* @param userType 用户类型
* @param templateCodes 模板编码列表
* @return 未读站内信列表(任一入参为空集时返回空列表)
*/
List<NotifyMessageDO> getUnreadNotifyMessageListByTemplateCodes(Collection<Long> userIds, Integer userType,
Collection<String> templateCodes);
/**
* 标记站内信为已读
*

View File

@@ -6,6 +6,7 @@ import com.njcn.rdms.module.system.controller.admin.notify.vo.message.NotifyMess
import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyMessageDO;
import com.njcn.rdms.module.system.dal.dataobject.notify.NotifyTemplateDO;
import com.njcn.rdms.module.system.dal.mysql.notify.NotifyMessageMapper;
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
@@ -28,11 +29,13 @@ public class NotifyMessageServiceImpl implements NotifyMessageService {
@Override
public Long createNotifyMessage(Long userId, Integer userType,
NotifyTemplateDO template, String templateContent, Map<String, Object> templateParams) {
NotifyTemplateDO template, String templateContent,
Map<String, Object> templateParams, Integer level) {
NotifyMessageDO message = new NotifyMessageDO().setUserId(userId).setUserType(userType)
.setTemplateId(template.getId()).setTemplateCode(template.getCode())
.setTemplateType(template.getType()).setTemplateNickname(template.getNickname())
.setTemplateContent(templateContent).setTemplateParams(templateParams).setReadStatus(false);
.setTemplateContent(templateContent).setTemplateParams(templateParams).setReadStatus(false)
.setLevel(level == null ? NotifyMessageLevelConstants.NORMAL : level);
notifyMessageMapper.insert(message);
return message.getId();
}
@@ -62,6 +65,16 @@ public class NotifyMessageServiceImpl implements NotifyMessageService {
return notifyMessageMapper.selectUnreadCountByUserIdAndUserType(userId, userType);
}
@Override
public List<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
public int updateNotifyMessageRead(Collection<Long> ids, Long userId, Integer userType) {
return notifyMessageMapper.updateListRead(ids, userId, userType);

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.system.service.notify;
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
import java.util.List;
import java.util.Map;
@@ -36,7 +38,7 @@ public interface NotifySendService {
String templateCode, Map<String, Object> templateParams);
/**
* 发送单条站内信给用户
* 发送单条站内信给用户(默认普通等级)
*
* @param userId 用户编号
* @param userType 用户类型
@@ -44,8 +46,24 @@ public interface NotifySendService {
* @param templateParams 站内信模板参数
* @return 发送日志编号
*/
Long sendSingleNotify( Long userId, Integer userType,
String templateCode, Map<String, Object> templateParams);
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,
String templateCode, Map<String, Object> templateParams, Integer level);
default void sendBatchNotify(List<String> mobiles, List<Long> userIds, Integer userType,
String templateCode, Map<String, Object> templateParams) {

View File

@@ -43,7 +43,8 @@ public class NotifySendServiceImpl implements NotifySendService {
}
@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);
if (Objects.equals(template.getStatus(), CommonStatusEnum.DISABLE.getStatus())) {
@@ -55,7 +56,7 @@ public class NotifySendServiceImpl implements NotifySendService {
// 发送站内信
String content = notifyTemplateService.formatNotifyTemplateContent(template.getContent(), templateParams);
return notifyMessageService.createNotifyMessage(userId, userType, template, content, templateParams);
return notifyMessageService.createNotifyMessage(userId, userType, template, content, templateParams, level);
}
@VisibleForTesting

View File

@@ -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;

View File

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