health-dimensions-phase1-plan.md 70 KB

身体健康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维代码枚举

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<T>@Mapper 注解
  • Service: 接口 + impl 模式,@Resource 注入
  • Controller: @RestController, @RequestMapping("/api/health"), 统一 @PostMapping
  • 响应: Result<T> (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() 方法):

  // === 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

  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

  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

  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

  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<HealthDimensionScore> {}

Step 6: Create mapper/HealthDataSourceRecordMapper.java

/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDataSourceRecordMapper.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<HealthDataSourceRecord> {}

Step 7: Create mapper/HealthNormReferenceMapper.java

/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthNormReferenceMapper.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<HealthNormReference> {}

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

  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<HealthDimensionScore> getLatestScores(Long memberId);

      /** 获取某成员某维度的最新评分 */
      HealthDimensionScore getLatestScore(Long memberId, String dimension);

      /** 获取某成员某维度的历史评分(按日期降序) */
      List<HealthDimensionScore> 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

  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<HealthDimensionScore> wrapper = new LambdaQueryWrapper<HealthDimensionScore>()
              .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<HealthDimensionScore> getLatestScores(Long memberId) {
          // 子查询: 取每个 dimension 的最新 assess_date
          // 先用 group by 方式简化
          LambdaQueryWrapper<HealthDimensionScore> wrapper = new LambdaQueryWrapper<HealthDimensionScore>()
              .eq(HealthDimensionScore::getMemberId, memberId)
              .orderByDesc(HealthDimensionScore::getAssessDate);
          List<HealthDimensionScore> 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<HealthDimensionScore> wrapper = new LambdaQueryWrapper<HealthDimensionScore>()
              .eq(HealthDimensionScore::getMemberId, memberId)
              .eq(HealthDimensionScore::getDimension, dimension)
              .orderByDesc(HealthDimensionScore::getAssessDate)
              .last("LIMIT 1");
          return healthDimensionScoreMapper.selectOne(wrapper);
      }

      @Override
      public List<HealthDimensionScore> getHistoryScores(Long memberId, String dimension, int limit) {
          LambdaQueryWrapper<HealthDimensionScore> wrapper = new LambdaQueryWrapper<HealthDimensionScore>()
              .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<HealthDimensionScore> wrapper = new LambdaUpdateWrapper<HealthDimensionScore>()
              .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 百分位种子数据(中国标准简化版)、睡眠推荐时长、运动推荐量。

需要注意:DatabaseInitializerrunMigrations() 方法有一个设计:如果某个 migration 执行失败抛出异常,后续不会继续执行。因此最好将数据插入放在同一个 migration lambda 内的表创建之后,或者单独一个 migration。

Step 1: 在 DatabaseInitializer.java 的 runMigrations() 中,在表创建后追加常模种子数据

定位到刚才添加的 migration lambda 末尾(在 log.info(...)return true 之间追加):

  // 插入常模种子数据 (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

  package com.etotem.cfc.dto;

  import java.util.List;

  /** 7维评分响应 */
  public class HealthDimensionVO {
      private List<DimensionItem> 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<DimensionItem> getDimensions() { return dimensions; }
      public void setDimensions(List<DimensionItem> 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

  package com.etotem.cfc.dto;

  import java.util.List;

  /** 数据源上传请求 */
  public class DimensionUploadVO {
      private Long memberId;
      private String sourceType;    // PDF/PHOTO/VOICE/MANUAL
      private List<String> 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<String> getDimensionCodes() { return dimensionCodes; }
      public void setDimensionCodes(List<String> 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

  package com.etotem.cfc.dto;

  import java.util.Map;

  /** 问卷提交请求(Tier 3 数据源) */
  public class HealthDimensionQuestionnaireVO {
      private Long memberId;
      private String dimension;          // 维度 code
      private Map<String, Object> 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<String, Object> getAnswers() { return answers; }
      public void setAnswers(Map<String, Object> answers) { this.answers = answers; }
  }

Step 4: Create service/DimensionScoreService.java

/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/service/DimensionScoreService.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

  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<String, String> 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<HealthDimensionScore> latestScores = healthDimensionScoreService.getLatestScores(memberId);

          HealthDimensionVO vo = new HealthDimensionVO();
          List<HealthDimensionVO.DimensionItem> items = new ArrayList<>();

          for (Map.Entry<String, String> entry : DIMENSIONS.entrySet()) {
              String code = entry.getKey();
              String name = entry.getValue();

              Optional<HealthDimensionScore> 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<HealthDataSourceRecord> countWrapper = new LambdaQueryWrapper<HealthDataSourceRecord>()
                      .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<String, Object> 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<HealthNormReference> wrapper = new LambdaQueryWrapper<HealthNormReference>()
              .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<HealthNormReference>()
                  .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

  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<HealthDimensionVO> getDimensions(
              @RequestAttribute("userId") Long userId,
              @RequestBody java.util.Map<String, Object> 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<String> 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<String> 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: 检查路由冲突

  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 <test-token>" -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() 方法:

  // 在 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.javauploadReport 方法。在 PdfParseService.parse() 调用完成后,获取返回值中的成员ID(subjectId),然后调 dimensionScoreService.processGutReport()

  // 在 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:

  @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 <token>" -H "Content-Type: application/json" -d '{"memberId": <testMemberId>}'
    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

  <template>
    <view class="page">
      <!-- 头部 -->
      <view class="header">
        <text class="title">健康画像</text>
        <text class="subtitle">{{ assessDate }} · 基于{{ dataSourceLabel }}评估</text>
      </view>

      <!-- 雷达图区域 -->
      <view class="radar-container">
        <canvas canvas-id="radarCanvas" class="radar-canvas"></canvas>
      </view>

      <!-- 图例 -->
      <view class="legend">
        <view class="legend-item" v-for="dim in dimensions" :key="dim.code">
          <view class="legend-dot" :style="{ background: levelColor(dim.level) }"></view>
          <text class="legend-label">{{ dim.name }}</text>
          <text class="legend-score" :style="{ color: levelColor(dim.level) }">
            {{ dim.score !== null ? dim.score : '--' }}
          </text>
          <text class="legend-percentile" v-if="dim.percentile !== null">
            P{{ dim.percentile }}
          </text>
        </view>
      </view>

      <!-- 操作入口 -->
      <view class="actions">
        <view class="action-btn" @click="showUploadSheet">
          <text class="action-icon">📷</text>
          <text class="action-text">上传报告/照片</text>
        </view>
        <view class="action-btn" @click="showVoiceInput">
          <text class="action-icon">🎤</text>
          <text class="action-text">语音输入</text>
        </view>
      </view>

      <!-- 维度问卷入口 -->
      <view class="dimension-list">
        <view
          class="dimension-card"
          v-for="dim in dimensions"
          :key="dim.code"
          @click="goQuestionnaire(dim)"
        >
          <view class="dim-left">
            <text class="dim-name">{{ dim.name }}</text>
            <text class="dim-desc">{{ dimDesc(dim) }}</text>
          </view>
          <view class="dim-right">
            <view class="dim-score" :style="{ color: levelColor(dim.level) }">
              {{ dim.score !== null ? dim.score : '--' }}
            </view>
            <view class="dim-level" :style="{ background: levelBgColor(dim.level) }">
              {{ dim.levelLabel }}
            </view>
            <text class="dim-source">{{ sourceLabel(dim.tier) }}</text>
          </view>
        </view>
      </view>

      <!-- 上传选项弹窗 -->
      <uni-popup ref="uploadPopup" type="bottom">
        <view class="upload-sheet">
          <view class="sheet-item" @click="uploadPhoto('scale')">
            <text class="sheet-icon">⚖️</text>
            <view>
              <text class="sheet-title">体脂称照片</text>
              <text class="sheet-desc">拍照识别体重/体脂率</text>
            </view>
          </view>
          <view class="sheet-item" @click="uploadPhoto('tongue')">
            <text class="sheet-icon">👅</text>
            <view>
              <text class="sheet-title">舌诊照片</text>
              <text class="sheet-desc">拍照分析肠胃/免疫力</text>
            </view>
          </view>
          <view class="sheet-item" @click="uploadPhoto('exam')">
            <text class="sheet-icon">🏥</text>
            <view>
              <text class="sheet-title">体检报告</text>
              <text class="sheet-desc">拍照自动提取指标</text>
            </view>
          </view>
          <view class="sheet-cancel" @click="hideUploadSheet">取消</view>
        </view>
      </uni-popup>
    </view>
  </template>

  <script>
  const API_BASE = require('../../config').API_BASE_URL

  export default {
    data() {
      return {
        dimensions: [],
        assessDate: '',
        token: ''
      }
    },

    onLoad() {
      this.token = uni.getStorageSync('token')
      this.loadDimensions()
    },

    onReady() {
      // canvas 需要在 onReady 后绘制
      setTimeout(() => this.drawRadar(), 300)
    },

    methods: {
      loadDimensions() {
        uni.request({
          url: API_BASE + '/api/health/dimensions',
          method: 'POST',
          header: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
          data: {},
          success: (res) => {
            if (res.data.code === 0) {
              this.dimensions = res.data.data.dimensions || []
              this.assessDate = res.data.data.assessDate || ''
              this.$nextTick(() => this.drawRadar())
            }
          }
        })
      },

      drawRadar() {
        const ctx = uni.createCanvasContext('radarCanvas', this)
        const width = 300
        const height = 300
        const centerX = width / 2
        const centerY = height / 2
        const radius = 120
        const count = this.dimensions.length || 7

        if (count === 0) return

        // 清除
        ctx.clearRect(0, 0, width, height)

        // 绘制网格 (同心多边形)
        const levels = [0.2, 0.4, 0.6, 0.8, 1.0]
        ctx.setStrokeStyle('#e0e0e0')
        ctx.setLineWidth(1)
        for (const l of levels) {
          this.drawPolygon(ctx, centerX, centerY, radius * l, count)
        }

        // 绘制轴线
        ctx.setStrokeStyle('#ccc')
        ctx.setLineWidth(1)
        for (let i = 0; i < count; i++) {
          const angle = (Math.PI * 2 / count) * i - Math.PI / 2
          const x = centerX + radius * Math.cos(angle)
          const y = centerY + radius * Math.sin(angle)
          ctx.beginPath()
          ctx.moveTo(centerX, centerY)
          ctx.lineTo(x, y)
          ctx.stroke()
        }

        // 绘制数据区域
        const validDims = this.dimensions.filter(d => d.score !== null)
        if (validDims.length > 0) {
          ctx.beginPath()
          for (let i = 0; i < count; i++) {
            const dim = this.dimensions[i]
            const value = dim.score !== null ? dim.score / 100 : 0
            const angle = (Math.PI * 2 / count) * i - Math.PI / 2
            const x = centerX + radius * value * Math.cos(angle)
            const y = centerY + radius * value * Math.sin(angle)
            if (i === 0) ctx.moveTo(x, y)
            else ctx.lineTo(x, y)
          }
          ctx.closePath()
          ctx.setFillStyle('rgba(249, 115, 22, 0.2)')
          ctx.setStrokeStyle('#F97316')
          ctx.setLineWidth(2)
          ctx.fill()
          ctx.stroke()
        }

        // 绘制维度标签
        ctx.setFontSize(12)
        ctx.setFillStyle('#333')
        ctx.setTextAlign('center')
        for (let i = 0; i < count; i++) {
          const dim = this.dimensions[i]
          const angle = (Math.PI * 2 / count) * i - Math.PI / 2
          const labelRadius = radius + 25
          const x = centerX + labelRadius * Math.cos(angle)
          const y = centerY + labelRadius * Math.sin(angle)
          ctx.fillText(dim.name, x, y)
        }

        ctx.draw()
      },

      drawPolygon(ctx, cx, cy, r, n) {
        ctx.beginPath()
        for (let i = 0; i < n; i++) {
          const angle = (Math.PI * 2 / n) * i - Math.PI / 2
          const x = cx + r * Math.cos(angle)
          const y = cy + r * Math.sin(angle)
          if (i === 0) ctx.moveTo(x, y)
          else ctx.lineTo(x, y)
        }
        ctx.closePath()
        ctx.stroke()
      },

      levelColor(level) {
        const map = { 'excellent': '#22c55e', 'good': '#86efac', 'average': '#eab308', 'concern': '#ef4444', 'unknown': '#999' }
        return map[level] || '#999'
      },

      levelBgColor(level) {
        const map = { 'excellent': '#dcfce7', 'good': '#f0fdf4', 'average': '#fef9c3', 'concern': '#fee2e2', 'unknown': '#f3f4f6' }
        return map[level] || '#f3f4f6'
      },

      dimDesc(dim) {
        if (dim.score === null) return '尚未评估,点击开始'
        const levelText = { 'excellent': '高于大多数同龄人', 'good': '处于良好水平', 'average': '处于一般水平', 'concern': '建议关注改善' }
        return levelText[dim.level] || '暂无数据'
      },

      sourceLabel(tier) {
        const map = { 1: '自动', 2: '语音', 3: '手动' }
        return map[tier] || ''
      },

      get dataSourceLabel() {
        const maxTier = Math.min(...this.dimensions.map(d => d.tier || 3))
        const map = { 1: '报告/照片', 2: '语音', 3: '手动填写' }
        return map[maxTier] || '手动填写'
      },

      goQuestionnaire(dim) {
        uni.navigateTo({
          url: '/pages/body/questionnaire?dimension=' + dim.code + '&name=' + dim.name
        })
      },

      showUploadSheet() {
        this.$refs.uploadPopup && this.$refs.uploadPopup.open()
      },

      hideUploadSheet() {
        this.$refs.uploadPopup && this.$refs.uploadPopup.close()
      },

      uploadPhoto(type) {
        this.hideUploadSheet()
        uni.chooseImage({
          count: 1,
          success: (res) => {
            const tempPath = res.tempFilePaths[0]
            // Phase 1: 上传照片待OCR服务实现后处理,先提示
            uni.showToast({ title: '照片已接收,解析功能开发中', icon: 'none' })
          }
        })
      },

      showVoiceInput() {
        uni.showToast({ title: '语音输入功能开发中', icon: 'none' })
      }
    }
  }
  </script>

  <style scoped>
  .page { padding: 20rpx 30rpx; background: #f5f5f5; min-height: 100vh; }
  .header { margin-bottom: 30rpx; }
  .title { font-size: 36rpx; font-weight: bold; color: #333; }
  .subtitle { font-size: 24rpx; color: #999; margin-top: 10rpx; display: block; }

  .radar-container { background: #fff; border-radius: 20rpx; padding: 30rpx; margin-bottom: 30rpx; display: flex; justify-content: center; }
  .radar-canvas { width: 600rpx; height: 600rpx; }

  .legend { display: flex; flex-wrap: wrap; gap: 20rpx; margin-bottom: 30rpx; }
  .legend-item { display: flex; align-items: center; gap: 8rpx; background: #fff; padding: 12rpx 20rpx; border-radius: 30rpx; }
  .legend-dot { width: 16rpx; height: 16rpx; border-radius: 50%; }
  .legend-label { font-size: 22rpx; color: #666; }
  .legend-score { font-size: 26rpx; font-weight: bold; }
  .legend-percentile { font-size: 20rpx; color: #999; }

  .actions { display: flex; gap: 20rpx; margin-bottom: 30rpx; }
  .action-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 10rpx; background: #fff; padding: 24rpx; border-radius: 16rpx; border: 2rpx solid #eee; }
  .action-icon { font-size: 32rpx; }
  .action-text { font-size: 26rpx; color: #333; }

  .dimension-list { display: flex; flex-direction: column; gap: 16rpx; }
  .dimension-card { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 24rpx; border-radius: 16rpx; }
  .dim-left { flex: 1; }
  .dim-name { font-size: 28rpx; font-weight: bold; color: #333; display: block; }
  .dim-desc { font-size: 22rpx; color: #999; margin-top: 6rpx; display: block; }
  .dim-right { display: flex; align-items: center; gap: 12rpx; }
  .dim-score { font-size: 36rpx; font-weight: bold; }
  .dim-level { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 20rpx; }
  .dim-source { font-size: 20rpx; color: #999; }

  .upload-sheet { background: #fff; border-radius: 20rpx 20rpx 0 0; padding: 30rpx; }
  .sheet-item { display: flex; align-items: center; gap: 20rpx; padding: 24rpx 0; border-bottom: 2rpx solid #f0f0f0; }
  .sheet-icon { font-size: 40rpx; }
  .sheet-title { font-size: 28rpx; color: #333; display: block; }
  .sheet-desc { font-size: 22rpx; color: #999; display: block; }
  .sheet-cancel { text-align: center; padding: 24rpx; color: #999; font-size: 28rpx; }
  </style>

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/ 相关的条目,在附近添加:

  {
    "path": "pages/body/health-dimensions",
    "style": {
      "navigationBarTitleText": "健康画像"
    }
  }

Step 2: 添加导航入口

pages/body/index.vue 或适当的页面中,添加一个入口按钮导航到 health-dimensions 页面:

  <!-- 在某个卡片或按钮中 -->
  <view class="menu-item" @click="goHealthDimensions">
    <text class="menu-icon">📊</text>
    <text class="menu-text">健康画像</text>
    <text class="menu-arrow">›</text>
  </view>
  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 Auditoracle 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 Reviewunspecified-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 QAunspecified-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 Checkdeep 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

# 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 <token>" -H "Content-Type: application/json" -d '{}'
curl -X POST http://localhost:9082/api/health/dimensions/upload -H "Authorization: Bearer <token>" -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 <token>" -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