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

feat(mind): implement emotion checkin service with trend/report + alert wiring

Update EmotionCheckinService.createCheckin() to persist fine-grained fields and return upgraded VO. Add getWeeklyReport() for trend aggregation. Wire EmotionAlertService for risk detection. Add getTrend() with scatter/moodTrend data. Add weekly report endpoint to controller.

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

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

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java

@@ -71,4 +71,11 @@ public class EmotionCheckinController {
         EmotionStatsVO result = emotionCheckinService.getStats(childId);
         return Result.success(result);
     }
+
+    @PostMapping("/weekly-report")
+    public Result<?> weeklyReport(@RequestBody Map<String, Object> params) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        Object result = emotionCheckinService.getWeeklyReport(childId);
+        return Result.success(result);
+    }
 }

+ 136 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/EmotionCheckinService.java

@@ -6,6 +6,8 @@ import com.etotem.cfc.dto.EmotionCheckinDTO;
 import com.etotem.cfc.dto.EmotionCheckinVO;
 import com.etotem.cfc.dto.EmotionStatsVO;
 import com.etotem.cfc.dto.EmotionTrendVO;
+import com.etotem.cfc.dto.MoodTrendPoint;
+import com.etotem.cfc.dto.AffectPoint;
 import com.etotem.cfc.entity.Child;
 import com.etotem.cfc.entity.EmotionCheckin;
 import com.etotem.cfc.mapper.ChildMapper;
@@ -31,6 +33,9 @@ public class EmotionCheckinService {
     @Resource
     private EnergyService energyService;
 
+    @Resource
+    private EmotionAlertService emotionAlertService;
+
     @Transactional
     public EmotionCheckinVO createCheckin(EmotionCheckinDTO dto, Long userId) {
         Child child = childMapper.selectById(dto.getChildId());
@@ -46,10 +51,28 @@ public class EmotionCheckinService {
         checkin.setNote(dto.getNote());
         checkin.setEnergyAwarded(5);
         checkin.setCheckinDate(new Date());
-        emotionCheckinMapper.insert(checkin);
 
+        // Phase 1.1: 精细情绪字段
+        if (dto.getMoodScore() != null) checkin.setMoodScore(dto.getMoodScore());
+        if (dto.getEmotionType() != null) checkin.setEmotionType(dto.getEmotionType());
+        if (dto.getStressLevel() != null) checkin.setStressLevel(dto.getStressLevel());
+        if (dto.getArousalLevel() != null) checkin.setArousalLevel(dto.getArousalLevel());
+        if (dto.getEnergyLevel() != null) checkin.setEnergyLevel(dto.getEnergyLevel());
+
+        // Phase 3.1: 睡眠字段预埋
+        if (dto.getSleepHours() != null) checkin.setSleepHours(dto.getSleepHours());
+        if (dto.getSleepQuality() != null) checkin.setSleepQuality(dto.getSleepQuality());
+
+        emotionCheckinMapper.insert(checkin);
         awardEnergy(checkin.getId(), dto.getChildId());
 
+        // Phase 1.2: 创建打卡后自动检测风险
+        try {
+            emotionAlertService.checkRiskAfterCheckin(checkin);
+        } catch (Exception e) {
+            log.error("情绪风险检测失败: checkinId={}", checkin.getId(), e);
+        }
+
         return toVO(checkin, child.getNickname(), false);
     }
 
@@ -116,7 +139,11 @@ public class EmotionCheckinService {
 
         Map<String, Integer> distribution = new HashMap<>();
         List<Map<String, Object>> dailyData = new ArrayList<>();
+        List<MoodTrendPoint> moodTrend = new ArrayList<>();
+        List<AffectPoint> scatterData = new ArrayList<>();
         int totalEnergy = 0;
+        double moodSum = 0;
+        int moodCount = 0;
 
         for (EmotionCheckin e : filtered) {
             String weather = e.getMoodWeather();
@@ -126,15 +153,46 @@ public class EmotionCheckinService {
             Map<String, Object> day = new HashMap<>();
             day.put("date", e.getCheckinDate());
             day.put("weather", e.getMoodWeather());
+            // Include fine-grained data in daily payload
+            if (e.getMoodScore() != null) day.put("moodScore", e.getMoodScore());
+            if (e.getArousalLevel() != null) day.put("arousalLevel", e.getArousalLevel());
+            if (e.getEmotionType() != null) day.put("emotionType", e.getEmotionType());
+            if (e.getStressLevel() != null) day.put("stressLevel", e.getStressLevel());
             dailyData.add(day);
+
+            // Mood trend for line chart
+            if (e.getMoodScore() != null) {
+                MoodTrendPoint pt = new MoodTrendPoint();
+                pt.setDate(e.getCheckinDate().toString());
+                pt.setMoodScore(e.getMoodScore());
+                moodTrend.add(pt);
+                moodSum += e.getMoodScore();
+                moodCount++;
+            }
+
+            // Scatter data for valence-arousal plot
+            if (e.getMoodScore() != null && e.getArousalLevel() != null) {
+                AffectPoint ap = new AffectPoint();
+                ap.setDate(e.getCheckinDate().toString());
+                ap.setValence(e.getMoodScore());
+                ap.setArousal(e.getArousalLevel());
+                scatterData.add(ap);
+            }
         }
 
+        // Sort by date ascending (currently descending from query)
+        Collections.reverse(moodTrend);
+        Collections.reverse(scatterData);
+
         EmotionTrendVO vo = new EmotionTrendVO();
         vo.setPeriod(period);
         vo.setWeatherDistribution(distribution);
         vo.setDailyData(dailyData);
         vo.setTotalCheckins(filtered.size());
         vo.setTotalEnergy(totalEnergy);
+        vo.setMoodScoreAvg(moodCount > 0 ? Math.round(moodSum / moodCount * 10.0) / 10.0 : null);
+        vo.setMoodTrend(moodTrend);
+        vo.setScatterData(scatterData);
         return vo;
     }
 
@@ -164,6 +222,72 @@ public class EmotionCheckinService {
         return vo;
     }
 
+    /**
+     * 周度情绪报告汇总
+     */
+    public Map<String, Object> getWeeklyReport(Long childId) {
+        LambdaQueryWrapper<EmotionCheckin> wrapper = new LambdaQueryWrapper<EmotionCheckin>()
+                .eq(EmotionCheckin::getChildId, childId)
+                .orderByDesc(EmotionCheckin::getCheckinDate);
+        List<EmotionCheckin> all = emotionCheckinMapper.selectList(wrapper);
+
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_YEAR, -7);
+        Date cutoff = cal.getTime();
+
+        List<EmotionCheckin> weekly = all.stream()
+                .filter(e -> e.getCheckinDate() != null && e.getCheckinDate().after(cutoff))
+                .collect(Collectors.toList());
+
+        Map<String, Object> report = new HashMap<>();
+        report.put("totalCheckins", weekly.size());
+
+        // Average mood score
+        OptionalDouble avgMood = weekly.stream()
+                .filter(e -> e.getMoodScore() != null)
+                .mapToInt(EmotionCheckin::getMoodScore)
+                .average();
+        report.put("avgMoodScore", avgMood.isPresent() ? Math.round(avgMood.getAsDouble() * 10.0) / 10.0 : null);
+
+        // Dominant emotion type
+        Map<String, Long> typeCount = weekly.stream()
+                .filter(e -> e.getEmotionType() != null)
+                .collect(Collectors.groupingBy(EmotionCheckin::getEmotionType, Collectors.counting()));
+        report.put("emotionTypeDistribution", typeCount);
+
+        // Average stress
+        OptionalDouble avgStress = weekly.stream()
+                .filter(e -> e.getStressLevel() != null)
+                .mapToInt(EmotionCheckin::getStressLevel)
+                .average();
+        report.put("avgStressLevel", avgStress.isPresent() ? Math.round(avgStress.getAsDouble() * 10.0) / 10.0 : null);
+
+        // Count of low-mood days (moodScore <= 4)
+        long lowMoodDays = weekly.stream()
+                .filter(e -> e.getMoodScore() != null && e.getMoodScore() <= 4)
+                .count();
+        report.put("lowMoodDays", lowMoodDays);
+
+        report.put("totalEnergy", weekly.stream()
+                .filter(e -> e.getEnergyAwarded() != null)
+                .mapToInt(EmotionCheckin::getEnergyAwarded)
+                .sum());
+
+        // Mood trend (last 7 data points)
+        List<Map<String, Object>> weekTrend = new ArrayList<>();
+        for (EmotionCheckin e : weekly) {
+            Map<String, Object> pt = new HashMap<>();
+            pt.put("date", e.getCheckinDate());
+            pt.put("moodScore", e.getMoodScore());
+            pt.put("weather", e.getMoodWeather());
+            weekTrend.add(pt);
+        }
+        weekTrend.sort(Comparator.comparing(m -> ((Map<String, Object>) m).get("date") != null ? ((Date) ((Map<String, Object>) m).get("date")).getTime() : 0));
+        report.put("dailyTrend", weekTrend);
+
+        return report;
+    }
+
     private int calculateStreak(List<EmotionCheckin> list) {
         if (list.isEmpty()) return 0;
         Calendar cal = Calendar.getInstance();
@@ -213,6 +337,17 @@ public class EmotionCheckinService {
         vo.setEnergyAwarded(ec.getEnergyAwarded());
         vo.setCheckinDate(ec.getCheckinDate());
         vo.setCreatedAt(ec.getCreatedAt());
+
+        // Phase 1.1: 精细情绪字段
+        vo.setMoodScore(ec.getMoodScore());
+        vo.setEmotionType(ec.getEmotionType());
+        vo.setStressLevel(ec.getStressLevel());
+        vo.setArousalLevel(ec.getArousalLevel());
+        vo.setEnergyLevel(ec.getEnergyLevel());
+
+        // Phase 3.1: 睡眠字段
+        vo.setSleepHours(ec.getSleepHours());
+        vo.setSleepQuality(ec.getSleepQuality());
         return vo;
     }
 }

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.dto.*;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -156,6 +158,50 @@ public class MembershipService implements MembershipServiceInterface {
         return true;
     }
 
+    /**
+     * 检查用户是否有指定功能的权限
+     * <p>
+     * 根据设计文档 Section 5.2:
+     * 1. 获取用户 memberLevel
+     * 2. 查 membership_levels 表对应等级的 features JSON
+     * 3. 检查 featureCode 是否在 features 数组中
+     * 4. 特殊:PROVIDER 类型还需校验 vendorStatus == 'approved'
+     *
+     * @param userId     用户ID
+     * @param featureCode 功能代码(如 "free_activities", "discount_purchase")
+     * @return true 如果有权限
+     */
+    public boolean hasPermission(Long userId, String featureCode) {
+        User user = userMapper.selectById(userId);
+        if (user == null) return false;
+
+        String level = getMemberLevel(userId);
+
+        MembershipLevel levelConfig = levelMapper.selectOne(
+                new LambdaQueryWrapper<MembershipLevel>()
+                        .eq(MembershipLevel::getLevelCode, level)
+        );
+        if (levelConfig == null || levelConfig.getFeatures() == null) return false;
+
+        if ("PROVIDER".equals(level) && !"approved".equals(user.getVendorStatus())) {
+            return false;
+        }
+
+        String featuresJson = levelConfig.getFeatures();
+        try {
+            // features 格式: ["free_activities","free_courses",...] 或逗号分隔
+            if (!featuresJson.startsWith("[")) {
+                return featuresJson.contains(featureCode);
+            }
+            ObjectMapper mapper = new ObjectMapper();
+            List<String> features = mapper.readValue(featuresJson,
+                    new TypeReference<List<String>>() {});
+            return features.contains(featureCode);
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
     // ==================== 订单与支付 ====================
 
     /**