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
设计详见 .sisyphus/drafts/health-dimensions-design.md,本计划实施其中的 Phase 1(核心框架 + 问卷兜底 + 菌群报告映射)。
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; // 中文名
}
@Data, @TableName, @TableId(type = IdType.AUTO), implements SerializableBaseMapper<T>,@Mapper 注解@Resource 注入@RestController, @RequestMapping("/api/health"), 统一 @PostMappingResult<T> (code/message/data)methods: {},data() { return {} },禁止可选链 ?.| # | 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 |
| # | File | Change |
|---|---|---|
| 15 | config/DatabaseInitializer.java |
添加 migration: 创建3张表 + 常模种子数据 |
| # | File | Responsibility |
|---|---|---|
| 16 | pages/body/health-dimensions.vue |
7维雷达图 + 问卷入口页面 |
| # | File | Change |
|---|---|---|
| 17 | pages.json |
注册 pages/body/health-dimensions 路由 |
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
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
feat(health): add health dimension score entities and DB tablesFiles: 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)
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 之间追加):
// 插入常模种子数据 (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' 行数>10QA 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
feat(health): add norm reference seed data (BMI, sleep)Files: cfc-backend/.../config/DatabaseInitializer.java
[ ] 4. DimensionScoreService — 核心聚合器
What to do: 创建 DimensionScoreService,这是整个7维体系的核心。它负责:
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
feat(health): add DimensionScoreService and DTOsFiles: 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
feat(health): add HealthDimensionController with dimension/upload/questionnaire endpointsFiles: 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.java 的 uploadReport 方法。在 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
feat(health): integrate gut report mapping to dimension scoresFiles: cfc-backend/.../service/impl/DimensionScoreServiceImpl.java, controller/HealthReportController.java
[ ] 7. 前端 7维雷达图页面 (health-dimensions.vue)
What to do: 创建 uni-app 页面,展示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:
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)
feat(health): add health-dimensions page with radar chart and questionnaire entryFiles: 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)
feat(health): register health-dimensions route and add navigation entrycfc-frontend/pages.json4 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
| 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 |
# 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"]}'