|
|
@@ -0,0 +1,280 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.*;
|
|
|
+import com.etotem.cfc.mapper.*;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Component
|
|
|
+public class HealthAlertEngine {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthCheckinMapper healthCheckinMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthSleepRecordMapper healthSleepRecordMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthExerciseRecordMapper healthExerciseRecordMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthWaterRecordMapper healthWaterRecordMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private MicroActionRecordMapper microActionRecordMapper;
|
|
|
+
|
|
|
+ /** 规则定义:编码 -> (文案, 建议, 严重程度) */
|
|
|
+ private static final Map<String, String[]> RULES = new LinkedHashMap<>();
|
|
|
+
|
|
|
+ static {
|
|
|
+ RULES.put("R001", new String[]{
|
|
|
+ "您已连续%d天睡眠质量下降",
|
|
|
+ "睡前1小时放下手机,喝杯温牛奶",
|
|
|
+ "medium"
|
|
|
+ });
|
|
|
+ RULES.put("R002", new String[]{
|
|
|
+ "您已经%d天没有运动了",
|
|
|
+ "站起来走3分钟就是好的开始!",
|
|
|
+ "medium"
|
|
|
+ });
|
|
|
+ RULES.put("R003", new String[]{
|
|
|
+ "近%d天饮水量偏低",
|
|
|
+ "每1小时喝一杯水,设置定时提醒",
|
|
|
+ "low"
|
|
|
+ });
|
|
|
+ RULES.put("R004", new String[]{
|
|
|
+ "今天运动量比平时多一半",
|
|
|
+ "适当拉伸,不要过度训练",
|
|
|
+ "medium"
|
|
|
+ });
|
|
|
+ RULES.put("R005", new String[]{
|
|
|
+ "您已经%d天没有健康打卡",
|
|
|
+ "从最小的动作开始:喝一杯水 ☕️",
|
|
|
+ "high"
|
|
|
+ });
|
|
|
+ RULES.put("R006", new String[]{
|
|
|
+ "今天的健康打卡还没完成哦",
|
|
|
+ "点我完成今日微行动",
|
|
|
+ "high"
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ public static Map<String, String[]> getRules() {
|
|
|
+ return RULES;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 评估单个成员的所有预警规则
|
|
|
+ * @return 命中的规则列表(含填充后的文案和严重程度)
|
|
|
+ */
|
|
|
+ public List<Map<String, String>> evaluate(Long childId) {
|
|
|
+ if (childId == null) return Collections.emptyList();
|
|
|
+ List<Map<String, String>> results = new ArrayList<>();
|
|
|
+
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ Date today = cal.getTime();
|
|
|
+
|
|
|
+ // R006: 今日未打卡(18:00后触发)
|
|
|
+ cal.set(Calendar.HOUR_OF_DAY, 18);
|
|
|
+ cal.set(Calendar.MINUTE, 0);
|
|
|
+ cal.set(Calendar.SECOND, 0);
|
|
|
+ Date triggerTime = cal.getTime();
|
|
|
+ if (today.after(triggerTime)) {
|
|
|
+ String todayStr = new SimpleDateFormat("yyyy-MM-dd").format(today);
|
|
|
+ long todayCheckinCount = healthCheckinMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<HealthCheckin>()
|
|
|
+ .eq(HealthCheckin::getChildId, childId)
|
|
|
+ .apply("DATE(checkin_date) = {0}", todayStr)
|
|
|
+ );
|
|
|
+ long todayMicroCount = microActionRecordMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<MicroActionRecord>()
|
|
|
+ .eq(MicroActionRecord::getChildId, childId)
|
|
|
+ .apply("DATE(completed_date) = {0}", todayStr)
|
|
|
+ );
|
|
|
+ if (todayCheckinCount == 0 && todayMicroCount == 0) {
|
|
|
+ results.add(buildResult("R006", 0));
|
|
|
+ return results;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // R005: 连续7天无打卡
|
|
|
+ int noCheckinDays = countConsecutiveDaysWithoutCheckin(childId, today);
|
|
|
+ if (noCheckinDays >= 7) {
|
|
|
+ results.add(buildResult("R005", noCheckinDays));
|
|
|
+ }
|
|
|
+
|
|
|
+ // R001: 连续3天睡眠评分下降
|
|
|
+ if (checkSleepDecline(childId)) {
|
|
|
+ results.add(buildResult("R001", 3));
|
|
|
+ }
|
|
|
+
|
|
|
+ // R002: 连续5天无运动
|
|
|
+ int noExerciseDays = countDaysSinceLastExercise(childId);
|
|
|
+ if (noExerciseDays >= 5) {
|
|
|
+ results.add(buildResult("R002", noExerciseDays));
|
|
|
+ }
|
|
|
+
|
|
|
+ // R003: 饮水<500ml 连续2天
|
|
|
+ int lowWaterDays = countConsecutiveLowWaterDays(childId);
|
|
|
+ if (lowWaterDays >= 2) {
|
|
|
+ results.add(buildResult("R003", lowWaterDays));
|
|
|
+ }
|
|
|
+
|
|
|
+ // R004: 运动强度突增>50%
|
|
|
+ if (checkExerciseSurge(childId)) {
|
|
|
+ results.add(buildResult("R004", 0));
|
|
|
+ }
|
|
|
+
|
|
|
+ return results;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, String> buildResult(String ruleCode, int days) {
|
|
|
+ String[] rule = RULES.get(ruleCode);
|
|
|
+ Map<String, String> result = new HashMap<>();
|
|
|
+ String text = String.format(rule[0], days);
|
|
|
+ result.put("ruleCode", ruleCode);
|
|
|
+ result.put("alertText", text);
|
|
|
+ result.put("actionSuggestion", rule[1]);
|
|
|
+ result.put("severity", rule[2]);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 计算连续无打卡天数(从今天往回数) */
|
|
|
+ private int countConsecutiveDaysWithoutCheckin(Long childId, Date today) {
|
|
|
+ int days = 0;
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(today);
|
|
|
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
|
|
+
|
|
|
+ for (int i = 0; i < 30; i++) {
|
|
|
+ String dateStr = sdf.format(cal.getTime());
|
|
|
+ long count = healthCheckinMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<HealthCheckin>()
|
|
|
+ .eq(HealthCheckin::getChildId, childId)
|
|
|
+ .apply("DATE(checkin_date) = {0}", dateStr)
|
|
|
+ );
|
|
|
+ long microCount = microActionRecordMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<MicroActionRecord>()
|
|
|
+ .eq(MicroActionRecord::getChildId, childId)
|
|
|
+ .apply("DATE(completed_date) = {0}", dateStr)
|
|
|
+ );
|
|
|
+ if (count > 0 || microCount > 0) break;
|
|
|
+ days++;
|
|
|
+ cal.add(Calendar.DAY_OF_YEAR, -1);
|
|
|
+ }
|
|
|
+ return days;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 检查连续3天睡眠评分下降 */
|
|
|
+ private boolean checkSleepDecline(Long childId) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
|
|
+ List<Integer> scores = new ArrayList<>();
|
|
|
+
|
|
|
+ for (int i = 0; i < 4; i++) {
|
|
|
+ String dateStr = sdf.format(cal.getTime());
|
|
|
+ HealthSleepRecord record = healthSleepRecordMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<HealthSleepRecord>()
|
|
|
+ .eq(HealthSleepRecord::getMemberId, childId)
|
|
|
+ .apply("DATE(created_at) = {0}", dateStr)
|
|
|
+ .last("LIMIT 1")
|
|
|
+ );
|
|
|
+ if (record != null && record.getQualityScore() != null) {
|
|
|
+ scores.add(record.getQualityScore());
|
|
|
+ } else {
|
|
|
+ return false; // 某天无数据,无法判断趋势
|
|
|
+ }
|
|
|
+ cal.add(Calendar.DAY_OF_YEAR, -1);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 需要最近4天数据,检查最近3天的趋势
|
|
|
+ if (scores.size() < 4) return false;
|
|
|
+ // 每天严格递减才算"持续下降"
|
|
|
+ return scores.get(0) < scores.get(1) &&
|
|
|
+ scores.get(1) < scores.get(2) &&
|
|
|
+ scores.get(2) < scores.get(3);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 计算距离上次运动的天数 */
|
|
|
+ private int countDaysSinceLastExercise(Long childId) {
|
|
|
+ HealthExerciseRecord last = healthExerciseRecordMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<HealthExerciseRecord>()
|
|
|
+ .eq(HealthExerciseRecord::getMemberId, childId)
|
|
|
+ .orderByDesc(HealthExerciseRecord::getStartTime)
|
|
|
+ .last("LIMIT 1")
|
|
|
+ );
|
|
|
+ if (last == null || last.getStartTime() == null) return 999;
|
|
|
+ long diff = System.currentTimeMillis() - last.getStartTime().getTime();
|
|
|
+ return (int) (diff / (1000 * 60 * 60 * 24));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 计算连续饮水偏低天数 */
|
|
|
+ private int countConsecutiveLowWaterDays(Long childId) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
|
|
+ int days = 0;
|
|
|
+
|
|
|
+ for (int i = 0; i < 7; i++) {
|
|
|
+ String dateStr = sdf.format(cal.getTime());
|
|
|
+ // 统计当天总饮水量
|
|
|
+ List<HealthWaterRecord> records = healthWaterRecordMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<HealthWaterRecord>()
|
|
|
+ .eq(HealthWaterRecord::getMemberId, childId)
|
|
|
+ .apply("DATE(drunk_at) = {0}", dateStr)
|
|
|
+ );
|
|
|
+ int totalMl = records.stream().filter(r -> r.getAmountMl() != null)
|
|
|
+ .mapToInt(HealthWaterRecord::getAmountMl).sum();
|
|
|
+ if (totalMl >= 500) break;
|
|
|
+ if (!records.isEmpty()) days++;
|
|
|
+ cal.add(Calendar.DAY_OF_YEAR, -1);
|
|
|
+ }
|
|
|
+ return days;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 检查运动量是否突增 >50%(对比前3天均值) */
|
|
|
+ private boolean checkExerciseSurge(Long childId) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
|
|
+
|
|
|
+ // 今天运动量
|
|
|
+ String todayStr = sdf.format(cal.getTime());
|
|
|
+ int todayDuration = 0;
|
|
|
+ List<HealthExerciseRecord> todayRecords = healthExerciseRecordMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<HealthExerciseRecord>()
|
|
|
+ .eq(HealthExerciseRecord::getMemberId, childId)
|
|
|
+ .apply("DATE(start_time) = {0}", todayStr)
|
|
|
+ );
|
|
|
+ for (HealthExerciseRecord r : todayRecords) {
|
|
|
+ if (r.getDurationMinutes() != null) todayDuration += r.getDurationMinutes();
|
|
|
+ }
|
|
|
+ if (todayDuration == 0) return false;
|
|
|
+
|
|
|
+ // 过去3天均值
|
|
|
+ int prevTotal = 0;
|
|
|
+ int prevCount = 0;
|
|
|
+ for (int i = 1; i <= 3; i++) {
|
|
|
+ cal.add(Calendar.DAY_OF_YEAR, -1);
|
|
|
+ String prevStr = sdf.format(cal.getTime());
|
|
|
+ List<HealthExerciseRecord> prevRecords = healthExerciseRecordMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<HealthExerciseRecord>()
|
|
|
+ .eq(HealthExerciseRecord::getMemberId, childId)
|
|
|
+ .apply("DATE(start_time) = {0}", prevStr)
|
|
|
+ );
|
|
|
+ for (HealthExerciseRecord r : prevRecords) {
|
|
|
+ if (r.getDurationMinutes() != null) {
|
|
|
+ prevTotal += r.getDurationMinutes();
|
|
|
+ prevCount++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (prevCount == 0) return false;
|
|
|
+ int avgPrev = prevTotal / prevCount;
|
|
|
+ return avgPrev > 0 && todayDuration > avgPrev * 1.5;
|
|
|
+ }
|
|
|
+}
|