Просмотр исходного кода

feat(mind): implement mental health screening service with PHQ-9/GAD-7 scoring

Create MentalHealthScreenMapper, MentalHealthScreenService (PHQ-9/GAD-7 scoring with severity classification, risk flags, suggestions), and ScreeningController (submit/result/history endpoints).

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Xiaogang Liao 2 месяцев назад
Родитель
Сommit
130e6008ff

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/ScreeningController.java

@@ -0,0 +1,63 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ScreenResultVO;
+import com.etotem.cfc.dto.ScreenSubmitDTO;
+import com.etotem.cfc.service.MentalHealthScreenService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+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/screening")
+public class ScreeningController {
+
+    @Resource
+    private MentalHealthScreenService mentalHealthScreenService;
+
+    /**
+     * 提交心理筛查问卷(PHQ-9 / GAD-7)
+     */
+    @PostMapping("/submit")
+    public Result<?> submit(@RequestBody ScreenSubmitDTO dto,
+                            @RequestAttribute("userId") Long userId) {
+        try {
+            ScreenResultVO result = mentalHealthScreenService.submitScreen(dto);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("提交心理筛查失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 获取筛查历史
+     */
+    @PostMapping("/history")
+    public Result<?> history(@RequestBody Map<String, Object> params) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        String screenType = params.containsKey("screenType") ? params.get("screenType").toString() : null;
+        int limit = params.containsKey("limit") ? Integer.parseInt(params.get("limit").toString()) : 10;
+        List<ScreenResultVO> list = mentalHealthScreenService.getScreenHistory(childId, screenType, limit);
+        return Result.success(list);
+    }
+
+    /**
+     * 获取最新一次筛查结果
+     */
+    @PostMapping("/latest")
+    public Result<?> latest(@RequestBody Map<String, Object> params) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        String screenType = params.containsKey("screenType") ? params.get("screenType").toString() : null;
+        ScreenResultVO result = mentalHealthScreenService.getLatestScreen(childId, screenType);
+        return Result.success(result);
+    }
+}

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

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

+ 307 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MentalHealthScreenService.java

@@ -0,0 +1,307 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.EmotionAlertVO;
+import com.etotem.cfc.dto.ScreenResultVO;
+import com.etotem.cfc.dto.ScreenSubmitDTO;
+import com.etotem.cfc.entity.EmotionAlert;
+import com.etotem.cfc.entity.MentalHealthScreen;
+import com.etotem.cfc.mapper.EmotionAlertMapper;
+import com.etotem.cfc.mapper.MentalHealthScreenMapper;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class MentalHealthScreenService {
+
+    @Resource
+    private MentalHealthScreenMapper mentalHealthScreenMapper;
+
+    @Resource
+    private EmotionAlertMapper emotionAlertMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    // === PHQ-9 量表定义 ===
+    private static final List<String> PHQ9_QUESTIONS = Arrays.asList(
+            "做事时提不起劲或没有兴趣",
+            "感到心情低落、沮丧或绝望",
+            "入睡困难、睡不安稳或睡眠过多",
+            "感觉疲倦或没有活力",
+            "食欲不振或吃太多",
+            "觉得自己很糟——或觉得自己很失败,或让自己或家人失望",
+            "对事物专注有困难,例如阅读报纸或看电视时",
+            "行动或说话速度缓慢到别人已经觉察?或正好相反——比平常更多话",
+            "有不如死掉或用某种方式伤害自己的念头"
+    );
+
+    private static final Map<Integer, String> PHQ9_SEVERITY_MAP = new LinkedHashMap<>();
+
+    static {
+        PHQ9_SEVERITY_MAP.put(4, "none");
+        PHQ9_SEVERITY_MAP.put(9, "mild");
+        PHQ9_SEVERITY_MAP.put(14, "moderate");
+        PHQ9_SEVERITY_MAP.put(19, "moderately_severe");
+        // >=20 -> "severe"
+    }
+
+    // === GAD-7 量表定义 ===
+    private static final List<String> GAD7_QUESTIONS = Arrays.asList(
+            "感到紧张、焦虑或烦躁",
+            "无法停止或控制担忧",
+            "对各种各样的事情担忧过多",
+            "很难放松下来",
+            "由于不安而无法静坐",
+            "变得容易烦恼或急躁",
+            "感到害怕,好像可能发生可怕的事情"
+    );
+
+    private static final Map<Integer, String> GAD7_SEVERITY_MAP = new LinkedHashMap<>();
+
+    static {
+        GAD7_SEVERITY_MAP.put(4, "none");
+        GAD7_SEVERITY_MAP.put(9, "mild");
+        GAD7_SEVERITY_MAP.put(14, "moderate");
+        // >=15 -> "severe"
+    }
+
+    // === 风险关键词 (Phase 1.2) ===
+    private static final Set<String> CRISIS_KEYWORDS = new HashSet<>(Arrays.asList(
+            "自杀", "不想活", "死了算了", "活不下去", "自残", "伤害自己",
+            "suicide", "kill myself", "end my life", "self-harm", "cutting"
+    ));
+
+    /**
+     * 提交心理筛查问卷并计算评分
+     */
+    @Transactional
+    public ScreenResultVO submitScreen(ScreenSubmitDTO dto) {
+        int totalScore = dto.getAnswers().stream().mapToInt(Integer::intValue).sum();
+        String severity = calculateSeverity(dto.getScreenType(), totalScore);
+
+        // 检测风险标记
+        List<String> riskFlags = detectRiskFlags(dto.getScreenType(), dto.getAnswers(), totalScore);
+
+        // 生成建议文案
+        String suggestions = generateSuggestions(dto.getScreenType(), severity, riskFlags);
+
+        MentalHealthScreen screen = new MentalHealthScreen();
+        screen.setChildId(dto.getChildId());
+        screen.setScreenType(dto.getScreenType());
+        try {
+            screen.setAnswers(objectMapper.writeValueAsString(dto.getAnswers()));
+        } catch (JsonProcessingException e) {
+            screen.setAnswers(dto.getAnswers().toString());
+        }
+        screen.setTotalScore(totalScore);
+        screen.setSeverityLevel(severity);
+        try {
+            screen.setRiskFlags(objectMapper.writeValueAsString(riskFlags));
+        } catch (JsonProcessingException e) {
+            screen.setRiskFlags(String.join(",", riskFlags));
+        }
+        screen.setSuggestions(suggestions);
+        screen.setCompletedAt(new Date());
+        mentalHealthScreenMapper.insert(screen);
+
+        // 高风险则自动创建告警
+        if ("moderately_severe".equals(severity) || "severe".equals(severity) || !riskFlags.isEmpty()) {
+            createAlertFromScreen(screen, severity, riskFlags);
+        }
+
+        return toVO(screen);
+    }
+
+    /**
+     * 获取筛查历史
+     */
+    public List<ScreenResultVO> getScreenHistory(Long childId, String screenType, int limit) {
+        LambdaQueryWrapper<MentalHealthScreen> wrapper = new LambdaQueryWrapper<MentalHealthScreen>()
+                .eq(MentalHealthScreen::getChildId, childId)
+                .eq(screenType != null, MentalHealthScreen::getScreenType, screenType)
+                .orderByDesc(MentalHealthScreen::getCompletedAt)
+                .last("LIMIT " + limit);
+        return mentalHealthScreenMapper.selectList(wrapper)
+                .stream().map(this::toVO).collect(Collectors.toList());
+    }
+
+    /**
+     * 获取最新一次筛查结果
+     */
+    public ScreenResultVO getLatestScreen(Long childId, String screenType) {
+        LambdaQueryWrapper<MentalHealthScreen> wrapper = new LambdaQueryWrapper<MentalHealthScreen>()
+                .eq(MentalHealthScreen::getChildId, childId)
+                .eq(MentalHealthScreen::getScreenType, screenType)
+                .orderByDesc(MentalHealthScreen::getCompletedAt)
+                .last("LIMIT 1");
+        MentalHealthScreen screen = mentalHealthScreenMapper.selectOne(wrapper);
+        return screen != null ? toVO(screen) : null;
+    }
+
+    /**
+     * 获取活跃告警(未解决的)
+     */
+    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::toAlertVO).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 String calculateSeverity(String screenType, int totalScore) {
+        if ("PHQ9".equals(screenType) || "phq9".equalsIgnoreCase(screenType)) {
+            if (totalScore >= 20) return "severe";
+            for (Map.Entry<Integer, String> entry : PHQ9_SEVERITY_MAP.entrySet()) {
+                if (totalScore <= entry.getKey()) return entry.getValue();
+            }
+            return "none";
+        } else if ("GAD7".equals(screenType) || "gad7".equalsIgnoreCase(screenType)) {
+            if (totalScore >= 15) return "severe";
+            for (Map.Entry<Integer, String> entry : GAD7_SEVERITY_MAP.entrySet()) {
+                if (totalScore <= entry.getKey()) return entry.getValue();
+            }
+            return "none";
+        }
+        return "unknown";
+    }
+
+    private List<String> detectRiskFlags(String screenType, List<Integer> answers, int totalScore) {
+        List<String> flags = new ArrayList<>();
+        if ("PHQ9".equals(screenType) || "phq9".equalsIgnoreCase(screenType)) {
+            // Q9: 自杀/自伤念头 (answers index 8)
+            if (answers.size() > 8 && answers.get(8) > 0) {
+                flags.add("suicide_ideation");
+            }
+            // Q1+Q2 high: 核心抑郁症状显著
+            if (answers.size() > 1 && answers.get(0) >= 2 && answers.get(1) >= 2) {
+                flags.add("core_depression_symptoms");
+            }
+        } else if ("GAD7".equals(screenType) || "gad7".equalsIgnoreCase(screenType)) {
+            // Q1+Q2 high: 核心焦虑症状显著
+            if (answers.size() > 1 && answers.get(0) >= 2 && answers.get(1) >= 2) {
+                flags.add("core_anxiety_symptoms");
+            }
+        }
+        return flags;
+    }
+
+    private String generateSuggestions(String screenType, String severity, List<String> riskFlags) {
+        StringBuilder sb = new StringBuilder();
+
+        if (riskFlags.contains("suicide_ideation")) {
+            sb.append("⚠️ 请注意:您提到了一些与自伤/自杀相关的想法。请立即联系心理危机热线(400-161-9995)或前往就近医院寻求帮助。");
+            return sb.toString();  // 危机建议优先级最高
+        }
+
+        if ("severe".equals(severity) || "moderately_severe".equals(severity)) {
+            sb.append("建议尽快联系专业心理咨询师或精神科医生进行进一步评估。");
+        } else if ("moderate".equals(severity)) {
+            sb.append("建议关注自身心理状态,适当进行放松练习,如有需要可咨询心理专业人员。");
+        } else if ("mild".equals(severity)) {
+            sb.append("目前状态基本良好,建议保持规律作息和适量运动,关注情绪变化。");
+        } else {
+            sb.append("状态良好,继续保持!");
+        }
+
+        if (!riskFlags.isEmpty()) {
+            sb.append(" 部分指标需要关注,建议定期进行心理自评。");
+        }
+
+        return sb.toString();
+    }
+
+    private void createAlertFromScreen(MentalHealthScreen screen, String severity, List<String> riskFlags) {
+        EmotionAlert alert = new EmotionAlert();
+        alert.setChildId(screen.getChildId());
+        alert.setResolved(false);
+        alert.setCreatedAt(new Date());
+
+        if (riskFlags.contains("suicide_ideation")) {
+            alert.setAlertType("crisis_keyword");
+            alert.setSeverity("high");
+            alert.setTitle("⚠️ 危机预警:自杀/自伤风险");
+            alert.setMessage("心理筛查结果显示存在自杀/自伤相关念头,建议立即联系危机干预热线。");
+        } else if ("severe".equals(severity)) {
+            alert.setAlertType("consecutive_low_mood");
+            alert.setSeverity("high");
+            String typeLabel = "PHQ9".equals(screen.getScreenType()) ? "抑郁" : "焦虑";
+            alert.setTitle("⚠️ " + typeLabel + "水平严重");
+            alert.setMessage("" + screen.getScreenType() + "筛查得分为" + screen.getTotalScore() + "分,建议尽快寻求专业帮助。");
+        } else if ("moderately_severe".equals(severity)) {
+            alert.setAlertType("consecutive_low_mood");
+            alert.setSeverity("medium");
+            String typeLabel = "PHQ9".equals(screen.getScreenType()) ? "抑郁" : "焦虑";
+            alert.setTitle("⚡ " + typeLabel + "水平中重度");
+            alert.setMessage("" + screen.getScreenType() + "筛查得分为" + screen.getTotalScore() + "分,建议关注心理状态。");
+        } else {
+            alert.setAlertType("consecutive_low_mood");
+            alert.setSeverity("low");
+            alert.setTitle("心理状态提醒");
+            alert.setMessage("筛查结果显示一些需要关注的指标,建议定期自评。");
+        }
+
+        emotionAlertMapper.insert(alert);
+    }
+
+    private ScreenResultVO toVO(MentalHealthScreen screen) {
+        ScreenResultVO vo = new ScreenResultVO();
+        vo.setId(screen.getId());
+        vo.setChildId(screen.getChildId());
+        vo.setScreenType(screen.getScreenType());
+        vo.setTotalScore(screen.getTotalScore());
+        vo.setSeverityLevel(screen.getSeverityLevel());
+        vo.setSuggestions(screen.getSuggestions());
+        vo.setCompletedAt(screen.getCompletedAt());
+
+        // 解析 riskFlags JSON
+        if (screen.getRiskFlags() != null) {
+            try {
+                List<String> flags = objectMapper.readValue(screen.getRiskFlags(), List.class);
+                vo.setRiskFlags(flags);
+            } catch (Exception e) {
+                vo.setRiskFlags(Collections.singletonList(screen.getRiskFlags()));
+            }
+        } else {
+            vo.setRiskFlags(Collections.emptyList());
+        }
+
+        return vo;
+    }
+
+    private EmotionAlertVO toAlertVO(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;
+    }
+}