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

feat(profile): 实现画像计算服务 — 五维指标聚合+增量写入

Sisyphus 2 месяцев назад
Родитель
Сommit
f08dd4d6ca

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProfileComputeService.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.service;
+
+public interface ProfileComputeService {
+    /** 为指定家庭成员重新计算并写入画像快照 */
+    void computeAndSave(Long memberId);
+    /** 批量计算所有家庭成员画像 */
+    void computeAll();
+}

+ 421 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.java

@@ -0,0 +1,421 @@
+package com.etotem.cfc.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.ProfileComputeService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.*;
+
+@Service
+public class ProfileComputeServiceImpl implements ProfileComputeService {
+
+    @Autowired private ProfileSnapshotMapper snapshotMapper;
+    @Autowired private ProfileHistoryMapper historyMapper;
+    @Autowired private HealthSleepRecordMapper sleepMapper;
+    @Autowired private HealthExerciseRecordMapper exerciseMapper;
+    @Autowired private HealthMealRecordMapper mealMapper;
+    @Autowired private HealthWaterRecordMapper waterMapper;
+    @Autowired private EmotionCheckinMapper emotionMapper;
+    @Autowired private TaskMapper taskMapper;
+    @Autowired private DanAssessmentResultMapper danMapper;
+    @Autowired private GameRecordMapper gameMapper;
+    @Autowired private FinanceCheckinMapper financeMapper;
+    @Autowired private FamilyMemberMapper memberMapper;
+    @Autowired private PointsLogMapper pointsLogMapper;
+
+    @Override
+    public void computeAndSave(Long memberId) {
+        LocalDate now = LocalDate.now();
+
+        JSONObject bodyMetrics = computeBodyMetrics(memberId, now);
+        JSONObject mindMetrics = computeMindMetrics(memberId, now);
+        JSONObject wisdomMetrics = computeWisdomMetrics(memberId);
+        JSONObject actionMetrics = computeActionMetrics(memberId, now);
+        JSONObject wealthMetrics = computeWealthMetrics(memberId, now);
+        JSONObject dimensionScores = computeDimensionScores(bodyMetrics, mindMetrics, wisdomMetrics, actionMetrics, wealthMetrics);
+        String problemDomains = computeProblemDomains(memberId);
+
+        LocalDateTime computedAt = LocalDateTime.now();
+        JSONObject allMetrics = new JSONObject();
+        allMetrics.put("dimension_scores", dimensionScores);
+        allMetrics.put("body_metrics", bodyMetrics);
+        allMetrics.put("mind_metrics", mindMetrics);
+        allMetrics.put("wisdom_metrics", wisdomMetrics);
+        allMetrics.put("action_metrics", actionMetrics);
+        allMetrics.put("wealth_metrics", wealthMetrics);
+        allMetrics.put("problem_domains", problemDomains);
+        allMetrics.put("computed_at", computedAt.toString());
+
+        // upsert snapshot
+        LambdaQueryWrapper<ProfileSnapshot> qw = new LambdaQueryWrapper<>();
+        qw.eq(ProfileSnapshot::getMemberId, memberId);
+        ProfileSnapshot snapshot = snapshotMapper.selectOne(qw);
+
+        if (snapshot == null) {
+            snapshot = new ProfileSnapshot();
+            snapshot.setMemberId(memberId);
+        }
+        snapshot.setDimensionScores(dimensionScores.toJSONString());
+        snapshot.setBodyMetrics(bodyMetrics.toJSONString());
+        snapshot.setMindMetrics(mindMetrics.toJSONString());
+        snapshot.setWisdomMetrics(wisdomMetrics.toJSONString());
+        snapshot.setActionMetrics(actionMetrics.toJSONString());
+        snapshot.setWealthMetrics(wealthMetrics.toJSONString());
+        snapshot.setProblemDomains(problemDomains);
+        snapshot.setComputedAt(computedAt);
+        snapshotMapper.insert(snapshot);
+
+        // upsert history
+        LambdaQueryWrapper<ProfileHistory> hqw = new LambdaQueryWrapper<>();
+        hqw.eq(ProfileHistory::getMemberId, memberId)
+           .eq(ProfileHistory::getSnapshotDate, new java.sql.Date(now.atStartOfDay().toEpochDay() * 86400000));
+        ProfileHistory history = historyMapper.selectOne(hqw);
+        if (history == null) {
+            history = new ProfileHistory();
+            history.setMemberId(memberId);
+            history.setSnapshotDate(new java.sql.Date(now.atStartOfDay().toEpochDay() * 86400000));
+            history.setAllMetrics(allMetrics.toJSONString());
+            historyMapper.insert(history);
+        } else {
+            history.setAllMetrics(allMetrics.toJSONString());
+            historyMapper.updateById(history);
+        }
+
+        // cleanup old history
+        cleanOldHistory(memberId, now);
+    }
+
+    @Override
+    public void computeAll() {
+        LambdaQueryWrapper<FamilyMember> qw = new LambdaQueryWrapper<>();
+        qw.select(FamilyMember::getId);
+        List<FamilyMember> members = memberMapper.selectList(qw);
+        for (FamilyMember m : members) {
+            try {
+                computeAndSave(m.getId());
+            } catch (Exception e) {
+                // ignore individual failure
+            }
+        }
+    }
+
+    // ========== 各维度计算方法 ==========
+
+    private JSONObject computeBodyMetrics(Long memberId, LocalDate now) {
+        JSONObject m = new JSONObject();
+        LocalDate thirtyDaysAgo = now.minusDays(30);
+        LocalDate sevenDaysAgo = now.minusDays(7);
+
+        // 睡眠:近30天
+        LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
+        sqw.eq(HealthSleepRecord::getMemberId, memberId)
+           .ge(HealthSleepRecord::getCreatedAt, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()));
+        List<HealthSleepRecord> sleeps = sleepMapper.selectList(sqw);
+        if (!sleeps.isEmpty()) {
+            int totalMin = sleeps.stream().mapToInt(r -> r.getDurationMinutes() != null ? r.getDurationMinutes() : 0).sum();
+            m.put("sleep_dur_avg", Math.round(totalMin / (double) sleeps.size() / 60 * 10) / 10.0);
+            int deepTotal = sleeps.stream().mapToInt(r -> r.getDeepSleepMinutes() != null ? r.getDeepSleepMinutes() : 0).sum();
+            int lightTotal = sleeps.stream().mapToInt(r -> r.getLightSleepMinutes() != null ? r.getLightSleepMinutes() : 0).sum();
+            int remTotal = sleeps.stream().mapToInt(r -> r.getRemMinutes() != null ? r.getRemMinutes() : 0).sum();
+            int totalSleepMin = deepTotal + lightTotal + remTotal;
+            m.put("deep_sleep_pct", totalSleepMin > 0 ? Math.round((double) deepTotal / totalSleepMin * 100) : 0);
+            m.put("sleep_records_count", sleeps.size());
+        }
+
+        // 运动:近7天
+        LambdaQueryWrapper<HealthExerciseRecord> eqw = new LambdaQueryWrapper<>();
+        eqw.eq(HealthExerciseRecord::getMemberId, memberId)
+           .ge(HealthExerciseRecord::getCreatedAt, java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay()));
+        List<HealthExerciseRecord> exercises = exerciseMapper.selectList(eqw);
+        m.put("exercise_count_week", exercises.size());
+        int totalExMin = exercises.stream().mapToInt(r -> r.getDurationMinutes() != null ? r.getDurationMinutes() : 0).sum();
+        m.put("exercise_duration_week", totalExMin);
+
+        // 喝水:近30天
+        LambdaQueryWrapper<HealthWaterRecord> wqw = new LambdaQueryWrapper<>();
+        wqw.eq(HealthWaterRecord::getMemberId, memberId)
+           .ge(HealthWaterRecord::getCreatedAt, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()));
+        List<HealthWaterRecord> waters = waterMapper.selectList(wqw);
+        if (!waters.isEmpty()) {
+            int totalMl = waters.stream().mapToInt(r -> r.getAmountMl() != null ? r.getAmountMl() : 0).sum();
+            m.put("water_intake_avg_ml", Math.round((double) totalMl / waters.size()));
+        }
+
+        // 饮食记录数
+        LambdaQueryWrapper<HealthMealRecord> mqw = new LambdaQueryWrapper<>();
+        mqw.eq(HealthMealRecord::getMemberId, memberId)
+           .ge(HealthMealRecord::getCreatedAt, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()));
+        long mealCount = mealMapper.selectCount(mqw);
+        m.put("meal_records_count", mealCount);
+
+        return m;
+    }
+
+    private JSONObject computeMindMetrics(Long memberId, LocalDate now) {
+        JSONObject m = new JSONObject();
+        LocalDate thirtyDaysAgo = now.minusDays(30);
+        LambdaQueryWrapper<EmotionCheckin> qw = new LambdaQueryWrapper<>();
+        qw.eq(EmotionCheckin::getChildId, memberId)
+           .ge(EmotionCheckin::getCreatedAt, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()));
+        List<EmotionCheckin> emotions = emotionMapper.selectList(qw);
+        if (!emotions.isEmpty()) {
+            long total = emotions.size();
+            long joyCount = emotions.stream().filter(e -> "joy".equals(e.getEmotionType()) || "excited".equals(e.getEmotionType())).count();
+            m.put("emotion_joy_ratio", Math.round(joyCount / (double) total * 100) / 100.0);
+            long sadCount = emotions.stream().filter(e -> "sad".equals(e.getEmotionType())).count();
+            long angryCount = emotions.stream().filter(e -> "angry".equals(e.getEmotionType())).count();
+            m.put("negative_ratio", Math.round((sadCount + angryCount) / (double) total * 100) / 100.0);
+            double stressSum = emotions.stream().filter(e -> e.getStressLevel() != null).mapToDouble(e -> e.getStressLevel()).sum();
+            long stressCount = emotions.stream().filter(e -> e.getStressLevel() != null).count();
+            m.put("stress_avg", stressCount > 0 ? Math.round(stressSum / stressCount * 10) / 10.0 : 0);
+            double energySum = emotions.stream().filter(e -> e.getEnergyLevel() != null).mapToDouble(e -> e.getEnergyLevel()).sum();
+            long energyCount = emotions.stream().filter(e -> e.getEnergyLevel() != null).count();
+            m.put("energy_avg", energyCount > 0 ? Math.round(energySum / energyCount * 10) / 10.0 : 0);
+            double moodSum = emotions.stream().filter(e -> e.getMoodScore() != null).mapToDouble(e -> e.getMoodScore()).sum();
+            long moodCount = emotions.stream().filter(e -> e.getMoodScore() != null).count();
+            m.put("mood_score_avg", moodCount > 0 ? Math.round(moodSum / moodCount * 10) / 10.0 : 0);
+            m.put("emotion_records_count", total);
+        }
+        return m;
+    }
+
+    private JSONObject computeWisdomMetrics(Long memberId) {
+        JSONObject m = new JSONObject();
+        // 最近一次DAN测评
+        LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<>();
+        qw.eq(DanAssessmentResult::getFamilyMemberId, memberId)
+           .isNotNull(DanAssessmentResult::getAssessmentDate)
+           .orderByDesc(DanAssessmentResult::getAssessmentDate)
+           .last("LIMIT 1");
+        DanAssessmentResult result = danMapper.selectOne(qw);
+        if (result != null) {
+            m.put("attention_score", result.getAttentionScore());
+            m.put("focus_score", result.getFocusScore());
+            m.put("memory_score", result.getMemoryScore());
+            m.put("logic_score", result.getLogicScore());
+            m.put("perception_score", result.getPerceptionScore());
+            m.put("spatial_score", result.getSpatialScore());
+            m.put("processing_speed_score", result.getProcessingSpeedScore());
+            m.put("overall_score", result.getOverallScore());
+            JSONObject bigFive = new JSONObject();
+            bigFive.put("openness", result.getOpennessScore());
+            bigFive.put("conscientiousness", result.getConscientiousnessScore());
+            bigFive.put("extraversion", result.getExtraversionScore());
+            bigFive.put("agreeableness", result.getAgreeablenessScore());
+            bigFive.put("neuroticism", result.getNeuroticismScore());
+            bigFive.put("overall", result.getBigFiveOverallScore());
+            m.put("big_five", bigFive);
+            JSONObject emi = new JSONObject();
+            emi.put("emotion_management", result.getEmotionManagementScore());
+            emi.put("empathy", result.getEmpathyScore());
+            emi.put("social_adaptability", result.getSocialAdaptabilityScore());
+            emi.put("self_motivation", result.getSelfMotivationScore());
+            m.put("emi", emi);
+            m.put("assessment_date", result.getAssessmentDate());
+        }
+        // 游戏分数(近7天)
+        LocalDate sevenDaysAgo = LocalDate.now().minusDays(7);
+        LambdaQueryWrapper<GameRecord> gqw = new LambdaQueryWrapper<>();
+        gqw.eq(GameRecord::getChildId, memberId)
+           .ge(GameRecord::getPlayedAt, java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay()));
+        List<GameRecord> games = gameMapper.selectList(gqw);
+        if (!games.isEmpty()) {
+            int avgScore = games.stream().mapToInt(g -> g.getScore() != null ? g.getScore() : 0).sum() / games.size();
+            m.put("game_avg_score_7d", avgScore);
+            m.put("game_count_7d", games.size());
+        }
+        return m;
+    }
+
+    private JSONObject computeActionMetrics(Long memberId, LocalDate now) {
+        JSONObject m = new JSONObject();
+        LocalDate sevenDaysAgo = now.minusDays(7);
+        Timestamp weekStart = java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay());
+
+        // 任务完成率
+        LambdaQueryWrapper<Task> tqw = new LambdaQueryWrapper<>();
+        tqw.and(w -> w.eq(Task::getExecutorId, memberId).or().eq(Task::getChildId, memberId))
+           .ge(Task::getCreatedAt, weekStart);
+        long totalTasks = taskMapper.selectCount(tqw);
+        LambdaQueryWrapper<Task> doneQw = new LambdaQueryWrapper<>();
+        doneQw.and(w -> w.eq(Task::getExecutorId, memberId).or().eq(Task::getChildId, memberId))
+              .eq(Task::getStatus, "completed")
+              .ge(Task::getCompletedAt, weekStart);
+        long doneTasks = taskMapper.selectCount(doneQw);
+        m.put("task_completion_rate", totalTasks > 0 ? Math.round(doneTasks / (double) totalTasks * 100) / 100.0 : 0);
+        m.put("task_total_7d", totalTasks);
+        m.put("task_done_7d", doneTasks);
+
+        // 打卡连续天数
+        m.put("checkin_streak", computeCheckinStreak(memberId));
+
+        // 积分速度
+        LambdaQueryWrapper<PointsLog> pqw = new LambdaQueryWrapper<>();
+        pqw.eq(PointsLog::getFamilyMemberId, memberId)
+           .gt(PointsLog::getAmount, 0)
+           .ge(PointsLog::getCreatedAt, weekStart);
+        List<PointsLog> pointsList = pointsLogMapper.selectList(pqw);
+        long pointsEarned = pointsList.stream().mapToInt(PointsLog::getAmount).sum();
+        m.put("points_velocity_7d", pointsEarned);
+
+        return m;
+    }
+
+    private JSONObject computeWealthMetrics(Long memberId, LocalDate now) {
+        JSONObject m = new JSONObject();
+        LocalDate thirtyDaysAgo = now.minusDays(30);
+        LambdaQueryWrapper<FinanceCheckin> qw = new LambdaQueryWrapper<>();
+        qw.eq(FinanceCheckin::getChildId, memberId)
+           .ge(FinanceCheckin::getCheckinDate, java.sql.Date.valueOf(thirtyDaysAgo));
+        List<FinanceCheckin> finances = financeMapper.selectList(qw);
+        if (!finances.isEmpty()) {
+            long income = finances.stream().filter(f -> "income".equals(f.getType())).count();
+            long expense = finances.stream().filter(f -> "expense".equals(f.getType())).count();
+            m.put("income_count", income);
+            m.put("expense_count", expense);
+            int totalIncome = finances.stream().filter(f -> "income".equals(f.getType()))
+                .mapToInt(f -> f.getAmount() != null ? f.getAmount() : 0).sum();
+            int totalExpense = finances.stream().filter(f -> "expense".equals(f.getType()))
+                .mapToInt(f -> f.getAmount() != null ? f.getAmount() : 0).sum();
+            m.put("savings_rate", totalIncome > 0 ? Math.round((totalIncome - totalExpense) / (double) totalIncome * 100) / 100.0 : 0);
+        }
+        return m;
+    }
+
+    private JSONObject computeDimensionScores(JSONObject body, JSONObject mind, JSONObject wisdom, JSONObject action, JSONObject wealth) {
+        JSONObject scores = new JSONObject();
+
+        // 身维度
+        int bodyScore = 50;
+        double sleepAvg = body.getDoubleValue("sleep_dur_avg", 0);
+        if (sleepAvg >= 8 && sleepAvg <= 10) bodyScore += 15;
+        else if (sleepAvg >= 7 && sleepAvg <= 11) bodyScore += 8;
+        int exerciseWeek = body.getIntValue("exercise_count_week", 0);
+        if (exerciseWeek >= 3) bodyScore += 15;
+        else if (exerciseWeek >= 1) bodyScore += 8;
+        int waterAvg = body.getIntValue("water_intake_avg_ml", 0);
+        if (waterAvg >= 1000) bodyScore += 10;
+        else if (waterAvg >= 500) bodyScore += 5;
+        scores.put("body", Math.min(bodyScore, 100));
+
+        // 心维度
+        int mindScore = 50;
+        double joyRatio = mind.getDoubleValue("emotion_joy_ratio", 0.5);
+        mindScore += (int) (joyRatio * 30);
+        double stressAvg = mind.getDoubleValue("stress_avg", 5);
+        mindScore -= (int) ((stressAvg - 3) * 5);
+        double negativeRatio = mind.getDoubleValue("negative_ratio", 0);
+        mindScore -= (int) (negativeRatio * 30);
+        scores.put("mind", Math.max(0, Math.min(100, mindScore)));
+
+        // 智维度
+        int wisdomScore = 50;
+        Integer overall = wisdom.getInteger("overall_score");
+        if (overall != null) wisdomScore = overall;
+        Integer gameAvg = wisdom.getInteger("game_avg_score_7d");
+        if (gameAvg != null) wisdomScore = Math.max(wisdomScore, gameAvg);
+        scores.put("wisdom", wisdomScore);
+
+        // 行维度
+        int actionScore = 50;
+        double compRate = action.getDoubleValue("task_completion_rate", 0.5);
+        actionScore += (int) (compRate * 30);
+        int streak = action.getIntValue("checkin_streak", 0);
+        actionScore += Math.min(streak, 10);
+        scores.put("action", Math.min(100, actionScore));
+
+        // 富维度
+        int wealthScore = 50;
+        double savingsRate = wealth.getDoubleValue("savings_rate", 0.5);
+        wealthScore += (int) (savingsRate * 30);
+        int financeCount = wealth.getIntValue("income_count", 0) + wealth.getIntValue("expense_count", 0);
+        if (financeCount >= 5) wealthScore += 20;
+        else if (financeCount >= 2) wealthScore += 10;
+        scores.put("wealth", Math.min(100, wealthScore));
+
+        return scores;
+    }
+
+    private String computeProblemDomains(Long memberId) {
+        Set<String> domains = new LinkedHashSet<>();
+        LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);
+
+        // 睡眠问题
+        LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
+        sqw.eq(HealthSleepRecord::getMemberId, memberId)
+           .ge(HealthSleepRecord::getCreatedAt, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()))
+           .lt(HealthSleepRecord::getDurationMinutes, 480);
+        if (sleepMapper.selectCount(sqw) > 3) domains.add("sleep");
+
+        // 注意力问题
+        LambdaQueryWrapper<DanAssessmentResult> dwqw = new LambdaQueryWrapper<>();
+        dwqw.eq(DanAssessmentResult::getFamilyMemberId, memberId)
+           .isNotNull(DanAssessmentResult::getAttentionScore)
+           .lt(DanAssessmentResult::getAttentionScore, 60)
+           .orderByDesc(DanAssessmentResult::getAssessmentDate)
+           .last("LIMIT 1");
+        if (danMapper.selectCount(dwqw) > 0) domains.add("attention");
+
+        // 情绪问题
+        LambdaQueryWrapper<EmotionCheckin> eqw = new LambdaQueryWrapper<>();
+        eqw.eq(EmotionCheckin::getChildId, memberId)
+           .ge(EmotionCheckin::getCreatedAt, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()))
+           .in(EmotionCheckin::getEmotionType, Arrays.asList("sad", "anxious", "angry"));
+        if (emotionMapper.selectCount(eqw) > 5) domains.add("emotion");
+
+        return domains.isEmpty() ? "[]" : JSON.toJSONString(new ArrayList<>(domains));
+    }
+
+    private int computeCheckinStreak(Long memberId) {
+        int streak = 0;
+        LocalDate today = LocalDate.now();
+        for (int i = 0; i < 60; i++) {
+            LocalDate d = today.minusDays(i);
+            Timestamp dayStart = java.sql.Timestamp.valueOf(d.atStartOfDay());
+            Timestamp dayEnd = java.sql.Timestamp.valueOf(d.plusDays(1).atStartOfDay());
+            boolean hasCheckin = false;
+
+            // sleep
+            LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
+            sqw.eq(HealthSleepRecord::getMemberId, memberId)
+               .ge(HealthSleepRecord::getCreatedAt, dayStart).lt(HealthSleepRecord::getCreatedAt, dayEnd);
+            if (sleepMapper.selectCount(sqw) > 0) hasCheckin = true;
+
+            // exercise
+            if (!hasCheckin) {
+                LambdaQueryWrapper<HealthExerciseRecord> eqw = new LambdaQueryWrapper<>();
+                eqw.eq(HealthExerciseRecord::getMemberId, memberId)
+                   .ge(HealthExerciseRecord::getCreatedAt, dayStart).lt(HealthExerciseRecord::getCreatedAt, dayEnd);
+                if (exerciseMapper.selectCount(eqw) > 0) hasCheckin = true;
+            }
+
+            // emotion
+            if (!hasCheckin) {
+                LambdaQueryWrapper<EmotionCheckin> emqw = new LambdaQueryWrapper<>();
+                emqw.eq(EmotionCheckin::getChildId, memberId)
+                   .ge(EmotionCheckin::getCreatedAt, dayStart).lt(EmotionCheckin::getCreatedAt, dayEnd);
+                if (emotionMapper.selectCount(emqw) > 0) hasCheckin = true;
+            }
+
+            if (hasCheckin) streak++;
+            else if (i > 0) break;
+        }
+        return streak;
+    }
+
+    private void cleanOldHistory(Long memberId, LocalDate now) {
+        LocalDate cutoff = now.minusDays(365);
+        LambdaQueryWrapper<ProfileHistory> qw = new LambdaQueryWrapper<>();
+        qw.eq(ProfileHistory::getMemberId, memberId)
+           .lt(ProfileHistory::getSnapshotDate, java.sql.Date.valueOf(cutoff));
+        historyMapper.delete(qw);
+    }
+}