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