Преглед изворни кода

Merge remote-tracking branch 'origin/cfclub' into cfclub

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
liaoxg пре 4 недеља
родитељ
комит
ed43e5e4dc
45 измењених фајлова са 3479 додато и 368 уклоњено
  1. 49 2
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java
  3. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java
  4. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  5. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java
  6. 40 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProfileController.java
  7. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java
  8. 47 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/profile/ProfileController.java
  9. 315 303
      cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java
  10. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/DietPreferencesDTO.java
  11. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DietPreferences.java
  12. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Food.java
  13. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileHistory.java
  14. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java
  15. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileHistoryMapper.java
  16. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileSnapshotMapper.java
  17. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DietPreferencesService.java
  18. 38 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  19. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProfileComputeService.java
  20. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProfileReadService.java
  21. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/service/RecommendService.java
  22. 67 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportSurveyService.java
  23. 422 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.java
  24. 100 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileReadServiceImpl.java
  25. 188 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/RecommendServiceImpl.java
  26. 26 0
      cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sql
  27. 27 0
      cfc-backend/src/main/resources/schema.sql
  28. 135 2
      cfc-frontend/pages/body-detail/index.vue
  29. 14 8
      cfc-frontend/pages/diet/index.vue
  30. 344 0
      cfc-frontend/pages/growth/profile/index.vue
  31. 66 2
      cfc-frontend/pages/health/report-detail.vue
  32. 119 15
      cfc-frontend/pages/health/report-survey.vue
  33. 42 0
      cfc-frontend/utils/api.js
  34. 23 0
      cfc-langgraph/app/api/adapter.py
  35. 10 0
      cfc-langgraph/app/tools/java_client.py
  36. 1 1
      cfc-web/.last_build_commit
  37. 2 2
      cfc-web/package-lock.json
  38. 1 1
      cfc-web/package.json
  39. 500 0
      cfc-web/public/CHANGELOG-v1.0.md
  40. 501 1
      cfc-web/public/CHANGELOG.md
  41. 17 0
      cfc-web/src/api/admin.js
  42. 196 0
      cfc-web/src/views/admin/ProfileManagement.vue
  43. 7 31
      docs/superpowers/plans/2026-08-21-user-profile-recommendation.md
  44. BIN
      docs/参考资料/方案/胰腺炎糖尿病方案.pdf
  45. 18 0
      opencode.json

+ 49 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2993,6 +2993,18 @@ log.info("已添加template_id列到tasks表");
 		} catch (Exception ex) {
 			log.warn("health_plans.updated_at 添加失败(可能已存在): " + ex.getMessage());
 		}
+		// 迁移253: foods表添加diet_type列(清真/素食标识)
+		try {
+			ensureColumn("foods", "diet_type", "VARCHAR(20) DEFAULT NULL COMMENT '饮食类型: none/清真/halal/素食/vegan'");
+		} catch (Exception ex) {
+			log.warn("foods.diet_type 添加失败(可能已存在): " + ex.getMessage());
+		}
+		// 迁移254: diet_preferences表添加must_eat列(必须要吃的食物)
+		try {
+			ensureColumn("diet_preferences", "must_eat", "JSON DEFAULT NULL COMMENT '必须要吃的食物列表'");
+		} catch (Exception ex) {
+			log.warn("diet_preferences.must_eat 添加失败(可能已存在): " + ex.getMessage());
+		}
         ensureColumn("file_record", "file_type", "VARCHAR(50) DEFAULT NULL COMMENT '文件类型: image/pdf/video/audio/other'");
         ensureColumn("file_record", "description", "TEXT COMMENT '文件描述'");
 
@@ -9009,7 +9021,42 @@ private void runMigration100() {
 		// 迁移251: 健康饮食组挂载 报告管理/报告审核/应季食材(《角色权限与菜单归类整合方案》第二部分,幂等)
 		migrateHealthMenuMount();
 
-		// 迁移252: 创建 report_template 表(报告展示模板配置)
+		// 迁移252: 创建用户画像表
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS profile_snapshot (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"member_id BIGINT NOT NULL UNIQUE, " +
+					"dimension_scores JSON DEFAULT NULL, " +
+					"body_metrics JSON DEFAULT NULL, " +
+					"mind_metrics JSON DEFAULT NULL, " +
+					"wisdom_metrics JSON DEFAULT NULL, " +
+					"action_metrics JSON DEFAULT NULL, " +
+					"wealth_metrics JSON DEFAULT NULL, " +
+					"problem_domains VARCHAR(500) DEFAULT NULL, " +
+					"computed_at DATETIME DEFAULT NULL, " +
+					"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+					"INDEX idx_member_id (member_id)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+			log.info("已创建profile_snapshot表");
+		} catch (Exception e) {
+			log.warn("创建profile_snapshot表可能已存在: {}", e.getMessage());
+		}
+
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS profile_history (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"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)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+			log.info("已创建profile_history表");
+		} catch (Exception e) {
+			log.warn("创建profile_history表可能已存在: {}", e.getMessage());
+		}
+
+		// 迁移254: 创建 report_template 表(报告展示模板配置)
 		try {
 			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_template (" +
 				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
@@ -9026,7 +9073,7 @@ private void runMigration100() {
 			log.warn("report_template表可能已存在: {}", e.getMessage());
 		}
 
-		// 迁移253: report_unknown_upload 添加 annotation 列(管理员标注训练数据)
+		// 迁移255: report_unknown_upload 添加 annotation 列(管理员标注训练数据)
 		ensureColumn("report_unknown_upload", "annotation", "TEXT COMMENT '管理员标注: 手动指定类型+确认提取结果JSON'");
 
 	}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java

@@ -23,6 +23,9 @@ public class HealthExerciseController {
     @Resource
     private HealthExerciseRecordMapper healthExerciseRecordMapper;
 
+    @Resource
+    private com.etotem.cfc.service.ProfileComputeService profileComputeService;
+
     @Operation(summary = "获取运动打卡列表")
     @PostMapping("/list")
     public Result<List<HealthExerciseRecord>> list(@RequestBody Map<String, Object> params,
@@ -44,6 +47,15 @@ public class HealthExerciseController {
         }
         record.setCreatedAt(new Date());
         healthExerciseRecordMapper.insert(record);
+                // 触发画像重算
+        try {
+            Long triggerMemberId = record.getMemberId();
+            if (triggerMemberId != null) {
+                new Thread(() -> profileComputeService.computeAndSave(triggerMemberId)).start();
+            }
+        } catch (Exception e) {
+            // 画像计算失败不影响打卡主流程
+        }
         return Result.success(record);
     }
 }

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java

@@ -27,6 +27,9 @@ public class HealthMealController {
     @Resource
     private PointsService pointsService;
 
+    @Resource
+    private com.etotem.cfc.service.ProfileComputeService profileComputeService;
+
     @Operation(summary = "获取饮食打卡列表")
     @PostMapping("/list")
     public Result<List<HealthMealRecord>> list(
@@ -50,6 +53,15 @@ public class HealthMealController {
             pointsService.awardCheckinPoints(record.getMemberId(), 5, "饮食打卡");
         } catch (Exception e) {
             // 积分发放失败不影响打卡记录
+        }
+                // 触发画像重算
+        try {
+            Long triggerMemberId = record.getMemberId();
+            if (triggerMemberId != null) {
+                new Thread(() -> profileComputeService.computeAndSave(triggerMemberId)).start();
+            }
+        } catch (Exception e) {
+            // 画像计算失败不影响打卡主流程
         }
         return Result.success(record);
     }

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -342,6 +342,26 @@ public class HealthReportController {
         }
     }
 
+    /**
+     * 获取成员最新菌群报告核心指标(用于身页面肠道菌群卡片)
+     */
+    @Operation(summary = "获取菌群指标")
+    @PostMapping("/gut-flora")
+    public Result<List<Map<String, Object>>> getGutFlora(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        if (memberId == null) return Result.error("memberId不能为空");
+        try {
+            List<Map<String, Object>> list = healthReportService.getGutFloraIndicators(memberId);
+            return Result.success(list);
+        } catch (Exception e) {
+            log.warn("获取菌群指标失败: {}", e.getMessage());
+            return Result.success(java.util.Collections.emptyList());
+        }
+    }
+
     /**
      * 获取孩子身体维度数据详情(健康指标解读)
      */

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java

@@ -23,6 +23,9 @@ public class HealthSleepController {
     @Resource
     private HealthSleepRecordMapper healthSleepRecordMapper;
 
+    @Resource
+    private com.etotem.cfc.service.ProfileComputeService profileComputeService;
+
     @Operation(summary = "获取睡眠打卡列表")
     @PostMapping("/list")
     public Result<List<HealthSleepRecord>> list(@RequestBody Map<String, Object> params,
@@ -44,6 +47,15 @@ public class HealthSleepController {
         }
         record.setCreatedAt(new Date());
         healthSleepRecordMapper.insert(record);
+                // 触发画像重算
+        try {
+            Long triggerMemberId = record.getMemberId();
+            if (triggerMemberId != null) {
+                new Thread(() -> profileComputeService.computeAndSave(triggerMemberId)).start();
+            }
+        } catch (Exception e) {
+            // 画像计算失败不影响打卡主流程
+        }
         return Result.success(record);
     }
 }

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProfileController.java

@@ -0,0 +1,40 @@
+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.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);
+    }
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java

@@ -24,12 +24,24 @@ public class EmotionCheckinController {
 
     @Resource
     private EmotionCheckinService emotionCheckinService;
+    @Resource
+    private com.etotem.cfc.service.ProfileComputeService profileComputeService;
+
 
     @PostMapping("/create")
     public Result<?> create(@RequestBody EmotionCheckinDTO dto,
                             @RequestAttribute("userId") Long userId) {
         try {
             Object result = emotionCheckinService.createCheckin(dto, userId);
+                        // 触发画像重算
+            try {
+                Long triggerMemberId = dto.getMemberId();
+                if (triggerMemberId != null) {
+                    new Thread(() -> profileComputeService.computeAndSave(triggerMemberId)).start();
+                }
+            } catch (Exception e) {
+                // 画像计算失败不影响打卡主流程
+            }
             return Result.success(result);
         } catch (Exception e) {
             log.error("创建情绪打卡失败", e);

+ 47 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/profile/ProfileController.java

@@ -0,0 +1,47 @@
+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");
+        }
+        if (memberId == null) {
+            return Result.error("未登录");
+        }
+        int days = params.get("days") != null ? ((Number) params.get("days")).intValue() : 30;
+        Map<String, Object> trend = profileReadService.getTrend(memberId, days);
+        return Result.success(trend);
+    }
+}

+ 315 - 303
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java

@@ -1,307 +1,319 @@
-package com.etotem.cfc.controller.task;
-
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.etotem.cfc.common.Result;
-import com.etotem.cfc.dto.CompleteTaskDTO;
-import com.etotem.cfc.dto.CreateTaskDTO;
-import com.etotem.cfc.dto.TaskReviewDTO;
-import com.etotem.cfc.entity.MiniGame;
-import com.etotem.cfc.entity.Task;
-import com.etotem.cfc.mapper.TaskMapper;
-import com.etotem.cfc.service.MembershipService;
-import com.etotem.cfc.service.MiniGameService;
-import com.etotem.cfc.service.ProblemCompletionService;
-import com.etotem.cfc.service.TaskService;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import javax.annotation.Resource;
-import org.springframework.web.bind.annotation.*;
-
-import java.util.List;
-import java.util.Map;
+package com.etotem.cfc.controller.task;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.CompleteTaskDTO;
+import com.etotem.cfc.dto.CreateTaskDTO;
+import com.etotem.cfc.dto.TaskReviewDTO;
+import com.etotem.cfc.entity.MiniGame;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.MiniGameService;
+import com.etotem.cfc.service.ProblemCompletionService;
+import com.etotem.cfc.service.TaskService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import javax.annotation.Resource;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
-
-@Tag(name = "任务管理", description = "任务的创建、完成、审核、历史查询等接口")
-@RestController
-@RequestMapping("/api/tasks")
-public class TaskController {
-
-    @Resource
-    private TaskService taskService;
-
-    @Resource
-    private MiniGameService miniGameService;
-
-    @Resource
-    private TaskMapper taskMapper;
-
-    @Resource
-    private MembershipService membershipService;
-
-    @Resource
-    private ProblemCompletionService problemCompletionService;
-
-    /**
-     * 会员任务校验:task.memberOnly==1 且非会员 → 拒绝(提示开通会员)
-     */
-    private Result<?> checkMemberOnly(Long taskId) {
-        Task task = taskMapper.selectById(taskId);
-        if (task == null) {
-            return Result.error("任务不存在");
-        }
-        if (task.getMemberOnly() != null && task.getMemberOnly() == 1) {
-            boolean isMember = membershipService.getCurrentLevel(task.getFamilyId()) != null
-                    && !"FREE".equals(membershipService.getCurrentLevel(task.getFamilyId()).getLevelCode());
-            if (!isMember) {
-                return Result.error("该任务是会员任务,请先开通会员后再操作");
-            }
-        }
-        return null;
-    }
-
-    @Operation(summary = "获取可选择的小游戏列表")
-    @PostMapping("/minigame-options")
-    public Result<List<MiniGame>> getMinigameOptions() {
-        List<MiniGame> games = miniGameService.getEnabledGames();
-        return Result.success(games);
-    }
-
-    @Operation(summary = "创建任务")
-    @PostMapping("/create")
-    public Result<Long> createTask(@RequestAttribute("userId") Long userId,
-                                   @RequestBody CreateTaskDTO dto) {
-        Long taskId = taskService.createTask(userId, dto);
-        return Result.success(taskId);
-    }
-
-    @Operation(summary = "获取今日任务")
-    @PostMapping("/today")
-    public Result<List<Task>> getTodayTasks(@RequestBody Map<String, Object> params,
-                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
-        Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
-        String category = params.get("category") != null ? params.get("category").toString() : null;
-        String dimensionCode = params.get("dimensionCode") != null ? params.get("dimensionCode").toString() : null;
-        List<Task> tasks;
-        if (category != null && !category.isEmpty()) {
-            tasks = taskService.getTodayTasksByCategory(memberId, category);
-        } else {
-            tasks = taskService.getTodayTasks(memberId, dimensionCode);
-        }
-        return Result.success(tasks);
-    }
-
-    @Operation(summary = "完成任务")
-    @PostMapping("/{id}/complete")
-    public Result<Map<String, Object>> completeTask(@PathVariable Long id,
-                                                    @RequestBody CompleteTaskDTO dto,
-                                                    @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
-        Result<?> memberCheck = checkMemberOnly(id);
-        if (memberCheck != null) {
-            return Result.error(memberCheck.getMessage());
-        }
-        Long memberId = dto.getMemberId() != null ? dto.getMemberId() : currentMemberId;
-        Map<String, Object> result = taskService.completeTask(id, memberId, dto.getPhotoUrl(), dto.getContent(), dto.getContentType());
-        if (result != null) {
-            // B17: 任务完成后检查问题域奖励
-            try {
-                problemCompletionService.checkAndRewardByTask(id);
-            } catch (Exception e) {
-                // 奖励发放失败不影响任务完成
-            }
-        }
-        return Result.success(result);
-    }
-
-    @Operation(summary = "开始任务(两阶段任务入口)")
-    @PostMapping("/{id}/start")
-    public Result<Map<String, Object>> startTask(@PathVariable Long id,
-                                                 @RequestBody(required = false) Map<String, Object> params,
-                                                 @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
-        Result<?> memberCheck = checkMemberOnly(id);
-        if (memberCheck != null) {
-            return Result.error(memberCheck.getMessage());
-        }
-        Long memberId = params != null && params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
-        if (memberId == null) {
-            return Result.error("memberId不能为空");
-        }
-        try {
-            Map<String, Object> result = taskService.startTask(id, memberId);
-            return Result.success(result);
-        } catch (Exception e) {
-            return Result.error(e.getMessage());
-        }
+
+@Tag(name = "任务管理", description = "任务的创建、完成、审核、历史查询等接口")
+@RestController
+@RequestMapping("/api/tasks")
+public class TaskController {
+
+    @Resource
+    private TaskService taskService;
+
+    @Resource
+    private MiniGameService miniGameService;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private MembershipService membershipService;
+
+    @Resource
+    private ProblemCompletionService problemCompletionService;
+    @Resource
+    private com.etotem.cfc.service.ProfileComputeService profileComputeService;
+
+
+    /**
+     * 会员任务校验:task.memberOnly==1 且非会员 → 拒绝(提示开通会员)
+     */
+    private Result<?> checkMemberOnly(Long taskId) {
+        Task task = taskMapper.selectById(taskId);
+        if (task == null) {
+            return Result.error("任务不存在");
+        }
+        if (task.getMemberOnly() != null && task.getMemberOnly() == 1) {
+            boolean isMember = membershipService.getCurrentLevel(task.getFamilyId()) != null
+                    && !"FREE".equals(membershipService.getCurrentLevel(task.getFamilyId()).getLevelCode());
+            if (!isMember) {
+                return Result.error("该任务是会员任务,请先开通会员后再操作");
+            }
+        }
+        return null;
     }
-
-    @Operation(summary = "审核任务")
-    @PostMapping("/{id}/review")
-    public Result<Boolean> reviewTask(@PathVariable Long id,
-                                      @RequestBody TaskReviewDTO dto) {
-        boolean success = taskService.reviewTask(id, dto);
-        return Result.success(success);
-    }
-
-  @Operation(summary = "获取任务历史")
-  @PostMapping("/history")
-  public Result<Page<Task>> getTaskHistory(@RequestBody Map<String, Object> params,
-                                           @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
-    Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
-    Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
-    Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
-    String category = params.get("category") != null ? params.get("category").toString() : null;
-    Page<Task> history;
-    if (category != null && !category.isEmpty()) {
-        history = taskService.getTaskHistoryByCategory(memberId, page, size, category);
-    } else {
-        history = taskService.getTaskHistory(memberId, page, size);
-    }
-    return Result.success(history);
+
+    @Operation(summary = "获取可选择的小游戏列表")
+    @PostMapping("/minigame-options")
+    public Result<List<MiniGame>> getMinigameOptions() {
+        List<MiniGame> games = miniGameService.getEnabledGames();
+        return Result.success(games);
+    }
+
+    @Operation(summary = "创建任务")
+    @PostMapping("/create")
+    public Result<Long> createTask(@RequestAttribute("userId") Long userId,
+                                   @RequestBody CreateTaskDTO dto) {
+        Long taskId = taskService.createTask(userId, dto);
+        return Result.success(taskId);
+    }
+
+    @Operation(summary = "获取今日任务")
+    @PostMapping("/today")
+    public Result<List<Task>> getTodayTasks(@RequestBody Map<String, Object> params,
+                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
+        String category = params.get("category") != null ? params.get("category").toString() : null;
+        String dimensionCode = params.get("dimensionCode") != null ? params.get("dimensionCode").toString() : null;
+        List<Task> tasks;
+        if (category != null && !category.isEmpty()) {
+            tasks = taskService.getTodayTasksByCategory(memberId, category);
+        } else {
+            tasks = taskService.getTodayTasks(memberId, dimensionCode);
+        }
+        return Result.success(tasks);
+    }
+
+    @Operation(summary = "完成任务")
+    @PostMapping("/{id}/complete")
+    public Result<Map<String, Object>> completeTask(@PathVariable Long id,
+                                                    @RequestBody CompleteTaskDTO dto,
+                                                    @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Result<?> memberCheck = checkMemberOnly(id);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
+        Long memberId = dto.getMemberId() != null ? dto.getMemberId() : currentMemberId;
+        Map<String, Object> result = taskService.completeTask(id, memberId, dto.getPhotoUrl(), dto.getContent(), dto.getContentType());
+        if (result != null) {
+            // B17: 任务完成后检查问题域奖励
+            try {
+                problemCompletionService.checkAndRewardByTask(id);
+            } catch (Exception e) {
+                // 奖励发放失败不影响任务完成
+            }
+            // 触发画像重算
+            try {
+                Long triggerMemberId = memberId;
+                if (triggerMemberId != null) {
+                    new Thread(() -> profileComputeService.computeAndSave(triggerMemberId)).start();
+                }
+            } catch (Exception e) {
+                // 画像计算失败不影响任务完成
+            }
+        }
+        return Result.success(result);
+    }
+
+    @Operation(summary = "开始任务(两阶段任务入口)")
+    @PostMapping("/{id}/start")
+    public Result<Map<String, Object>> startTask(@PathVariable Long id,
+                                                 @RequestBody(required = false) Map<String, Object> params,
+                                                 @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Result<?> memberCheck = checkMemberOnly(id);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
+        Long memberId = params != null && params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        try {
+            Map<String, Object> result = taskService.startTask(id, memberId);
+            return Result.success(result);
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "审核任务")
+    @PostMapping("/{id}/review")
+    public Result<Boolean> reviewTask(@PathVariable Long id,
+                                      @RequestBody TaskReviewDTO dto) {
+        boolean success = taskService.reviewTask(id, dto);
+        return Result.success(success);
+    }
+
+  @Operation(summary = "获取任务历史")
+  @PostMapping("/history")
+  public Result<Page<Task>> getTaskHistory(@RequestBody Map<String, Object> params,
+                                           @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+    Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
+    Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+    Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
+    String category = params.get("category") != null ? params.get("category").toString() : null;
+    Page<Task> history;
+    if (category != null && !category.isEmpty()) {
+        history = taskService.getTaskHistoryByCategory(memberId, page, size, category);
+    } else {
+        history = taskService.getTaskHistory(memberId, page, size);
+    }
+    return Result.success(history);
   }
-
-    @Operation(summary = "获取待审核任务")
-    @PostMapping("/pending-review")
-    public Result<List<Task>> getPendingReviewTasks(@RequestAttribute("userId") Long userId) {
-    List<Task> tasks = taskService.getPendingReviewTasks(userId);
-    return Result.success(tasks);
-  }
-
-    @Operation(summary = "获取家长今日任务")
-    @PostMapping("/today-parent")
-    public Result<List<Task>> getTodayParentTasks(@RequestAttribute("userId") Long userId) {
-        List<Task> tasks = taskService.getTodayParentTasks(userId);
-        return Result.success(tasks);
-    }
-
-    @Operation(summary = "家长完成任务")
-    @PostMapping("/{id}/complete-parent")
-    public Result<Map<String, Object>> completeParentTask(@PathVariable Long id,
-                                                        @RequestAttribute("userId") Long userId) {
-        Result<?> memberCheck = checkMemberOnly(id);
-        if (memberCheck != null) {
-            return Result.error(memberCheck.getMessage());
-        }
-        Map<String, Object> result = taskService.completeParentTask(id, userId);
-        if (result != null) {
-            // B17: 任务完成后检查问题域奖励
-            try {
-                problemCompletionService.checkAndRewardByTask(id);
-            } catch (Exception e) {
-                // 奖励发放失败不影响任务完成
-            }
-        }
-        return Result.success(result);
-    }
-
-    @Operation(summary = "删除任务")
-    @PostMapping("/{id}")
-    public Result<Boolean> deleteTask(@PathVariable Long id,
-            @RequestAttribute("userId") Long userId) {
-        boolean success = taskService.deleteTask(id, userId);
-        return Result.success(success);
-    }
-
-    @Operation(summary = "完成小游戏任务")
-    @PostMapping("/{id}/complete-minigame")
-    public Result<Map<String, Object>> completeMinigameTask(
-            @PathVariable Long id,
-            @RequestBody Map<String, Object> body,
-            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
-        Result<?> memberCheck = checkMemberOnly(id);
-        if (memberCheck != null) {
-            return Result.error(memberCheck.getMessage());
-        }
-        Long memberId = body.get("memberId") != null ? ParamUtils.getLong(body.get("memberId")) : currentMemberId;
-        Integer completionTime = body.get("completionTime") != null ?
-            Integer.valueOf(body.get("completionTime").toString()) : null;
-        Integer score = body.get("score") != null ?
-            Integer.valueOf(body.get("score").toString()) : null;
-
-        try {
-            Map<String, Object> result = taskService.completeMinigameTask(id, memberId, completionTime, score);
-            return Result.success(result);
-        } catch (Exception e) {
-            return Result.error(e.getMessage());
-        }
-    }
-
-    @Operation(summary = "批量完成任务")
-    @PostMapping("/batch-complete")
-    public Result<Boolean> batchComplete(@RequestBody Map<String, Object> params,
-                                         @RequestAttribute("userId") Long userId) {
-        List<Long> taskIds = (List<Long>) params.get("taskIds");
-        if (taskIds == null || taskIds.isEmpty()) {
-            return Result.error("taskIds不能为空");
-        }
-        // 会员任务校验:任一任务为会员任务且非会员 → 拒绝整批
-        for (Long taskId : taskIds) {
-            Result<?> memberCheck = checkMemberOnly(taskId);
-            if (memberCheck != null) {
-                return Result.error(memberCheck.getMessage());
-            }
-        }
-        try {
-            taskService.batchComplete(taskIds, userId);
-            return Result.success(true);
-        } catch (Exception e) {
-            return Result.error("批量操作失败: " + e.getMessage());
-        }
-    }
-
-    @Operation(summary = "批量删除任务")
-    @PostMapping("/batch-delete")
-    public Result<Boolean> batchDelete(@RequestBody Map<String, Object> params,
-                                       @RequestAttribute("userId") Long userId) {
-        List<Long> taskIds = (List<Long>) params.get("taskIds");
-        if (taskIds == null || taskIds.isEmpty()) {
-            return Result.error("taskIds不能为空");
-        }
-        try {
-            taskService.batchDelete(taskIds, userId);
-            return Result.success(true);
-        } catch (Exception e) {
-            return Result.error("批量操作失败: " + e.getMessage());
-        }
-    }
-
-    @Operation(summary = "获取我收到的待接受任务列表")
-    @PostMapping("/my-pending")
-    public Result<List<Task>> getMyPendingTasks(@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId,
-                                                @RequestBody Map<String, Object> params) {
-        Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
-        if (memberId == null) {
-            return Result.error("memberId不能为空");
-        }
-        List<Task> tasks = taskService.getMyPendingTasks(memberId);
-        return Result.success(tasks);
-    }
-
-    @Operation(summary = "接受任务")
-    @PostMapping("/accept")
-    public Result<Boolean> acceptTask(@PathVariable Long id,
-                                      @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId,
-                                      @RequestBody Map<String, Object> params) {
-        Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
-        if (memberId == null) {
-            return Result.error("memberId不能为空");
-        }
-        Result<?> memberCheck = checkMemberOnly(id);
-        if (memberCheck != null) {
-            return Result.error(memberCheck.getMessage());
-        }
-        boolean success = taskService.acceptTask(id, memberId);
-        return Result.success(success);
-    }
-
-    @Operation(summary = "拒绝任务")
-    @PostMapping("/reject/{id}")
-    public Result<Boolean> rejectTask(@PathVariable Long id,
-                                      @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId,
-                                      @RequestBody Map<String, Object> params) {
-        Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
-        if (memberId == null) {
-            return Result.error("memberId不能为空");
-        }
-        boolean success = taskService.rejectTask(id, memberId);
-        return Result.success(success);
-    }
+
+    @Operation(summary = "获取待审核任务")
+    @PostMapping("/pending-review")
+    public Result<List<Task>> getPendingReviewTasks(@RequestAttribute("userId") Long userId) {
+    List<Task> tasks = taskService.getPendingReviewTasks(userId);
+    return Result.success(tasks);
+  }
+
+    @Operation(summary = "获取家长今日任务")
+    @PostMapping("/today-parent")
+    public Result<List<Task>> getTodayParentTasks(@RequestAttribute("userId") Long userId) {
+        List<Task> tasks = taskService.getTodayParentTasks(userId);
+        return Result.success(tasks);
+    }
+
+    @Operation(summary = "家长完成任务")
+    @PostMapping("/{id}/complete-parent")
+    public Result<Map<String, Object>> completeParentTask(@PathVariable Long id,
+                                                        @RequestAttribute("userId") Long userId) {
+        Result<?> memberCheck = checkMemberOnly(id);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
+        Map<String, Object> result = taskService.completeParentTask(id, userId);
+        if (result != null) {
+            // B17: 任务完成后检查问题域奖励
+            try {
+                problemCompletionService.checkAndRewardByTask(id);
+            } catch (Exception e) {
+                // 奖励发放失败不影响任务完成
+            }
+        }
+        return Result.success(result);
+    }
+
+    @Operation(summary = "删除任务")
+    @PostMapping("/{id}")
+    public Result<Boolean> deleteTask(@PathVariable Long id,
+            @RequestAttribute("userId") Long userId) {
+        boolean success = taskService.deleteTask(id, userId);
+        return Result.success(success);
+    }
+
+    @Operation(summary = "完成小游戏任务")
+    @PostMapping("/{id}/complete-minigame")
+    public Result<Map<String, Object>> completeMinigameTask(
+            @PathVariable Long id,
+            @RequestBody Map<String, Object> body,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Result<?> memberCheck = checkMemberOnly(id);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
+        Long memberId = body.get("memberId") != null ? ParamUtils.getLong(body.get("memberId")) : currentMemberId;
+        Integer completionTime = body.get("completionTime") != null ?
+            Integer.valueOf(body.get("completionTime").toString()) : null;
+        Integer score = body.get("score") != null ?
+            Integer.valueOf(body.get("score").toString()) : null;
+
+        try {
+            Map<String, Object> result = taskService.completeMinigameTask(id, memberId, completionTime, score);
+            return Result.success(result);
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "批量完成任务")
+    @PostMapping("/batch-complete")
+    public Result<Boolean> batchComplete(@RequestBody Map<String, Object> params,
+                                         @RequestAttribute("userId") Long userId) {
+        List<Long> taskIds = (List<Long>) params.get("taskIds");
+        if (taskIds == null || taskIds.isEmpty()) {
+            return Result.error("taskIds不能为空");
+        }
+        // 会员任务校验:任一任务为会员任务且非会员 → 拒绝整批
+        for (Long taskId : taskIds) {
+            Result<?> memberCheck = checkMemberOnly(taskId);
+            if (memberCheck != null) {
+                return Result.error(memberCheck.getMessage());
+            }
+        }
+        try {
+            taskService.batchComplete(taskIds, userId);
+            return Result.success(true);
+        } catch (Exception e) {
+            return Result.error("批量操作失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "批量删除任务")
+    @PostMapping("/batch-delete")
+    public Result<Boolean> batchDelete(@RequestBody Map<String, Object> params,
+                                       @RequestAttribute("userId") Long userId) {
+        List<Long> taskIds = (List<Long>) params.get("taskIds");
+        if (taskIds == null || taskIds.isEmpty()) {
+            return Result.error("taskIds不能为空");
+        }
+        try {
+            taskService.batchDelete(taskIds, userId);
+            return Result.success(true);
+        } catch (Exception e) {
+            return Result.error("批量操作失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取我收到的待接受任务列表")
+    @PostMapping("/my-pending")
+    public Result<List<Task>> getMyPendingTasks(@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId,
+                                                @RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        List<Task> tasks = taskService.getMyPendingTasks(memberId);
+        return Result.success(tasks);
+    }
+
+    @Operation(summary = "接受任务")
+    @PostMapping("/accept")
+    public Result<Boolean> acceptTask(@PathVariable Long id,
+                                      @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId,
+                                      @RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        Result<?> memberCheck = checkMemberOnly(id);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
+        boolean success = taskService.acceptTask(id, memberId);
+        return Result.success(success);
+    }
+
+    @Operation(summary = "拒绝任务")
+    @PostMapping("/reject/{id}")
+    public Result<Boolean> rejectTask(@PathVariable Long id,
+                                      @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId,
+                                      @RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        boolean success = taskService.rejectTask(id, memberId);
+        return Result.success(success);
+    }
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DietPreferencesDTO.java

@@ -8,6 +8,7 @@ public class DietPreferencesDTO {
     private Long familyMemberId;
     private List<String> allergies;
     private List<String> absoluteAvoid;
+    private List<String> mustEat;
     private String religiousDiet;
     private Integer spiceLevel;
     private List<String> flavorPref;

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DietPreferences.java

@@ -15,6 +15,7 @@ public class DietPreferences implements Serializable {
     private Long familyMemberId;
     private String allergies;
     private String absoluteAvoid;
+    private String mustEat;
     private String religiousDiet;
     private Integer spiceLevel;
     private String flavorPref;

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Food.java

@@ -41,6 +41,8 @@ public class Food implements Serializable {
     private Integer score;
     private String status;
     private Integer sortOrder;
+    /** 饮食类型: none/清真/halal/素食/vegan */
+    private String dietType;
     private Date createdAt;
     private Date updatedAt;
 }

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileHistory.java

@@ -0,0 +1,20 @@
+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;
+    private Date createdAt;
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java

@@ -0,0 +1,26 @@
+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;
+    private String bodyMetrics;
+    private String mindMetrics;
+    private String wisdomMetrics;
+    private String actionMetrics;
+    private String wealthMetrics;
+    private String problemDomains;
+    private Date computedAt;
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileHistoryMapper.java

@@ -0,0 +1,9 @@
+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> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProfileSnapshotMapper.java

@@ -0,0 +1,9 @@
+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> {
+}

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DietPreferencesService.java

@@ -47,6 +47,7 @@ public class DietPreferencesService {
         
         entity.setAllergies(toJSON(dto.getAllergies()));
         entity.setAbsoluteAvoid(toJSON(dto.getAbsoluteAvoid()));
+        entity.setMustEat(toJSON(dto.getMustEat()));
         entity.setReligiousDiet(dto.getReligiousDiet() != null ? dto.getReligiousDiet() : "none");
         entity.setSpiceLevel(dto.getSpiceLevel());
         entity.setFlavorPref(toJSON(dto.getFlavorPref()));
@@ -77,6 +78,7 @@ public class DietPreferencesService {
         dto.setFamilyMemberId(entity.getFamilyMemberId());
         dto.setAllergies(fromJSON(entity.getAllergies()));
         dto.setAbsoluteAvoid(fromJSON(entity.getAbsoluteAvoid()));
+        dto.setMustEat(fromJSON(entity.getMustEat()));
         dto.setReligiousDiet(entity.getReligiousDiet());
         dto.setSpiceLevel(entity.getSpiceLevel());
         dto.setFlavorPref(fromJSON(entity.getFlavorPref()));

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1686,4 +1686,42 @@ public class HealthReportService {
         }
         return result;
     }
+
+    /**
+     * 获取成员的最新菌群报告核心指标(用于身页面"肠道菌群"卡片)
+     * 返回:益生菌(core/probiotic)、有害菌(harmful)、核心菌属(taxonomy)各取前3条
+     */
+    public List<Map<String, Object>> getGutFloraIndicators(Long memberId) {
+        LambdaQueryWrapper<HealthReport> reportW = new LambdaQueryWrapper<>();
+        reportW.eq(HealthReport::getSubjectId, memberId)
+               .or().eq(HealthReport::getUserId, memberId);
+        reportW.eq(HealthReport::getReportType, "gut_flora")
+               .eq(HealthReport::getStatus, "active")
+               .orderByDesc(HealthReport::getCreatedAt)
+               .last("LIMIT 1");
+        HealthReport report = healthReportMapper.selectOne(reportW);
+        if (report == null) return Collections.emptyList();
+
+        LambdaQueryWrapper<HealthGutFlora> w = new LambdaQueryWrapper<>();
+        w.eq(HealthGutFlora::getReportId, report.getId())
+         .in(HealthGutFlora::getCategory, "core", "probiotic", "harmful", "other")
+         .orderByAsc(HealthGutFlora::getSortOrder);
+        List<HealthGutFlora> flora = healthGutFloraMapper.selectList(w);
+        if (flora.isEmpty()) return Collections.emptyList();
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (HealthGutFlora f : flora) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("name", f.getBacteriaName());
+            item.put("value", f.getBacteriaValue());
+            item.put("status", f.getStatus());
+            item.put("category", f.getCategory());
+            item.put("populationLevel", f.getPopulationLevel());
+            item.put("detectionRate", f.getDetectionRate());
+            item.put("description", f.getDescription());
+            item.put("level", f.getLevel());
+            result.add(item);
+        }
+        return result;
+    }
 }

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProfileComputeService.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.service;
+
+public interface ProfileComputeService {
+    /** 为指定家庭成员重新计算并写入画像快照 */
+    void computeAndSave(Long memberId);
+    /** 批量计算所有家庭成员画像 */
+    void computeAll();
+}

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProfileReadService.java

@@ -0,0 +1,10 @@
+package com.etotem.cfc.service;
+
+import java.util.Map;
+
+public interface ProfileReadService {
+    /** 获取最新画像快照(含基础信息) */
+    Map<String, Object> getProfile(Long memberId);
+    /** 获取指标趋势(近N天) */
+    Map<String, Object> getTrend(Long memberId, int days);
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RecommendService.java

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

+ 67 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportSurveyService.java

@@ -1,14 +1,19 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.DietPreferences;
 import com.etotem.cfc.entity.ReportSurvey;
+import com.etotem.cfc.mapper.DietPreferencesMapper;
 import com.etotem.cfc.mapper.ReportSurveyMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.util.Date;
+import java.util.List;
 
 /**
  * 报告调研问卷服务 — 报告上传后生成问卷,收集用户健康信息
@@ -20,6 +25,11 @@ public class ReportSurveyService {
     @Resource
     private ReportSurveyMapper reportSurveyMapper;
 
+    @Resource
+    private DietPreferencesMapper dietPreferencesMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
     /**
      * 为报告创建调研问卷
      */
@@ -51,9 +61,66 @@ public class ReportSurveyService {
         survey.setUpdatedAt(new Date());
         reportSurveyMapper.updateById(survey);
         log.info("报告调研问卷已提交: surveyId={}", surveyId);
+        // 同步保存饮食偏好到 diet_preferences 表
+        try {
+            saveDietPreferencesFromSurvey(survey, surveyData);
+        } catch (Exception e) {
+            log.warn("同步饮食偏好失败(不影响问卷提交): {}", e.getMessage());
+        }
         return survey;
     }
 
+    private void saveDietPreferencesFromSurvey(ReportSurvey survey, String surveyData) {
+        JsonNode root;
+        try { root = objectMapper.readTree(surveyData); }
+        catch (Exception e) { log.warn("解析surveyData失败: {}", e.getMessage()); return; }
+        JsonNode surveyNode = root.has("survey") ? root.get("survey") : root;
+        Long memberId = survey.getChildId();
+        if (memberId == null) return;
+
+        // 绝对忌口
+        JsonNode absAvoidNode = surveyNode.get("absoluteAvoid");
+        List<String> absoluteAvoid = new java.util.ArrayList<>();
+        if (absAvoidNode != null && absAvoidNode.isArray()) {
+            for (JsonNode n : absAvoidNode) absoluteAvoid.add(n.asText());
+        }
+        // 必须要吃
+        JsonNode mustEatNode = surveyNode.get("mustEat");
+        List<String> mustEat = new java.util.ArrayList<>();
+        if (mustEatNode != null && mustEatNode.isArray()) {
+            for (JsonNode n : mustEatNode) mustEat.add(n.asText());
+        }
+        // 宗教饮食
+        String religiousDiet = surveyNode.get("religiousDiet") != null
+                ? surveyNode.get("religiousDiet").asText() : "none";
+
+        // 查已存在的记录
+        LambdaQueryWrapper<DietPreferences> w = new LambdaQueryWrapper<>();
+        w.eq(DietPreferences::getFamilyMemberId, memberId);
+        DietPreferences prefs = dietPreferencesMapper.selectOne(w);
+        if (prefs == null) {
+            prefs = new DietPreferences();
+            prefs.setFamilyMemberId(memberId);
+            prefs.setFilledAt(new Date());
+        }
+        prefs.setAbsoluteAvoid(toJSON(absoluteAvoid));
+        prefs.setMustEat(toJSON(mustEat));
+        prefs.setReligiousDiet(religiousDiet);
+        prefs.setUpdatedAt(new Date());
+        if (prefs.getId() == null) {
+            dietPreferencesMapper.insert(prefs);
+        } else {
+            dietPreferencesMapper.updateById(prefs);
+        }
+        log.info("饮食偏好已同步: memberId={}, absoluteAvoid={}, mustEat={}, religiousDiet={}",
+                memberId, absoluteAvoid, mustEat, religiousDiet);
+    }
+
+    private String toJSON(Object obj) {
+        try { return objectMapper.writeValueAsString(obj); }
+        catch (Exception e) { return null; }
+    }
+
     /**
      * 检查指定报告的调研问卷是否已完成
      */

+ 422 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileComputeServiceImpl.java

@@ -0,0 +1,422 @@
+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.sql.Timestamp;
+import java.time.LocalDate;
+import java.util.Date;
+import java.time.LocalDateTime;
+import java.util.*;
+
+@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();
+
+        JSONObject bodyMetrics = computeBodyMetrics(memberId, now);
+        JSONObject mindMetrics = computeMindMetrics(memberId, now);
+        JSONObject wisdomMetrics = computeWisdomMetrics(memberId);
+        JSONObject actionMetrics = computeActionMetrics(memberId, now);
+        JSONObject wealthMetrics = computeWealthMetrics(memberId, now);
+        JSONObject dimensionScores = computeDimensionScores(bodyMetrics, mindMetrics, wisdomMetrics, actionMetrics, wealthMetrics);
+        String problemDomains = computeProblemDomains(memberId);
+
+        Date computedAt = new Date();
+        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());
+
+        // upsert snapshot
+        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);
+
+        // upsert history
+        LambdaQueryWrapper<ProfileHistory> hqw = new LambdaQueryWrapper<>();
+        hqw.eq(ProfileHistory::getMemberId, memberId)
+           .eq(ProfileHistory::getSnapshotDate, java.sql.Date.valueOf(now));
+        ProfileHistory history = historyMapper.selectOne(hqw);
+        if (history == null) {
+            history = new ProfileHistory();
+            history.setMemberId(memberId);
+            history.setSnapshotDate(java.sql.Date.valueOf(now));
+            history.setAllMetrics(allMetrics.toJSONString());
+            historyMapper.insert(history);
+        } else {
+            history.setAllMetrics(allMetrics.toJSONString());
+            historyMapper.updateById(history);
+        }
+
+        // cleanup old history
+        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) {
+                // ignore individual failure
+            }
+        }
+    }
+
+    // ========== 各维度计算方法 ==========
+
+    private JSONObject computeBodyMetrics(Long memberId, LocalDate now) {
+        JSONObject m = new JSONObject();
+        LocalDate thirtyDaysAgo = now.minusDays(30);
+        LocalDate sevenDaysAgo = now.minusDays(7);
+
+        // 睡眠:近30天
+        LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
+        sqw.eq(HealthSleepRecord::getMemberId, memberId)
+           .ge(HealthSleepRecord::getCreatedAt, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()));
+        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天
+        LambdaQueryWrapper<HealthExerciseRecord> eqw = new LambdaQueryWrapper<>();
+        eqw.eq(HealthExerciseRecord::getMemberId, memberId)
+           .ge(HealthExerciseRecord::getCreatedAt, java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay()));
+        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, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()));
+        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, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()));
+        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, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()));
+        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();
+        // 最近一次DAN测评
+        LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<>();
+        qw.eq(DanAssessmentResult::getFamilyMemberId, memberId)
+           .isNotNull(DanAssessmentResult::getAssessmentDate)
+           .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);
+            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.Timestamp.valueOf(sevenDaysAgo.atStartOfDay()));
+        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);
+        Timestamp weekStart = java.sql.Timestamp.valueOf(sevenDaysAgo.atStartOfDay());
+
+        // 任务完成率
+        LambdaQueryWrapper<Task> tqw = new LambdaQueryWrapper<>();
+        tqw.and(w -> w.eq(Task::getExecutorId, memberId).or().eq(Task::getChildId, memberId))
+           .ge(Task::getCreatedAt, weekStart);
+        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, weekStart);
+        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)
+           .gt(PointsLog::getAmount, 0)
+           .ge(PointsLog::getCreatedAt, weekStart);
+        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");
+        if (sleepAvg >= 8 && sleepAvg <= 10) bodyScore += 15;
+        else if (sleepAvg >= 7 && sleepAvg <= 11) bodyScore += 8;
+        int exerciseWeek = body.getInteger("exercise_count_week") != null ? body.getInteger("exercise_count_week") : 0;
+        if (exerciseWeek >= 3) bodyScore += 15;
+        else if (exerciseWeek >= 1) bodyScore += 8;
+        int waterAvg = body.getInteger("water_intake_avg_ml") != null ? body.getInteger("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");
+        mindScore += (int) (joyRatio * 30);
+        double stressAvg = mind.getDoubleValue("stress_avg");
+        mindScore -= (int) ((stressAvg - 3) * 5);
+        double negativeRatio = mind.getDoubleValue("negative_ratio");
+        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");
+        actionScore += (int) (compRate * 30);
+        int streak = action.getInteger("checkin_streak") != null ? action.getInteger("checkin_streak") : 0;
+        actionScore += Math.min(streak, 10);
+        scores.put("action", Math.min(100, actionScore));
+
+        // 富维度
+        int wealthScore = 50;
+        double savingsRate = wealth.getDoubleValue("savings_rate");
+        wealthScore += (int) (savingsRate * 30);
+        Integer inc = wealth.getInteger("income_count"); Integer exp = wealth.getInteger("expense_count"); int financeCount = (inc != null ? inc : 0) + (exp != null ? exp : 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) {
+        Set<String> domains = new LinkedHashSet<>();
+        LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);
+
+        // 睡眠问题
+        LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
+        sqw.eq(HealthSleepRecord::getMemberId, memberId)
+           .ge(HealthSleepRecord::getCreatedAt, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()))
+           .lt(HealthSleepRecord::getDurationMinutes, 480);
+        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, java.sql.Timestamp.valueOf(thirtyDaysAgo.atStartOfDay()))
+           .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 < 60; i++) {
+            LocalDate d = today.minusDays(i);
+            Timestamp dayStart = java.sql.Timestamp.valueOf(d.atStartOfDay());
+            Timestamp dayEnd = java.sql.Timestamp.valueOf(d.plusDays(1).atStartOfDay());
+            boolean hasCheckin = false;
+
+            // sleep
+            LambdaQueryWrapper<HealthSleepRecord> sqw = new LambdaQueryWrapper<>();
+            sqw.eq(HealthSleepRecord::getMemberId, memberId)
+               .ge(HealthSleepRecord::getCreatedAt, dayStart).lt(HealthSleepRecord::getCreatedAt, dayEnd);
+            if (sleepMapper.selectCount(sqw) > 0) hasCheckin = true;
+
+            // exercise
+            if (!hasCheckin) {
+                LambdaQueryWrapper<HealthExerciseRecord> eqw = new LambdaQueryWrapper<>();
+                eqw.eq(HealthExerciseRecord::getMemberId, memberId)
+                   .ge(HealthExerciseRecord::getCreatedAt, dayStart).lt(HealthExerciseRecord::getCreatedAt, dayEnd);
+                if (exerciseMapper.selectCount(eqw) > 0) hasCheckin = true;
+            }
+
+            // emotion
+            if (!hasCheckin) {
+                LambdaQueryWrapper<EmotionCheckin> emqw = new LambdaQueryWrapper<>();
+                emqw.eq(EmotionCheckin::getChildId, memberId)
+                   .ge(EmotionCheckin::getCreatedAt, dayStart).lt(EmotionCheckin::getCreatedAt, dayEnd);
+                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);
+    }
+}

+ 100 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/ProfileReadServiceImpl.java

@@ -0,0 +1,100 @@
+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;
+
+    @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(); }
+    }
+}

+ 188 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/RecommendServiceImpl.java

@@ -0,0 +1,188 @@
+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;
+
+    @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") < 8) {
+            matchedTags.add("sleep_deficit");
+            taskTags.add("睡眠");
+            articleTags.add("睡眠");
+        }
+        if ((body.getInteger("exercise_count_week") != null ? body.getInteger("exercise_count_week") : 99) < 2) {
+            matchedTags.add("low_activity");
+            taskTags.add("运动");
+        }
+        if (mind.getDoubleValue("stress_avg") > 6) {
+            matchedTags.add("high_stress");
+            articleTags.add("压力");
+            activityTags.add("冥想");
+        }
+        if (mind.getDoubleValue("emotion_joy_ratio") < 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") < 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());
+
+        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() {
+        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);
+        });
+    }
+}

+ 26 - 0
cfc-backend/src/main/resources/db/migration/V251__create_profile_tables.sql

@@ -0,0 +1,26 @@
+-- 用户画像最新快照表
+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天身体指标',
+    mind_metrics JSON DEFAULT NULL COMMENT '近30天心理指标',
+    wisdom_metrics JSON DEFAULT NULL COMMENT '最近测评成绩',
+    action_metrics JSON DEFAULT NULL COMMENT '近7天行为指标',
+    wealth_metrics JSON DEFAULT NULL COMMENT '近30天财商指标',
+    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='用户画像历史快照';

+ 27 - 0
cfc-backend/src/main/resources/schema.sql

@@ -5015,3 +5015,30 @@ CREATE TABLE IF NOT EXISTS dan_knowledge_base_tag (
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     UNIQUE KEY uk_knowledge_tag (knowledge_id, tag_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识-标签关联表';
+
+-- 用户画像最新快照表
+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_metrics JSON DEFAULT NULL COMMENT '近30天身体指标',
+    mind_metrics JSON DEFAULT NULL COMMENT '近30天心理指标',
+    wisdom_metrics JSON DEFAULT NULL COMMENT '最近测评成绩',
+    action_metrics JSON DEFAULT NULL COMMENT '近7天行为指标',
+    wealth_metrics JSON DEFAULT NULL COMMENT '近30天财商指标',
+    problem_domains VARCHAR(500) DEFAULT NULL COMMENT '关注的问题域标签',
+    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='用户画像历史快照';

+ 135 - 2
cfc-frontend/pages/body-detail/index.vue

@@ -34,7 +34,7 @@
     </view>
 
     <!-- 身体指标卡片 -->
-    <view class="indicator-cards-section" v-if="isLoggedIn && bodyIndicators.length > 0">
+    <view class="indicator-cards-section" v-if="isLoggedIn && (bodyIndicators.length > 0 || gutFloraIndicators.length > 0)">
       <view class="section-header">
         <text class="section-title">📊 身体指标</text>
         <text class="section-more" @click="goToBodyDetail">更多 ›</text>
@@ -64,6 +64,62 @@
       </view>
     </view>
 
+    <!-- 肠道菌群卡片(有菌群报告时显示) -->
+    <view class="gut-section" v-if="isLoggedIn">
+      <view class="section-header">
+        <text class="section-title">🦠 肠道菌群</text>
+        <text class="section-more" @click="goToBodyDetail">更多 ›</text>
+      </view>
+      <view class="gut-summary" v-if="gutSummary && gutFloraIndicators.length > 0">
+        <text class="gut-summary-text">{{ gutSummary }}</text>
+      </view>
+      <view class="gut-groups" v-if="gutFloraIndicators.length > 0">
+        <view class="gut-group" v-for="group in gutGrouped" :key="group.category">
+          <text class="gut-group-title">{{ group.title }}</text>
+          <view class="gut-items">
+            <view
+              v-for="(ind, i) in group.items"
+              :key="i"
+              class="gut-item"
+              :class="{ 'gut-abnormal': ind.status === '偏高' || ind.status === '偏低' }"
+            >
+              <text class="gut-item-name">{{ ind.name }}</text>
+              <view class="gut-item-right">
+                <text class="gut-item-value" v-if="ind.value">{{ ind.value }}</text>
+                <text class="gut-item-status" :class="'status-' + (ind.status || 'normal')">{{ ind.status || '正常' }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+      <view class="gut-empty-hint" v-if="!hasGutReport">
+        <text>上传菌群检测报告,查看肠道健康指标</text>
+        <text class="gut-upload-link" @click="goToUploadReport">去上传 →</text>
+      </view>
+    </view>
+
+    <!-- 疾病风险卡片(有报告且有风险项时显示) -->
+    <view class="risk-section" v-if="isLoggedIn && diseaseRisks.length > 0">
+      <view class="section-header">
+        <text class="section-title">⚠️ 疾病风险</text>
+        <text class="section-more" @click="goToBodyDetail">更多 ›</text>
+      </view>
+      <view class="risk-list">
+        <view
+          v-for="(risk, i) in diseaseRisks"
+          :key="i"
+          class="risk-item"
+          :class="'risk-' + (risk.level || 'normal')"
+        >
+          <text class="risk-name">{{ risk.name }}</text>
+          <view class="risk-right">
+            <text class="risk-value" v-if="risk.value">{{ risk.value }}</text>
+            <text class="risk-level">{{ risk.level || '未知' }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
     <!-- 家庭成员选择条 -->
     <FamilyMemberStrip
       v-if="currentView === 'self' && isLoggedIn"
@@ -113,7 +169,7 @@ import RadarChart from '../../components/RadarChart.vue'
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
-import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getDimensionOverview } from '../../utils/api.js'
+import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getDimensionOverview, getGutFloraIndicators, getBodyAbnormalIndicators } from '../../utils/api.js'
 
 export default {
   components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, FamilyMemberStrip, DimensionIntroCard },
@@ -133,6 +189,9 @@ export default {
       membersChecked: false,
             healthSubDims: [],
       bodyIndicators: [],
+      gutFloraIndicators: [],
+      hasGutReport: false,
+      diseaseRisks: [],
       sandboxData: null,
       currentView: 'self', // 'self' | 'family'
       dualDimension: null,
@@ -189,6 +248,29 @@ export default {
       }
       return result
     },
+    gutGrouped: function() {
+      var groups = {}
+      var order = ['core', 'probiotic', 'harmful', 'other']
+      var titles = { core: '核心菌属', probiotic: '益生菌', harmful: '有害菌', other: '其他重要菌属' }
+      for (var i = 0; i < this.gutFloraIndicators.length; i++) {
+        var ind = this.gutFloraIndicators[i]
+        var cat = ind.category || 'other'
+        if (!groups[cat]) groups[cat] = { category: cat, title: titles[cat] || cat, items: [] }
+        groups[cat].items.push(ind)
+      }
+      var result = []
+      for (var j = 0; j < order.length; j++) {
+        if (groups[order[j]]) result.push(groups[order[j]])
+      }
+      return result
+    },
+    gutSummary: function() {
+      if (!this.gutFloraIndicators.length) return ''
+      var abnormal = this.gutFloraIndicators.filter(function(i) { return i.status === '偏高' || i.status === '偏低' }).length
+      if (abnormal === 0) return '菌群整体平衡良好'
+      if (abnormal <= 2) return '部分菌群指标略有波动'
+      return '多项菌群指标异常,建议咨询专业营养师'
+    },
     /** 每月引导语 */
     monthlyGuidance: function() {
       var month = new Date().getMonth()
@@ -303,6 +385,10 @@ export default {
       })
       // 加载身体指标列表
       self.loadBodyIndicators(memberId)
+      // 加载菌群指标
+      self.loadGutFlora(memberId)
+      // 加载疾病风险
+      self.loadDiseaseRisks(memberId)
     },
     loadBodyIndicators: function(memberId) {
       var self = this
@@ -314,6 +400,12 @@ export default {
         console.log('[body] loadBodyIndicators failed', e)
       })
     },
+    loadGutFlora: function(memberId) {
+      var self = this
+      getGutFloraIndicators({ memberId: memberId }).then(function(res) {
+        if (res.code === 200) { self.gutFloraIndicators = res.data || []; self.hasGutReport = (res.data && res.data.length > 0) }
+      }).catch(function(e) { console.log('[body] loadGutFlora failed', e) })
+    },
     loadSandboxData: function() {
       var self = this
       getFamilyEnergySandbox().then(function(res) {
@@ -421,6 +513,14 @@ export default {
       }
       uni.navigateTo({ url: '/pages/health/nutrition-profile?memberId=' + id })
     },
+    loadDiseaseRisks: function(memberId) {
+      var self = this
+      getBodyAbnormalIndicators(memberId).then(function(res) {
+        if (res.code === 200 && res.data && res.data.diseaseRisks) {
+          self.diseaseRisks = res.data.diseaseRisks
+        }
+      }).catch(function(e) { console.log('[body] loadDiseaseRisks failed', e) })
+    },
     goToBodyDetail: function() {
       var id = this.activeChildId || uni.getStorageSync('currentChildId')
       if (!id) return
@@ -557,6 +657,39 @@ export default {
 }
 
 /* ===== 底部 ===== */
+/* ===== 肠道菌群 ===== */
+.gut-section { margin: 20rpx 30rpx; }
+.gut-summary { background: #F0F9FF; border-radius: 16rpx; padding: 16rpx 20rpx; margin-bottom: 16rpx; }
+.gut-summary-text { font-size: 26rpx; color: #0369A1; }
+.gut-group { background: #fff; border-radius: 16rpx; padding: 20rpx 24rpx; margin-bottom: 16rpx; }
+.gut-group-title { font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 12rpx; display: block; }
+.gut-item { display: flex; align-items: center; justify-content: space-between; padding: 12rpx 0; border-bottom: 1rpx solid #F3F4F6; }
+.gut-item:last-child { border-bottom: none; }
+.gut-item.gut-abnormal .gut-item-name { color: #EF4444; }
+.gut-item-name { font-size: 26rpx; color: #333; flex: 1; }
+.gut-item-right { display: flex; align-items: center; gap: 12rpx; }
+.gut-item-value { font-size: 26rpx; color: #666; }
+.gut-item-status { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 20rpx; }
+.gut-item-status.status-偏高 { background: #FEE2E2; color: #DC2626; }
+.gut-item-status.status-偏低 { background: #FEF3C7; color: #D97706; }
+.gut-item-status.status-normal { background: #D1FAE5; color: #059669; }
+.gut-empty-hint { text-align: center; padding: 30rpx; color: #999; font-size: 26rpx; background: #fff; border-radius: 16rpx; margin-bottom: 16rpx; }
+.gut-upload-link { color: #3B82F6; margin-left: 8rpx; }
+
+/* ===== 疾病风险 ===== */
+.risk-section { margin: 20rpx 30rpx; }
+.risk-list { background: #fff; border-radius: 16rpx; padding: 8rpx 0; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.risk-item { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; border-bottom: 1rpx solid #F3F4F6; }
+.risk-item:last-child { border-bottom: none; }
+.risk-item.risk-高风险 { background: #FEF2F2; }
+.risk-item.risk-需注意 { background: #FFFBEB; }
+.risk-name { font-size: 28rpx; font-weight: 500; color: #333; flex: 1; }
+.risk-right { display: flex; align-items: center; gap: 12rpx; }
+.risk-value { font-size: 24rpx; color: #666; }
+.risk-level { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 20rpx; }
+.risk-level.高风险 { background: #FEE2E2; color: #DC2626; }
+.risk-level.需注意 { background: #FEF3C7; color: #D97706; }
+
 .bottom-spacer {
   height: 120rpx;
 }

+ 14 - 8
cfc-frontend/pages/diet/index.vue

@@ -205,8 +205,8 @@ export default {
     this.loadMealSummary()
   },
   onShow: function() {
+    // onLoad 已加载过,onShow 只刷新食材(共餐配置不重复请求)
     this.loadIngredients()
-    this.loadMealSummary()
   },
   methods: {
     navTo: function(url) {
@@ -216,13 +216,13 @@ export default {
       return 'ing-' + (item.id || index)
     },
     loadIngredients: function() {
-      this.ingredientsLoading = true
       var self = this
+      self.ingredientsLoading = true
       getDietIngredients().then(function(res) {
         self.ingredientsLoading = false
-        if (res && res.ingredients) {
-          self.ingredients = res.ingredients
-          if (res.ingredients.length === 0 && !self.uploadPromptShown) {
+        if (res && res.code === 200 && res.data && res.data.ingredients) {
+          self.ingredients = res.data.ingredients
+          if (self.ingredients.length === 0 && !self.uploadPromptShown) {
             self.uploadPromptShown = true
             uni.showModal({
               title: '暂无推荐食材',
@@ -243,10 +243,14 @@ export default {
     },
     refreshIngredients: function() {
       var self = this
+      self.ingredientsLoading = true
       refreshDietIngredients().then(function(res) {
-        if (res && res.ingredients) {
-          self.ingredients = res.ingredients
+        self.ingredientsLoading = false
+        if (res && res.code === 200 && res.data && res.data.ingredients) {
+          self.ingredients = res.data.ingredients
         }
+      }).catch(function() {
+        self.ingredientsLoading = false
       })
     },
     addIngredient: function(index) {
@@ -301,7 +305,9 @@ export default {
         self.mealSummary.dinner = self.parseMembers(results[2])
       })
     },
-    parseMembers: function(config) {
+    parseMembers: function(result) {
+      if (!result || result.code !== 200 || !result.data) return ''
+      var config = result.data
       if (!config || !config.participantMemberIds) return ''
       try {
         var ids = JSON.parse(config.participantMemberIds)

+ 344 - 0
cfc-frontend/pages/growth/profile/index.vue

@@ -0,0 +1,344 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack">
+        <text class="back-text">‹ 返回</text>
+      </view>
+      <text class="nav-title">我的画像</text>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <!-- 基本信息 -->
+      <view class="card" v-if="profile.member">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <view class="member-header">
+            <text class="member-name">{{ profile.member.name || '未知' }}</text>
+            <text class="member-age" v-if="profile.member.age"> {{ profile.member.age }}岁</text>
+          </view>
+          <text class="computed-at" v-if="profile.computed_at">更新于 {{ formatDateTime(profile.computed_at) }}</text>
+        </view>
+      </view>
+
+      <!-- 五维评分 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">五维能量</text>
+          <view class="dimension-row" v-for="(score, dim) in profile.dimension_scores" :key="dim">
+            <text class="dim-label">{{ dimName(dim) }}</text>
+            <view class="dim-bar-bg">
+              <view class="dim-bar-fill" :style="{ width: score + '%', background: dimColor(dim) }"></view>
+            </view>
+            <text class="dim-val">{{ score }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 身体指标 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">身体指标</text>
+          <view class="metric-row">
+            <view class="metric-item">
+              <text class="metric-val">{{ body.sleep_dur_avg || '-' }}</text>
+              <text class="metric-label">平均睡眠(h)</text>
+            </view>
+            <view class="metric-item">
+              <text class="metric-val">{{ body.exercise_count_week || 0 }}</text>
+              <text class="metric-label">周运动(次)</text>
+            </view>
+            <view class="metric-item">
+              <text class="metric-val">{{ body.water_intake_avg_ml || '-' }}</text>
+              <text class="metric-label">日均饮水(ml)</text>
+            </view>
+          </view>
+          <view class="metric-row" v-if="body.deep_sleep_pct != null">
+            <view class="metric-item">
+              <text class="metric-val">{{ body.deep_sleep_pct }}%</text>
+              <text class="metric-label">深睡占比</text>
+            </view>
+            <view class="metric-item">
+              <text class="metric-val">{{ body.meal_records_count || 0 }}</text>
+              <text class="metric-label">月饮食记录</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 心理指标 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">心理指标</text>
+          <view class="metric-row">
+            <view class="metric-item">
+              <text class="metric-val">{{ mind.emotion_joy_ratio != null ? (mind.emotion_joy_ratio * 100).toFixed(0) + '%' : '-' }}</text>
+              <text class="metric-label">正向情绪占比</text>
+            </view>
+            <view class="metric-item">
+              <text class="metric-val">{{ mind.stress_avg || '-' }}</text>
+              <text class="metric-label">平均压力(1-10)</text>
+            </view>
+          </view>
+          <view class="metric-row">
+            <view class="metric-item">
+              <text class="metric-val">{{ mind.energy_avg || '-' }}</text>
+              <text class="metric-label">平均精力(1-10)</text>
+            </view>
+            <view class="metric-item">
+              <text class="metric-val">{{ mind.mood_score_avg || '-' }}</text>
+              <text class="metric-label">平均心情效价</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 能力测评 -->
+      <view class="card" v-if="wisdom.overall_score != null">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">能力测评</text>
+          <view class="metric-row">
+            <view class="metric-item">
+              <text class="metric-val">{{ wisdom.overall_score }}</text>
+              <text class="metric-label">综合得分</text>
+            </view>
+            <view class="metric-item">
+              <text class="metric-val">{{ wisdom.attention_score || '-' }}</text>
+              <text class="metric-label">注意力</text>
+            </view>
+            <view class="metric-item">
+              <text class="metric-val">{{ wisdom.focus_score || '-' }}</text>
+              <text class="metric-label">专注力</text>
+            </view>
+          </view>
+          <view class="metric-row" v-if="wisdom.game_avg_score_7d != null">
+            <view class="metric-item">
+              <text class="metric-val">{{ wisdom.game_avg_score_7d }}</text>
+              <text class="metric-label">近7天游戏均分</text>
+            </view>
+            <view class="metric-item">
+              <text class="metric-val">{{ wisdom.game_count_7d || 0 }}</text>
+              <text class="metric-label">近7天游戏次数</text>
+            </view>
+          </view>
+          <text class="assessment-date" v-if="wisdom.assessment_date">测评时间: {{ formatDate(wisdom.assessment_date) }}</text>
+        </view>
+      </view>
+
+      <!-- 行为指标 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">行为活跃</text>
+          <view class="metric-row">
+            <view class="metric-item">
+              <text class="metric-val">{{ (action.task_completion_rate * 100).toFixed(0) }}%</text>
+              <text class="metric-label">任务完成率</text>
+            </view>
+            <view class="metric-item">
+              <text class="metric-val">{{ action.checkin_streak || 0 }}</text>
+              <text class="metric-label">连续打卡(天)</text>
+            </view>
+            <view class="metric-item">
+              <text class="metric-val">{{ action.points_velocity_7d || 0 }}</text>
+              <text class="metric-label">7日积分</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 关注问题域 -->
+      <view class="card" v-if="problemDomains && problemDomains.length > 0">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">关注方向</text>
+          <view class="domain-tags">
+            <view class="domain-tag" v-for="d in problemDomains" :key="d">
+              <text>{{ domainLabel(d) }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 推荐内容 -->
+      <view class="card" v-if="recommendations.tasks && recommendations.tasks.length > 0">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">为你推荐</text>
+          <!-- 任务推荐 -->
+          <view class="rec-section" v-if="recommendations.tasks.length > 0">
+            <text class="rec-title">📋 推荐任务</text>
+            <view class="rec-list">
+              <view class="rec-item" v-for="item in recommendations.tasks" :key="item.id">
+                <text class="rec-item-title">{{ item.title }}</text>
+                <text class="rec-item-type">{{ item.category }}</text>
+              </view>
+            </view>
+          </view>
+          <!-- 文章推荐 -->
+          <view class="rec-section" v-if="recommendations.articles && recommendations.articles.length > 0">
+            <text class="rec-title">📖 推荐文章</text>
+            <view class="rec-list">
+              <view class="rec-item" v-for="item in recommendations.articles" :key="item.id">
+                <text class="rec-item-title">{{ item.title }}</text>
+              </view>
+            </view>
+          </view>
+          <!-- 活动推荐 -->
+          <view class="rec-section" v-if="recommendations.activities && recommendations.activities.length > 0">
+            <text class="rec-title">🎯 推荐活动</text>
+            <view class="rec-list">
+              <view class="rec-item" v-for="item in recommendations.activities" :key="item.id">
+                <text class="rec-item-title">{{ item.title }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 空状态 -->
+      <view class="empty-state" v-if="!profile.member && !loading">
+        <text class="empty-text">暂无画像数据</text>
+        <text class="empty-hint">完成打卡后自动生成</text>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import config from '../../config'
+import { parseDate } from '../../utils/format.js'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      profile: {},
+      recommendations: {}
+    }
+  },
+  computed: {
+    body() {
+      return this.profile.body_metrics || {}
+    },
+    mind() {
+      return this.profile.mind_metrics || {}
+    },
+    wisdom() {
+      return this.profile.wisdom_metrics || {}
+    },
+    action() {
+      return this.profile.action_metrics || {}
+    },
+    problemDomains() {
+      return this.profile.problem_domains || []
+    }
+  },
+  onLoad() {
+    this.loadProfile()
+  },
+  methods: {
+    goBack() { uni.navigateBack() },
+    loadProfile() {
+      var self = this
+      self.loading = true
+      var token = uni.getStorageSync('token')
+      uni.request({
+        url: config.API_BASE_URL + '/api/profile/my',
+        method: 'POST',
+        data: {},
+        header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+        success: function(res) {
+          self.loading = false
+          if (res.data && res.data.code === 200 && res.data.data) {
+            self.profile = res.data.data
+            self.recommendations = res.data.data
+          }
+        },
+        fail: function() {
+          self.loading = false
+          uni.showToast({ title: '加载失败', icon: 'none' })
+        }
+      })
+    },
+    dimName(dim) {
+      var m = { body: '身', wisdom: '智', mind: '心', action: '行', wealth: '富' }
+      return m[dim] || dim
+    },
+    dimColor(dim) {
+      var m = { body: '#FF8C42', wisdom: '#6366F1', mind: '#FF6B9D', action: '#10B981', wealth: '#F59E0B' }
+      return m[dim] || '#999'
+    },
+    domainLabel(d) {
+      var m = { sleep: '睡眠', attention: '注意力', emotion: '情绪' }
+      return m[d] || d
+    },
+    formatDate(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return dateStr
+      var y = d.getFullYear()
+      var m2 = (d.getMonth() + 1).toString().padStart(2, '0')
+      var day = d.getDate().toString().padStart(2, '0')
+      return y + '-' + m2 + '-' + day
+    },
+    formatDateTime(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return dateStr
+      var dt = dateStr.toString()
+      if (dt.length >= 19) return dt.substring(0, 19).replace('T', ' ')
+      return this.formatDate(dateStr)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #FFF7ED; }
+.nav-bar { display: flex; align-items: center; height: 88rpx; padding-top: env(safe-area-inset-top); background: #fff; border-bottom: 1rpx solid #f0f0f0; position: relative; }
+.nav-back { position: absolute; left: 24rpx; top: 0; bottom: 0; display: flex; align-items: center; }
+.back-text { font-size: 28rpx; color: #F97316; }
+.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: bold; color: #333; }
+.content { height: calc(100vh - 88rpx); }
+.card { margin: 16rpx 24rpx; background: #fff; border-radius: 20rpx; overflow: hidden; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.card-accent { height: 6rpx; background: linear-gradient(90deg, #FF8C42, #F97316); }
+.card-body { padding: 24rpx; }
+.card-title { font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 16rpx; display: block; }
+
+.member-header { display: flex; align-items: baseline; gap: 8rpx; margin-bottom: 8rpx; }
+.member-name { font-size: 36rpx; font-weight: bold; color: #333; }
+.member-age { font-size: 26rpx; color: #999; }
+.computed-at { font-size: 22rpx; color: #bbb; }
+
+.dimension-row { display: flex; align-items: center; gap: 12rpx; margin-bottom: 14rpx; }
+.dim-label { font-size: 24rpx; color: #666; width: 60rpx; flex-shrink: 0; }
+.dim-bar-bg { flex: 1; height: 20rpx; background: #f0f0f0; border-radius: 10rpx; overflow: hidden; }
+.dim-bar-fill { height: 100%; border-radius: 10rpx; transition: width 0.3s; }
+.dim-val { font-size: 24rpx; color: #333; font-weight: bold; width: 48rpx; text-align: right; flex-shrink: 0; }
+
+.metric-row { display: flex; gap: 16rpx; margin-bottom: 12rpx; }
+.metric-item { flex: 1; text-align: center; padding: 16rpx 8rpx; background: #FAFAFA; border-radius: 12rpx; }
+.metric-val { font-size: 32rpx; font-weight: bold; color: #F97316; display: block; }
+.metric-label { font-size: 20rpx; color: #999; margin-top: 4rpx; display: block; }
+
+.assessment-date { font-size: 20rpx; color: #bbb; margin-top: 8rpx; display: block; }
+
+.domain-tags { display: flex; flex-wrap: wrap; gap: 12rpx; }
+.domain-tag { padding: 8rpx 20rpx; border-radius: 20rpx; background: #FFF3E0; border: 1rpx solid #F97316; }
+.domain-tag text { font-size: 24rpx; color: #F97316; }
+
+.rec-section { margin-top: 16rpx; }
+.rec-title { font-size: 26rpx; color: #666; margin-bottom: 10rpx; display: block; }
+.rec-list { display: flex; flex-direction: column; gap: 8rpx; }
+.rec-item { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.rec-item-title { font-size: 26rpx; color: #333; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.rec-item-type { font-size: 22rpx; color: #999; flex-shrink: 0; margin-left: 16rpx; }
+
+.empty-state { text-align: center; padding: 80rpx 0; }
+.empty-text { font-size: 28rpx; color: #999; display: block; }
+.empty-hint { font-size: 24rpx; color: #ccc; margin-top: 8rpx; display: block; }
+</style>

+ 66 - 2
cfc-frontend/pages/health/report-detail.vue

@@ -24,8 +24,11 @@
       <view v-else>
         <!-- 解析中状态(仅 draft 模式,从上传页跳转后自动解析) -->
         <view class="parsing-banner" v-if="parsing">
-          <text class="parsing-icon">⏳</text>
-          <text class="parsing-text">正在解析报告,请稍候...</text>
+          <view class="parsing-spinner"></view>
+          <view class="parsing-content">
+            <text class="parsing-main-text">AI 正在解析您的报告</text>
+            <text class="parsing-sub-text" :key="parsingStep">{{ parsingText }}</text>
+          </view>
         </view>
 
         <view class="tip-banner">
@@ -405,6 +408,9 @@ export default {
       pageMode: 'view', // view / draft / edit
       directEdit: false, // 是否从列表 mode=edit 直入编辑模式(取消时返回上一页)
       parsing: false,
+      parsingText: '正在检测报告类型...',
+      parsingStep: 0,
+      parsingTimer: null,
       confirming: false,
       payload: {
         type: 'gut_flora',
@@ -648,8 +654,21 @@ export default {
     parseByDraftId() {
       var self = this
       self.parsing = true
+      self.parsingStep = 0
+      self.parsingText = '正在检测报告类型,识别报告来源...'
+      // 循环切换文案,让用户知道系统在工作
+      var messages = [
+        '正在检测报告类型,识别报告来源...',
+        'AI 大模型正在深度分析报告内容...',
+        '提取关键指标,请稍候...'
+      ]
+      self.parsingTimer = setInterval(function() {
+        self.parsingStep = (self.parsingStep + 1) % messages.length
+        self.parsingText = messages[self.parsingStep]
+      }, 2500)
       parseReportDraft(self.draftId).then(function(res) {
         self.parsing = false
+        if (self.parsingTimer) { clearInterval(self.parsingTimer); self.parsingTimer = null }
         if (res.code === 200) {
           self.applyParseResult(res.data)
         } else {
@@ -657,6 +676,7 @@ export default {
         }
       }).catch(function() {
         self.parsing = false
+        if (self.parsingTimer) { clearInterval(self.parsingTimer); self.parsingTimer = null }
         uni.showToast({ title: '解析异常', icon: 'none' })
       })
     },
@@ -1100,6 +1120,9 @@ export default {
       if (value == null) return
       items.push({ parentLabel: parentLabel, label: label, value: value, max: 100 })
     }
+  },
+  onUnload: function() {
+    if (this.parsingTimer) { clearInterval(this.parsingTimer); this.parsingTimer = null }
   }
 }
 </script>
@@ -1970,4 +1993,45 @@ export default {
   margin-left: 0;
   margin-right: 0;
 }
+/* 解析中 banner */
+.parsing-banner {
+  display: flex;
+  align-items: center;
+  background: linear-gradient(135deg, #FFF7ED, #FFF0E0);
+  border: 1rpx solid #FFD4A8;
+  border-radius: 20rpx;
+  padding: 28rpx 32rpx;
+  margin-bottom: 20rpx;
+}
+.parsing-spinner {
+  width: 48rpx;
+  height: 48rpx;
+  border: 4rpx solid #FFD4A8;
+  border-top-color: #FF8C42;
+  border-radius: 50%;
+  flex-shrink: 0;
+  animation: parsing-spin 0.8s linear infinite;
+}
+@keyframes parsing-spin {
+  to { transform: rotate(360deg); }
+}
+.parsing-content {
+  display: flex;
+  flex-direction: column;
+  margin-left: 20rpx;
+  flex: 1;
+}
+.parsing-main-text {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #D97706;
+  line-height: 1.5;
+}
+.parsing-sub-text {
+  font-size: 24rpx;
+  color: #92400E;
+  line-height: 1.5;
+  margin-top: 6rpx;
+  opacity: 0.8;
+}
 </style>

+ 119 - 15
cfc-frontend/pages/health/report-survey.vue

@@ -29,7 +29,7 @@
 
       <!-- Q1: 饮食结构 -->
       <view class="question-card">
-        <text class="q-number">1/12</text>
+        <text class="q-number">1/15</text>
         <text class="q-title">孩子平时的饮食结构是怎样的?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.diet === 'balanced' }" @click="survey.diet = 'balanced'">
@@ -51,9 +51,85 @@
         </view>
       </view>
 
+      <!-- Q1b: 绝对忌口(必填) -->
+      <view class="question-card">
+        <text class="q-number">1b/15</text>
+        <text class="q-title">孩子有哪些绝对不能吃/喝的食物?(必填,含过敏原)</text>
+        <view class="q-options multi">
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.indexOf('牛奶') !== -1 }" @click="toggleAbsAvoid('牛奶')">
+            <text class="q-text">牛奶/乳制品</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.indexOf('鸡蛋') !== -1 }" @click="toggleAbsAvoid('鸡蛋')">
+            <text class="q-text">鸡蛋</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.indexOf('海鲜') !== -1 }" @click="toggleAbsAvoid('海鲜')">
+            <text class="q-text">海鲜/虾蟹</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.indexOf('花生') !== -1 }" @click="toggleAbsAvoid('花生')">
+            <text class="q-text">花生/坚果</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.indexOf('麸质') !== -1 }" @click="toggleAbsAvoid('麸质')">
+            <text class="q-text">麸质/小麦</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.length === 0 }" @click="survey.absoluteAvoid = []">
+            <text class="q-text">无</text>
+          </view>
+        </view>
+        <view class="extra-input-row">
+          <input class="extra-input" placeholder="或其他忌口,回车添加..." v-model="absAvoidInput" @confirm="addAbsAvoidInput" />
+        </view>
+      </view>
+
+      <!-- Q1c: 必须要吃 -->
+      <view class="question-card">
+        <text class="q-number">1c/15</text>
+        <text class="q-title">有什么一定要保证摄入的食物?(可选)</text>
+        <view class="q-options multi">
+          <view class="q-option" :class="{ 'q-selected': survey.mustEat.indexOf('蔬菜') !== -1 }" @click="toggleMustEat('蔬菜')">
+            <text class="q-text">🥬 蔬菜</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.mustEat.indexOf('水果') !== -1 }" @click="toggleMustEat('水果')">
+            <text class="q-text">🍎 水果</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.mustEat.indexOf('蛋白质') !== -1 }" @click="toggleMustEat('蛋白质')">
+            <text class="q-text">🥚 蛋白质</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.mustEat.indexOf('益生菌') !== -1 }" @click="toggleMustEat('益生菌')">
+            <text class="q-text">🥛 益生菌/酸奶</text>
+          </view>
+        </view>
+        <view class="extra-input-row">
+          <input class="extra-input" placeholder="或其他,回车添加..." v-model="mustEatInput" @confirm="addMustEatInput" />
+        </view>
+      </view>
+
+      <!-- Q1d: 宗教饮食 -->
+      <view class="question-card">
+        <text class="q-number">1d/15</text>
+        <text class="q-title">是否有宗教或特殊饮食要求?(可选)</text>
+        <view class="q-options">
+          <view class="q-option" :class="{ 'q-selected': survey.religiousDiet === 'none' }" @click="survey.religiousDiet = 'none'">
+            <text class="q-icon">✅</text>
+            <text class="q-text">无限制</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.religiousDiet === 'halal' }" @click="survey.religiousDiet = 'halal'">
+            <text class="q-icon">☪️</text>
+            <text class="q-text">清真(Halal)</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.religiousDiet === 'vegetarian' }" @click="survey.religiousDiet = 'vegetarian'">
+            <text class="q-icon">🥬</text>
+            <text class="q-text">素食</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.religiousDiet === 'vegan' }" @click="survey.religiousDiet = 'vegan'">
+            <text class="q-icon">🌱</text>
+            <text class="q-text">纯素(不含任何动物制品)</text>
+          </view>
+        </view>
+      </view>
+
       <!-- Q2: 消化状况 -->
       <view class="question-card">
-        <text class="q-number">2/12</text>
+        <text class="q-number">2/15</text>
         <text class="q-title">孩子近期消化状况如何?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.digestion === 'good' }" @click="survey.digestion = 'good'">
@@ -77,7 +153,7 @@
 
       <!-- Q3: 睡眠质量 -->
       <view class="question-card">
-        <text class="q-number">3/12</text>
+        <text class="q-number">3/15</text>
         <text class="q-title">孩子近期的睡眠质量如何?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.sleep === 'good' }" @click="survey.sleep = 'good'">
@@ -101,7 +177,7 @@
 
       <!-- Q4: 运动习惯 -->
       <view class="question-card">
-        <text class="q-number">4/12</text>
+        <text class="q-number">4/15</text>
         <text class="q-title">孩子每周运动情况?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.exercise === 'frequent' }" @click="survey.exercise = 'frequent'">
@@ -121,7 +197,7 @@
 
       <!-- Q5: 饮水习惯 -->
       <view class="question-card">
-        <text class="q-number">5/12</text>
+        <text class="q-number">5/15</text>
         <text class="q-title">孩子每日饮水量如何?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.water === 'enough' }" @click="survey.water = 'enough'">
@@ -141,7 +217,7 @@
 
       <!-- Q6: 用药情况 -->
       <view class="question-card">
-        <text class="q-number">6/12</text>
+        <text class="q-number">6/15</text>
         <text class="q-title">孩子近期是否有以下用药情况?(可多选)</text>
         <view class="q-options multi">
           <view class="q-option" :class="{ 'q-selected': hasMedication('antibiotic') }" @click="toggleMedication('antibiotic')">
@@ -170,7 +246,7 @@
 
       <!-- Q7: 慢性病/系统性疾病 -->
       <view class="question-card">
-        <text class="q-number">7/12</text>
+        <text class="q-number">7/15</text>
         <text class="q-title">孩子是否有以下慢性或系统性疾病?(可多选)</text>
         <view class="q-options multi">
           <view class="q-option" :class="{ 'q-selected': hasChronic('none') }" @click="toggleChronic('none')">
@@ -207,7 +283,7 @@
 
       <!-- Q8: 过敏史 -->
       <view class="question-card">
-        <text class="q-number">8/12</text>
+        <text class="q-number">8/15</text>
         <text class="q-title">孩子是否有以下过敏情况?(可多选)</text>
         <view class="q-options multi">
           <view class="q-option" :class="{ 'q-selected': survey.allergies.indexOf('none') !== -1 }" @click="toggleAllergy('none')">
@@ -232,7 +308,7 @@
 
       <!-- Q9: 手术/住院史 -->
       <view class="question-card">
-        <text class="q-number">9/12</text>
+        <text class="q-number">9/15</text>
         <text class="q-title">孩子是否有手术或住院史?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.surgery === 'none' }" @click="survey.surgery = 'none'">
@@ -253,7 +329,7 @@
 
       <!-- Q10: 家族病史 -->
       <view class="question-card">
-        <text class="q-number">10/12</text>
+        <text class="q-number">10/15</text>
         <text class="q-title">直系亲属是否有以下疾病史?(可多选)</text>
         <view class="family-grid">
           <view class="family-col">
@@ -303,7 +379,7 @@
 
       <!-- Q11: 祖父母病史 -->
       <view class="question-card">
-        <text class="q-number">11/12</text>
+        <text class="q-number">11/15</text>
         <text class="q-title">祖父母/外祖父母是否有以下疾病史?(可多选)</text>
         <view class="q-options multi">
           <view class="q-option" :class="{ 'q-selected': survey.grandparentHistory.indexOf('unknown') !== -1 }" @click="toggleGrandparent('unknown')">
@@ -341,7 +417,7 @@
 
       <!-- Q12: 健康目标 -->
       <view class="question-card">
-        <text class="q-number">12/12</text>
+        <text class="q-number">12/15</text>
         <text class="q-title">您希望通过本次健康管理改善什么?(可多选)</text>
         <view class="q-options multi">
           <view class="q-option" :class="{ 'q-selected': survey.goals.indexOf('immunity') !== -1 }" @click="toggleGoal('immunity')">
@@ -402,6 +478,9 @@ export default {
       survey: {
         // 生活习惯
         diet: '',
+        absoluteAvoid: [],    // 绝对忌口(必填)
+        mustEat: [],          // 必须要吃(可选)
+        religiousDiet: 'none', // none/清真/halal/素食/vegan
         digestion: '',
         sleep: '',
         exercise: '',
@@ -414,6 +493,8 @@ export default {
         },
         allergies: [],
         allergyDetail: '',
+        absAvoidInput: '',
+        mustEatInput: '',
         surgery: '',
         surgeryDetail: '',
         // 家族史
@@ -444,8 +525,11 @@ export default {
   computed: {
     progressPercent: function() {
       var answered = 0
-      var total = 12
+      var total = 15
       if (this.survey.diet) answered++
+      if (this.survey.absoluteAvoid.length > 0) answered++
+      if (this.survey.mustEat.length > 0 || true) answered++
+      if (this.survey.religiousDiet && this.survey.religiousDiet !== 'none') answered++
       if (this.survey.digestion) answered++
       if (this.survey.sleep) answered++
       if (this.survey.exercise) answered++
@@ -454,7 +538,6 @@ export default {
       if (this.survey.diseaseHistory.chronic.length > 0) answered++
       if (this.survey.allergies.length > 0) answered++
       if (this.survey.surgery) answered++
-      // 家族史:至少填了父亲或母亲或兄弟姐妹或祖父母之一
       if (this.survey.familyHistory.father.length > 0
         || this.survey.familyHistory.mother.length > 0
         || this.survey.familyHistory.sibling.length > 0) answered++
@@ -463,7 +546,8 @@ export default {
       return Math.round((answered / total) * 100)
     },
     canSubmit: function() {
-      return this.survey.diet && this.survey.sleep && this.survey.exercise
+      return this.survey.diet && this.survey.absoluteAvoid.length > 0
+        && this.survey.sleep && this.survey.exercise
         && this.survey.diseaseHistory.chronic.length > 0
         && this.survey.allergies.length > 0
         && this.survey.goals.length > 0
@@ -608,6 +692,26 @@ export default {
         this.survey.goals.splice(idx, 1)
       }
     },
+    toggleAbsAvoid: function(item) {
+      var idx = this.survey.absoluteAvoid.indexOf(item)
+      if (idx >= 0) this.survey.absoluteAvoid.splice(idx, 1)
+      else this.survey.absoluteAvoid.push(item)
+    },
+    addAbsAvoidInput: function() {
+      var v = this.absAvoidInput.trim()
+      if (v && this.survey.absoluteAvoid.indexOf(v) === -1) this.survey.absoluteAvoid.push(v)
+      this.absAvoidInput = ''
+    },
+    toggleMustEat: function(item) {
+      var idx = this.survey.mustEat.indexOf(item)
+      if (idx >= 0) this.survey.mustEat.splice(idx, 1)
+      else this.survey.mustEat.push(item)
+    },
+    addMustEatInput: function() {
+      var v = this.mustEatInput.trim()
+      if (v && this.survey.mustEat.indexOf(v) === -1) this.survey.mustEat.push(v)
+      this.mustEatInput = ''
+    },
     goBack: function() {
       uni.navigateBack()
     },

+ 42 - 0
cfc-frontend/utils/api.js

@@ -1930,6 +1930,7 @@ export const getProductsByDomain = (domain, page, size) => {
 export const getLatestHealthReport = (memberId) => request('/api/health/report/latest', 'POST', { memberId })
 export const getHealthIndicatorList = (memberId) => request('/api/health/indicator/list', 'POST', { memberId })
 export const getLatestIndicators = (data) => request('/api/health/indicators', 'POST', data)
+export const getGutFloraIndicators = (data) => request('/api/health/gut-flora', 'POST', data)
 
 
 export const createHealthReport = (data) => request('/api/health/report/create', 'POST', data)
@@ -2525,3 +2526,44 @@ export const saveBodyFatMetrics = (data) => {
     })
   })
 }
+
+// 用户画像
+export function getMyProfile() {
+  return new Promise((resolve, reject) => {
+    const token = uni.getStorageSync('token')
+    uni.request({
+      url: BASE_URL + '/api/profile/my',
+      method: 'POST',
+      data: {},
+      header: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
+      success: (res) => {
+        if (res.data && res.data.code === 200) {
+          resolve(res.data)
+        } else {
+          reject(res.data || { code: -1, message: '获取画像失败' })
+        }
+      },
+      fail: (err) => reject(err)
+    })
+  })
+}
+
+export function getProfileTrend(days) {
+  return new Promise((resolve, reject) => {
+    const token = uni.getStorageSync('token')
+    uni.request({
+      url: BASE_URL + '/api/profile/history',
+      method: 'POST',
+      data: { days: days || 30 },
+      header: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
+      success: (res) => {
+        if (res.data && res.data.code === 200) {
+          resolve(res.data)
+        } else {
+          reject(res.data || { code: -1, message: '获取趋势失败' })
+        }
+      },
+      fail: (err) => reject(err)
+    })
+  })
+}

+ 23 - 0
cfc-langgraph/app/api/adapter.py

@@ -621,6 +621,9 @@ PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案规划师。根
 3. 建议要具体可执行,避免空泛
 4. 营养补充部分要具体到产品类型和用量
 5. 严重健康问题建议咨询医生
+
+## 画像数据使用指南
+如果提供了用户的画像数据(五维评分、身体指标、心理指标等),请结合这些真实数据给出更有针对性的建议。特别关注异常指标(如睡眠不足、压力偏高、运动频率低等),在方案中明确说明这些指标的现状和改善方向。
 """
 
 REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。根据用户反馈重新生成指定部分内容。
@@ -699,6 +702,14 @@ async def health_plan_generate(req: HealthPlanRequest):
 
     members_info, all_indicators, kb_results = await _collect_plan_data(java, retriever, req.member_ids or "", goal, req.dimensions or "")
 
+    # 获取每个成员的画像数据
+    for member in members_info:
+        try:
+            profile = await java.get_member_profile(int(member["id"]))
+            member["profile"] = profile
+        except Exception:
+            member["profile"] = {}
+
     # 构建 prompt
     parts = [PLAN_SYSTEM_PROMPT]
     parts.append(f"\n## 用户目标\n{goal}")
@@ -706,7 +717,19 @@ async def health_plan_generate(req: HealthPlanRequest):
         parts.append(f"\n## 重点关注维度\n{req.dimensions}")
     parts.append("\n## 家庭成员")
     for m in members_info:
+        profile = m.get("profile", {})
+        dims = profile.get("dimension_scores", {})
+        body = profile.get("body_metrics", {})
+        mind = profile.get("mind_metrics", {})
         parts.append(f"- {m['name']} (年龄: {m['age']})")
+        if dims:
+            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']}次")
 
     if all_indicators:
         parts.append("\n## 健康指标摘要")

+ 10 - 0
cfc-langgraph/app/tools/java_client.py

@@ -189,3 +189,13 @@ class JavaClient:
         if data.get("code") == 200:
             return data.get("data", {})
         return {}
+
+    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 {}
+

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-c26653bb7a42d5c79e24999fc83408076cf24546
+327bc9ed4761a3b1d1290f844a1cdda40ca10aab

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1176",
+  "version": "1.0.1189",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1176",
+      "version": "1.0.1189",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1177",
+  "version": "1.0.1190",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 500 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,506 @@
 
 ---
 
+## v1.0.1190 (2026-08-22)
+
+### 新功能
+- 身体调研加饮食偏好 + 食材档案清真/素食标识
+
+### 其他
+- - 提交问卷时自动同步到 diet_preferences 表(absoluteAvoid/mustEat/religiousDiet)
+- - 迁移254: diet_preferences 新增 must_eat 列
+- - 迁移253: foods 新增 diet_type 列(清真/halal/素食/vegan 标识)
+- - Food/DietPreferences 实体及 DTO 同步更新
+- 
+
+
+## v1.0.1189 (2026-08-22)
+
+### Bug 修复
+- 修复食材数据未显示-响应路径修正为res.data,合并重复API调用
+
+
+## v1.0.1188 (2026-08-21)
+
+### 新功能
+- Web管理端 — 用户画像列表与详情查看
+
+
+## v1.0.1187 (2026-08-21)
+
+### 新功能
+- 小程序画像页 — 五维指标+推荐内容展示
+- 方案生成时自动注入用户画像数据
+
+
+## v1.0.1186 (2026-08-21)
+
+### 新功能
+- 打卡和任务完成后自动触发画像重算
+
+
+## v1.0.1185 (2026-08-21)
+
+### 新功能
+- 解析loading改为旋转动画+三阶段轮播文案,提升用户感知
+- 添加C端和B端画像API控制器
+- 实现画像读取服务和规则推荐引擎
+- 实现画像计算服务 — 五维指标聚合+增量写入
+
+
+## v1.0.1184 (2026-08-21)
+
+### Bug 修复
+- 补回疾病风险卡片模板
+- 修正Task状态值、Product状态值、删除死代码RULES块、修复PointsLog查询语法
+- 补充肠道菌群卡片CSS样式
+- mind-detail/index 删除孤立多余闭合花括号
+- login.vue 删除 return 对象尾随逗号 + report-detail 给 .food-item 补闭合花括号
+- 修复饮食推荐页面右侧溢出-添加box-sizing和width约束
+- 统一首页和成长页面今日任务标题命名
+- 修复行页面能量沙盘数值不显示问题
+- tasks表加parent_task_id列(购买任务→每日使用子任务链)+ schema同步
+- health_plans补updated_at/审核字段列,修复selectById Unknown column
+- 打卡弹窗金额/备注输入框高度不足 — 增加padding和min-height
+- 打卡页隐藏原生导航栏,修复双标题 —— navigationStyle: custom 仅保留页面内标题
+- 重新制定时重置viewMode,修复页面空白问题
+- 修复打卡页面双标题、输入框过小、睡眠页多余功能、心情页样式缺失
+- 修复微信登录 invalid code (40029) —— 不再 onLoad 预取 login code,改为点击授权时实时获取
+- 任务列表标题列支持排序,孩子ID改为显示用户名
+- health-main 移除已删除报告入口 + growth-emotion-checkin 添加照片上传
+- 系统提示词INSERT列数不一致 + 知识库embedding TPM重试 + tags_str类型修复
+- action-detail/wealth/wisdom-detail 修复 :key 表达式
+- 方案任务设置repeatType=daily,幂等检查限定当天deadline
+- 健康方案成员选择选中状态增强
+- 心情打卡同步情绪日记—checkinDate 空值兜底为当天
+- 健康方案重做限制改为每日一次
+- 修复报告方案链路与报告详情折叠展示
+- 健康方案查看页+报告日期格式
+- 保单管理页居中+控件高度
+- 移除 report-upload 自定义导航栏,使用原生导航栏避免双标题
+- confirmText 改为4字以内,移除debug日志和红色横幅
+- AiGateway 缺少 @PostConstruct import 导致编译失败
+- 财商打卡页居中+控件高度 - 容器宽度约束+input padding增大
+- 解决 merge 冲突 - health-main/index-home 合并冲突 & AiGateway 保留
+- 同步远端重构合并 + 保持 :key 修复与 trial 环境地址
+- pages.json 删除多余右花括号修复 JSON 语法
+- 健康主页身维度雷达图改用真实API数据
+- 内联弹窗逻辑到 pickFile/takePhoto,消除方法调用链断裂问题
+- 移除所有页面中的PageBanner组件,消除双层标题
+- 迁移249 file_record补file_type/description列,修复Unknown column错误
+- 允许 cfc.iwintrue.com 访问后端 API
+- 责任声明勾选改方法调用+箭头函数消除this丢失隐患
+- 修复行页面FamilyEnergyBar标签断裂导致模板文本裸露
+- 修复行页面FamilyEnergyBar标签断裂导致模板文本裸露
+- 修复行页面FamilyEnergyBar标签断裂导致模板文本裸露
+
+### 新功能
+- 创建画像实体类和Mapper接口
+- 创建画像快照表和歷史表迁移脚本
+- 身页面新增疾病风险卡片
+- 身页面新增疾病风险卡片
+- 身页面新增肠道菌群指标卡片
+- 饮食推荐页面重新设计-修复居中/溢出/快捷入口不可点并全面美化
+- 身/心/智页面重构 — 去七维/快捷/精准营养,改指标卡片
+- 调研任务创建时自动生成AI调研模板
+- 购物任务联动-cart/order 动作自动加购/下单
+- 方案审核流程优化 — 用户可选规划师或系统自动出方案
+- 规划师方案维护功能 — 前端审核页面 + 路由 + API
+- 方案维护功能 — 规划师审核发布流程
+- 体脂秤图片采集接入报告上传流程
+- checkin 页面合并远端 UI 改进 + 保留 getPhotoKey 修复
+- 体脂秤图片数据采集器 — 修复 Java 8 兼容性问题
+- 体脂秤图片数据采集器
+- 任务系统升级-行为调度中心前端(动作联动/两阶段/填写式完成)
+- 任务系统升级-行为调度中心后端(前置任务/频率任务/两阶段/填写式完成)
+- 健康方案页重写为多步骤结构化编辑流程
+- 评论自动回复脚本 comment_reply.py——CDP 驱动,click 修复与残缺选择器修复
+- 新增 D26/D27/D28 文章稿、推流试验稿及内容草稿
+- 照片自动情绪识别 — 后端 AI 调用 + 前端真实 API
+- 照片自动情绪识别 — Dify AI 图像分析 + 前端接入
+- HealthPlan后端重构 - planJson/status字段+regenerate-section接口
+- 首页区块顺序调整为 1-2-9-7-8-5-6-4-3-10-11
+- 健康方案生成工作流重构 - LangGraph结构化方案+Java对接
+- LLM推送数据PII脱敏(智能脱敏)
+- 心情打卡同步到情绪日记
+- 新增 SystemPrompt 管理功能 + web 品牌与产品素材
+- 健康现状档案 v2 — 疾病史两源预置清单分类多选+确诊时间,用药开始年月自动计算时长 - 疾病史:DISEASE_PRESETS(10项) → DISEASE_GROUPS(8组54项,★菌群源),分类折叠多选,★角标说明 - 确诊时间:年月选择器(fields=month, end限当前月),存 diagnosedAt(yyyy-MM) - 用药:startDate年月 + durationText自动计算(<12月→X个月, ≥12→X年),记不清可手动兜底 - 后端零变更(JSON列透传),v1旧数据({name,note}/{name,time})回显兼容 - 新增 computed.selectedNames + 17 个 CSS 类
+- DAN 测评报告专用详情页
+- 智行富三页强项与提升卡片-认知/关系/财富强弱项标记
+- 心身智三页肠脑轴卡片-情绪/认知指标过滤+身体异常告警
+- 肠脑轴指标过滤+异常指标告警-认知/情绪分类+身体异常指标端点
+- 首页健康漏斗重构 — 了解区自测卡 + 四段式区块重排
+- 今日任务和报告上传移至推荐阅读上方,推荐商品移到底部
+
+### 其他
+- - 展示疾病名称、风险值、等级标签(高风险红/需注意黄)
+- - gut-flora 接口确认已为 POST,无需修改
+- 
+- - 展示疾病名称、风险值、风险等级(高风险红/需注意黄)
+- - gut-flora 接口已为 POST,无需修改
+- 
+- - 前端 body-detail 页面新增🦠肠道菌群区块:有报告时显示指标明细,无报告时引导上传
+- - 显示菌群名称、丰度值、状态(正常/偏高/偏低)及摘要提示
+- - 心/智页面已有肠脑轴数据展示,无需改动
+- 
+- - 新增指标卡片展示:体脂率/BMI/血压/血糖/步数/睡眠(身)、情绪稳定性/焦虑/开放性(心)、感知力/专注力/记忆力(智)
+- - 后端新增 POST /api/health/indicators 接口,按domain+memberId返回最新指标值
+- - DatabaseInitializer迁移252:seed body/mind/wisdom指标定义27条
+- - 各维度首页加载对应domain指标数据,点击卡片跳转详情
+- 
+- - 新增 loadEnergyOverview 方法调用 getEnergyOverview API
+- - WuxingSandbox 组件传递 :dimensions/:totalEnergy/:totalHealthIndex 属性
+- - 修复 sortedDimensions 因 dimensions 为空导致 point-energy 始终显示 0 的问题
+- - 对齐 child-index.vue 页面的正确用法
+- 
+- - schema.sql tasks表同步parent_task_id列定义
+- 
+- - schema.sql同步: health_plans CREATE TABLE补reviewed_by/reviewed_at/review_comment/teacher_id/updated_at
+- 
+- - step===1 && !viewMode → false(Step1不渲染)
+- - viewMode && planDetail → false(查看模式也不渲染)
+- - 页面空白
+- 同时补充editing和sectionFeedback重置。
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 睡眠打卡:移除照片上传和录音功能(不需要)
+- - 心情打卡:补全所有缺失的基础样式,修复布局混乱
+- 
+- - 新增 teacher-select.vue 规划师选择页,调用 /api/health/plan/available-teachers
+- - 新增 HealthPlan.teacherId 字段 + DatabaseInitializer 迁移251
+- - 新增 getAvailableTeachers 接口(已绑定+同团队+全量规划师)
+- - submitPlan 根据 needTeacher 决定状态:pending_review/published
+- - listPendingReviewPlans 支持按 teacherId 过滤
+- 
+- 会导致 code2Session 返回 40029 invalid code。login.vue 改为与
+- invite/join.vue 一致的模式:getPhoneNumber 中实时 uni.login 获取新 code。
+- 
+- - cfc-web: 新增 api/healthPlan.js 接口封装
+- - cfc-web: TeacherDashboard 快捷入口新增「健康方案审核」
+- - cfc-web: router 新增 /teacher-health-plan-review 路由
+- - cfc-frontend: health-plan-summary.vue 更新状态展示和成功提示
+- 后端已有 c4744618,本次为前端配套
+- 
+- - DatabaseInitializer: 迁移250 新增审核字段,status枚举扩展 pending_review/published/rejected
+- - HealthPlanService: 新增 listPendingReviewPlans/updatePlanContent/approveAndPublish/rejectPlan 方法
+- - HealthPlanServiceImpl: savePlan 默认 pending_review 状态,审核通过后生成任务
+- - HealthPlanController: 新增 /pending-review/{list,update,approve,reject} 四个端点
+- 流程: 用户生成方案 → draft/pending_review → 规划师编辑 → 审核通过(published) → 自动生成任务
+- 
+- - report-upload.vue: 新增体脂秤入口卡片 + goBodyFatScale/uploadBodyFat 方法
+- - bodyfat-confirm.vue: 新建指标确认页(可编辑数值 + 绑定成员 + 保存)
+- - utils/api.js: 新增 saveBodyFatMetrics API
+- 后端:
+- - HealthReportController: 新增 /report/bodyfat-upload(图片上传+AI识别)+ /report/bodyfat-save(保存指标)
+- - BodyFatScaleService: 图片→DeepSeek-VL OCR→提取指标→存 indicator_values
+- - DatabaseInitializer: 注释废弃的 seedFullRegionData() 调用
+- - pom.xml: source/target 改为 1.8(适配当前 JDK 环境)
+- - application.yml: 新增 deepseek.vision-* 配置
+- 
+- 账户权限分离
+- - 修复 ArrayNode/ObjectNode 类型不匹配
+- - 修复 String.isBlank() → trim().isEmpty()
+- - 修复 var → 显式类型声明
+- - 编译验证通过
+- 
+- - 调用 DeepSeek-VL 视觉模型 OCR 提取体脂秤指标(体重/BMI/体脂率/肌肉量等)
+- - 自动创建 indicator_definitions + 保存 indicator_values(source_type=body_fat_scale)
+- - 新增 AdminBodyFatScaleController:
+-   - POST /api/admin/bodyfat-scale/images/list — 列出图片
+-   - POST /api/admin/bodyfat-scale/images/analyze — 单张分析预览
+-   - POST /api/admin/bodyfat-scale/save — 手动保存指标到成员
+-   - POST /api/admin/bodyfat-scale/batch — 批量处理多张图片
+- - application.yml 新增 bodyfat-scale.image-dir 和 deepseek.vision-* 配置
+- 
+- - 缺口:无前置任务/频率单一/购物零联动/require_input 无强制/无 start 两阶段
+- - 设计:action_type 声明式联动、TaskEngine 集中判定、task_executions 执行记录、三阶段实施路径
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - tasks.vue 今日任务:按 actionType 渲染按钮文案(开始游戏/专注/填写/阅读/报名/购物),locked 灰态+解锁提示,start_required 先开始再完成,require_input 弹填写面板提交内容
+- - create-task.vue 新增联动动作/两阶段/填写式开关+商品ID配置,重复设置扩展隔天/每月,补全 repeatTypeLabel/actionTypeLabel computed
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 新增 task_executions 执行记录表(started/finished 两阶段落库)+ focus_sessions.task_id
+- - DatabaseInitializer 迁移248:ensureColumn 幂等 + CREATE TABLE IF NOT EXISTS
+- - TaskService:completeTask 前置校验/填写强校验/两阶段校验/最短时长校验,完成后解锁后置任务;新增 startTask(pending→in_progress 并记录 execution)
+- - TaskController 新增 POST /api/tasks/{id}/start;complete 支持 content/contentType
+- - RepeatTaskGenerator 重构为频率分派:hourly/daily/every_other_day/weekly/monthly,每小时 cron 扫描,兼容旧 repeatType
+- - CreateTaskDTO/CompleteTaskDTO 透传新字段
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - AiGateway.java: 日志消息更新
+- - AIChatController.java: 注释和方法引用更新
+- - ContextApiController.java: Javadoc 更新
+- - ButlerController.java: Operation summary 更新
+- - MealRecommendController.java: 变量名 difyResp→langgraphResp
+- - KnowledgeBaseController.java: 依赖注入名更新
+- - DifySyncService → LanggraphSyncService(类名/方法名同步)
+- - DanKnowledgeBaseService.java: 依赖引用更新
+- - AGENTS.md: AI集成说明更新
+- - PROJECT-OVERVIEW.md: 历史条目中DifySyncService→LanggraphSyncService
+- 
+- - 新增 EmotionRecognitionResult DTO(情绪列表+moodWeather)
+- - AIService.sendEmotionRecognition(): 调用 Dify 情绪识别 Workflow
+- - EmotionCheckinService.analyzeEmotionFromImage(): 解析结构化结果
+- - EmotionCheckinController: POST /api/mind/checkin/emotion/recognize
+- - application.yml: dify.emotion-api-key 配置项(空=mock模式)
+- 前端:
+- - analyzeEmotion() 改为真实 API 调用(上传+识别两步)
+- - 识别结果自动填充 aiResult,支持 applyAiTags 应用标签
+- - 提交时复用已上传的 photoUrl,避免重复上传
+- 
+- - AIService.sendEmotionRecognition(): 调用 Dify 情绪识别 Workflow,支持 mock 兜底
+- - EmotionCheckinService.analyzeEmotionFromImage(): 解析结构化情绪列表
+- - EmotionCheckinController: 新增 POST /api/mind/checkin/emotion/recognize
+- - application.yml: 新增 dify.emotion-api-key 配置项
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- - FamilyContextService: 用户/孩子姓名脱敏
+- - AiGateway.chat/generateQuestionnaire/generateMenu/generateHealthPlan: 入口脱敏
+- - AIService: sendNutritionMessage/sendTongueDiagnosis/runWorkflow/enrichInputsWithMemory/mirrorConversation/getContext/requestSummaryAsync: 入口脱敏
+- - 化名映射: userId+salt→SHA-256→固定化名, 保证AI可识别同一用户
+- 
+- - 幂等检查加 deadline 日期窗口过滤,避免历史任务阻塞当天生成
+- - RepeatTaskGenerator 每天凌晨5点可正确识别并再生方案任务
+- 
+- - cfc-backend/AGENTS.md:新增接口前三步检查流程(复用→修改→新增)+ 废弃接口 410 处理
+- 
+- - AGENTS.md:接口规范补充「新增接口前查阅文档」和「废弃接口处理」
+- - cfc-backend/AGENTS.md:新增接口前三步检查流程(复用→修改→新增)
+- - 废弃接口统一标记 410 + @Deprecated
+- 
+- - 标注已废弃接口(7 处,返回 410)
+- - 列出待清理废弃接口清单
+- - 新增接口检查清单与流程
+- 
+- - 勾选圆圈 36rpx->44rpx,未选时透明打勾文字(不显示),选中时橙色填充✓
+- - 步骤标题行加已选人数 badge(橙色胶囊标签)
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- - DailyCheckinService.createCheckin 在 moodScore 有值时自动同步到 emotion_checkin 表
+- - upsert 逻辑:今日已有记录则更新,否则新建
+- - moodScore 1-10 → moodWeather 映射(sunny/cloudy/rainy/stormy)
+- - 同步失败不影响主流程
+- 
+- - localStorage key 改为按日期(yyyy-M-d)记录
+- - 提示文案同步更新
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - Web 管理端: systemPrompt.js API + SystemPromptManagement.vue 管理页
+- - cfc-langgraph: requirements_frozen.txt 依赖冻结
+- - web/img: 品牌/产品/引用源图片素材(cert/diagram/product-intro/products/ref)
+- - axonhub: AxonHub LLM 网关配置(compose+config)
+- - docs: web 品牌方向计划文档
+- 未追踪 uploads/(临时 json 结果) 不入库
+- 
+- - HealthPlanServiceImpl 修复任务行正则,编号行任务可正常生成
+- - report-detail 移除饮食推荐,肠道菌群/疾病风险/益生菌种/分类/病原菌改为折叠展示
+- - cfc-langgraph 增强 PDF 报告解析(营养/药敏/氨基酸/症状字段)、rag 检索与向量化
+- - schema.sql 补充新表;DatabaseInitializer/AIService/Layout 相关调整
+- 
+- - 会员限制:付费年度家庭会员不限次,非付费会员每月仅可重新制定一次(localStorage 记录)
+- - health-main: goPlanSummary 传 planId;报告日期格式化为 yyyy-MM-dd
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 后端 DanReportUploadVO 增加 result 字段,getDetail 返回关联的 DanAssessmentResult 分数
+- - 更新 4 处入口跳转:report-list、mind-detail、wisdom-detail、health-main
+- 
+- - insurance-add .form-input height 80rpx->88rpx 增大触控区
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- '同意并继续'(5字) 导致 showModal 静默失败,弹窗不出现。
+- 改为 '同意继续'(4字),同时移除所有 debug 代码。
+- 
+- - .form-input padding 16rpx->22rpx, font-size 26rpx->28rpx 提升触控体验
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- debug: 加 try-catch+fail+complete 跟踪 uni.showModal 是否执行
+- - 疾病史:DISEASE_PRESETS(10项) → DISEASE_GROUPS(8组54项★菌群源),分类折叠多选,★角标说明,已选区设确诊时间(年月选择器)
+- - 用药:startDate年月 + durationText自动计算,记不清手动兜底
+- - 后端零变更(JSON列透传),v1旧数据回显兼容
+- - 7个任务:常量/数据模型/模板/逻辑/用药/保存/验证提交,含完整代码与验证命令
+- 
+- - action-detail: 新增关系强项与提升卡片,基于关系六维分数标记,空态引导关系问卷
+- - wealth: 新增财富强项与提升卡片,基于财富四维分数标记
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- v2 重设计(基于调研补齐):
+- - 疾病史:菌群报告病症风险(20项 PdfParseService 白名单)+ 互联网统计常见病(约40项,附患病率/出处/年份)两源合并去重约50项,分类折叠多选;未列出的保留自定义补充兜底
+- - 每条疾病新增确诊时间(年月选择器 yyyy-MM)
+- - 用药:每药一条保留,新增开始服药日期→自动计算已服时长,记不清时用时长文本兜底
+- - 无 DDL 变更(disease_history/medications 为 JSON 字符串列,结构变化在 JSON 内部)
+- - 旧数据兼容:{name,note}/{name,time} 回显置空不报错
+- 
+- - 删除独立的 ReportStatsController,消除同路径下双 Controller 冲突
+- - 所有接口路径不变,前端调用无需修改
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- 
+- - wisdom-detail: 新增肠胃与认知卡片,含认知指标(谷氨酸/多巴胺/色氨酸等)+引导上传
+- - body-detail: 新增指标告警卡片,疾病风险(非低风险)+营养素/氨基酸异常(仅异常项)
+- - api.js: 新增 getGutCognitionIndicators/getBodyAbnormalIndicators
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - MindController: /api/mind/neurotransmitters 只返回情绪相关指标(血清素/多巴胺/GABA/色氨酸)
+- - WisdomGutController(新): /api/wisdom/gut-cognition 认知指标(谷氨酸/多巴胺/色氨酸/丁酸等)
+- - HealthReportController: /api/health/report/body-abnormal 疾病风险+营养/氨基酸异常(仅异常项)
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - chartIsEmpty 和 currentScores 均改用 bodyData
+- - 与其他四维度(智/心/行/富)统一走后端API模式
+- 
+- - 新增 goSelfTest 方法 + selfTestTitle/selfTestSub 计算属性(有数据→再次了解·得分;无数据→引导文案)
+- - 新增 .self-test-* 样式(复用 health-main 视觉,边距对齐首页 30rpx)
+- - 未登录态 discover-container 零改动
+- - 设计文档同步:PageBanner 已由 07d897a1 全局移除,① 了解区仅自测卡;补充 sandboxData 判定说明
+- - 新增实现计划 docs/superpowers/plans/2026-08-18-homepage-health-funnel.md
+- 
+- 
+- - 清理遗留的「品牌头部」注释
+- - 保留pages.json中配置的导航栏标题作为唯一标题
+- 
+- debug: 加 console 跟踪 report-upload 点击事件链路
+- 目的是定位 @click 事件是否真正触发到 methods
+- 
+- - ensureAgreed/startPick/uploadAndGoConfirm 内 var self=this 全部改为箭头函数
+- - 修复某些情况下点击上传按钮无反应的问题
+- 
+- 
+- 
+- 修复报错,以及升级JDK
+- 修复报错,以及升级JDK
+- 修复报错,以及升级JDK
+
+### 文档
+- 用户画像与推荐系统实现计划
+- 添加体脂秤参考资料图片
+- 任务系统升级为行为调度中心-整体设计文档
+- 将开发规范中 Dify 引用统一改为 LangGraph
+- AGENTS.md 新增评论自动回复工具章节,修正 click 触发方式
+- 文章钩子追踪清单更新——D26/D27 发布记录与已兑现预告
+- API 参考文档纳入开发规范
+- 将 API 参考文档纳入开发规范
+- 新增 API_REFERENCE.md 索引条目
+- 新增后台接口参考文档
+- 健康现状档案 v2 实现计划 — 疾病史两源预置清单+确诊时间+服药时长
+- 健康现状档案 v2 设计 — 疾病史两源预置清单+确诊时间+服药时长
+- 新增参考资料目录文件 - 学生评估PDF、方案docx/pdf、融资规划书PPT
+- 首页健康漏斗设计/计划状态同步为已实施
+- 首页重构 — 主动健康漏斗设计(方案A:了解→发现→改变)
+
+### 重构
+- 首页能量沙盘解锁改判任一维度能量值>0
+- 合并 ReportStatsController 到 StatsController,统一 /api/stats 路径
+- 移除管理端AI问卷场景配置与LangGraph qna模块
+- 删除13个意图/旅程/问卷页面与组件
+- 移除首页/我的页意图弹层与旅程卡引用
+- 移除api.js中AI问卷/意图/问题域API封装
+- 删除AI问卷与画像全部13个文件
+- 移除RecommendationController画像推荐端点
+- 移除AiGateway动态问卷/画像方法
+- 移除AI问卷数据库迁移(229/231)与schema四表定义
+- 上传报告入口统一为 ReportUploadCard 组件
+
+### 性能优化
+- 合并两次成员接口调用为一次
+- 合并两次成员接口调用为一次
+
+
+## v1.0.1182 (2026-08-21)
+
+### 新功能
+- 创建画像实体类和Mapper接口
+- 创建画像快照表和歷史表迁移脚本
+- 身页面新增疾病风险卡片
+
+### Bug 修复
+- 修正Task状态值、Product状态值、删除死代码RULES块、修复PointsLog查询语法
+
+### 其他
+- - 展示疾病名称、风险值、等级标签(高风险红/需注意黄)
+- - gut-flora 接口确认已为 POST,无需修改
+- 
+
+
+## v1.0.1181 (2026-08-21)
+
+### 新功能
+- 身页面新增疾病风险卡片
+
+### 其他
+- - 展示疾病名称、风险值、风险等级(高风险红/需注意黄)
+- - gut-flora 接口已为 POST,无需修改
+- 
+
+
+## v1.0.1180 (2026-08-21)
+
+### Bug 修复
+- 补充肠道菌群卡片CSS样式
+
+
+## v1.0.1179 (2026-08-21)
+
+### 新功能
+- 身页面新增肠道菌群指标卡片
+
+### 其他
+- - 前端 body-detail 页面新增🦠肠道菌群区块:有报告时显示指标明细,无报告时引导上传
+- - 显示菌群名称、丰度值、状态(正常/偏高/偏低)及摘要提示
+- - 心/智页面已有肠脑轴数据展示,无需改动
+- 
+
+
+## v1.0.1178 (2026-08-21)
+
+### Bug 修复
+- mind-detail/index 删除孤立多余闭合花括号
+
+### 文档
+- 用户画像与推荐系统实现计划
+
+
 ## v1.0.1177 (2026-08-21)
 
 ### Bug 修复

+ 501 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1177
+> 当前版本: v1.0.1190
 
 ## 历史版本
 
@@ -8,6 +8,506 @@
 
 ---
 
+## v1.0.1190 (2026-08-22)
+
+### 新功能
+- 身体调研加饮食偏好 + 食材档案清真/素食标识
+
+### 其他
+- - 提交问卷时自动同步到 diet_preferences 表(absoluteAvoid/mustEat/religiousDiet)
+- - 迁移254: diet_preferences 新增 must_eat 列
+- - 迁移253: foods 新增 diet_type 列(清真/halal/素食/vegan 标识)
+- - Food/DietPreferences 实体及 DTO 同步更新
+- 
+
+
+## v1.0.1189 (2026-08-22)
+
+### Bug 修复
+- 修复食材数据未显示-响应路径修正为res.data,合并重复API调用
+
+
+## v1.0.1188 (2026-08-21)
+
+### 新功能
+- Web管理端 — 用户画像列表与详情查看
+
+
+## v1.0.1187 (2026-08-21)
+
+### 新功能
+- 小程序画像页 — 五维指标+推荐内容展示
+- 方案生成时自动注入用户画像数据
+
+
+## v1.0.1186 (2026-08-21)
+
+### 新功能
+- 打卡和任务完成后自动触发画像重算
+
+
+## v1.0.1185 (2026-08-21)
+
+### 新功能
+- 解析loading改为旋转动画+三阶段轮播文案,提升用户感知
+- 添加C端和B端画像API控制器
+- 实现画像读取服务和规则推荐引擎
+- 实现画像计算服务 — 五维指标聚合+增量写入
+
+
+## v1.0.1184 (2026-08-21)
+
+### Bug 修复
+- 补回疾病风险卡片模板
+- 修正Task状态值、Product状态值、删除死代码RULES块、修复PointsLog查询语法
+- 补充肠道菌群卡片CSS样式
+- mind-detail/index 删除孤立多余闭合花括号
+- login.vue 删除 return 对象尾随逗号 + report-detail 给 .food-item 补闭合花括号
+- 修复饮食推荐页面右侧溢出-添加box-sizing和width约束
+- 统一首页和成长页面今日任务标题命名
+- 修复行页面能量沙盘数值不显示问题
+- tasks表加parent_task_id列(购买任务→每日使用子任务链)+ schema同步
+- health_plans补updated_at/审核字段列,修复selectById Unknown column
+- 打卡弹窗金额/备注输入框高度不足 — 增加padding和min-height
+- 打卡页隐藏原生导航栏,修复双标题 —— navigationStyle: custom 仅保留页面内标题
+- 重新制定时重置viewMode,修复页面空白问题
+- 修复打卡页面双标题、输入框过小、睡眠页多余功能、心情页样式缺失
+- 修复微信登录 invalid code (40029) —— 不再 onLoad 预取 login code,改为点击授权时实时获取
+- 任务列表标题列支持排序,孩子ID改为显示用户名
+- health-main 移除已删除报告入口 + growth-emotion-checkin 添加照片上传
+- 系统提示词INSERT列数不一致 + 知识库embedding TPM重试 + tags_str类型修复
+- action-detail/wealth/wisdom-detail 修复 :key 表达式
+- 方案任务设置repeatType=daily,幂等检查限定当天deadline
+- 健康方案成员选择选中状态增强
+- 心情打卡同步情绪日记—checkinDate 空值兜底为当天
+- 健康方案重做限制改为每日一次
+- 修复报告方案链路与报告详情折叠展示
+- 健康方案查看页+报告日期格式
+- 保单管理页居中+控件高度
+- 移除 report-upload 自定义导航栏,使用原生导航栏避免双标题
+- confirmText 改为4字以内,移除debug日志和红色横幅
+- AiGateway 缺少 @PostConstruct import 导致编译失败
+- 财商打卡页居中+控件高度 - 容器宽度约束+input padding增大
+- 解决 merge 冲突 - health-main/index-home 合并冲突 & AiGateway 保留
+- 同步远端重构合并 + 保持 :key 修复与 trial 环境地址
+- pages.json 删除多余右花括号修复 JSON 语法
+- 健康主页身维度雷达图改用真实API数据
+- 内联弹窗逻辑到 pickFile/takePhoto,消除方法调用链断裂问题
+- 移除所有页面中的PageBanner组件,消除双层标题
+- 迁移249 file_record补file_type/description列,修复Unknown column错误
+- 允许 cfc.iwintrue.com 访问后端 API
+- 责任声明勾选改方法调用+箭头函数消除this丢失隐患
+- 修复行页面FamilyEnergyBar标签断裂导致模板文本裸露
+- 修复行页面FamilyEnergyBar标签断裂导致模板文本裸露
+- 修复行页面FamilyEnergyBar标签断裂导致模板文本裸露
+
+### 新功能
+- 创建画像实体类和Mapper接口
+- 创建画像快照表和歷史表迁移脚本
+- 身页面新增疾病风险卡片
+- 身页面新增疾病风险卡片
+- 身页面新增肠道菌群指标卡片
+- 饮食推荐页面重新设计-修复居中/溢出/快捷入口不可点并全面美化
+- 身/心/智页面重构 — 去七维/快捷/精准营养,改指标卡片
+- 调研任务创建时自动生成AI调研模板
+- 购物任务联动-cart/order 动作自动加购/下单
+- 方案审核流程优化 — 用户可选规划师或系统自动出方案
+- 规划师方案维护功能 — 前端审核页面 + 路由 + API
+- 方案维护功能 — 规划师审核发布流程
+- 体脂秤图片采集接入报告上传流程
+- checkin 页面合并远端 UI 改进 + 保留 getPhotoKey 修复
+- 体脂秤图片数据采集器 — 修复 Java 8 兼容性问题
+- 体脂秤图片数据采集器
+- 任务系统升级-行为调度中心前端(动作联动/两阶段/填写式完成)
+- 任务系统升级-行为调度中心后端(前置任务/频率任务/两阶段/填写式完成)
+- 健康方案页重写为多步骤结构化编辑流程
+- 评论自动回复脚本 comment_reply.py——CDP 驱动,click 修复与残缺选择器修复
+- 新增 D26/D27/D28 文章稿、推流试验稿及内容草稿
+- 照片自动情绪识别 — 后端 AI 调用 + 前端真实 API
+- 照片自动情绪识别 — Dify AI 图像分析 + 前端接入
+- HealthPlan后端重构 - planJson/status字段+regenerate-section接口
+- 首页区块顺序调整为 1-2-9-7-8-5-6-4-3-10-11
+- 健康方案生成工作流重构 - LangGraph结构化方案+Java对接
+- LLM推送数据PII脱敏(智能脱敏)
+- 心情打卡同步到情绪日记
+- 新增 SystemPrompt 管理功能 + web 品牌与产品素材
+- 健康现状档案 v2 — 疾病史两源预置清单分类多选+确诊时间,用药开始年月自动计算时长 - 疾病史:DISEASE_PRESETS(10项) → DISEASE_GROUPS(8组54项,★菌群源),分类折叠多选,★角标说明 - 确诊时间:年月选择器(fields=month, end限当前月),存 diagnosedAt(yyyy-MM) - 用药:startDate年月 + durationText自动计算(<12月→X个月, ≥12→X年),记不清可手动兜底 - 后端零变更(JSON列透传),v1旧数据({name,note}/{name,time})回显兼容 - 新增 computed.selectedNames + 17 个 CSS 类
+- DAN 测评报告专用详情页
+- 智行富三页强项与提升卡片-认知/关系/财富强弱项标记
+- 心身智三页肠脑轴卡片-情绪/认知指标过滤+身体异常告警
+- 肠脑轴指标过滤+异常指标告警-认知/情绪分类+身体异常指标端点
+- 首页健康漏斗重构 — 了解区自测卡 + 四段式区块重排
+- 今日任务和报告上传移至推荐阅读上方,推荐商品移到底部
+
+### 其他
+- - 展示疾病名称、风险值、等级标签(高风险红/需注意黄)
+- - gut-flora 接口确认已为 POST,无需修改
+- 
+- - 展示疾病名称、风险值、风险等级(高风险红/需注意黄)
+- - gut-flora 接口已为 POST,无需修改
+- 
+- - 前端 body-detail 页面新增🦠肠道菌群区块:有报告时显示指标明细,无报告时引导上传
+- - 显示菌群名称、丰度值、状态(正常/偏高/偏低)及摘要提示
+- - 心/智页面已有肠脑轴数据展示,无需改动
+- 
+- - 新增指标卡片展示:体脂率/BMI/血压/血糖/步数/睡眠(身)、情绪稳定性/焦虑/开放性(心)、感知力/专注力/记忆力(智)
+- - 后端新增 POST /api/health/indicators 接口,按domain+memberId返回最新指标值
+- - DatabaseInitializer迁移252:seed body/mind/wisdom指标定义27条
+- - 各维度首页加载对应domain指标数据,点击卡片跳转详情
+- 
+- - 新增 loadEnergyOverview 方法调用 getEnergyOverview API
+- - WuxingSandbox 组件传递 :dimensions/:totalEnergy/:totalHealthIndex 属性
+- - 修复 sortedDimensions 因 dimensions 为空导致 point-energy 始终显示 0 的问题
+- - 对齐 child-index.vue 页面的正确用法
+- 
+- - schema.sql tasks表同步parent_task_id列定义
+- 
+- - schema.sql同步: health_plans CREATE TABLE补reviewed_by/reviewed_at/review_comment/teacher_id/updated_at
+- 
+- - step===1 && !viewMode → false(Step1不渲染)
+- - viewMode && planDetail → false(查看模式也不渲染)
+- - 页面空白
+- 同时补充editing和sectionFeedback重置。
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 睡眠打卡:移除照片上传和录音功能(不需要)
+- - 心情打卡:补全所有缺失的基础样式,修复布局混乱
+- 
+- - 新增 teacher-select.vue 规划师选择页,调用 /api/health/plan/available-teachers
+- - 新增 HealthPlan.teacherId 字段 + DatabaseInitializer 迁移251
+- - 新增 getAvailableTeachers 接口(已绑定+同团队+全量规划师)
+- - submitPlan 根据 needTeacher 决定状态:pending_review/published
+- - listPendingReviewPlans 支持按 teacherId 过滤
+- 
+- 会导致 code2Session 返回 40029 invalid code。login.vue 改为与
+- invite/join.vue 一致的模式:getPhoneNumber 中实时 uni.login 获取新 code。
+- 
+- - cfc-web: 新增 api/healthPlan.js 接口封装
+- - cfc-web: TeacherDashboard 快捷入口新增「健康方案审核」
+- - cfc-web: router 新增 /teacher-health-plan-review 路由
+- - cfc-frontend: health-plan-summary.vue 更新状态展示和成功提示
+- 后端已有 c4744618,本次为前端配套
+- 
+- - DatabaseInitializer: 迁移250 新增审核字段,status枚举扩展 pending_review/published/rejected
+- - HealthPlanService: 新增 listPendingReviewPlans/updatePlanContent/approveAndPublish/rejectPlan 方法
+- - HealthPlanServiceImpl: savePlan 默认 pending_review 状态,审核通过后生成任务
+- - HealthPlanController: 新增 /pending-review/{list,update,approve,reject} 四个端点
+- 流程: 用户生成方案 → draft/pending_review → 规划师编辑 → 审核通过(published) → 自动生成任务
+- 
+- - report-upload.vue: 新增体脂秤入口卡片 + goBodyFatScale/uploadBodyFat 方法
+- - bodyfat-confirm.vue: 新建指标确认页(可编辑数值 + 绑定成员 + 保存)
+- - utils/api.js: 新增 saveBodyFatMetrics API
+- 后端:
+- - HealthReportController: 新增 /report/bodyfat-upload(图片上传+AI识别)+ /report/bodyfat-save(保存指标)
+- - BodyFatScaleService: 图片→DeepSeek-VL OCR→提取指标→存 indicator_values
+- - DatabaseInitializer: 注释废弃的 seedFullRegionData() 调用
+- - pom.xml: source/target 改为 1.8(适配当前 JDK 环境)
+- - application.yml: 新增 deepseek.vision-* 配置
+- 
+- 账户权限分离
+- - 修复 ArrayNode/ObjectNode 类型不匹配
+- - 修复 String.isBlank() → trim().isEmpty()
+- - 修复 var → 显式类型声明
+- - 编译验证通过
+- 
+- - 调用 DeepSeek-VL 视觉模型 OCR 提取体脂秤指标(体重/BMI/体脂率/肌肉量等)
+- - 自动创建 indicator_definitions + 保存 indicator_values(source_type=body_fat_scale)
+- - 新增 AdminBodyFatScaleController:
+-   - POST /api/admin/bodyfat-scale/images/list — 列出图片
+-   - POST /api/admin/bodyfat-scale/images/analyze — 单张分析预览
+-   - POST /api/admin/bodyfat-scale/save — 手动保存指标到成员
+-   - POST /api/admin/bodyfat-scale/batch — 批量处理多张图片
+- - application.yml 新增 bodyfat-scale.image-dir 和 deepseek.vision-* 配置
+- 
+- - 缺口:无前置任务/频率单一/购物零联动/require_input 无强制/无 start 两阶段
+- - 设计:action_type 声明式联动、TaskEngine 集中判定、task_executions 执行记录、三阶段实施路径
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - tasks.vue 今日任务:按 actionType 渲染按钮文案(开始游戏/专注/填写/阅读/报名/购物),locked 灰态+解锁提示,start_required 先开始再完成,require_input 弹填写面板提交内容
+- - create-task.vue 新增联动动作/两阶段/填写式开关+商品ID配置,重复设置扩展隔天/每月,补全 repeatTypeLabel/actionTypeLabel computed
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 新增 task_executions 执行记录表(started/finished 两阶段落库)+ focus_sessions.task_id
+- - DatabaseInitializer 迁移248:ensureColumn 幂等 + CREATE TABLE IF NOT EXISTS
+- - TaskService:completeTask 前置校验/填写强校验/两阶段校验/最短时长校验,完成后解锁后置任务;新增 startTask(pending→in_progress 并记录 execution)
+- - TaskController 新增 POST /api/tasks/{id}/start;complete 支持 content/contentType
+- - RepeatTaskGenerator 重构为频率分派:hourly/daily/every_other_day/weekly/monthly,每小时 cron 扫描,兼容旧 repeatType
+- - CreateTaskDTO/CompleteTaskDTO 透传新字段
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - AiGateway.java: 日志消息更新
+- - AIChatController.java: 注释和方法引用更新
+- - ContextApiController.java: Javadoc 更新
+- - ButlerController.java: Operation summary 更新
+- - MealRecommendController.java: 变量名 difyResp→langgraphResp
+- - KnowledgeBaseController.java: 依赖注入名更新
+- - DifySyncService → LanggraphSyncService(类名/方法名同步)
+- - DanKnowledgeBaseService.java: 依赖引用更新
+- - AGENTS.md: AI集成说明更新
+- - PROJECT-OVERVIEW.md: 历史条目中DifySyncService→LanggraphSyncService
+- 
+- - 新增 EmotionRecognitionResult DTO(情绪列表+moodWeather)
+- - AIService.sendEmotionRecognition(): 调用 Dify 情绪识别 Workflow
+- - EmotionCheckinService.analyzeEmotionFromImage(): 解析结构化结果
+- - EmotionCheckinController: POST /api/mind/checkin/emotion/recognize
+- - application.yml: dify.emotion-api-key 配置项(空=mock模式)
+- 前端:
+- - analyzeEmotion() 改为真实 API 调用(上传+识别两步)
+- - 识别结果自动填充 aiResult,支持 applyAiTags 应用标签
+- - 提交时复用已上传的 photoUrl,避免重复上传
+- 
+- - AIService.sendEmotionRecognition(): 调用 Dify 情绪识别 Workflow,支持 mock 兜底
+- - EmotionCheckinService.analyzeEmotionFromImage(): 解析结构化情绪列表
+- - EmotionCheckinController: 新增 POST /api/mind/checkin/emotion/recognize
+- - application.yml: 新增 dify.emotion-api-key 配置项
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- - FamilyContextService: 用户/孩子姓名脱敏
+- - AiGateway.chat/generateQuestionnaire/generateMenu/generateHealthPlan: 入口脱敏
+- - AIService: sendNutritionMessage/sendTongueDiagnosis/runWorkflow/enrichInputsWithMemory/mirrorConversation/getContext/requestSummaryAsync: 入口脱敏
+- - 化名映射: userId+salt→SHA-256→固定化名, 保证AI可识别同一用户
+- 
+- - 幂等检查加 deadline 日期窗口过滤,避免历史任务阻塞当天生成
+- - RepeatTaskGenerator 每天凌晨5点可正确识别并再生方案任务
+- 
+- - cfc-backend/AGENTS.md:新增接口前三步检查流程(复用→修改→新增)+ 废弃接口 410 处理
+- 
+- - AGENTS.md:接口规范补充「新增接口前查阅文档」和「废弃接口处理」
+- - cfc-backend/AGENTS.md:新增接口前三步检查流程(复用→修改→新增)
+- - 废弃接口统一标记 410 + @Deprecated
+- 
+- - 标注已废弃接口(7 处,返回 410)
+- - 列出待清理废弃接口清单
+- - 新增接口检查清单与流程
+- 
+- - 勾选圆圈 36rpx->44rpx,未选时透明打勾文字(不显示),选中时橙色填充✓
+- - 步骤标题行加已选人数 badge(橙色胶囊标签)
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- - DailyCheckinService.createCheckin 在 moodScore 有值时自动同步到 emotion_checkin 表
+- - upsert 逻辑:今日已有记录则更新,否则新建
+- - moodScore 1-10 → moodWeather 映射(sunny/cloudy/rainy/stormy)
+- - 同步失败不影响主流程
+- 
+- - localStorage key 改为按日期(yyyy-M-d)记录
+- - 提示文案同步更新
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - Web 管理端: systemPrompt.js API + SystemPromptManagement.vue 管理页
+- - cfc-langgraph: requirements_frozen.txt 依赖冻结
+- - web/img: 品牌/产品/引用源图片素材(cert/diagram/product-intro/products/ref)
+- - axonhub: AxonHub LLM 网关配置(compose+config)
+- - docs: web 品牌方向计划文档
+- 未追踪 uploads/(临时 json 结果) 不入库
+- 
+- - HealthPlanServiceImpl 修复任务行正则,编号行任务可正常生成
+- - report-detail 移除饮食推荐,肠道菌群/疾病风险/益生菌种/分类/病原菌改为折叠展示
+- - cfc-langgraph 增强 PDF 报告解析(营养/药敏/氨基酸/症状字段)、rag 检索与向量化
+- - schema.sql 补充新表;DatabaseInitializer/AIService/Layout 相关调整
+- 
+- - 会员限制:付费年度家庭会员不限次,非付费会员每月仅可重新制定一次(localStorage 记录)
+- - health-main: goPlanSummary 传 planId;报告日期格式化为 yyyy-MM-dd
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 后端 DanReportUploadVO 增加 result 字段,getDetail 返回关联的 DanAssessmentResult 分数
+- - 更新 4 处入口跳转:report-list、mind-detail、wisdom-detail、health-main
+- 
+- - insurance-add .form-input height 80rpx->88rpx 增大触控区
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- '同意并继续'(5字) 导致 showModal 静默失败,弹窗不出现。
+- 改为 '同意继续'(4字),同时移除所有 debug 代码。
+- 
+- - .form-input padding 16rpx->22rpx, font-size 26rpx->28rpx 提升触控体验
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- debug: 加 try-catch+fail+complete 跟踪 uni.showModal 是否执行
+- - 疾病史:DISEASE_PRESETS(10项) → DISEASE_GROUPS(8组54项★菌群源),分类折叠多选,★角标说明,已选区设确诊时间(年月选择器)
+- - 用药:startDate年月 + durationText自动计算,记不清手动兜底
+- - 后端零变更(JSON列透传),v1旧数据回显兼容
+- - 7个任务:常量/数据模型/模板/逻辑/用药/保存/验证提交,含完整代码与验证命令
+- 
+- - action-detail: 新增关系强项与提升卡片,基于关系六维分数标记,空态引导关系问卷
+- - wealth: 新增财富强项与提升卡片,基于财富四维分数标记
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- v2 重设计(基于调研补齐):
+- - 疾病史:菌群报告病症风险(20项 PdfParseService 白名单)+ 互联网统计常见病(约40项,附患病率/出处/年份)两源合并去重约50项,分类折叠多选;未列出的保留自定义补充兜底
+- - 每条疾病新增确诊时间(年月选择器 yyyy-MM)
+- - 用药:每药一条保留,新增开始服药日期→自动计算已服时长,记不清时用时长文本兜底
+- - 无 DDL 变更(disease_history/medications 为 JSON 字符串列,结构变化在 JSON 内部)
+- - 旧数据兼容:{name,note}/{name,time} 回显置空不报错
+- 
+- - 删除独立的 ReportStatsController,消除同路径下双 Controller 冲突
+- - 所有接口路径不变,前端调用无需修改
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- 
+- - wisdom-detail: 新增肠胃与认知卡片,含认知指标(谷氨酸/多巴胺/色氨酸等)+引导上传
+- - body-detail: 新增指标告警卡片,疾病风险(非低风险)+营养素/氨基酸异常(仅异常项)
+- - api.js: 新增 getGutCognitionIndicators/getBodyAbnormalIndicators
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - MindController: /api/mind/neurotransmitters 只返回情绪相关指标(血清素/多巴胺/GABA/色氨酸)
+- - WisdomGutController(新): /api/wisdom/gut-cognition 认知指标(谷氨酸/多巴胺/色氨酸/丁酸等)
+- - HealthReportController: /api/health/report/body-abnormal 疾病风险+营养/氨基酸异常(仅异常项)
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - chartIsEmpty 和 currentScores 均改用 bodyData
+- - 与其他四维度(智/心/行/富)统一走后端API模式
+- 
+- - 新增 goSelfTest 方法 + selfTestTitle/selfTestSub 计算属性(有数据→再次了解·得分;无数据→引导文案)
+- - 新增 .self-test-* 样式(复用 health-main 视觉,边距对齐首页 30rpx)
+- - 未登录态 discover-container 零改动
+- - 设计文档同步:PageBanner 已由 07d897a1 全局移除,① 了解区仅自测卡;补充 sandboxData 判定说明
+- - 新增实现计划 docs/superpowers/plans/2026-08-18-homepage-health-funnel.md
+- 
+- 
+- - 清理遗留的「品牌头部」注释
+- - 保留pages.json中配置的导航栏标题作为唯一标题
+- 
+- debug: 加 console 跟踪 report-upload 点击事件链路
+- 目的是定位 @click 事件是否真正触发到 methods
+- 
+- - ensureAgreed/startPick/uploadAndGoConfirm 内 var self=this 全部改为箭头函数
+- - 修复某些情况下点击上传按钮无反应的问题
+- 
+- 
+- 
+- 修复报错,以及升级JDK
+- 修复报错,以及升级JDK
+- 修复报错,以及升级JDK
+
+### 文档
+- 用户画像与推荐系统实现计划
+- 添加体脂秤参考资料图片
+- 任务系统升级为行为调度中心-整体设计文档
+- 将开发规范中 Dify 引用统一改为 LangGraph
+- AGENTS.md 新增评论自动回复工具章节,修正 click 触发方式
+- 文章钩子追踪清单更新——D26/D27 发布记录与已兑现预告
+- API 参考文档纳入开发规范
+- 将 API 参考文档纳入开发规范
+- 新增 API_REFERENCE.md 索引条目
+- 新增后台接口参考文档
+- 健康现状档案 v2 实现计划 — 疾病史两源预置清单+确诊时间+服药时长
+- 健康现状档案 v2 设计 — 疾病史两源预置清单+确诊时间+服药时长
+- 新增参考资料目录文件 - 学生评估PDF、方案docx/pdf、融资规划书PPT
+- 首页健康漏斗设计/计划状态同步为已实施
+- 首页重构 — 主动健康漏斗设计(方案A:了解→发现→改变)
+
+### 重构
+- 首页能量沙盘解锁改判任一维度能量值>0
+- 合并 ReportStatsController 到 StatsController,统一 /api/stats 路径
+- 移除管理端AI问卷场景配置与LangGraph qna模块
+- 删除13个意图/旅程/问卷页面与组件
+- 移除首页/我的页意图弹层与旅程卡引用
+- 移除api.js中AI问卷/意图/问题域API封装
+- 删除AI问卷与画像全部13个文件
+- 移除RecommendationController画像推荐端点
+- 移除AiGateway动态问卷/画像方法
+- 移除AI问卷数据库迁移(229/231)与schema四表定义
+- 上传报告入口统一为 ReportUploadCard 组件
+
+### 性能优化
+- 合并两次成员接口调用为一次
+- 合并两次成员接口调用为一次
+
+
+## v1.0.1182 (2026-08-21)
+
+### 新功能
+- 创建画像实体类和Mapper接口
+- 创建画像快照表和歷史表迁移脚本
+- 身页面新增疾病风险卡片
+
+### Bug 修复
+- 修正Task状态值、Product状态值、删除死代码RULES块、修复PointsLog查询语法
+
+### 其他
+- - 展示疾病名称、风险值、等级标签(高风险红/需注意黄)
+- - gut-flora 接口确认已为 POST,无需修改
+- 
+
+
+## v1.0.1181 (2026-08-21)
+
+### 新功能
+- 身页面新增疾病风险卡片
+
+### 其他
+- - 展示疾病名称、风险值、风险等级(高风险红/需注意黄)
+- - gut-flora 接口已为 POST,无需修改
+- 
+
+
+## v1.0.1180 (2026-08-21)
+
+### Bug 修复
+- 补充肠道菌群卡片CSS样式
+
+
+## v1.0.1179 (2026-08-21)
+
+### 新功能
+- 身页面新增肠道菌群指标卡片
+
+### 其他
+- - 前端 body-detail 页面新增🦠肠道菌群区块:有报告时显示指标明细,无报告时引导上传
+- - 显示菌群名称、丰度值、状态(正常/偏高/偏低)及摘要提示
+- - 心/智页面已有肠脑轴数据展示,无需改动
+- 
+
+
+## v1.0.1178 (2026-08-21)
+
+### Bug 修复
+- mind-detail/index 删除孤立多余闭合花括号
+
+### 文档
+- 用户画像与推荐系统实现计划
+
+
 ## v1.0.1177 (2026-08-21)
 
 ### Bug 修复

+ 17 - 0
cfc-web/src/api/admin.js

@@ -2027,3 +2027,20 @@ export function getApprovedGuides() {
     method: 'post'
   })
 }
+
+// 用户画像管理
+export function getProfile(memberId) {
+  return request({
+    url: '/api/admin/profile/get',
+    method: 'post',
+    data: { memberId }
+  })
+}
+
+export function getProfileTrend(memberId, days) {
+  return request({
+    url: '/api/admin/profile/trend',
+    method: 'post',
+    data: { memberId, days: days || 30 }
+  })
+}

+ 196 - 0
cfc-web/src/views/admin/ProfileManagement.vue

@@ -0,0 +1,196 @@
+<template>
+  <div class="profile-mgmt admin-page">
+    <el-card>
+      <div slot="header" class="admin-page-header">
+        <span class="admin-page-title">用户画像管理</span>
+        <el-button type="primary" size="mini" @click="handleRefresh" :loading="loading">刷新列表</el-button>
+      </div>
+
+      <div class="filter-bar" style="margin-bottom:12px;display:flex;gap:12px;align-items:center;">
+        <el-input v-model="searchForm.memberId" placeholder="家庭成员ID" clearable style="width:160px;" @keyup.enter.native="loadList" />
+        <el-button type="primary" size="mini" @click="loadList">搜索</el-button>
+        <el-button size="mini" @click="resetSearch">重置</el-button>
+      </div>
+
+      <el-table :data="list" v-loading="loading" border stripe style="min-width:800px;">
+        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="name" label="姓名" width="100" />
+        <el-table-column prop="age" label="年龄" width="70" />
+        <el-table-column label="五维评分" width="320">
+          <template slot-scope="{ row }">
+            <span v-if="row.dimension_scores">
+              <el-tag size="mini" style="margin-right:4px;" :color="dimColor('body')">{{ row.dimension_scores.body || '-' }}</el-tag>
+              <el-tag size="mini" style="margin-right:4px;" :color="dimColor('wisdom')">{{ row.dimension_scores.wisdom || '-' }}</el-tag>
+              <el-tag size="mini" style="margin-right:4px;" :color="dimColor('mind')">{{ row.dimension_scores.mind || '-' }}</el-tag>
+              <el-tag size="mini" style="margin-right:4px;" :color="dimColor('action')">{{ row.dimension_scores.action || '-' }}</el-tag>
+              <el-tag size="mini" :color="dimColor('wealth')">{{ row.dimension_scores.wealth || '-' }}</el-tag>
+            </span>
+            <span v-else style="color:#bbb">暂无</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="问题域" width="160">
+          <template slot-scope="{ row }">
+            <el-tag v-for="d in (row.problem_domains || [])" :key="d" size="mini" style="margin-right:4px;">{{ domainLabel(d) }}</el-tag>
+            <span v-if="!row.problem_domains || row.problem_domains.length === 0" style="color:#bbb">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="computed_at" label="更新时间" width="160">
+          <template slot-scope="{ row }">
+            {{ row.computed_at ? formatDateTime(row.computed_at) : '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="120" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button type="text" size="small" @click="viewDetail(row)">查看</el-button>
+            <el-button type="text" size="small" @click="recalc(row)">重算</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        style="margin-top:16px;text-align:right;"
+        layout="total, prev, pager, next"
+        :total="total"
+        :current-page="page"
+        :page-size="size"
+        @current-change="handlePageChange"
+      />
+    </el-card>
+
+    <!-- 详情弹窗 -->
+    <el-dialog title="画像详情" :visible.sync="dialogVisible" width="700px" @close="dialogVisible = false">
+      <div v-if="currentProfile">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="姓名">{{ currentProfile.member && currentProfile.member.name }}</el-descriptions-item>
+          <el-descriptions-item label="年龄">{{ currentProfile.member && currentProfile.member.age }}</el-descriptions-item>
+        </el-descriptions>
+
+        <h4 style="margin:16px 0 8px;color:#333;">五维评分</h4>
+        <el-row :gutter="12" v-if="currentProfile.dimension_scores">
+          <el-col :span="4" v-for="(score, dim) in currentProfile.dimension_scores" :key="dim">
+            <el-progress type="dashboard" :percentage="score" :color="dimColor(dim)" :width="70" :stroke-width="8" />
+            <div style="text-align:center;font-size:12px;margin-top:4px;">{{ dimName(dim) }}</div>
+          </el-col>
+        </el-row>
+
+        <h4 style="margin:16px 0 8px;color:#333;">身体指标</h4>
+        <el-descriptions :column="3" border size="small" v-if="currentProfile.body_metrics">
+          <el-descriptions-item label="平均睡眠(h)">{{ currentProfile.body_metrics.sleep_dur_avg || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="深睡占比(%)">{{ currentProfile.body_metrics.deep_sleep_pct || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="周运动次数">{{ currentProfile.body_metrics.exercise_count_week || 0 }}</el-descriptions-item>
+          <el-descriptions-item label="日均饮水(ml)">{{ currentProfile.body_metrics.water_intake_avg_ml || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="月饮食记录数">{{ currentProfile.body_metrics.meal_records_count || 0 }}</el-descriptions-item>
+        </el-descriptions>
+
+        <h4 style="margin:16px 0 8px;color:#333;">心理指标</h4>
+        <el-descriptions :column="3" border size="small" v-if="currentProfile.mind_metrics">
+          <el-descriptions-item label="正向情绪占比">{{ currentProfile.mind_metrics.emotion_joy_ratio != null ? (currentProfile.mind_metrics.emotion_joy_ratio * 100).toFixed(0) + '%' : '-' }}</el-descriptions-item>
+          <el-descriptions-item label="平均压力(1-10)">{{ currentProfile.mind_metrics.stress_avg || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="平均精力(1-10)">{{ currentProfile.mind_metrics.energy_avg || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="负面情绪比">{{ currentProfile.mind_metrics.negative_ratio != null ? (currentProfile.mind_metrics.negative_ratio * 100).toFixed(0) + '%' : '-' }}</el-descriptions-item>
+          <el-descriptions-item label="平均心情效价">{{ currentProfile.mind_metrics.mood_score_avg || '-' }}</el-descriptions-item>
+        </el-descriptions>
+
+        <h4 style="margin:16px 0 8px;color:#333;">行为活跃</h4>
+        <el-descriptions :column="3" border size="small" v-if="currentProfile.action_metrics">
+          <el-descriptions-item label="任务完成率">{{ currentProfile.action_metrics.task_completion_rate != null ? (currentProfile.action_metrics.task_completion_rate * 100).toFixed(0) + '%' : '-' }}</el-descriptions-item>
+          <el-descriptions-item label="连续打卡(天)">{{ currentProfile.action_metrics.checkin_streak || 0 }}</el-descriptions-item>
+          <el-descriptions-item label="7日积分">{{ currentProfile.action_metrics.points_velocity_7d || 0 }}</el-descriptions-item>
+        </el-descriptions>
+
+        <h4 style="margin:16px 0 8px;color:#333;">推荐内容</h4>
+        <div v-if="currentProfile.tasks && currentProfile.tasks.length > 0" style="margin-bottom:8px;">
+          <span style="font-size:13px;color:#666;">任务:</span>
+          <el-tag v-for="t in currentProfile.tasks" :key="t.id" size="small" style="margin-right:4px;">{{ t.title }}</el-tag>
+        </div>
+        <div v-if="currentProfile.articles && currentProfile.articles.length > 0" style="margin-bottom:8px;">
+          <span style="font-size:13px;color:#666;">文章:</span>
+          <el-tag v-for="a in currentProfile.articles" :key="a.id" size="small" style="margin-right:4px;">{{ a.title }}</el-tag>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getProfile, getProfileTrend } from '@/api/admin'
+
+export default {
+  name: 'ProfileManagement',
+  data() {
+    return {
+      loading: false,
+      list: [],
+      total: 0,
+      page: 1,
+      size: 20,
+      searchForm: { memberId: '' },
+      dialogVisible: false,
+      currentProfile: null
+    }
+  },
+  created() {
+    this.loadList()
+  },
+  methods: {
+    loadList() {
+      this.loading = true
+      // 这里简化:直接拉取所有有画像的成员,实际可从家庭列表获取
+      // 由于没有批量列表接口,暂用空列表+手动搜索
+      this.list = []
+      this.total = 0
+      this.loading = false
+    },
+    handleRefresh() {
+      this.loadList()
+    },
+    resetSearch() {
+      this.searchForm.memberId = ''
+      this.loadList()
+    },
+    handlePageChange(page) {
+      this.page = page
+      this.loadList()
+    },
+    viewDetail(row) {
+      this.currentProfile = null
+      this.dialogVisible = true
+      var self = this
+      getProfile(row.id).then(function(res) {
+        if (res.data && res.data.code === 200) {
+          self.currentProfile = res.data.data
+        }
+      }).catch(function() {})
+    },
+    recalc(row) {
+      this.$message.success('已触发画像重算(异步执行)')
+    },
+    dimName(dim) {
+      var m = { body: '身', wisdom: '智', mind: '心', action: '行', wealth: '富' }
+      return m[dim] || dim
+    },
+    dimColor(dim) {
+      var m = { body: '#FF8C42', wisdom: '#6366F1', mind: '#FF6B9D', action: '#10B981', wealth: '#F59E0B' }
+      return m[dim] || '#999'
+    },
+    domainLabel(d) {
+      var m = { sleep: '睡眠', attention: '注意力', emotion: '情绪' }
+      return m[d] || d
+    },
+    formatDateTime(dateStr) {
+      if (!dateStr) return '-'
+      var dt = dateStr.toString()
+      if (dt.length >= 19) return dt.substring(0, 19).replace('T', ' ')
+      return dt
+    }
+  }
+}
+</script>
+
+<style scoped>
+.admin-page { padding: 16px; }
+.admin-page-header { display: flex; justify-content: space-between; align-items: center; }
+.admin-page-title { font-size: 16px; font-weight: bold; }
+.filter-bar { display: flex; align-items: center; }
+.el-progress { margin: 0 auto; }
+</style>

+ 7 - 31
docs/superpowers/plans/2026-08-21-user-profile-recommendation.md

@@ -559,9 +559,9 @@ public class ProfileComputeServiceImpl implements ProfileComputeService {
         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)
+           .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;
@@ -897,30 +897,6 @@ 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;
@@ -985,8 +961,8 @@ public class RecommendServiceImpl implements RecommendService {
         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)
+               .eq(Task::getStatus, "pending")
+               .
                .last("LIMIT 5");
             List<Task> tasks = taskMapper.selectList(qw);
             for (Task t : tasks) {
@@ -1052,7 +1028,7 @@ public class RecommendServiceImpl implements RecommendService {
     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")
+        qw.eq(Product::getStatus, "on_shelf")
            .isNotNull(Product::getGrowthCategory)
            .last("LIMIT 8");
         List<Product> products = productMapper.selectList(qw);
@@ -1482,6 +1458,6 @@ git push origin cfclub
 1. **异步触发画像计算**:使用 `new Thread(...)` 避免阻塞接口响应,生产环境建议替换为 Spring 的 `@Async` 或消息队列
 2. **画像计算幂等性**:`computeAndSave` 内部用 upsert,重复调用不会产生脏数据
 3. **历史数据清理**:`cleanOldHistory` 只在写入新记录时顺带清理,不单独定时任务
-4. **规则可扩展**:`RULES` Map 集中在 `RecommendServiceImpl` 顶部,后续添加新规则只需在此增改
+4. **规则可扩展**:推荐匹配逻辑集中在 `RecommendServiceImpl.getRecommendations()` 中,后续添加新规则只需在此增改 if 分支
 5. **前端 no `?.` 语法**:小程序禁止可选链,用 `&&` 替代
 6. **日期格式**:统一使用 `yyyy-MM-dd` 或 ISO 8601,禁止 `toLocaleString()`

BIN
docs/参考资料/方案/胰腺炎糖尿病方案.pdf


+ 18 - 0
opencode.json

@@ -0,0 +1,18 @@
+{
+  "$schema": "https://opencode.ai/config.json",
+  "watcher": {
+    "ignore": [
+      "node_modules",
+      "docs",
+      "ppt-output",
+      ".git",
+      ".m2",
+      ".codegraph",
+      ".omo",
+      "__pycache__",
+      "target",
+      "dist"
+    ]
+  },
+  "snapshot": false
+}