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维健康实现(body/fitness/cardio/blood/sleep/psyche/diet)重构对齐到设计文档规范的7维模型(growth/sleep/vision/immunity/nutrition/gut/exercise),包括数据库表结构、评分引擎、常模百分位系统、菌群报告映射。
核心架构变更:
health_dimension_score → health_dimension_scores,增加 percentile/tier/raw_data/expire_date 字段health_norm_reference 改为百分位结构(percentile_5~95),按性别+年龄月查询health_data_source_record 增加 sourceType/dimensionCodes/parsedResult/confidenceDimensionScoreServiceImpl 核心聚合器:替换7维列表 + 实现百分位计算 + 数据源Tier优先级 + 菌群映射集成Tech Stack: Java 8, Spring Boot 2.7.18, MyBatis-Plus, MySQL 8.0, uni-app Vue 2
// 枚举值(维度code → 中文名)
growth → "生长发育"
sleep → "睡眠质量"
vision → "视力健康"
immunity → "免疫力"
nutrition → "营养均衡"
gut → "肠胃健康"
exercise → "运动活力"
health_dimension_scores / health_data_sources / health_norm_reference 3张表DimensionScoreService 聚合逻辑(Tier 3 问卷兜底 + Tier 1 报告覆盖)HealthDimensionController — 获取7维 / 手动输入问卷| 菌群指标 | 生长 | 睡眠 | 视力 | 免疫 | 营养 | 肠胃 | 运动 |
|---|---|---|---|---|---|---|---|
| gutHealthScore | ✅ | ✅ | |||||
| balanceScore | ✅ | ||||||
| diversityScore | ✅ | ✅ | |||||
| nutritionScore | ✅ | ||||||
| chronicDiseaseScore | ✅ | ✅ | |||||
| beneficialBacteriaScore | ✅ | ✅ | |||||
| harmfulBacteriaScore | ✅ | ✅ | |||||
| coreGenusScore | ✅ | ||||||
| gutAge | ✅ | ✅ | |||||
| 神经递质(indicators) | ✅ | ||||||
| 肠道屏障(indicators) | ✅ | ✅ | |||||
| 短链脂肪酸(indicators) | ✅ | ✅ | |||||
| 疾病风险(diseaseRisks) | ✅ | ✅ | |||||
| 菌种丰度(populationLevel) | ✅ | ✅ | ✅ | ✅ | |||
| 微量指标(维生素/矿物质) | ✅ |
后端:
entity/ HealthDimensionScore.java HealthNormReference.java HealthDataSourceRecord.java
mapper/ HealthDimensionScoreMapper.java HealthNormReferenceMapper.java HealthDataSourceRecordMapper.java
service/ HealthDimensionScoreService.java + impl/
DimensionScoreService.java + impl/DimensionScoreServiceImpl.java
HealthDataSourceRecordService.java (interface)
PdfParseService.java ← 菌群报告解析器(已有)
controller/HealthDimensionController.java
dto/ HealthDimensionVO.java DimensionUploadVO.java HealthDimensionQuestionnaireVO.java DimensionScoreVO.java
config/ DatabaseInitializer.java
前端:
pages/body/health-dimensions.vue
pages.json (路由已注册,无需修改)
| # | File | Change |
|---|---|---|
| 1 | entity/HealthDimensionScore.java |
表名→health_dimension_scores,增 percentile/tier/rawData/assessDate/expireDate/updatedAt |
| 2 | entity/HealthNormReference.java |
改为百分位字段 percentile5-95 + gender/ageMin/ageMax(月) |
| 3 | entity/HealthDataSourceRecord.java |
改为 sourceType/dimensionCodes/parsedResult/fileUrl/confidence |
| 4 | mapper/HealthDimensionScoreMapper.java |
SQL适配新表名和字段,新增百分位查询 |
| 5 | mapper/HealthNormReferenceMapper.java |
新查询方法 selectPercentile(dimension, gender, ageMonths, score) |
| 6 | mapper/HealthDataSourceRecordMapper.java |
SQL适配新表结构 |
| 7 | service/HealthDimensionScoreService.java |
接口更新 |
| 8 | service/impl/HealthDimensionScoreServiceImpl.java |
实现更新 |
| 9 | service/DimensionScoreService.java |
接口新增 refreshFromReport() 等 |
| 10 | service/impl/DimensionScoreServiceImpl.java |
⚠️ 核心重写:新7维、百分位引擎、Tier优先级、菌群映射 |
| 11 | controller/HealthDimensionController.java |
适配新DTO,新增菌群刷新端点 |
| 12 | dto/HealthDimensionVO.java |
增 percentile/dataSource/tier/expireDate |
| 13 | dto/DimensionUploadVO.java |
增 sourceType/confidence |
| 14 | dto/HealthDimensionQuestionnaireVO.java |
不变(结构通用) |
| 15 | dto/DimensionScoreVO.java |
更新默认7维列表 |
| 16 | config/DatabaseInitializer.java |
⚠️ 更新DDL(3表)+ 新增7维常模种子数据 |
| # | File | Change |
|---|---|---|
| 17 | pages/body/health-dimensions.vue |
7维标签更换 + 数据源图标 + 问卷入口 |
Wave 1 (DB + Entity — 3 parallel tasks, depends on nothing):
├── Task 1: Database migration (更新3张表DDL + 常模种子数据)
├── Task 2: 更新3个实体类
└── Task 3: 更新3个Mapper
Wave 2 (Service Core — 3 parallel tasks, depends on Wave 1):
├── Task 4: HealthDimensionScoreService CRUD + HealthDataSourceRecordService 更新
├── Task 5: DimensionScoreService 核心聚合器重构 (新7维 + 百分位 + Tier优先级)
└── Task 6: 菌群报告→维度映射 (PdfParseService 集成)
Wave 3 (API + DTO — 2 parallel tasks, depends on Wave 2):
├── Task 7: 更新DTO对象
└── Task 8: 更新Controller (适配新DTO + 新增refresh端点)
Wave 4 (Frontend — depends on Wave 3):
└── Task 9: 更新前端页面
Wave FINAL (Verification):
├── F1: mvn clean compile
├── F2: 启动验证 + API curl测试
├── F3: 前端构建验证
└── F4: 设计文档合规性检查
Files: config/DatabaseInitializer.java
Note: 现有表 health_dimension_score 和 health_data_source_record 已有生产数据。迁移策略:
is_latest=1 的记录映射到新表)注意: DatabaseInitializer.java 使用 jdbcTemplate.execute() 和 migrate("V...", () -> { ... }) 模式。新迁移加在 migrateHealthDimensionsTable() 方法中。
[ ] Step 1: 创建 health_dimension_scores 新表(在 migrateHealthDimensionsTable() 前添加新迁移)
// 在 DatabaseInitializer.java 的 migrateHealthDimensionsTable() 调用之前,添加一个新的 migrate 调用:
migrate("V20260626_01__create_health_dimension_scores", () -> {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_dimension_scores` (\n" +
" `id` bigint NOT NULL AUTO_INCREMENT,\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 DEFAULT NULL 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 DEFAULT NULL COMMENT '原始数据快照',\n" +
" `assess_date` date NOT NULL COMMENT '评估日期',\n" +
" `expire_date` date DEFAULT NULL COMMENT '数据过期日',\n" +
" `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" +
" `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n" +
" PRIMARY KEY (`id`),\n" +
" UNIQUE KEY `uk_member_dim_date` (`member_id`, `dimension`, `assess_date`)\n" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康维度评分表'");
log.info("health_dimension_scores 表创建完成");
return true;
});
[ ] Step 2: 创建 health_data_sources 新表
migrate("V20260626_02__create_health_data_sources", () -> {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_data_sources` (\n" +
" `id` bigint NOT NULL AUTO_INCREMENT,\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 DEFAULT NULL COMMENT '解析结果全量',\n" +
" `file_url` varchar(500) DEFAULT NULL COMMENT '原始文件URL',\n" +
" `confidence` decimal(3,2) DEFAULT NULL COMMENT 'AI解析置信度',\n" +
" `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" +
" PRIMARY KEY (`id`),\n" +
" KEY `idx_member_source` (`member_id`, `source_type`)\n" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据源记录表'");
log.info("health_data_sources 表创建完成");
return true;
});
[ ] Step 3: 重构 health_norm_reference 表(修改表结构为百分位模式)
migrate("V20260626_03__recreate_health_norm_reference", () -> {
jdbcTemplate.execute("DROP TABLE IF EXISTS `health_norm_reference_old`");
jdbcTemplate.execute("RENAME TABLE `health_norm_reference` TO `health_norm_reference_old`");
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_norm_reference` (\n" +
" `id` bigint NOT NULL AUTO_INCREMENT,\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_X 统一为 0-100 评分(与 health_dimension_scores.score 同尺度)
" `percentile_5` int DEFAULT NULL,\n" +
" `percentile_15` int DEFAULT NULL,\n" +
" `percentile_25` int DEFAULT NULL,\n" +
" `percentile_50` int DEFAULT NULL COMMENT '中位值',\n" +
" `percentile_75` int DEFAULT NULL,\n" +
" `percentile_85` int DEFAULT NULL,\n" +
" `percentile_95` int DEFAULT NULL,\n" +
" `source` varchar(50) DEFAULT NULL COMMENT '常模来源',\n" +
" `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" +
" PRIMARY KEY (`id`),\n" +
" UNIQUE KEY `uk_dim_gender_age` (`dimension`, `gender`, `age_min`, `age_max`)\n" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康常模对照表'");
log.info("health_norm_reference 表重建完成");
return true;
});
[ ] Step 4: 插入7维常模种子数据(修改 seedHealthNorms() 方法)
// 替换现有的 seedHealthNorms() 方法体
// 注意:所有 percentile_X 字段的单位统一为 0-100 评分(与 health_dimension_scores.score 同尺度),
// 这样 calcPercentile() 可以直接用 score 与常模比较,无需单位换算。
// 以下数据基于国家标准/行业参考的人群分布,转换为百分制评分。
private void seedHealthNorms() {
try {
int count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM health_norm_reference", Integer.class);
if (count == 0) {
// 生长发育 (growth) — 基于BMI-for-age分布映射到0-100评分
jdbcTemplate.execute("INSERT 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\n" +
"('growth', 'male', 72, 84, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
"('growth', 'female', 72, 84, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
"('growth', 'male', 84, 96, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
"('growth', 'female', 84, 96, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
"('growth', 'male', 96, 120, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
"('growth', 'female', 96, 120, 25, 35, 45, 55, 70, 80, 90, 'BMI-for-age→百分制'),\n" +
// 睡眠质量 (sleep) — 时长分布→百分制评分
"('sleep', 'all', 0, 12, 40, 50, 60, 70, 80, 88, 95, 'NSF婴幼儿睡眠→百分制'),\n" +
"('sleep', 'all', 12, 36, 40, 50, 60, 70, 80, 88, 95, 'NSF幼儿睡眠→百分制'),\n" +
"('sleep', 'all', 36, 72, 40, 50, 60, 70, 80, 88, 95, 'NSF学龄前睡眠→百分制'),\n" +
"('sleep', 'all', 72, 156, 40, 50, 60, 70, 80, 88, 95, 'NSF学龄儿童睡眠→百分制'),\n" +
// 视力健康 (vision) — 对数视力→百分制评分
"('vision', 'male', 72, 144, 45, 55, 65, 75, 85, 90, 95, '视力发育标准→百分制'),\n" +
"('vision', 'female', 72, 144, 45, 55, 65, 75, 85, 90, 95, '视力发育标准→百分制'),\n" +
// 免疫力 (immunity) — 年感冒次数→百分制评分(次数越少分越高)
"('immunity', 'all', 0, 72, 15, 30, 45, 65, 80, 90, 95, '年患病次数→百分制'),\n" +
"('immunity', 'all', 72, 192, 15, 35, 50, 70, 85, 92, 98, '年患病次数→百分制'),\n" +
// 营养均衡 (nutrition) — 膳食多样性评分→百分制
"('nutrition', 'all', 36, 192, 20, 30, 40, 50, 60, 70, 80, '膳食多样性→百分制'),\n" +
// 肠胃健康 (gut) — 菌群健康度评分(已是0-100)
"('gut', 'all', 0, 192, 40, 50, 60, 70, 80, 85, 95, '菌群人群分布(平台自建)'),\n" +
// 运动活力 (exercise) — 体质健康标准(已是0-100)
"('exercise', 'male', 72, 144, 30, 40, 50, 60, 70, 80, 90, '国家学生体质健康标准'),\n" +
"('exercise', 'female', 72, 144, 25, 35, 45, 55, 65, 75, 85, '国家学生体质健康标准')");
log.info("health_norm_reference 常模种子数据已加载 (18条, 统一0-100百分制)");
} else {
log.info("health_norm_reference 常模数据已存在 ({}条), 跳过", count);
}
} catch (Exception e) {
log.warn("health_norm_reference 常模种子数据初始化失败: {}", e.getMessage());
}
}
[ ] Step 5: 数据迁移 — 将旧 health_dimension_score 数据迁移到新表
migrate("V20260626_04__migrate_old_dimension_scores", () -> {
// 检查旧表是否有数据
Integer oldCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'health_dimension_score'",
Integer.class);
if (oldCount != null && oldCount > 0) {
// 将旧表数据映射到新表(维度code映射)
jdbcTemplate.execute("INSERT IGNORE INTO health_dimension_scores (member_id, dimension, score, data_source, tier, assess_date, created_at)\n" +
" SELECT member_id,\n" +
" CASE dimension\n" +
" WHEN 'body' THEN 'growth'\n" +
" WHEN 'fitness' THEN 'exercise'\n" +
" WHEN 'cardio' THEN 'exercise'\n" +
" WHEN 'blood' THEN 'immunity'\n" +
" WHEN 'sleep' THEN 'sleep'\n" +
" WHEN 'psyche' THEN 'growth'\n" +
" WHEN 'diet' THEN 'nutrition'\n" +
" ELSE dimension\n" +
" END,\n" +
" score,\n" +
" UPPER(data_source),\n" +
" 3,\n" +
" record_date,\n" +
" NOW()\n" +
" FROM health_dimension_score WHERE is_latest = 1");
log.info("旧维度评分数据迁移完成");
}
return true;
});
[ ] Step 6: 编译验证
export PATH=$HOME/.local/bin:$PATH
cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
[ ] Step 7: 提交
git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
git commit -m "feat(db): migrate 7-dim health tables to design spec schema + norm seed data"
Files:
entity/HealthDimensionScore.javaentity/HealthNormReference.javaModify: entity/HealthDataSourceRecord.java
[ ] Step 1: 重写 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 2: 重写 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 3: 重写 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; // 0.00-1.00
private Date createdAt;
}
[ ] Step 4: 编译验证
cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
[ ] Step 5: 提交
git add cfc-backend/src/main/java/com/etotem/cfc/entity/
git commit -m "feat(entity): remodel 3 health dimension entities to match design spec"
Files:
mapper/HealthDimensionScoreMapper.javamapper/HealthNormReferenceMapper.javaModify: mapper/HealthDataSourceRecordMapper.java
[ ] Step 1: 重写 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;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface HealthDimensionScoreMapper extends BaseMapper<HealthDimensionScore> {
// 取某个member最新的7维评分(每个维度最新assess_date的一条)
@Select("SELECT h.* FROM health_dimension_scores h " +
"INNER JOIN (SELECT dimension, MAX(assess_date) max_date FROM health_dimension_scores " +
"WHERE member_id = #{memberId} GROUP BY dimension) latest " +
"ON h.dimension = latest.dimension AND h.assess_date = latest.max_date " +
"WHERE h.member_id = #{memberId}")
List<HealthDimensionScore> selectLatestByMember(@Param("memberId") Long memberId);
// 取某member某维度的最新评分
@Select("SELECT * FROM health_dimension_scores WHERE member_id = #{memberId} " +
"AND dimension = #{dimension} ORDER BY assess_date DESC LIMIT 1")
HealthDimensionScore selectLatest(@Param("memberId") Long memberId, @Param("dimension") String dimension);
// 取某member某维度的历史评分
@Select("SELECT * FROM health_dimension_scores WHERE member_id = #{memberId} " +
"AND dimension = #{dimension} ORDER BY assess_date DESC LIMIT #{limit}")
List<HealthDimensionScore> selectHistory(@Param("memberId") Long memberId,
@Param("dimension") String dimension, @Param("limit") int limit);
// 删除现有迁移方法中用不到 `updateExpired`,新表用 UK 机制保证唯一性
}
[ ] Step 2: 重写 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;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface HealthNormReferenceMapper extends BaseMapper<HealthNormReference> {
// 根据维度+性别+年龄查到对应的常模行(按年龄范围匹配)
@Select("SELECT * FROM health_norm_reference WHERE dimension = #{dimension} " +
"AND (gender = #{gender} OR gender = 'all') " +
"AND age_min <= #{ageMonths} AND age_max > #{ageMonths} " +
"ORDER BY gender DESC LIMIT 1")
HealthNormReference selectNorm(@Param("dimension") String dimension,
@Param("gender") String gender, @Param("ageMonths") Integer ageMonths);
}
[ ] Step 3: 重写 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;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface HealthDataSourceRecordMapper extends BaseMapper<HealthDataSourceRecord> {
@Select("SELECT * FROM health_data_sources WHERE member_id = #{memberId} " +
"AND source_type = #{sourceType} ORDER BY created_at DESC LIMIT 1")
HealthDataSourceRecord selectLatestBySource(@Param("memberId") Long memberId,
@Param("sourceType") String sourceType);
@Select("SELECT * FROM health_data_sources WHERE member_id = #{memberId} " +
"ORDER BY created_at DESC")
List<HealthDataSourceRecord> selectByMember(@Param("memberId") Long memberId);
@Select("SELECT * FROM health_data_sources WHERE member_id = #{memberId} " +
"AND JSON_CONTAINS(dimension_codes, #{dimensionCode}) " +
"ORDER BY created_at DESC LIMIT #{limit}")
List<HealthDataSourceRecord> selectByDimension(@Param("memberId") Long memberId,
@Param("dimensionCode") String dimensionCode, @Param("limit") int limit);
}
[ ] Step 4: 编译验证
cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
[ ] Step 5: 提交
git add cfc-backend/src/main/java/com/etotem/cfc/mapper/
git commit -m "feat(mapper): update 3 health dimension mappers for new schema"
Files:
service/HealthDimensionScoreService.javaModify: service/impl/HealthDimensionScoreServiceImpl.java
[ ] Step 1: 更新 HealthDimensionScoreService.java 接口
package com.etotem.cfc.service;
import com.etotem.cfc.entity.HealthDimensionScore;
import java.util.List;
public interface HealthDimensionScoreService {
void saveScore(HealthDimensionScore score);
List<HealthDimensionScore> getLatestScores(Long memberId);
HealthDimensionScore getLatestScore(Long memberId, String dimension);
List<HealthDimensionScore> getHistoryScores(Long memberId, String dimension, int limit);
}
[ ] Step 2: 更新 HealthDimensionScoreServiceImpl.java
package com.etotem.cfc.service.impl;
import com.etotem.cfc.entity.HealthDimensionScore;
import com.etotem.cfc.mapper.HealthDimensionScoreMapper;
import com.etotem.cfc.service.HealthDimensionScoreService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.List;
@Service
public class HealthDimensionScoreServiceImpl implements HealthDimensionScoreService {
@Resource
private HealthDimensionScoreMapper scoreMapper;
@Override
@Transactional
public void saveScore(HealthDimensionScore score) {
scoreMapper.insert(score);
}
@Override
public List<HealthDimensionScore> getLatestScores(Long memberId) {
return scoreMapper.selectLatestByMember(memberId);
}
@Override
public HealthDimensionScore getLatestScore(Long memberId, String dimension) {
return scoreMapper.selectLatest(memberId, dimension);
}
@Override
public List<HealthDimensionScore> getHistoryScores(Long memberId, String dimension, int limit) {
return scoreMapper.selectHistory(memberId, dimension, limit);
}
}
[ ] Step 3: 编译验证
cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
[ ] Step 4: 提交
git add cfc-backend/src/main/java/com/etotem/cfc/service/HealthDimensionScoreService.java
git add cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDimensionScoreServiceImpl.java
git commit -m "feat(service): update HealthDimensionScoreService for new entity"
Files:
service/DimensionScoreService.javaservice/impl/DimensionScoreServiceImpl.java这是整个重构的核心。需要实现:
[ ] Step 1: 更新 DimensionScoreService.java 接口
package com.etotem.cfc.service;
import com.etotem.cfc.dto.HealthDimensionVO;
import com.etotem.cfc.dto.DimensionUploadVO;
import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
import java.util.List;
public interface DimensionScoreService {
/** 获取某member的7维概览(含百分位) */
HealthDimensionVO getHealthDimension(Long memberId);
/** 手动上传单维度评分 */
void uploadScore(DimensionUploadVO vo);
/** 问卷提交(答案→均分→维度评分) */
void submitQuestionnaire(HealthDimensionQuestionnaireVO vo);
/** 获取维度历史 */
List<HealthDimensionVO.DimensionItem> getDimensionHistory(Long memberId, String dimension, int limit);
/** 菌群报告解析后刷新维度(核心映射逻辑) */
void refreshFromGutReport(Long memberId, Long reportId);
}
[ ] Step 2: 重写 DimensionScoreServiceImpl.java
package com.etotem.cfc.service.impl;
import com.etotem.cfc.dto.HealthDimensionVO;
import com.etotem.cfc.dto.DimensionUploadVO;
import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
import com.etotem.cfc.entity.HealthDimensionScore;
import com.etotem.cfc.entity.HealthDataSourceRecord;
import com.etotem.cfc.entity.HealthGutFlora;
import com.etotem.cfc.entity.HealthIndicator;
import com.etotem.cfc.entity.HealthDiseaseRisk;
import com.etotem.cfc.entity.HealthNormReference;
import com.etotem.cfc.entity.HealthReport;
import com.etotem.cfc.mapper.HealthNormReferenceMapper;
import com.etotem.cfc.service.DimensionScoreService;
import com.etotem.cfc.service.HealthDimensionScoreService;
import com.etotem.cfc.service.HealthReportService;
import com.etotem.cfc.service.HealthDataSourceRecordService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class DimensionScoreServiceImpl implements DimensionScoreService {
// === 设计文档规范的7维 ===
private static final List<String[]> DIMENSIONS = Arrays.asList(
new String[]{"growth", "生长发育"},
new String[]{"sleep", "睡眠质量"},
new String[]{"vision", "视力健康"},
new String[]{"immunity", "免疫力"},
new String[]{"nutrition", "营养均衡"},
new String[]{"gut", "肠胃健康"},
new String[]{"exercise", "运动活力"}
);
private static final Map<String, Integer> DIMENSION_TIER = new HashMap<>();
static {
// Tier 1: 报告/自动数据
DIMENSION_TIER.put("growth", 1);
DIMENSION_TIER.put("sleep", 1);
DIMENSION_TIER.put("vision", 1);
DIMENSION_TIER.put("immunity", 1);
DIMENSION_TIER.put("nutrition", 1);
DIMENSION_TIER.put("gut", 1);
DIMENSION_TIER.put("exercise", 1);
}
@Resource
private HealthDimensionScoreService scoreService;
@Resource
private HealthNormReferenceMapper normMapper;
@Resource
private HealthReportService healthReportService;
@Override
public HealthDimensionVO getHealthDimension(Long memberId) {
List<HealthDimensionScore> latest = scoreService.getLatestScores(memberId);
Map<String, HealthDimensionScore> scoreMap = latest.stream()
.collect(Collectors.toMap(HealthDimensionScore::getDimension, s -> s, (a, b) -> a));
List<HealthDimensionVO.DimensionItem> items = new ArrayList<>();
BigDecimal totalScore = BigDecimal.ZERO;
int validCount = 0;
for (String[] dim : DIMENSIONS) {
String code = dim[0];
String label = dim[1];
HealthDimensionScore found = scoreMap.get(code);
HealthDimensionVO.DimensionItem item = new HealthDimensionVO.DimensionItem();
item.setDimension(code);
item.setLabel(label);
if (found != null) {
item.setScore(BigDecimal.valueOf(found.getScore()));
item.setPercentile(found.getPercentile());
item.setDataSource(found.getDataSource());
item.setTier(found.getTier());
item.setLevel(calcLevel(found.getScore()));
item.setTrend("stable");
if (found.getExpireDate() != null) {
item.setExpireDate(new SimpleDateFormat("yyyy-MM-dd").format(found.getExpireDate()));
}
totalScore = totalScore.add(BigDecimal.valueOf(found.getScore()));
validCount++;
} else {
item.setScore(null);
item.setLevel("none");
item.setTrend(null);
}
items.add(item);
}
HealthDimensionVO vo = new HealthDimensionVO();
vo.setMemberId(memberId);
vo.setHealthIndex(validCount > 0
? totalScore.divide(BigDecimal.valueOf(validCount), 2, RoundingMode.HALF_UP) : null);
vo.setDimensions(items);
vo.setUpdateTime(new Date());
return vo;
}
@Override
@Transactional
public void uploadScore(DimensionUploadVO vo) {
HealthDimensionScore score = new HealthDimensionScore();
score.setMemberId(vo.getMemberId());
score.setDimension(vo.getDimension());
score.setScore(vo.getScore() != null ? vo.getScore().intValue() : 0);
score.setDataSource(vo.getSourceType() != null ? vo.getSourceType() : "MANUAL");
score.setTier(3); // 手动上传默认 Tier 3
if (vo.getRecordDate() != null) {
score.setAssessDate(java.sql.Date.valueOf(vo.getRecordDate()));
} else {
score.setAssessDate(new java.sql.Date(System.currentTimeMillis()));
}
// 计算百分位
score.setPercentile(calcPercentile(vo.getMemberId(), vo.getDimension(),
vo.getScore() != null ? vo.getScore().intValue() : 0));
scoreService.saveScore(score);
}
@Override
@Transactional
public void submitQuestionnaire(HealthDimensionQuestionnaireVO vo) {
BigDecimal total = BigDecimal.ZERO;
int count = 0;
for (HealthDimensionQuestionnaireVO.QuestionAnswer qa : vo.getAnswers()) {
if (qa.getScore() != null) {
total = total.add(qa.getScore());
count++;
}
}
if (count == 0) return;
int avgScore = total.divide(BigDecimal.valueOf(count), 0, RoundingMode.HALF_UP).intValue();
HealthDimensionScore score = new HealthDimensionScore();
score.setMemberId(vo.getMemberId());
score.setDimension(vo.getDimension());
score.setScore(avgScore);
score.setDataSource("MANUAL");
score.setTier(3);
score.setAssessDate(new java.sql.Date(System.currentTimeMillis()));
score.setPercentile(calcPercentile(vo.getMemberId(), vo.getDimension(), avgScore));
scoreService.saveScore(score);
}
@Override
public List<HealthDimensionVO.DimensionItem> getDimensionHistory(Long memberId, String dimension, int limit) {
List<HealthDimensionScore> history = scoreService.getHistoryScores(memberId, dimension, limit);
List<HealthDimensionVO.DimensionItem> items = new ArrayList<>();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (HealthDimensionScore s : history) {
HealthDimensionVO.DimensionItem item = new HealthDimensionVO.DimensionItem();
item.setDimension(s.getDimension());
item.setScore(BigDecimal.valueOf(s.getScore()));
item.setPercentile(s.getPercentile());
item.setDataSource(s.getDataSource());
item.setTier(s.getTier());
item.setLevel(calcLevel(s.getScore()));
item.setRecordDate(sdf.format(s.getAssessDate()));
items.add(item);
}
return items;
}
@Override
@Transactional
public void refreshFromGutReport(Long memberId, Long reportId) {
HealthReport report = healthReportService.getReportById(reportId);
if (report == null) return;
List<HealthGutFlora> gutFlora = healthReportService.getGutFloraByReportId(reportId);
List<HealthIndicator> indicators = healthReportService.getReportIndicators(reportId);
List<HealthDiseaseRisk> diseaseRisks = healthReportService.getDiseaseRisksByReportId(reportId);
Map<String, Integer> dimScores = mapGutFloraToDimensions(report, gutFlora, indicators, diseaseRisks);
// 写入维度评分(Tier 1: 报告数据)
Date assessDate = report.getReportDate() != null
? report.getReportDate() : new java.sql.Date(System.currentTimeMillis());
for (Map.Entry<String, Integer> entry : dimScores.entrySet()) {
HealthDimensionScore score = new HealthDimensionScore();
score.setMemberId(memberId);
score.setDimension(entry.getKey());
score.setScore(entry.getValue());
score.setPercentile(calcPercentile(memberId, entry.getKey(), entry.getValue()));
score.setDataSource("REPORT");
score.setTier(1);
score.setAssessDate(assessDate);
// 菌群报告有效期6个月
Calendar cal = Calendar.getInstance();
cal.setTime(assessDate);
cal.add(Calendar.MONTH, 6);
score.setExpireDate(cal.getTime());
score.setRawData("{\"reportId\":" + reportId + "}");
scoreService.saveScore(score);
}
}
/**
* 根据设计文档的映射矩阵,将已有健康报告数据映射到7维评分。
*
* 数据来源:
* - HealthReport 本身的评分子段: gutHealthScore, chronicDiseaseScore, nutritionScore,
* gutBalanceScore, gutDiversityScore, beneficialBacteriaScore, harmfulBacteriaScore, coreSpeciesScore
* - HealthIndicator(indicators): category + indicatorValue(需解析String→数值)
* - HealthDiseaseRisk(diseaseRisks): riskValue(String)/riskLevel → 反向分
* - HealthGutFlora(gutFlora): populationLevel(String, e.g."35%") 作为人群百分位参考
*
* 映射矩阵参照设计文档 3.2 节。
*/
private Map<String, Integer> mapGutFloraToDimensions(
HealthReport report,
List<HealthGutFlora> gutFlora,
List<HealthIndicator> indicators,
List<HealthDiseaseRisk> diseaseRisks) {
Map<String, List<Integer>> dimValues = new HashMap<>();
for (String[] dim : DIMENSIONS) {
dimValues.put(dim[0], new ArrayList<>());
}
// === 1. 从 HealthReport 直接取评分字段 ===
// gutHealthScore → immunity, gut
if (report.getGutHealthScore() != null) {
dimValues.get("immunity").add(report.getGutHealthScore());
dimValues.get("gut").add(report.getGutHealthScore());
}
// chronicDiseaseScore → growth, nutrition(反转:分越高越健康)
if (report.getChronicDiseaseScore() != null) {
int inverted = 100 - report.getChronicDiseaseScore();
dimValues.get("growth").add(inverted);
dimValues.get("nutrition").add(inverted);
}
// nutritionScore → nutrition
if (report.getNutritionScore() != null) {
dimValues.get("nutrition").add(report.getNutritionScore());
}
// gutBalanceScore → gut
if (report.getGutBalanceScore() != null) {
dimValues.get("gut").add(report.getGutBalanceScore());
}
// gutDiversityScore → immunity, gut
if (report.getGutDiversityScore() != null) {
dimValues.get("immunity").add(report.getGutDiversityScore());
dimValues.get("gut").add(report.getGutDiversityScore());
}
// beneficialBacteriaScore → immunity, gut
if (report.getBeneficialBacteriaScore() != null) {
dimValues.get("immunity").add(report.getBeneficialBacteriaScore());
dimValues.get("gut").add(report.getBeneficialBacteriaScore());
}
// harmfulBacteriaScore → immunity, gut(反转)
if (report.getHarmfulBacteriaScore() != null) {
int inverted = 100 - report.getHarmfulBacteriaScore();
dimValues.get("immunity").add(inverted);
dimValues.get("gut").add(inverted);
}
// coreSpeciesScore → gut
if (report.getCoreSpeciesScore() != null) {
dimValues.get("gut").add(report.getCoreSpeciesScore());
}
// gutAge → sleep, gut(解析字符串,如 "35岁" → 数值映射)
if (report.getGutAge() != null) {
try {
int gutAgeYears = Integer.parseInt(report.getGutAge().replaceAll("[^0-9]", ""));
// 肠道年龄越小越好:计算与生理年龄的差值映射
int gutAgeScore = Math.max(0, Math.min(100, 100 - gutAgeYears * 2));
dimValues.get("sleep").add(gutAgeScore);
dimValues.get("gut").add(gutAgeScore);
} catch (NumberFormatException ignored) {}
}
// === 2. 从 HealthIndicator 取(category分类)===
for (HealthIndicator ind : indicators) {
String cat = ind.getCategory();
String rawVal = ind.getIndicatorValue();
if (cat == null || rawVal == null) continue;
int val = tryParseNumericIndicator(rawVal);
if (val < 0) continue; // 无法解析为数值
if ("神经递质与激素".contains(cat != null ? cat : "")) {
dimValues.get("sleep").add(val);
} else if ("短链脂肪酸".equals(cat)) {
dimValues.get("nutrition").add(val);
dimValues.get("gut").add(val);
} else if ("肠道屏障".equals(cat)) {
dimValues.get("immunity").add(val);
dimValues.get("gut").add(val);
} else if ("抗生素耐药".equals(cat)) {
// 耐药分越低越好 → 反转
dimValues.get("immunity").add(100 - Math.min(val, 100));
} else if (cat != null && (cat.contains("维生素") || cat.contains("矿物质") || cat.contains("微量"))) {
dimValues.get("nutrition").add(val);
}
}
// === 3. 从 HealthDiseaseRisk 取 ===
for (HealthDiseaseRisk risk : diseaseRisks) {
if (risk.getRiskValue() == null) continue;
int riskVal = tryParseNumericIndicator(risk.getRiskValue());
if (riskVal < 0) {
// 用 riskLevel 映射
if ("低风险".equals(risk.getRiskLevel())) riskVal = 20;
else if ("需注意".equals(risk.getRiskLevel())) riskVal = 50;
else if ("高风险".equals(risk.getRiskLevel())) riskVal = 80;
else continue;
}
int invertedScore = 100 - Math.min(riskVal, 100);
// 慢性病/代谢类风险 → growth, nutrition
String name = risk.getDiseaseName();
if (name != null && (name.contains("代谢") || name.contains("肥胖") || name.contains("超重"))) {
dimValues.get("growth").add(invertedScore);
dimValues.get("nutrition").add(invertedScore);
}
// 感染类风险 → immunity
if (name != null && (name.contains("感染") || name.contains("炎症"))) {
dimValues.get("immunity").add(invertedScore);
}
// 其他风险都影响肠胃
dimValues.get("gut").add(invertedScore);
}
// === 4. 从 HealthGutFlora 取 populationLevel(作为百分位参考,不直接用于打分)===
// populationLevel 如 "35%" 表示该菌种处于人群35%水平
// 这里暂不直接用,留给后续百分位校准
// 聚合:每个维度取平均值
Map<String, Integer> result = new HashMap<>();
for (String[] dim : DIMENSIONS) {
List<Integer> vals = dimValues.get(dim[0]);
if (vals != null && !vals.isEmpty()) {
int avg = (int) vals.stream().mapToInt(v -> v).average().orElse(0);
result.put(dim[0], Math.min(100, Math.max(0, avg)));
}
}
return result;
}
/** 尝试从字符串解析数值,失败返回 -1 */
private int tryParseNumericIndicator(String val) {
if (val == null) return -1;
try {
String cleaned = val.replaceAll("[^0-9.]", "").trim();
if (cleaned.isEmpty()) return -1;
return (int) Double.parseDouble(cleaned);
} catch (NumberFormatException e) {
return -1;
}
}
/**
* 百分位计算:根据原始分(0-100) + 性别年龄 → 查常模 → 返回百分位
*
* 常模表 health_norm_reference 的 percentile_X 字段统一为 0-100 评分,
* 与 health_dimension_scores.score 同尺度,可直接整数比较。
*/
private Integer calcPercentile(Long memberId, String dimension, int score) {
// TODO: 需要从用户资料读取性别和年龄(月)
// 这里简化实现,使用默认常模
HealthNormReference norm = normMapper.selectNorm(dimension, "all", 120);
if (norm == null) return null;
// 将原始分映射到百分位(同尺度直接比较)
if (norm.getPercentile95() != null && score >= norm.getPercentile95()) return 95;
if (norm.getPercentile85() != null && score >= norm.getPercentile85()) return 85;
if (norm.getPercentile75() != null && score >= norm.getPercentile75()) return 75;
if (norm.getPercentile50() != null && score >= norm.getPercentile50()) return 50;
if (norm.getPercentile25() != null && score >= norm.getPercentile25()) return 25;
if (norm.getPercentile15() != null && score >= norm.getPercentile15()) return 15;
if (norm.getPercentile5() != null && score >= norm.getPercentile5()) return 5;
return 1;
}
private String calcLevel(int score) {
if (score >= 85) return "excellent";
if (score >= 60) return "good";
if (score >= 40) return "fair";
return "poor";
}
}
[ ] Step 3: 编译验证
cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
[ ] Step 4: 提交
git add cfc-backend/src/main/java/com/etotem/cfc/service/DimensionScoreService.java
git add cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java
git commit -m "feat(service): refactor DimensionScoreService - new 7-dim, percentile engine, gut mapping"
Files:
service/PdfParseService.java 或新增加方法HealthReportController.java 中添加报告上传后的触发调用注意: PdfParseService 的 ParsedReportResult 对象包含 getGutHealthScore()、getChronicDiseaseScore()、getNutritionScore()、getBalanceScore()、getDiversityScore()、getBeneficialScore()、getHarmfulScore()、getCoreGenusScore() 等字段。已在 HealthReportController 的报告上传流程中使用。
需要在报告上传成功创建后,调用 DimensionScoreServiceImpl.refreshFromGutReport()。
HealthReportController 的报告创建端点中,创建完后调用菌群映射定位到 HealthReportController 中的报告创建端点(/api/health/report/upload 或类似),在 healthReportService.createReport() 调用之后添加:
// 触发菌群报告→7维映射
try {
dimensionScoreService.refreshFromGutReport(matchedChildId, created.getId());
log.info("已触发菌群报告维度映射: reportId={}, childId={}", created.getId(), matchedChildId);
} catch (Exception e) {
log.warn("菌群报告维度映射失败: {}", e.getMessage());
}
需要在 Controller 中注入 DimensionScoreService:@Resource private DimensionScoreService dimensionScoreService;
[ ] Step 2: 编译验证
cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
[ ] Step 3: 提交
git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
git commit -m "feat(integration): trigger gut flora dimension mapping after report upload"
Files:
dto/HealthDimensionVO.javadto/DimensionUploadVO.javaModify: dto/DimensionScoreVO.java
[ ] Step 1: 更新 HealthDimensionVO.java
package com.etotem.cfc.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
@Data
public class HealthDimensionVO {
private Long memberId;
private BigDecimal healthIndex; // 综合健康指数
private List<DimensionItem> dimensions;
private Date updateTime;
@Data
public static class DimensionItem {
private String dimension; // growth/sleep/vision/immunity/nutrition/gut/exercise
private String label; // 中文名
private BigDecimal score; // 0-100
private Integer percentile; // 人群百分位
private String dataSource; // REPORT/PHOTO/VOICE/MANUAL
private Integer tier; // 1/2/3
private String level; // excellent/good/fair/poor/none
private String trend; // up/down/stable
private String recordDate; // 评估日期
private String expireDate; // 过期日期
}
}
[ ] Step 2: 更新 DimensionUploadVO.java
package com.etotem.cfc.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class DimensionUploadVO {
private Long memberId;
private String dimension;
private BigDecimal score;
private String sourceType; // REPORT/PHOTO/VOICE/MANUAL
private BigDecimal confidence; // AI置信度
private String recordDate;
}
[ ] Step 3: 更新 DimensionScoreVO.java
package com.etotem.cfc.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
public class DimensionScoreVO {
private String dimension;
private BigDecimal score;
private String label;
private String dataSource;
private String recordDate;
public DimensionScoreVO() {}
public DimensionScoreVO(String dimension, String label, BigDecimal score, String dataSource, String recordDate) {
this.dimension = dimension;
this.label = label;
this.score = score;
this.dataSource = dataSource;
this.recordDate = recordDate;
}
public static List<DimensionScoreVO> DEFAULT_DIMENSIONS = java.util.Arrays.asList(
new DimensionScoreVO("growth", "生长发育", null, null, null),
new DimensionScoreVO("sleep", "睡眠质量", null, null, null),
new DimensionScoreVO("vision", "视力健康", null, null, null),
new DimensionScoreVO("immunity", "免疫力", null, null, null),
new DimensionScoreVO("nutrition", "营养均衡", null, null, null),
new DimensionScoreVO("gut", "肠胃健康", null, null, null),
new DimensionScoreVO("exercise", "运动活力", null, null, null)
);
}
[ ] Step 4: 编译验证
cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
[ ] Step 5: 提交
git add cfc-backend/src/main/java/com/etotem/cfc/dto/
git commit -m "feat(dto): update DTOs for new 7-dim model and percentile"
Files:
Modify: controller/HealthDimensionController.java
[ ] Step 1: 更新 Controller
保留现有4个端点,@RequestMapping("/api/dimension") 也保持不动(API对外稳定),仅更新 DTO 引用即可。
主要变更:
upload 端点:参数验证适配新字段名可选:新增统计端点
package com.etotem.cfc.controller;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.dto.DimensionUploadVO;
import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
import com.etotem.cfc.dto.HealthDimensionVO;
import com.etotem.cfc.service.DimensionScoreService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@Tag(name = "健康维度", description = "七维健康指标查询与录入(设计文档规范版)")
@RestController
@RequestMapping("/api/dimension")
public class HealthDimensionController {
@Resource
private DimensionScoreService dimensionScoreService;
@Operation(summary = "获取健康维度概览(7维+百分位)")
@PostMapping("/overview")
public Result<HealthDimensionVO> overview(
@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long userId) {
Long memberId = params.get("memberId") != null
? Long.valueOf(params.get("memberId").toString()) : null;
if (memberId == null) return Result.error("memberId不能为空");
HealthDimensionVO vo = dimensionScoreService.getHealthDimension(memberId);
return Result.success(vo);
}
@Operation(summary = "手动录入维度评分")
@PostMapping("/upload")
public Result<Void> upload(
@RequestBody DimensionUploadVO vo,
@RequestAttribute("userId") Long userId) {
if (vo.getMemberId() == null || vo.getDimension() == null || vo.getScore() == null) {
return Result.error("memberId, dimension, score不能为空");
}
dimensionScoreService.uploadScore(vo);
return Result.success(null);
}
@Operation(summary = "问卷提交维度评分")
@PostMapping("/questionnaire")
public Result<Void> questionnaire(
@RequestBody HealthDimensionQuestionnaireVO vo,
@RequestAttribute("userId") Long userId) {
if (vo.getMemberId() == null || vo.getDimension() == null || vo.getAnswers() == null) {
return Result.error("memberId, dimension, answers不能为空");
}
dimensionScoreService.submitQuestionnaire(vo);
return Result.success(null);
}
@Operation(summary = "获取维度历史记录")
@PostMapping("/history")
public Result<List<HealthDimensionVO.DimensionItem>> history(
@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long userId) {
Long memberId = params.get("memberId") != null
? Long.valueOf(params.get("memberId").toString()) : null;
String dimension = (String) params.get("dimension");
int limit = params.get("limit") != null ? Integer.parseInt(params.get("limit").toString()) : 10;
if (memberId == null || dimension == null) {
return Result.error("memberId和dimension不能为空");
}
List<HealthDimensionVO.DimensionItem> items =
dimensionScoreService.getDimensionHistory(memberId, dimension, limit);
return Result.success(items);
}
}
[ ] Step 2: 编译验证
cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
[ ] Step 3: 提交
git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthDimensionController.java
git commit -m "feat(controller): update to use new DTOs and consistent 7-dim API"
Files:
Modify: cfc-frontend/pages/body/health-dimensions.vue
[ ] Step 1: 更新维度选项和数据源选项
// 将 dimensionOptions 从旧的7维替换为新的7维:
dimensionOptions: [
{ code: 'growth', label: '生长发育' },
{ code: 'sleep', label: '睡眠质量' },
{ code: 'vision', label: '视力健康' },
{ code: 'immunity', label: '免疫力' },
{ code: 'nutrition', label: '营养均衡' },
{ code: 'gut', label: '肠胃健康' },
{ code: 'exercise', label: '运动活力' }
],
sourceOptions: [
{ code: 'REPORT', label: '健康报告' },
{ code: 'PHOTO', label: '拍照识别' },
{ code: 'VOICE', label: '语音输入' },
{ code: 'MANUAL', label: '手动录入' }
]
[ ] Step 2: 更新模板中的数据显示
在维度卡片的 dim-header 区域旁边新增百分位显示:
<text class="dim-percentile" v-if="dim.percentile">
优于 {{ dim.percentile }}% 同龄人
</text>
在 dim-footer 区域新增数据源图标显示:
<text class="dim-source-icon" v-if="dim.dataSource">
{{ dim.dataSource === 'REPORT' ? '📄' : dim.dataSource === 'PHOTO' ? '📷' : dim.dataSource === 'VOICE' ? '🎤' : '✏️' }}
{{ dim.dataSource }}
</text>
在级别标签旁新增Tier显示:
<text class="dim-tier" v-if="dim.tier">
Tier{{ dim.tier }}
</text>
[ ] Step 3: 编译验证
cd /sc-data/cfc/cfc-frontend && rtk npx tsc --noEmit 2>&1 | tail -10 || echo "no TS check; verifying via build"
[ ] Step 4: 提交
git add cfc-frontend/pages/body/health-dimensions.vue
git commit -m "feat(frontend): update 7-dim labels and add percentile+source display"
[ ] F1: 编译验证
cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1
cd /sc-data/cfc/cfc-frontend && rtk npx vue-cli-service build --mode production 2>&1 | tail -10
[ ] F2: API端点测试(curl)
# 启动应用(后台)
cd /sc-data/cfc/cfc-backend && mvn spring-boot:run > /tmp/app.log 2>&1 &
sleep 30
# 测试overview端点
curl -s -X POST http://localhost:9082/api/dimension/overview \
-H "Content-Type: application/json" \
-d '{"memberId": 1}' | python3 -m json.tool
# 测试upload端点
curl -s -X POST http://localhost:9082/api/dimension/upload \
-H "Content-Type: application/json" \
-d '{"memberId":1,"dimension":"sleep","score":85,"sourceType":"MANUAL"}' | python3 -m json.tool
# 测试questionnaire端点
curl -s -X POST http://localhost:9082/api/dimension/questionnaire \
-H "Content-Type: application/json" \
-d '{"memberId":1,"dimension":"nutrition","answers":[{"questionId":"q1","answer":"A","score":80},{"questionId":"q2","answer":"B","score":70}]}' | python3 -m json.tool
# 停应用
kill %1
[ ] F3: 设计文档合规性检查
逐项检查:
growth/sleep/vision/immunity/nutrition/gut/exercisepercentile、tier、raw_data、assess_date、expire_datehealth_norm_reference 是否为百分位结构(percentile_5~95)refreshFromGutReport()[ ] 常模种子数据是否包含了所有7维
[ ] F4: 最终提交
git push origin cfclub