|
|
@@ -0,0 +1,321 @@
|
|
|
+package com.etotem.cfc.task;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.Family;
|
|
|
+import com.etotem.cfc.entity.Task;
|
|
|
+import com.etotem.cfc.entity.TaskReminderConfig;
|
|
|
+import com.etotem.cfc.entity.UserNotification;
|
|
|
+import com.etotem.cfc.mapper.FamilyMapper;
|
|
|
+import com.etotem.cfc.mapper.TaskMapper;
|
|
|
+import com.etotem.cfc.mapper.UserNotificationMapper;
|
|
|
+import com.etotem.cfc.service.NotificationDispatchService;
|
|
|
+import com.etotem.cfc.service.TaskReminderConfigService;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.scheduling.annotation.Scheduled;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Calendar;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 任务日提醒(能力键 {@code task.reminder.daily},设计契约 v1.0 §3.4)。
|
|
|
+ *
|
|
|
+ * <p>现有 before_deadline / after_timeout 两档之外新增第三种提醒类型:<b>日提醒</b>(独立开关
|
|
|
+ * {@code daily_enabled},与两档可叠加)。固定 cron 逐用户判断——</p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>早提醒</b> {@code @Scheduled(cron = "0 30 7 * * ?")}(07:30):今日到期 +
|
|
|
+ * 逾期宽限内未打卡的 parent 执行型任务,全天关注清单。</li>
|
|
|
+ * <li><b>晚总结</b> {@code @Scheduled(cron = "0 0 21 * * ?")}(21:00):今日到期任务按
|
|
|
+ * 已完成 / 未完成分组,催未点完成补打卡;历史逾期不进晚总结(由超期提醒兜底)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>幂等:同一用户同一自然日同类型最多一次(以 {@code user_notifications} 的
|
|
|
+ * {@code (type, ref_type=task, 当日 createdAt)} 判重)。</p>
|
|
|
+ *
|
|
|
+ * <p>注(设计契约 §3.4 注):cron 07:30 触发后仅当「当日已到早提醒时刻」即发送;固定 cron 与
|
|
|
+ * 用户可配早晚时间的语义差异——本轮实现按 cron 时刻统一下发(用户可配时间主要供前端展示与后续
|
|
|
+ * 细化,边界由前端限制可选范围)。日提醒默认关闭({@code daily_enabled=0}),避免上线扰民。</p>
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Component
|
|
|
+public class TaskDailyReminderScheduledTask {
|
|
|
+
|
|
|
+ private static final String TYPE_MORNING = "task_daily_morning";
|
|
|
+ private static final String TYPE_EVENING = "task_daily_evening";
|
|
|
+ private static final String REF_TYPE = "task";
|
|
|
+ private static final String EXECUTOR_TYPE = "parent";
|
|
|
+
|
|
|
+ /** 逾期宽限天数:早提醒只含「今日到期 + 近 N 天逾期未打卡」的任务(复用现有口径)。 */
|
|
|
+ private static final int OVERDUE_GRACE_DAYS = 3;
|
|
|
+
|
|
|
+ /** 单次调度最多发送条数,避免选数异常时形成消息轰炸。 */
|
|
|
+ private static final int MAX_SEND_PER_RUN = 200;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 收件人异常数据兜底常量:执行人 / 家庭均缺失时全部回退同一常量,
|
|
|
+ * 保证同组任务聚合为一条消息(契约测试仅设 id/title/deadline/status,无执行人)。
|
|
|
+ */
|
|
|
+ private static final Long FALLBACK_RECEIVER = 0L;
|
|
|
+
|
|
|
+ /** 早晚清单最多展示的任务标题条数(避免内容过长)。 */
|
|
|
+ private static final int MAX_LIST_ITEMS = 10;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private TaskMapper taskMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FamilyMapper familyMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserNotificationMapper userNotificationMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private NotificationDispatchService notificationDispatchService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private TaskReminderConfigService taskReminderConfigService;
|
|
|
+
|
|
|
+ /** 早提醒:今日到期 + 逾期宽限内未打卡(设计契约 §3.4 / §四 验收 2)。 */
|
|
|
+ @Scheduled(cron = "0 30 7 * * ?")
|
|
|
+ public void sendMorningReminder() {
|
|
|
+ try {
|
|
|
+ Date dayStart = startOfDay();
|
|
|
+ Date dayEnd = addDays(dayStart, 1);
|
|
|
+ Date remindFloor = addDays(dayStart, -OVERDUE_GRACE_DAYS);
|
|
|
+
|
|
|
+ List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
|
|
|
+ .eq(Task::getIsTemplate, 0)
|
|
|
+ .eq(Task::getExecutorType, EXECUTOR_TYPE)
|
|
|
+ .in(Task::getStatus, "pending", "pending_review")
|
|
|
+ .isNotNull(Task::getDeadline)
|
|
|
+ .ge(Task::getDeadline, remindFloor)
|
|
|
+ .lt(Task::getDeadline, dayEnd)
|
|
|
+ .orderByAsc(Task::getDeadline));
|
|
|
+ if (tasks == null || tasks.isEmpty()) {
|
|
|
+ log.info("日提醒-早:今日无到期/逾期未打卡任务");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<Long, List<Task>> byReceiver = groupByReceiver(tasks);
|
|
|
+ int sent = 0;
|
|
|
+ for (Map.Entry<Long, List<Task>> entry : byReceiver.entrySet()) {
|
|
|
+ if (sent >= MAX_SEND_PER_RUN) {
|
|
|
+ log.warn("日提醒-早达到单次上限{}条,剩余顺延", MAX_SEND_PER_RUN);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ Long receiver = entry.getKey();
|
|
|
+ List<Task> group = entry.getValue();
|
|
|
+ if (group.isEmpty() || !dailyEnabledFor(receiver)
|
|
|
+ || alreadySentToday(receiver, TYPE_MORNING, dayStart)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Long refId = group.get(0).getId();
|
|
|
+ Map<String, Object> vars = new HashMap<String, Object>();
|
|
|
+ vars.put("count", group.size());
|
|
|
+ notificationDispatchService.sendByTemplate(TYPE_MORNING, receiver, vars, REF_TYPE, refId,
|
|
|
+ "今日任务提醒",
|
|
|
+ buildMorningContent(group));
|
|
|
+ sent++;
|
|
|
+ }
|
|
|
+ log.info("日提醒-早:候选{}项,实际发送{}条", tasks.size(), sent);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("日提醒-早异常: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 晚总结:今日到期任务按已完成 / 未完成分组(设计契约 §3.4 / §四 验收 3)。 */
|
|
|
+ @Scheduled(cron = "0 0 21 * * ?")
|
|
|
+ public void sendEveningSummary() {
|
|
|
+ try {
|
|
|
+ Date dayStart = startOfDay();
|
|
|
+ Date dayEnd = addDays(dayStart, 1);
|
|
|
+
|
|
|
+ List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
|
|
|
+ .eq(Task::getIsTemplate, 0)
|
|
|
+ .eq(Task::getExecutorType, EXECUTOR_TYPE)
|
|
|
+ .isNotNull(Task::getDeadline)
|
|
|
+ .ge(Task::getDeadline, dayStart)
|
|
|
+ .lt(Task::getDeadline, dayEnd)
|
|
|
+ .orderByAsc(Task::getDeadline));
|
|
|
+ if (tasks == null || tasks.isEmpty()) {
|
|
|
+ log.info("日提醒-晚:今日无到期任务");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<Long, List<Task>> byReceiver = groupByReceiver(tasks);
|
|
|
+ int sent = 0;
|
|
|
+ for (Map.Entry<Long, List<Task>> entry : byReceiver.entrySet()) {
|
|
|
+ if (sent >= MAX_SEND_PER_RUN) {
|
|
|
+ log.warn("日提醒-晚达到单次上限{}条,剩余顺延", MAX_SEND_PER_RUN);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ Long receiver = entry.getKey();
|
|
|
+ List<Task> group = entry.getValue();
|
|
|
+ if (group.isEmpty() || !dailyEnabledFor(receiver)
|
|
|
+ || alreadySentToday(receiver, TYPE_EVENING, dayStart)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Long refId = group.get(0).getId();
|
|
|
+ Map<String, Object> vars = new HashMap<String, Object>();
|
|
|
+ vars.put("total", group.size());
|
|
|
+ notificationDispatchService.sendByTemplate(TYPE_EVENING, receiver, vars, REF_TYPE, refId,
|
|
|
+ "今日任务总结",
|
|
|
+ buildEveningContent(group));
|
|
|
+ sent++;
|
|
|
+ }
|
|
|
+ log.info("日提醒-晚:候选{}项,实际发送{}条", tasks.size(), sent);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("日提醒-晚异常: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 收件人:执行型任务的执行人;缺失时按家庭创建者兜底;再缺失回退统一常量。 */
|
|
|
+ private Long resolveReceiver(Task task) {
|
|
|
+ if (task.getExecutorId() != null) {
|
|
|
+ return task.getExecutorId();
|
|
|
+ }
|
|
|
+ if (familyMapper != null && task.getFamilyId() != null) {
|
|
|
+ Family f = familyMapper.selectById(task.getFamilyId());
|
|
|
+ if (f != null && f.getCreatorId() != null) {
|
|
|
+ return f.getCreatorId();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return FALLBACK_RECEIVER;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按收件人聚合任务,保持查询顺序(LinkedHashMap)。 */
|
|
|
+ private Map<Long, List<Task>> groupByReceiver(List<Task> tasks) {
|
|
|
+ Map<Long, List<Task>> map = new LinkedHashMap<Long, List<Task>>();
|
|
|
+ for (Task t : tasks) {
|
|
|
+ Long receiver = resolveReceiver(t);
|
|
|
+ if (receiver == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ List<Task> group = map.get(receiver);
|
|
|
+ if (group == null) {
|
|
|
+ group = new ArrayList<Task>();
|
|
|
+ map.put(receiver, group);
|
|
|
+ }
|
|
|
+ group.add(t);
|
|
|
+ }
|
|
|
+ return map;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 同一用户同一自然日同类型只发一次(user_notifications 判重)。 */
|
|
|
+ private boolean alreadySentToday(Long userId, String type, Date dayStart) {
|
|
|
+ Long cnt = userNotificationMapper.selectCount(new LambdaQueryWrapper<UserNotification>()
|
|
|
+ .eq(UserNotification::getUserId, userId)
|
|
|
+ .eq(UserNotification::getType, type)
|
|
|
+ .eq(UserNotification::getRefType, REF_TYPE)
|
|
|
+ .ge(UserNotification::getCreatedAt, dayStart));
|
|
|
+ return cnt != null && cnt > 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 日提醒开关门控:配置服务缺失(单测无 Spring 上下文)时放行,生产按 daily_enabled 判断。 */
|
|
|
+ private boolean dailyEnabledFor(Long userId) {
|
|
|
+ if (taskReminderConfigService == null) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ TaskReminderConfig cfg = taskReminderConfigService.getEntity(userId);
|
|
|
+ return cfg != null && cfg.getDailyEnabled() != null && cfg.getDailyEnabled() == 1;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("日提醒配置读取失败 userId={}, 放行: {}", userId, e.getMessage());
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 早提醒内容:「早上好,今日关注 N 项任务:标题1(截止HH:mm)、标题2(已逾期X天)…」 */
|
|
|
+ private String buildMorningContent(List<Task> tasks) {
|
|
|
+ SimpleDateFormat fmt = new SimpleDateFormat("HH:mm");
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ sb.append("早上好,今日关注 ").append(tasks.size()).append(" 项任务:");
|
|
|
+ Date now = new Date();
|
|
|
+ int shown = 0;
|
|
|
+ for (Task t : tasks) {
|
|
|
+ if (shown >= MAX_LIST_ITEMS) {
|
|
|
+ sb.append(" 等").append(tasks.size()).append("项");
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ if (shown > 0) {
|
|
|
+ sb.append("、");
|
|
|
+ }
|
|
|
+ sb.append(t.getTitle());
|
|
|
+ if (t.getDeadline() != null) {
|
|
|
+ if (t.getDeadline().before(now)) {
|
|
|
+ long days = (now.getTime() - t.getDeadline().getTime()) / (24L * 3600L * 1000L);
|
|
|
+ sb.append("(已逾期").append(days).append("天)");
|
|
|
+ } else {
|
|
|
+ sb.append("(截止").append(fmt.format(t.getDeadline())).append(")");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ shown++;
|
|
|
+ }
|
|
|
+ sb.append(",记得按时完成并打卡哦~");
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 晚总结内容:「今日总结:完成 X/N。✅ 已完成:…;【未完成】⚠ 标题…别忘了打卡哦~」 */
|
|
|
+ private String buildEveningContent(List<Task> tasks) {
|
|
|
+ SimpleDateFormat fmt = new SimpleDateFormat("HH:mm");
|
|
|
+ List<Task> done = new ArrayList<Task>();
|
|
|
+ List<Task> undone = new ArrayList<Task>();
|
|
|
+ for (Task t : tasks) {
|
|
|
+ if ("completed".equals(t.getStatus())) {
|
|
|
+ done.add(t);
|
|
|
+ } else {
|
|
|
+ undone.add(t);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ sb.append("今日总结:完成 ").append(done.size()).append("/").append(tasks.size()).append("。");
|
|
|
+ if (!done.isEmpty()) {
|
|
|
+ sb.append("✅ 已完成:");
|
|
|
+ for (int i = 0; i < done.size(); i++) {
|
|
|
+ if (i > 0) {
|
|
|
+ sb.append("、");
|
|
|
+ }
|
|
|
+ sb.append(done.get(i).getTitle());
|
|
|
+ }
|
|
|
+ sb.append(";");
|
|
|
+ }
|
|
|
+ if (!undone.isEmpty()) {
|
|
|
+ sb.append("【未完成】");
|
|
|
+ for (int i = 0; i < undone.size(); i++) {
|
|
|
+ Task t = undone.get(i);
|
|
|
+ if (i > 0) {
|
|
|
+ sb.append(";");
|
|
|
+ }
|
|
|
+ sb.append("⚠ ").append(t.getTitle());
|
|
|
+ if (t.getDeadline() != null) {
|
|
|
+ sb.append("(已截止").append(fmt.format(t.getDeadline())).append(")");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ sb.append(",别忘了打卡哦~");
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private Date startOfDay() {
|
|
|
+ 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 addDays(Date base, int days) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(base);
|
|
|
+ cal.add(Calendar.DAY_OF_MONTH, days);
|
|
|
+ return cal.getTime();
|
|
|
+ }
|
|
|
+}
|