面向 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.sqlcfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.javacfc-backend/src/main/java/com/etotem/cfc/entity/ProfileHistory.javacfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileSnapshotMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileHistoryMapper.javacfc-backend/src/main/java/com/etotem/cfc/service/ProfileComputeService.javacfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.javacfc-backend/src/main/java/com/etotem/cfc/service/ProfileReadService.javacfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileReadServiceImpl.javacfc-backend/src/main/java/com/etotem/cfc/service/RecommendService.javacfc-backend/src/main/java/com/etotem/cfc/service/impl/RecommendServiceImpl.javacfc-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 | 注意力训练任务 |
文件:
cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sqlcfc-backend/src/main/resources/schema.sql修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
[ ] 步骤 1:编写迁移SQL
创建 cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sql:
-- 用户画像最新快照表
CREATE TABLE IF NOT EXISTS profile_snapshot (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
member_id BIGINT NOT NULL UNIQUE COMMENT '家庭成员ID',
dimension_scores JSON DEFAULT NULL COMMENT '五维能力评分 {"body":72,"wisdom":65,"mind":80,"action":58,"wealth":45}',
body_metrics JSON DEFAULT NULL COMMENT '近30天身体指标 {"sleep_dur_avg":9.2,"deep_sleep_pct":35,"exercise_count_week":3,"water_intake_avg_ml":1200,"meal_regularity":0.7}',
mind_metrics JSON DEFAULT NULL COMMENT '近30天心理指标 {"emotion_joy_ratio":0.72,"stress_avg":3.1,"energy_avg":6.5,"negative_ratio":0.15}',
wisdom_metrics JSON DEFAULT NULL COMMENT '最近测评成绩 {"attention":78,"focus":72,"memory":65,"logic":70,"big_five":{"openness":75,"conscientiousness":68,"extraversion":60,"agreeableness":80,"neuroticism":35},"emi":{"emotion_management":72,"empathy":78,"social_adaptability":65,"self_motivation":70}}',
action_metrics JSON DEFAULT NULL COMMENT '近7天行为指标 {"task_completion_rate":0.65,"daily_checkin_streak":12,"points_velocity":45}',
wealth_metrics JSON DEFAULT NULL COMMENT '近30天财商指标 {"income_count":3,"expense_count":5,"savings_rate":0.35}',
problem_domains VARCHAR(500) DEFAULT NULL COMMENT '关注的问题域标签 ["sleep","attention","emotion"]',
computed_at DATETIME DEFAULT NULL COMMENT '最后一次计算时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_member_id (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户画像最新快照';
-- 画像历史表(保留365天,用于趋势展示)
CREATE TABLE IF NOT EXISTS profile_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
member_id BIGINT NOT NULL COMMENT '家庭成员ID',
snapshot_date DATE NOT NULL COMMENT '日期',
all_metrics JSON NOT NULL COMMENT '当日所有指标完整快照',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_member_date (member_id, snapshot_date),
INDEX idx_date (snapshot_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户画像历史快照';
在 schema.sql 末尾追加相同的建表语句(搜索 CREATE TABLE 找到最后一个表的结束位置)。
在 DatabaseInitializer.java 的 runMigrations() 方法中添加:
ensureTable("profile_snapshot", """
CREATE TABLE IF NOT EXISTS profile_snapshot (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
member_id BIGINT NOT NULL UNIQUE,
dimension_scores JSON,
body_metrics JSON,
mind_metrics JSON,
wisdom_metrics JSON,
action_metrics JSON,
wealth_metrics JSON,
problem_domains VARCHAR(500),
computed_at DATETIME,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_member_id (member_id)
)
""");
ensureTable("profile_history", """
CREATE TABLE IF NOT EXISTS profile_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
member_id BIGINT NOT NULL,
snapshot_date DATE NOT NULL,
all_metrics JSON NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_member_date (member_id, snapshot_date)
)
""");
[ ] 步骤 4:编译验证
cd cfc-backend && mvn clean compile -q
预期:BUILD SUCCESS
[ ] 步骤 5:Commit
git add cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sql \
cfc-backend/src/main/resources/schema.sql \
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
git commit -m "feat(profile): 创建画像快照表和歷史表迁移脚本"
文件:
cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.javacfc-backend/src/main/java/com/etotem/cfc/entity/ProfileHistory.javacfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileSnapshotMapper.java新建:cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileHistoryMapper.java
[ ] 步骤 1:创建 ProfileSnapshot 实体
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("profile_snapshot")
public class ProfileSnapshot implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long memberId;
private String dimensionScores; // JSON
private String bodyMetrics; // JSON
private String mindMetrics; // JSON
private String wisdomMetrics; // JSON
private String actionMetrics; // JSON
private String wealthMetrics; // JSON
private String problemDomains; // JSON array string
private Date computedAt;
private Date updatedAt;
}
[ ] 步骤 2:创建 ProfileHistory 实体
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("profile_history")
public class ProfileHistory implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long memberId;
private Date snapshotDate;
private String allMetrics; // JSON
private Date createdAt;
}
[ ] 步骤 3:创建 Mapper 接口
// ProfileSnapshotMapper.java
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.ProfileSnapshot;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ProfileSnapshotMapper extends BaseMapper<ProfileSnapshot> {
}
// ProfileHistoryMapper.java
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.ProfileHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ProfileHistoryMapper extends BaseMapper<ProfileHistory> {
}
[ ] 步骤 4:编译验证
cd cfc-backend && mvn clean compile -q
[ ] 步骤 5:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java \
cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileHistory.java \
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileSnapshotMapper.java \
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileHistoryMapper.java
git commit -m "feat(profile): 创建画像实体类和Mapper接口"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/ProfileComputeService.java新建:cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.java
[ ] 步骤 1:创建 Service 接口
package com.etotem.cfc.service;
public interface ProfileComputeService {
/** 为指定家庭成员重新计算并写入画像快照 */
void computeAndSave(Long memberId);
/** 批量计算所有家庭成员画像 */
void computeAll();
}
[ ] 步骤 2:创建实现类 — 核心计算逻辑
文件:cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.java
核心思路:查询各维度历史数据 → 按时间窗口聚合 → 写入 profile_snapshot。
package com.etotem.cfc.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.*;
import com.etotem.cfc.mapper.*;
import com.etotem.cfc.service.ProfileComputeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class ProfileComputeServiceImpl implements ProfileComputeService {
@Autowired private ProfileSnapshotMapper snapshotMapper;
@Autowired private ProfileHistoryMapper historyMapper;
@Autowired private HealthSleepRecordMapper sleepMapper;
@Autowired private HealthExerciseRecordMapper exerciseMapper;
@Autowired private HealthMealRecordMapper mealMapper;
@Autowired private HealthWaterRecordMapper waterMapper;
@Autowired private EmotionCheckinMapper emotionMapper;
@Autowired private TaskMapper taskMapper;
@Autowired private DanAssessmentResultMapper danMapper;
@Autowired private GameRecordMapper gameMapper;
@Autowired private FinanceCheckinMapper financeMapper;
@Autowired private FamilyMemberMapper memberMapper;
@Autowired private PointsLogMapper pointsLogMapper;
@Override
public void computeAndSave(Long memberId) {
LocalDate now = LocalDate.now();
// 1. 身体指标(近30天)
JSONObject bodyMetrics = computeBodyMetrics(memberId, now);
// 2. 心理指标(近30天)
JSONObject mindMetrics = computeMindMetrics(memberId, now);
// 3. 智育指标(最近测评)
JSONObject wisdomMetrics = computeWisdomMetrics(memberId);
// 4. 行为指标(近7天)
JSONObject actionMetrics = computeActionMetrics(memberId, now);
// 5. 财富指标(近30天)
JSONObject wealthMetrics = computeWealthMetrics(memberId, now);
// 6. 五维综合评分
JSONObject dimensionScores = computeDimensionScores(bodyMetrics, mindMetrics, wisdomMetrics, actionMetrics, wealthMetrics);
// 7. 问题域(从problem_survey的resultTags推断)
String problemDomains = computeProblemDomains(memberId);
// 8. 组装快照
LocalDateTime computedAt = LocalDateTime.now();
JSONObject allMetrics = new JSONObject();
allMetrics.put("dimension_scores", dimensionScores);
allMetrics.put("body_metrics", bodyMetrics);
allMetrics.put("mind_metrics", mindMetrics);
allMetrics.put("wisdom_metrics", wisdomMetrics);
allMetrics.put("action_metrics", actionMetrics);
allMetrics.put("wealth_metrics", wealthMetrics);
allMetrics.put("problem_domains", problemDomains);
allMetrics.put("computed_at", computedAt.toString());
// 9. 写入快照表(upsert)
LambdaQueryWrapper<ProfileSnapshot> qw = new LambdaQueryWrapper<>();
qw.eq(ProfileSnapshot::getMemberId, memberId);
ProfileSnapshot snapshot = snapshotMapper.selectOne(qw);
if (snapshot == null) {
snapshot = new ProfileSnapshot();
snapshot.setMemberId(memberId);
}
snapshot.setDimensionScores(dimensionScores.toJSONString());
snapshot.setBodyMetrics(bodyMetrics.toJSONString());
snapshot.setMindMetrics(mindMetrics.toJSONString());
snapshot.setWisdomMetrics(wisdomMetrics.toJSONString());
snapshot.setActionMetrics(actionMetrics.toJSONString());
snapshot.setWealthMetrics(wealthMetrics.toJSONString());
snapshot.setProblemDomains(problemDomains);
snapshot.setComputedAt(computedAt);
snapshotMapper.insert(snapshot);
// 10. 写入历史表(每日一条,去重)
LambdaQueryWrapper<ProfileHistory> hqw = new LambdaQueryWrapper<>();
hqw.eq(ProfileHistory::getMemberId, memberId)
.eq(ProfileHistory::getSnapshotDate, now);
ProfileHistory history = historyMapper.selectOne(hqw);
if (history == null) {
history = new ProfileHistory();
history.setMemberId(memberId);
history.setSnapshotDate(new Date());
history.setAllMetrics(allMetrics.toJSONString());
historyMapper.insert(history);
} else {
history.setAllMetrics(allMetrics.toJSONString());
historyMapper.updateById(history);
}
// 11. 清理365天外的历史
cleanOldHistory(memberId, now);
}
@Override
public void computeAll() {
LambdaQueryWrapper<FamilyMember> qw = new LambdaQueryWrapper<>();
qw.select(FamilyMember::getId);
List<FamilyMember> members = memberMapper.selectList(qw);
for (FamilyMember m : members) {
try {
computeAndSave(m.getId());
} catch (Exception e) {
// 单条失败不影响整体
}
}
}
// ---- 各维度计算方法 ----
private JSONObject computeBodyMetrics(Long memberId, LocalDate now) {
JSONObject m = new JSONObject();
// 睡眠:近30天平均入睡/醒来时间,平均时长
LocalDate thirtyDaysAgo = now.minusDays(30);
LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
sqw.eq(HealthSleepRecord::getMemberId, memberId)
.ge(HealthSleepRecord::getCreatedAt, thirtyDaysAgo);
List<HealthSleepRecord> sleeps = sleepMapper.selectList(sqw);
if (!sleeps.isEmpty()) {
int totalMin = sleeps.stream().mapToInt(r -> r.getDurationMinutes() != null ? r.getDurationMinutes() : 0).sum();
m.put("sleep_dur_avg", Math.round(totalMin / (double)sleeps.size() / 60 * 10) / 10.0);
int deepTotal = sleeps.stream().mapToInt(r -> r.getDeepSleepMinutes() != null ? r.getDeepSleepMinutes() : 0).sum();
int lightTotal = sleeps.stream().mapToInt(r -> r.getLightSleepMinutes() != null ? r.getLightSleepMinutes() : 0).sum();
int remTotal = sleeps.stream().mapToInt(r -> r.getRemMinutes() != null ? r.getRemMinutes() : 0).sum();
int totalSleepMin = deepTotal + lightTotal + remTotal;
m.put("deep_sleep_pct", totalSleepMin > 0 ? Math.round((double)deepTotal / totalSleepMin * 100) : 0);
m.put("sleep_records_count", sleeps.size());
}
// 运动:近7天运动次数
LocalDate sevenDaysAgo = now.minusDays(7);
LambdaQueryWrapper<HealthExerciseRecord> eqw = new LambdaQueryWrapper<>();
eqw.eq(HealthExerciseRecord::getMemberId, memberId)
.ge(HealthExerciseRecord::getCreatedAt, sevenDaysAgo);
List<HealthExerciseRecord> exercises = exerciseMapper.selectList(eqw);
m.put("exercise_count_week", exercises.size());
int totalExMin = exercises.stream().mapToInt(r -> r.getDurationMinutes() != null ? r.getDurationMinutes() : 0).sum();
m.put("exercise_duration_week", totalExMin);
// 喝水:近30天平均
LambdaQueryWrapper<HealthWaterRecord> wqw = new LambdaQueryWrapper<>();
wqw.eq(HealthWaterRecord::getMemberId, memberId)
.ge(HealthWaterRecord::getCreatedAt, thirtyDaysAgo);
List<HealthWaterRecord> waters = waterMapper.selectList(wqw);
if (!waters.isEmpty()) {
int totalMl = waters.stream().mapToInt(r -> r.getAmountMl() != null ? r.getAmountMl() : 0).sum();
m.put("water_intake_avg_ml", Math.round((double)totalMl / waters.size()));
}
// 饮食规律性
LambdaQueryWrapper<HealthMealRecord> mqw = new LambdaQueryWrapper<>();
mqw.eq(HealthMealRecord::getMemberId, memberId)
.ge(HealthMealRecord::getCreatedAt, thirtyDaysAgo);
long mealCount = mealMapper.selectCount(mqw);
m.put("meal_records_count", mealCount);
return m;
}
private JSONObject computeMindMetrics(Long memberId, LocalDate now) {
JSONObject m = new JSONObject();
LocalDate thirtyDaysAgo = now.minusDays(30);
LambdaQueryWrapper<EmotionCheckin> qw = new LambdaQueryWrapper<>();
qw.eq(EmotionCheckin::getChildId, memberId)
.ge(EmotionCheckin::getCreatedAt, thirtyDaysAgo);
List<EmotionCheckin> emotions = emotionMapper.selectList(qw);
if (!emotions.isEmpty()) {
long total = emotions.size();
long joyCount = emotions.stream().filter(e -> "joy".equals(e.getEmotionType()) || "excited".equals(e.getEmotionType())).count();
m.put("emotion_joy_ratio", Math.round(joyCount / (double)total * 100) / 100.0);
long sadCount = emotions.stream().filter(e -> "sad".equals(e.getEmotionType())).count();
long angryCount = emotions.stream().filter(e -> "angry".equals(e.getEmotionType())).count();
m.put("negative_ratio", Math.round((sadCount + angryCount) / (double)total * 100) / 100.0);
double stressSum = emotions.stream().filter(e -> e.getStressLevel() != null).mapToDouble(e -> e.getStressLevel()).sum();
long stressCount = emotions.stream().filter(e -> e.getStressLevel() != null).count();
m.put("stress_avg", stressCount > 0 ? Math.round(stressSum / stressCount * 10) / 10.0 : 0);
double energySum = emotions.stream().filter(e -> e.getEnergyLevel() != null).mapToDouble(e -> e.getEnergyLevel()).sum();
long energyCount = emotions.stream().filter(e -> e.getEnergyLevel() != null).count();
m.put("energy_avg", energyCount > 0 ? Math.round(energySum / energyCount * 10) / 10.0 : 0);
double moodSum = emotions.stream().filter(e -> e.getMoodScore() != null).mapToDouble(e -> e.getMoodScore()).sum();
long moodCount = emotions.stream().filter(e -> e.getMoodScore() != null).count();
m.put("mood_score_avg", moodCount > 0 ? Math.round(moodSum / moodCount * 10) / 10.0 : 0);
m.put("emotion_records_count", total);
}
return m;
}
private JSONObject computeWisdomMetrics(Long memberId) {
JSONObject m = new JSONObject();
LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<>();
qw.eq(DanAssessmentResult::getFamilyMemberId, memberId)
.orderByDesc(DanAssessmentResult::getAssessmentDate)
.last("LIMIT 1");
DanAssessmentResult result = danMapper.selectOne(qw);
if (result != null) {
m.put("attention_score", result.getAttentionScore());
m.put("focus_score", result.getFocusScore());
m.put("memory_score", result.getMemoryScore());
m.put("logic_score", result.getLogicScore());
m.put("perception_score", result.getPerceptionScore());
m.put("spatial_score", result.getSpatialScore());
m.put("processing_speed_score", result.getProcessingSpeedScore());
m.put("overall_score", result.getOverallScore());
// 大五人格
JSONObject bigFive = new JSONObject();
bigFive.put("openness", result.getOpennessScore());
bigFive.put("conscientiousness", result.getConscientiousnessScore());
bigFive.put("extraversion", result.getExtraversionScore());
bigFive.put("agreeableness", result.getAgreeablenessScore());
bigFive.put("neuroticism", result.getNeuroticismScore());
bigFive.put("overall", result.getBigFiveOverallScore());
m.put("big_five", bigFive);
// EMI情商
JSONObject emi = new JSONObject();
emi.put("emotion_management", result.getEmotionManagementScore());
emi.put("empathy", result.getEmpathyScore());
emi.put("social_adaptability", result.getSocialAdaptabilityScore());
emi.put("self_motivation", result.getSelfMotivationScore());
m.put("emi", emi);
m.put("assessment_date", result.getAssessmentDate());
}
// 游戏分数(近7天)
LocalDate sevenDaysAgo = LocalDate.now().minusDays(7);
LambdaQueryWrapper<GameRecord> gqw = new LambdaQueryWrapper<>();
gqw.eq(GameRecord::getChildId, memberId)
.ge(GameRecord::getPlayedAt, java.sql.Date.valueOf(sevenDaysAgo));
List<GameRecord> games = gameMapper.selectList(gqw);
if (!games.isEmpty()) {
int avgScore = games.stream().mapToInt(g -> g.getScore() != null ? g.getScore() : 0).sum() / games.size();
m.put("game_avg_score_7d", avgScore);
m.put("game_count_7d", games.size());
}
return m;
}
private JSONObject computeActionMetrics(Long memberId, LocalDate now) {
JSONObject m = new JSONObject();
LocalDate sevenDaysAgo = now.minusDays(7);
// 任务完成率
LambdaQueryWrapper<Task> tqw = new LambdaQueryWrapper<>();
tqw.and(w -> w.eq(Task::getExecutorId, memberId).or().eq(Task::getChildId, memberId))
.ge(Task::getCreatedAt, java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay()));
long totalTasks = taskMapper.selectCount(tqw);
LambdaQueryWrapper<Task> doneQw = new LambdaQueryWrapper<>();
doneQw.and(w -> w.eq(Task::getExecutorId, memberId).or().eq(Task::getChildId, memberId))
.eq(Task::getStatus, "completed")
.ge(Task::getCompletedAt, java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay()));
long doneTasks = taskMapper.selectCount(doneQw);
m.put("task_completion_rate", totalTasks > 0 ? Math.round(doneTasks / (double)totalTasks * 100) / 100.0 : 0);
m.put("task_total_7d", totalTasks);
m.put("task_done_7d", doneTasks);
// 打卡连续天数
m.put("checkin_streak", computeCheckinStreak(memberId));
// 积分速度
LambdaQueryWrapper<PointsLog> pqw = new LambdaQueryWrapper<>();
pqw.eq(PointsLog::getFamilyMemberId, memberId)
.ge(PointsLog::getCreatedAt, java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay()))
.gt(PointsLog::getAmount, 0);
List<PointsLog> pointsList = pointsLogMapper.selectList(pqw);
long pointsEarned = pointsList.stream()
.mapToInt(PointsLog::getAmount).sum();
m.put("points_velocity_7d", pointsEarned);
return m;
}
private JSONObject computeWealthMetrics(Long memberId, LocalDate now) {
JSONObject m = new JSONObject();
LocalDate thirtyDaysAgo = now.minusDays(30);
LambdaQueryWrapper<FinanceCheckin> qw = new LambdaQueryWrapper<>();
qw.eq(FinanceCheckin::getChildId, memberId)
.ge(FinanceCheckin::getCheckinDate, java.sql.Date.valueOf(thirtyDaysAgo));
List<FinanceCheckin> finances = financeMapper.selectList(qw);
if (!finances.isEmpty()) {
long income = finances.stream().filter(f -> "income".equals(f.getType())).count();
long expense = finances.stream().filter(f -> "expense".equals(f.getType())).count();
m.put("income_count", income);
m.put("expense_count", expense);
int totalIncome = finances.stream().filter(f -> "income".equals(f.getType()))
.mapToInt(f -> f.getAmount() != null ? f.getAmount() : 0).sum();
int totalExpense = finances.stream().filter(f -> "expense".equals(f.getType()))
.mapToInt(f -> f.getAmount() != null ? f.getAmount() : 0).sum();
m.put("savings_rate", totalIncome > 0 ? Math.round((totalIncome - totalExpense) / (double)totalIncome * 100) / 100.0 : 0);
}
return m;
}
private JSONObject computeDimensionScores(JSONObject body, JSONObject mind, JSONObject wisdom, JSONObject action, JSONObject wealth) {
JSONObject scores = new JSONObject();
// 身维度:综合睡眠+运动+饮食
int bodyScore = 50;
double sleepAvg = body.getDoubleValue("sleep_dur_avg", 0);
if (sleepAvg >= 8 && sleepAvg <= 10) bodyScore += 15;
else if (sleepAvg >= 7 && sleepAvg <= 11) bodyScore += 8;
int exerciseWeek = body.getIntValue("exercise_count_week", 0);
if (exerciseWeek >= 3) bodyScore += 15;
else if (exerciseWeek >= 1) bodyScore += 8;
int waterAvg = body.getIntValue("water_intake_avg_ml", 0);
if (waterAvg >= 1000) bodyScore += 10;
else if (waterAvg >= 500) bodyScore += 5;
scores.put("body", Math.min(bodyScore, 100));
// 心维度:情绪正向率+压力反向
int mindScore = 50;
double joyRatio = mind.getDoubleValue("emotion_joy_ratio", 0.5);
mindScore += (int)(joyRatio * 30);
double stressAvg = mind.getDoubleValue("stress_avg", 5);
mindScore -= (int)((stressAvg - 3) * 5);
double negativeRatio = mind.getDoubleValue("negative_ratio", 0);
mindScore -= (int)(negativeRatio * 30);
scores.put("mind", Math.max(0, Math.min(100, mindScore)));
// 智维度:测评分+游戏分
int wisdomScore = 50;
Integer overall = wisdom.getInteger("overall_score");
if (overall != null) wisdomScore = overall;
Integer gameAvg = wisdom.getInteger("game_avg_score_7d");
if (gameAvg != null) wisdomScore = Math.max(wisdomScore, gameAvg);
scores.put("wisdom", wisdomScore);
// 行维度:完成率+连续天数
int actionScore = 50;
double compRate = action.getDoubleValue("task_completion_rate", 0.5);
actionScore += (int)(compRate * 30);
int streak = action.getIntValue("checkin_streak", 0);
actionScore += Math.min(streak, 10);
scores.put("action", Math.min(100, actionScore));
// 富维度
int wealthScore = 50;
double savingsRate = wealth.getDoubleValue("savings_rate", 0.5);
wealthScore += (int)(savingsRate * 30);
int financeCount = wealth.getIntValue("income_count", 0) + wealth.getIntValue("expense_count", 0);
if (financeCount >= 5) wealthScore += 20;
else if (financeCount >= 2) wealthScore += 10;
scores.put("wealth", Math.min(100, wealthScore));
return scores;
}
private String computeProblemDomains(Long memberId) {
// 从problem_survey的resultTags和behavior推断
Set<String> domains = new LinkedHashSet<>();
// 睡眠问题
LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
sqw.eq(HealthSleepRecord::getMemberId, memberId)
.ge(HealthSleepRecord::getCreatedAt, LocalDate.now().minusDays(30))
.lt(HealthSleepRecord::getDurationMinutes, 480); // < 8h
if (sleepMapper.selectCount(sqw) > 3) domains.add("sleep");
// 注意力问题
LambdaQueryWrapper<DanAssessmentResult> dwqw = new LambdaQueryWrapper<>();
dwqw.eq(DanAssessmentResult::getFamilyMemberId, memberId)
.isNotNull(DanAssessmentResult::getAttentionScore)
.lt(DanAssessmentResult::getAttentionScore, 60)
.orderByDesc(DanAssessmentResult::getAssessmentDate)
.last("LIMIT 1");
if (danMapper.selectCount(dwqw) > 0) domains.add("attention");
// 情绪问题
LambdaQueryWrapper<EmotionCheckin> eqw = new LambdaQueryWrapper<>();
eqw.eq(EmotionCheckin::getChildId, memberId)
.ge(EmotionCheckin::getCreatedAt, LocalDate.now().minusDays(30))
.in(EmotionCheckin::getEmotionType, Arrays.asList("sad", "anxious", "angry"));
if (emotionMapper.selectCount(eqw) > 5) domains.add("emotion");
return domains.isEmpty() ? "[]" : JSON.toJSONString(new ArrayList<>(domains));
}
private int computeCheckinStreak(Long memberId) {
// 简化版:计算连续打卡天数
int streak = 0;
LocalDate today = LocalDate.now();
for (int i = 0; i < 365; i++) {
LocalDate d = today.minusDays(i);
// 检查当天是否有任意打卡记录
boolean hasCheckin = false;
// sleep
LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
sqw.eq(HealthSleepRecord::getMemberId, memberId)
.ge(HealthSleepRecord::getCreatedAt, java.sql.Timestamp.valueOf(d.atStartOfDay()))
.lt(HealthSleepRecord::getCreatedAt, java.sql.Timestamp.valueOf(d.plusDays(1).atStartOfDay()));
if (sleepMapper.selectCount(sqw) > 0) { hasCheckin = true; }
// exercise
if (!hasCheckin) {
LambdaQueryWrapper<HealthExerciseRecord> eqw = new LambdaQueryWrapper<>();
eqw.eq(HealthExerciseRecord::getMemberId, memberId)
.ge(HealthExerciseRecord::getCreatedAt, java.sql.Timestamp.valueOf(d.atStartOfDay()))
.lt(HealthExerciseRecord::getCreatedAt, java.sql.Timestamp.valueOf(d.plusDays(1).atStartOfDay()));
if (exerciseMapper.selectCount(eqw) > 0) { hasCheckin = true; }
}
// emotion
if (!hasCheckin) {
LambdaQueryWrapper<EmotionCheckin> emqw = new LambdaQueryWrapper<>();
emqw.eq(EmotionCheckin::getChildId, memberId)
.ge(EmotionCheckin::getCreatedAt, java.sql.Timestamp.valueOf(d.atStartOfDay()))
.lt(EmotionCheckin::getCreatedAt, java.sql.Timestamp.valueOf(d.plusDays(1).atStartOfDay()));
if (emotionMapper.selectCount(emqw) > 0) { hasCheckin = true; }
}
if (hasCheckin) streak++;
else if (i > 0) break;
}
return streak;
}
private void cleanOldHistory(Long memberId, LocalDate now) {
LocalDate cutoff = now.minusDays(365);
LambdaQueryWrapper<ProfileHistory> qw = new LambdaQueryWrapper<>();
qw.eq(ProfileHistory::getMemberId, memberId)
.lt(ProfileHistory::getSnapshotDate, java.sql.Date.valueOf(cutoff));
historyMapper.delete(qw);
}
}
[ ] 步骤 3:编译验证
cd cfc-backend && mvn clean compile -q
预期:BUILD SUCCESS。如有错误,修复后再commit。
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/ProfileComputeService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.java
git commit -m "feat(profile): 实现画像计算服务 — 五维指标聚合+增量写入"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/ProfileReadService.javacfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileReadServiceImpl.javacfc-backend/src/main/java/com/etotem/cfc/service/RecommendService.java新建:cfc-backend/src/main/java/com/etotem/cfc/service/impl/RecommendServiceImpl.java
[ ] 步骤 1:创建 ProfileReadService
package com.etotem.cfc.service;
import java.util.Map;
public interface ProfileReadService {
/** 获取最新画像快照(含基础信息) */
Map<String, Object> getProfile(Long memberId);
/** 获取指标趋势(近30天) */
Map<String, Object> getTrend(Long memberId, int days);
}
[ ] 步骤 2:创建实现类
package com.etotem.cfc.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.*;
import com.etotem.cfc.mapper.*;
import com.etotem.cfc.service.ProfileReadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.*;
@Service
public class ProfileReadServiceImpl implements ProfileReadService {
@Autowired private ProfileSnapshotMapper snapshotMapper;
@Autowired private ProfileHistoryMapper historyMapper;
@Autowired private FamilyMemberMapper memberMapper;
@Autowired private UserMapper userMapper;
@Override
public Map<String, Object> getProfile(Long memberId) {
Map<String, Object> result = new HashMap<>();
// 基本信息
FamilyMember member = memberMapper.selectById(memberId);
if (member == null) {
result.put("error", "member_not_found");
return result;
}
Map<String, Object> memberInfo = new HashMap<>();
memberInfo.put("id", member.getId());
memberInfo.put("name", member.getNickname());
memberInfo.put("age", member.getAge());
memberInfo.put("gender", member.getGender());
result.put("member", memberInfo);
// 画像快照
LambdaQueryWrapper<ProfileSnapshot> qw = new LambdaQueryWrapper<>();
qw.eq(ProfileSnapshot::getMemberId, memberId);
ProfileSnapshot snapshot = snapshotMapper.selectOne(qw);
if (snapshot != null) {
result.put("dimension_scores", parseJsonOrEmpty(snapshot.getDimensionScores()));
result.put("body_metrics", parseJsonOrEmpty(snapshot.getBodyMetrics()));
result.put("mind_metrics", parseJsonOrEmpty(snapshot.getMindMetrics()));
result.put("wisdom_metrics", parseJsonOrEmpty(snapshot.getWisdomMetrics()));
result.put("action_metrics", parseJsonOrEmpty(snapshot.getActionMetrics()));
result.put("wealth_metrics", parseJsonOrEmpty(snapshot.getWealthMetrics()));
result.put("problem_domains", parseJsonArrayOrEmpty(snapshot.getProblemDomains()));
result.put("computed_at", snapshot.getComputedAt());
} else {
// 无快照返回空结构
result.put("dimension_scores", Collections.emptyMap());
result.put("body_metrics", Collections.emptyMap());
result.put("mind_metrics", Collections.emptyMap());
result.put("wisdom_metrics", Collections.emptyMap());
result.put("action_metrics", Collections.emptyMap());
result.put("wealth_metrics", Collections.emptyMap());
result.put("problem_domains", Collections.emptyList());
}
return result;
}
@Override
public Map<String, Object> getTrend(Long memberId, int days) {
Map<String, Object> result = new HashMap<>();
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days);
LambdaQueryWrapper<ProfileHistory> qw = new LambdaQueryWrapper<>();
qw.eq(ProfileHistory::getMemberId, memberId)
.ge(ProfileHistory::getSnapshotDate, java.sql.Date.valueOf(start))
.le(ProfileHistory::getSnapshotDate, java.sql.Date.valueOf(end))
.orderByAsc(ProfileHistory::getSnapshotDate);
List<ProfileHistory> histories = historyMapper.selectList(qw);
List<Map<String, Object>> points = new ArrayList<>();
for (ProfileHistory h : histories) {
JSONObject metrics = JSON.parseObject(h.getAllMetrics());
Map<String, Object> point = new HashMap<>();
point.put("date", h.getSnapshotDate().toString());
point.put("dimension_scores", metrics.getObject("dimension_scores", JSONObject.class));
point.put("body_metrics", metrics.getObject("body_metrics", JSONObject.class));
point.put("mind_metrics", metrics.getObject("mind_metrics", JSONObject.class));
points.add(point);
}
result.put("points", points);
result.put("total", histories.size());
return result;
}
private Map<String, Object> parseJsonOrEmpty(String json) {
if (json == null || json.isEmpty()) return Collections.emptyMap();
try { return JSON.parseObject(json); } catch (Exception e) { return Collections.emptyMap(); }
}
private List<String> parseJsonArrayOrEmpty(String json) {
if (json == null || json.isEmpty()) return Collections.emptyList();
try { return JSON.parseArray(json, String.class); } catch (Exception e) { return Collections.emptyList(); }
}
}
[ ] 步骤 3:创建 RecommendService
package com.etotem.cfc.service;
import java.util.List;
import java.util.Map;
public interface RecommendService {
/** 基于画像获取推荐列表 */
Map<String, Object> getRecommendations(Long memberId);
}
实现:
package com.etotem.cfc.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.*;
import com.etotem.cfc.mapper.*;
import com.etotem.cfc.service.RecommendService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
public class RecommendServiceImpl implements RecommendService {
@Autowired private ProfileSnapshotMapper snapshotMapper;
@Autowired private ArticleMapper articleMapper;
@Autowired private ActivityMapper activityMapper;
@Autowired private TaskMapper taskMapper;
@Autowired private ProductMapper productMapper;
@Autowired private ArticleCategoryMapper categoryMapper;
@Override
public Map<String, Object> getRecommendations(Long memberId) {
Map<String, Object> result = new HashMap<>();
LambdaQueryWrapper<ProfileSnapshot> qw = new LambdaQueryWrapper<>();
qw.eq(ProfileSnapshot::getMemberId, memberId);
ProfileSnapshot snapshot = snapshotMapper.selectOne(qw);
if (snapshot == null) {
result.put("tasks", Collections.emptyList());
result.put("articles", Collections.emptyList());
result.put("activities", Collections.emptyList());
result.put("products", Collections.emptyList());
result.put("matched_tags", Collections.emptyList());
return result;
}
JSONObject body = JSON.parseObject(snapshot.getBodyMetrics());
JSONObject mind = JSON.parseObject(snapshot.getMindMetrics());
JSONObject wisdom = JSON.parseObject(snapshot.getWisdomMetrics());
JSONObject action = JSON.parseObject(snapshot.getActionMetrics());
List<String> domains = JSON.parseArray(snapshot.getProblemDomains(), String.class);
if (domains == null) domains = Collections.emptyList();
Set<String> matchedTags = new LinkedHashSet<>();
List<String> taskTags = new ArrayList<>();
List<String> articleTags = new ArrayList<>();
List<String> activityTags = new ArrayList<>();
// 规则匹配
if (body.getDoubleValue("sleep_dur_avg", 99) < 8) { matchedTags.add("sleep_deficit"); taskTags.add("sleep"); articleTags.add("睡眠"); }
if (body.getIntValue("exercise_count_week", 99) < 2) { matchedTags.add("low_activity"); taskTags.add("运动"); }
if (mind.getDoubleValue("stress_avg", 0) > 6) { matchedTags.add("high_stress"); articleTags.add("压力"); }
if (mind.getDoubleValue("emotion_joy_ratio", 1) < 0.3) { matchedTags.add("low_mood"); articleTags.add("情绪"); }
if (wisdom.getInteger("attention_score") != null && wisdom.getInteger("attention_score") < 60) { matchedTags.add("attention_weak"); taskTags.add("注意力"); }
if (action.getDoubleValue("task_completion_rate", 1) < 0.4) { matchedTags.add("task_avoidance"); taskTags.add("入门"); }
for (String domain : domains) {
matchedTags.add(domain + "_focus");
if (domain.equals("sleep")) { articleTags.add("睡眠"); }
if (domain.equals("attention")) { taskTags.add("注意力"); }
if (domain.equals("emotion")) { articleTags.add("情绪"); }
}
// 查询推荐内容
result.put("matched_tags", new ArrayList<>(matchedTags));
result.put("tasks", queryTasks(taskTags));
result.put("articles", queryArticles(articleTags));
result.put("activities", queryActivities(activityTags));
result.put("products", queryProducts(matchedTags));
return result;
}
private List<Map<String, Object>> queryTasks(List<String> tags) {
List<Map<String, Object>> list = new ArrayList<>();
for (String tag : tags) {
LambdaQueryWrapper<Task> qw = new LambdaQueryWrapper<>();
qw.like(Task::getCategory, tag).or().like(Task::getDescription, tag)
.eq(Task::getStatus, "pending")
.
.last("LIMIT 5");
List<Task> tasks = taskMapper.selectList(qw);
for (Task t : tasks) {
Map<String, Object> item = new HashMap<>();
item.put("id", t.getId());
item.put("title", t.getTitle());
item.put("category", t.getCategory());
item.put("type", "task");
list.add(item);
}
}
deduplicate(list);
return list;
}
private List<Map<String, Object>> queryArticles(List<String> tags) {
List<Map<String, Object>> list = new ArrayList<>();
for (String tag : tags) {
LambdaQueryWrapper<Article> qw = new LambdaQueryWrapper<>();
qw.and(w -> w.like(Article::getTags, tag).or().like(Article::getTitle, tag))
.eq(Article::getAuditStatus, "approved")
.eq(Article::getStatus, "published")
.orderByDesc(Article::getPublishedAt)
.last("LIMIT 5");
List<Article> articles = articleMapper.selectList(qw);
for (Article a : articles) {
Map<String, Object> item = new HashMap<>();
item.put("id", a.getId());
item.put("title", a.getTitle());
item.put("summary", a.getSummary());
item.put("coverImage", a.getCoverImage());
item.put("type", "article");
list.add(item);
}
}
deduplicate(list);
return list;
}
private List<Map<String, Object>> queryActivities(List<String> tags) {
List<Map<String, Object>> list = new ArrayList<>();
for (String tag : tags) {
LambdaQueryWrapper<Activity> qw = new LambdaQueryWrapper<>();
qw.and(w -> w.like(Activity::getTitle, tag).or().like(Activity::getDescription, tag))
.eq(Activity::getAuditStatus, "approved")
.eq(Activity::getStatus, "published")
.orderByAsc(Activity::getStartTime)
.last("LIMIT 3");
List<Activity> acts = activityMapper.selectList(qw);
for (Activity a : acts) {
Map<String, Object> item = new HashMap<>();
item.put("id", a.getId());
item.put("title", a.getTitle());
item.put("dimensionCode", a.getDimensionCode());
item.put("type", "activity");
list.add(item);
}
}
deduplicate(list);
return list;
}
private List<Map<String, Object>> queryProducts(Set<String> tags) {
List<Map<String, Object>> list = new ArrayList<>();
LambdaQueryWrapper<Product> qw = new LambdaQueryWrapper<>();
qw.eq(Product::getStatus, "on_shelf")
.isNotNull(Product::getGrowthCategory)
.last("LIMIT 8");
List<Product> products = productMapper.selectList(qw);
for (Product p : products) {
Map<String, Object> item = new HashMap<>();
item.put("id", p.getId());
item.put("name", p.getName());
item.put("price", p.getPrice());
item.put("growthCategory", p.getGrowthCategory());
item.put("type", "product");
list.add(item);
}
return list;
}
private void deduplicate(List<Map<String, Object>> list) {
Set<Long> seen = new HashSet<>();
list.removeIf(item -> {
Long id = ((Number) item.get("id")).longValue();
return !seen.add(id);
});
}
}
[ ] 步骤 4:编译验证
cd cfc-backend && mvn clean compile -q
[ ] 步骤 5:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/ProfileReadService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileReadServiceImpl.java \
cfc-backend/src/main/java/com/etotem/cfc/service/RecommendService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/impl/RecommendServiceImpl.java
git commit -m "feat(profile): 实现画像读取服务和规则推荐引擎"
文件:
cfc-backend/src/main/java/com/etotem/cfc/controller/profile/ProfileController.java新建:cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProfileController.java
[ ] 步骤 1:创建 C端 ProfileController
package com.etotem.cfc.controller.profile;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.service.ProfileReadService;
import com.etotem.cfc.service.RecommendService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
@RestController
@RequestMapping("/api/profile")
public class ProfileController {
@Autowired private ProfileReadService profileReadService;
@Autowired private RecommendService recommendService;
@PostMapping("/my")
public Result<Map<String, Object>> getMyProfile(HttpServletRequest request) {
Long memberId = (Long) request.getAttribute("memberId");
if (memberId == null) {
memberId = (Long) request.getAttribute("userId");
}
if (memberId == null) {
return Result.error("未登录");
}
Map<String, Object> profile = profileReadService.getProfile(memberId);
Map<String, Object> recommendations = recommendService.getRecommendations(memberId);
profile.putAll(recommendations);
return Result.success(profile);
}
@PostMapping("/history")
public Result<Map<String, Object>> getHistory(@RequestBody Map<String, Object> params, HttpServletRequest request) {
Long memberId = (Long) request.getAttribute("memberId");
if (memberId == null) {
memberId = (Long) request.getAttribute("userId");
}
int days = params.get("days") != null ? ((Number) params.get("days")).intValue() : 30;
Map<String, Object> trend = profileReadService.getTrend(memberId, days);
return Result.success(trend);
}
}
[ ] 步骤 2:创建 B端 AdminProfileController
package com.etotem.cfc.controller.admin;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.service.ProfileReadService;
import com.etotem.cfc.service.RecommendService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/profile")
public class AdminProfileController {
@Autowired private ProfileReadService profileReadService;
@Autowired private RecommendService recommendService;
@PostMapping("/get")
public Result<Map<String, Object>> getProfile(@RequestBody Map<String, Object> params) {
Long memberId = params.get("memberId") != null ? ((Number) params.get("memberId")).longValue() : null;
if (memberId == null) {
return Result.error("缺少memberId参数");
}
Map<String, Object> profile = profileReadService.getProfile(memberId);
Map<String, Object> recs = recommendService.getRecommendations(memberId);
profile.putAll(recs);
return Result.success(profile);
}
@PostMapping("/trend")
public Result<Map<String, Object>> getTrend(@RequestBody Map<String, Object> params) {
Long memberId = params.get("memberId") != null ? ((Number) params.get("memberId")).longValue() : null;
int days = params.get("days") != null ? ((Number) params.get("days")).intValue() : 30;
if (memberId == null) {
return Result.error("缺少memberId参数");
}
Map<String, Object> trend = profileReadService.getTrend(memberId, days);
return Result.success(trend);
}
}
[ ] 步骤 3:编译验证
cd cfc-backend && mvn clean compile -q
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/profile/ProfileController.java \
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProfileController.java
git commit -m "feat(profile): 添加C端和B端画像API控制器"
文件:
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.javacfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.javacfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.javacfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.javacfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java每个控制器在成功写入数据后,异步触发 profileComputeService.computeAndSave(memberId)。
在 create 接口成功后添加:
@Autowired
private com.etotem.cfc.service.ProfileComputeService profileComputeService;
// 在 success 回调中:
Long memberId = request.getLong("memberId");
if (memberId != null) {
new Thread(() -> profileComputeService.computeAndSave(memberId)).start();
}
同上,在 create 成功后触发:
Long memberId = request.getLong("memberId");
if (memberId != null) {
new Thread(() -> profileComputeService.computeAndSave(memberId)).start();
}
同上模式。
在 create 成功后触发:
Long childId = dto.getChildId();
if (childId != null) {
new Thread(() -> profileComputeService.computeAndSave(childId)).start();
}
在任务完成(status→completed)后触发:
// 在 review/complete 接口成功后
Long executorId = task.getExecutorId();
if (executorId != null) {
new Thread(() -> profileComputeService.computeAndSave(executorId)).start();
}
[ ] 步骤 6:编译验证
cd cfc-backend && mvn clean compile -q
[ ] 步骤 7:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java \
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java \
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java \
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java \
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java
git commit -m "feat(profile): 在打卡和任务完成后自动触发画像重算"
文件:
cfc-langgraph/app/tools/java_client.py修改:cfc-langgraph/app/api/adapter.py
[ ] 步骤 1:新增 JavaClient 方法
async def get_member_profile(self, member_id: int) -> dict:
"""获取家庭成员画像快照"""
client = await self._get_client()
resp = await client.post("/api/profile/my", json={"memberId": member_id})
data = resp.json()
if data.get("code") == 200 and data.get("data"):
return data["data"]
return {}
[ ] 步骤 2:修改 adapter.py 方案生成 prompt
在 _collect_plan_data 函数中,添加画像数据获取:
# 在 _collect_plan_data 末尾追加
profile_data = await java.get_member_profile(int(member["id"]))
member["profile"] = profile_data
在 PLAN_SYSTEM_PROMPT 中追加画像段:
# 修改 PLAN_SYSTEM_PROMPT 字符串,追加:
"""
## 用户画像数据
{profile_section}
请结合上述真实指标给出针对性建议,特别是异常指标要重点说明。
"""
在构建 prompt 时动态填充:
# 在 health_plan_generate 函数中,构建 parts 时追加:
if member.get("profile"):
profile = member["profile"]
dims = profile.get("dimension_scores", {})
body = profile.get("body_metrics", {})
mind = profile.get("mind_metrics", {})
parts.append(f"\n## {member['name']} 画像")
parts.append(f"- 五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
if body.get('sleep_dur_avg'):
parts.append(f"- 平均睡眠: {body['sleep_dur_avg']}小时")
if mind.get('stress_avg'):
parts.append(f"- 平均压力: {mind['stress_avg']}/10")
if body.get('exercise_count_week'):
parts.append(f"- 周运动次数: {body['exercise_count_week']}次")
[ ] 步骤 3:Commit
git add cfc-langgraph/app/tools/java_client.py cfc-langgraph/app/api/adapter.py
git commit -m "feat(profile): 方案生成时自动注入用户画像数据"
文件:
cfc-frontend/pages/growth/profile/index.vue修改:cfc-frontend/utils/api.js
[ ] 步骤 1:新增 API 调用
在 cfc-frontend/utils/api.js 末尾追加:
// 画像
export function getMyProfile() {
return request('/api/profile/my', 'POST', {})
}
export function getProfileTrend(days) {
return request('/api/profile/history', 'POST', { days })
}
创建 cfc-frontend/pages/growth/profile/index.vue,包含:
风格参照现有 growth 页的暖橙配色(#F97316)。
[ ] 步骤 3:Commit
git add cfc-frontend/utils/api.js cfc-frontend/pages/growth/profile/index.vue
git commit -m "feat(profile): 小程序画像页 — 五维指标+推荐内容"
文件:
新建:cfc-web/src/views/admin/ProfileManagement.vue
[ ] 步骤 1:创建管理端页面
功能:
使用 Element UI Table + Card 布局,风格与现有 admin 页一致。
[ ] 步骤 2:Commit
git add cfc-web/src/views/admin/ProfileManagement.vue
git commit -m "feat(profile): Web管理端 — 家庭成员画像列表与管理"
[ ] 步骤 1:数据库迁移执行
# 连接数据库执行迁移
mysql -h 192.168.16.251 -u zxyj -p'zxyj@123' zxyj < cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sql
[ ] 步骤 2:启动后端,测试 C端接口
cd cfc-backend && mvn spring-boot:run
# 另开终端测试
curl -X POST http://localhost:9082/api/profile/my \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
预期:返回包含 member、dimension_scores、body_metrics 等字段的 JSON。
[ ] 步骤 3:测试 B端接口
curl -X POST http://localhost:9082/api/admin/profile/get \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"memberId": 1}'
[ ] 步骤 4:触发一次画像计算,验证数据写入
通过数据库直接查询:
SELECT member_id, dimension_scores, computed_at FROM profile_snapshot LIMIT 5;
SELECT COUNT(*) FROM profile_history;
/api/profile/mybody_metrics.sleep_records_count 增加[ ] 步骤 6:修复发现的bug,最终 commit
git add -A
git commit -m "fix(profile): 联调测试修复"
git push origin cfclub
new Thread(...) 避免阻塞接口响应,生产环境建议替换为 Spring 的 @Async 或消息队列computeAndSave 内部用 upsert,重复调用不会产生脏数据cleanOldHistory 只在写入新记录时顺带清理,不单独定时任务RecommendServiceImpl.getRecommendations() 中,后续添加新规则只需在此增改 if 分支?. 语法:小程序禁止可选链,用 && 替代yyyy-MM-dd 或 ISO 8601,禁止 toLocaleString()