# 用户画像与推荐系统 实现计划 > **面向 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 { } ``` ```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 { } ``` - [ ] **步骤 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 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 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 qw = new LambdaQueryWrapper<>(); qw.select(FamilyMember::getId); List 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 sqw = new LambdaQueryWrapper<>(); sqw.eq(HealthSleepRecord::getMemberId, memberId) .ge(HealthSleepRecord::getCreatedAt, thirtyDaysAgo); List 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 eqw = new LambdaQueryWrapper<>(); eqw.eq(HealthExerciseRecord::getMemberId, memberId) .ge(HealthExerciseRecord::getCreatedAt, sevenDaysAgo); List 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 wqw = new LambdaQueryWrapper<>(); wqw.eq(HealthWaterRecord::getMemberId, memberId) .ge(HealthWaterRecord::getCreatedAt, thirtyDaysAgo); List 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 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 qw = new LambdaQueryWrapper<>(); qw.eq(EmotionCheckin::getChildId, memberId) .ge(EmotionCheckin::getCreatedAt, thirtyDaysAgo); List 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 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 gqw = new LambdaQueryWrapper<>(); gqw.eq(GameRecord::getChildId, memberId) .ge(GameRecord::getPlayedAt, java.sql.Date.valueOf(sevenDaysAgo)); List 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 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 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 pqw = new LambdaQueryWrapper<>(); pqw.eq(PointsLog::getFamilyMemberId, memberId) .ge(PointsLog::getCreatedAt, java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay())) .gt(PointsLog::getAmount, 0); List 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 qw = new LambdaQueryWrapper<>(); qw.eq(FinanceCheckin::getChildId, memberId) .ge(FinanceCheckin::getCheckinDate, java.sql.Date.valueOf(thirtyDaysAgo)); List 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 domains = new LinkedHashSet<>(); // 睡眠问题 LambdaQueryWrapper 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 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 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 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 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 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 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 getProfile(Long memberId); /** 获取指标趋势(近30天) */ Map 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 getProfile(Long memberId) { Map result = new HashMap<>(); // 基本信息 FamilyMember member = memberMapper.selectById(memberId); if (member == null) { result.put("error", "member_not_found"); return result; } Map 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 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 getTrend(Long memberId, int days) { Map result = new HashMap<>(); LocalDate end = LocalDate.now(); LocalDate start = end.minusDays(days); LambdaQueryWrapper 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 histories = historyMapper.selectList(qw); List> points = new ArrayList<>(); for (ProfileHistory h : histories) { JSONObject metrics = JSON.parseObject(h.getAllMetrics()); Map 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 parseJsonOrEmpty(String json) { if (json == null || json.isEmpty()) return Collections.emptyMap(); try { return JSON.parseObject(json); } catch (Exception e) { return Collections.emptyMap(); } } private List 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 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 { @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 getRecommendations(Long memberId) { Map result = new HashMap<>(); LambdaQueryWrapper 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 domains = JSON.parseArray(snapshot.getProblemDomains(), String.class); if (domains == null) domains = Collections.emptyList(); Set matchedTags = new LinkedHashSet<>(); List taskTags = new ArrayList<>(); List articleTags = new ArrayList<>(); List 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> queryTasks(List tags) { List> list = new ArrayList<>(); for (String tag : tags) { LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.like(Task::getCategory, tag).or().like(Task::getDescription, tag) .eq(Task::getStatus, "pending") . .last("LIMIT 5"); List tasks = taskMapper.selectList(qw); for (Task t : tasks) { Map 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> queryArticles(List tags) { List> list = new ArrayList<>(); for (String tag : tags) { LambdaQueryWrapper
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
articles = articleMapper.selectList(qw); for (Article a : articles) { Map 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> queryActivities(List tags) { List> list = new ArrayList<>(); for (String tag : tags) { LambdaQueryWrapper 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 acts = activityMapper.selectList(qw); for (Activity a : acts) { Map 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> queryProducts(Set tags) { List> list = new ArrayList<>(); LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.eq(Product::getStatus, "on_shelf") .isNotNull(Product::getGrowthCategory) .last("LIMIT 8"); List products = productMapper.selectList(qw); for (Product p : products) { Map 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> list) { Set 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> getMyProfile(HttpServletRequest request) { Long memberId = (Long) request.getAttribute("memberId"); if (memberId == null) { memberId = (Long) request.getAttribute("userId"); } if (memberId == null) { return Result.error("未登录"); } Map profile = profileReadService.getProfile(memberId); Map recommendations = recommendService.getRecommendations(memberId); profile.putAll(recommendations); return Result.success(profile); } @PostMapping("/history") public Result> getHistory(@RequestBody Map 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 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> getProfile(@RequestBody Map params) { Long memberId = params.get("memberId") != null ? ((Number) params.get("memberId")).longValue() : null; if (memberId == null) { return Result.error("缺少memberId参数"); } Map profile = profileReadService.getProfile(memberId); Map recs = recommendService.getRecommendations(memberId); profile.putAll(recs); return Result.success(profile); } @PostMapping("/trend") public Result> getTrend(@RequestBody Map 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 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. **规则可扩展**:推荐匹配逻辑集中在 `RecommendServiceImpl.getRecommendations()` 中,后续添加新规则只需在此增改 if 分支 5. **前端 no `?.` 语法**:小程序禁止可选链,用 `&&` 替代 6. **日期格式**:统一使用 `yyyy-MM-dd` 或 ISO 8601,禁止 `toLocaleString()`