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