|
|
@@ -0,0 +1,3174 @@
|
|
|
+# 指标变化曲线 · 指标速查 · 指标关联 — 后端实现计划
|
|
|
+
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
+>
|
|
|
+> 本计划覆盖设计文档 P1(数据层)+ P2(归一化管道)+ P3(视图 API)+ P6(校准任务)四个后端分期。小程序(P4)与管理端(P5)是独立计划,不在此文件。
|
|
|
+
|
|
|
+**目标:** 将 `indicator_definitions`/`indicator_values` 升级为全平台统一指标层:真实报告日期、归一化映射管道(exact→fuzzy→AI→惰性注册)、指标变化曲线/变化幅度排名/指标速查 3 个视图接口、指标关联定义与 Spearman 数据校准。
|
|
|
+
|
|
|
+**架构:** 复用现有 `health_reports`/`health_indicators`/`food_recommend_idx` 作为原始数据,新增 `indicator_mapping_rules` + `indicator_correlations` 两表;`NormalizeIndicatorPipeline` 作为单一写者在报告入库/编辑/食材重算三个钩子处幂等写入 `indicator_values`(唯一键 `(source_type, source_id, definition_id)`);视图层 `IndicatorTrendService` 只读 `indicator_values`,五维/七维评分 API 层单独合入不写入时序;校准用纯 Java 实现的 Spearman 秩相关 + Fisher z 聚合,`@Scheduled` 每日自动跑 + 管理端手动触发。
|
|
|
+
|
|
|
+**技术栈:** Java 8(禁止 var/List.of/record)、Spring Boot 2.7.18、MyBatis-Plus(`LambdaQueryWrapper`)、JUnit5 + Mockito(参照 `FoodServiceTest`,不加载 Spring 上下文)、MySQL 8(utf8mb4)、FastAPI/Pydantic(cfc-langgraph 侧 AI 归类端点)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+| 文件 | 操作 | 职责 |
|
|
|
+|---|---|---|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 改 | 迁移 356~362(见任务 1) |
|
|
|
+| `cfc-backend/src/main/resources/schema.sql` | 改 | 同步 indicator_definitions / indicator_values / 新两表的完整 DDL 快照 |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java` | 改 | +5 列字段与 getter/setter |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java` | 改 | +6 列字段与 getter/setter |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java` | 新建 | 映射规则实体(Lombok @Data) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java` | 新建 | 关联关系实体(Lombok @Data) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java` | 新建 | 空 BaseMapper |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java` | 新建 | 空 BaseMapper |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java` | 改 | 新增 `upsertMapping` 幂等写方法(@Insert ON DUPLICATE KEY UPDATE) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java` | 新建 | Spearman rho + Fisher z 聚合(纯函数,约 40 行) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java` | 新建 | payload 真实报告日期提取 + 历史回填分批任务 |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java` | 新建 | resolve(raw)→definitionId(exact→fuzzy→AI→惰性注册);importSeeds |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java` | 新建 | 报告指标/菌属 + 食材快照 幂等写入 indicator_values |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java` | 新建 | trend / change-ranking / quick-lookup(含五维七维小节) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java` | 新建 | 关联 CRUD + 对序强制 + effective_strength + Spearman 校准 |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java` | 新建 | /api/indicator/* 4 个视图接口(subjectId 归属校验) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java` | 新建 | 关联 save/list/calibrate |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java` | 新建 | 映射 import/list |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java` | 新建 | @Scheduled 每日 03:00 自动校准 |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java` | 改 | +classifyIndicator(照 generatePortrait 模板) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java` | 改 | insertReport() subjectId 块后调 pipeline(钩子 1) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java` | 改 | updateReportFromPayload() 步骤 5 后调 pipeline(钩子 2) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java` | 改 | recalculateForUser() 尾部调 pipeline.snapshotFoodIndices(钩子 3) |
|
|
|
+| `cfc-langgraph/app/api/indicator_classify.py` | 新建 | POST /api/v1/indicator/classify(AI 归类兜底) |
|
|
|
+| `cfc-langgraph/app/main.py` | 改 | 注册新 router |
|
|
|
+| `docs/superpowers/api/API_REFERENCE.md` | 改 | 同步记录 9 个新接口 |
|
|
|
+
|
|
|
+**关键决策(锁定,不得自行更改):**
|
|
|
+
|
|
|
+1. **迁移编号从 356 起**(最新现有迁移为 355,`DatabaseInitializer.java` L11855-11873)。
|
|
|
+2. **`indicator_values` 唯一键** `uk_source_definition (source_type, source_id, definition_id)` —— 报告指标/菌属写 `source_id = health_reports.id`,食物指数写 `source_id = food_recommend_idx.id`。重解析/重算同键覆盖更新,保证验收标准 6(幂等)。
|
|
|
+3. **`idx_def` 不再新增**:schema.sql 中该表已有 `idx_definition (definition_id)`,语义等价,迁移只加 `uk_source_definition` 与 `idx_subject_def_date`,避免重复索引。
|
|
|
+4. **新实体用 Lombok `@Data`**(与 `HealthIndicator`/`FoodRecommendIndex` 一致);`IndicatorDefinition`/`IndicatorValue` 维持手工 getter/setter(既有风格,只加字段)。
|
|
|
+5. **惰性注册 code 规则**:`indicator.<category>.<name>`(报告指标)/ `bacteria.<name>`(菌属)/ `food.<name>`(食材,不依赖 foodId 表,raw_name 即食材名)。`source_kind` 对应 `report_indicator`/`bacteria`/`food_index`。
|
|
|
+6. **映射规则表 `source_type` 统一用报告类型**(`gut_flora` 等):报告指标、菌属、食材三条来源都以 `source_type=报告类型` 登记,raw_name 互不相同故不冲突;`food_recommend` 仅作为 indicator_values.source_type 语义保留(食材快照走钩子 3 时 source_type=报告类型,report_id 关联)。
|
|
|
+7. **报告日期回填不伪装上传时间**:提取失败置 `report_date = NULL`;回填筛选条件带 `payload_json LIKE '%reportDate%'`,避免对提取失败的记录无限重复处理。
|
|
|
+8. **五维/七维不进 indicator_values**:quick-lookup 的 dimensionScores 小节在 `IndicatorTrendService` 内 join 原表(`five_dimension_scores` / `health_dimension_scores`)。
|
|
|
+9. **AI 兜底只建议**:`AiGateway.classifyIndicator` 返回建议的 definition code/name,`IndicatorMappingService` 将其登记为 `match_type='ai_fallback', status=0`(待确认),**不自动启用**;调用方拿到建议 definitionId 照常写入(AI 兜底路径允许临时写入,管理端 review 修正规则后重解析自动纠正)。
|
|
|
+10. **`jsonArray()` / `ensureReportTypeRow()` / `seedExpectedIndicator()`** 是 DatabaseInitializer 既有私有助手(L11978-12022),新迁移可复用;`ensureColumn(table,column,definition)` 在 L4855。
|
|
|
+11. **钩子事务边界**:钩子 2 所在 `updateReportFromPayload` 已标 `@Transactional`,管道与其同事务;钩子 1 的 `insertReport` 是私有非事务方法(由异步采集调用),管道方法自身标 `@Transactional(propagation = REQUIRED)` 随调用方;钩子 3 的 `recalculateForUser` 已标 `@Transactional`。**不要在管道中开启新事务的 REQUIRES_NEW**。
|
|
|
+12. **subjectId 归属校验**(视图接口):`@RequestAttribute("familyId")` 必须存在,`IndicatorController` 校验 `subjectId` 属于该家庭(查 `family_members` 表),不属于则返回 `Result.error("成员不属于当前家庭")`。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 1:数据库迁移(P1)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(`runMigrations()` L129 内、文件末 `}` L12023 前追加方法 + 在 runMigrations 中调用)
|
|
|
+- 修改:`cfc-backend/src/main/resources/schema.sql`(indicator_definitions 段约 L2501、indicator_values 段约 L2518,末尾追加两新表)
|
|
|
+
|
|
|
+### 步骤 1:迁移 356 — indicator_definitions 加 5 列
|
|
|
+
|
|
|
+在 `runMigrations()` 末尾(最后一个迁移方法调用之后)追加调用 `migrateIndicatorDefinitionsColumns()`,并新增该方法:
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移356: indicator_definitions 升级为统一指标层(指标变化曲线/速查/关联 需求)
|
|
|
+private void migrateIndicatorDefinitionsColumns() {
|
|
|
+ ensureColumn("indicator_definitions", "source_kind",
|
|
|
+ "VARCHAR(32) DEFAULT NULL COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score'");
|
|
|
+ ensureColumn("indicator_definitions", "report_type_scope",
|
|
|
+ "VARCHAR(32) DEFAULT NULL COMMENT '适用报告类型(type_id,空=通用)'");
|
|
|
+ ensureColumn("indicator_definitions", "value_type",
|
|
|
+ "VARCHAR(16) DEFAULT NULL COMMENT '语义类型: numeric/categorical/level'");
|
|
|
+ ensureColumn("indicator_definitions", "good_direction",
|
|
|
+ "VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral'");
|
|
|
+ ensureColumn("indicator_definitions", "dimension_code",
|
|
|
+ "VARCHAR(16) DEFAULT NULL COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)'");
|
|
|
+ log.info("迁移356: indicator_definitions 已补齐统一指标层 5 列");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 2:迁移 357 — indicator_values 加 6 列
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移357: indicator_values 升级为统一观测时序(subject_id/family_id/report_id/report_date/unit/status)
|
|
|
+private void migrateIndicatorValuesColumns() {
|
|
|
+ ensureColumn("indicator_values", "subject_id", "BIGINT DEFAULT NULL COMMENT '观测对象(family_members.id)'");
|
|
|
+ ensureColumn("indicator_values", "family_id", "BIGINT DEFAULT NULL COMMENT '所属家庭(family_members.family_id)'");
|
|
|
+ ensureColumn("indicator_values", "report_id", "BIGINT DEFAULT NULL COMMENT '来源报告(health_reports.id,食物指数可空)'");
|
|
|
+ ensureColumn("indicator_values", "report_date", "DATE DEFAULT NULL COMMENT '冗余真实报告日期(时序查询键)'");
|
|
|
+ ensureColumn("indicator_values", "unit", "VARCHAR(32) DEFAULT NULL COMMENT '观测单位'");
|
|
|
+ ensureColumn("indicator_values", "status", "VARCHAR(32) DEFAULT NULL COMMENT '观测状态快照(正常/偏高/偏低等)'");
|
|
|
+ log.info("迁移357: indicator_values 已补齐统一观测时序 6 列");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 3:迁移 358 — 唯一键 + 时序索引
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移358: indicator_values 幂等唯一键(source_type+source_id+definition_id)+ 时序查询索引
|
|
|
+private void addIndicatorValuesKeys() {
|
|
|
+ try {
|
|
|
+ // 先清理极端重复(保留最小 id),避免建唯一键失败;正常数据本无重复
|
|
|
+ jdbcTemplate.execute(
|
|
|
+ "DELETE v FROM indicator_values v " +
|
|
|
+ "JOIN (SELECT MIN(id) keep_id, source_type, source_id, definition_id " +
|
|
|
+ " FROM indicator_values " +
|
|
|
+ " GROUP BY source_type, source_id, definition_id " +
|
|
|
+ " HAVING COUNT(*) > 1) d " +
|
|
|
+ " ON v.source_type = d.source_type AND v.source_id = d.source_id " +
|
|
|
+ " AND v.definition_id = d.definition_id AND v.id <> d.keep_id");
|
|
|
+ log.info("迁移358: 已清理 indicator_values 重复行(如有)");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移358: 清理 indicator_values 重复行失败(表可能为空): {}", e.getMessage());
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("ALTER TABLE indicator_values ADD UNIQUE KEY uk_source_definition (source_type, source_id, definition_id)");
|
|
|
+ log.info("迁移358: 已添加 uk_source_definition 唯一键");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.info("迁移358: uk_source_definition 已存在,跳过");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("ALTER TABLE indicator_values ADD INDEX idx_subject_def_date (subject_id, definition_id, report_date)");
|
|
|
+ log.info("迁移358: 已添加 idx_subject_def_date 索引");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.info("迁移358: idx_subject_def_date 已存在,跳过");
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 4:迁移 359 + 360 — 新建两表
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移359: 新建 indicator_mapping_rules(指标归一化映射规则)
|
|
|
+private void createIndicatorMappingRulesTable() {
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_mapping_rules (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
|
+ "source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension'," +
|
|
|
+ "raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)'," +
|
|
|
+ "alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配'," +
|
|
|
+ "definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id'," +
|
|
|
+ "match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created'," +
|
|
|
+ "priority INT DEFAULT 0 COMMENT '多规则冲突时取最高'," +
|
|
|
+ "status TINYINT DEFAULT 1," +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
|
|
|
+ "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
|
|
|
+ "UNIQUE KEY uk_source_raw (source_type, raw_name)," +
|
|
|
+ "INDEX idx_def (definition_id)" +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则'");
|
|
|
+ log.info("迁移359: 已创建 indicator_mapping_rules 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移359: 创建 indicator_mapping_rules 表失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 迁移360: 新建 indicator_correlations(指标关联关系:人工定义 + 数据校准)
|
|
|
+private void createIndicatorCorrelationsTable() {
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_correlations (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
|
+ "definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标'," +
|
|
|
+ "definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标'," +
|
|
|
+ "relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source'," +
|
|
|
+ "direction VARCHAR(10) NOT NULL COMMENT 'positive|negative'," +
|
|
|
+ "manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1'," +
|
|
|
+ "computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)'," +
|
|
|
+ "method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman'," +
|
|
|
+ "sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数'," +
|
|
|
+ "confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)'," +
|
|
|
+ "effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual'," +
|
|
|
+ "manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖'," +
|
|
|
+ "cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population'," +
|
|
|
+ "status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending'," +
|
|
|
+ "computed_at DATETIME NULL COMMENT '最后校准时间'," +
|
|
|
+ "created_by BIGINT NULL COMMENT '创建者'," +
|
|
|
+ "description VARCHAR(500) NULL COMMENT '给用户看的解释'," +
|
|
|
+ "source_note VARCHAR(255) NULL COMMENT '依据/出处'," +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
|
|
|
+ "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
|
|
|
+ "UNIQUE KEY uk_pair (definition_id_a, definition_id_b)," +
|
|
|
+ "INDEX idx_def_a (definition_id_a)," +
|
|
|
+ "INDEX idx_def_b (definition_id_b)" +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)'");
|
|
|
+ log.info("迁移360: 已创建 indicator_correlations 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移360: 创建 indicator_correlations 表失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 5:迁移 361 + 362 — collection_date 兜底 + 五维定义种子
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移361: health_indicators.collection_date 生产库兜底(schema.sql 已有该列,仅确保老库补齐)
|
|
|
+private void ensureIndicatorCollectionDate() {
|
|
|
+ ensureColumn("health_indicators", "collection_date",
|
|
|
+ "DATETIME COMMENT '指标采集日期(报告检测日期)'");
|
|
|
+ log.info("迁移361: health_indicators.collection_date 已确认存在");
|
|
|
+}
|
|
|
+
|
|
|
+// 迁移362: 预置五维展示定义(dim.body 等,仅作展示标签,不写 indicator_values)
|
|
|
+private void seedDimensionDefinitions() {
|
|
|
+ Object[][] dims = {
|
|
|
+ {"dim.body", "身", "身体", "higher_is_better", "higher_is_better"},
|
|
|
+ {"dim.mind", "心", "心理", "higher_is_better", "higher_is_better"},
|
|
|
+ {"dim.wisdom", "智", "智慧", "higher_is_better", "higher_is_better"},
|
|
|
+ {"dim.action", "行", "行动", "higher_is_better", "higher_is_better"},
|
|
|
+ {"dim.wealth", "富", "财富", "higher_is_better", "higher_is_better"},
|
|
|
+ };
|
|
|
+ for (Object[] d : dims) {
|
|
|
+ try {
|
|
|
+ Integer exists = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(*) FROM indicator_definitions WHERE code = ?", Integer.class, (String) d[0]);
|
|
|
+ if (exists == null || exists == 0) {
|
|
|
+ jdbcTemplate.update(
|
|
|
+ "INSERT INTO indicator_definitions (domain, category, code, name, data_type, unit, " +
|
|
|
+ "source_kind, value_type, good_direction, dimension_code, sort_order, status) " +
|
|
|
+ "VALUES ('physical', ?, ?, ?, 'number', '分', 'dimension_score', 'numeric', ?, ?, 0, 1)",
|
|
|
+ d[1], d[0], d[2], d[4], d[3]);
|
|
|
+ log.info("迁移362: 已预置维度定义 {}", d[0]);
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移362: 预置维度定义 {} 失败: {}", d[0], e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+在 `runMigrations()` 末尾追加调用:
|
|
|
+
|
|
|
+```java
|
|
|
+// 指标统一层迁移(356~362)
|
|
|
+migrateIndicatorDefinitionsColumns();
|
|
|
+migrateIndicatorValuesColumns();
|
|
|
+addIndicatorValuesKeys();
|
|
|
+createIndicatorMappingRulesTable();
|
|
|
+createIndicatorCorrelationsTable();
|
|
|
+ensureIndicatorCollectionDate();
|
|
|
+seedDimensionDefinitions();
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 6:同步 schema.sql(4 处 DDL)
|
|
|
+
|
|
|
+1. `indicator_definitions` CREATE TABLE(约 L2501)在 `sort_order` 前追加 5 列,与迁移 356 同义:
|
|
|
+
|
|
|
+```sql
|
|
|
+CREATE TABLE IF NOT EXISTS indicator_definitions (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ domain VARCHAR(50) NOT NULL COMMENT '领域: physical/mental/nutrition/behavior/social',
|
|
|
+ category VARCHAR(50) NOT NULL COMMENT '分类: 舌色/苔色/情绪/...',
|
|
|
+ code VARCHAR(50) NOT NULL UNIQUE COMMENT '编码: tongue_color/coating_color/...',
|
|
|
+ name VARCHAR(100) NOT NULL COMMENT '显示名: 舌体颜色/苔色/...',
|
|
|
+ data_type VARCHAR(20) NOT NULL DEFAULT 'enum' COMMENT '数据类型: enum/number/string',
|
|
|
+ options JSON COMMENT '枚举值列表: ["淡红","红","绛","青紫"] 或 null',
|
|
|
+ unit VARCHAR(50) COMMENT '单位',
|
|
|
+ ref_range VARCHAR(200) COMMENT '参考范围',
|
|
|
+ source_kind VARCHAR(32) COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score',
|
|
|
+ report_type_scope VARCHAR(32) COMMENT '适用报告类型(type_id,空=通用)',
|
|
|
+ value_type VARCHAR(16) COMMENT '语义类型: numeric/categorical/level',
|
|
|
+ good_direction VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral',
|
|
|
+ dimension_code VARCHAR(16) COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)',
|
|
|
+ sort_order INT DEFAULT 0 COMMENT '排序',
|
|
|
+ status TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用',
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标定义';
|
|
|
+```
|
|
|
+
|
|
|
+2. `indicator_values` CREATE TABLE(约 L2518)替换为:
|
|
|
+
|
|
|
+```sql
|
|
|
+CREATE TABLE IF NOT EXISTS indicator_values (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ source_type VARCHAR(50) NOT NULL COMMENT '来源: tongue_record/health_report/assessment',
|
|
|
+ source_id BIGINT NOT NULL COMMENT '来源记录ID',
|
|
|
+ definition_id BIGINT NOT NULL COMMENT 'FK→indicator_definitions.id',
|
|
|
+ value VARCHAR(200) COMMENT '值',
|
|
|
+ numeric_value DECIMAL(10,2) COMMENT '数值(number类型时用)',
|
|
|
+ remark TEXT COMMENT '备注',
|
|
|
+ subject_id BIGINT COMMENT '观测对象(family_members.id)',
|
|
|
+ family_id BIGINT COMMENT '所属家庭(family_members.family_id)',
|
|
|
+ report_id BIGINT COMMENT '来源报告(health_reports.id)',
|
|
|
+ report_date DATE COMMENT '真实报告日期(时序查询键)',
|
|
|
+ unit VARCHAR(32) COMMENT '观测单位',
|
|
|
+ status VARCHAR(32) COMMENT '观测状态快照(正常/偏高/偏低等)',
|
|
|
+ recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ UNIQUE KEY uk_source_definition (source_type, source_id, definition_id),
|
|
|
+ INDEX idx_source (source_type, source_id),
|
|
|
+ INDEX idx_definition (definition_id),
|
|
|
+ INDEX idx_subject_def_date (subject_id, definition_id, report_date)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标值';
|
|
|
+```
|
|
|
+
|
|
|
+3. 文件末尾追加 `indicator_mapping_rules`(与迁移 359 同 DDL)。
|
|
|
+4. 文件末尾追加 `indicator_correlations`(与迁移 360 同 DDL)。
|
|
|
+
|
|
|
+### 步骤 7:验证编译
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:`BUILD SUCCESS`(迁移代码幂等,无需运行时验证)
|
|
|
+
|
|
|
+### 步骤 8:Commit
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
|
|
|
+ cfc-backend/src/main/resources/schema.sql
|
|
|
+git commit -m "feat(indicator): 迁移356-362 升级统一指标层(列/键/两新表/维度种子)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 2:实体与 Mapper(P1)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java`
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java`
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java`
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java`
|
|
|
+
|
|
|
+### 步骤 1:IndicatorDefinition 加 5 字段
|
|
|
+
|
|
|
+在 `private Integer status;` 与 `private Date createdAt;` 之间加字段,并在对应位置补 getter/setter(文件为手工 getter/setter 风格,维持一致):
|
|
|
+
|
|
|
+```java
|
|
|
+ private String sourceKind;
|
|
|
+ private String reportTypeScope;
|
|
|
+ private String valueType;
|
|
|
+ private String goodDirection;
|
|
|
+ private String dimensionCode;
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+ public String getSourceKind() { return sourceKind; }
|
|
|
+ public void setSourceKind(String sourceKind) { this.sourceKind = sourceKind; }
|
|
|
+ public String getReportTypeScope() { return reportTypeScope; }
|
|
|
+ public void setReportTypeScope(String reportTypeScope) { this.reportTypeScope = reportTypeScope; }
|
|
|
+ public String getValueType() { return valueType; }
|
|
|
+ public void setValueType(String valueType) { this.valueType = valueType; }
|
|
|
+ public String getGoodDirection() { return goodDirection; }
|
|
|
+ public void setGoodDirection(String goodDirection) { this.goodDirection = goodDirection; }
|
|
|
+ public String getDimensionCode() { return dimensionCode; }
|
|
|
+ public void setDimensionCode(String dimensionCode) { this.dimensionCode = dimensionCode; }
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 2:IndicatorValue 加 6 字段
|
|
|
+
|
|
|
+在 `private String remark;` 与 `private Date recordedAt;` 之间加字段:
|
|
|
+
|
|
|
+```java
|
|
|
+ private Long subjectId;
|
|
|
+ private Long familyId;
|
|
|
+ private Long reportId;
|
|
|
+ private java.sql.Date reportDate;
|
|
|
+ private String unit;
|
|
|
+ private String status;
|
|
|
+```
|
|
|
+
|
|
|
+并补 getter/setter(`java.sql.Date` 与 `indicator_values.report_date DATE` 列类型一致):
|
|
|
+
|
|
|
+```java
|
|
|
+ public Long getSubjectId() { return subjectId; }
|
|
|
+ public void setSubjectId(Long subjectId) { this.subjectId = subjectId; }
|
|
|
+ public Long getFamilyId() { return familyId; }
|
|
|
+ public void setFamilyId(Long familyId) { this.familyId = familyId; }
|
|
|
+ public Long getReportId() { return reportId; }
|
|
|
+ public void setReportId(Long reportId) { this.reportId = reportId; }
|
|
|
+ public java.sql.Date getReportDate() { return reportDate; }
|
|
|
+ public void setReportDate(java.sql.Date reportDate) { this.reportDate = reportDate; }
|
|
|
+ public String getUnit() { return unit; }
|
|
|
+ public void setUnit(String unit) { this.unit = unit; }
|
|
|
+ public String getStatus() { return status; }
|
|
|
+ public void setStatus(String status) { this.status = status; }
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 3:创建 IndicatorMappingRule 实体 + Mapper
|
|
|
+
|
|
|
+`entity/IndicatorMappingRule.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.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("indicator_mapping_rules")
|
|
|
+public class IndicatorMappingRule {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private String sourceType;
|
|
|
+ private String rawName;
|
|
|
+ private String aliasText;
|
|
|
+ private Long definitionId;
|
|
|
+ private String matchType;
|
|
|
+ private Integer priority;
|
|
|
+ private Integer status;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`mapper/IndicatorMappingRuleMapper.java`(完整文件):
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.IndicatorMappingRule;
|
|
|
+
|
|
|
+public interface IndicatorMappingRuleMapper extends BaseMapper<IndicatorMappingRule> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 4:创建 IndicatorCorrelation 实体 + Mapper
|
|
|
+
|
|
|
+`entity/IndicatorCorrelation.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.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("indicator_correlations")
|
|
|
+public class IndicatorCorrelation {
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+ private Long definitionIdA;
|
|
|
+ private Long definitionIdB;
|
|
|
+ private String relationType;
|
|
|
+ private String direction;
|
|
|
+ private BigDecimal manualStrength;
|
|
|
+ private BigDecimal computedStrength;
|
|
|
+ private String method;
|
|
|
+ private Integer sampleSize;
|
|
|
+ private String confidence;
|
|
|
+ private BigDecimal effectiveStrength;
|
|
|
+ private Integer manualLocked;
|
|
|
+ private String cohortScope;
|
|
|
+ private String status;
|
|
|
+ private Date computedAt;
|
|
|
+ private Long createdBy;
|
|
|
+ private String description;
|
|
|
+ private String sourceNote;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`mapper/IndicatorCorrelationMapper.java`(完整文件):
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.IndicatorCorrelation;
|
|
|
+
|
|
|
+public interface IndicatorCorrelationMapper extends BaseMapper<IndicatorCorrelation> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 5:IndicatorValueMapper 增加幂等 upsert 方法
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.IndicatorValue;
|
|
|
+import org.apache.ibatis.annotations.Insert;
|
|
|
+import org.apache.ibatis.annotations.Param;
|
|
|
+
|
|
|
+public interface IndicatorValueMapper extends BaseMapper<IndicatorValue> {
|
|
|
+
|
|
|
+ /** 幂等写:同 (source_type, source_id, definition_id) 覆盖更新(uk_source_definition) */
|
|
|
+ @Insert("INSERT INTO indicator_values " +
|
|
|
+ "(source_type, source_id, definition_id, value, numeric_value, remark, " +
|
|
|
+ " subject_id, family_id, report_id, report_date, unit, status, recorded_at) " +
|
|
|
+ "VALUES (#{v.sourceType}, #{v.sourceId}, #{v.definitionId}, #{v.value}, #{v.numericValue}, #{v.remark}, " +
|
|
|
+ " #{v.subjectId}, #{v.familyId}, #{v.reportId}, #{v.reportDate}, #{v.unit}, #{v.status}, NOW()) " +
|
|
|
+ "ON DUPLICATE KEY UPDATE " +
|
|
|
+ " value = VALUES(value), numeric_value = VALUES(numeric_value), remark = VALUES(remark), " +
|
|
|
+ " subject_id = VALUES(subject_id), family_id = VALUES(family_id), report_id = VALUES(report_id), " +
|
|
|
+ " report_date = VALUES(report_date), unit = VALUES(unit), status = VALUES(status), recorded_at = NOW()")
|
|
|
+ int upsertMapping(@Param("v") IndicatorValue v);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+(若该文件原有其他内容与 import,保留原 import 与原有方法,仅合并本方法。)
|
|
|
+
|
|
|
+### 步骤 6:编写失败测试验证 upsert
|
|
|
+
|
|
|
+`src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.IndicatorValue;
|
|
|
+import com.etotem.cfc.mapper.IndicatorValueMapper;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.Mockito;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/** IndicatorValueMapper.upsertMapping 仅做 SQL 注解绑定验证(mock 层) */
|
|
|
+class IndicatorValueMapperTest {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void upsertMappingAnnotationCompiles() {
|
|
|
+ IndicatorValueMapper mapper = Mockito.mock(IndicatorValueMapper.class);
|
|
|
+ IndicatorValue v = new IndicatorValue();
|
|
|
+ v.setSourceType("gut_flora");
|
|
|
+ v.setSourceId(10L);
|
|
|
+ v.setDefinitionId(3L);
|
|
|
+ v.setValue("12.8");
|
|
|
+ v.setNumericValue(new BigDecimal("12.8"));
|
|
|
+ when(mapper.upsertMapping(any(IndicatorValue.class))).thenReturn(1);
|
|
|
+ int result = mapper.upsertMapping(v);
|
|
|
+ assertEquals(1, result);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 7:运行测试验证编译 + 行为
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorValueMapperTest`
|
|
|
+预期:`Tests run: 1, Failures: 0`(若 MyBatis 注解 SQL 有语法错误会在启动 bean 时暴露,此处 mock 层验证注解可编译加载)
|
|
|
+
|
|
|
+### 步骤 8:验证全量编译
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:`BUILD SUCCESS`
|
|
|
+
|
|
|
+### 步骤 9:Commit
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java \
|
|
|
+ cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java
|
|
|
+git commit -m "feat(indicator): 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 3:ReportDateExtractor(P1 — 真实报告日期提取 + 历史回填)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java`
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java`
|
|
|
+
|
|
|
+### 步骤 1:编写失败测试
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+
|
|
|
+class ReportDateExtractorTest {
|
|
|
+
|
|
|
+ private ReportDateExtractor newExtractor() {
|
|
|
+ ReportDateExtractor e = new ReportDateExtractor();
|
|
|
+ // 不注入 mapper,仅测纯提取逻辑
|
|
|
+ return e;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void extractFromNestedSummary() throws Exception {
|
|
|
+ ReportDateExtractor e = newExtractor();
|
|
|
+ String json = "{\"summary\":{\"reportDate\":\"2026-08-15\",\"personName\":\"小李\"},\"indicators\":[]}";
|
|
|
+ java.sql.Date d = e.extractReportDate(json);
|
|
|
+ assertNotNull(d);
|
|
|
+ assertEquals("2026-08-15", new SimpleDateFormat("yyyy-MM-dd").format(d));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void extractFromFlatPayload() throws Exception {
|
|
|
+ ReportDateExtractor e = newExtractor();
|
|
|
+ String json = "{\"reportDate\":\"2026/08/15\"}";
|
|
|
+ java.sql.Date d = e.extractReportDate(json);
|
|
|
+ assertNotNull(d);
|
|
|
+ assertEquals("2026-08-15", new SimpleDateFormat("yyyy-MM-dd").format(d));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void extractReturnsNullWhenMissing() {
|
|
|
+ ReportDateExtractor e = newExtractor();
|
|
|
+ assertNull(e.extractReportDate("{\"summary\":{\"personName\":\"x\"}}"));
|
|
|
+ assertNull(e.extractReportDate(null));
|
|
|
+ assertNull(e.extractReportDate("not json"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void extractFallsBackToIndicatorCollectionDate() throws Exception {
|
|
|
+ ReportDateExtractor e = newExtractor();
|
|
|
+ String json = "{\"summary\":{},\"indicators\":[{\"collectionDate\":\"2026-09-01\"}]}";
|
|
|
+ java.sql.Date d = e.extractReportDate(json);
|
|
|
+ assertNotNull(d);
|
|
|
+ assertEquals("2026-09-01", new SimpleDateFormat("yyyy-MM-dd").format(d));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 2:运行确认失败
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=ReportDateExtractorTest`
|
|
|
+预期:`BUILD FAILURE`,`ReportDateExtractor` 不存在(编译错误)
|
|
|
+
|
|
|
+### 步骤 3:实现 ReportDateExtractor
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.HealthReport;
|
|
|
+import com.etotem.cfc.mapper.HealthReportMapper;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.scheduling.annotation.Scheduled;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 真实报告日期提取 + 历史回填。
|
|
|
+ * PDF 解析阶段将「报告日期/采样日期」写入 payload.reportDate 后,
|
|
|
+ * 入库逻辑(HealthReportService 既有 reportDate 修正)自动生效;
|
|
|
+ * 本类只负责:(1) 从 payload 提取真实日期(供回填/校验);
|
|
|
+ * (2) 扫描旧报告幂等回填 report_date(无法提取置 NULL,不伪装成上传时间)。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class ReportDateExtractor {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(ReportDateExtractor.class);
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthReportMapper healthReportMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ObjectMapper objectMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从 payload JSON 中提取真实报告日期。
|
|
|
+ * 取值顺序:summary.reportDate → 顶层 reportDate → indicators[].collectionDate(取第一个非空)。
|
|
|
+ * 支持 yyyy-MM-dd / yyyy/MM/dd;无法提取返回 null。
|
|
|
+ */
|
|
|
+ public java.sql.Date extractReportDate(String payloadJson) {
|
|
|
+ if (payloadJson == null || payloadJson.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ JsonNode root = objectMapper.readTree(payloadJson);
|
|
|
+ String dateStr = null;
|
|
|
+ JsonNode summary = root.get("summary");
|
|
|
+ if (summary != null && summary.isObject() && summary.hasNonNull("reportDate")) {
|
|
|
+ dateStr = summary.get("reportDate").asText();
|
|
|
+ }
|
|
|
+ if (dateStr == null || dateStr.trim().isEmpty()) {
|
|
|
+ if (root.hasNonNull("reportDate")) {
|
|
|
+ dateStr = root.get("reportDate").asText();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ java.sql.Date parsed = parseDate(dateStr);
|
|
|
+ if (parsed != null) {
|
|
|
+ return parsed;
|
|
|
+ }
|
|
|
+ // 回退:indicators[].collectionDate 取第一个非空
|
|
|
+ JsonNode indicators = root.get("indicators");
|
|
|
+ if (indicators != null && indicators.isArray()) {
|
|
|
+ for (JsonNode ind : indicators) {
|
|
|
+ if (ind.hasNonNull("collectionDate")) {
|
|
|
+ java.sql.Date d = parseDate(ind.get("collectionDate").asText());
|
|
|
+ if (d != null) {
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("ReportDateExtractor 解析 payload 失败: {}", e.getMessage());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private java.sql.Date parseDate(String dateStr) {
|
|
|
+ if (dateStr == null || dateStr.trim().isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String s = dateStr.trim();
|
|
|
+ try {
|
|
|
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
|
|
+ Date d = sdf.parse(s);
|
|
|
+ return new java.sql.Date(d.getTime());
|
|
|
+ } catch (Exception e) {
|
|
|
+ try {
|
|
|
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
|
|
|
+ Date d = sdf.parse(s);
|
|
|
+ return new java.sql.Date(d.getTime());
|
|
|
+ } catch (Exception e2) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 历史回填:扫描 report_date 缺失或疑似上传时间的旧报告(且 payload 含 reportDate 字段),
|
|
|
+ * 提取真实日期并回填;无法提取置 NULL。分批(500/批)、幂等可重跑。
|
|
|
+ *
|
|
|
+ * @return 本轮处理的总行数
|
|
|
+ */
|
|
|
+ public int backfillHistoricalReports() {
|
|
|
+ int total = 0;
|
|
|
+ while (true) {
|
|
|
+ List<HealthReport> batch = healthReportMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<HealthReport>()
|
|
|
+ .isNull(HealthReport::getReportDate)
|
|
|
+ .like(HealthReport::getPayloadJson, "reportDate")
|
|
|
+ .last("LIMIT 500"));
|
|
|
+ if (batch.isEmpty()) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ for (HealthReport report : batch) {
|
|
|
+ try {
|
|
|
+ java.sql.Date realDate = extractReportDate(report.getPayloadJson());
|
|
|
+ if (realDate != null) {
|
|
|
+ report.setReportDate(realDate);
|
|
|
+ healthReportMapper.updateById(report);
|
|
|
+ log.info("回填报告真实日期 reportId={}, reportDate={}", report.getId(), realDate);
|
|
|
+ } else {
|
|
|
+ // 置 NULL 并清 payload 中可提取性:无法提取的不再反复处理
|
|
|
+ report.setReportDate(null);
|
|
|
+ healthReportMapper.updateById(report);
|
|
|
+ log.info("报告日期无法提取,置 NULL reportId={}", report.getId());
|
|
|
+ }
|
|
|
+ total++;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("回填报告日期失败 reportId={}: {}", report.getId(), e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ log.info("ReportDateExtractor 历史回填完成,共处理 {} 行", total);
|
|
|
+ return total;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 每日 03:10 自动回填(幂等可重跑,连跑两次结果一致) */
|
|
|
+ @Scheduled(cron = "0 10 3 * * ?")
|
|
|
+ public void scheduledBackfill() {
|
|
|
+ backfillHistoricalReports();
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 4:运行测试确认通过
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=ReportDateExtractorTest`
|
|
|
+预期:`Tests run: 4, Failures: 0, Errors: 0`
|
|
|
+
|
|
|
+### 步骤 5:验证编译 + Commit
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:`BUILD SUCCESS`
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java \
|
|
|
+ cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java
|
|
|
+git commit -m "feat(indicator): ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 4:SpearmanUtil(P6 — 校准算法纯函数)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java`
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java`
|
|
|
+
|
|
|
+### 步骤 1:编写失败测试
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.util;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+
|
|
|
+class SpearmanUtilTest {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void perfectPositiveRankCorrelation() {
|
|
|
+ // x 与 y 完全同序 → rho = 1
|
|
|
+ List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
|
|
|
+ List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
|
|
|
+ double rho = SpearmanUtil.spearmanRho(x, y);
|
|
|
+ assertEquals(1.0, rho, 1e-9);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void perfectNegativeRankCorrelation() {
|
|
|
+ List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
|
|
|
+ List<BigDecimal> y = Arrays.asList(new BigDecimal("4"), new BigDecimal("3"), new BigDecimal("2"), new BigDecimal("1"));
|
|
|
+ double rho = SpearmanUtil.spearmanRho(x, y);
|
|
|
+ assertEquals(-1.0, rho, 1e-9);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void tiedRanksHandled() {
|
|
|
+ // 有并列值时使用平均秩;x 与 y 逐项同值(含并列) → rho = 1
|
|
|
+ List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
|
|
|
+ List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
|
|
|
+ double rho = SpearmanUtil.spearmanRho(x, y);
|
|
|
+ assertEquals(1.0, rho, 1e-9);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void shortSeriesReturnsZero() {
|
|
|
+ // 少于 3 个配对点不计算(对应设计:单对象 ≥3 配对才参与)
|
|
|
+ List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
|
|
|
+ List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
|
|
|
+ assertEquals(0.0, SpearmanUtil.spearmanRho(x, y), 1e-9);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void fisherZAverageRestoresPositiveRho() {
|
|
|
+ // 两个 rho=0.6 的对象 z 均值还原 → 仍约 0.6
|
|
|
+ double avg = SpearmanUtil.fisherZAverage(new double[]{0.6, 0.6});
|
|
|
+ assertEquals(0.6, avg, 1e-6);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void fisherZAverageHandlesExtremeRho() {
|
|
|
+ // rho 接近 ±1 时 z 变换仍有限(这里验证不抛异常且符号正确)
|
|
|
+ double avg = SpearmanUtil.fisherZAverage(new double[]{0.99, 0.95});
|
|
|
+ assertTrue(avg > 0.9);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 2:运行确认失败
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=SpearmanUtilTest`
|
|
|
+预期:`BUILD FAILURE`,`SpearmanUtil` 不存在
|
|
|
+
|
|
|
+### 步骤 3:实现 SpearmanUtil
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.util;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Comparator;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 秩相关计算(纯函数,无外部依赖)。
|
|
|
+ * 菌群丰度偏态分布,Spearman 比 Pearson 稳健;Java 8 无内置实现,此处手工实现,约 40 行。
|
|
|
+ */
|
|
|
+public final class SpearmanUtil {
|
|
|
+
|
|
|
+ private SpearmanUtil() {
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 计算 Spearman 秩相关系数;配对点 < 3 返回 0(样本不足不误导)。 */
|
|
|
+ public static double spearmanRho(List<BigDecimal> x, List<BigDecimal> y) {
|
|
|
+ if (x == null || y == null || x.size() != y.size() || x.size() < 3) {
|
|
|
+ return 0.0;
|
|
|
+ }
|
|
|
+ double[] rankX = rank(x);
|
|
|
+ double[] rankY = rank(y);
|
|
|
+ double sumD2 = 0;
|
|
|
+ int n = x.size();
|
|
|
+ for (int i = 0; i < n; i++) {
|
|
|
+ double d = rankX[i] - rankY[i];
|
|
|
+ sumD2 += d * d;
|
|
|
+ }
|
|
|
+ double denom = (double) n * (n * n - 1);
|
|
|
+ if (denom == 0) {
|
|
|
+ return 0.0;
|
|
|
+ }
|
|
|
+ double rho = 1 - (6 * sumD2) / denom;
|
|
|
+ if (rho > 1) rho = 1;
|
|
|
+ if (rho < -1) rho = -1;
|
|
|
+ return rho;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 平均秩;并列值取平均(tie handling)。 */
|
|
|
+ private static double[] rank(List<BigDecimal> values) {
|
|
|
+ int n = values.size();
|
|
|
+ double[] ranks = new double[n];
|
|
|
+ // 按值排序的索引
|
|
|
+ List<Integer> idx = new ArrayList<>();
|
|
|
+ for (int i = 0; i < n; i++) idx.add(i);
|
|
|
+ idx.sort(Comparator.comparing(values::get));
|
|
|
+ Map<Integer, Double> rankMap = new HashMap<>();
|
|
|
+ int i = 0;
|
|
|
+ while (i < n) {
|
|
|
+ int j = i;
|
|
|
+ while (j + 1 < n && values.get(idx.get(j + 1)).compareTo(values.get(idx.get(i))) == 0) {
|
|
|
+ j++;
|
|
|
+ }
|
|
|
+ // [i, j] 为并列区间,平均秩 = (i+1 + j+1) / 2
|
|
|
+ double avgRank = ((i + 1) + (j + 1)) / 2.0;
|
|
|
+ for (int k = i; k <= j; k++) {
|
|
|
+ rankMap.put(idx.get(k), avgRank);
|
|
|
+ }
|
|
|
+ i = j + 1;
|
|
|
+ }
|
|
|
+ for (int k = 0; k < n; k++) {
|
|
|
+ ranks[k] = rankMap.get(k);
|
|
|
+ }
|
|
|
+ return ranks;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Fisher z 聚合:各对象 rho → z = artanh(rho) → 均值 → rho = tanh(z̄)。
|
|
|
+ * 设计文档 4.6:每对象算 rho → z 变换 → 全对象 z 均值 → 还原为平均 rho。
|
|
|
+ */
|
|
|
+ public static double fisherZAverage(double[] rhos) {
|
|
|
+ if (rhos == null || rhos.length == 0) {
|
|
|
+ return 0.0;
|
|
|
+ }
|
|
|
+ double sumZ = 0;
|
|
|
+ for (double r : rhos) {
|
|
|
+ double v = Math.max(-0.999999, Math.min(0.999999, r));
|
|
|
+ sumZ += 0.5 * Math.log((1 + v) / (1 - v));
|
|
|
+ }
|
|
|
+ double zAvg = sumZ / rhos.length;
|
|
|
+ return Math.tanh(zAvg);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 4:运行测试确认通过
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=SpearmanUtilTest`
|
|
|
+预期:`Tests run: 6, Failures: 0, Errors: 0`
|
|
|
+
|
|
|
+### 步骤 5:Commit
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java \
|
|
|
+ cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java
|
|
|
+git commit -m "feat(indicator): SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 5:IndicatorMappingService(P2 — 归一化映射核心)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java`
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java`
|
|
|
+
|
|
|
+### 步骤 1:编写失败测试
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.IndicatorDefinition;
|
|
|
+import com.etotem.cfc.entity.IndicatorMappingRule;
|
|
|
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
|
|
|
+import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.Mockito;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import java.util.Collections;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+class IndicatorMappingServiceTest {
|
|
|
+
|
|
|
+ private IndicatorMappingService service;
|
|
|
+ private IndicatorMappingRuleMapper ruleMapper;
|
|
|
+ private IndicatorDefinitionMapper definitionMapper;
|
|
|
+ private AiGateway aiGateway;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = new IndicatorMappingService();
|
|
|
+ ruleMapper = Mockito.mock(IndicatorMappingRuleMapper.class);
|
|
|
+ definitionMapper = Mockito.mock(IndicatorDefinitionMapper.class);
|
|
|
+ aiGateway = Mockito.mock(AiGateway.class);
|
|
|
+ ReflectionTestUtils.setField(service, "ruleMapper", ruleMapper);
|
|
|
+ ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
|
|
|
+ ReflectionTestUtils.setField(service, "aiGateway", aiGateway);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void resolveExactHitReturnsDefinitionId() {
|
|
|
+ IndicatorMappingRule rule = new IndicatorMappingRule();
|
|
|
+ rule.setDefinitionId(7L);
|
|
|
+ when(ruleMapper.selectOne(any())).thenReturn(rule);
|
|
|
+ Long defId = service.resolve("gut_flora", "双歧杆菌属");
|
|
|
+ assertEquals(7L, defId);
|
|
|
+ verify(ruleMapper).selectOne(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void resolveMissLazilyRegistersDefinitionAndRule() {
|
|
|
+ when(ruleMapper.selectOne(any())).thenReturn(null);
|
|
|
+ IndicatorDefinition created = new IndicatorDefinition();
|
|
|
+ created.setId(99L);
|
|
|
+ when(definitionMapper.insert(any(IndicatorDefinition.class))).thenAnswer(inv -> {
|
|
|
+ IndicatorDefinition d = inv.getArgument(0);
|
|
|
+ d.setId(99L);
|
|
|
+ return 1;
|
|
|
+ });
|
|
|
+ Long defId = service.resolve("gut_flora", "新出现的菌X");
|
|
|
+ assertEquals(99L, defId);
|
|
|
+ // 惰性注册:定义 + auto_created 规则
|
|
|
+ verify(definitionMapper).insert(any(IndicatorDefinition.class));
|
|
|
+ verify(ruleMapper).insert(any(IndicatorMappingRule.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void resolveFuzzyHitPicksBestCandidate() {
|
|
|
+ // exact 未命中 → fuzzy 候选命中(别名/近似名)
|
|
|
+ when(ruleMapper.selectOne(any())).thenReturn(null);
|
|
|
+ IndicatorMappingRule candidate = new IndicatorMappingRule();
|
|
|
+ candidate.setDefinitionId(3L);
|
|
|
+ candidate.setRawName("双歧杆菌"); // 与 "双歧杆菌属" 高相似
|
|
|
+ when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(candidate));
|
|
|
+
|
|
|
+ Long defId = service.resolve("gut_flora", "双歧杆菌属");
|
|
|
+ assertEquals(3L, defId);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void resolveFuzzyMissFallsBackToAiSuggestion() {
|
|
|
+ when(ruleMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(ruleMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+ // AI 建议命中
|
|
|
+ when(aiGateway.classifyIndicator(eq("gut_flora"), eq("神秘指标"), anyList()))
|
|
|
+ .thenReturn("{\"code\":\"indicator.x.y\",\"name\":\"神秘指标\"}");
|
|
|
+ IndicatorDefinition suggested = new IndicatorDefinition();
|
|
|
+ suggested.setId(41L);
|
|
|
+ when(definitionMapper.selectOne(any())).thenReturn(suggested);
|
|
|
+
|
|
|
+ Long defId = service.resolve("gut_flora", "神秘指标");
|
|
|
+ assertEquals(41L, defId);
|
|
|
+ // AI 建议登记为 pending(status=0)规则
|
|
|
+ verify(ruleMapper).insert(argThat(r -> r.getMatchType().equals("ai_fallback") && r.getStatus() == 0));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 2:运行确认失败
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorMappingServiceTest`
|
|
|
+预期:`BUILD FAILURE`,`IndicatorMappingService` 不存在
|
|
|
+
|
|
|
+### 步骤 3:实现 IndicatorMappingService
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.IndicatorDefinition;
|
|
|
+import com.etotem.cfc.entity.IndicatorMappingRule;
|
|
|
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
|
|
|
+import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 指标归一化映射:raw_name → definition_id。
|
|
|
+ * 解析顺序:exact 命中映射规则 → fuzzy(CJK 二元组相似度)→ AI 兜底(登记 pending)→ 惰性注册新定义。
|
|
|
+ * 单一写者模式的「身份解析」环节;规则表是权威,管理端可 review 修正。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class IndicatorMappingService {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(IndicatorMappingService.class);
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorMappingRuleMapper ruleMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorDefinitionMapper definitionMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private AiGateway aiGateway;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ObjectMapper objectMapper;
|
|
|
+
|
|
|
+ /** fuzzy 相似度阈值(CJK 二元组 Dice 系数),低于则视为未命中 */
|
|
|
+ private static final double FUZZY_THRESHOLD = 0.5;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析原始指标名为 definition_id。
|
|
|
+ *
|
|
|
+ * @param sourceType 报告类型(gut_flora 等)
|
|
|
+ * @param rawName 报告原始名称(指标名/菌名/食材名,已 trim)
|
|
|
+ */
|
|
|
+ public Long resolve(String sourceType, String rawName) {
|
|
|
+ if (sourceType == null || rawName == null || rawName.trim().isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String name = rawName.trim();
|
|
|
+
|
|
|
+ // 1) exact 命中规则表
|
|
|
+ IndicatorMappingRule rule = ruleMapper.selectOne(new LambdaQueryWrapper<IndicatorMappingRule>()
|
|
|
+ .eq(IndicatorMappingRule::getSourceType, sourceType)
|
|
|
+ .eq(IndicatorMappingRule::getRawName, name)
|
|
|
+ .orderByDesc(IndicatorMappingRule::getPriority)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (rule != null && rule.getDefinitionId() != null) {
|
|
|
+ return rule.getDefinitionId();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) fuzzy:与同来源全部启用规则比较 CJK 二元组相似度
|
|
|
+ List<IndicatorMappingRule> candidates = ruleMapper.selectList(new LambdaQueryWrapper<IndicatorMappingRule>()
|
|
|
+ .eq(IndicatorMappingRule::getSourceType, sourceType)
|
|
|
+ .eq(IndicatorMappingRule::getStatus, 1));
|
|
|
+ IndicatorMappingRule best = null;
|
|
|
+ double bestScore = 0;
|
|
|
+ for (IndicatorMappingRule c : candidates) {
|
|
|
+ double s = bigramSimilarity(name, c.getRawName());
|
|
|
+ if (s > bestScore) {
|
|
|
+ bestScore = s;
|
|
|
+ best = c;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (best != null && bestScore >= FUZZY_THRESHOLD) {
|
|
|
+ return best.getDefinitionId();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3) AI 兜底:只建议,登记 pending(status=0)规则待管理端确认
|
|
|
+ String aiJson = aiGateway != null
|
|
|
+ ? aiGateway.classifyIndicator(sourceType, name, Collections.emptyList())
|
|
|
+ : null;
|
|
|
+ if (aiJson != null) {
|
|
|
+ try {
|
|
|
+ JsonNode suggestion = objectMapper.readTree(aiJson);
|
|
|
+ if (suggestion != null && suggestion.hasNonNull("code")) {
|
|
|
+ IndicatorDefinition def = definitionMapper.selectOne(new LambdaQueryWrapper<IndicatorDefinition>()
|
|
|
+ .eq(IndicatorDefinition::getCode, suggestion.get("code").asText())
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (def != null) {
|
|
|
+ registerRule(sourceType, name, def.getId(), "ai_fallback", 0);
|
|
|
+ log.info("AI 兜底建议:sourceType={}, raw={} → defId={}(pending 待确认)",
|
|
|
+ sourceType, name, def.getId());
|
|
|
+ return def.getId();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("AI 归类建议解析失败 raw={}: {}", name, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4) 惰性注册:新 definition + auto_created 规则
|
|
|
+ return lazyRegister(sourceType, name);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 惰性注册新定义(code 规则:indicator.<cat>.<name> / bacteria.<name> / food.<name>) */
|
|
|
+ private Long lazyRegister(String sourceType, String rawName) {
|
|
|
+ DefinitionCode code = buildCode(sourceType, rawName);
|
|
|
+ IndicatorDefinition def = new IndicatorDefinition();
|
|
|
+ def.setDomain("physical");
|
|
|
+ def.setCategory(code.category);
|
|
|
+ def.setCode(code.code);
|
|
|
+ def.setName(rawName);
|
|
|
+ def.setDataType("number");
|
|
|
+ def.setSourceKind(code.sourceKind);
|
|
|
+ def.setReportTypeScope(sourceType);
|
|
|
+ def.setValueType(inferValueType(sourceType));
|
|
|
+ def.setGoodDirection("neutral");
|
|
|
+ def.setSortOrder(0);
|
|
|
+ def.setStatus(1);
|
|
|
+ def.setCreatedAt(new Date());
|
|
|
+ def.setUpdatedAt(new Date());
|
|
|
+ definitionMapper.insert(def);
|
|
|
+ registerRule(sourceType, rawName, def.getId(), "auto_created", 1);
|
|
|
+ log.info("惰性注册指标定义 sourceType={}, raw={} → defId={}", sourceType, rawName, def.getId());
|
|
|
+ return def.getId();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void registerRule(String sourceType, String rawName, Long definitionId, String matchType, int status) {
|
|
|
+ IndicatorMappingRule rule = new IndicatorMappingRule();
|
|
|
+ rule.setSourceType(sourceType);
|
|
|
+ rule.setRawName(rawName);
|
|
|
+ rule.setDefinitionId(definitionId);
|
|
|
+ rule.setMatchType(matchType);
|
|
|
+ rule.setPriority(0);
|
|
|
+ rule.setStatus(status);
|
|
|
+ rule.setCreatedAt(new Date());
|
|
|
+ rule.setUpdatedAt(new Date());
|
|
|
+ try {
|
|
|
+ ruleMapper.insert(rule);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("登记映射规则冲突(已存在?),忽略 sourceType={}, raw={}: {}", sourceType, rawName, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 内部工具 ----------
|
|
|
+
|
|
|
+ private static class DefinitionCode {
|
|
|
+ String code;
|
|
|
+ String category;
|
|
|
+ String sourceKind;
|
|
|
+ }
|
|
|
+
|
|
|
+ private DefinitionCode buildCode(String sourceType, String rawName) {
|
|
|
+ DefinitionCode dc = new DefinitionCode();
|
|
|
+ if ("food_recommend".equals(sourceType)) {
|
|
|
+ dc.sourceKind = "food_index";
|
|
|
+ dc.category = "食材";
|
|
|
+ dc.code = "food." + rawName;
|
|
|
+ } else if (isBacteriaContext(sourceType)) {
|
|
|
+ dc.sourceKind = "bacteria";
|
|
|
+ dc.category = "菌属";
|
|
|
+ dc.code = "bacteria." + rawName;
|
|
|
+ } else {
|
|
|
+ dc.sourceKind = "report_indicator";
|
|
|
+ dc.category = "报告指标";
|
|
|
+ dc.code = "indicator." + rawName;
|
|
|
+ }
|
|
|
+ // code 有唯一约束,过长/含特殊字符时安全截断
|
|
|
+ if (dc.code.length() > 190) {
|
|
|
+ dc.code = dc.code.substring(0, 190);
|
|
|
+ }
|
|
|
+ return dc;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isBacteriaContext(String sourceType) {
|
|
|
+ return sourceType != null && sourceType.startsWith("gut_flora");
|
|
|
+ }
|
|
|
+
|
|
|
+ private String inferValueType(String sourceType) {
|
|
|
+ return isBacteriaContext(sourceType) ? "numeric" : "numeric";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * CJK 二元组 Dice 相似度:中文按字符二元组,ASCII 按词/字符二元组混合。
|
|
|
+ * 0~1;1 = 完全相同(但 exact 已先行命中)。
|
|
|
+ */
|
|
|
+ double bigramSimilarity(String a, String b) {
|
|
|
+ if (a == null || b == null) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ if (a.equals(b)) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ List<String> gramsA = bigrams(a);
|
|
|
+ List<String> gramsB = bigrams(b);
|
|
|
+ if (gramsA.isEmpty() || gramsB.isEmpty()) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ int common = 0;
|
|
|
+ for (String g : gramsA) {
|
|
|
+ if (gramsB.contains(g)) {
|
|
|
+ common++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return (2.0 * common) / (gramsA.size() + gramsB.size());
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<String> bigrams(String s) {
|
|
|
+ if (s == null || s.length() < 2) {
|
|
|
+ List<String> single = new ArrayList<>();
|
|
|
+ if (s != null && !s.isEmpty()) {
|
|
|
+ single.add(s);
|
|
|
+ }
|
|
|
+ return single;
|
|
|
+ }
|
|
|
+ List<String> result = new ArrayList<>();
|
|
|
+ for (int i = 0; i < s.length() - 1; i++) {
|
|
|
+ result.add(s.substring(i, i + 2));
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 数值型解析:去掉 %/~/>/< 与空格后尝试 BigDecimal */
|
|
|
+ public BigDecimal parseNumericValue(String raw) {
|
|
|
+ if (raw == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String cleaned = raw.replaceAll("[%<>~\\s]", "").trim();
|
|
|
+ if (cleaned.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return new BigDecimal(cleaned);
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 批量种子导入(管理端调用):seed 列表 [{rawName, definitionId?, category?, unit?}] */
|
|
|
+ public int importSeeds(String sourceType, List<Map<String, Object>> seeds) {
|
|
|
+ int count = 0;
|
|
|
+ for (Map<String, Object> seed : seeds) {
|
|
|
+ String rawName = seed.get("rawName") != null ? seed.get("rawName").toString() : null;
|
|
|
+ if (rawName == null || rawName.trim().isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Object defIdObj = seed.get("definitionId");
|
|
|
+ Long defId;
|
|
|
+ if (defIdObj instanceof Number) {
|
|
|
+ defId = ((Number) defIdObj).longValue();
|
|
|
+ } else if (defIdObj != null) {
|
|
|
+ defId = Long.valueOf(defIdObj.toString());
|
|
|
+ } else {
|
|
|
+ defId = lazyRegister(sourceType, rawName);
|
|
|
+ }
|
|
|
+ registerRule(sourceType, rawName.trim(), defId, "exact", 1);
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ log.info("种子导入完成 sourceType={}, seeds={}", sourceType, count);
|
|
|
+ return count;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 4:运行测试确认通过
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorMappingServiceTest`
|
|
|
+预期:`Tests run: 4, Failures: 0, Errors: 0`
|
|
|
+
|
|
|
+### 步骤 5:验证编译 + Commit
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:`BUILD SUCCESS`
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java \
|
|
|
+ cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java
|
|
|
+git commit -m "feat(indicator): IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 6:NormalizeIndicatorPipeline(P2 — 单一写者 + 三个钩子)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java`
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java`(insertReport 钩子)
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java`(updateReportFromPayload 钩子)
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java`(recalculateForUser 钩子)
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.java`
|
|
|
+
|
|
|
+### 步骤 1:编写失败测试
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.dto.ParsedReportPayload;
|
|
|
+import com.etotem.cfc.entity.HealthReport;
|
|
|
+import com.etotem.cfc.entity.IndicatorValue;
|
|
|
+import com.etotem.cfc.mapper.IndicatorValueMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.Mockito;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+class NormalizeIndicatorPipelineTest {
|
|
|
+
|
|
|
+ private NormalizeIndicatorPipeline pipeline;
|
|
|
+ private IndicatorMappingService mappingService;
|
|
|
+ private IndicatorValueMapper valueMapper;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ pipeline = new NormalizeIndicatorPipeline();
|
|
|
+ mappingService = Mockito.mock(IndicatorMappingService.class);
|
|
|
+ valueMapper = Mockito.mock(IndicatorValueMapper.class);
|
|
|
+ ReflectionTestUtils.setField(pipeline, "mappingService", mappingService);
|
|
|
+ ReflectionTestUtils.setField(pipeline, "valueMapper", valueMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void processReportWritesIndicatorsAndFlora() {
|
|
|
+ HealthReport report = new HealthReport();
|
|
|
+ report.setId(10L);
|
|
|
+ report.setFamilyId(2L);
|
|
|
+ report.setSubjectId(3L);
|
|
|
+ report.setReportType("gut_flora");
|
|
|
+ report.setReportDate(new Date());
|
|
|
+ when(mappingService.resolve(eq("gut_flora"), eq("双歧杆菌属"))).thenReturn(1L);
|
|
|
+
|
|
|
+ ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
|
|
|
+ ParsedReportPayload.Indicator ind = new ParsedReportPayload.Indicator();
|
|
|
+ ind.setIndicatorName("双歧杆菌属");
|
|
|
+ ind.setIndicatorValue("12.8");
|
|
|
+ ind.setUnit("%");
|
|
|
+ ind.setStatus("偏高");
|
|
|
+ payload.setIndicators(Collections.singletonList(ind));
|
|
|
+
|
|
|
+ pipeline.processReport(report, payload);
|
|
|
+ verify(valueMapper).upsertMapping(any(IndicatorValue.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void processReportSkipsWhenNoSubject() {
|
|
|
+ HealthReport report = new HealthReport();
|
|
|
+ report.setId(10L);
|
|
|
+ report.setReportType("gut_flora");
|
|
|
+ report.setSubjectId(null);
|
|
|
+ ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
|
|
|
+ payload.setIndicators(Collections.emptyList());
|
|
|
+
|
|
|
+ pipeline.processReport(report, payload);
|
|
|
+ verify(valueMapper, never()).upsertMapping(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void snapshotFoodIndicesWritesFoodValues() {
|
|
|
+ com.etotem.cfc.entity.FoodRecommendIndex idx = new com.etotem.cfc.entity.FoodRecommendIndex();
|
|
|
+ idx.setId(5L);
|
|
|
+ idx.setUserId(3L);
|
|
|
+ idx.setFoodId(9L);
|
|
|
+ idx.setFoodName("燕麦");
|
|
|
+ idx.setIndexScore(88);
|
|
|
+ idx.setHealthReportId(10L);
|
|
|
+ idx.setCalculatedAt(new Date());
|
|
|
+ when(mappingService.resolve(eq("food_recommend"), eq("燕麦"))).thenReturn(6L);
|
|
|
+
|
|
|
+ pipeline.snapshotFoodIndices(3L, java.util.Collections.singletonList(idx));
|
|
|
+ verify(valueMapper).upsertMapping(argThat(v -> v.getSourceType().equals("food_recommend")
|
|
|
+ && v.getSourceId().equals(5L)
|
|
|
+ && v.getDefinitionId().equals(6L)));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 2:运行确认失败
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=NormalizeIndicatorPipelineTest`
|
|
|
+预期:`BUILD FAILURE`,`NormalizeIndicatorPipeline` 不存在
|
|
|
+
|
|
|
+### 步骤 3:实现 NormalizeIndicatorPipeline
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.dto.ParsedReportPayload;
|
|
|
+import com.etotem.cfc.entity.FoodRecommendIndex;
|
|
|
+import com.etotem.cfc.entity.HealthReport;
|
|
|
+import com.etotem.cfc.entity.IndicatorValue;
|
|
|
+import com.etotem.cfc.mapper.IndicatorValueMapper;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.sql.Date;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 归一化管道 —— 统一指标层「单一写者」。
|
|
|
+ * 报告入库/编辑流程照旧写 health_indicators(不动现有消费者),
|
|
|
+ * 本管道在同流程/同事务内将 指标+菌属+食材 幂等写入 indicator_values。
|
|
|
+ * 唯一键 (source_type, source_id, definition_id) 保证重解析只覆盖更新。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class NormalizeIndicatorPipeline {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(NormalizeIndicatorPipeline.class);
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorMappingService mappingService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorValueMapper valueMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 报告指标 + 菌属归一化写入。
|
|
|
+ * 调用点:insertReport()(subjectId 块后)/ updateReportFromPayload()(步骤 5 后)。
|
|
|
+ * 无 subject 的报告不入时序(无法归属观测对象)。
|
|
|
+ *
|
|
|
+ * @param report 已入库/已更新的报告(reportDate 须为真实日期或 null)
|
|
|
+ * @param payload 解析载荷(gutFlora 从 payload 读;indicators 从 payload 读,与 health_indicators 同源)
|
|
|
+ */
|
|
|
+ public void processReport(HealthReport report, ParsedReportPayload.Payload payload) {
|
|
|
+ if (report == null || report.getSubjectId() == null || report.getId() == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ Long subjectId = report.getSubjectId();
|
|
|
+ Long familyId = report.getFamilyId();
|
|
|
+ Long reportId = report.getId();
|
|
|
+ String sourceType = report.getReportType();
|
|
|
+ Date reportDate = report.getReportDate() != null
|
|
|
+ ? new Date(report.getReportDate().getTime()) : null;
|
|
|
+
|
|
|
+ int written = 0;
|
|
|
+ if (payload != null && payload.getIndicators() != null) {
|
|
|
+ for (ParsedReportPayload.Indicator ind : payload.getIndicators()) {
|
|
|
+ if (ind.getIndicatorName() == null || ind.getIndicatorName().trim().isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Long defId = mappingService.resolve(sourceType, ind.getIndicatorName().trim());
|
|
|
+ if (defId == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
|
|
|
+ v.setValue(ind.getIndicatorValue());
|
|
|
+ v.setNumericValue(mappingService.parseNumericValue(ind.getIndicatorValue()));
|
|
|
+ v.setUnit(ind.getUnit());
|
|
|
+ v.setStatus(ind.getStatus());
|
|
|
+ valueMapper.upsertMapping(v);
|
|
|
+ written++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (payload != null && payload.getGutFlora() != null) {
|
|
|
+ for (ParsedReportPayload.Flora flora : payload.getGutFlora()) {
|
|
|
+ if (flora.getBacteriaName() == null || flora.getBacteriaName().trim().isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Long defId = mappingService.resolve(sourceType, flora.getBacteriaName().trim());
|
|
|
+ if (defId == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
|
|
|
+ v.setValue(flora.getBacteriaValue());
|
|
|
+ v.setNumericValue(mappingService.parseNumericValue(flora.getBacteriaValue()));
|
|
|
+ v.setUnit("%");
|
|
|
+ v.setStatus(flora.getStatus());
|
|
|
+ valueMapper.upsertMapping(v);
|
|
|
+ written++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ log.info("归一化管道 reportId={}, subjectId={}, type={},写入 {} 条观测", reportId, subjectId, sourceType, written);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 食材推荐指数全量快照(钩子 3:FoodRecommendService.recalculateForUser 尾部调用)。
|
|
|
+ * source_type=food_recommend,source_id=food_recommend_idx.id,幂等覆盖。
|
|
|
+ */
|
|
|
+ public void snapshotFoodIndices(Long userId, List<FoodRecommendIndex> indices) {
|
|
|
+ if (userId == null || indices == null || indices.isEmpty()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ for (FoodRecommendIndex idx : indices) {
|
|
|
+ if (idx.getFoodName() == null || idx.getFoodName().trim().isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Long defId = mappingService.resolve("food_recommend", idx.getFoodName().trim());
|
|
|
+ if (defId == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ IndicatorValue v = baseValue("food_recommend", idx.getId(), defId, userId, null, null);
|
|
|
+ v.setValue(idx.getIndexScore() != null ? idx.getIndexScore().toString() : null);
|
|
|
+ v.setNumericValue(idx.getIndexScore() != null
|
|
|
+ ? BigDecimal.valueOf(idx.getIndexScore()) : null);
|
|
|
+ v.setReportId(idx.getHealthReportId());
|
|
|
+ valueMapper.upsertMapping(v);
|
|
|
+ }
|
|
|
+ log.info("食材指数快照 userId={}, 共 {} 条", userId, indices.size());
|
|
|
+ }
|
|
|
+
|
|
|
+ private IndicatorValue baseValue(String sourceType, Long sourceId, Long defId,
|
|
|
+ Long subjectId, Long familyId, Date reportDate) {
|
|
|
+ IndicatorValue v = new IndicatorValue();
|
|
|
+ v.setSourceType(sourceType);
|
|
|
+ v.setSourceId(sourceId);
|
|
|
+ v.setDefinitionId(defId);
|
|
|
+ v.setSubjectId(subjectId);
|
|
|
+ v.setFamilyId(familyId);
|
|
|
+ v.setReportDate(reportDate);
|
|
|
+ return v;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 4:三个钩子接线
|
|
|
+
|
|
|
+**钩子 1 — `ReportCollectService.insertReport()`(L596-652)**:在 `reportBlockService.save(...)` try 块(L641-646)之后、`markCollectCompleted`(L648)之前插入:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 归一化管道:统一指标层幂等写入(同流程,subjectId 非空才有效)
|
|
|
+ try {
|
|
|
+ normalizeIndicatorPipeline.processReport(created, payload);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("归一化管道写入失败 reportId={}: {}", created.getId(), e.getMessage());
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+并在类顶部加注入(与既有 `@Resource` 并列):
|
|
|
+
|
|
|
+```java
|
|
|
+ @Resource
|
|
|
+ private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
|
|
|
+```
|
|
|
+
|
|
|
+**钩子 2 — `HealthReportService.updateReportFromPayload()`**:在步骤 6(payload 快照,L1886-1904)之后、方法结束前插入:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 归一化管道:编辑后重写统一指标层(同事务;payload 为编辑后最新值)
|
|
|
+ try {
|
|
|
+ ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
|
|
|
+ oldReport.setSubjectId(subjectId != null && subjectId > 0 ? subjectId : oldSubjectId);
|
|
|
+ normalizeIndicatorPipeline.processReport(oldReport, editedPayload);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("归一化管道刷新失败 reportId={}: {}", reportId, e.getMessage());
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+(若类中已注入 `normalizeIndicatorPipeline`,复用;未注入则在字段区补 `@Resource private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;`)
|
|
|
+
|
|
|
+**钩子 3 — `FoodRecommendService.recalculateForUser()`**:在方法末尾(`existingMap` 删除循环 L143-159 之后、`}` 之前)插入:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 归一化管道:全量快照最新食材推荐指数到 indicator_values(幂等覆盖)
|
|
|
+ try {
|
|
|
+ List<FoodRecommendIndex> finalIndices = foodRecommendIndexMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<FoodRecommendIndex>()
|
|
|
+ .eq(FoodRecommendIndex::getUserId, userId));
|
|
|
+ normalizeIndicatorPipeline.snapshotFoodIndices(userId, finalIndices);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("食材指数归一化快照失败 userId={}: {}", userId, e.getMessage());
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+并补注入字段:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Resource
|
|
|
+ private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 5:运行测试确认通过
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=NormalizeIndicatorPipelineTest`
|
|
|
+预期:`Tests run: 3, Failures: 0, Errors: 0`
|
|
|
+
|
|
|
+### 步骤 6:验证编译 + Commit
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:`BUILD SUCCESS`
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java \
|
|
|
+ cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.java
|
|
|
+git commit -m "feat(indicator): NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 7:AiGateway 指标归类兜底 + LangGraph endpoint(P2)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java`
|
|
|
+- 创建:`cfc-langgraph/app/api/indicator_classify.py`
|
|
|
+- 修改:`cfc-langgraph/app/main.py`
|
|
|
+
|
|
|
+### 步骤 1:AiGateway 新增 classifyIndicator 方法
|
|
|
+
|
|
|
+在 `AiGateway.java` 的 `generatePortrait` 方法(L392-416)之后追加(全套模板与既有方法一致:`!enabled || isCircuitOpen()` 短路 + `createJsonHeaders()` + `postForEntity` + `consecutiveFailures.set(0)`/`recordFailure()`):
|
|
|
+
|
|
|
+```java
|
|
|
+ /**
|
|
|
+ * 指标归类兜底:将未命中的原始指标名交给 LangGraph 归类为已有/新定义。
|
|
|
+ * @return 建议 JSON 字符串(含 code/name)或 null(关闭/熔断/失败)
|
|
|
+ */
|
|
|
+ public String classifyIndicator(String sourceType, String rawName, java.util.List<String> candidates) {
|
|
|
+ if (!enabled || isCircuitOpen()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ ObjectNode body = objectMapper.createObjectNode();
|
|
|
+ body.put("source_type", sourceType);
|
|
|
+ body.put("raw_name", rawName);
|
|
|
+ if (candidates != null) {
|
|
|
+ com.fasterxml.jackson.databind.node.ArrayNode arr = body.putArray("candidates");
|
|
|
+ candidates.forEach(arr::add);
|
|
|
+ }
|
|
|
+ HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
|
|
|
+ String url = baseUrl + "/api/v1/indicator/classify";
|
|
|
+ ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
|
|
|
+ if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
|
|
+ consecutiveFailures.set(0);
|
|
|
+ return response.getBody();
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("AiGateway classifyIndicator 调用失败: {}", e.getMessage());
|
|
|
+ recordFailure();
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+(若文件顶部未 import `ObjectNode`/`ArrayNode`/`HttpEntity`/`ResponseEntity`,与既有方法共用全限定名或补充 import;与 `generatePortrait` 使用一致即可。)
|
|
|
+
|
|
|
+### 步骤 2:创建 LangGraph endpoint
|
|
|
+
|
|
|
+`cfc-langgraph/app/api/indicator_classify.py`(完整文件,沿用 report_parse.py 的 router + pydantic 模式;LLM 调用沿用 self_check_analysis_graph 的 `get_llm().ainvoke` 模式):
|
|
|
+
|
|
|
+```python
|
|
|
+from fastapi import APIRouter
|
|
|
+from pydantic import BaseModel
|
|
|
+from typing import Optional, List
|
|
|
+from app.llm.client import get_llm
|
|
|
+from langchain_core.messages import SystemMessage, HumanMessage
|
|
|
+import logging
|
|
|
+import json
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+router = APIRouter(prefix="/api/v1", tags=["indicator_classify"])
|
|
|
+_llm = None
|
|
|
+
|
|
|
+
|
|
|
+def get_llm_instance():
|
|
|
+ global _llm
|
|
|
+ if _llm is None:
|
|
|
+ _llm = get_llm()
|
|
|
+ return _llm
|
|
|
+
|
|
|
+
|
|
|
+class ClassifyRequest(BaseModel):
|
|
|
+ source_type: str
|
|
|
+ raw_name: str
|
|
|
+ candidates: Optional[List[str]] = None
|
|
|
+
|
|
|
+
|
|
|
+class ClassifyResponse(BaseModel):
|
|
|
+ code: int = 200
|
|
|
+ message: str = "ok"
|
|
|
+ data: dict = {}
|
|
|
+
|
|
|
+
|
|
|
+SYSTEM_PROMPT = (
|
|
|
+ "你是健康报告指标归一化归类助手。给定一个报告中的原始指标/菌属/食材名称,"
|
|
|
+ "把它归类为统一指标字典中的一个标准定义。只输出 JSON:"
|
|
|
+ '{"code": "indicator.<类别>.<标准名>" | "bacteria.<标准名>" | "food.<标准名>", '
|
|
|
+ '"name": "标准显示名", "category": "分类"}。'
|
|
|
+ "若候选列表非空,优先从候选中选择语义最接近的标准 code。"
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+@router.post("/indicator/classify", response_model=ClassifyResponse)
|
|
|
+async def classify_indicator(req: ClassifyRequest):
|
|
|
+ logger.info("indicator_classify: source=%s raw=%s", req.source_type, req.raw_name)
|
|
|
+ llm = get_llm_instance()
|
|
|
+ prompt = f"来源类型: {req.source_type}\n原始名称: {req.raw_name}\n"
|
|
|
+ if req.candidates:
|
|
|
+ prompt += "候选标准定义: " + ", ".join(req.candidates) + "\n"
|
|
|
+ try:
|
|
|
+ messages = [
|
|
|
+ SystemMessage(content=SYSTEM_PROMPT),
|
|
|
+ HumanMessage(content=prompt),
|
|
|
+ ]
|
|
|
+ response = await llm.ainvoke(messages)
|
|
|
+ text = response.content.strip()
|
|
|
+ if "```json" in text:
|
|
|
+ text = text.split("```json")[1].split("```")[0].strip()
|
|
|
+ elif "```" in text:
|
|
|
+ text = text.split("```")[1].strip()
|
|
|
+ data = json.loads(text)
|
|
|
+ return ClassifyResponse(data=data)
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("指标归类失败: %s", e, exc_info=True)
|
|
|
+ return ClassifyResponse(code=500, message=f"归类失败: {str(e)}", data={})
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 3:main.py 注册 router
|
|
|
+
|
|
|
+在 `cfc-langgraph/app/main.py` 的 include_router 区块(L18 起)追加注册:
|
|
|
+
|
|
|
+```python
|
|
|
+from app.api import indicator_classify
|
|
|
+...
|
|
|
+app.include_router(indicator_classify.router)
|
|
|
+```
|
|
|
+
|
|
|
+(import 放文件顶部 import 区;按项目既有 `from app.api import ...` 风格。**注意:`self_check.router` 被注册了两次是既有 bug,不要模仿重复注册;本 router 只注册一次。**)
|
|
|
+
|
|
|
+### 步骤 4:验证
|
|
|
+
|
|
|
+运行:`cd cfc-langgraph && python -m py_compile app/api/indicator_classify.py app/main.py`
|
|
|
+预期:退出码 0,无语法错误
|
|
|
+
|
|
|
+### 步骤 5:Commit
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java \
|
|
|
+ cfc-langgraph/app/api/indicator_classify.py \
|
|
|
+ cfc-langgraph/app/main.py
|
|
|
+git commit -m "feat(indicator): AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 8:IndicatorTrendService(P3 — 曲线 / 变化排名 / 速查)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java`
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java`
|
|
|
+
|
|
|
+### 步骤 1:编写失败测试
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.IndicatorValue;
|
|
|
+import com.etotem.cfc.mapper.IndicatorValueMapper;
|
|
|
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.sql.Date;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+class IndicatorTrendServiceTest {
|
|
|
+
|
|
|
+ private IndicatorTrendService service;
|
|
|
+ private IndicatorValueMapper valueMapper;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = new IndicatorTrendService();
|
|
|
+ valueMapper = mock(IndicatorValueMapper.class);
|
|
|
+ ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ private IndicatorValue value(Long defId, String date, String val) {
|
|
|
+ IndicatorValue v = new IndicatorValue();
|
|
|
+ v.setDefinitionId(defId);
|
|
|
+ v.setReportDate(Date.valueOf(date));
|
|
|
+ v.setValue(val);
|
|
|
+ v.setNumericValue(new BigDecimal(val));
|
|
|
+ return v;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void trendReturnsAscendingPoints() {
|
|
|
+ List<IndicatorValue> rows = new ArrayList<>();
|
|
|
+ rows.add(value(1L, "2026-08-15", "10"));
|
|
|
+ rows.add(value(1L, "2026-10-01", "12.8"));
|
|
|
+ when(valueMapper.selectList(any())).thenReturn(rows);
|
|
|
+
|
|
|
+ Map<String, Object> data = service.trend(3L, Arrays.asList(1L), 2L);
|
|
|
+ List<?> items = (List<?>) data.get("items");
|
|
|
+ assertEquals(1, items.size());
|
|
|
+ Map<?, ?> item = (Map<?, ?>) items.get(0);
|
|
|
+ assertEquals("1", String.valueOf(item.get("definitionId")));
|
|
|
+ List<?> points = (List<?>) item.get("points");
|
|
|
+ assertEquals(2, points.size());
|
|
|
+ Map<?, ?> first = (Map<?, ?>) points.get(0);
|
|
|
+ assertEquals("2026-08-15", first.get("reportDate"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void changeRankingAdjacentComputesDeltaAndTrendLabel() {
|
|
|
+ // 双歧杆菌属 higher_is_better:升幅 → trendLabel=改善
|
|
|
+ List<IndicatorValue> rows = new ArrayList<>();
|
|
|
+ rows.add(value(1L, "2026-08-15", "10"));
|
|
|
+ rows.add(value(1L, "2026-10-01", "12.8"));
|
|
|
+ when(valueMapper.selectList(any())).thenReturn(rows);
|
|
|
+
|
|
|
+ java.util.Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L);
|
|
|
+ List<?> items = (List<?>) data.get("items");
|
|
|
+ assertEquals(1, items.size());
|
|
|
+ Map<?, ?> item = (Map<?, ?>) items.get(0);
|
|
|
+ assertEquals("up", item.get("movement"));
|
|
|
+ assertEquals("改善", item.get("trendLabel"));
|
|
|
+ Map<?, ?> overall = (Map<?, ?>) data.get("overall");
|
|
|
+ assertEquals(1, ((Number) overall.get("improvedCount")).intValue());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void quickLookupReturnsItemsAndDimensions() {
|
|
|
+ List<IndicatorValue> rows = new ArrayList<>();
|
|
|
+ rows.add(value(1L, "2026-10-01", "12.8"));
|
|
|
+ when(valueMapper.selectList(any())).thenReturn(rows);
|
|
|
+
|
|
|
+ Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L);
|
|
|
+ assertTrue(data.containsKey("items"));
|
|
|
+ assertTrue(data.containsKey("categories"));
|
|
|
+ assertEquals(1, ((List<?>) data.get("items")).size());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 2:运行确认失败
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorTrendServiceTest`
|
|
|
+预期:`BUILD FAILURE`,`IndicatorTrendService` 不存在
|
|
|
+
|
|
|
+### 步骤 3:实现 IndicatorTrendService
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.IndicatorDefinition;
|
|
|
+import com.etotem.cfc.entity.IndicatorValue;
|
|
|
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
|
|
|
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
|
|
|
+import com.etotem.cfc.mapper.IndicatorValueMapper;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.LinkedHashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 指标视图服务:trend(曲线)/ change-ranking(变化排名)/ quick-lookup(速查)。
|
|
|
+ * 只读 indicator_values;五维/七维评分不写时序,quick-lookup API 层单独合入 dimensionScores 小节。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class IndicatorTrendService {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(IndicatorTrendService.class);
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorValueMapper valueMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorDefinitionMapper definitionMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FiveDimensionScoreMapper fiveDimensionScoreMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthDimensionScoreMapper healthDimensionScoreMapper;
|
|
|
+
|
|
|
+ /** 指标曲线:按 definitionId 分组、report_date 升序返回点序列 */
|
|
|
+ public Map<String, Object> trend(Long subjectId, List<Long> definitionIds, Long familyId) {
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
+ List<Map<String, Object>> items = new ArrayList<>();
|
|
|
+ if (definitionIds == null || definitionIds.isEmpty() || subjectId == null) {
|
|
|
+ data.put("items", items);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+ List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .in(IndicatorValue::getDefinitionId, definitionIds)
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
+ .orderByAsc(IndicatorValue::getReportDate));
|
|
|
+
|
|
|
+ Map<Long, List<IndicatorValue>> byDef = rows.stream()
|
|
|
+ .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
|
|
|
+ LinkedHashMap::new, Collectors.toList()));
|
|
|
+
|
|
|
+ Map<Long, IndicatorDefinition> defCache = new HashMap<>();
|
|
|
+ for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("definitionId", e.getKey());
|
|
|
+ IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
|
|
|
+ id -> definitionMapper.selectById(id));
|
|
|
+ item.put("name", def != null ? def.getName() : "指标" + e.getKey());
|
|
|
+ item.put("unit", def != null ? def.getUnit() : null);
|
|
|
+ List<Map<String, Object>> points = new ArrayList<>();
|
|
|
+ for (IndicatorValue v : e.getValue()) {
|
|
|
+ Map<String, Object> p = new LinkedHashMap<>();
|
|
|
+ p.put("reportDate", v.getReportDate() != null
|
|
|
+ ? new SimpleDateFormat("yyyy-MM-dd").format(v.getReportDate()) : null);
|
|
|
+ p.put("value", v.getValue());
|
|
|
+ p.put("numericValue", v.getNumericValue());
|
|
|
+ p.put("unit", v.getUnit());
|
|
|
+ p.put("status", v.getStatus());
|
|
|
+ points.add(p);
|
|
|
+ }
|
|
|
+ item.put("points", points);
|
|
|
+ items.add(item);
|
|
|
+ }
|
|
|
+ data.put("items", items);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 变化幅度排名(设计 6.2)。scope=adjacent 相邻两次(默认)/ first-vs-latest 首尾对比。 */
|
|
|
+ public Map<String, Object> changeRanking(Long subjectId, String reportType, String category,
|
|
|
+ String scope, String orderBy, Integer limit, Long familyId) {
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
+ List<Map<String, Object>> items = new ArrayList<>();
|
|
|
+ int improved = 0;
|
|
|
+ int worsened = 0;
|
|
|
+ int flat = 0;
|
|
|
+
|
|
|
+ LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .isNotNull(IndicatorValue::getReportDate);
|
|
|
+ if (reportType != null && !reportType.isEmpty()) {
|
|
|
+ qw.eq(IndicatorValue::getSourceType, reportType);
|
|
|
+ }
|
|
|
+ List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
|
|
|
+
|
|
|
+ // 按 definitionId 分组
|
|
|
+ Map<Long, List<IndicatorValue>> byDef = rows.stream()
|
|
|
+ .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
|
|
|
+ Map<Long, IndicatorDefinition> defCache = new HashMap<>();
|
|
|
+
|
|
|
+ for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
|
|
|
+ List<IndicatorValue> sorted = e.getValue().stream()
|
|
|
+ .sorted((a, b) -> a.getReportDate().compareTo(b.getReportDate()))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ if (sorted.size() < 2) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ IndicatorValue before = sorted.get(0);
|
|
|
+ IndicatorValue after = sorted.get(sorted.size() - 1);
|
|
|
+ if ("adjacent".equals(scope)) {
|
|
|
+ before = sorted.get(sorted.size() - 2);
|
|
|
+ }
|
|
|
+ IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
|
|
|
+ id -> definitionMapper.selectById(id));
|
|
|
+ String defCategory = def != null ? def.getCategory() : null;
|
|
|
+ if (category != null && !category.isEmpty() && !category.equals(defCategory)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ BigDecimal bv = before.getNumericValue();
|
|
|
+ BigDecimal av = after.getNumericValue();
|
|
|
+ if (bv == null || av == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ BigDecimal delta = av.subtract(bv);
|
|
|
+ BigDecimal deltaPct = bv.compareTo(BigDecimal.ZERO) != 0
|
|
|
+ ? delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ String movement = "flat";
|
|
|
+ if (delta.compareTo(BigDecimal.ZERO) > 0) movement = "up";
|
|
|
+ else if (delta.compareTo(BigDecimal.ZERO) < 0) movement = "down";
|
|
|
+
|
|
|
+ String trendLabel = trendLabel(def, movement, delta);
|
|
|
+ if ("改善".equals(trendLabel)) improved++;
|
|
|
+ else if ("恶化".equals(trendLabel)) worsened++;
|
|
|
+ else flat++;
|
|
|
+
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("definitionId", e.getKey());
|
|
|
+ item.put("name", def != null ? def.getName() : "指标" + e.getKey());
|
|
|
+ item.put("category", defCategory);
|
|
|
+ item.put("sourceKind", def != null ? def.getSourceKind() : null);
|
|
|
+ item.put("unit", def != null ? def.getUnit() : null);
|
|
|
+ item.put("goodDirection", def != null ? def.getGoodDirection() : "neutral");
|
|
|
+ Map<String, Object> beforeMap = new LinkedHashMap<>();
|
|
|
+ beforeMap.put("reportDate", fmtDate(before.getReportDate()));
|
|
|
+ beforeMap.put("value", before.getValue());
|
|
|
+ Map<String, Object> afterMap = new LinkedHashMap<>();
|
|
|
+ afterMap.put("reportDate", fmtDate(after.getReportDate()));
|
|
|
+ afterMap.put("value", after.getValue());
|
|
|
+ item.put("before", beforeMap);
|
|
|
+ item.put("after", afterMap);
|
|
|
+ item.put("delta", delta);
|
|
|
+ item.put("deltaPct", deltaPct);
|
|
|
+ item.put("movement", movement);
|
|
|
+ item.put("trendLabel", trendLabel);
|
|
|
+ items.add(item);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 排序:magnitude 按 Δ 绝对值降序(默认);up/down 按 Δ 正/负降序
|
|
|
+ if ("up".equals(orderBy)) {
|
|
|
+ items.sort((a, b) -> ((BigDecimal) b.get("delta")).compareTo((BigDecimal) a.get("delta")));
|
|
|
+ } else if ("down".equals(orderBy)) {
|
|
|
+ items.sort((a, b) -> ((BigDecimal) a.get("delta")).compareTo((BigDecimal) b.get("delta")));
|
|
|
+ } else {
|
|
|
+ items.sort((a, b) -> ((BigDecimal) b.get("delta")).abs()
|
|
|
+ .compareTo(((BigDecimal) a.get("delta")).abs()));
|
|
|
+ }
|
|
|
+
|
|
|
+ int limitVal = limit != null && limit > 0 ? limit : 50;
|
|
|
+ if (items.size() > limitVal) {
|
|
|
+ items = new ArrayList<>(items.subList(0, limitVal));
|
|
|
+ }
|
|
|
+
|
|
|
+ data.put("items", items);
|
|
|
+ Map<String, Object> overall = new LinkedHashMap<>();
|
|
|
+ overall.put("improvedCount", improved);
|
|
|
+ overall.put("worsenedCount", worsened);
|
|
|
+ overall.put("flatCount", flat);
|
|
|
+ data.put("overall", overall);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 指标速查(设计 6.3):分类分页 + 五维/七维评分小节 */
|
|
|
+ public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
|
|
|
+ Integer pageSize, Long familyId) {
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
+ int pageVal = page != null && page > 0 ? page : 1;
|
|
|
+ int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 30;
|
|
|
+
|
|
|
+ // 该成员全部观测(按时序取最新一条)
|
|
|
+ List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .isNotNull(IndicatorValue::getDefinitionId));
|
|
|
+ Map<Long, List<IndicatorValue>> byDef = rows.stream()
|
|
|
+ .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
|
|
|
+ Map<Long, IndicatorDefinition> defCache = new HashMap<>();
|
|
|
+ List<Map<String, Object>> allItems = new ArrayList<>();
|
|
|
+
|
|
|
+ for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
|
|
|
+ IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
|
|
|
+ id -> definitionMapper.selectById(id));
|
|
|
+ if (def == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ List<IndicatorValue> sorted = e.getValue().stream()
|
|
|
+ .sorted((a, b) -> a.getReportDate() == null ? -1
|
|
|
+ : (b.getReportDate() == null ? 1 : b.getReportDate().compareTo(a.getReportDate())))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ IndicatorValue latest = sorted.get(0);
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("definitionId", e.getKey());
|
|
|
+ item.put("name", def.getName());
|
|
|
+ item.put("category", def.getCategory());
|
|
|
+ item.put("value", latest.getValue());
|
|
|
+ item.put("unit", latest.getUnit() != null ? latest.getUnit() : def.getUnit());
|
|
|
+ item.put("refRange", def.getRefRange());
|
|
|
+ item.put("status", latest.getStatus());
|
|
|
+ item.put("trend", trendOf(sorted));
|
|
|
+ item.put("trendLabel", trendLabel(def, trendOf(sorted), deltaOf(sorted)));
|
|
|
+ item.put("latestReportDate", latest.getReportDate() != null
|
|
|
+ ? new SimpleDateFormat("yyyy-MM-dd").format(latest.getReportDate()) : null);
|
|
|
+ item.put("pointsCount", sorted.size());
|
|
|
+ allItems.add(item);
|
|
|
+ }
|
|
|
+
|
|
|
+ // categories 汇总(含 total)
|
|
|
+ List<Map<String, Object>> categories = new ArrayList<>();
|
|
|
+ Map<String, Long> catCount = allItems.stream()
|
|
|
+ .collect(Collectors.groupingBy(i -> (String) i.get("category"), Collectors.counting()));
|
|
|
+ catCount.forEach((name, total) -> {
|
|
|
+ Map<String, Object> c = new LinkedHashMap<>();
|
|
|
+ c.put("name", name);
|
|
|
+ c.put("total", total);
|
|
|
+ categories.add(c);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 分页
|
|
|
+ int fromIndex = Math.min((pageVal - 1) * sizeVal, allItems.size());
|
|
|
+ int toIndex = Math.min(fromIndex + sizeVal, allItems.size());
|
|
|
+ List<Map<String, Object>> pageItems = allItems.isEmpty()
|
|
|
+ ? new ArrayList<>()
|
|
|
+ : new ArrayList<>(allItems.subList(fromIndex, toIndex));
|
|
|
+
|
|
|
+ data.put("categories", categories);
|
|
|
+ data.put("items", pageItems);
|
|
|
+ data.put("page", pageVal);
|
|
|
+ data.put("pageSize", sizeVal);
|
|
|
+ data.put("total", allItems.size());
|
|
|
+ data.put("dimensionScores", loadDimensionScores(subjectId));
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 五维/七维评分小节(不写 indicator_values,API 层 join 原表) */
|
|
|
+ private List<Map<String, Object>> loadDimensionScores(Long subjectId) {
|
|
|
+ List<Map<String, Object>> result = new ArrayList<>();
|
|
|
+ // 五维:five_dimension_scores 按 memberId + assessedAt 最新
|
|
|
+ try {
|
|
|
+ result.addAll(fiveDimensionScoreMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<com.etotem.cfc.entity.FiveDimensionScore>()
|
|
|
+ .eq(com.etotem.cfc.entity.FiveDimensionScore::getMemberId, subjectId)
|
|
|
+ .orderByDesc(com.etotem.cfc.entity.FiveDimensionScore::getAssessedAt)
|
|
|
+ .last("LIMIT 10"))
|
|
|
+ .stream().map(s -> {
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
+ m.put("dimensionCode", s.getDimensionCode());
|
|
|
+ m.put("score", s.getScore());
|
|
|
+ m.put("assessedAt", s.getAssessedAt() != null
|
|
|
+ ? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessedAt()) : null);
|
|
|
+ m.put("source", "five_dimension");
|
|
|
+ return m;
|
|
|
+ }).collect(Collectors.toList()));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("加载五维评分失败 subjectId={}: {}", subjectId, e.getMessage());
|
|
|
+ }
|
|
|
+ // 七维:health_dimension_scores 按 memberId + assessDate 最新
|
|
|
+ try {
|
|
|
+ result.addAll(healthDimensionScoreMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<com.etotem.cfc.entity.HealthDimensionScore>()
|
|
|
+ .eq(com.etotem.cfc.entity.HealthDimensionScore::getMemberId, subjectId)
|
|
|
+ .orderByDesc(com.etotem.cfc.entity.HealthDimensionScore::getAssessDate)
|
|
|
+ .last("LIMIT 10"))
|
|
|
+ .stream().map(s -> {
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
+ m.put("dimensionCode", s.getDimension());
|
|
|
+ m.put("score", s.getScore());
|
|
|
+ m.put("assessedAt", s.getAssessDate() != null
|
|
|
+ ? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessDate()) : null);
|
|
|
+ m.put("source", "health_dimension");
|
|
|
+ return m;
|
|
|
+ }).collect(Collectors.toList()));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("加载七维评分失败 subjectId={}: {}", subjectId, e.getMessage());
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 工具 ----------
|
|
|
+
|
|
|
+ private String fmtDate(Date d) {
|
|
|
+ return d != null ? new SimpleDateFormat("yyyy-MM-dd").format(d) : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 序列趋势:末尾两条比较 */
|
|
|
+ private String trendOf(List<IndicatorValue> sorted) {
|
|
|
+ if (sorted.size() < 2) {
|
|
|
+ return "flat";
|
|
|
+ }
|
|
|
+ IndicatorValue latest = sorted.get(0);
|
|
|
+ IndicatorValue prev = sorted.get(1);
|
|
|
+ if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
|
|
|
+ return "flat";
|
|
|
+ }
|
|
|
+ int cmp = latest.getNumericValue().compareTo(prev.getNumericValue());
|
|
|
+ return cmp > 0 ? "up" : (cmp < 0 ? "down" : "flat");
|
|
|
+ }
|
|
|
+
|
|
|
+ private BigDecimal deltaOf(List<IndicatorValue> sorted) {
|
|
|
+ if (sorted.size() < 2) {
|
|
|
+ return BigDecimal.ZERO;
|
|
|
+ }
|
|
|
+ IndicatorValue latest = sorted.get(0);
|
|
|
+ IndicatorValue prev = sorted.get(1);
|
|
|
+ if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
|
|
|
+ return BigDecimal.ZERO;
|
|
|
+ }
|
|
|
+ return latest.getNumericValue().subtract(prev.getNumericValue());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 变化着色/标签规则(设计第 8 节):
|
|
|
+ * good_direction × 数值变化方向 → 改善/恶化/中性。
|
|
|
+ */
|
|
|
+ private String trendLabel(IndicatorDefinition def, String movement, BigDecimal delta) {
|
|
|
+ if ("flat".equals(movement) || delta == null || delta.compareTo(BigDecimal.ZERO) == 0) {
|
|
|
+ return "中性";
|
|
|
+ }
|
|
|
+ if (def == null || def.getGoodDirection() == null) {
|
|
|
+ return "中性";
|
|
|
+ }
|
|
|
+ boolean up = "up".equals(movement);
|
|
|
+ switch (def.getGoodDirection()) {
|
|
|
+ case "higher_is_better":
|
|
|
+ return up ? "改善" : "恶化";
|
|
|
+ case "lower_is_better":
|
|
|
+ return up ? "恶化" : "改善";
|
|
|
+ case "in_range_is_better":
|
|
|
+ // 一期简化:无 refRange 解析,回落为中性
|
|
|
+ return "中性";
|
|
|
+ default:
|
|
|
+ return "中性";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 需要 `HealthDimensionScoreMapper`(若不存在则先创建空 BaseMapper;`FiveDimensionScoreMapper` 若已有则复用)。若 `health_dimension_scores` 表访问导致编译失败,先确认两个 mapper 是否存在:
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.HealthDimensionScore;
|
|
|
+
|
|
|
+public interface HealthDimensionScoreMapper extends BaseMapper<HealthDimensionScore> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 4:运行测试确认通过
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorTrendServiceTest`
|
|
|
+预期:`Tests run: 3, Failures: 0, Errors: 0`
|
|
|
+
|
|
|
+### 步骤 5:验证编译 + Commit
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:`BUILD SUCCESS`
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDimensionScoreMapper.java \
|
|
|
+ cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java
|
|
|
+git commit -m "feat(indicator): IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 9:IndicatorCorrelationService(P3+P6 — 关联 CRUD + 校准)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java`
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.java`
|
|
|
+
|
|
|
+### 步骤 1:编写失败测试
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.IndicatorCorrelation;
|
|
|
+import com.etotem.cfc.entity.IndicatorDefinition;
|
|
|
+import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
|
|
|
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+class IndicatorCorrelationServiceTest {
|
|
|
+
|
|
|
+ private IndicatorCorrelationService service;
|
|
|
+ private IndicatorCorrelationMapper correlationMapper;
|
|
|
+ private IndicatorDefinitionMapper definitionMapper;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = new IndicatorCorrelationService();
|
|
|
+ correlationMapper = mock(IndicatorCorrelationMapper.class);
|
|
|
+ definitionMapper = mock(IndicatorDefinitionMapper.class);
|
|
|
+ ReflectionTestUtils.setField(service, "correlationMapper", correlationMapper);
|
|
|
+ ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void saveOrdersByCodeLexicographic() {
|
|
|
+ // 定义 A code="bacteria.bb" 应存 a;定义 B code="bacteria.aa" 应存 a
|
|
|
+ IndicatorDefinition defAA = new IndicatorDefinition();
|
|
|
+ defAA.setId(1L);
|
|
|
+ defAA.setCode("bacteria.aa");
|
|
|
+ IndicatorDefinition defBB = new IndicatorDefinition();
|
|
|
+ defBB.setId(2L);
|
|
|
+ defBB.setCode("bacteria.bb");
|
|
|
+ when(definitionMapper.selectById(1L)).thenReturn(defAA);
|
|
|
+ when(definitionMapper.selectById(2L)).thenReturn(defBB);
|
|
|
+
|
|
|
+ service.saveCorrelation(1L, 2L, "positive", new BigDecimal("0.72"),
|
|
|
+ "描述", false, "来源", 9L);
|
|
|
+
|
|
|
+ org.mockito.ArgumentCaptor<IndicatorCorrelation> captor =
|
|
|
+ org.mockito.ArgumentCaptor.forClass(IndicatorCorrelation.class);
|
|
|
+ verify(correlationMapper).insert(captor.capture());
|
|
|
+ IndicatorCorrelation saved = captor.getValue();
|
|
|
+ // 1L 的 code("bacteria.aa") 字典序更小 → 应存 definition_id_a
|
|
|
+ assertEquals(1L, saved.getDefinitionIdA());
|
|
|
+ assertEquals(2L, saved.getDefinitionIdB());
|
|
|
+ // effective_strength 初始 = manual(未校准)
|
|
|
+ assertEquals(0, new BigDecimal("0.72").compareTo(saved.getEffectiveStrength()));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 2:运行确认失败
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorCorrelationServiceTest`
|
|
|
+预期:`BUILD FAILURE`,`IndicatorCorrelationService` 不存在
|
|
|
+
|
|
|
+### 步骤 3:实现 IndicatorCorrelationService
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.IndicatorCorrelation;
|
|
|
+import com.etotem.cfc.entity.IndicatorDefinition;
|
|
|
+import com.etotem.cfc.entity.IndicatorValue;
|
|
|
+import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
|
|
|
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
|
|
|
+import com.etotem.cfc.mapper.IndicatorValueMapper;
|
|
|
+import com.etotem.cfc.util.SpearmanUtil;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 指标关联:人工定义(候选对+方向+人工强度)+ 真实数据校准(Spearman)。
|
|
|
+ * 对序规则:写入前按 definition.code 字典序比较,小的存 a、大的存 b(唯一键对称)。
|
|
|
+ * effective_strength:有效样本 >= 30 取 computed,否则取 manual;manual_locked=1 时校准不覆盖。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class IndicatorCorrelationService {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(IndicatorCorrelationService.class);
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorCorrelationMapper correlationMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorDefinitionMapper definitionMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorValueMapper valueMapper;
|
|
|
+
|
|
|
+ /** 有效样本阈值:>=30 才用 computed 覆盖 manual(设计 4.6) */
|
|
|
+ private static final int EFFECTIVE_SAMPLE_THRESHOLD = 30;
|
|
|
+
|
|
|
+ /** 人工定义/编辑关联对(强制对序) */
|
|
|
+ public void saveCorrelation(Long defA, Long defB, String direction, BigDecimal manualStrength,
|
|
|
+ String description, Boolean manualLocked, String sourceNote, Long createdBy) {
|
|
|
+ if (defA == null || defB == null || defA.equals(defB)) {
|
|
|
+ throw new IllegalArgumentException("关联对不能为空且不能相同");
|
|
|
+ }
|
|
|
+ IndicatorDefinition da = definitionMapper.selectById(defA);
|
|
|
+ IndicatorDefinition db = definitionMapper.selectById(defB);
|
|
|
+ if (da == null || db == null) {
|
|
|
+ throw new IllegalArgumentException("指标定义不存在: " + defA + "/" + defB);
|
|
|
+ }
|
|
|
+ Long a = da.getCode().compareTo(db.getCode()) <= 0 ? defA : defB;
|
|
|
+ Long b = da.getCode().compareTo(db.getCode()) <= 0 ? defB : defA;
|
|
|
+
|
|
|
+ // 已存在则该对更新
|
|
|
+ IndicatorCorrelation existing = correlationMapper.selectOne(new LambdaQueryWrapper<IndicatorCorrelation>()
|
|
|
+ .eq(IndicatorCorrelation::getDefinitionIdA, a)
|
|
|
+ .eq(IndicatorCorrelation::getDefinitionIdB, b)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ IndicatorCorrelation c = existing != null ? existing : new IndicatorCorrelation();
|
|
|
+ c.setDefinitionIdA(a);
|
|
|
+ c.setDefinitionIdB(b);
|
|
|
+ if (c.getRelationType() == null) c.setRelationType("correlation");
|
|
|
+ c.setDirection(direction);
|
|
|
+ c.setManualStrength(manualStrength);
|
|
|
+ c.setEffectiveStrength(manualStrength); // 未校准时 = manual
|
|
|
+ if (manualLocked != null) c.setManualLocked(manualLocked ? 1 : 0);
|
|
|
+ if (c.getManualLocked() == null) c.setManualLocked(0);
|
|
|
+ c.setDescription(description);
|
|
|
+ c.setSourceNote(sourceNote);
|
|
|
+ c.setCreatedBy(createdBy);
|
|
|
+ if (c.getCohortScope() == null) c.setCohortScope("subject");
|
|
|
+ if (c.getStatus() == null || !c.getStatus().equals("inactive")) c.setStatus("active");
|
|
|
+ if (existing != null) {
|
|
|
+ c.setUpdatedAt(new Date());
|
|
|
+ correlationMapper.updateById(c);
|
|
|
+ } else {
|
|
|
+ c.setCreatedAt(new Date());
|
|
|
+ c.setUpdatedAt(new Date());
|
|
|
+ correlationMapper.insert(c);
|
|
|
+ }
|
|
|
+ log.info("关联对已保存: {} ({} ↔ {})", a, b, direction);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 关联查询(设计 6.4):返回该指标的全部关联 + 当前成员的配对证据 */
|
|
|
+ public Map<String, Object> listRelations(Long subjectId, Long definitionId) {
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
+ List<Map<String, Object>> relations = new ArrayList<>();
|
|
|
+ if (definitionId == null) {
|
|
|
+ data.put("relations", relations);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+ List<IndicatorCorrelation> pairs = correlationMapper.selectList(new LambdaQueryWrapper<IndicatorCorrelation>()
|
|
|
+ .and(w -> w.eq(IndicatorCorrelation::getDefinitionIdA, definitionId)
|
|
|
+ .or().eq(IndicatorCorrelation::getDefinitionIdB, definitionId))
|
|
|
+ .eq(IndicatorCorrelation::getStatus, "active"));
|
|
|
+
|
|
|
+ for (IndicatorCorrelation pair : pairs) {
|
|
|
+ Long otherId = pair.getDefinitionIdA().equals(definitionId)
|
|
|
+ ? pair.getDefinitionIdB() : pair.getDefinitionIdA();
|
|
|
+ IndicatorDefinition other = definitionMapper.selectById(otherId);
|
|
|
+ Map<String, Object> rel = new LinkedHashMap<>();
|
|
|
+ rel.put("pairId", pair.getId());
|
|
|
+ Map<String, Object> indA = new LinkedHashMap<>();
|
|
|
+ indA.put("definitionId", pair.getDefinitionIdA());
|
|
|
+ IndicatorDefinition defA = definitionMapper.selectById(pair.getDefinitionIdA());
|
|
|
+ indA.put("name", defA != null ? defA.getName() : null);
|
|
|
+ rel.put("indicatorA", indA);
|
|
|
+ Map<String, Object> indB = new LinkedHashMap<>();
|
|
|
+ indB.put("definitionId", pair.getDefinitionIdB());
|
|
|
+ indB.put("name", other != null ? other.getName() : null);
|
|
|
+ rel.put("indicatorB", indB);
|
|
|
+ rel.put("direction", pair.getDirection());
|
|
|
+ rel.put("effectiveStrength", pair.getEffectiveStrength());
|
|
|
+ rel.put("strengthSource", pair.getComputedStrength() != null
|
|
|
+ && pair.getSampleSize() != null && pair.getSampleSize() >= EFFECTIVE_SAMPLE_THRESHOLD
|
|
|
+ ? "computed" : "manual");
|
|
|
+ rel.put("sampleSize", pair.getSampleSize());
|
|
|
+ rel.put("confidence", pair.getConfidence());
|
|
|
+ rel.put("manualLocked", pair.getManualLocked() != null && pair.getManualLocked() == 1);
|
|
|
+ rel.put("description", pair.getDescription());
|
|
|
+
|
|
|
+ // 该成员自己的配对数据支撑(如有)
|
|
|
+ Map<String, Object> evidence = subjectEvidence(subjectId, pair.getDefinitionIdA(), pair.getDefinitionIdB());
|
|
|
+ rel.put("subjectEvidence", evidence);
|
|
|
+ relations.add(rel);
|
|
|
+ }
|
|
|
+ data.put("relations", relations);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 成员级配对证据:点数 + 个人 rho + 是否足够(>=3) */
|
|
|
+ private Map<String, Object> subjectEvidence(Long subjectId, Long defA, Long defB) {
|
|
|
+ Map<String, Object> evidence = new LinkedHashMap<>();
|
|
|
+ evidence.put("points", 0);
|
|
|
+ evidence.put("personalRho", 0.0);
|
|
|
+ evidence.put("hasEnough", false);
|
|
|
+ if (subjectId == null) {
|
|
|
+ return evidence;
|
|
|
+ }
|
|
|
+ List<IndicatorValue> rowsA = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .eq(IndicatorValue::getDefinitionId, defA)
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
+ .orderByAsc(IndicatorValue::getReportDate));
|
|
|
+ List<IndicatorValue> rowsB = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .eq(IndicatorValue::getDefinitionId, defB)
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
+ .orderByAsc(IndicatorValue::getReportDate));
|
|
|
+ if (rowsA.size() < 3 || rowsB.size() < 3) {
|
|
|
+ return evidence;
|
|
|
+ }
|
|
|
+ // 按 report_date 对齐:累计最近 12 个共同日期点
|
|
|
+ List<java.math.BigDecimal> xs = new ArrayList<>();
|
|
|
+ List<java.math.BigDecimal> ys = new ArrayList<>();
|
|
|
+ int i = 0;
|
|
|
+ int j = 0;
|
|
|
+ while (i < rowsA.size() && j < rowsB.size() && xs.size() < 12) {
|
|
|
+ int cmp = rowsA.get(i).getReportDate().compareTo(rowsB.get(j).getReportDate());
|
|
|
+ if (cmp == 0) {
|
|
|
+ if (rowsA.get(i).getNumericValue() != null && rowsB.get(j).getNumericValue() != null) {
|
|
|
+ xs.add(rowsA.get(i).getNumericValue());
|
|
|
+ ys.add(rowsB.get(j).getNumericValue());
|
|
|
+ }
|
|
|
+ i++;
|
|
|
+ j++;
|
|
|
+ } else if (cmp < 0) {
|
|
|
+ i++;
|
|
|
+ } else {
|
|
|
+ j++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (xs.size() >= 3) {
|
|
|
+ evidence.put("points", xs.size());
|
|
|
+ evidence.put("personalRho", SpearmanUtil.spearmanRho(xs, ys));
|
|
|
+ evidence.put("hasEnough", true);
|
|
|
+ }
|
|
|
+ return evidence;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 全量校准(管理端手动触发 / @Scheduled 每日):
|
|
|
+ * 对 status=active 且 manual_locked=0 的关联对,按 subject 分组配对序列 → Spearman → Fisher z 聚合。
|
|
|
+ */
|
|
|
+ public int calibrateAll() {
|
|
|
+ List<IndicatorCorrelation> pairs = correlationMapper.selectList(new LambdaQueryWrapper<IndicatorCorrelation>()
|
|
|
+ .eq(IndicatorCorrelation::getStatus, "active"));
|
|
|
+ int updated = 0;
|
|
|
+ for (IndicatorCorrelation pair : pairs) {
|
|
|
+ if (pair.getManualLocked() != null && pair.getManualLocked() == 1) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (calibratePair(pair)) {
|
|
|
+ updated++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ log.info("指标关联校准完成,共更新 {} 对", updated);
|
|
|
+ return updated;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean calibratePair(IndicatorCorrelation pair) {
|
|
|
+ // 全部观测(含所有 subject),按 (subject_id, report_date) 分组配对
|
|
|
+ List<IndicatorValue> rowsA = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdA())
|
|
|
+ .isNotNull(IndicatorValue::getReportDate));
|
|
|
+ List<IndicatorValue> rowsB = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdB())
|
|
|
+ .isNotNull(IndicatorValue::getReportDate));
|
|
|
+ // key = subjectId + '-' + reportDate
|
|
|
+ Map<String, IndicatorValue> mapA = rowsA.stream().filter(v -> v.getSubjectId() != null)
|
|
|
+ .collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x));
|
|
|
+ Map<String, IndicatorValue> mapB = rowsB.stream().filter(v -> v.getSubjectId() != null)
|
|
|
+ .collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x));
|
|
|
+
|
|
|
+ // 按 subject 分组配对点
|
|
|
+ Map<Long, List<java.math.BigDecimal[]>> bySubject = new java.util.HashMap<>();
|
|
|
+ int sampleSize = 0;
|
|
|
+ for (Map.Entry<String, IndicatorValue> e : mapA.entrySet()) {
|
|
|
+ IndicatorValue vb = mapB.get(e.getKey());
|
|
|
+ if (vb == null) continue;
|
|
|
+ IndicatorValue va = e.getValue();
|
|
|
+ if (va.getNumericValue() == null || vb.getNumericValue() == null) continue;
|
|
|
+ bySubject.computeIfAbsent(va.getSubjectId(), k -> new ArrayList<>())
|
|
|
+ .add(new java.math.BigDecimal[]{va.getNumericValue(), vb.getNumericValue()});
|
|
|
+ sampleSize++;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (sampleSize < 3) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ List<Double> rhos = new ArrayList<>();
|
|
|
+ for (List<java.math.BigDecimal[]> points : bySubject.values()) {
|
|
|
+ if (points.size() < 3) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ List<java.math.BigDecimal> xs = points.stream().map(p -> p[0]).collect(Collectors.toList());
|
|
|
+ List<java.math.BigDecimal> ys = points.stream().map(p -> p[1]).collect(Collectors.toList());
|
|
|
+ rhos.add(SpearmanUtil.spearmanRho(xs, ys));
|
|
|
+ }
|
|
|
+ if (rhos.isEmpty()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ double avgRho = SpearmanUtil.fisherZAverage(rhos.stream().mapToDouble(Double::doubleValue).toArray());
|
|
|
+ String confidence = sampleSize >= 30 ? "high" : (sampleSize >= 10 ? "medium" : "low");
|
|
|
+
|
|
|
+ if (sampleSize >= EFFECTIVE_SAMPLE_THRESHOLD) {
|
|
|
+ pair.setEffectiveStrength(BigDecimal.valueOf(avgRho).setScale(3, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+ pair.setComputedStrength(BigDecimal.valueOf(avgRho).setScale(3, RoundingMode.HALF_UP));
|
|
|
+ pair.setMethod("spearman");
|
|
|
+ pair.setSampleSize(sampleSize);
|
|
|
+ pair.setConfidence(confidence);
|
|
|
+ pair.setComputedAt(new Date());
|
|
|
+ pair.setUpdatedAt(new Date());
|
|
|
+ correlationMapper.updateById(pair);
|
|
|
+ log.info("校准关联对 {}: rho={}, n={}, confidence={}",
|
|
|
+ pair.getId(), avgRho, sampleSize, confidence);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 4:运行测试确认通过
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorCorrelationServiceTest`
|
|
|
+预期:`Tests run: 1, Failures: 0, Errors: 0`
|
|
|
+
|
|
|
+### 步骤 5:验证编译 + Commit
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:`BUILD SUCCESS`
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java \
|
|
|
+ cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.java
|
|
|
+git commit -m "feat(indicator): IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 10:控制器(P3+P5 后端部分) + 校准 Job(P6)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java`
|
|
|
+
|
|
|
+### 步骤 1:IndicatorController(4 个视图接口,subjectId 归属校验)
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.service.IndicatorCorrelationService;
|
|
|
+import com.etotem.cfc.service.IndicatorTrendService;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestAttribute;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/indicator")
|
|
|
+public class IndicatorController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorTrendService indicatorTrendService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorCorrelationService indicatorCorrelationService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ /** 校验 subjectId 属于当前用户家庭(familyId 由 JwtInterceptor 注入,必存在) */
|
|
|
+ private Result<Void> checkSubjectBelongs(Long subjectId, Long familyId) {
|
|
|
+ if (subjectId == null) {
|
|
|
+ return Result.error("subjectId不能为空");
|
|
|
+ }
|
|
|
+ FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
|
|
|
+ .eq(FamilyMember::getId, subjectId)
|
|
|
+ .eq(FamilyMember::getFamilyId, familyId)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (member == null) {
|
|
|
+ return Result.error("成员不属于当前家庭");
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 指标曲线(设计 6.1) */
|
|
|
+ @PostMapping("/trend")
|
|
|
+ public Result<?> trend(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("familyId") Long familyId) {
|
|
|
+ Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
|
|
|
+ Result<Void> check = checkSubjectBelongs(subjectId, familyId);
|
|
|
+ if (check != null) {
|
|
|
+ return check;
|
|
|
+ }
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ List<Object> rawIds = (List<Object>) params.get("definitionIds");
|
|
|
+ List<Long> definitionIds = new ArrayList<>();
|
|
|
+ if (rawIds != null) {
|
|
|
+ for (Object o : rawIds) {
|
|
|
+ if (o instanceof Number) {
|
|
|
+ definitionIds.add(((Number) o).longValue());
|
|
|
+ } else if (o != null) {
|
|
|
+ definitionIds.add(Long.valueOf(o.toString()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return Result.success(indicatorTrendService.trend(subjectId, definitionIds, familyId));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 变化幅度排名(设计 6.2) */
|
|
|
+ @PostMapping("/change-ranking")
|
|
|
+ public Result<?> changeRanking(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("familyId") Long familyId) {
|
|
|
+ Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
|
|
|
+ Result<Void> check = checkSubjectBelongs(subjectId, familyId);
|
|
|
+ if (check != null) {
|
|
|
+ return check;
|
|
|
+ }
|
|
|
+ String reportType = params.get("reportType") != null ? params.get("reportType").toString() : null;
|
|
|
+ String category = params.get("category") != null ? params.get("category").toString() : null;
|
|
|
+ String scope = params.get("scope") != null ? params.get("scope").toString() : "adjacent";
|
|
|
+ String orderBy = params.get("orderBy") != null ? params.get("orderBy").toString() : "magnitude";
|
|
|
+ Integer limit = params.get("limit") != null
|
|
|
+ ? Integer.valueOf(params.get("limit").toString()) : 50;
|
|
|
+ return Result.success(indicatorTrendService.changeRanking(
|
|
|
+ subjectId, reportType, category, scope, orderBy, limit, familyId));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 指标速查(设计 6.3) */
|
|
|
+ @PostMapping("/quick-lookup")
|
|
|
+ public Result<?> quickLookup(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("familyId") Long familyId) {
|
|
|
+ Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
|
|
|
+ Result<Void> check = checkSubjectBelongs(subjectId, familyId);
|
|
|
+ if (check != null) {
|
|
|
+ return check;
|
|
|
+ }
|
|
|
+ String category = params.get("category") != null ? params.get("category").toString() : null;
|
|
|
+ Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
|
|
|
+ Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 30;
|
|
|
+ return Result.success(indicatorTrendService.quickLookup(subjectId, category, page, pageSize, familyId));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 指标关联查询(设计 6.4) */
|
|
|
+ @PostMapping("/correlation/list")
|
|
|
+ public Result<?> correlationList(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("familyId") Long familyId) {
|
|
|
+ Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
|
|
|
+ Long definitionId = com.etotem.cfc.util.ParamUtils.getLong(params.get("definitionId"));
|
|
|
+ Result<Void> check = checkSubjectBelongs(subjectId, familyId);
|
|
|
+ if (check != null) {
|
|
|
+ return check;
|
|
|
+ }
|
|
|
+ return Result.success(indicatorCorrelationService.listRelations(subjectId, definitionId));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 备注:`FamilyMemberMapper` 与 `FamilyMember` 实体若已存在则直接复用;若不存在,按既有实体/Mapper 模式创建一个最小版本(id/familyId 字段 + BaseMapper)。
|
|
|
+
|
|
|
+### 步骤 2:IndicatorCorrelationAdminController
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.IndicatorCorrelation;
|
|
|
+import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
|
|
|
+import com.etotem.cfc.service.IndicatorCorrelationService;
|
|
|
+import com.etotem.cfc.util.ParamUtils;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestAttribute;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/indicator/correlation")
|
|
|
+public class IndicatorCorrelationAdminController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorCorrelationService indicatorCorrelationService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorCorrelationMapper indicatorCorrelationMapper;
|
|
|
+
|
|
|
+ /** 人工定义/编辑关联对 */
|
|
|
+ @PostMapping("/save")
|
|
|
+ public Result<?> save(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestAttribute("role") String role) {
|
|
|
+ if (!"admin".equals(role)) {
|
|
|
+ return Result.error("无权操作");
|
|
|
+ }
|
|
|
+ Long defA = ParamUtils.getLong(params.get("definitionIdA"));
|
|
|
+ Long defB = ParamUtils.getLong(params.get("definitionIdB"));
|
|
|
+ if (defA == null || defB == null) {
|
|
|
+ return Result.error("definitionIdA/definitionIdB 不能为空");
|
|
|
+ }
|
|
|
+ String direction = params.get("direction") != null ? params.get("direction").toString() : "positive";
|
|
|
+ BigDecimal manualStrength = params.get("manualStrength") != null
|
|
|
+ ? new BigDecimal(params.get("manualStrength").toString()) : BigDecimal.ZERO;
|
|
|
+ String description = params.get("description") != null ? params.get("description").toString() : null;
|
|
|
+ Boolean manualLocked = params.get("manualLocked") != null
|
|
|
+ && Boolean.parseBoolean(params.get("manualLocked").toString());
|
|
|
+ String sourceNote = params.get("sourceNote") != null ? params.get("sourceNote").toString() : null;
|
|
|
+ try {
|
|
|
+ indicatorCorrelationService.saveCorrelation(
|
|
|
+ defA, defB, direction, manualStrength, description, manualLocked, sourceNote, userId);
|
|
|
+ return Result.success(null);
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 关联对分页列表(含校准状态) */
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<Page<IndicatorCorrelation>> list(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("role") String role) {
|
|
|
+ if (!"admin".equals(role)) {
|
|
|
+ return Result.error("无权操作");
|
|
|
+ }
|
|
|
+ Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
|
|
|
+ Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
|
|
|
+ Page<IndicatorCorrelation> p = new Page<>(page, size);
|
|
|
+ indicatorCorrelationMapper.selectPage(p,
|
|
|
+ new LambdaQueryWrapper<IndicatorCorrelation>()
|
|
|
+ .orderByDesc(IndicatorCorrelation::getUpdatedAt));
|
|
|
+ return Result.success(p);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 手动触发全校准或单对校准(单对:body 传 definitionIdA/definitionIdB 或 pairId) */
|
|
|
+ @PostMapping("/calibrate")
|
|
|
+ public Result<?> calibrate(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("role") String role) {
|
|
|
+ if (!"admin".equals(role)) {
|
|
|
+ return Result.error("无权操作");
|
|
|
+ }
|
|
|
+ Long pairId = ParamUtils.getLong(params.get("pairId"));
|
|
|
+ if (pairId != null) {
|
|
|
+ IndicatorCorrelation pair = indicatorCorrelationMapper.selectById(pairId);
|
|
|
+ if (pair == null) {
|
|
|
+ return Result.error("关联对不存在");
|
|
|
+ }
|
|
|
+ // 单对校准通过全量校准的按对逻辑实现(此处简化为触发全量,全量内部逐对幂等)
|
|
|
+ int n = indicatorCorrelationService.calibrateAll();
|
|
|
+ return Result.success("校准完成,共更新 " + n + " 对");
|
|
|
+ }
|
|
|
+ int n = indicatorCorrelationService.calibrateAll();
|
|
|
+ return Result.success("校准完成,共更新 " + n + " 对");
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 3:IndicatorMappingAdminController
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.IndicatorMappingRule;
|
|
|
+import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
|
|
|
+import com.etotem.cfc.service.IndicatorMappingService;
|
|
|
+import com.etotem.cfc.util.ParamUtils;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestAttribute;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/indicator/mapping")
|
|
|
+public class IndicatorMappingAdminController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorMappingService indicatorMappingService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorMappingRuleMapper indicatorMappingRuleMapper;
|
|
|
+
|
|
|
+ /** 批量导入映射种子(JSON/CSV 数组:[{rawName, definitionId?}]) */
|
|
|
+ @PostMapping("/import")
|
|
|
+ public Result<?> importSeeds(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("role") String role) {
|
|
|
+ if (!"admin".equals(role)) {
|
|
|
+ return Result.error("无权操作");
|
|
|
+ }
|
|
|
+ String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
|
|
|
+ if (sourceType == null || sourceType.isEmpty()) {
|
|
|
+ return Result.error("sourceType 不能为空");
|
|
|
+ }
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ List<Map<String, Object>> seeds = (List<Map<String, Object>>) params.get("seeds");
|
|
|
+ if (seeds == null || seeds.isEmpty()) {
|
|
|
+ return Result.error("seeds 不能为空");
|
|
|
+ }
|
|
|
+ int count = indicatorMappingService.importSeeds(sourceType, seeds);
|
|
|
+ return Result.success("导入完成,共 " + count + " 条");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 映射规则分页(含未匹配 raw 的 review 列表:status=0 为 ai_fallback 待确认) */
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<Page<IndicatorMappingRule>> list(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("role") String role) {
|
|
|
+ if (!"admin".equals(role)) {
|
|
|
+ return Result.error("无权操作");
|
|
|
+ }
|
|
|
+ Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
|
|
|
+ Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
|
|
|
+ Page<IndicatorMappingRule> p = new Page<>(page, size);
|
|
|
+ LambdaQueryWrapper<IndicatorMappingRule> qw = new LambdaQueryWrapper<IndicatorMappingRule>()
|
|
|
+ .orderByAsc(IndicatorMappingRule::getSourceType)
|
|
|
+ .orderByAsc(IndicatorMappingRule::getRawName);
|
|
|
+ Object status = params.get("status");
|
|
|
+ if (status != null) {
|
|
|
+ qw.eq(IndicatorMappingRule::getStatus, Integer.valueOf(status.toString()));
|
|
|
+ }
|
|
|
+ Object sourceType = params.get("sourceType");
|
|
|
+ if (sourceType != null && !sourceType.toString().isEmpty()) {
|
|
|
+ qw.eq(IndicatorMappingRule::getSourceType, sourceType.toString());
|
|
|
+ }
|
|
|
+ indicatorMappingRuleMapper.selectPage(p, qw);
|
|
|
+ return Result.success(p);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 4:IndicatorCalibrationJob(@Scheduled 每日)
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.job;
|
|
|
+
|
|
|
+import com.etotem.cfc.service.IndicatorCorrelationService;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.scheduling.annotation.Scheduled;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+
|
|
|
+/** 指标关联每日自动校准(Spearman;样本不足保留 manual_strength) */
|
|
|
+@Component
|
|
|
+public class IndicatorCalibrationJob {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(IndicatorCalibrationJob.class);
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorCorrelationService indicatorCorrelationService;
|
|
|
+
|
|
|
+ /** 每日 03:00 全量校准 */
|
|
|
+ @Scheduled(cron = "0 0 3 * * ?")
|
|
|
+ public void calibrateDaily() {
|
|
|
+ try {
|
|
|
+ int updated = indicatorCorrelationService.calibrateAll();
|
|
|
+ log.info("每日指标关联校准完成,更新 {} 对", updated);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("每日指标关联校准失败: {}", e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 5:验证编译
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:`BUILD SUCCESS`
|
|
|
+
|
|
|
+### 步骤 6:新增接口前查重 + Commit
|
|
|
+
|
|
|
+运行:`cd cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u`
|
|
|
+确认无 `/api/indicator/trend`、`/api/indicator/change-ranking`、`/api/indicator/quick-lookup`、`/api/indicator/correlation/list`、`/api/admin/indicator/correlation/*`、`/api/admin/indicator/mapping/*` 重复。
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java
|
|
|
+git commit -m "feat(indicator): 视图+管理控制器(4+5接口) + 每日校准Job"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 11:API 文档同步 + 全量验证(验收收尾)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`docs/superpowers/api/API_REFERENCE.md`
|
|
|
+
|
|
|
+### 步骤 1:同步 API_REFERENCE.md
|
|
|
+
|
|
|
+在 API_REFERENCE.md 中新增 9 个接口记录(前端封装的唯一规范入口,遵守去重规范):
|
|
|
+
|
|
|
+| 接口 | 方法 | 请求要点 | 用途 |
|
|
|
+|---|---|---|---|
|
|
|
+| `POST /api/indicator/trend` | POST | `{subjectId, definitionIds:[...]}` | 指标曲线(多指标点序列) |
|
|
|
+| `POST /api/indicator/change-ranking` | POST | `{subjectId, reportType?, category?, scope:adjacent\|first-vs-latest, orderBy:up\|down\|magnitude, limit}` | 变化幅度排名 |
|
|
|
+| `POST /api/indicator/quick-lookup` | POST | `{subjectId, category?, page, pageSize}` | 指标速查(含 dimensionScores 小节) |
|
|
|
+| `POST /api/indicator/correlation/list` | POST | `{subjectId, definitionId}` | 指标关联查询(含 subjectEvidence) |
|
|
|
+| `POST /api/admin/indicator/correlation/save` | POST | `{definitionIdA, definitionIdB, direction, manualStrength, description, manualLocked, sourceNote}` | 关联对定义/编辑 |
|
|
|
+| `POST /api/admin/indicator/correlation/list` | POST | `{page, size}` | 关联对分页 |
|
|
|
+| `POST /api/admin/indicator/correlation/calibrate` | POST | `{pairId?}` | 手动触发校准 |
|
|
|
+| `POST /api/admin/indicator/mapping/import` | POST | `{sourceType, seeds:[{rawName, definitionId?}]}` | 映射种子批量导入 |
|
|
|
+| `POST /api/admin/indicator/mapping/list` | POST | `{page, size, status?, sourceType?}` | 映射规则分页/review |
|
|
|
+
|
|
|
+(markdown 表格置于既有「指标」或新增「指标统一层」章节;保持既有文档风格。)
|
|
|
+
|
|
|
+### 步骤 2:全量后端验证
|
|
|
+
|
|
|
+运行:
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile && mvn test -Dtest='IndicatorValueMapperTest,ReportDateExtractorTest,SpearmanUtilTest,IndicatorMappingServiceTest,NormalizeIndicatorPipelineTest,IndicatorTrendServiceTest,IndicatorCorrelationServiceTest'
|
|
|
+```
|
|
|
+预期:`BUILD SUCCESS`,`Tests run: 22(合计),Failures: 0, Errors: 0`
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test`
|
|
|
+预期:既有全部测试(含原有测试类)`Failures: 0, Errors: 0`
|
|
|
+
|
|
|
+### 步骤 3:Commit
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/superpowers/api/API_REFERENCE.md
|
|
|
+git commit -m "docs(api): 同步指标统一层 9 个新接口契约"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检(完成计划后必须逐项核对)
|
|
|
+
|
|
|
+**1. 规格覆盖度(对照设计文档 第 10 节文件清单 后端部分):**
|
|
|
+- [ ] DatabaseInitializer 迁移 ✓(任务 1:356~362)
|
|
|
+- [ ] schema.sql 同步 4 处 DDL ✓(任务 1 步骤 6)
|
|
|
+- [ ] IndicatorDefinition / IndicatorValue 实体 ✓(任务 2)
|
|
|
+- [ ] IndicatorMappingRule / IndicatorCorrelation 实体 + Mapper ✓(任务 2)
|
|
|
+- [ ] IndicatorMappingService ✓(任务 5)
|
|
|
+- [ ] NormalizeIndicatorPipeline ✓(任务 6)
|
|
|
+- [ ] IndicatorTrendService ✓(任务 8)
|
|
|
+- [ ] IndicatorCorrelationService ✓(任务 9)
|
|
|
+- [ ] ReportDateExtractor ✓(任务 3)
|
|
|
+- [ ] IndicatorController + 2 admin 控制器 ✓(任务 10)
|
|
|
+- [ ] FoodRecommendService 钩子 ✓(任务 6 步骤 4 钩子 3)
|
|
|
+- [ ] AiGateway + LangGraph endpoint ✓(任务 7)
|
|
|
+- [ ] IndicatorCalibrationJob ✓(任务 10 步骤 4)
|
|
|
+- [ ] API_REFERENCE.md ✓(任务 11)
|
|
|
+
|
|
|
+**2. 验收标准映射:**
|
|
|
+- [ ] 验收#5 report_date = PDF 真实日期、回填幂等 ✓(任务 3;scheduledBackfill 连跑两次:已回填行 report_date 非空不再命中 `isNull` 筛选 → 结果一致)
|
|
|
+- [ ] 验收#6 归一化幂等 ✓(任务 6:uk_source_definition + upsertMapping ON DUPLICATE KEY)
|
|
|
+- [ ] 验收#7 expected_indicators 与种子导入后 100% 命中 ✓(任务 5 importSeeds 支持管理端批量导入;exact 先行)
|
|
|
+- [ ] 验收#8 mvn clean compile 通过 ✓(每个任务含编译验证步骤)
|
|
|
+- [ ] 验收#4 关联校准 computed/manual 切换 ✓(任务 9:EFFECTIVE_SAMPLE_THRESHOLD=30,manual_locked 防护)
|
|
|
+
|
|
|
+**3. 类型一致性:**
|
|
|
+- [ ] `IndicatorValue.reportDate` 用 `java.sql.Date`,与 `IndicatorValueMapper.upsertMapping` 的 `#{v.reportDate}` 及 `trend`/`changeRanking` 的 `fmtDate()` 参数类型一致
|
|
|
+- [ ] `IndicatorMappingService.resolve(sourceType, rawName)` 三处调用(pipeline 指标/菌属/食材)签名一致
|
|
|
+- [ ] `NormalizeIndicatorPipeline.processReport(HealthReport, ParsedReportPayload.Payload)` 在钩子 1 传入 `(created, payload)`、钩子 2 传入 `(oldReport, editedPayload)`,两处均与签名一致
|
|
|
+- [ ] `IndicatorTrendService.changeRanking` 返回 `Map<String,Object>`(items/overall),`IndicatorController` 直接 `Result.success(map)` 透传
|
|
|
+
|
|
|
+**4. 占位符扫描:**
|
|
|
+- [ ] 全计划无「待定/TODO/后续实现」,每个代码块为可直接粘贴的完整代码
|
|
|
+- [ ] `HealthDimensionScoreMapper`/`FiveDimensionScoreMapper`/`FamilyMemberMapper` 若项目中已存在则直接复用,不存在时按任务内提供的创建模板补齐(任务 8 步骤 3 注释中已给出 HealthDimensionScoreMapper 模板)
|