|
|
@@ -2,42 +2,55 @@ package com.etotem.cfc.task;
|
|
|
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
import com.etotem.cfc.config.DatabaseInitializer;
|
|
|
+import com.etotem.cfc.dto.NotificationMessage;
|
|
|
import com.etotem.cfc.entity.Task;
|
|
|
+import com.etotem.cfc.entity.TaskReminderConfig;
|
|
|
import com.etotem.cfc.entity.User;
|
|
|
import com.etotem.cfc.entity.UserNotification;
|
|
|
import com.etotem.cfc.mapper.TaskMapper;
|
|
|
import com.etotem.cfc.mapper.UserMapper;
|
|
|
import com.etotem.cfc.mapper.UserNotificationMapper;
|
|
|
-import com.etotem.cfc.service.WechatWaitMsgService;
|
|
|
+import com.etotem.cfc.service.TaskReminderConfigService;
|
|
|
+import com.etotem.cfc.service.notification.NotificationChannel;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.scheduling.annotation.Scheduled;
|
|
|
import org.springframework.stereotype.Component;
|
|
|
|
|
|
import javax.annotation.Resource;
|
|
|
+import java.util.ArrayList;
|
|
|
import java.util.Calendar;
|
|
|
import java.util.Date;
|
|
|
import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
|
|
|
/**
|
|
|
- * 每日任务打卡提醒(webhook):提醒时间(任务 deadline)过 1 小时仍未打卡 → 微信待办消息推送。
|
|
|
+ * 任务打卡提醒调度器(可配置提醒模块)
|
|
|
* <p>
|
|
|
- * 扫描范围:今日到期的「家长执行型」待打卡任务(executor_type=parent,status=pending),
|
|
|
- * deadline 已超过 now-60min(提醒时间 +1 小时宽限)且当天未推送过 → 调 WechatWaitMsgService 推送。
|
|
|
- * 幂等:以 user_notifications (type=task_checkin_webhook, ref_type=task, ref_id=任务ID, 当日) 判重,
|
|
|
- * 同一任务同一自然日最多推送一次;webhook 失败不写标记,下一分钟自动重试。
|
|
|
+ * 每分钟扫描「今日到期的家长执行型待打卡任务」,按用户提醒配置(task_reminder_config)触发:
|
|
|
+ * <ul>
|
|
|
+ * <li>before_deadline:截止时间前 offset 分钟提醒"即将到期"</li>
|
|
|
+ * <li>after_timeout:截止时间过后 offset 分钟仍未打卡提醒"已超时"</li>
|
|
|
+ * </ul>
|
|
|
+ * 渠道按配置多选分发(wechat=微信待办 webhook / sms=短信 / phone=语音 / email=邮件),
|
|
|
+ * 站内信(in_app)始终兜底写入并作幂等标记;未配置的用户走默认(微信+超时60分钟)。
|
|
|
+ * 同一任务同一自然日只提醒一次(兼容旧 type=task_checkin_webhook 记录,避免重复推送)。
|
|
|
*/
|
|
|
@Slf4j
|
|
|
@Component
|
|
|
public class TaskCheckinWebhookReminderTask {
|
|
|
|
|
|
- /** 宽限分钟数:提醒时间(deadline)过后多久未打卡才推送 */
|
|
|
- private static final long GRACE_MINUTES = 60L;
|
|
|
+ /** 站内信/幂等标记通知类型(旧版为 task_checkin_webhook,一并纳入判重) */
|
|
|
+ private static final String NOTIFY_TYPE = "task_checkin_remind";
|
|
|
+ private static final String LEGACY_NOTIFY_TYPE = "task_checkin_webhook";
|
|
|
+ private static final String REF_TYPE = "task";
|
|
|
|
|
|
- /** 单次调度最多处理条数,避免选数异常时形成消息轰炸 */
|
|
|
- private static final int MAX_PROCESS_PER_RUN = 20;
|
|
|
+ /** 默认配置:微信 + 超时后60分钟(与线上行为一致) */
|
|
|
+ private static final String DEFAULT_CHANNELS = "wechat";
|
|
|
+ private static final String DEFAULT_TIMING = "after_timeout";
|
|
|
+ private static final int DEFAULT_OFFSET_MINUTES = 60;
|
|
|
|
|
|
- private static final String NOTIFY_TYPE = "task_checkin_webhook";
|
|
|
- private static final String REF_TYPE = "task";
|
|
|
+ /** 单次调度最多处理条数,避免异常时消息轰炸 */
|
|
|
+ private static final int MAX_PROCESS_PER_RUN = 200;
|
|
|
|
|
|
@Resource
|
|
|
private TaskMapper taskMapper;
|
|
|
@@ -49,7 +62,10 @@ public class TaskCheckinWebhookReminderTask {
|
|
|
private UserNotificationMapper userNotificationMapper;
|
|
|
|
|
|
@Resource
|
|
|
- private WechatWaitMsgService wechatWaitMsgService;
|
|
|
+ private TaskReminderConfigService taskReminderConfigService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private List<NotificationChannel> notificationChannels;
|
|
|
|
|
|
/** 每分钟执行一次 */
|
|
|
@Scheduled(cron = "0 * * * * ?")
|
|
|
@@ -60,54 +76,113 @@ public class TaskCheckinWebhookReminderTask {
|
|
|
}
|
|
|
try {
|
|
|
Date now = new Date();
|
|
|
- Calendar cal = Calendar.getInstance();
|
|
|
- cal.set(Calendar.HOUR_OF_DAY, 0);
|
|
|
- cal.set(Calendar.MINUTE, 0);
|
|
|
- cal.set(Calendar.SECOND, 0);
|
|
|
- cal.set(Calendar.MILLISECOND, 0);
|
|
|
- Date dayStart = cal.getTime();
|
|
|
+ Date dayStart = startOfToday();
|
|
|
|
|
|
- Date threshold = new Date(now.getTime() - GRACE_MINUTES * 60_000L);
|
|
|
+ Map<Long, TaskReminderConfig> configs = taskReminderConfigService.loadAllConfigs();
|
|
|
|
|
|
- // 今日到期 + 已过提醒时间1小时 + 仍未打卡(pending)的家长执行型任务
|
|
|
List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
|
|
|
.eq(Task::getIsTemplate, 0)
|
|
|
.eq(Task::getExecutorType, "parent")
|
|
|
.eq(Task::getStatus, "pending")
|
|
|
.ge(Task::getDeadline, dayStart)
|
|
|
- .lt(Task::getDeadline, threshold)
|
|
|
+ .lt(Task::getDeadline, endOfToday())
|
|
|
.orderByAsc(Task::getDeadline)
|
|
|
.last("LIMIT " + MAX_PROCESS_PER_RUN));
|
|
|
if (tasks == null || tasks.isEmpty()) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- int sent = 0;
|
|
|
+ int reminded = 0;
|
|
|
for (Task task : tasks) {
|
|
|
try {
|
|
|
if (task.getExecutorId() == null) {
|
|
|
continue;
|
|
|
}
|
|
|
- if (alreadySentToday(task.getExecutorId(), task.getId(), dayStart)) {
|
|
|
+ Long userId = task.getExecutorId();
|
|
|
+ TaskReminderConfig config = configs.getOrDefault(userId, null);
|
|
|
+ if (config != null && Integer.valueOf(0).equals(config.getEnabled())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ String timing = config != null ? config.getTimingMode() : DEFAULT_TIMING;
|
|
|
+ int offset = config != null && config.getOffsetMinutes() != null
|
|
|
+ ? config.getOffsetMinutes() : DEFAULT_OFFSET_MINUTES;
|
|
|
+ String channels = config != null && config.getChannels() != null && !config.getChannels().isEmpty()
|
|
|
+ ? "in_app," + config.getChannels() : "in_app," + DEFAULT_CHANNELS;
|
|
|
+
|
|
|
+ Date deadline = task.getDeadline();
|
|
|
+ if (deadline == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String reminderPhase = null;
|
|
|
+ if ("before_deadline".equals(timing)) {
|
|
|
+ // 截止前 offset 分钟提醒(已过截止时间则不再做提前提醒)
|
|
|
+ if (deadline.after(now) && !deadline.after(new Date(now.getTime() + offset * 60_000L))) {
|
|
|
+ reminderPhase = "before";
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 超时 offset 分钟后提醒(今日到期、仍未打卡)
|
|
|
+ if (!deadline.after(new Date(now.getTime() - offset * 60_000L))) {
|
|
|
+ reminderPhase = "after";
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (reminderPhase == null) {
|
|
|
continue;
|
|
|
}
|
|
|
- boolean ok = wechatWaitMsgService.sendWaitMsg(buildContent(task));
|
|
|
- if (ok) {
|
|
|
- markSent(task.getExecutorId(), task, now);
|
|
|
- sent++;
|
|
|
+ if (alreadySentToday(userId, task.getId(), dayStart)) {
|
|
|
+ continue;
|
|
|
}
|
|
|
+
|
|
|
+ String content = buildContent(task, reminderPhase, offset, timing);
|
|
|
+ NotificationMessage msg = new NotificationMessage(userId, NOTIFY_TYPE,
|
|
|
+ "任务打卡提醒", content, REF_TYPE, task.getId(), channels);
|
|
|
+ dispatch(msg);
|
|
|
+ reminded++;
|
|
|
} catch (Exception e) {
|
|
|
- log.warn("打卡提醒推送失败 taskId={}, error={}", task.getId(), e.getMessage());
|
|
|
+ log.warn("打卡提醒失败 taskId={}, error={}", task.getId(), e.getMessage());
|
|
|
}
|
|
|
}
|
|
|
- log.info("打卡提醒完成:候选{}条,推送成功{}条", tasks.size(), sent);
|
|
|
+ log.info("打卡提醒完成:候选{}条,实际提醒{}条", tasks.size(), reminded);
|
|
|
} catch (Exception e) {
|
|
|
log.warn("打卡提醒任务异常: {}", e.getMessage());
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /** 生成推送内容:问候用户昵称 + 任务标题(含说明摘要) */
|
|
|
- private String buildContent(Task task) {
|
|
|
+ /** 按 channels 分发:in_app 恒在首位兜底;外部通道未启用/未接通时跳过并不阻断 */
|
|
|
+ private void dispatch(NotificationMessage msg) {
|
|
|
+ List<String> targets = new ArrayList<>();
|
|
|
+ for (String ch : msg.getChannels().split(",")) {
|
|
|
+ String c = ch.trim();
|
|
|
+ if (!c.isEmpty() && !targets.contains(c)) {
|
|
|
+ targets.add(c);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (String target : targets) {
|
|
|
+ NotificationChannel matched = null;
|
|
|
+ for (NotificationChannel channel : notificationChannels) {
|
|
|
+ if (target.equals(channel.getChannelType())) {
|
|
|
+ matched = channel;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (matched == null) {
|
|
|
+ log.warn("渠道不存在: {},跳过", target);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (!matched.isEnabled()) {
|
|
|
+ log.info("渠道未启用: {},跳过", target);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ matched.send(msg);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("渠道发送异常: {} userId={}, error={}", target, msg.getUserId(), e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 组装提醒文案(before=即将到期 / after=已超时) */
|
|
|
+ private String buildContent(Task task, String phase, int offsetMinutes, String timing) {
|
|
|
String name = "您";
|
|
|
try {
|
|
|
if (task.getExecutorId() != null) {
|
|
|
@@ -117,44 +192,58 @@ public class TaskCheckinWebhookReminderTask {
|
|
|
}
|
|
|
}
|
|
|
} catch (Exception e) {
|
|
|
- // 昵称查询失败不阻塞推送
|
|
|
+ // 昵称查询失败不阻塞提醒
|
|
|
}
|
|
|
StringBuilder sb = new StringBuilder();
|
|
|
- sb.append("「").append(name).append("」,您的健康任务已超过提醒时间 1 小时还没打卡:")
|
|
|
- .append(task.getTitle());
|
|
|
- if (task.getDescription() != null && !task.getDescription().isEmpty()) {
|
|
|
- String desc = task.getDescription();
|
|
|
- if (desc.length() > 60) {
|
|
|
- desc = desc.substring(0, 60) + "…";
|
|
|
+ sb.append("「").append(name).append("」,您的健康任务「").append(task.getTitle()).append("」");
|
|
|
+ if ("before".equals(phase)) {
|
|
|
+ sb.append("将于").append(formatTime(task.getDeadline())).append("前截止,记得按时完成打卡哦~");
|
|
|
+ } else {
|
|
|
+ sb.append("已超过截止时间 ").append(offsetMinutes).append(" 分钟还没打卡");
|
|
|
+ if (task.getDescription() != null && !task.getDescription().isEmpty()) {
|
|
|
+ String desc = task.getDescription();
|
|
|
+ if (desc.length() > 40) {
|
|
|
+ desc = desc.substring(0, 40) + "…";
|
|
|
+ }
|
|
|
+ sb.append("(").append(desc).append(")");
|
|
|
}
|
|
|
- sb.append("(").append(desc).append(")");
|
|
|
+ sb.append("。记得及时完成哦~");
|
|
|
}
|
|
|
- sb.append("。记得及时完成打卡哦~");
|
|
|
return sb.toString();
|
|
|
}
|
|
|
|
|
|
- /** 同一任务同一自然日只推送一次 */
|
|
|
+ private String formatTime(Date date) {
|
|
|
+ java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("HH:mm");
|
|
|
+ return sdf.format(date);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 同一任务同一自然日只提醒一次(含旧版 webhook 类型,避免升级后当天重复推送) */
|
|
|
private boolean alreadySentToday(Long userId, Long taskId, Date dayStart) {
|
|
|
Long cnt = userNotificationMapper.selectCount(new LambdaQueryWrapper<UserNotification>()
|
|
|
.eq(UserNotification::getUserId, userId)
|
|
|
- .eq(UserNotification::getType, NOTIFY_TYPE)
|
|
|
+ .in(UserNotification::getType, NOTIFY_TYPE, LEGACY_NOTIFY_TYPE)
|
|
|
.eq(UserNotification::getRefType, REF_TYPE)
|
|
|
.eq(UserNotification::getRefId, taskId)
|
|
|
.ge(UserNotification::getCreatedAt, dayStart));
|
|
|
return cnt != null && cnt > 0;
|
|
|
}
|
|
|
|
|
|
- /** 推送成功落标记(同时作为站内通知展示给用户) */
|
|
|
- private void markSent(Long userId, Task task, Date now) {
|
|
|
- UserNotification n = new UserNotification();
|
|
|
- n.setUserId(userId);
|
|
|
- n.setType(NOTIFY_TYPE);
|
|
|
- n.setTitle("任务打卡提醒");
|
|
|
- n.setContent("「" + task.getTitle() + "」已超过提醒时间 1 小时未打卡,记得完成哦~");
|
|
|
- n.setRefType(REF_TYPE);
|
|
|
- n.setRefId(task.getId());
|
|
|
- n.setIsRead(0);
|
|
|
- n.setCreatedAt(now);
|
|
|
- userNotificationMapper.insert(n);
|
|
|
+ private Date startOfToday() {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.set(Calendar.HOUR_OF_DAY, 0);
|
|
|
+ cal.set(Calendar.MINUTE, 0);
|
|
|
+ cal.set(Calendar.SECOND, 0);
|
|
|
+ cal.set(Calendar.MILLISECOND, 0);
|
|
|
+ return cal.getTime();
|
|
|
+ }
|
|
|
+
|
|
|
+ private Date endOfToday() {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.set(Calendar.HOUR_OF_DAY, 0);
|
|
|
+ cal.set(Calendar.MINUTE, 0);
|
|
|
+ cal.set(Calendar.SECOND, 0);
|
|
|
+ cal.set(Calendar.MILLISECOND, 0);
|
|
|
+ cal.add(Calendar.DAY_OF_MONTH, 1);
|
|
|
+ return cal.getTime();
|
|
|
}
|
|
|
}
|