# 指标变化曲线 · 指标速查 · 指标关联 — 后端实现计划 > **面向 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..`(报告指标)/ `bacteria.`(菌属)/ `food.`(食材,不依赖 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 { } ``` ### 步骤 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 { } ``` ### 步骤 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 { /** 幂等写:同 (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 batch = healthReportMapper.selectList( new LambdaQueryWrapper() .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 x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4")); List 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 x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4")); List 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 x = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2")); List 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 x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2")); List 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 x, List 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 values) { int n = values.size(); double[] ranks = new double[n]; // 按值排序的索引 List idx = new ArrayList<>(); for (int i = 0; i < n; i++) idx.add(i); idx.sort(Comparator.comparing(values::get)); Map 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() .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 candidates = ruleMapper.selectList(new LambdaQueryWrapper() .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() .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.. / bacteria. / food.) */ 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 gramsA = bigrams(a); List 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 bigrams(String s) { if (s == null || s.length() < 2) { List single = new ArrayList<>(); if (s != null && !s.isEmpty()) { single.add(s); } return single; } List 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> seeds) { int count = 0; for (Map 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 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 finalIndices = foodRecommendIndexMapper.selectList( new LambdaQueryWrapper() .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 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 entity = new HttpEntity<>(body.toString(), createJsonHeaders()); String url = baseUrl + "/api/v1/indicator/classify"; ResponseEntity 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 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 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 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 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 rows = new ArrayList<>(); rows.add(value(1L, "2026-10-01", "12.8")); when(valueMapper.selectList(any())).thenReturn(rows); Map 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 trend(Long subjectId, List definitionIds, Long familyId) { Map data = new LinkedHashMap<>(); List> items = new ArrayList<>(); if (definitionIds == null || definitionIds.isEmpty() || subjectId == null) { data.put("items", items); return data; } List rows = valueMapper.selectList(new LambdaQueryWrapper() .eq(IndicatorValue::getSubjectId, subjectId) .in(IndicatorValue::getDefinitionId, definitionIds) .isNotNull(IndicatorValue::getReportDate) .orderByAsc(IndicatorValue::getReportDate)); Map> byDef = rows.stream() .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId, LinkedHashMap::new, Collectors.toList())); Map defCache = new HashMap<>(); for (Map.Entry> e : byDef.entrySet()) { Map 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> points = new ArrayList<>(); for (IndicatorValue v : e.getValue()) { Map 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 changeRanking(Long subjectId, String reportType, String category, String scope, String orderBy, Integer limit, Long familyId) { Map data = new LinkedHashMap<>(); List> items = new ArrayList<>(); int improved = 0; int worsened = 0; int flat = 0; LambdaQueryWrapper qw = new LambdaQueryWrapper() .eq(IndicatorValue::getSubjectId, subjectId) .isNotNull(IndicatorValue::getReportDate); if (reportType != null && !reportType.isEmpty()) { qw.eq(IndicatorValue::getSourceType, reportType); } List rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate)); // 按 definitionId 分组 Map> byDef = rows.stream() .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId)); Map defCache = new HashMap<>(); for (Map.Entry> e : byDef.entrySet()) { List 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 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 beforeMap = new LinkedHashMap<>(); beforeMap.put("reportDate", fmtDate(before.getReportDate())); beforeMap.put("value", before.getValue()); Map 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 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 quickLookup(Long subjectId, String category, Integer page, Integer pageSize, Long familyId) { Map data = new LinkedHashMap<>(); int pageVal = page != null && page > 0 ? page : 1; int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 30; // 该成员全部观测(按时序取最新一条) List rows = valueMapper.selectList(new LambdaQueryWrapper() .eq(IndicatorValue::getSubjectId, subjectId) .isNotNull(IndicatorValue::getDefinitionId)); Map> byDef = rows.stream() .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId)); Map defCache = new HashMap<>(); List> allItems = new ArrayList<>(); for (Map.Entry> 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 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 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> categories = new ArrayList<>(); Map catCount = allItems.stream() .collect(Collectors.groupingBy(i -> (String) i.get("category"), Collectors.counting())); catCount.forEach((name, total) -> { Map 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> 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> loadDimensionScores(Long subjectId) { List> result = new ArrayList<>(); // 五维:five_dimension_scores 按 memberId + assessedAt 最新 try { result.addAll(fiveDimensionScoreMapper.selectList( new LambdaQueryWrapper() .eq(com.etotem.cfc.entity.FiveDimensionScore::getMemberId, subjectId) .orderByDesc(com.etotem.cfc.entity.FiveDimensionScore::getAssessedAt) .last("LIMIT 10")) .stream().map(s -> { Map 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() .eq(com.etotem.cfc.entity.HealthDimensionScore::getMemberId, subjectId) .orderByDesc(com.etotem.cfc.entity.HealthDimensionScore::getAssessDate) .last("LIMIT 10")) .stream().map(s -> { Map 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 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 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 { } ``` ### 步骤 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 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() .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 listRelations(Long subjectId, Long definitionId) { Map data = new LinkedHashMap<>(); List> relations = new ArrayList<>(); if (definitionId == null) { data.put("relations", relations); return data; } List pairs = correlationMapper.selectList(new LambdaQueryWrapper() .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 rel = new LinkedHashMap<>(); rel.put("pairId", pair.getId()); Map 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 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 evidence = subjectEvidence(subjectId, pair.getDefinitionIdA(), pair.getDefinitionIdB()); rel.put("subjectEvidence", evidence); relations.add(rel); } data.put("relations", relations); return data; } /** 成员级配对证据:点数 + 个人 rho + 是否足够(>=3) */ private Map subjectEvidence(Long subjectId, Long defA, Long defB) { Map evidence = new LinkedHashMap<>(); evidence.put("points", 0); evidence.put("personalRho", 0.0); evidence.put("hasEnough", false); if (subjectId == null) { return evidence; } List rowsA = valueMapper.selectList(new LambdaQueryWrapper() .eq(IndicatorValue::getSubjectId, subjectId) .eq(IndicatorValue::getDefinitionId, defA) .isNotNull(IndicatorValue::getReportDate) .orderByAsc(IndicatorValue::getReportDate)); List rowsB = valueMapper.selectList(new LambdaQueryWrapper() .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 xs = new ArrayList<>(); List 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 pairs = correlationMapper.selectList(new LambdaQueryWrapper() .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 rowsA = valueMapper.selectList(new LambdaQueryWrapper() .eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdA()) .isNotNull(IndicatorValue::getReportDate)); List rowsB = valueMapper.selectList(new LambdaQueryWrapper() .eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdB()) .isNotNull(IndicatorValue::getReportDate)); // key = subjectId + '-' + reportDate Map mapA = rowsA.stream().filter(v -> v.getSubjectId() != null) .collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x)); Map mapB = rowsB.stream().filter(v -> v.getSubjectId() != null) .collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x)); // 按 subject 分组配对点 Map> bySubject = new java.util.HashMap<>(); int sampleSize = 0; for (Map.Entry 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 rhos = new ArrayList<>(); for (List points : bySubject.values()) { if (points.size() < 3) { continue; } List xs = points.stream().map(p -> p[0]).collect(Collectors.toList()); List 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 checkSubjectBelongs(Long subjectId, Long familyId) { if (subjectId == null) { return Result.error("subjectId不能为空"); } FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper() .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 params, @RequestAttribute("familyId") Long familyId) { Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId")); Result check = checkSubjectBelongs(subjectId, familyId); if (check != null) { return check; } @SuppressWarnings("unchecked") List rawIds = (List) params.get("definitionIds"); List 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 params, @RequestAttribute("familyId") Long familyId) { Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId")); Result 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 params, @RequestAttribute("familyId") Long familyId) { Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId")); Result 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 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 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 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> list(@RequestBody Map 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 p = new Page<>(page, size); indicatorCorrelationMapper.selectPage(p, new LambdaQueryWrapper() .orderByDesc(IndicatorCorrelation::getUpdatedAt)); return Result.success(p); } /** 手动触发全校准或单对校准(单对:body 传 definitionIdA/definitionIdB 或 pairId) */ @PostMapping("/calibrate") public Result calibrate(@RequestBody Map 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 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> seeds = (List>) 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> list(@RequestBody Map 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 p = new Page<>(page, size); LambdaQueryWrapper qw = new LambdaQueryWrapper() .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`(items/overall),`IndicatorController` 直接 `Result.success(map)` 透传 **4. 占位符扫描:** - [ ] 全计划无「待定/TODO/后续实现」,每个代码块为可直接粘贴的完整代码 - [ ] `HealthDimensionScoreMapper`/`FiveDimensionScoreMapper`/`FamilyMemberMapper` 若项目中已存在则直接复用,不存在时按任务内提供的创建模板补齐(任务 8 步骤 3 注释中已给出 HealthDimensionScoreMapper 模板)