From f5c9868a076556fbda2105b8c19c5a6d77360ecd Mon Sep 17 00:00:00 2001 From: bot5 Date: Thu, 19 Mar 2026 08:25:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E6=96=B0=E5=A2=9E=E6=9C=8D?= =?UTF-8?q?=E8=8D=AF=E4=BB=BB=E5=8A=A1=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MedicationTaskJob:每日生成服药任务、标记过期记录 - 新增 MedicationRemindJob:扫描并发送服药提醒 - 扩展 HealthMedicationRecordMapper:新增统计和查询方法 - 新增定时任务配置SQL和提醒日志表 --- .../mapper/HealthMedicationRecordMapper.java | 26 ++ .../intc/health/task/MedicationRemindJob.java | 153 ++++++++ .../intc/health/task/MedicationTaskJob.java | 333 ++++++++++++++++++ .../health/HealthMedicationRecordMapper.xml | 21 ++ sql/20250319-medication-task-job.sql | 90 +++++ 5 files changed, 623 insertions(+) create mode 100644 intc-modules/intc-health/src/main/java/com/intc/health/task/MedicationRemindJob.java create mode 100644 intc-modules/intc-health/src/main/java/com/intc/health/task/MedicationTaskJob.java create mode 100644 sql/20250319-medication-task-job.sql diff --git a/intc-modules/intc-health/src/main/java/com/intc/health/mapper/HealthMedicationRecordMapper.java b/intc-modules/intc-health/src/main/java/com/intc/health/mapper/HealthMedicationRecordMapper.java index 61042c8..1248135 100644 --- a/intc-modules/intc-health/src/main/java/com/intc/health/mapper/HealthMedicationRecordMapper.java +++ b/intc-modules/intc-health/src/main/java/com/intc/health/mapper/HealthMedicationRecordMapper.java @@ -103,4 +103,30 @@ public interface HealthMedicationRecordMapper * @return 结果 */ public int removeHealthMedicationRecordByIds(Long[] ids); + + /** + * 统计某计划某天的记录数(用于检查是否已生成) + * + * @param planId 计划ID + * @param date 日期 + * @return 记录数 + */ + public int countByPlanAndDate(@Param("planId") Long planId, @Param("date") String date); + + /** + * 标记过期的服药记录(超过24小时未服用) + * + * @param expireThreshold 过期阈值时间 + * @return 更新的记录数 + */ + public int markExpiredRecords(@Param("expireThreshold") java.time.LocalDateTime expireThreshold); + + /** + * 查询待提醒的服药记录 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return 服药记录集合 + */ + public List selectPendingRemindRecords(@Param("startTime") java.time.LocalDateTime startTime, @Param("endTime") java.time.LocalDateTime endTime); } \ No newline at end of file diff --git a/intc-modules/intc-health/src/main/java/com/intc/health/task/MedicationRemindJob.java b/intc-modules/intc-health/src/main/java/com/intc/health/task/MedicationRemindJob.java new file mode 100644 index 0000000..56864e0 --- /dev/null +++ b/intc-modules/intc-health/src/main/java/com/intc/health/task/MedicationRemindJob.java @@ -0,0 +1,153 @@ +package com.intc.health.task; + +import com.intc.health.domain.vo.HealthMedicationRecordVo; +import com.intc.health.mapper.HealthMedicationRecordMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 用药提醒定时器 + * 用于扫描待提醒的服药任务并发送提醒 + * + * @author bot5 + * @date 2026-03-19 + */ +@Component("medicationRemindJob") +public class MedicationRemindJob { + + private static final Logger log = LoggerFactory.getLogger(MedicationRemindJob.class); + + @Resource + private HealthMedicationRecordMapper recordMapper; + + // TODO: 注入消息推送服务,后续实现 + // @Resource + // private MessagePushService messagePushService; + + /** + * 扫描并发送服药提醒 + * 定时任务配置:每分钟执行 + * 调用方式:medicationRemindJob.scanAndSendReminders + */ + public void scanAndSendReminders() { + log.debug("========== 开始扫描服药提醒 =========="); + + LocalDateTime now = LocalDateTime.now(); + // 扫描未来1分钟内的待服药记录 + LocalDateTime startTime = now.plusMinutes(-1); + LocalDateTime endTime = now.plusMinutes(1); + + List pendingRecords = recordMapper.selectPendingRemindRecords(startTime, endTime); + + if (pendingRecords == null || pendingRecords.isEmpty()) { + log.debug("没有需要提醒的服药记录"); + return; + } + + log.info("发现 {} 条待提醒的服药记录", pendingRecords.size()); + + for (HealthMedicationRecordVo record : pendingRecords) { + try { + sendReminder(record); + } catch (Exception e) { + log.error("发送服药提醒失败,记录ID: {}, 错误: {}", record.getId(), e.getMessage(), e); + } + } + + log.debug("========== 服药提醒扫描完成 =========="); + } + + /** + * 发送服药提醒 + * + * @param record 服药记录 + */ + private void sendReminder(HealthMedicationRecordVo record) { + log.info("发送服药提醒: 人员={}, 药品={}, 计划时间={}", + record.getPersonName(), + record.getMedicineName(), + record.getScheduledTime()); + + // TODO: 实现消息推送逻辑 + // 1. 查询用户的提醒设置 + // 2. 根据设置选择推送渠道(APP推送/微信/短信) + // 3. 发送推送消息 + // 4. 记录提醒日志 + + // 暂时只打印日志 + String message = String.format("【服药提醒】%s,请按时服用 %s,剂量:%s%s", + record.getPersonName(), + record.getMedicineName(), + record.getDosage() != null ? record.getDosage() : "", + record.getDosageUnit() != null ? record.getDosageUnit() : ""); + + log.info("推送消息: {}", message); + + // TODO: 调用消息推送服务 + // messagePushService.push(record.getPersonId(), message); + } + + /** + * 扫描超时未服药记录并发送二次提醒 + * 定时任务配置:每5分钟执行 + * 调用方式:medicationRemindJob.scanOverdueRecords + */ + public void scanOverdueRecords() { + log.debug("========== 开始扫描超时未服药记录 =========="); + + LocalDateTime now = LocalDateTime.now(); + // 扫描超过计划时间30分钟仍未服药的记录 + LocalDateTime overdueThreshold = now.minusMinutes(30); + + // 查询超时的待服药记录 + List overdueRecords = recordMapper.selectPendingRemindRecords( + overdueThreshold.minusMinutes(5), // 查询5分钟内超时的 + overdueThreshold + ); + + if (overdueRecords == null || overdueRecords.isEmpty()) { + log.debug("没有超时未服药记录"); + return; + } + + log.info("发现 {} 条超时未服药记录", overdueRecords.size()); + + for (HealthMedicationRecordVo record : overdueRecords) { + try { + sendOverdueReminder(record); + } catch (Exception e) { + log.error("发送超时提醒失败,记录ID: {}, 错误: {}", record.getId(), e.getMessage(), e); + } + } + + log.debug("========== 超时未服药扫描完成 =========="); + } + + /** + * 发送超时提醒(二次提醒/通知家属) + * + * @param record 服药记录 + */ + private void sendOverdueReminder(HealthMedicationRecordVo record) { + log.warn("超时未服药: 人员={}, 药品={}, 计划时间={}", + record.getPersonName(), + record.getMedicineName(), + record.getScheduledTime()); + + // TODO: 实现超时提醒逻辑 + // 1. 发送二次提醒给用户 + // 2. 如果配置了家属通知,同时通知家属 + // 3. 记录提醒日志 + + String message = String.format("【漏服提醒】%s 未按时服用 %s,请及时处理", + record.getPersonName(), + record.getMedicineName()); + + log.info("推送超时消息: {}", message); + } +} \ No newline at end of file diff --git a/intc-modules/intc-health/src/main/java/com/intc/health/task/MedicationTaskJob.java b/intc-modules/intc-health/src/main/java/com/intc/health/task/MedicationTaskJob.java new file mode 100644 index 0000000..6194b4c --- /dev/null +++ b/intc-modules/intc-health/src/main/java/com/intc/health/task/MedicationTaskJob.java @@ -0,0 +1,333 @@ +package com.intc.health.task; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.intc.common.core.utils.IdWorker; +import com.intc.health.domain.HealthMedicationRecord; +import com.intc.health.domain.vo.HealthMedicationPlanVo; +import com.intc.health.mapper.HealthMedicationPlanMapper; +import com.intc.health.mapper.HealthMedicationRecordMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * 用药任务定时器 + * 用于每日自动生成服药记录 + * + * @author bot5 + * @date 2026-03-19 + */ +@Component("medicationTaskJob") +public class MedicationTaskJob { + + private static final Logger log = LoggerFactory.getLogger(MedicationTaskJob.class); + + @Resource + private HealthMedicationPlanMapper planMapper; + + @Resource + private HealthMedicationRecordMapper recordMapper; + + /** + * 每日生成服药任务 + * 定时任务配置:每日 00:05 执行 + * 调用方式:medicationTaskJob.generateDailyTasks + */ + public void generateDailyTasks() { + log.info("========== 开始生成每日服药任务 =========="); + LocalDate today = LocalDate.now(); + int generatedCount = generateTasksForDate(today); + log.info("========== 每日服药任务生成完成,共生成 {} 条记录 ==========", generatedCount); + } + + /** + * 生成指定日期的服药任务 + * 可用于补生成历史任务 + * + * @param date 日期 + * @return 生成的记录数 + */ + public int generateTasksForDate(LocalDate date) { + int generatedCount = 0; + + // 查询所有进行中的计划 + List activePlans = planMapper.selectActivePlanList(); + if (activePlans == null || activePlans.isEmpty()) { + log.info("没有进行中的用药计划"); + return 0; + } + + String dateStr = date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + + for (HealthMedicationPlanVo plan : activePlans) { + try { + // 检查该计划当天是否需要服药 + if (!shouldTakeMedicineOnDate(plan, date)) { + log.debug("计划 {} 在 {} 不需要服药", plan.getPlanName(), dateStr); + continue; + } + + // 检查该计划当天是否已生成过记录 + int existCount = recordMapper.countByPlanAndDate(plan.getId(), dateStr); + if (existCount > 0) { + log.debug("计划 {} 在 {} 已有 {} 条记录,跳过", plan.getPlanName(), dateStr, existCount); + continue; + } + + // 生成当天的服药记录 + List records = generateRecordsForPlan(plan, date); + if (!records.isEmpty()) { + recordMapper.batchInsertHealthMedicationRecord(records); + generatedCount += records.size(); + log.info("计划 {} 在 {} 生成了 {} 条服药记录", plan.getPlanName(), dateStr, records.size()); + } + } catch (Exception e) { + log.error("生成计划 {} 的服药记录失败: {}", plan.getId(), e.getMessage(), e); + } + } + + return generatedCount; + } + + /** + * 判断某计划在指定日期是否需要服药 + * + * @param plan 用药计划 + * @param date 日期 + * @return true-需要服药,false-不需要 + */ + private boolean shouldTakeMedicineOnDate(HealthMedicationPlanVo plan, LocalDate date) { + // 检查是否在计划日期范围内 + LocalDate startDate = plan.getStartDate() != null ? + plan.getStartDate().toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate() : null; + LocalDate endDate = plan.getEndDate() != null ? + plan.getEndDate().toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate() : null; + + if (startDate != null && date.isBefore(startDate)) { + return false; + } + if (endDate != null && date.isAfter(endDate)) { + return false; + } + + // 根据频率类型判断 + String frequencyType = plan.getFrequencyType(); + if (frequencyType == null) { + frequencyType = "1"; // 默认每日 + } + + switch (frequencyType) { + case "1": // 每日 + return true; + case "2": // 隔日 + return shouldTakeOnAlternateDay(plan, date); + case "3": // 每周 + return shouldTakeOnWeekly(plan, date); + case "4": // 自定义 + return shouldTakeOnCustom(plan, date); + default: + return true; + } + } + + /** + * 判断隔日服药 + */ + private boolean shouldTakeOnAlternateDay(HealthMedicationPlanVo plan, LocalDate date) { + LocalDate startDate = plan.getStartDate() != null ? + plan.getStartDate().toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate() : date; + long daysBetween = ChronoUnit.DAYS.between(startDate, date); + return daysBetween % 2 == 0; + } + + /** + * 判断每周服药 + */ + private boolean shouldTakeOnWeekly(HealthMedicationPlanVo plan, LocalDate date) { + String weekDays = plan.getWeekDays(); + if (weekDays == null || weekDays.isEmpty()) { + return true; + } + + try { + // weekDays 格式为 JSON 数组,如 [1,3,5] 表示周一三五 + JSONArray daysArray = JSON.parseArray(weekDays); + DayOfWeek dayOfWeek = date.getDayOfWeek(); + int dayValue = dayOfWeek.getValue(); // 1=周一, 7=周日 + + for (int i = 0; i < daysArray.size(); i++) { + if (daysArray.getInteger(i) == dayValue) { + return true; + } + } + return false; + } catch (Exception e) { + log.error("解析周几配置失败: {}", weekDays, e); + return true; + } + } + + /** + * 判断自定义频率服药 + * 暂时默认返回 true,后续可根据具体需求扩展 + */ + private boolean shouldTakeOnCustom(HealthMedicationPlanVo plan, LocalDate date) { + // 自定义频率可以根据 remark 或其他字段配置 + // 暂时默认每日都服药 + return true; + } + + /** + * 为某个计划生成指定日期的服药记录 + * + * @param plan 用药计划 + * @param date 日期 + * @return 服药记录列表 + */ + private List generateRecordsForPlan(HealthMedicationPlanVo plan, LocalDate date) { + List records = new ArrayList<>(); + + // 获取服药时间点 + String timePoints = plan.getTimePoints(); + if (timePoints == null || timePoints.isEmpty()) { + // 如果没有配置时间点,根据频率生成默认时间点 + timePoints = generateDefaultTimePoints(plan.getFrequency()); + } + + try { + JSONArray timePointsArray = JSON.parseArray(timePoints); + LocalDateTime now = LocalDateTime.now(); + + for (int i = 0; i < timePointsArray.size(); i++) { + String timeStr = timePointsArray.getString(i); + LocalTime time = parseTime(timeStr); + + HealthMedicationRecord record = new HealthMedicationRecord(); + record.setId(IdWorker.getId()); + record.setPlanId(plan.getId()); + record.setPersonId(plan.getPersonId()); + record.setMedicineId(plan.getMedicineId()); + record.setScheduledTime(LocalDateTime.of(date, time)); + record.setDosage(plan.getDosage()); + record.setDosageUnit(plan.getDosageUnit()); + record.setStatus("1"); // 待服用 + record.setCreateTime(now); + record.setDelFlag("0"); + + records.add(record); + } + } catch (Exception e) { + log.error("解析时间点配置失败: {}", timePoints, e); + } + + return records; + } + + /** + * 根据每日次数生成默认时间点 + * + * @param frequency 每日次数 + * @return JSON 格式的时间点数组 + */ + private String generateDefaultTimePoints(Integer frequency) { + if (frequency == null || frequency <= 0) { + frequency = 1; + } + + JSONArray timePoints = new JSONArray(); + switch (frequency) { + case 1: + timePoints.add("08:00"); + break; + case 2: + timePoints.add("08:00"); + timePoints.add("20:00"); + break; + case 3: + timePoints.add("08:00"); + timePoints.add("12:00"); + timePoints.add("20:00"); + break; + case 4: + timePoints.add("08:00"); + timePoints.add("12:00"); + timePoints.add("16:00"); + timePoints.add("20:00"); + break; + default: + // 超过4次,均匀分布在 8:00-22:00 之间 + int startHour = 8; + int endHour = 22; + int interval = (endHour - startHour) / frequency; + for (int i = 0; i < frequency; i++) { + int hour = startHour + i * interval; + timePoints.add(String.format("%02d:00", hour)); + } + } + + return timePoints.toJSONString(); + } + + /** + * 解析时间字符串 + * + * @param timeStr 时间字符串,格式如 "08:00" 或 "8:00" + * @return LocalTime + */ + private LocalTime parseTime(String timeStr) { + try { + // 支持多种格式 + String normalized = timeStr.trim(); + if (normalized.length() == 4 && !normalized.contains(":")) { + // "0800" -> "08:00" + normalized = normalized.substring(0, 2) + ":" + normalized.substring(2); + } + + String[] parts = normalized.split(":"); + int hour = Integer.parseInt(parts[0]); + int minute = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; + return LocalTime.of(hour, minute); + } catch (Exception e) { + log.warn("解析时间失败: {},使用默认时间 08:00", timeStr); + return LocalTime.of(8, 0); + } + } + + /** + * 检查并标记过期的服药记录 + * 定时任务配置:每5分钟执行 + * 调用方式:medicationTaskJob.markExpiredRecords + */ + public void markExpiredRecords() { + log.info("========== 开始检查过期服药记录 =========="); + LocalDateTime now = LocalDateTime.now(); + // 超过计划时间24小时的记录标记为过期 + LocalDateTime expireThreshold = now.minusHours(24); + + int count = recordMapper.markExpiredRecords(expireThreshold); + log.info("========== 标记过期记录完成,共 {} 条 ==========", count); + } + + /** + * 手动触发生成指定日期的任务(用于测试或补录) + * + * @param dateStr 日期字符串,格式 yyyy-MM-dd + * @return 生成的记录数 + */ + public int generateTasksManually(String dateStr) { + LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd")); + return generateTasksForDate(date); + } +} \ No newline at end of file diff --git a/intc-modules/intc-health/src/main/resources/mapper/health/HealthMedicationRecordMapper.xml b/intc-modules/intc-health/src/main/resources/mapper/health/HealthMedicationRecordMapper.xml index 89b6b6f..d12edd9 100644 --- a/intc-modules/intc-health/src/main/resources/mapper/health/HealthMedicationRecordMapper.xml +++ b/intc-modules/intc-health/src/main/resources/mapper/health/HealthMedicationRecordMapper.xml @@ -169,4 +169,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{id} + + + + + update health_medication_record + set status = '4', update_time = current_timestamp + where del_flag='0' and status = '1' + and scheduled_time < #{expireThreshold} + + + \ No newline at end of file diff --git a/sql/20250319-medication-task-job.sql b/sql/20250319-medication-task-job.sql new file mode 100644 index 0000000..f04ccb9 --- /dev/null +++ b/sql/20250319-medication-task-job.sql @@ -0,0 +1,90 @@ +-- ======================================== +-- 用药任务定时任务配置 +-- 在 sys_job 表中插入定时任务 +-- ======================================== + +-- 1. 每日生成服药任务(每日 00:05 执行) +INSERT INTO sys_job (job_id, job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark) +VALUES ( + nextval('seq_sys_job'), + '每日生成服药任务', + 'DEFAULT', + 'medicationTaskJob.generateDailyTasks', + '0 5 0 * * ?', + '2', + '1', + '0', + 'admin', + NOW(), + '每日凌晨00:05自动生成当天的服药任务' +); + +-- 2. 扫描并发送服药提醒(每分钟执行) +INSERT INTO sys_job (job_id, job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark) +VALUES ( + nextval('seq_sys_job'), + '扫描服药提醒', + 'DEFAULT', + 'medicationRemindJob.scanAndSendReminders', + '0 * * * * ?', + '2', + '1', + '0', + 'admin', + NOW(), + '每分钟扫描待提醒的服药任务并发送提醒' +); + +-- 3. 扫描超时未服药记录(每5分钟执行) +INSERT INTO sys_job (job_id, job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark) +VALUES ( + nextval('seq_sys_job'), + '扫描超时未服药', + 'DEFAULT', + 'medicationRemindJob.scanOverdueRecords', + '0 */5 * * * ?', + '2', + '1', + '0', + 'admin', + NOW(), + '每5分钟扫描超时未服药的记录并发送提醒' +); + +-- 4. 标记过期服药记录(每小时执行) +INSERT INTO sys_job (job_id, job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark) +VALUES ( + nextval('seq_sys_job'), + '标记过期服药记录', + 'DEFAULT', + 'medicationTaskJob.markExpiredRecords', + '0 0 * * * ?', + '2', + '1', + '0', + 'admin', + NOW(), + '每小时检查并标记过期的服药记录' +); + +-- ======================================== +-- 提醒日志表(可选,用于记录提醒历史) +-- ======================================== +DROP TABLE IF EXISTS health_medication_remind_log; +CREATE TABLE health_medication_remind_log ( + id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + plan_id BIGINT NOT NULL, + person_id BIGINT NOT NULL, + remind_type CHAR(1) DEFAULT '1' COMMENT '提醒类型:1-按时提醒 2-超时提醒 3-漏服通知家属', + remind_time DATETIME NOT NULL, + remind_channel VARCHAR(20) DEFAULT 'app' COMMENT '提醒渠道:app/sms/wechat', + remind_result CHAR(1) DEFAULT '1' COMMENT '结果:1-成功 2-失败', + error_msg VARCHAR(500) NULL, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='用药提醒日志表'; + +CREATE INDEX idx_rmrl_record_id ON health_medication_remind_log(record_id); +CREATE INDEX idx_rmrl_person_id ON health_medication_remind_log(person_id); +CREATE INDEX idx_rmrl_remind_time ON health_medication_remind_log(remind_time); \ No newline at end of file