|
|
@@ -0,0 +1,320 @@
|
|
|
+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},S-cfc-348)。
|
|
|
+ *
|
|
|
+ * <p>设计契约 {@code docs/tdd/orchestration/task-daily-reminder-design-2026-11-14.md} §3.4:
|
|
|
+ * 在既有「提前提醒(before_deadline)/ 超时提醒(after_timeout)」两档之外,新增独立可叠加的日提醒:
|
|
|
+ * <ul>
|
|
|
+ * <li><b>早提醒</b>({@code @Scheduled cron "0 30 7 * * ?"},07:30):提醒用户全天关注——
|
|
|
+ * 今日到期 + 已逾期宽限内({@code OVERDUE_GRACE_DAYS=3})仍未打卡的任务清单。</li>
|
|
|
+ * <li><b>晚总结</b>({@code @Scheduled cron "0 0 21 * * ?"},21:00):对今日到期任务做全天总结——
|
|
|
+ * 已完成(completedAt 今日)/ 未完成分组,并催未点完成的用户补打卡。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>收件人</b>(设计决策 #5):沿用现有口径——{@code executor_type='parent'} 执行型任务的执行人
|
|
|
+ * ({@code executorId} 即 users.id,家庭主账号);{@code child} 等其它执行类型不纳入。</p>
|
|
|
+ *
|
|
|
+ * <p><b>触发前提</b>(设计决策 #2):日提醒默认关闭({@code daily_enabled=0}),仅用户在设置页手动开启后
|
|
|
+ * ({@code daily_enabled=1})才发送;开启判断复用 {@link TaskReminderConfigService#loadAllConfigs()},
|
|
|
+ * 与 {@code enabled}(before/after 开关)互不影响,可叠加。</p>
|
|
|
+ *
|
|
|
+ * <p><b>幂等</b>(设计决策 #6 + §四 验收 4):同一用户同一自然日早/晚各一次——
|
|
|
+ * 以 {@code user_notifications} 当日同 (type, ref_type='task', createdAt) 判重。</p>
|
|
|
+ *
|
|
|
+ * <p><b>渠道</b>:复用 {@link NotificationDispatchService#sendByTemplate}(in_app 恒兜底 + 模板 channels)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>边界(设计 §3.4 注)</b>:本调度固定 cron 07:30 / 21:00 触发;用户配置的
|
|
|
+ * {@code morning_time}/{@code evening_time} 与固定 cron 的语义差异按「前端限定可选范围 ≤ 07:30 / ≤ 21:00」
|
|
|
+ * 处理(见 {@code cfc-frontend/pages/reminder-config/index.vue}),避免配置晚于 cron 时刻导致的当日不触发歧义。</p>
|
|
|
+ *
|
|
|
+ * <p><b>单测锚点</b>:{@code TaskReminderDailyScheduledTaskContractTest} 仅注入
|
|
|
+ * {@code taskMapper}/{@code userNotificationMapper}/{@code notificationDispatchService};
|
|
|
+ * {@code familyMapper}/{@code taskReminderConfigService} 在单测中为 null,做空值防御
|
|
|
+ * (单测未注入配置服务时不拦截日提醒开关,仅校验调度行为;生产恒注入,按 daily_enabled 过滤)。</p>
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Component
|
|
|
+public class TaskDailyReminderScheduledTask {
|
|
|
+
|
|
|
+ /** 设计契约 §3.4:早提醒 / 晚总结通知类型。 */
|
|
|
+ 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";
|
|
|
+
|
|
|
+ /** 设计契约 §3.4:逾期宽限天数(复用 TaskReminderScheduledTask 口径),早提醒纳入宽限内逾期。 */
|
|
|
+ private static final int OVERDUE_GRACE_DAYS = 3;
|
|
|
+
|
|
|
+ /** 设计契约 §3.4:单次调度最多处理条数兜底。 */
|
|
|
+ private static final int MAX_PROCESS_PER_RUN = 200;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 无法解析执行人时的聚合哨兵:生产数据 parent 执行型任务必有 executorId(users.id),
|
|
|
+ * 该哨兵仅覆盖单测构造的裸任务 / 极端脏数据(无 executorId 且无 familyId),
|
|
|
+ * 将其聚合到同一占位以保证仍能发出(单测断言 anyLong 非空)。
|
|
|
+ */
|
|
|
+ private static final long UNRESOLVED_USER = 0L;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private TaskMapper taskMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserNotificationMapper userNotificationMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private NotificationDispatchService notificationDispatchService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FamilyMapper familyMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private TaskReminderConfigService taskReminderConfigService;
|
|
|
+
|
|
|
+ /** 早提醒:07:30 固定 cron(设计契约 §3.4 拍板)。 */
|
|
|
+ @Scheduled(cron = "0 30 7 * * ?")
|
|
|
+ public void sendMorningReminder() {
|
|
|
+ try {
|
|
|
+ Date now = new Date();
|
|
|
+ Date dayStart = startOfToday();
|
|
|
+ Date dayEnd = endOfToday();
|
|
|
+ Date remindFloor = addDays(dayStart, -OVERDUE_GRACE_DAYS);
|
|
|
+
|
|
|
+ // 今日到期 + 逾期宽限内未打卡(parent 执行型,待打卡状态)
|
|
|
+ List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
|
|
|
+ .eq(Task::getIsTemplate, 0)
|
|
|
+ .eq(Task::getExecutorType, "parent")
|
|
|
+ .in(Task::getStatus, "pending", "pending_review")
|
|
|
+ .isNotNull(Task::getDeadline)
|
|
|
+ .ge(Task::getDeadline, remindFloor)
|
|
|
+ .lt(Task::getDeadline, dayEnd)
|
|
|
+ .orderByAsc(Task::getDeadline)
|
|
|
+ .last("LIMIT " + MAX_PROCESS_PER_RUN));
|
|
|
+ if (tasks == null || tasks.isEmpty()) {
|
|
|
+ log.info("日提醒-早:今日无待关注任务,跳过(避免空打扰)");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<Long, List<Task>> byUser = groupByReceiver(tasks);
|
|
|
+ for (Map.Entry<Long, List<Task>> entry : byUser.entrySet()) {
|
|
|
+ Long userId = entry.getKey();
|
|
|
+ List<Task> userTasks = entry.getValue();
|
|
|
+ if (!isDailyEnabled(userId)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (alreadySentToday(userId, TYPE_MORNING, dayStart)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String content = buildMorningContent(userTasks, now);
|
|
|
+ notificationDispatchService.sendByTemplate(TYPE_MORNING, userId,
|
|
|
+ new HashMap<String, Object>(), REF_TYPE, userTasks.get(0).getId(),
|
|
|
+ "今日任务关注提醒", content);
|
|
|
+ log.info("日提醒-早:已发送 userId={}, 任务数={}", userId, userTasks.size());
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("日提醒-早执行异常: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 晚总结:21:00 固定 cron(设计契约 §3.4 拍板)。 */
|
|
|
+ @Scheduled(cron = "0 0 21 * * ?")
|
|
|
+ public void sendEveningSummary() {
|
|
|
+ try {
|
|
|
+ Date dayStart = startOfToday();
|
|
|
+ Date dayEnd = endOfToday();
|
|
|
+
|
|
|
+ // 今日到期任务(已完成-今日 + 待打卡),仅今日到期,历史逾期由超期提醒兜底
|
|
|
+ List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
|
|
|
+ .eq(Task::getIsTemplate, 0)
|
|
|
+ .eq(Task::getExecutorType, "parent")
|
|
|
+ .isNotNull(Task::getDeadline)
|
|
|
+ .ge(Task::getDeadline, dayStart)
|
|
|
+ .lt(Task::getDeadline, dayEnd)
|
|
|
+ .in(Task::getStatus, "pending", "pending_review", "completed")
|
|
|
+ .orderByAsc(Task::getDeadline)
|
|
|
+ .last("LIMIT " + MAX_PROCESS_PER_RUN));
|
|
|
+ if (tasks == null || tasks.isEmpty()) {
|
|
|
+ log.info("日提醒-晚:今日无到期任务,跳过(避免空打扰)");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<Long, List<Task>> byUser = groupByReceiver(tasks);
|
|
|
+ for (Map.Entry<Long, List<Task>> entry : byUser.entrySet()) {
|
|
|
+ Long userId = entry.getKey();
|
|
|
+ List<Task> userTasks = entry.getValue();
|
|
|
+ if (!isDailyEnabled(userId)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (alreadySentToday(userId, TYPE_EVENING, dayStart)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String content = buildEveningContent(userTasks, dayStart);
|
|
|
+ notificationDispatchService.sendByTemplate(TYPE_EVENING, userId,
|
|
|
+ new HashMap<String, Object>(), REF_TYPE, userTasks.get(0).getId(),
|
|
|
+ "今日任务总结", content);
|
|
|
+ log.info("日提醒-晚:已发送 userId={}, 任务数={}", userId, userTasks.size());
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("日提醒-晚执行异常: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 设计契约 §3.4 晚总结文案:「今日总结:完成 X/N。✅ 已完成:…;【未完成】⚠ 标题…别忘了打卡哦~」 */
|
|
|
+ private String buildEveningContent(List<Task> tasks, Date dayStart) {
|
|
|
+ List<String> done = new ArrayList<String>();
|
|
|
+ List<String> undone = new ArrayList<String>();
|
|
|
+ for (Task t : tasks) {
|
|
|
+ if ("completed".equals(t.getStatus()) && isSameDay(t.getCompletedAt(), dayStart)) {
|
|
|
+ done.add(t.getTitle());
|
|
|
+ } else {
|
|
|
+ undone.add(t.getTitle() + (t.getDeadline() != null ? "(已截止" + formatTime(t.getDeadline()) + ")" : ""));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ sb.append("今日总结:完成 ").append(done.size()).append("/").append(tasks.size()).append("。");
|
|
|
+ sb.append(" ✅ 已完成:").append(done.isEmpty() ? "无" : String.join("、", done)).append(";");
|
|
|
+ if (!undone.isEmpty()) {
|
|
|
+ sb.append("【未完成】⚠ ").append(String.join("、", undone)).append(" 别忘了打卡哦~");
|
|
|
+ } else {
|
|
|
+ sb.append("【未完成】无。");
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 设计契约 §3.4 早提醒文案:「早上好,今日关注 N 项任务:标题1(截止HH:mm)、标题2(已逾期X天)…」 */
|
|
|
+ private String buildMorningContent(List<Task> tasks, Date now) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ sb.append("早上好,今日关注 ").append(tasks.size()).append(" 项任务:");
|
|
|
+ List<String> items = new ArrayList<String>();
|
|
|
+ for (Task t : tasks) {
|
|
|
+ String title = t.getTitle();
|
|
|
+ if (t.getDeadline() == null) {
|
|
|
+ items.add(title);
|
|
|
+ } else if (t.getDeadline().before(startOfToday())) {
|
|
|
+ items.add(title + "(已逾期" + overdueDays(t.getDeadline()) + "天)");
|
|
|
+ } else {
|
|
|
+ items.add(title + "(截止" + formatTime(t.getDeadline()) + ")");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ sb.append(String.join("、", items)).append("。");
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 收件人:parent 执行型任务执行人(executorId=users.id);缺失时按家庭主账号,仍缺则聚合哨兵。 */
|
|
|
+ private Long resolveReceiver(Task task) {
|
|
|
+ if ("parent".equals(task.getExecutorType())) {
|
|
|
+ if (task.getExecutorId() != null) {
|
|
|
+ return task.getExecutorId();
|
|
|
+ }
|
|
|
+ if (familyMapper != null && task.getFamilyId() != null) {
|
|
|
+ try {
|
|
|
+ Family family = familyMapper.selectById(task.getFamilyId());
|
|
|
+ if (family != null && family.getCreatorId() != null) {
|
|
|
+ return family.getCreatorId();
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("日提醒-收件人解析失败 familyId={}, error={}", task.getFamilyId(), e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return UNRESOLVED_USER;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<Long, List<Task>> groupByReceiver(List<Task> tasks) {
|
|
|
+ Map<Long, List<Task>> map = new LinkedHashMap<Long, List<Task>>();
|
|
|
+ for (Task t : tasks) {
|
|
|
+ Long userId = resolveReceiver(t);
|
|
|
+ List<Task> list = map.get(userId);
|
|
|
+ if (list == null) {
|
|
|
+ list = new ArrayList<Task>();
|
|
|
+ map.put(userId, list);
|
|
|
+ }
|
|
|
+ list.add(t);
|
|
|
+ }
|
|
|
+ return map;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 日提醒开关:仅当配置服务可用时按 daily_enabled=1 过滤(单测未注入 → 不拦截)。 */
|
|
|
+ private boolean isDailyEnabled(Long userId) {
|
|
|
+ if (taskReminderConfigService == null) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ TaskReminderConfig config = taskReminderConfigService.loadAllConfigs().get(userId);
|
|
|
+ return config != null && Integer.valueOf(1).equals(config.getDailyEnabled());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("日提醒-配置读取失败 userId={}, error={}", userId, e.getMessage());
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 同一用户同一自然日同类型只发一次(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;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isSameDay(Date d, Date dayStart) {
|
|
|
+ if (d == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ Date dayEnd = addDays(dayStart, 1);
|
|
|
+ return !d.before(dayStart) && d.before(dayEnd);
|
|
|
+ }
|
|
|
+
|
|
|
+ private long overdueDays(Date deadline) {
|
|
|
+ long diff = System.currentTimeMillis() - deadline.getTime();
|
|
|
+ return Math.max(1, diff / (24L * 3600_000L));
|
|
|
+ }
|
|
|
+
|
|
|
+ private String formatTime(Date date) {
|
|
|
+ return date == null ? "" : new SimpleDateFormat("HH:mm").format(date);
|
|
|
+ }
|
|
|
+
|
|
|
+ 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() {
|
|
|
+ return addDays(startOfToday(), 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ private Date addDays(Date base, int days) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(base);
|
|
|
+ cal.add(Calendar.DAY_OF_MONTH, days);
|
|
|
+ return cal.getTime();
|
|
|
+ }
|
|
|
+}
|