|
|
@@ -0,0 +1,1466 @@
|
|
|
+# 身体健康7维模型 — 重构对齐计划(Phase 1)
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 将现有7维健康实现(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/confidence
|
|
|
+- `DimensionScoreServiceImpl` 核心聚合器:替换7维列表 + 实现百分位计算 + 数据源Tier优先级 + 菌群映射集成
|
|
|
+
|
|
|
+**Tech Stack:** Java 8, Spring Boot 2.7.18, MyBatis-Plus, MySQL 8.0, uni-app Vue 2
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Context
|
|
|
+
|
|
|
+### 设计文档中定义的7维
|
|
|
+```java
|
|
|
+// 枚举值(维度code → 中文名)
|
|
|
+growth → "生长发育"
|
|
|
+sleep → "睡眠质量"
|
|
|
+vision → "视力健康"
|
|
|
+immunity → "免疫力"
|
|
|
+nutrition → "营养均衡"
|
|
|
+gut → "肠胃健康"
|
|
|
+exercise → "运动活力"
|
|
|
+```
|
|
|
+
|
|
|
+### 设计文档中的Phase 1范围
|
|
|
+1. 核心框架:`health_dimension_scores` / `health_data_sources` / `health_norm_reference` 3张表
|
|
|
+2. `DimensionScoreService` 聚合逻辑(Tier 3 问卷兜底 + Tier 1 报告覆盖)
|
|
|
+3. `HealthDimensionController` — 获取7维 / 手动输入问卷
|
|
|
+4. 前端:7维雷达图 + 每个维度简单问卷
|
|
|
+5. **已有菌群报告数据直接映射**(PdfParseService 解析结果 → 维度评分)
|
|
|
+6. 部署常模种子数据(BMI/睡眠/体测等国家标准)
|
|
|
+
|
|
|
+### 数据源优先级
|
|
|
+- Tier 1 (REPORT): 菌群报告/体检报告 → 覆盖低层级
|
|
|
+- Tier 2 (VOICE/PHOTO): 语音解析/拍照 → 置信度<0.6降级
|
|
|
+- Tier 3 (MANUAL): 问卷/手动 → 兜底
|
|
|
+
|
|
|
+### 常模百分位颜色
|
|
|
+- ≥85 → 优秀 (深绿)
|
|
|
+- 60-84 → 良好 (浅绿)
|
|
|
+- 40-59 → 一般 (黄)
|
|
|
+- <40 → 关注 (橙/红)
|
|
|
+
|
|
|
+### 菌群报告 → 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 Structure
|
|
|
+
|
|
|
+### Modified Backend Files
|
|
|
+| # | 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维常模种子数据 |
|
|
|
+
|
|
|
+### Modified Frontend Files
|
|
|
+| # | File | Change |
|
|
|
+|:-:|------|--------|
|
|
|
+| 17 | `pages/body/health-dimensions.vue` | 7维标签更换 + 数据源图标 + 问卷入口 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Parallel Execution Waves
|
|
|
+
|
|
|
+```
|
|
|
+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: 设计文档合规性检查
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## TODOs
|
|
|
+
|
|
|
+### Task 1: 数据库迁移(更新3张表 + 常模种子数据)
|
|
|
+
|
|
|
+**Files:** `config/DatabaseInitializer.java`
|
|
|
+
|
|
|
+**Note:** 现有表 `health_dimension_score` 和 `health_data_source_record` 已有生产数据。迁移策略:
|
|
|
+1. 创建新表(新名字/新结构)
|
|
|
+2. 迁移旧数据(将 `is_latest=1` 的记录映射到新表)
|
|
|
+3. 保留旧表不动(防止数据丢失),仅在新表上工作
|
|
|
+
|
|
|
+**注意:** `DatabaseInitializer.java` 使用 `jdbcTemplate.execute()` 和 `migrate("V...", () -> { ... })` 模式。新迁移加在 `migrateHealthDimensionsTable()` 方法中。
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 `health_dimension_scores` 新表(在 `migrateHealthDimensionsTable()` 前添加新迁移)**
|
|
|
+
|
|
|
+```java
|
|
|
+// 在 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` 新表**
|
|
|
+
|
|
|
+```java
|
|
|
+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` 表(修改表结构为百分位模式)**
|
|
|
+
|
|
|
+```java
|
|
|
+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()` 方法)**
|
|
|
+
|
|
|
+```java
|
|
|
+ // 替换现有的 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` 数据迁移到新表**
|
|
|
+
|
|
|
+```java
|
|
|
+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: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+export PATH=$HOME/.local/bin:$PATH
|
|
|
+cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 更新3个实体类
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `entity/HealthDimensionScore.java`
|
|
|
+- Modify: `entity/HealthNormReference.java`
|
|
|
+- Modify: `entity/HealthDataSourceRecord.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 重写 `HealthDimensionScore.java`**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+import java.io.Serializable;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("health_dimension_scores")
|
|
|
+public class HealthDimensionScore implements Serializable {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long memberId;
|
|
|
+ private String dimension; // growth/sleep/vision/immunity/nutrition/gut/exercise
|
|
|
+ private Integer score; // 0-100
|
|
|
+ private Integer percentile; // 人群百分位 0-100
|
|
|
+ private String dataSource; // REPORT/PHOTO/VOICE/MANUAL
|
|
|
+ private Integer tier; // 1/2/3
|
|
|
+ private String rawData; // JSON
|
|
|
+ private Date assessDate;
|
|
|
+ private Date expireDate;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 重写 `HealthNormReference.java`**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+import java.io.Serializable;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("health_norm_reference")
|
|
|
+public class HealthNormReference implements Serializable {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private String dimension;
|
|
|
+ private String gender; // male/female/all
|
|
|
+ private Integer ageMin; // 月
|
|
|
+ private Integer ageMax; // 月
|
|
|
+ private Integer percentile5;
|
|
|
+ private Integer percentile15;
|
|
|
+ private Integer percentile25;
|
|
|
+ private Integer percentile50;
|
|
|
+ private Integer percentile75;
|
|
|
+ private Integer percentile85;
|
|
|
+ private Integer percentile95;
|
|
|
+ private String source;
|
|
|
+ private Date createdAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 重写 `HealthDataSourceRecord.java`**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+import java.io.Serializable;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("health_data_sources")
|
|
|
+public class HealthDataSourceRecord implements Serializable {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long memberId;
|
|
|
+ private String sourceType; // PDF/PHOTO/VOICE/MANUAL/WEIXIN_STEP
|
|
|
+ private String dimensionCodes; // JSON array
|
|
|
+ private String parsedResult; // JSON
|
|
|
+ private String fileUrl;
|
|
|
+ private BigDecimal confidence; // 0.00-1.00
|
|
|
+ private Date createdAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 更新3个Mapper
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `mapper/HealthDimensionScoreMapper.java`
|
|
|
+- Modify: `mapper/HealthNormReferenceMapper.java`
|
|
|
+- Modify: `mapper/HealthDataSourceRecordMapper.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 重写 `HealthDimensionScoreMapper.java`**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.HealthDimensionScore;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+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`**
|
|
|
+
|
|
|
+```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`**
|
|
|
+
|
|
|
+```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: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/
|
|
|
+git commit -m "feat(mapper): update 3 health dimension mappers for new schema"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 更新 HealthDimensionScoreService + HealthDataSourceRecordService
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `service/HealthDimensionScoreService.java`
|
|
|
+- Modify: `service/impl/HealthDimensionScoreServiceImpl.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 更新 `HealthDimensionScoreService.java` 接口**
|
|
|
+
|
|
|
+```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`**
|
|
|
+
|
|
|
+```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: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: DimensionScoreService 核心聚合器重构
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `service/DimensionScoreService.java`
|
|
|
+- Modify: `service/impl/DimensionScoreServiceImpl.java`
|
|
|
+
|
|
|
+这是整个重构的核心。需要实现:
|
|
|
+1. 7维列表从旧 → 新
|
|
|
+2. 百分位计算引擎(原始分 → 查常模 → 百分位)
|
|
|
+3. 数据源Tier优先级合并
|
|
|
+4. 健康指数计算
|
|
|
+
|
|
|
+- [ ] **Step 1: 更新 `DimensionScoreService.java` 接口**
|
|
|
+
|
|
|
+```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`**
|
|
|
+
|
|
|
+```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: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: 菌群报告映射集成完善
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `service/PdfParseService.java` 或新增加方法
|
|
|
+- Modify: 在 `HealthReportController.java` 中添加报告上传后的触发调用
|
|
|
+
|
|
|
+**注意**: PdfParseService 的 `ParsedReportResult` 对象包含 `getGutHealthScore()`、`getChronicDiseaseScore()`、`getNutritionScore()`、`getBalanceScore()`、`getDiversityScore()`、`getBeneficialScore()`、`getHarmfulScore()`、`getCoreGenusScore()` 等字段。已在 `HealthReportController` 的报告上传流程中使用。
|
|
|
+
|
|
|
+需要在报告上传成功创建后,调用 `DimensionScoreServiceImpl.refreshFromGutReport()`。
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 `HealthReportController` 的报告创建端点中,创建完后调用菌群映射**
|
|
|
+
|
|
|
+定位到 `HealthReportController` 中的报告创建端点(`/api/health/report/upload` 或类似),在 `healthReportService.createReport()` 调用之后添加:
|
|
|
+
|
|
|
+```java
|
|
|
+// 触发菌群报告→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: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: 更新DTO对象
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `dto/HealthDimensionVO.java`
|
|
|
+- Modify: `dto/DimensionUploadVO.java`
|
|
|
+- Modify: `dto/DimensionScoreVO.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 更新 `HealthDimensionVO.java`**
|
|
|
+
|
|
|
+```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`**
|
|
|
+
|
|
|
+```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`**
|
|
|
+
|
|
|
+```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: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: 更新 Controller
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `controller/HealthDimensionController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 更新 Controller**
|
|
|
+
|
|
|
+保留现有4个端点,`@RequestMapping("/api/dimension")` 也保持不动(API对外稳定),仅更新 DTO 引用即可。
|
|
|
+
|
|
|
+主要变更:
|
|
|
+1. `upload` 端点:参数验证适配新字段名
|
|
|
+2. 可选:新增统计端点
|
|
|
+
|
|
|
+```java
|
|
|
+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: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && rtk mvn clean compile -q 2>&1 | tail -20
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 9: 更新前端页面
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/pages/body/health-dimensions.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 更新维度选项和数据源选项**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 将 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` 区域旁边新增百分位显示:
|
|
|
+```html
|
|
|
+<text class="dim-percentile" v-if="dim.percentile">
|
|
|
+ 优于 {{ dim.percentile }}% 同龄人
|
|
|
+</text>
|
|
|
+```
|
|
|
+
|
|
|
+在 `dim-footer` 区域新增数据源图标显示:
|
|
|
+```html
|
|
|
+<text class="dim-source-icon" v-if="dim.dataSource">
|
|
|
+ {{ dim.dataSource === 'REPORT' ? '📄' : dim.dataSource === 'PHOTO' ? '📷' : dim.dataSource === 'VOICE' ? '🎤' : '✏️' }}
|
|
|
+ {{ dim.dataSource }}
|
|
|
+</text>
|
|
|
+```
|
|
|
+
|
|
|
+在级别标签旁新增Tier显示:
|
|
|
+```html
|
|
|
+<text class="dim-tier" v-if="dim.tier">
|
|
|
+ Tier{{ dim.tier }}
|
|
|
+</text>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-frontend && rtk npx tsc --noEmit 2>&1 | tail -10 || echo "no TS check; verifying via build"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/body/health-dimensions.vue
|
|
|
+git commit -m "feat(frontend): update 7-dim labels and add percentile+source display"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Final Verification Wave
|
|
|
+
|
|
|
+- [ ] **F1: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+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)**
|
|
|
+
|
|
|
+```bash
|
|
|
+# 启动应用(后台)
|
|
|
+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: 设计文档合规性检查**
|
|
|
+
|
|
|
+逐项检查:
|
|
|
+- [ ] 7维code是否为 `growth/sleep/vision/immunity/nutrition/gut/exercise`
|
|
|
+- [ ] 表结构是否有 `percentile`、`tier`、`raw_data`、`assess_date`、`expire_date`
|
|
|
+- [ ] `health_norm_reference` 是否为百分位结构(percentile_5~95)
|
|
|
+- [ ] 菌群报告上传后是否触发了 `refreshFromGutReport()`
|
|
|
+- [ ] 常模种子数据是否包含了所有7维
|
|
|
+
|
|
|
+- [ ] **F4: 最终提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git push origin cfclub
|
|
|
+```
|