2026-08-21-user-profile-recommendation.md 60 KB

用户画像与推荐系统 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 建立基于真实指标的用户画像系统,支持C端查看画像+个性化推荐、B端规划师查看客户画像,并在方案生成时自动注入画像数据。

架构: 增量写入快照表(每次打卡/任务完成后触发),O(1)读取;规则层按指标阈值匹配内容标签;AI层补充长尾场景;方案生成自动注入画像。

技术栈: Java 8 + MyBatis-Plus、LangGraph Python (FastAPI)、uni-app Vue 2、Element UI


文件结构

新建文件

  • cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sql
  • cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java
  • cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileHistory.java
  • cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileSnapshotMapper.java
  • cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileHistoryMapper.java
  • cfc-backend/src/main/java/com/etotem/cfc/service/ProfileComputeService.java
  • cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.java
  • cfc-backend/src/main/java/com/etotem/cfc/service/ProfileReadService.java
  • cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileReadServiceImpl.java
  • cfc-backend/src/main/java/com/etotem/cfc/service/RecommendService.java
  • cfc-backend/src/main/java/com/etotem/cfc/service/impl/RecommendServiceImpl.java
  • cfc-backend/src/main/java/com/etotem/cfc/controller/profile/ProfileController.java (C端)
  • cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProfileController.java (B端)
  • cfc-langgraph/app/api/profile_recommend.py (新增LangGraph端点)
  • cfc-frontend/pages/growth/profile/index.vue (小程序画像页)
  • cfc-web/src/views/admin/ProfileManagement.vue (管理端画像列表)

修改文件

  • cfc-backend/src/main/resources/schema.sql — 添加两张表定义
  • cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java — 添加迁移
  • cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java — 打卡后触发画像计算
  • cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java — 同
  • cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java — 同
  • cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java — 同
  • cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java — 任务完成后触发
  • cfc-langgraph/app/api/adapter.py — 方案生成时注入画像数据
  • cfc-langgraph/app/tools/java_client.py — 新增get_member_profile()方法
  • cfc-frontend/utils/api.js — 新增画像相关API调用

规则映射表(实现时参考)

指标条件 标签code 推荐品类
sleep_dur_avg < 8h sleep_deficit 助眠文章、睡前活动
exercise_count_week < 2 low_activity 趣味运动任务
stress_avg > 6 high_stress 情绪疏导文章、正念冥想
emotion_joy_ratio < 0.3 low_mood 心理支持活动
attention_score < 60 attention_weak 注意力训练游戏、专注力课程
completion_rate < 0.4 task_avoidance 轻量入门任务
problem_domains contains "sleep" sleep_focus 睡眠改善文章/活动
problem_domains contains "attention" attention_focus 注意力训练任务

任务分解

任务1:数据库迁移 — 创建画像表

文件:

  • 新建:cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sql
  • 修改:cfc-backend/src/main/resources/schema.sql
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

  • [ ] 步骤 1:编写迁移SQL

创建 cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sql:

-- 用户画像最新快照表
CREATE TABLE IF NOT EXISTS profile_snapshot (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    member_id BIGINT NOT NULL UNIQUE COMMENT '家庭成员ID',
    dimension_scores JSON DEFAULT NULL COMMENT '五维能力评分 {"body":72,"wisdom":65,"mind":80,"action":58,"wealth":45}',
    body_metrics JSON DEFAULT NULL COMMENT '近30天身体指标 {"sleep_dur_avg":9.2,"deep_sleep_pct":35,"exercise_count_week":3,"water_intake_avg_ml":1200,"meal_regularity":0.7}',
    mind_metrics JSON DEFAULT NULL COMMENT '近30天心理指标 {"emotion_joy_ratio":0.72,"stress_avg":3.1,"energy_avg":6.5,"negative_ratio":0.15}',
    wisdom_metrics JSON DEFAULT NULL COMMENT '最近测评成绩 {"attention":78,"focus":72,"memory":65,"logic":70,"big_five":{"openness":75,"conscientiousness":68,"extraversion":60,"agreeableness":80,"neuroticism":35},"emi":{"emotion_management":72,"empathy":78,"social_adaptability":65,"self_motivation":70}}',
    action_metrics JSON DEFAULT NULL COMMENT '近7天行为指标 {"task_completion_rate":0.65,"daily_checkin_streak":12,"points_velocity":45}',
    wealth_metrics JSON DEFAULT NULL COMMENT '近30天财商指标 {"income_count":3,"expense_count":5,"savings_rate":0.35}',
    problem_domains VARCHAR(500) DEFAULT NULL COMMENT '关注的问题域标签 ["sleep","attention","emotion"]',
    computed_at DATETIME DEFAULT NULL COMMENT '最后一次计算时间',
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_member_id (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户画像最新快照';

-- 画像历史表(保留365天,用于趋势展示)
CREATE TABLE IF NOT EXISTS profile_history (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
    snapshot_date DATE NOT NULL COMMENT '日期',
    all_metrics JSON NOT NULL COMMENT '当日所有指标完整快照',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_member_date (member_id, snapshot_date),
    INDEX idx_date (snapshot_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户画像历史快照';
  • 步骤 2:同步到 schema.sql

schema.sql 末尾追加相同的建表语句(搜索 CREATE TABLE 找到最后一个表的结束位置)。

  • 步骤 3:添加迁移到 DatabaseInitializer

DatabaseInitializer.javarunMigrations() 方法中添加:

ensureTable("profile_snapshot", """
    CREATE TABLE IF NOT EXISTS profile_snapshot (
        id BIGINT PRIMARY KEY AUTO_INCREMENT,
        member_id BIGINT NOT NULL UNIQUE,
        dimension_scores JSON,
        body_metrics JSON,
        mind_metrics JSON,
        wisdom_metrics JSON,
        action_metrics JSON,
        wealth_metrics JSON,
        problem_domains VARCHAR(500),
        computed_at DATETIME,
        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        INDEX idx_member_id (member_id)
    )
""");
ensureTable("profile_history", """
    CREATE TABLE IF NOT EXISTS profile_history (
        id BIGINT PRIMARY KEY AUTO_INCREMENT,
        member_id BIGINT NOT NULL,
        snapshot_date DATE NOT NULL,
        all_metrics JSON NOT NULL,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_member_date (member_id, snapshot_date)
    )
""");
  • [ ] 步骤 4:编译验证

    cd cfc-backend && mvn clean compile -q
    

预期:BUILD SUCCESS

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sql \
       cfc-backend/src/main/resources/schema.sql \
       cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
    git commit -m "feat(profile): 创建画像快照表和歷史表迁移脚本"
    

任务2:Java实体类和Mapper

文件:

  • 新建:cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java
  • 新建:cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileHistory.java
  • 新建:cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileSnapshotMapper.java
  • 新建:cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileHistoryMapper.java

  • [ ] 步骤 1:创建 ProfileSnapshot 实体

    package com.etotem.cfc.entity;
    
    import com.baomidou.mybatisplus.annotation.IdType;
    import com.baomidou.mybatisplus.annotation.TableId;
    import com.baomidou.mybatisplus.annotation.TableName;
    import lombok.Data;
    import java.io.Serializable;
    import java.util.Date;
    
    @Data
    @TableName("profile_snapshot")
    public class ProfileSnapshot implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long memberId;
    private String dimensionScores;   // JSON
    private String bodyMetrics;       // JSON
    private String mindMetrics;       // JSON
    private String wisdomMetrics;     // JSON
    private String actionMetrics;     // JSON
    private String wealthMetrics;     // JSON
    private String problemDomains;    // JSON array string
    private Date computedAt;
    private Date updatedAt;
    }
    
  • [ ] 步骤 2:创建 ProfileHistory 实体

    package com.etotem.cfc.entity;
    
    import com.baomidou.mybatisplus.annotation.IdType;
    import com.baomidou.mybatisplus.annotation.TableId;
    import com.baomidou.mybatisplus.annotation.TableName;
    import lombok.Data;
    import java.io.Serializable;
    import java.util.Date;
    
    @Data
    @TableName("profile_history")
    public class ProfileHistory implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long memberId;
    private Date snapshotDate;
    private String allMetrics;        // JSON
    private Date createdAt;
    }
    
  • [ ] 步骤 3:创建 Mapper 接口

    // ProfileSnapshotMapper.java
    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.ProfileSnapshot;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface ProfileSnapshotMapper extends BaseMapper<ProfileSnapshot> {
    }
    
    // ProfileHistoryMapper.java
    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.ProfileHistory;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface ProfileHistoryMapper extends BaseMapper<ProfileHistory> {
    }
    
  • [ ] 步骤 4:编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java \
       cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileHistory.java \
       cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileSnapshotMapper.java \
       cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileHistoryMapper.java
    git commit -m "feat(profile): 创建画像实体类和Mapper接口"
    

任务3:画像计算服务

文件:

  • 新建:cfc-backend/src/main/java/com/etotem/cfc/service/ProfileComputeService.java
  • 新建:cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.java

  • [ ] 步骤 1:创建 Service 接口

    package com.etotem.cfc.service;
    
    public interface ProfileComputeService {
    /** 为指定家庭成员重新计算并写入画像快照 */
    void computeAndSave(Long memberId);
    /** 批量计算所有家庭成员画像 */
    void computeAll();
    }
    
  • [ ] 步骤 2:创建实现类 — 核心计算逻辑

文件:cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.java

核心思路:查询各维度历史数据 → 按时间窗口聚合 → 写入 profile_snapshot。

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.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;

@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();
        
        // 1. 身体指标(近30天)
        JSONObject bodyMetrics = computeBodyMetrics(memberId, now);
        
        // 2. 心理指标(近30天)
        JSONObject mindMetrics = computeMindMetrics(memberId, now);
        
        // 3. 智育指标(最近测评)
        JSONObject wisdomMetrics = computeWisdomMetrics(memberId);
        
        // 4. 行为指标(近7天)
        JSONObject actionMetrics = computeActionMetrics(memberId, now);
        
        // 5. 财富指标(近30天)
        JSONObject wealthMetrics = computeWealthMetrics(memberId, now);
        
        // 6. 五维综合评分
        JSONObject dimensionScores = computeDimensionScores(bodyMetrics, mindMetrics, wisdomMetrics, actionMetrics, wealthMetrics);
        
        // 7. 问题域(从problem_survey的resultTags推断)
        String problemDomains = computeProblemDomains(memberId);
        
        // 8. 组装快照
        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());
        
        // 9. 写入快照表(upsert)
        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);
        
        // 10. 写入历史表(每日一条,去重)
        LambdaQueryWrapper<ProfileHistory> hqw = new LambdaQueryWrapper<>();
        hqw.eq(ProfileHistory::getMemberId, memberId)
           .eq(ProfileHistory::getSnapshotDate, now);
        ProfileHistory history = historyMapper.selectOne(hqw);
        if (history == null) {
            history = new ProfileHistory();
            history.setMemberId(memberId);
            history.setSnapshotDate(new Date());
            history.setAllMetrics(allMetrics.toJSONString());
            historyMapper.insert(history);
        } else {
            history.setAllMetrics(allMetrics.toJSONString());
            historyMapper.updateById(history);
        }
        
        // 11. 清理365天外的历史
        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) {
                // 单条失败不影响整体
            }
        }
    }
    
    // ---- 各维度计算方法 ----
    
    private JSONObject computeBodyMetrics(Long memberId, LocalDate now) {
        JSONObject m = new JSONObject();
        // 睡眠:近30天平均入睡/醒来时间,平均时长
        LocalDate thirtyDaysAgo = now.minusDays(30);
        LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
        sqw.eq(HealthSleepRecord::getMemberId, memberId)
           .ge(HealthSleepRecord::getCreatedAt, thirtyDaysAgo);
        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天运动次数
        LocalDate sevenDaysAgo = now.minusDays(7);
        LambdaQueryWrapper<HealthExerciseRecord> eqw = new LambdaQueryWrapper<>();
        eqw.eq(HealthExerciseRecord::getMemberId, memberId)
           .ge(HealthExerciseRecord::getCreatedAt, sevenDaysAgo);
        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, thirtyDaysAgo);
        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, thirtyDaysAgo);
        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, thirtyDaysAgo);
        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();
        LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<>();
        qw.eq(DanAssessmentResult::getFamilyMemberId, memberId)
           .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);
            // EMI情商
            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.Date.valueOf(sevenDaysAgo));
        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);
        // 任务完成率
        LambdaQueryWrapper<Task> tqw = new LambdaQueryWrapper<>();
        tqw.and(w -> w.eq(Task::getExecutorId, memberId).or().eq(Task::getChildId, memberId))
           .ge(Task::getCreatedAt, java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay()));
        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, java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay()));
        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)
           .ge(PointsLog::getCreatedAt, java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay()))
           .gt(PointsLog::getAmount, 0);
        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) {
        // 从problem_survey的resultTags和behavior推断
        Set<String> domains = new LinkedHashSet<>();
        // 睡眠问题
        LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
        sqw.eq(HealthSleepRecord::getMemberId, memberId)
           .ge(HealthSleepRecord::getCreatedAt, LocalDate.now().minusDays(30))
           .lt(HealthSleepRecord::getDurationMinutes, 480); // < 8h
        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, LocalDate.now().minusDays(30))
           .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 < 365; i++) {
            LocalDate d = today.minusDays(i);
            // 检查当天是否有任意打卡记录
            boolean hasCheckin = false;
            // sleep
            LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
            sqw.eq(HealthSleepRecord::getMemberId, memberId)
               .ge(HealthSleepRecord::getCreatedAt, java.sql.Timestamp.valueOf(d.atStartOfDay()))
               .lt(HealthSleepRecord::getCreatedAt, java.sql.Timestamp.valueOf(d.plusDays(1).atStartOfDay()));
            if (sleepMapper.selectCount(sqw) > 0) { hasCheckin = true; }
            // exercise
            if (!hasCheckin) {
                LambdaQueryWrapper<HealthExerciseRecord> eqw = new LambdaQueryWrapper<>();
                eqw.eq(HealthExerciseRecord::getMemberId, memberId)
                   .ge(HealthExerciseRecord::getCreatedAt, java.sql.Timestamp.valueOf(d.atStartOfDay()))
                   .lt(HealthExerciseRecord::getCreatedAt, java.sql.Timestamp.valueOf(d.plusDays(1).atStartOfDay()));
                if (exerciseMapper.selectCount(eqw) > 0) { hasCheckin = true; }
            }
            // emotion
            if (!hasCheckin) {
                LambdaQueryWrapper<EmotionCheckin> emqw = new LambdaQueryWrapper<>();
                emqw.eq(EmotionCheckin::getChildId, memberId)
                   .ge(EmotionCheckin::getCreatedAt, java.sql.Timestamp.valueOf(d.atStartOfDay()))
                   .lt(EmotionCheckin::getCreatedAt, java.sql.Timestamp.valueOf(d.plusDays(1).atStartOfDay()));
                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);
    }
}
  • [ ] 步骤 3:编译验证

    cd cfc-backend && mvn clean compile -q
    

预期:BUILD SUCCESS。如有错误,修复后再commit。

  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/ProfileComputeService.java \
       cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.java
    git commit -m "feat(profile): 实现画像计算服务 — 五维指标聚合+增量写入"
    

任务4:画像读取服务 + 规则推荐引擎

文件:

  • 新建:cfc-backend/src/main/java/com/etotem/cfc/service/ProfileReadService.java
  • 新建:cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileReadServiceImpl.java
  • 新建:cfc-backend/src/main/java/com/etotem/cfc/service/RecommendService.java
  • 新建:cfc-backend/src/main/java/com/etotem/cfc/service/impl/RecommendServiceImpl.java

  • [ ] 步骤 1:创建 ProfileReadService

    package com.etotem.cfc.service;
    
    import java.util.Map;
    
    public interface ProfileReadService {
    /** 获取最新画像快照(含基础信息) */
    Map<String, Object> getProfile(Long memberId);
    /** 获取指标趋势(近30天) */
    Map<String, Object> getTrend(Long memberId, int days);
    }
    
  • [ ] 步骤 2:创建实现类

    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.ProfileReadService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    
    import java.time.LocalDate;
    import java.util.*;
    
    @Service
    public class ProfileReadServiceImpl implements ProfileReadService {
    
    @Autowired private ProfileSnapshotMapper snapshotMapper;
    @Autowired private ProfileHistoryMapper historyMapper;
    @Autowired private FamilyMemberMapper memberMapper;
    @Autowired private UserMapper userMapper;
    
    @Override
    public Map<String, Object> getProfile(Long memberId) {
        Map<String, Object> result = new HashMap<>();
            
        // 基本信息
        FamilyMember member = memberMapper.selectById(memberId);
        if (member == null) {
            result.put("error", "member_not_found");
            return result;
        }
        Map<String, Object> memberInfo = new HashMap<>();
        memberInfo.put("id", member.getId());
        memberInfo.put("name", member.getNickname());
        memberInfo.put("age", member.getAge());
        memberInfo.put("gender", member.getGender());
        result.put("member", memberInfo);
            
        // 画像快照
        LambdaQueryWrapper<ProfileSnapshot> qw = new LambdaQueryWrapper<>();
        qw.eq(ProfileSnapshot::getMemberId, memberId);
        ProfileSnapshot snapshot = snapshotMapper.selectOne(qw);
            
        if (snapshot != null) {
            result.put("dimension_scores", parseJsonOrEmpty(snapshot.getDimensionScores()));
            result.put("body_metrics", parseJsonOrEmpty(snapshot.getBodyMetrics()));
            result.put("mind_metrics", parseJsonOrEmpty(snapshot.getMindMetrics()));
            result.put("wisdom_metrics", parseJsonOrEmpty(snapshot.getWisdomMetrics()));
            result.put("action_metrics", parseJsonOrEmpty(snapshot.getActionMetrics()));
            result.put("wealth_metrics", parseJsonOrEmpty(snapshot.getWealthMetrics()));
            result.put("problem_domains", parseJsonArrayOrEmpty(snapshot.getProblemDomains()));
            result.put("computed_at", snapshot.getComputedAt());
        } else {
            // 无快照返回空结构
            result.put("dimension_scores", Collections.emptyMap());
            result.put("body_metrics", Collections.emptyMap());
            result.put("mind_metrics", Collections.emptyMap());
            result.put("wisdom_metrics", Collections.emptyMap());
            result.put("action_metrics", Collections.emptyMap());
            result.put("wealth_metrics", Collections.emptyMap());
            result.put("problem_domains", Collections.emptyList());
        }
            
        return result;
    }
        
    @Override
    public Map<String, Object> getTrend(Long memberId, int days) {
        Map<String, Object> result = new HashMap<>();
        LocalDate end = LocalDate.now();
        LocalDate start = end.minusDays(days);
            
        LambdaQueryWrapper<ProfileHistory> qw = new LambdaQueryWrapper<>();
        qw.eq(ProfileHistory::getMemberId, memberId)
           .ge(ProfileHistory::getSnapshotDate, java.sql.Date.valueOf(start))
           .le(ProfileHistory::getSnapshotDate, java.sql.Date.valueOf(end))
           .orderByAsc(ProfileHistory::getSnapshotDate);
        List<ProfileHistory> histories = historyMapper.selectList(qw);
            
        List<Map<String, Object>> points = new ArrayList<>();
        for (ProfileHistory h : histories) {
            JSONObject metrics = JSON.parseObject(h.getAllMetrics());
            Map<String, Object> point = new HashMap<>();
            point.put("date", h.getSnapshotDate().toString());
            point.put("dimension_scores", metrics.getObject("dimension_scores", JSONObject.class));
            point.put("body_metrics", metrics.getObject("body_metrics", JSONObject.class));
            point.put("mind_metrics", metrics.getObject("mind_metrics", JSONObject.class));
            points.add(point);
        }
        result.put("points", points);
        result.put("total", histories.size());
        return result;
    }
        
    private Map<String, Object> parseJsonOrEmpty(String json) {
        if (json == null || json.isEmpty()) return Collections.emptyMap();
        try { return JSON.parseObject(json); } catch (Exception e) { return Collections.emptyMap(); }
    }
        
    private List<String> parseJsonArrayOrEmpty(String json) {
        if (json == null || json.isEmpty()) return Collections.emptyList();
        try { return JSON.parseArray(json, String.class); } catch (Exception e) { return Collections.emptyList(); }
    }
    }
    
  • [ ] 步骤 3:创建 RecommendService

    package com.etotem.cfc.service;
    
    import java.util.List;
    import java.util.Map;
    
    public interface RecommendService {
    /** 基于画像获取推荐列表 */
    Map<String, Object> getRecommendations(Long memberId);
    }
    

实现:

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.RecommendService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.*;

@Service
public class RecommendServiceImpl implements RecommendService {

    @Autowired private ProfileSnapshotMapper snapshotMapper;
    @Autowired private ArticleMapper articleMapper;
    @Autowired private ActivityMapper activityMapper;
    @Autowired private TaskMapper taskMapper;
    @Autowired private ProductMapper productMapper;
    @Autowired private ArticleCategoryMapper categoryMapper;

    @Override
    public Map<String, Object> getRecommendations(Long memberId) {
        Map<String, Object> result = new HashMap<>();
        
        LambdaQueryWrapper<ProfileSnapshot> qw = new LambdaQueryWrapper<>();
        qw.eq(ProfileSnapshot::getMemberId, memberId);
        ProfileSnapshot snapshot = snapshotMapper.selectOne(qw);
        if (snapshot == null) {
            result.put("tasks", Collections.emptyList());
            result.put("articles", Collections.emptyList());
            result.put("activities", Collections.emptyList());
            result.put("products", Collections.emptyList());
            result.put("matched_tags", Collections.emptyList());
            return result;
        }
        
        JSONObject body = JSON.parseObject(snapshot.getBodyMetrics());
        JSONObject mind = JSON.parseObject(snapshot.getMindMetrics());
        JSONObject wisdom = JSON.parseObject(snapshot.getWisdomMetrics());
        JSONObject action = JSON.parseObject(snapshot.getActionMetrics());
        List<String> domains = JSON.parseArray(snapshot.getProblemDomains(), String.class);
        if (domains == null) domains = Collections.emptyList();
        
        Set<String> matchedTags = new LinkedHashSet<>();
        List<String> taskTags = new ArrayList<>();
        List<String> articleTags = new ArrayList<>();
        List<String> activityTags = new ArrayList<>();
        
        // 规则匹配
        if (body.getDoubleValue("sleep_dur_avg", 99) < 8) { matchedTags.add("sleep_deficit"); taskTags.add("sleep"); articleTags.add("睡眠"); }
        if (body.getIntValue("exercise_count_week", 99) < 2) { matchedTags.add("low_activity"); taskTags.add("运动"); }
        if (mind.getDoubleValue("stress_avg", 0) > 6) { matchedTags.add("high_stress"); articleTags.add("压力"); }
        if (mind.getDoubleValue("emotion_joy_ratio", 1) < 0.3) { matchedTags.add("low_mood"); articleTags.add("情绪"); }
        if (wisdom.getInteger("attention_score") != null && wisdom.getInteger("attention_score") < 60) { matchedTags.add("attention_weak"); taskTags.add("注意力"); }
        if (action.getDoubleValue("task_completion_rate", 1) < 0.4) { matchedTags.add("task_avoidance"); taskTags.add("入门"); }
        for (String domain : domains) {
            matchedTags.add(domain + "_focus");
            if (domain.equals("sleep")) { articleTags.add("睡眠"); }
            if (domain.equals("attention")) { taskTags.add("注意力"); }
            if (domain.equals("emotion")) { articleTags.add("情绪"); }
        }
        
        // 查询推荐内容
        result.put("matched_tags", new ArrayList<>(matchedTags));
        result.put("tasks", queryTasks(taskTags));
        result.put("articles", queryArticles(articleTags));
        result.put("activities", queryActivities(activityTags));
        result.put("products", queryProducts(matchedTags));
        
        return result;
    }
    
    private List<Map<String, Object>> queryTasks(List<String> tags) {
        List<Map<String, Object>> list = new ArrayList<>();
        for (String tag : tags) {
            LambdaQueryWrapper<Task> qw = new LambdaQueryWrapper<>();
            qw.like(Task::getCategory, tag).or().like(Task::getDescription, tag)
               .eq(Task::getStatus, "pending")
               .
               .last("LIMIT 5");
            List<Task> tasks = taskMapper.selectList(qw);
            for (Task t : tasks) {
                Map<String, Object> item = new HashMap<>();
                item.put("id", t.getId());
                item.put("title", t.getTitle());
                item.put("category", t.getCategory());
                item.put("type", "task");
                list.add(item);
            }
        }
        deduplicate(list);
        return list;
    }
    
    private List<Map<String, Object>> queryArticles(List<String> tags) {
        List<Map<String, Object>> list = new ArrayList<>();
        for (String tag : tags) {
            LambdaQueryWrapper<Article> qw = new LambdaQueryWrapper<>();
            qw.and(w -> w.like(Article::getTags, tag).or().like(Article::getTitle, tag))
               .eq(Article::getAuditStatus, "approved")
               .eq(Article::getStatus, "published")
               .orderByDesc(Article::getPublishedAt)
               .last("LIMIT 5");
            List<Article> articles = articleMapper.selectList(qw);
            for (Article a : articles) {
                Map<String, Object> item = new HashMap<>();
                item.put("id", a.getId());
                item.put("title", a.getTitle());
                item.put("summary", a.getSummary());
                item.put("coverImage", a.getCoverImage());
                item.put("type", "article");
                list.add(item);
            }
        }
        deduplicate(list);
        return list;
    }
    
    private List<Map<String, Object>> queryActivities(List<String> tags) {
        List<Map<String, Object>> list = new ArrayList<>();
        for (String tag : tags) {
            LambdaQueryWrapper<Activity> qw = new LambdaQueryWrapper<>();
            qw.and(w -> w.like(Activity::getTitle, tag).or().like(Activity::getDescription, tag))
               .eq(Activity::getAuditStatus, "approved")
               .eq(Activity::getStatus, "published")
               .orderByAsc(Activity::getStartTime)
               .last("LIMIT 3");
            List<Activity> acts = activityMapper.selectList(qw);
            for (Activity a : acts) {
                Map<String, Object> item = new HashMap<>();
                item.put("id", a.getId());
                item.put("title", a.getTitle());
                item.put("dimensionCode", a.getDimensionCode());
                item.put("type", "activity");
                list.add(item);
            }
        }
        deduplicate(list);
        return list;
    }
    
    private List<Map<String, Object>> queryProducts(Set<String> tags) {
        List<Map<String, Object>> list = new ArrayList<>();
        LambdaQueryWrapper<Product> qw = new LambdaQueryWrapper<>();
        qw.eq(Product::getStatus, "on_shelf")
           .isNotNull(Product::getGrowthCategory)
           .last("LIMIT 8");
        List<Product> products = productMapper.selectList(qw);
        for (Product p : products) {
            Map<String, Object> item = new HashMap<>();
            item.put("id", p.getId());
            item.put("name", p.getName());
            item.put("price", p.getPrice());
            item.put("growthCategory", p.getGrowthCategory());
            item.put("type", "product");
            list.add(item);
        }
        return list;
    }
    
    private void deduplicate(List<Map<String, Object>> list) {
        Set<Long> seen = new HashSet<>();
        list.removeIf(item -> {
            Long id = ((Number) item.get("id")).longValue();
            return !seen.add(id);
        });
    }
}
  • [ ] 步骤 4:编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/ProfileReadService.java \
       cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileReadServiceImpl.java \
       cfc-backend/src/main/java/com/etotem/cfc/service/RecommendService.java \
       cfc-backend/src/main/java/com/etotem/cfc/service/impl/RecommendServiceImpl.java
    git commit -m "feat(profile): 实现画像读取服务和规则推荐引擎"
    

任务5:控制器 — C端 + B端接口

文件:

  • 新建:cfc-backend/src/main/java/com/etotem/cfc/controller/profile/ProfileController.java
  • 新建:cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProfileController.java

  • [ ] 步骤 1:创建 C端 ProfileController

    package com.etotem.cfc.controller.profile;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.service.ProfileReadService;
    import com.etotem.cfc.service.RecommendService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.*;
    
    import javax.servlet.http.HttpServletRequest;
    import java.util.Map;
    
    @RestController
    @RequestMapping("/api/profile")
    public class ProfileController {
    
    @Autowired private ProfileReadService profileReadService;
    @Autowired private RecommendService recommendService;
    
    @PostMapping("/my")
    public Result<Map<String, Object>> getMyProfile(HttpServletRequest request) {
        Long memberId = (Long) request.getAttribute("memberId");
        if (memberId == null) {
            memberId = (Long) request.getAttribute("userId");
        }
        if (memberId == null) {
            return Result.error("未登录");
        }
        Map<String, Object> profile = profileReadService.getProfile(memberId);
        Map<String, Object> recommendations = recommendService.getRecommendations(memberId);
        profile.putAll(recommendations);
        return Result.success(profile);
    }
    
    @PostMapping("/history")
    public Result<Map<String, Object>> getHistory(@RequestBody Map<String, Object> params, HttpServletRequest request) {
        Long memberId = (Long) request.getAttribute("memberId");
        if (memberId == null) {
            memberId = (Long) request.getAttribute("userId");
        }
        int days = params.get("days") != null ? ((Number) params.get("days")).intValue() : 30;
        Map<String, Object> trend = profileReadService.getTrend(memberId, days);
        return Result.success(trend);
    }
    }
    
  • [ ] 步骤 2:创建 B端 AdminProfileController

    package com.etotem.cfc.controller.admin;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.service.ProfileReadService;
    import com.etotem.cfc.service.RecommendService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.*;
    
    import java.util.HashMap;
    import java.util.Map;
    
    @RestController
    @RequestMapping("/api/admin/profile")
    public class AdminProfileController {
    
    @Autowired private ProfileReadService profileReadService;
    @Autowired private RecommendService recommendService;
    
    @PostMapping("/get")
    public Result<Map<String, Object>> getProfile(@RequestBody Map<String, Object> params) {
        Long memberId = params.get("memberId") != null ? ((Number) params.get("memberId")).longValue() : null;
        if (memberId == null) {
            return Result.error("缺少memberId参数");
        }
        Map<String, Object> profile = profileReadService.getProfile(memberId);
        Map<String, Object> recs = recommendService.getRecommendations(memberId);
        profile.putAll(recs);
        return Result.success(profile);
    }
    
    @PostMapping("/trend")
    public Result<Map<String, Object>> getTrend(@RequestBody Map<String, Object> params) {
        Long memberId = params.get("memberId") != null ? ((Number) params.get("memberId")).longValue() : null;
        int days = params.get("days") != null ? ((Number) params.get("days")).intValue() : 30;
        if (memberId == null) {
            return Result.error("缺少memberId参数");
        }
        Map<String, Object> trend = profileReadService.getTrend(memberId, days);
        return Result.success(trend);
    }
    }
    
  • [ ] 步骤 3:编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/profile/ProfileController.java \
       cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProfileController.java
    git commit -m "feat(profile): 添加C端和B端画像API控制器"
    

任务6:打卡触发画像重算

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java

每个控制器在成功写入数据后,异步触发 profileComputeService.computeAndSave(memberId)

  • 步骤 1:修改 HealthSleepController

create 接口成功后添加:

@Autowired
private com.etotem.cfc.service.ProfileComputeService profileComputeService;

// 在 success 回调中:
Long memberId = request.getLong("memberId");
if (memberId != null) {
    new Thread(() -> profileComputeService.computeAndSave(memberId)).start();
}
  • 步骤 2:修改 HealthExerciseController

同上,在 create 成功后触发:

Long memberId = request.getLong("memberId");
if (memberId != null) {
    new Thread(() -> profileComputeService.computeAndSave(memberId)).start();
}
  • 步骤 3:修改 HealthMealController

同上模式。

  • 步骤 4:修改 EmotionCheckinController

create 成功后触发:

Long childId = dto.getChildId();
if (childId != null) {
    new Thread(() -> profileComputeService.computeAndSave(childId)).start();
}
  • 步骤 5:修改 TaskController

在任务完成(status→completed)后触发:

// 在 review/complete 接口成功后
Long executorId = task.getExecutorId();
if (executorId != null) {
    new Thread(() -> profileComputeService.computeAndSave(executorId)).start();
}
  • [ ] 步骤 6:编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] 步骤 7:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java \
       cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java \
       cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java \
       cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java \
       cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java
    git commit -m "feat(profile): 在打卡和任务完成后自动触发画像重算"
    

任务7:LangGraph — 方案生成注入画像

文件:

  • 修改:cfc-langgraph/app/tools/java_client.py
  • 修改:cfc-langgraph/app/api/adapter.py

  • [ ] 步骤 1:新增 JavaClient 方法

    async def get_member_profile(self, member_id: int) -> dict:
    """获取家庭成员画像快照"""
    client = await self._get_client()
    resp = await client.post("/api/profile/my", json={"memberId": member_id})
    data = resp.json()
    if data.get("code") == 200 and data.get("data"):
        return data["data"]
    return {}
    
  • [ ] 步骤 2:修改 adapter.py 方案生成 prompt

_collect_plan_data 函数中,添加画像数据获取:

# 在 _collect_plan_data 末尾追加
profile_data = await java.get_member_profile(int(member["id"]))
member["profile"] = profile_data

PLAN_SYSTEM_PROMPT 中追加画像段:

# 修改 PLAN_SYSTEM_PROMPT 字符串,追加:
"""
## 用户画像数据
{profile_section}
请结合上述真实指标给出针对性建议,特别是异常指标要重点说明。
"""

在构建 prompt 时动态填充:

# 在 health_plan_generate 函数中,构建 parts 时追加:
if member.get("profile"):
    profile = member["profile"]
    dims = profile.get("dimension_scores", {})
    body = profile.get("body_metrics", {})
    mind = profile.get("mind_metrics", {})
    parts.append(f"\n## {member['name']} 画像")
    parts.append(f"- 五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
    if body.get('sleep_dur_avg'):
        parts.append(f"- 平均睡眠: {body['sleep_dur_avg']}小时")
    if mind.get('stress_avg'):
        parts.append(f"- 平均压力: {mind['stress_avg']}/10")
    if body.get('exercise_count_week'):
        parts.append(f"- 周运动次数: {body['exercise_count_week']}次")
  • [ ] 步骤 3:Commit

    git add cfc-langgraph/app/tools/java_client.py cfc-langgraph/app/api/adapter.py
    git commit -m "feat(profile): 方案生成时自动注入用户画像数据"
    

任务8:前端 — 小程序画像页

文件:

  • 新建:cfc-frontend/pages/growth/profile/index.vue
  • 修改:cfc-frontend/utils/api.js

  • [ ] 步骤 1:新增 API 调用

cfc-frontend/utils/api.js 末尾追加:

// 画像
export function getMyProfile() {
  return request('/api/profile/my', 'POST', {})
}
export function getProfileTrend(days) {
  return request('/api/profile/history', 'POST', { days })
}
  • 步骤 2:创建画像页面

创建 cfc-frontend/pages/growth/profile/index.vue,包含:

  • 五维雷达图/进度条(dimension_scores)
  • 各维度指标卡片(body/mind/wisdom/action/wealth)
  • 推荐内容区(任务/文章/活动 tabs)
  • 近30天趋势折线图(使用简单的 canvas 或 el-progress 模拟)

风格参照现有 growth 页的暖橙配色(#F97316)。

  • [ ] 步骤 3:Commit

    git add cfc-frontend/utils/api.js cfc-frontend/pages/growth/profile/index.vue
    git commit -m "feat(profile): 小程序画像页 — 五维指标+推荐内容"
    

任务9:前端 — Web管理端画像列表

文件:

  • 新建:cfc-web/src/views/admin/ProfileManagement.vue

  • [ ] 步骤 1:创建管理端页面

功能:

  • 家庭成员列表,显示各人画像摘要
  • 点击可查看完整画像+趋势
  • 手动触发"重新计算画像"按钮
  • 搜索/筛选(按家庭、按维度)

使用 Element UI Table + Card 布局,风格与现有 admin 页一致。

  • [ ] 步骤 2:Commit

    git add cfc-web/src/views/admin/ProfileManagement.vue
    git commit -m "feat(profile): Web管理端 — 家庭成员画像列表与管理"
    

任务10:联调测试与修复

  • [ ] 步骤 1:数据库迁移执行

    # 连接数据库执行迁移
    mysql -h 192.168.16.251 -u zxyj -p'zxyj@123' zxyj < cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sql
    
  • [ ] 步骤 2:启动后端,测试 C端接口

    cd cfc-backend && mvn spring-boot:run
    # 另开终端测试
    curl -X POST http://localhost:9082/api/profile/my \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{}'
    

预期:返回包含 memberdimension_scoresbody_metrics 等字段的 JSON。

  • [ ] 步骤 3:测试 B端接口

    curl -X POST http://localhost:9082/api/admin/profile/get \
    -H "Authorization: Bearer $ADMIN_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"memberId": 1}'
    
  • [ ] 步骤 4:触发一次画像计算,验证数据写入

通过数据库直接查询:

SELECT member_id, dimension_scores, computed_at FROM profile_snapshot LIMIT 5;
SELECT COUNT(*) FROM profile_history;
  • 步骤 5:做一次完整的打卡→画像刷新→读取链路测试
  1. 提交一条睡眠打卡
  2. 等待1秒(线程异步)
  3. 调用 /api/profile/my
  4. 验证 body_metrics.sleep_records_count 增加
  • [ ] 步骤 6:修复发现的bug,最终 commit

    git add -A
    git commit -m "fix(profile): 联调测试修复"
    git push origin cfclub
    

注意事项

  1. 异步触发画像计算:使用 new Thread(...) 避免阻塞接口响应,生产环境建议替换为 Spring 的 @Async 或消息队列
  2. 画像计算幂等性computeAndSave 内部用 upsert,重复调用不会产生脏数据
  3. 历史数据清理cleanOldHistory 只在写入新记录时顺带清理,不单独定时任务
  4. 规则可扩展:推荐匹配逻辑集中在 RecommendServiceImpl.getRecommendations() 中,后续添加新规则只需在此增改 if 分支
  5. 前端 no ?. 语法:小程序禁止可选链,用 && 替代
  6. 日期格式:统一使用 yyyy-MM-dd 或 ISO 8601,禁止 toLocaleString()