feat(health): 新增服药任务自动生成定时任务

- 新增 MedicationTaskJob:每日生成服药任务、标记过期记录
- 新增 MedicationRemindJob:扫描并发送服药提醒
- 扩展 HealthMedicationRecordMapper:新增统计和查询方法
- 新增定时任务配置SQL和提醒日志表
This commit is contained in:
bot5
2026-03-19 08:25:36 +08:00
parent 77e503c460
commit f5c9868a07
5 changed files with 623 additions and 0 deletions

View File

@@ -103,4 +103,30 @@ public interface HealthMedicationRecordMapper
* @return 结果 * @return 结果
*/ */
public int removeHealthMedicationRecordByIds(Long[] ids); 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<HealthMedicationRecordVo> selectPendingRemindRecords(@Param("startTime") java.time.LocalDateTime startTime, @Param("endTime") java.time.LocalDateTime endTime);
} }

View File

@@ -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<HealthMedicationRecordVo> 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<HealthMedicationRecordVo> 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);
}
}

View File

@@ -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<HealthMedicationPlanVo> 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<HealthMedicationRecord> 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<HealthMedicationRecord> generateRecordsForPlan(HealthMedicationPlanVo plan, LocalDate date) {
List<HealthMedicationRecord> 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);
}
}

View File

@@ -169,4 +169,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{id} #{id}
</foreach> </foreach>
</update> </update>
<select id="countByPlanAndDate" resultType="int">
select count(1) from health_medication_record
where del_flag='0' and plan_id = #{planId}
and date(scheduled_time) = date(#{date})
</select>
<update id="markExpiredRecords">
update health_medication_record
set status = '4', update_time = current_timestamp
where del_flag='0' and status = '1'
and scheduled_time &lt; #{expireThreshold}
</update>
<select id="selectPendingRemindRecords" resultMap="HealthMedicationRecordResult">
<include refid="selectHealthMedicationRecordVo"/>
where a.del_flag='0' and a.status = '1'
and a.scheduled_time &gt;= #{startTime}
and a.scheduled_time &lt;= #{endTime}
order by a.scheduled_time asc
</select>
</mapper> </mapper>

View File

@@ -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);