فهرست منبع

feat(mind): implement emotion alert service with risk detection

Create EmotionAlertMapper, EmotionAlertService (checkRiskAfterCheckin with crisis keywords, consecutive low mood ≥3d, sharp decline ≥4pts detection), and EmotionAlertController (active/resolve/checkin-alerts endpoints).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Xiaogang Liao 2 ماه پیش
والد
کامیت
610fb6eb1d

+ 62 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionAlertController.java

@@ -0,0 +1,62 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.EmotionAlertVO;
+import com.etotem.cfc.service.EmotionAlertService;
+import com.etotem.cfc.service.MentalHealthScreenService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/mind/alert")
+public class EmotionAlertController {
+
+    @Resource
+    private MentalHealthScreenService mentalHealthScreenService;
+
+    @Resource
+    private EmotionAlertService emotionAlertService;
+
+    /**
+     * 获取活跃告警列表
+     */
+    @PostMapping("/active")
+    public Result<?> active(@RequestBody Map<String, Object> params) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        List<EmotionAlertVO> alerts = mentalHealthScreenService.getActiveAlerts(childId);
+        return Result.success(alerts);
+    }
+
+    /**
+     * 获取情绪风险告警(从打卡自动检测)
+     */
+    @PostMapping("/checkin-alerts")
+    public Result<?> checkinAlerts(@RequestBody Map<String, Object> params) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        List<EmotionAlertVO> alerts = emotionAlertService.getActiveAlerts(childId);
+        return Result.success(alerts);
+    }
+
+    /**
+     * 标记告警已处理
+     */
+    @PostMapping("/resolve")
+    public Result<?> resolve(@RequestBody Map<String, Object> params) {
+        Long alertId = Long.valueOf(params.get("alertId").toString());
+        String source = params.containsKey("source") ? params.get("source").toString() : "screening";
+        if ("checkin".equals(source)) {
+            emotionAlertService.resolveAlert(alertId);
+        } else {
+            mentalHealthScreenService.resolveAlert(alertId);
+        }
+        return Result.success(null);
+    }
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/EmotionAlertMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.EmotionAlert;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface EmotionAlertMapper extends BaseMapper<EmotionAlert> {
+}

+ 183 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EmotionAlertService.java

@@ -0,0 +1,183 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.EmotionAlertVO;
+import com.etotem.cfc.entity.EmotionAlert;
+import com.etotem.cfc.entity.EmotionCheckin;
+import com.etotem.cfc.mapper.EmotionAlertMapper;
+import com.etotem.cfc.mapper.EmotionCheckinMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 情绪风险告警服务 —— 在每次情绪打卡完成后自动检查风险模式:
+ * 1. 连续低情绪(3天+ moodScore <= 4)
+ * 2. 情绪锐降(比前一天下降 4+ 分)
+ * 3. 备注中的危机关键词
+ */
+@Slf4j
+@Service
+public class EmotionAlertService {
+
+    @Resource
+    private EmotionAlertMapper emotionAlertMapper;
+
+    @Resource
+    private EmotionCheckinMapper emotionCheckinMapper;
+
+    /**
+     * 检查情绪打卡是否存在风险,返回新创建的告警列表
+     */
+    public List<EmotionAlert> checkRiskAfterCheckin(EmotionCheckin checkin) {
+        List<EmotionAlert> newAlerts = new ArrayList<>();
+
+        // 1. 检查备注中是否含危机关键词
+        if (checkin.getNote() != null) {
+            Set<String> matched = matchCrisisKeywords(checkin.getNote());
+            if (!matched.isEmpty()) {
+                EmotionAlert alert = createAlert(
+                        checkin.getChildId(),
+                        "crisis_keyword",
+                        "high",
+                        "⚠️ 危机关键词预警",
+                        "情绪打卡备注中检测到危机关键词:" + String.join(", ", matched) + ",建议及时关注。",
+                        checkin.getId()
+                );
+                newAlerts.add(alert);
+            }
+        }
+
+        // 2. 检查是否有 moodScore 数据
+        if (checkin.getMoodScore() == null) {
+            return newAlerts;
+        }
+
+        // 3. 检查连续低情绪
+        List<EmotionCheckin> recent = getRecentCheckins(checkin.getChildId(), 7);
+        List<EmotionCheckin> lowMoodDays = recent.stream()
+                .filter(e -> e.getMoodScore() != null && e.getMoodScore() <= 4)
+                .collect(Collectors.toList());
+
+        if (lowMoodDays.size() >= 3) {
+            // 检查是否已有未解决的连续低情绪告警
+            boolean hasExisting = hasActiveAlert(checkin.getChildId(), "consecutive_low_mood");
+            if (!hasExisting) {
+                EmotionAlert alert = createAlert(
+                        checkin.getChildId(),
+                        "consecutive_low_mood",
+                        lowMoodDays.size() >= 5 ? "high" : "medium",
+                        "⚡ 连续低情绪提醒",
+                        "最近" + recent.size() + "天中有" + lowMoodDays.size() + "天情绪偏低(≤4分),建议关注孩子心理状态。",
+                        checkin.getId()
+                );
+                newAlerts.add(alert);
+            }
+        }
+
+        // 4. 检查情绪锐降 (与最近一次打卡比较)
+        if (recent.size() >= 2) {
+            EmotionCheckin prev = recent.get(1); // 上一次
+            if (prev.getMoodScore() != null && (prev.getMoodScore() - checkin.getMoodScore()) >= 4) {
+                boolean hasDeclineAlert = hasActiveAlert(checkin.getChildId(), "sharp_decline");
+                if (!hasDeclineAlert) {
+                    EmotionAlert alert = createAlert(
+                            checkin.getChildId(),
+                            "sharp_decline",
+                            "medium",
+                            "📉 情绪明显下降",
+                            "情绪评分从" + prev.getMoodScore() + "分降至" + checkin.getMoodScore() + "分,下降幅度较大。",
+                            checkin.getId()
+                    );
+                    newAlerts.add(alert);
+                }
+            }
+        }
+
+        return newAlerts;
+    }
+
+    /**
+     * 批量创建检查告警(用于后台定时任务或全量扫描)
+     */
+    public List<EmotionAlertVO> getActiveAlerts(Long childId) {
+        LambdaQueryWrapper<EmotionAlert> wrapper = new LambdaQueryWrapper<EmotionAlert>()
+                .eq(EmotionAlert::getChildId, childId)
+                .eq(EmotionAlert::getResolved, false)
+                .orderByDesc(EmotionAlert::getCreatedAt);
+        return emotionAlertMapper.selectList(wrapper)
+                .stream().map(this::toVO).collect(Collectors.toList());
+    }
+
+    public void resolveAlert(Long alertId) {
+        EmotionAlert alert = emotionAlertMapper.selectById(alertId);
+        if (alert != null) {
+            alert.setResolved(true);
+            alert.setResolvedAt(new Date());
+            emotionAlertMapper.updateById(alert);
+        }
+    }
+
+    // === 内部方法 ===
+
+    private static final Set<String> CRISIS_KEYWORDS = new HashSet<>(Arrays.asList(
+            "自杀", "不想活", "死了算了", "活不下去", "自残", "伤害自己",
+            "好痛苦", "没意思", "撑不下去了", "想死", "结束",
+            "suicide", "kill myself", "end my life", "self-harm", "cutting"
+    ));
+
+    private Set<String> matchCrisisKeywords(String text) {
+        Set<String> matched = new HashSet<>();
+        for (String keyword : CRISIS_KEYWORDS) {
+            if (text.contains(keyword)) {
+                matched.add(keyword);
+            }
+        }
+        return matched;
+    }
+
+    private List<EmotionCheckin> getRecentCheckins(Long childId, int limit) {
+        return emotionCheckinMapper.selectLatestByChildId(childId, limit);
+    }
+
+    private boolean hasActiveAlert(Long childId, String alertType) {
+        LambdaQueryWrapper<EmotionAlert> wrapper = new LambdaQueryWrapper<EmotionAlert>()
+                .eq(EmotionAlert::getChildId, childId)
+                .eq(EmotionAlert::getAlertType, alertType)
+                .eq(EmotionAlert::getResolved, false);
+        return emotionAlertMapper.selectCount(wrapper) > 0;
+    }
+
+    private EmotionAlert createAlert(Long childId, String alertType, String severity,
+                                     String title, String message, Long relatedCheckinId) {
+        EmotionAlert alert = new EmotionAlert();
+        alert.setChildId(childId);
+        alert.setAlertType(alertType);
+        alert.setSeverity(severity);
+        alert.setTitle(title);
+        alert.setMessage(message);
+        alert.setRelatedCheckinId(relatedCheckinId);
+        alert.setResolved(false);
+        alert.setCreatedAt(new Date());
+        emotionAlertMapper.insert(alert);
+
+        log.info("情绪告警创建: childId={}, type={}, severity={}", childId, alertType, severity);
+        return alert;
+    }
+
+    private EmotionAlertVO toVO(EmotionAlert alert) {
+        EmotionAlertVO vo = new EmotionAlertVO();
+        vo.setId(alert.getId());
+        vo.setChildId(alert.getChildId());
+        vo.setAlertType(alert.getAlertType());
+        vo.setSeverity(alert.getSeverity());
+        vo.setTitle(alert.getTitle());
+        vo.setMessage(alert.getMessage());
+        vo.setResolved(alert.getResolved());
+        vo.setCreatedAt(alert.getCreatedAt());
+        return vo;
+    }
+}