# 身体健康7维模型 — Phase 1 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现7维健康子维度的核心存储、评分聚合、菌群报告映射和问卷兜底前端,使所有用户可查看7维雷达图。 **架构:** 后端 Spring Boot 2.7.18 + MyBatis-Plus, 前端 uni-app Vue 2 Options API。DimensionScoreService 为核心聚合器,读取多数据源按优先级计算各维度得分,查常模表输出百分位。 **Tech Stack:** Java 8, Spring Boot, MyBatis-Plus, MySQL 8.0, uni-app Vue 2 --- ## Context ### 设计文档 设计详见 `.sisyphus/drafts/health-dimensions-design.md`,本计划实施其中的 **Phase 1**(核心框架 + 问卷兜底 + 菌群报告映射)。 ### 7维代码枚举 ```java public enum HealthDimension { GROWTH("growth", "生长发育"), SLEEP("sleep", "睡眠质量"), VISION("vision", "视力健康"), IMMUNITY("immunity", "免疫力"), NUTRITION("nutrition", "营养均衡"), GUT("gut", "肠胃健康"), EXERCISE("exercise", "运动活力"); public final String code; // DB中使用的键 public final String name; // 中文名 } ``` ### 数据源优先级 - Tier 1 (REPORT/PHOTO/AUTO): 自动/拍照数据 → 覆盖低层级 - Tier 2 (VOICE): 语音解析 → 置信度<0.6时降级 - Tier 3 (MANUAL): 问卷/手动输入 → 兜底 ### 常模百分位颜色 - ≥85 → 优秀 (深绿) - 60-84 → 良好 (浅绿) - 40-59 → 一般 (黄) - <40 → 关注 (橙/红) ### Existing Patterns to Follow - 实体: `@Data`, `@TableName`, `@TableId(type = IdType.AUTO)`, implements `Serializable` - Mapper: 继承 `BaseMapper`,`@Mapper` 注解 - Service: 接口 + impl 模式,`@Resource` 注入 - Controller: `@RestController`, `@RequestMapping("/api/health")`, 统一 `@PostMapping` - 响应: `Result` (code/message/data) - 前端: Vue 2 Options API,`methods: {}`,`data() { return {} }`,禁止可选链 `?.` --- ## File Structure ### New Backend Files | # | File | Responsibility | |---|------|---------------| | 1 | `entity/HealthDimensionScore.java` | 维度评分记录实体 | | 2 | `entity/HealthDataSourceRecord.java` | 数据源上传记录实体 | | 3 | `entity/HealthNormReference.java` | 常模对照表实体 | | 4 | `mapper/HealthDimensionScoreMapper.java` | 维度评分 Mapper | | 5 | `mapper/HealthDataSourceRecordMapper.java` | 数据源记录 Mapper | | 6 | `mapper/HealthNormReferenceMapper.java` | 常模 Mapper | | 7 | `service/HealthDimensionScoreService.java` | 维度评分基础 CRUD 接口 | | 8 | `service/impl/HealthDimensionScoreServiceImpl.java` | 维度评分基础 CRUD 实现 | | 9 | `service/DimensionScoreService.java` | 核心聚合器接口(多数据源合并+百分位计算) | | 10 | `service/impl/DimensionScoreServiceImpl.java` | 核心聚合器实现 | | 11 | `controller/HealthDimensionController.java` | 7维评分 API | | 12 | `dto/HealthDimensionVO.java` | 7维响应 DTO | | 13 | `dto/DimensionUploadVO.java` | 数据源上传 DTO | | 14 | `dto/HealthDimensionQuestionnaireVO.java` | 问卷提交 DTO | ### Modified Backend Files | # | File | Change | |---|------|--------| | 15 | `config/DatabaseInitializer.java` | 添加 migration: 创建3张表 + 常模种子数据 | ### New Frontend Files | # | File | Responsibility | |---|------|---------------| | 16 | `pages/body/health-dimensions.vue` | 7维雷达图 + 问卷入口页面 | ### Modified Frontend Files | # | File | Change | |---|------|--------| | 17 | `pages.json` | 注册 `pages/body/health-dimensions` 路由 | --- ## Parallel Execution Waves ``` Wave 1 (Foundation — 3 parallel tasks): ├── Task 1: DB migration + 实体 + Mapper (3表3实体3Mapper) ├── Task 2: HealthDimensionScoreService (CRUD) └── Task 3: 常模种子数据 (BMI/睡眠/运动常模) Wave 2 (Core Engine — 3 parallel tasks): ├── Task 4: DimensionScoreService (聚合器核心逻辑) ├── Task 5: HealthDimensionController (API 端点) └── Task 6: 菌群报告映射集成 (复用现有解析结果写入新表) Wave 3 (Frontend): ├── Task 7: health-dimensions.vue (雷达图+数据源上传+问卷) └── Task 8: pages.json路由 + 页面导航 Wave FINAL (Verification): ├── F1: 编译+数据库初始化验证 ├── F2: API 端点测试 (curl) ├── F3: 前端页面验证 └── F4: Plan compliance + scope fidelity check ``` --- ## TODOs - [ ] 1. 数据库迁移 + 3个实体类 + 3个Mapper **What to do**: 在 `DatabaseInitializer.runMigrations()` 中添加3张表的 CREATE TABLE IF NOT EXISTS,然后创建对应的 Entity 和 Mapper 类。 **Step 1: DB Migration in DatabaseInitializer.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` 在 `runMigrations()` 方法末尾添加(定位到最后一个 migration 之后,注意该文件尾部有 `run()` 方法): ```java // === 7维健康子维度系统 (Phase 1) === migrate("V20260625_01__create_health_dimension_tables", () -> { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_dimension_scores (\n" + " id BIGINT AUTO_INCREMENT PRIMARY KEY,\n" + " member_id BIGINT NOT NULL COMMENT '家庭成员ID',\n" + " dimension VARCHAR(20) NOT NULL COMMENT '维度: growth/sleep/vision/immunity/nutrition/gut/exercise',\n" + " score INT NOT NULL COMMENT '0-100分',\n" + " percentile INT COMMENT '人群百分位 0-100',\n" + " data_source VARCHAR(20) NOT NULL COMMENT '数据源: REPORT/PHOTO/VOICE/MANUAL',\n" + " tier TINYINT NOT NULL DEFAULT 3 COMMENT '数据层级: 1自动/2语音/3手动',\n" + " raw_data JSON COMMENT '原始数据快照',\n" + " assess_date DATE NOT NULL COMMENT '评估日期',\n" + " expire_date DATE COMMENT '数据过期日',\n" + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n" + " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n" + " UNIQUE KEY uk_member_dim_date (member_id, dimension, assess_date)\n" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康维度评分表'"); jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_data_sources (\n" + " id BIGINT AUTO_INCREMENT PRIMARY KEY,\n" + " member_id BIGINT NOT NULL,\n" + " source_type VARCHAR(20) NOT NULL COMMENT 'PDF/PHOTO/VOICE/MANUAL/WEIXIN_STEP',\n" + " dimension_codes JSON NOT NULL COMMENT '影响的维度列表',\n" + " parsed_result JSON COMMENT '解析结果全量',\n" + " file_url VARCHAR(500) COMMENT '原始文件URL',\n" + " confidence DECIMAL(3,2) COMMENT 'AI解析置信度',\n" + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n" + " INDEX idx_member_source (member_id, source_type)\n" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据源记录表'"); jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_norm_reference (\n" + " id BIGINT AUTO_INCREMENT PRIMARY KEY,\n" + " dimension VARCHAR(20) NOT NULL COMMENT '维度',\n" + " gender VARCHAR(10) NOT NULL COMMENT 'male/female/all',\n" + " age_min INT NOT NULL COMMENT '年龄下限(月)',\n" + " age_max INT NOT NULL COMMENT '年龄上限(月)',\n" + " percentile_5 INT,\n" + " percentile_15 INT,\n" + " percentile_25 INT,\n" + " percentile_50 INT COMMENT '中位值',\n" + " percentile_75 INT,\n" + " percentile_85 INT,\n" + " percentile_95 INT,\n" + " source VARCHAR(50) COMMENT '常模来源',\n" + " UNIQUE KEY uk_dim_gender_age (dimension, gender, age_min, age_max)\n" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康常模对照表'"); log.info("健康维度表创建完成"); return true; }); ``` > **注意**: `DatabaseInitializer.java` 使用 `jdbcTemplate` 执行 SQL,且以 `migrate("V...", () -> { ... })` 模式运行。确认找到文件中其他 `migrate` 调用的位置和格式,保持风格一致。 **Step 2: Create entity/HealthDimensionScore.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDimensionScore.java` ```java package com.etotem.cfc.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.util.Date; @Data @TableName("health_dimension_scores") public class HealthDimensionScore implements Serializable { @TableId(type = IdType.AUTO) private Long id; private Long memberId; private String dimension; // growth/sleep/vision/immunity/nutrition/gut/exercise private Integer score; // 0-100 private Integer percentile; // 常模百分位 0-100 private String dataSource; // REPORT/PHOTO/VOICE/MANUAL private Integer tier; // 1/2/3 private String rawData; // JSON private Date assessDate; private Date expireDate; private Date createdAt; private Date updatedAt; } ``` **Step 3: Create entity/HealthDataSourceRecord.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDataSourceRecord.java` ```java package com.etotem.cfc.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; @Data @TableName("health_data_sources") public class HealthDataSourceRecord implements Serializable { @TableId(type = IdType.AUTO) private Long id; private Long memberId; private String sourceType; // PDF/PHOTO/VOICE/MANUAL/WEIXIN_STEP private String dimensionCodes; // JSON array private String parsedResult; // JSON private String fileUrl; private BigDecimal confidence; private Date createdAt; } ``` **Step 4: Create entity/HealthNormReference.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/entity/HealthNormReference.java` ```java package com.etotem.cfc.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.util.Date; @Data @TableName("health_norm_reference") public class HealthNormReference implements Serializable { @TableId(type = IdType.AUTO) private Long id; private String dimension; private String gender; // male/female/all private Integer ageMin; // 月 private Integer ageMax; // 月 private Integer percentile5; private Integer percentile15; private Integer percentile25; private Integer percentile50; private Integer percentile75; private Integer percentile85; private Integer percentile95; private String source; private Date createdAt; } ``` **Step 5: Create mapper/HealthDimensionScoreMapper.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDimensionScoreMapper.java` ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.HealthDimensionScore; import org.apache.ibatis.annotations.Mapper; @Mapper public interface HealthDimensionScoreMapper extends BaseMapper {} ``` **Step 6: Create mapper/HealthDataSourceRecordMapper.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDataSourceRecordMapper.java` ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.HealthDataSourceRecord; import org.apache.ibatis.annotations.Mapper; @Mapper public interface HealthDataSourceRecordMapper extends BaseMapper {} ``` **Step 7: Create mapper/HealthNormReferenceMapper.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthNormReferenceMapper.java` ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.HealthNormReference; import org.apache.ibatis.annotations.Mapper; @Mapper public interface HealthNormReferenceMapper extends BaseMapper {} ``` **Verification:** - [ ] `mvn clean compile` 编译通过 - [ ] `mvn spring-boot:run` 启动后检查 health_dimension_scores / health_data_sources / health_norm_reference 三张表已创建 **QA Scenarios:** ``` Scenario: Compilation verification Tool: Bash Steps: 1. cd /sc-data/cfc/cfc-backend && mvn clean compile Expected Result: BUILD SUCCESS — 所有新实体和Mapper编译通过 Evidence: .sisyphus/evidence/task-1-compile.txt Scenario: DB table creation verification Tool: Bash Steps: 1. 启动应用: cd /sc-data/cfc/cfc-backend && mvn spring-boot:run 2. 等待 Started 日志出现 (timeout 60s) 3. 检查表结构: mysql -h 192.168.16.251 -u cfc -pcfc123 cfc -e "SHOW TABLES LIKE 'health_%'" Expected Result: health_dimension_scores, health_data_sources, health_norm_reference 三表存在 Evidence: .sisyphus/evidence/task-1-tables.txt ``` **Commit**: YES - Message: `feat(health): add health dimension score entities and DB tables` - Files: `cfc-backend/.../config/DatabaseInitializer.java`, `cfc-backend/.../entity/HealthDimensionScore.java`, `entity/HealthDataSourceRecord.java`, `entity/HealthNormReference.java`, `mapper/*.java` - [ ] 2. HealthDimensionScoreService CRUD 基础服务 **What to do**: 创建 Service 接口和实现,提供对 `health_dimension_scores` 表的基础 CRUD 操作,以及按 member+维度+日期查询的功能。 **Step 1: Create service/HealthDimensionScoreService.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/service/HealthDimensionScoreService.java` ```java package com.etotem.cfc.service; import com.etotem.cfc.entity.HealthDimensionScore; import java.util.Date; import java.util.List; public interface HealthDimensionScoreService { /** 保存或更新某成员某维度的评分(按 member_id + dimension + assess_date 唯一键 upsert) */ void saveScore(HealthDimensionScore score); /** 获取某成员所有维度最新评分 */ List getLatestScores(Long memberId); /** 获取某成员某维度的最新评分 */ HealthDimensionScore getLatestScore(Long memberId, String dimension); /** 获取某成员某维度的历史评分(按日期降序) */ List getHistoryScores(Long memberId, String dimension, int limit); /** 标记过期数据 */ void markExpired(Long memberId, String dimension); } ``` **Step 2: Create service/impl/HealthDimensionScoreServiceImpl.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDimensionScoreServiceImpl.java` ```java package com.etotem.cfc.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.etotem.cfc.entity.HealthDimensionScore; import com.etotem.cfc.mapper.HealthDimensionScoreMapper; import com.etotem.cfc.service.HealthDimensionScoreService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Date; import java.util.List; @Slf4j @Service public class HealthDimensionScoreServiceImpl implements HealthDimensionScoreService { @Resource private HealthDimensionScoreMapper healthDimensionScoreMapper; @Override public void saveScore(HealthDimensionScore score) { // 按唯一键查询是否已有记录 LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(HealthDimensionScore::getMemberId, score.getMemberId()) .eq(HealthDimensionScore::getDimension, score.getDimension()) .eq(HealthDimensionScore::getAssessDate, score.getAssessDate()); HealthDimensionScore existing = healthDimensionScoreMapper.selectOne(wrapper); if (existing != null) { score.setId(existing.getId()); score.setUpdatedAt(new Date()); healthDimensionScoreMapper.updateById(score); } else { score.setCreatedAt(new Date()); score.setUpdatedAt(new Date()); healthDimensionScoreMapper.insert(score); } } @Override public List getLatestScores(Long memberId) { // 子查询: 取每个 dimension 的最新 assess_date // 先用 group by 方式简化 LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(HealthDimensionScore::getMemberId, memberId) .orderByDesc(HealthDimensionScore::getAssessDate); List all = healthDimensionScoreMapper.selectList(wrapper); // 只取每个维度的最新一条 return all.stream() .filter(score -> { // 去重: 只保留相同 dimension 的第一个(已按日期降序) return all.stream() .filter(s -> s.getDimension().equals(score.getDimension())) .findFirst() .map(s -> s.getId().equals(score.getId())) .orElse(false); }) .toList(); } @Override public HealthDimensionScore getLatestScore(Long memberId, String dimension) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(HealthDimensionScore::getMemberId, memberId) .eq(HealthDimensionScore::getDimension, dimension) .orderByDesc(HealthDimensionScore::getAssessDate) .last("LIMIT 1"); return healthDimensionScoreMapper.selectOne(wrapper); } @Override public List getHistoryScores(Long memberId, String dimension, int limit) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(HealthDimensionScore::getMemberId, memberId) .eq(HealthDimensionScore::getDimension, dimension) .orderByDesc(HealthDimensionScore::getAssessDate) .last("LIMIT " + limit); return healthDimensionScoreMapper.selectList(wrapper); } @Override public void markExpired(Long memberId, String dimension) { LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() .eq(HealthDimensionScore::getMemberId, memberId) .eq(HealthDimensionScore::getDimension, dimension) .set(HealthDimensionScore::getExpireDate, new Date()); healthDimensionScoreMapper.update(null, wrapper); } } ``` **Verification:** - [ ] `mvn clean compile` 编译通过 **QA Scenarios:** ``` Scenario: Service compilation Tool: Bash Steps: 1. cd /sc-data/cfc/cfc-backend && mvn clean compile Expected Result: BUILD SUCCESS Evidence: .sisyphus/evidence/task-2-compile.txt ``` **Commit**: NO (groups with Task 1) - [ ] 3. 常模种子数据(BMI / 睡眠 / 运动参考值) **What to do**: 在 `DatabaseInitializer.runMigrations()` 中(同一个 V20260625_01 migration 内或作为第二个 migration),插入常模种子数据。先插入 BMI-for-age 百分位种子数据(中国标准简化版)、睡眠推荐时长、运动推荐量。 需要注意:`DatabaseInitializer` 的 `runMigrations()` 方法有一个设计:如果某个 migration 执行失败抛出异常,后续不会继续执行。因此最好将数据插入放在同一个 migration lambda 内的表创建之后,或者单独一个 migration。 **Step 1: 在 DatabaseInitializer.java 的 `runMigrations()` 中,在表创建后追加常模种子数据** 定位到刚才添加的 migration lambda 末尾(在 `log.info(...)` 和 `return true` 之间追加): ```java // 插入常模种子数据 (BMI-for-age 百分位, 简化版 6-18岁) // 数据来源: 中国卫健委 WS/T 586-2018 学龄儿童青少年超重与肥胖筛查 // 此处只插入了部分关键年龄点作为启动数据,运营后可扩展 String insertBmiNorm = "INSERT IGNORE INTO health_norm_reference " + "(dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES "; // 男孩 BMI-for-age 百分位 (年龄按月计, 6岁=72月, 18岁=216月) // 此处列出关键年龄点(6/8/10/12/14/16/18岁)的P50值作为种子 // 性别: male, 年龄: 72-83月(6岁) jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 72, 83, 13.2, 13.9, 14.5, 15.5, 16.8, 17.8, 19.5, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 84, 95, 13.3, 14.0, 14.7, 15.7, 17.0, 18.0, 19.8, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 96, 107, 13.4, 14.2, 14.9, 16.0, 17.4, 18.5, 20.4, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 108, 119, 13.6, 14.4, 15.2, 16.4, 17.9, 19.1, 21.2, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 120, 131, 13.9, 14.8, 15.6, 16.9, 18.5, 19.8, 22.1, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 132, 143, 14.3, 15.2, 16.1, 17.5, 19.3, 20.8, 23.3, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 144, 155, 14.7, 15.7, 16.7, 18.2, 20.1, 21.7, 24.4, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 156, 167, 15.1, 16.2, 17.2, 18.8, 20.9, 22.6, 25.4, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 168, 179, 15.5, 16.6, 17.7, 19.4, 21.5, 23.3, 26.2, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 180, 191, 15.8, 17.0, 18.1, 19.8, 22.0, 23.8, 26.8, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 192, 203, 16.0, 17.2, 18.3, 20.1, 22.3, 24.2, 27.2, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'male', 204, 216, 16.2, 17.4, 18.5, 20.3, 22.5, 24.4, 27.5, 'WS/T 586-2018')"); // 女孩 BMI-for-age 百分位 jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 72, 83, 12.8, 13.6, 14.2, 15.3, 16.6, 17.6, 19.3, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 84, 95, 12.9, 13.7, 14.4, 15.5, 16.8, 17.9, 19.7, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 96, 107, 13.0, 13.8, 14.5, 15.7, 17.1, 18.2, 20.2, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 108, 119, 13.1, 14.0, 14.8, 16.0, 17.5, 18.7, 20.9, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 120, 131, 13.4, 14.3, 15.1, 16.4, 18.0, 19.4, 21.7, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 132, 143, 13.8, 14.7, 15.6, 17.0, 18.7, 20.2, 22.8, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 144, 155, 14.2, 15.2, 16.1, 17.6, 19.4, 21.1, 23.8, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 156, 167, 14.6, 15.6, 16.6, 18.2, 20.1, 21.9, 24.8, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 168, 179, 15.0, 16.1, 17.1, 18.8, 20.8, 22.6, 25.6, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 180, 191, 15.3, 16.4, 17.5, 19.2, 21.3, 23.2, 26.3, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 192, 203, 15.5, 16.7, 17.8, 19.5, 21.7, 23.6, 26.7, 'WS/T 586-2018')"); jdbcTemplate.execute(insertBmiNorm + "('growth', 'female', 204, 216, 15.7, 16.9, 18.0, 19.8, 22.0, 23.9, 27.1, 'WS/T 586-2018')"); // 睡眠推荐时长常模 (不分性别) String insertSleepNorm = "INSERT IGNORE INTO health_norm_reference " + "(dimension, gender, age_min, age_max, percentile_50, source) VALUES "; // 学龄前 (3-5岁): 10-13h, 学龄 (6-13岁): 9-11h, 青少年 (14-17岁): 8-10h jdbcTemplate.execute(insertSleepNorm + "('sleep', 'all', 36, 59, 11, 'NSF')"); jdbcTemplate.execute(insertSleepNorm + "('sleep', 'all', 60, 71, 11, 'NSF')"); jdbcTemplate.execute(insertSleepNorm + "('sleep', 'all', 72, 95, 10, 'NSF')"); jdbcTemplate.execute(insertSleepNorm + "('sleep', 'all', 96, 119, 10, 'NSF')"); jdbcTemplate.execute(insertSleepNorm + "('sleep', 'all', 120, 155, 9, 'NSF')"); jdbcTemplate.execute(insertSleepNorm + "('sleep', 'all', 156, 167, 9, 'NSF')"); jdbcTemplate.execute(insertSleepNorm + "('sleep', 'all', 168, 203, 8, 'NSF')"); jdbcTemplate.execute(insertSleepNorm + "('sleep', 'all', 204, 216, 8, 'NSF')"); log.info("常模种子数据插入完成"); ``` > **注意**: 实际编写时,确保 `INSERT IGNORE INTO` 语法与 MySQL 兼容。如果 `DatabaseInitializer` 中的 `jdbcTemplate` 不支持执行多个 execute 调用,可以使用单个 execute 拼接多条 VALUES。观察文件中其他 migration 的写法保持一致。 **Verification:** - [ ] `mvn clean compile` 编译通过 - [ ] 启动后 `SELECT COUNT(*) FROM health_norm_reference WHERE dimension='growth'` 行数>10 **QA Scenarios:** ``` Scenario: Norm seed data verification Tool: Bash Steps: 1. 启动应用 2. mysql -h 192.168.16.251 -u cfc -pcfc123 cfc -e "SELECT dimension, COUNT(*) FROM health_norm_reference GROUP BY dimension" Expected Result: growth 维有记录(>10行), sleep 维有记录(8行) Evidence: .sisyphus/evidence/task-3-norm-seed.txt ``` **Commit**: YES - Message: `feat(health): add norm reference seed data (BMI, sleep)` - Files: `cfc-backend/.../config/DatabaseInitializer.java` - [ ] 4. DimensionScoreService — 核心聚合器 **What to do**: 创建 DimensionScoreService,这是整个7维体系的核心。它负责: 1. 收集某成员所有维度的活跃数据源 2. 按Tier优先级合并,高Tier覆盖低Tier 3. 查询常模表计算百分位 4. 持久化到 health_dimension_scores **Step 1: Create DTO — HealthDimensionVO.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/dto/HealthDimensionVO.java` ```java package com.etotem.cfc.dto; import java.util.List; /** 7维评分响应 */ public class HealthDimensionVO { private List dimensions; private String assessDate; // 评估日期 public static class DimensionItem { private String code; // growth/sleep/... private String name; // 生长发育/睡眠质量/... private Integer score; // 0-100 private Integer percentile; // 常模百分位 private Integer tier; // 数据层级 1/2/3 private String level; // excellent/good/average/concern private String levelLabel; // 优秀/良好/一般/关注 private Integer dataSourceCount; // 活跃数据源数 // getters/setters public String getCode() { return code; } public void setCode(String code) { this.code = code; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getScore() { return score; } public void setScore(Integer score) { this.score = score; } public Integer getPercentile() { return percentile; } public void setPercentile(Integer percentile) { this.percentile = percentile; } public Integer getTier() { return tier; } public void setTier(Integer tier) { this.tier = tier; } public String getLevel() { return level; } public void setLevel(String level) { this.level = level; } public String getLevelLabel() { return levelLabel; } public void setLevelLabel(String levelLabel) { this.levelLabel = levelLabel; } public Integer getDataSourceCount() { return dataSourceCount; } public void setDataSourceCount(Integer dataSourceCount) { this.dataSourceCount = dataSourceCount; } } public List getDimensions() { return dimensions; } public void setDimensions(List dimensions) { this.dimensions = dimensions; } public String getAssessDate() { return assessDate; } public void setAssessDate(String assessDate) { this.assessDate = assessDate; } } ``` **Step 2: Create DTO — DimensionUploadVO.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionUploadVO.java` ```java package com.etotem.cfc.dto; import java.util.List; /** 数据源上传请求 */ public class DimensionUploadVO { private Long memberId; private String sourceType; // PDF/PHOTO/VOICE/MANUAL private List dimensionCodes; // 影响的维度 private String parsedResultJson; // 解析结果JSON private String fileUrl; // getters/setters public Long getMemberId() { return memberId; } public void setMemberId(Long memberId) { this.memberId = memberId; } public String getSourceType() { return sourceType; } public void setSourceType(String sourceType) { this.sourceType = sourceType; } public List getDimensionCodes() { return dimensionCodes; } public void setDimensionCodes(List dimensionCodes) { this.dimensionCodes = dimensionCodes; } public String getParsedResultJson() { return parsedResultJson; } public void setParsedResultJson(String parsedResultJson) { this.parsedResultJson = parsedResultJson; } public String getFileUrl() { return fileUrl; } public void setFileUrl(String fileUrl) { this.fileUrl = fileUrl; } } ``` **Step 3: Create DTO — HealthDimensionQuestionnaireVO.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/dto/HealthDimensionQuestionnaireVO.java` ```java package com.etotem.cfc.dto; import java.util.Map; /** 问卷提交请求(Tier 3 数据源) */ public class HealthDimensionQuestionnaireVO { private Long memberId; private String dimension; // 维度 code private Map answers; // 问卷答案 key-value public Long getMemberId() { return memberId; } public void setMemberId(Long memberId) { this.memberId = memberId; } public String getDimension() { return dimension; } public void setDimension(String dimension) { this.dimension = dimension; } public Map getAnswers() { return answers; } public void setAnswers(Map answers) { this.answers = answers; } } ``` **Step 4: Create service/DimensionScoreService.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/service/DimensionScoreService.java` ```java package com.etotem.cfc.service; import com.etotem.cfc.dto.HealthDimensionVO; import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO; import com.etotem.cfc.dto.DimensionUploadVO; public interface DimensionScoreService { /** 获取某成员7维最新评分 */ HealthDimensionVO getDimensions(Long memberId); /** 上传数据源,触发受影响维度的重新计算 */ void uploadDataSource(DimensionUploadVO vo); /** 提交问卷答案,生成 Tier 3 评分 */ void submitQuestionnaire(HealthDimensionQuestionnaireVO vo); /** 计算常模百分位 */ Integer calcPercentile(String dimension, String gender, int ageMonths, int rawValue); } ``` **Step 5: Create service/impl/DimensionScoreServiceImpl.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java` ```java package com.etotem.cfc.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.etotem.cfc.dto.HealthDimensionVO; import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO; import com.etotem.cfc.dto.DimensionUploadVO; import com.etotem.cfc.entity.HealthDimensionScore; import com.etotem.cfc.entity.HealthDataSourceRecord; import com.etotem.cfc.entity.HealthNormReference; import com.etotem.cfc.mapper.HealthDataSourceRecordMapper; import com.etotem.cfc.mapper.HealthNormReferenceMapper; import com.etotem.cfc.service.DimensionScoreService; import com.etotem.cfc.service.HealthDimensionScoreService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.math.BigDecimal; import java.util.*; @Slf4j @Service public class DimensionScoreServiceImpl implements DimensionScoreService { @Resource private HealthDimensionScoreService healthDimensionScoreService; @Resource private HealthDataSourceRecordMapper healthDataSourceRecordMapper; @Resource private HealthNormReferenceMapper healthNormReferenceMapper; /** 7维定义 */ private static final LinkedHashMap DIMENSIONS = new LinkedHashMap<>(); static { DIMENSIONS.put("growth", "生长发育"); DIMENSIONS.put("sleep", "睡眠质量"); DIMENSIONS.put("vision", "视力健康"); DIMENSIONS.put("immunity", "免疫力"); DIMENSIONS.put("nutrition", "营养均衡"); DIMENSIONS.put("gut", "肠胃健康"); DIMENSIONS.put("exercise", "运动活力"); } @Override public HealthDimensionVO getDimensions(Long memberId) { List latestScores = healthDimensionScoreService.getLatestScores(memberId); HealthDimensionVO vo = new HealthDimensionVO(); List items = new ArrayList<>(); for (Map.Entry entry : DIMENSIONS.entrySet()) { String code = entry.getKey(); String name = entry.getValue(); Optional optScore = latestScores.stream() .filter(s -> s.getDimension().equals(code)) .findFirst(); HealthDimensionVO.DimensionItem item = new HealthDimensionVO.DimensionItem(); item.setCode(code); item.setName(name); if (optScore.isPresent()) { HealthDimensionScore score = optScore.get(); item.setScore(score.getScore()); item.setPercentile(score.getPercentile()); item.setTier(score.getTier()); // 数据源计数 LambdaQueryWrapper countWrapper = new LambdaQueryWrapper() .eq(HealthDataSourceRecord::getMemberId, memberId) .like(HealthDataSourceRecord::getDimensionCodes, "\"" + code + "\""); int count = healthDataSourceRecordMapper.selectCount(countWrapper); item.setDataSourceCount(count); } else { item.setScore(null); item.setPercentile(null); item.setTier(3); item.setDataSourceCount(0); } // 根据百分位确定等级 if (item.getPercentile() != null) { int p = item.getPercentile(); if (p >= 85) { item.setLevel("excellent"); item.setLevelLabel("优秀"); } else if (p >= 60) { item.setLevel("good"); item.setLevelLabel("良好"); } else if (p >= 40) { item.setLevel("average"); item.setLevelLabel("一般"); } else { item.setLevel("concern"); item.setLevelLabel("关注"); } } else { item.setLevel("unknown"); item.setLevelLabel("未评估"); } items.add(item); } vo.setDimensions(items); vo.setAssessDate(new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date())); return vo; } @Override public void uploadDataSource(DimensionUploadVO vo) { // 保存数据源记录 HealthDataSourceRecord record = new HealthDataSourceRecord(); record.setMemberId(vo.getMemberId()); record.setSourceType(vo.getSourceType()); // dimensionCodes 转为 JSON 数组字符串 if (vo.getDimensionCodes() != null) { record.setDimensionCodes( "[" + String.join(",", vo.getDimensionCodes().stream() .map(c -> "\"" + c + "\"").toList()) + "]" ); } record.setFileUrl(vo.getFileUrl()); record.setConfidence(new BigDecimal("1.00")); record.setCreatedAt(new Date()); healthDataSourceRecordMapper.insert(record); log.info("数据源记录已保存: memberId={}, type={}, dimensions={}", vo.getMemberId(), vo.getSourceType(), vo.getDimensionCodes()); } @Override public void submitQuestionnaire(HealthDimensionQuestionnaireVO vo) { // Phase 1: 问卷评分逻辑简化 — 将问卷答案原样保存为数据源 // 后续 Phase 2 将实现各维度的具体分数算法 HealthDimensionScore score = new HealthDimensionScore(); score.setMemberId(vo.getMemberId()); score.setDimension(vo.getDimension()); score.setTier(3); score.setDataSource("MANUAL"); // 简单问卷评分:根据 answers 生成 0-100 分 // 此处先设置为 50 分占位,后续 Phase 2 实现各维度的具体映射算法 int calculatedScore = calculateScoreFromAnswers(vo.getDimension(), vo.getAnswers()); score.setScore(calculatedScore); score.setRawData(vo.getAnswers() != null ? vo.getAnswers().toString() : "{}"); score.setAssessDate(new java.sql.Date(System.currentTimeMillis())); // 常模百分位暂不计算(Phase 1 简化) healthDimensionScoreService.saveScore(score); log.info("问卷评分已保存: memberId={}, dimension={}, score={}", vo.getMemberId(), vo.getDimension(), calculatedScore); } /** 简易问卷评分算法 — Phase 1 占位实现,后续各维度独立 */ private int calculateScoreFromAnswers(String dimension, Map answers) { if (answers == null || answers.isEmpty()) return 50; // 简化: 根据维度返回默认中位值 // Phase 2 将实现各维度的具体评分映射 return 50; } @Override public Integer calcPercentile(String dimension, String gender, int ageMonths, int rawValue) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(HealthNormReference::getDimension, dimension) .eq(HealthNormReference::getGender, gender) .le(HealthNormReference::getAgeMin, ageMonths) .ge(HealthNormReference::getAgeMax, ageMonths) .orderByAsc(HealthNormReference::getAgeMin) .last("LIMIT 1"); HealthNormReference norm = healthNormReferenceMapper.selectOne(wrapper); if (norm == null) { // 性别不匹配时尝试 all wrapper = new LambdaQueryWrapper() .eq(HealthNormReference::getDimension, dimension) .eq(HealthNormReference::getGender, "all") .le(HealthNormReference::getAgeMin, ageMonths) .ge(HealthNormReference::getAgeMax, ageMonths) .last("LIMIT 1"); norm = healthNormReferenceMapper.selectOne(wrapper); } if (norm == null) return 50; // 简化: 在百分位之间线性插值 if (rawValue <= norm.getPercentile5()) return 5; if (rawValue >= norm.getPercentile95()) return 95; if (rawValue <= norm.getPercentile15()) return linearlyInterpolate(rawValue, norm.getPercentile5(), 5, norm.getPercentile15(), 15); if (rawValue <= norm.getPercentile25()) return linearlyInterpolate(rawValue, norm.getPercentile15(), 15, norm.getPercentile25(), 25); if (rawValue <= norm.getPercentile50()) return linearlyInterpolate(rawValue, norm.getPercentile25(), 25, norm.getPercentile50(), 50); if (rawValue <= norm.getPercentile75()) return linearlyInterpolate(rawValue, norm.getPercentile50(), 50, norm.getPercentile75(), 75); if (rawValue <= norm.getPercentile85()) return linearlyInterpolate(rawValue, norm.getPercentile75(), 75, norm.getPercentile85(), 85); return linearlyInterpolate(rawValue, norm.getPercentile85(), 85, norm.getPercentile95(), 95); } private int linearlyInterpolate(int value, int lowerVal, int lowerPct, int upperVal, int upperPct) { if (upperVal == lowerVal) return (lowerPct + upperPct) / 2; double ratio = (double)(value - lowerVal) / (upperVal - lowerVal); return (int)Math.round(lowerPct + ratio * (upperPct - lowerPct)); } } ``` **Verification:** - [ ] `mvn clean compile` 编译通过 **QA Scenarios:** ``` Scenario: Compilation + DTO validation Tool: Bash Steps: 1. cd /sc-data/cfc/cfc-backend && mvn clean compile Expected Result: BUILD SUCCESS Evidence: .sisyphus/evidence/task-4-compile.txt ``` **Commit**: YES - Message: `feat(health): add DimensionScoreService and DTOs` - Files: `cfc-backend/.../dto/HealthDimensionVO.java`, `dto/DimensionUploadVO.java`, `dto/HealthDimensionQuestionnaireVO.java`, `service/DimensionScoreService.java`, `service/impl/DimensionScoreServiceImpl.java` - [ ] 5. HealthDimensionController — API 端点 **What to do**: 创建控制器,提供7维评分的获取、数据源上传、问卷提交三个核心 API。 **Step 1: Create controller/HealthDimensionController.java** `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/controller/HealthDimensionController.java` ```java package com.etotem.cfc.controller; import com.etotem.cfc.common.Result; import com.etotem.cfc.dto.HealthDimensionVO; import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO; import com.etotem.cfc.dto.DimensionUploadVO; import com.etotem.cfc.service.DimensionScoreService; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; @Slf4j @RestController @RequestMapping("/api/health") public class HealthDimensionController { @Resource private DimensionScoreService dimensionScoreService; /** 获取某成员7维最新评分 */ @PostMapping("/dimensions") public Result getDimensions( @RequestAttribute("userId") Long userId, @RequestBody java.util.Map params) { Long memberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : userId; HealthDimensionVO vo = dimensionScoreService.getDimensions(memberId); return Result.success(vo); } /** 上传数据源(PDF/照片/语音解析结果) */ @PostMapping("/dimensions/upload") public Result uploadDataSource( @RequestAttribute("userId") Long userId, @RequestBody DimensionUploadVO vo) { if (vo.getMemberId() == null) { vo.setMemberId(userId); } dimensionScoreService.uploadDataSource(vo); return Result.success("数据源上传成功"); } /** 提交问卷评分 */ @PostMapping("/dimensions/questionnaire") public Result submitQuestionnaire( @RequestAttribute("userId") Long userId, @RequestBody HealthDimensionQuestionnaireVO vo) { if (vo.getMemberId() == null) { vo.setMemberId(userId); } dimensionScoreService.submitQuestionnaire(vo); return Result.success("问卷提交成功"); } } ``` **Step 2: 编译验证** - [ ] `mvn clean compile` 编译通过 **Step 3: 检查路由冲突** ```bash grep -rn '@PostMapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '"/api[^"]*"' | sort ``` 确认 `/api/health/dimensions`、`/api/health/dimensions/upload`、`/api/health/dimensions/questionnaire` 无冲突。 **QA Scenarios:** ``` Scenario: Route conflict check Tool: Bash Steps: 1. cd /sc-data/cfc/cfc-backend && grep -rn '@PostMapping' src/main/java/com/etotem/cfc/controller/ | grep "health" Expected Result: 只有新创建的 HealthDimensionController 包含 /api/health 路由,无重复 Evidence: .sisyphus/evidence/task-5-routes.txt Scenario: API response validation Tool: Bash (curl) Steps: 1. 启动应用 2. curl -X POST http://localhost:9082/api/health/dimensions -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"memberId": 1}' Expected Result: 返回 JSON 包含 dimensions 数组,含7个维度的 code/name/score/percentile/level Evidence: .sisyphus/evidence/task-5-api.txt ``` **Commit**: YES - Message: `feat(health): add HealthDimensionController with dimension/upload/questionnaire endpoints` - Files: `cfc-backend/.../controller/HealthDimensionController.java` - [ ] 6. 菌群报告映射集成 — 复用现有解析数据 **What to do**: 扩展 `HealthReportService` 或创建一个新 Service,在菌群报告上传解析完成后,自动将解析结果映射到 `health_dimension_scores` 表。 现有流程:上传PDF → `PdfParseService.parse()` → `ParsedReportResult` → 保存到 `HealthReport` + `HealthGutFlora` + `HealthIndicator` 新增流程:解析完成后 → 调 `DimensionScoreService.uploadDataSource()` 记录数据源 → 计算维度分数 → 保存到 `health_dimension_scores` **Step 1: Service 层菌群报告维度计算** 在 `service/impl/DimensionScoreServiceImpl.java` 中新增方法,或创建一个 `service/GutReportMappingService.java`。这里直接在 `DimensionScoreServiceImpl` 中新增 `processGutReport()` 方法: ```java // 在 DimensionScoreServiceImpl.java 中新增: /** 处理菌群报告解析结果,映射到7维评分 */ public void processGutReport(Long memberId, ParsedReportResult parsedResult) { if (parsedResult == null) return; // 创建数据源记录 DimensionUploadVO uploadVO = new DimensionUploadVO(); uploadVO.setMemberId(memberId); uploadVO.setSourceType("REPORT"); uploadVO.setDimensionCodes(Arrays.asList("nutrition", "gut", "immunity", "growth", "sleep")); uploadVO.setParsedResultJson(parsedResult.toString()); uploadDataSource(uploadVO); // 肠胃健康维度 (从菌群报告最完整获取) saveGutScore(memberId, parsedResult); // 营养均衡维度 saveNutritionScore(memberId, parsedResult); // 免疫力维度 saveImmunityScore(memberId, parsedResult); // 生长发育维度 (慢性病/易胖风险) saveGrowthScore(memberId, parsedResult); // 睡眠质量维度 (神经递质) saveSleepScore(memberId, parsedResult); log.info("菌群报告维度映射完成: memberId={}", memberId); } private void saveGutScore(Long memberId, ParsedReportResult result) { // 肠胃健康 = 综合菌群多项指标加权 int score = average( result.getGutHealthScore(), result.getBalanceScore(), result.getDiversityScore(), result.getBeneficialScore(), result.getCoreGenusScore() ); // 如果有有害菌偏高则扣分 if (result.getHarmfulScore() != null && result.getHarmfulScore() > 60) { score = (int)(score * 0.85); } saveDimensionScore(memberId, "gut", score, 1, "REPORT"); } private void saveNutritionScore(Long memberId, ParsedReportResult result) { int score = result.getNutritionScore() != null ? result.getNutritionScore() : 50; saveDimensionScore(memberId, "nutrition", score, 1, "REPORT"); } private void saveImmunityScore(Long memberId, ParsedReportResult result) { // 免疫力 = 肠道屏障 + 有益菌水平 - 有害菌风险 int gutHealth = result.getGutHealthScore() != null ? result.getGutHealthScore() : 50; int beneficial = result.getBeneficialScore() != null ? result.getBeneficialScore() : 50; int harmful = result.getHarmfulScore() != null ? result.getHarmfulScore() : 50; int score = (int)(gutHealth * 0.4 + beneficial * 0.4 - (harmful - 50) * 0.5 + 50); score = Math.max(0, Math.min(100, score)); saveDimensionScore(memberId, "immunity", score, 1, "REPORT"); } private void saveGrowthScore(Long memberId, ParsedReportResult result) { // 生长发育 = 慢性病控制度 + BMI (BMI在其他数据源中) int chronic = result.getChronicDiseaseScore() != null ? result.getChronicDiseaseScore() : 50; saveDimensionScore(memberId, "growth", chronic, 1, "REPORT"); } private void saveSleepScore(Long memberId, ParsedReportResult result) { // 睡眠质量 = 神经递质指标(从indicators中提取) // Phase 1: 如果没有解析出神经递质指标,暂不覆盖 // 可通过 HealthIndicator category="神经递质" 获取 // 暂时保留问卷/Tier2数据 log.info("睡眠维度暂不从菌群报告映射,等待神经递质指标解析完善"); } private void saveDimensionScore(Long memberId, String dimension, int score, int tier, String dataSource) { HealthDimensionScore record = new HealthDimensionScore(); record.setMemberId(memberId); record.setDimension(dimension); record.setScore(score); record.setTier(tier); record.setDataSource(dataSource); record.setAssessDate(new java.sql.Date(System.currentTimeMillis())); healthDimensionScoreService.saveScore(record); } private int average(Integer... values) { int sum = 0, count = 0; for (Integer v : values) { if (v != null) { sum += v; count++; } } return count > 0 ? sum / count : 50; } ``` **Step 2: 在 HealthReportController 或现有报告处理流程中调用** 定位到 `/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java` 的 `uploadReport` 方法。在 `PdfParseService.parse()` 调用完成后,获取返回值中的成员ID(`subjectId`),然后调 `dimensionScoreService.processGutReport()`。 ```java // 在 uploadReport 方法中,pdf解析并保存到DB之后添加: // 7维健康评分映射 (Phase 1) try { HealthReport savedReport = /* 刚刚保存的报告 */; if (savedReport != null && savedReport.getSubjectId() != null) { dimensionScoreService.processGutReport(savedReport.getSubjectId(), parsedReport); } } catch (Exception e) { log.error("菌群报告维度映射失败", e); // 不阻断主流程 } ``` 需要注入 `DimensionScoreService`: ```java @Resource private DimensionScoreService dimensionScoreService; ``` **Step 3: 编译验证** - [ ] `mvn clean compile` 编译通过 **QA Scenarios:** ``` Scenario: Gut report mapping with existing test PDF Tool: Bash (curl) Steps: 1. 启动应用 2. curl -X POST http://localhost:9082/api/health/report/upload (with test PDF) 3. curl -X POST http://localhost:9082/api/health/dimensions -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"memberId": }' Expected Result: dimensions 数组中的 gut/nutrition/immunity/growth 维度有 score 值,dataSource 为 "REPORT",tier=1 Evidence: .sisyphus/evidence/task-6-gut-mapping.txt ``` **Commit**: YES - Message: `feat(health): integrate gut report mapping to dimension scores` - Files: `cfc-backend/.../service/impl/DimensionScoreServiceImpl.java`, `controller/HealthReportController.java` - [ ] 7. 前端 7维雷达图页面 (health-dimensions.vue) **What to do**: 创建 uni-app 页面,展示7维雷达图和每个维度的详情。包括: - 雷达图(用 canvas 绘制7边形) - 每个维度的分数、等级、数据源标识 - 点击维度进入问卷填写 - 上传数据源入口 **Step 1: Create pages/body/health-dimensions.vue** `/sc-data/cfc/cfc-frontend/pages/body/health-dimensions.vue` ```vue ``` **Verification:** - [ ] 页面编译正常(uni-app 微信开发者工具导入后无报错) - [ ] 雷达图渲染正常 **QA Scenarios:** ``` Scenario: Frontend compilation Tool: Bash Steps: 1. cd /sc-data/cfc/cfc-frontend && npm install --no-optional 2>/dev/null; npm run dev:mp-weixin 2>&1 | head -10 Expected Result: 编译成功,无语法错误 Note: uni-app 可能需要在微信开发者工具中验证实际渲染 Evidence: .sisyphus/evidence/task-7-frontend.txt ``` **Commit**: YES (with Task 8) - Message: `feat(health): add health-dimensions page with radar chart and questionnaire entry` - Files: `cfc-frontend/pages/body/health-dimensions.vue` - [ ] 8. 页面路由注册与导航 **What to do**: 在 `pages.json` 中注册新页面,并在适当的入口添加导航(如 "身体" Tab 下或 index 页面)。 **Step 1: 在 pages.json 中注册** `/sc-data/cfc/cfc-frontend/pages.json` 在 `pages` 数组中找到 `pages/body/` 相关的条目,在附近添加: ```json { "path": "pages/body/health-dimensions", "style": { "navigationBarTitleText": "健康画像" } } ``` **Step 2: 添加导航入口** 在 `pages/body/index.vue` 或适当的页面中,添加一个入口按钮导航到 health-dimensions 页面: ```vue 📊 健康画像 ``` ```javascript methods: { goHealthDimensions() { uni.navigateTo({ url: '/pages/body/health-dimensions' }) } } ``` > 具体入口位置需要确认 body/index.vue 的现有布局后确定。基本原则是放在"身体"相关功能区域。 **QA Scenarios:** ``` Scenario: Route registration Tool: Bash (grep) Steps: 1. grep '"pages/body/health-dimensions"' /sc-data/cfc/cfc-frontend/pages.json Expected Result: 找到路由注册条目 Evidence: .sisyphus/evidence/task-8-route.txt ``` **Commit**: YES (with Task 7) - Message: `feat(health): register health-dimensions route and add navigation entry` - Files: `cfc-frontend/pages.json` --- ## Final Verification Wave (MANDATORY — after ALL implementation tasks) > 4 review agents run in PARALLEL. ALL must APPROVE. Present consolidated results to user and get explicit "okay" before completing. > > **Do NOT auto-proceed after verification. Wait for user's explicit approval before marking work complete.** - [ ] F1. **Plan Compliance Audit** — `oracle` Read the plan end-to-end. For each "Must Have": verify implementation exists (read Java files for entity/Service/Controller methods, check curl endpoint, run `mvn compile`). For each "Must NOT Have": search for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan. Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT` - [ ] F2. **Code Quality Review** — `unspecified-high` Run `mvn clean compile` + check for `@SuppressWarnings`, `System.out`, `e.printStackTrace()`, unused imports, missing `Serializable` on entities. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp). Output: `Build [PASS/FAIL] | Files [N clean/N issues] | VERDICT` - [ ] F3. **Real Manual QA** — `unspecified-high` Start from clean state (fresh DB). Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (dimension scoring + gut report mapping). Test edge cases: empty memberId, duplicate submissions. Save to `.sisyphus/evidence/final-qa/`. Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT` - [ ] F4. **Scope Fidelity Check** — `deep` For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes. Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT` --- ## Commit Strategy | Task(s) | Message | Scope | |---------|---------|-------| | 1 | `feat(health): add DB migration, dimension entities, and Mappers` | Backend | | 2 | `feat(health): implement HealthDimensionScoreService CRUD` | Backend | | 3 | `feat(health): add norm reference seed data` | Backend | | 4 | `feat(health): implement DimensionScoreService core aggregator` | Backend | | 5 | `feat(health): add HealthDimensionController endpoints` | Backend | | 6 | `feat(health): integrate gut report mapping to dimension scores` | Backend | | 7 | `feat(health): add health-dimensions page with radar chart` | Frontend | | 8 | `feat(health): register health-dimensions route` | Frontend | --- ## Success Criteria ### Verification Commands ```bash # Backend compilation cd /sc-data/cfc/cfc-backend && mvn clean compile # API smoke tests curl -X POST http://localhost:9082/api/health/dimensions -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{}' curl -X POST http://localhost:9082/api/health/dimensions/upload -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"memberId":1,"sourceType":"REPORT","dimensionCodes":["gut","nutrition"]}' curl -X POST http://localhost:9082/api/health/dimensions/questionnaire -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"memberId":1,"dimension":"sleep","answers":["1","3","2"]}' ``` ### Final Checklist - [ ] All 8 backend tasks implemented and compiled - [ ] Frontend page renders radar chart - [ ] All verification scenarios executed with passing evidence - [ ] All "Must Have" present - [ ] All "Must NOT Have" absent - [ ] F1-F4 all approve - [ ] User gives explicit final approval