Przeglądaj źródła

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

Sisyphus 2 miesięcy temu
rodzic
commit
d753cc6442

+ 1487 - 0
docs/superpowers/plans/2026-08-21-user-profile-recommendation.md

@@ -0,0 +1,1487 @@
+# 用户画像与推荐系统 实现计划
+
+> **面向 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`:
+
+```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.java` 的 `runMigrations()` 方法中添加:
+```java
+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:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+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 实体**
+
+```java
+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 实体**
+
+```java
+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 接口**
+
+```java
+// 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> {
+}
+```
+
+```java
+// 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:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+- [ ] **步骤 5:Commit**
+
+```bash
+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 接口**
+
+```java
+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。
+
+```java
+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()))
+           .eq(PointsLog::getAmount, e -> true); // positive amounts only
+        long pointsEarned = pointsLogMapper.selectList(pqw).stream()
+            .filter(p -> p.getAmount() != null && p.getAmount() > 0)
+            .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:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+预期:BUILD SUCCESS。如有错误,修复后再commit。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+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**
+
+```java
+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:创建实现类**
+
+```java
+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**
+
+```java
+package com.etotem.cfc.service;
+
+import java.util.List;
+import java.util.Map;
+
+public interface RecommendService {
+    /** 基于画像获取推荐列表 */
+    Map<String, Object> getRecommendations(Long memberId);
+}
+```
+
+实现:
+
+```java
+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 {
+
+    // 规则映射:标签code -> 匹配的Content查询条件
+    private static final Map<String, RecommendRule> RULES = new LinkedHashMap<>();
+    static {
+        RULES.put("sleep_deficit", new RecommendRule("sleep", 0.8, 
+            q -> q.like(Article::getTags, "睡眠").or().like(Article::getTags, "助眠"),
+            q -> q.eq(Activity::getDimensionCode, "body").or().like(Activity::getDescription, "睡眠")));
+        RULES.put("low_activity", new RecommendRule("action", 0.7,
+            q -> q.like(Task::getCategory, "运动").or().eq(Task::getDimensionCode, "body"),
+            null));
+        RULES.put("high_stress", new RecommendRule("mind", 0.85,
+            q -> q.like(Article::getTags, "压力").or().like(Article::getTags, "情绪管理"),
+            q -> q.eq(Activity::getDimensionCode, "mind").or().like(Activity::getTitle, "冥想")));
+        RULES.put("low_mood", new RecommendRule("mind", 0.9,
+            q -> q.like(Article::getTags, "情绪").or().like(Article::getTags, "心理"),
+            q -> q.eq(Activity::getDimensionCode, "mind")));
+        RULES.put("attention_weak", new RecommendRule("wisdom", 0.85,
+            q -> q.like(Article::getTags, "注意力").or().like(Article::getTags, "专注力"),
+            q -> q.eq(Activity::getDimensionCode, "wisdom").or().like(Activity::getTitle, "注意力")),
+            null); // task已包含
+        RULES.put("task_avoidance", new RecommendRule("action", 0.75,
+            q -> q.like(Task::getCategory, "入门").or().eq(Task::getDifficultyLevel, 1),
+            null));
+    }
+
+    @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, "active")
+               .orderByAsc(Task::getSortOrder)
+               .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_sale")
+           .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:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+- [ ] **步骤 5:Commit**
+
+```bash
+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**
+
+```java
+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**
+
+```java
+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:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+- [ ] **步骤 4:Commit**
+
+```bash
+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` 接口成功后添加:
+```java
+@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` 成功后触发:
+```java
+Long memberId = request.getLong("memberId");
+if (memberId != null) {
+    new Thread(() -> profileComputeService.computeAndSave(memberId)).start();
+}
+```
+
+- [ ] **步骤 3:修改 HealthMealController**
+
+同上模式。
+
+- [ ] **步骤 4:修改 EmotionCheckinController**
+
+在 `create` 成功后触发:
+```java
+Long childId = dto.getChildId();
+if (childId != null) {
+    new Thread(() -> profileComputeService.computeAndSave(childId)).start();
+}
+```
+
+- [ ] **步骤 5:修改 TaskController**
+
+在任务完成(status→completed)后触发:
+```java
+// 在 review/complete 接口成功后
+Long executorId = task.getExecutorId();
+if (executorId != null) {
+    new Thread(() -> profileComputeService.computeAndSave(executorId)).start();
+}
+```
+
+- [ ] **步骤 6:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+- [ ] **步骤 7:Commit**
+
+```bash
+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 方法**
+
+```python
+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` 函数中,添加画像数据获取:
+
+```python
+# 在 _collect_plan_data 末尾追加
+profile_data = await java.get_member_profile(int(member["id"]))
+member["profile"] = profile_data
+```
+
+在 `PLAN_SYSTEM_PROMPT` 中追加画像段:
+
+```python
+# 修改 PLAN_SYSTEM_PROMPT 字符串,追加:
+"""
+## 用户画像数据
+{profile_section}
+请结合上述真实指标给出针对性建议,特别是异常指标要重点说明。
+"""
+```
+
+在构建 prompt 时动态填充:
+```python
+# 在 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**
+
+```bash
+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` 末尾追加:
+```javascript
+// 画像
+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**
+
+```bash
+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**
+
+```bash
+git add cfc-web/src/views/admin/ProfileManagement.vue
+git commit -m "feat(profile): Web管理端 — 家庭成员画像列表与管理"
+```
+
+---
+
+### 任务10:联调测试与修复
+
+- [ ] **步骤 1:数据库迁移执行**
+
+```bash
+# 连接数据库执行迁移
+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端接口**
+
+```bash
+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 '{}'
+```
+
+预期:返回包含 `member`、`dimension_scores`、`body_metrics` 等字段的 JSON。
+
+- [ ] **步骤 3:测试 B端接口**
+
+```bash
+curl -X POST http://localhost:9082/api/admin/profile/get \
+  -H "Authorization: Bearer $ADMIN_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"memberId": 1}'
+```
+
+- [ ] **步骤 4:触发一次画像计算,验证数据写入**
+
+通过数据库直接查询:
+```sql
+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**
+
+```bash
+git add -A
+git commit -m "fix(profile): 联调测试修复"
+git push origin cfclub
+```
+
+---
+
+## 注意事项
+
+1. **异步触发画像计算**:使用 `new Thread(...)` 避免阻塞接口响应,生产环境建议替换为 Spring 的 `@Async` 或消息队列
+2. **画像计算幂等性**:`computeAndSave` 内部用 upsert,重复调用不会产生脏数据
+3. **历史数据清理**:`cleanOldHistory` 只在写入新记录时顺带清理,不单独定时任务
+4. **规则可扩展**:`RULES` Map 集中在 `RecommendServiceImpl` 顶部,后续添加新规则只需在此增改
+5. **前端 no `?.` 语法**:小程序禁止可选链,用 `&&` 替代
+6. **日期格式**:统一使用 `yyyy-MM-dd` 或 ISO 8601,禁止 `toLocaleString()`