Jelajahi Sumber

feat(task): 每日任务打卡超时 webhook 提醒

- 新增 WechatWaitMsgService:POST 微信待办接口(URL/token 支持 yml 覆盖,msgLabel 读 sys_config task_checkin_webhook_msg_label 可运行期配置,缺省 xiaoqiang)
- 新增 TaskCheckinWebhookReminderTask:每分钟扫描家长执行型待打卡任务,提醒时间(deadline)过 1 小时未完成则推送 webhook
- 幂等:user_notifications 标记,同任务同日仅推一次;失败下一分钟自动重试

模板任务数据(9 条健康打卡)一次性直接落库,不写入代码。
Xiaogang Liao 5 hari lalu
induk
melakukan
a0e354af46

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WechatWaitMsgService.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.SysConfig;
+import com.etotem.cfc.mapper.SysConfigMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 微信待办消息推送(webhook 直连)
+ * <p>
+ * 目标端点:POST {url},请求头 Authorization: {token},请求体 {"msgLabel": ..., "content": ...}。
+ * URL / token 通过 application.yml 配置(etotem.webhook.wait-msg.*),msgLabel 优先读
+ * sys_config 表 task_checkin_webhook_msg_label(运行期可改,无需发版),缺省回退 yml 默认值。
+ */
+@Slf4j
+@Service
+public class WechatWaitMsgService {
+
+    /** sys_config 中的 msgLabel 配置键(可配置项) */
+    public static final String MSG_LABEL_CONFIG_KEY = "task_checkin_webhook_msg_label";
+
+    @Value("${etotem.webhook.wait-msg.url:http://wechat.iwintrue.com/wechat/wechat/wxwaitmsg/update}")
+    private String waitMsgUrl;
+
+    @Value("${etotem.webhook.wait-msg.token:01HVTE7T3E09AT30635M5F4H1Q}")
+    private String waitMsgToken;
+
+    @Value("${etotem.webhook.wait-msg.msg-label:xiaoqiang}")
+    private String defaultMsgLabel;
+
+    @Resource
+    private RestTemplate restTemplate;
+
+    @Resource
+    private SysConfigMapper sysConfigMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 推送一条待办消息。成功返回 true;任何异常均捕获并记录 WARN,返回 false(由调用方决定是否重试)。
+     */
+    public boolean sendWaitMsg(String content) {
+        try {
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            headers.set("Authorization", waitMsgToken);
+
+            Map<String, String> body = new HashMap<>();
+            body.put("msgLabel", resolveMsgLabel());
+            body.put("content", content);
+
+            String json = objectMapper.writeValueAsString(body);
+            HttpEntity<String> entity = new HttpEntity<>(json, headers);
+
+            ResponseEntity<String> resp = restTemplate.postForEntity(waitMsgUrl, entity, String.class);
+            if (resp.getStatusCode().is2xxSuccessful()) {
+                log.info("微信待办推送成功: msgLabel={}, resp={}", body.get("msgLabel"), resp.getBody());
+                return true;
+            }
+            log.warn("微信待办推送非2xx: code={}, resp={}", resp.getStatusCodeValue(), resp.getBody());
+            return false;
+        } catch (Exception e) {
+            log.warn("微信待办推送异常: {}", e.getMessage());
+            return false;
+        }
+    }
+
+    /** msgLabel 可配置:sys_config 优先,缺省回退 yml 默认值 */
+    private String resolveMsgLabel() {
+        try {
+            SysConfig cfg = sysConfigMapper.selectOne(
+                    new LambdaQueryWrapper<SysConfig>()
+                            .eq(SysConfig::getConfigKey, MSG_LABEL_CONFIG_KEY)
+                            .last("LIMIT 1"));
+            if (cfg != null && cfg.getConfigValue() != null && !cfg.getConfigValue().isEmpty()) {
+                return cfg.getConfigValue();
+            }
+        } catch (Exception e) {
+            log.warn("读取 sys_config.{} 失败,回退默认 msgLabel: {}", MSG_LABEL_CONFIG_KEY, e.getMessage());
+        }
+        return defaultMsgLabel;
+    }
+}

+ 160 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/TaskCheckinWebhookReminderTask.java

@@ -0,0 +1,160 @@
+package com.etotem.cfc.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.config.DatabaseInitializer;
+import com.etotem.cfc.entity.Task;
+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 lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 每日任务打卡提醒(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 失败不写标记,下一分钟自动重试。
+ */
+@Slf4j
+@Component
+public class TaskCheckinWebhookReminderTask {
+
+    /** 宽限分钟数:提醒时间(deadline)过后多久未打卡才推送 */
+    private static final long GRACE_MINUTES = 60L;
+
+    /** 单次调度最多处理条数,避免选数异常时形成消息轰炸 */
+    private static final int MAX_PROCESS_PER_RUN = 20;
+
+    private static final String NOTIFY_TYPE = "task_checkin_webhook";
+    private static final String REF_TYPE = "task";
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private UserNotificationMapper userNotificationMapper;
+
+    @Resource
+    private WechatWaitMsgService wechatWaitMsgService;
+
+    /** 每分钟执行一次 */
+    @Scheduled(cron = "0 * * * * ?")
+    public void remindUncheckedTasks() {
+        if (!DatabaseInitializer.isMigrationsComplete()) {
+            log.info("数据库迁移尚未完成,跳过本轮打卡提醒");
+            return;
+        }
+        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 threshold = new Date(now.getTime() - GRACE_MINUTES * 60_000L);
+
+            // 今日到期 + 已过提醒时间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)
+                    .orderByAsc(Task::getDeadline)
+                    .last("LIMIT " + MAX_PROCESS_PER_RUN));
+            if (tasks == null || tasks.isEmpty()) {
+                return;
+            }
+
+            int sent = 0;
+            for (Task task : tasks) {
+                try {
+                    if (task.getExecutorId() == null) {
+                        continue;
+                    }
+                    if (alreadySentToday(task.getExecutorId(), task.getId(), dayStart)) {
+                        continue;
+                    }
+                    boolean ok = wechatWaitMsgService.sendWaitMsg(buildContent(task));
+                    if (ok) {
+                        markSent(task.getExecutorId(), task, now);
+                        sent++;
+                    }
+                } catch (Exception e) {
+                    log.warn("打卡提醒推送失败 taskId={}, error={}", task.getId(), e.getMessage());
+                }
+            }
+            log.info("打卡提醒完成:候选{}条,推送成功{}条", tasks.size(), sent);
+        } catch (Exception e) {
+            log.warn("打卡提醒任务异常: {}", e.getMessage());
+        }
+    }
+
+    /** 生成推送内容:问候用户昵称 + 任务标题(含说明摘要) */
+    private String buildContent(Task task) {
+        String name = "您";
+        try {
+            if (task.getExecutorId() != null) {
+                User u = userMapper.selectById(task.getExecutorId());
+                if (u != null && u.getNickname() != null && !u.getNickname().isEmpty()) {
+                    name = u.getNickname();
+                }
+            }
+        } 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(desc).append(")");
+        }
+        sb.append("。记得及时完成打卡哦~");
+        return sb.toString();
+    }
+
+    /** 同一任务同一自然日只推送一次 */
+    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)
+                .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);
+    }
+}