2026-10-09-indicator-change-curve-backend.md 139 KB

指标变化曲线 · 指标速查 · 指标关联 — 后端实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

本计划覆盖设计文档 P1(数据层)+ P2(归一化管道)+ P3(视图 API)+ P6(校准任务)四个后端分期。小程序(P4)与管理端(P5)是独立计划,不在此文件。

目标: 将 indicator_definitions/indicator_values 升级为全平台统一指标层:真实报告日期、归一化映射管道(exact→fuzzy→AI→惰性注册)、指标变化曲线/变化幅度排名/指标速查 3 个视图接口、指标关联定义与 Spearman 数据校准。

架构: 复用现有 health_reports/health_indicators/food_recommend_idx 作为原始数据,新增 indicator_mapping_rules + indicator_correlations 两表;NormalizeIndicatorPipeline 作为单一写者在报告入库/编辑/食材重算三个钩子处幂等写入 indicator_values(唯一键 (source_type, source_id, definition_id));视图层 IndicatorTrendService 只读 indicator_values,五维/七维评分 API 层单独合入不写入时序;校准用纯 Java 实现的 Spearman 秩相关 + Fisher z 聚合,@Scheduled 每日自动跑 + 管理端手动触发。

技术栈: Java 8(禁止 var/List.of/record)、Spring Boot 2.7.18、MyBatis-Plus(LambdaQueryWrapper)、JUnit5 + Mockito(参照 FoodServiceTest,不加载 Spring 上下文)、MySQL 8(utf8mb4)、FastAPI/Pydantic(cfc-langgraph 侧 AI 归类端点)。


文件结构

文件 操作 职责
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java 改 迁移 356~362(见任务 1)
cfc-backend/src/main/resources/schema.sql 改 同步 indicator_definitions / indicator_values / 新两表的完整 DDL 快照
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java 改 +5 列字段与 getter/setter
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java 改 +6 列字段与 getter/setter
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java 新建 映射规则实体(Lombok @Data)
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java 新建 关联关系实体(Lombok @Data)
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java 新建 空 BaseMapper
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java 新建 空 BaseMapper
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java 改 新增 upsertMapping 幂等写方法(@Insert ON DUPLICATE KEY UPDATE)
cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java 新建 Spearman rho + Fisher z 聚合(纯函数,约 40 行)
cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java 新建 payload 真实报告日期提取 + 历史回填分批任务
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java 新建 resolve(raw)→definitionId(exact→fuzzy→AI→惰性注册);importSeeds
cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java 新建 报告指标/菌属 + 食材快照 幂等写入 indicator_values
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java 新建 trend / change-ranking / quick-lookup(含五维七维小节)
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java 新建 关联 CRUD + 对序强制 + effective_strength + Spearman 校准
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java 新建 /api/indicator/* 4 个视图接口(subjectId 归属校验)
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java 新建 关联 save/list/calibrate
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java 新建 映射 import/list
cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java 新建 @Scheduled 每日 03:00 自动校准
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java 改 +classifyIndicator(照 generatePortrait 模板)
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java 改 insertReport() subjectId 块后调 pipeline(钩子 1)
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java 改 updateReportFromPayload() 步骤 5 后调 pipeline(钩子 2)
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java 改 recalculateForUser() 尾部调 pipeline.snapshotFoodIndices(钩子 3)
cfc-langgraph/app/api/indicator_classify.py 新建 POST /api/v1/indicator/classify(AI 归类兜底)
cfc-langgraph/app/main.py 改 注册新 router
docs/superpowers/api/API_REFERENCE.md 改 同步记录 9 个新接口

关键决策(锁定,不得自行更改):

  1. 迁移编号从 356 起(最新现有迁移为 355,DatabaseInitializer.java L11855-11873)。
  2. indicator_values 唯一键 uk_source_definition (source_type, source_id, definition_id) —— 报告指标/菌属写 source_id = health_reports.id,食物指数写 source_id = food_recommend_idx.id。重解析/重算同键覆盖更新,保证验收标准 6(幂等)。
  3. idx_def 不再新增:schema.sql 中该表已有 idx_definition (definition_id),语义等价,迁移只加 uk_source_definition 与 idx_subject_def_date,避免重复索引。
  4. 新实体用 Lombok @Data(与 HealthIndicator/FoodRecommendIndex 一致);IndicatorDefinition/IndicatorValue 维持手工 getter/setter(既有风格,只加字段)。
  5. 惰性注册 code 规则:indicator.<category>.<name>(报告指标)/ bacteria.<name>(菌属)/ food.<name>(食材,不依赖 foodId 表,raw_name 即食材名)。source_kind 对应 report_indicator/bacteria/food_index。
  6. 映射规则表 source_type 统一用报告类型(gut_flora 等):报告指标、菌属、食材三条来源都以 source_type=报告类型 登记,raw_name 互不相同故不冲突;food_recommend 仅作为 indicator_values.source_type 语义保留(食材快照走钩子 3 时 source_type=报告类型,report_id 关联)。
  7. 报告日期回填不伪装上传时间:提取失败置 report_date = NULL;回填筛选条件带 payload_json LIKE '%reportDate%',避免对提取失败的记录无限重复处理。
  8. 五维/七维不进 indicator_values:quick-lookup 的 dimensionScores 小节在 IndicatorTrendService 内 join 原表(five_dimension_scores / health_dimension_scores)。
  9. AI 兜底只建议:AiGateway.classifyIndicator 返回建议的 definition code/name,IndicatorMappingService 将其登记为 match_type='ai_fallback', status=0(待确认),不自动启用;调用方拿到建议 definitionId 照常写入(AI 兜底路径允许临时写入,管理端 review 修正规则后重解析自动纠正)。
  10. jsonArray() / ensureReportTypeRow() / seedExpectedIndicator() 是 DatabaseInitializer 既有私有助手(L11978-12022),新迁移可复用;ensureColumn(table,column,definition) 在 L4855。
  11. 钩子事务边界:钩子 2 所在 updateReportFromPayload 已标 @Transactional,管道与其同事务;钩子 1 的 insertReport 是私有非事务方法(由异步采集调用),管道方法自身标 @Transactional(propagation = REQUIRED) 随调用方;钩子 3 的 recalculateForUser 已标 @Transactional。不要在管道中开启新事务的 REQUIRES_NEW。
  12. subjectId 归属校验(视图接口):@RequestAttribute("familyId") 必须存在,IndicatorController 校验 subjectId 属于该家庭(查 family_members 表),不属于则返回 Result.error("成员不属于当前家庭")。

任务 1:数据库迁移(P1)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(runMigrations() L129 内、文件末 } L12023 前追加方法 + 在 runMigrations 中调用)
  • 修改:cfc-backend/src/main/resources/schema.sql(indicator_definitions 段约 L2501、indicator_values 段约 L2518,末尾追加两新表)

步骤 1:迁移 356 — indicator_definitions 加 5 列

在 runMigrations() 末尾(最后一个迁移方法调用之后)追加调用 migrateIndicatorDefinitionsColumns(),并新增该方法:

// 迁移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 列

// 迁移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 — 唯一键 + 时序索引

// 迁移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 — 新建两表

// 迁移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 兜底 + 五维定义种子

// 迁移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() 末尾追加调用:

// 指标统一层迁移(356~362)
migrateIndicatorDefinitionsColumns();
migrateIndicatorValuesColumns();
addIndicatorValuesKeys();
createIndicatorMappingRulesTable();
createIndicatorCorrelationsTable();
ensureIndicatorCollectionDate();
seedDimensionDefinitions();

步骤 6:同步 schema.sql(4 处 DDL)

  1. indicator_definitions CREATE TABLE(约 L2501)在 sort_order 前追加 5 列,与迁移 356 同义:

    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)替换为:

    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

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 风格,维持一致):

    private String sourceKind;
    private String reportTypeScope;
    private String valueType;
    private String goodDirection;
    private String dimensionCode;
    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; 之间加字段:

    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 列类型一致):

    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(完整文件):

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(完整文件):

package com.etotem.cfc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.IndicatorMappingRule;

public interface IndicatorMappingRuleMapper extends BaseMapper<IndicatorMappingRule> {
}

步骤 4:创建 IndicatorCorrelation 实体 + Mapper

entity/IndicatorCorrelation.java(完整文件):

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(完整文件):

package com.etotem.cfc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.IndicatorCorrelation;

public interface IndicatorCorrelationMapper extends BaseMapper<IndicatorCorrelation> {
}

步骤 5:IndicatorValueMapper 增加幂等 upsert 方法

package com.etotem.cfc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.IndicatorValue;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;

public interface IndicatorValueMapper extends BaseMapper<IndicatorValue> {

    /** 幂等写:同 (source_type, source_id, definition_id) 覆盖更新(uk_source_definition) */
    @Insert("INSERT INTO indicator_values " +
            "(source_type, source_id, definition_id, value, numeric_value, remark, " +
            " subject_id, family_id, report_id, report_date, unit, status, recorded_at) " +
            "VALUES (#{v.sourceType}, #{v.sourceId}, #{v.definitionId}, #{v.value}, #{v.numericValue}, #{v.remark}, " +
            " #{v.subjectId}, #{v.familyId}, #{v.reportId}, #{v.reportDate}, #{v.unit}, #{v.status}, NOW()) " +
            "ON DUPLICATE KEY UPDATE " +
            " value = VALUES(value), numeric_value = VALUES(numeric_value), remark = VALUES(remark), " +
            " subject_id = VALUES(subject_id), family_id = VALUES(family_id), report_id = VALUES(report_id), " +
            " report_date = VALUES(report_date), unit = VALUES(unit), status = VALUES(status), recorded_at = NOW()")
    int upsertMapping(@Param("v") IndicatorValue v);
}

(若该文件原有其他内容与 import,保留原 import 与原有方法,仅合并本方法。)

步骤 6:编写失败测试验证 upsert

src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java:

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

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:编写失败测试

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

package com.etotem.cfc.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.HealthReport;
import com.etotem.cfc.mapper.HealthReportMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;

/**
 * 真实报告日期提取 + 历史回填。
 * PDF 解析阶段将「报告日期/采样日期」写入 payload.reportDate 后,
 * 入库逻辑(HealthReportService 既有 reportDate 修正)自动生效;
 * 本类只负责:(1) 从 payload 提取真实日期(供回填/校验);
 * (2) 扫描旧报告幂等回填 report_date(无法提取置 NULL,不伪装成上传时间)。
 */
@Service
public class ReportDateExtractor {

    private static final Logger log = LoggerFactory.getLogger(ReportDateExtractor.class);

    @Resource
    private HealthReportMapper healthReportMapper;

    @Resource
    private ObjectMapper objectMapper;

    /**
     * 从 payload JSON 中提取真实报告日期。
     * 取值顺序:summary.reportDate → 顶层 reportDate → indicators[].collectionDate(取第一个非空)。
     * 支持 yyyy-MM-dd / yyyy/MM/dd;无法提取返回 null。
     */
    public java.sql.Date extractReportDate(String payloadJson) {
        if (payloadJson == null || payloadJson.isEmpty()) {
            return null;
        }
        try {
            JsonNode root = objectMapper.readTree(payloadJson);
            String dateStr = null;
            JsonNode summary = root.get("summary");
            if (summary != null && summary.isObject() && summary.hasNonNull("reportDate")) {
                dateStr = summary.get("reportDate").asText();
            }
            if (dateStr == null || dateStr.trim().isEmpty()) {
                if (root.hasNonNull("reportDate")) {
                    dateStr = root.get("reportDate").asText();
                }
            }
            java.sql.Date parsed = parseDate(dateStr);
            if (parsed != null) {
                return parsed;
            }
            // 回退:indicators[].collectionDate 取第一个非空
            JsonNode indicators = root.get("indicators");
            if (indicators != null && indicators.isArray()) {
                for (JsonNode ind : indicators) {
                    if (ind.hasNonNull("collectionDate")) {
                        java.sql.Date d = parseDate(ind.get("collectionDate").asText());
                        if (d != null) {
                            return d;
                        }
                    }
                }
            }
            return null;
        } catch (Exception e) {
            log.warn("ReportDateExtractor 解析 payload 失败: {}", e.getMessage());
            return null;
        }
    }

    private java.sql.Date parseDate(String dateStr) {
        if (dateStr == null || dateStr.trim().isEmpty()) {
            return null;
        }
        String s = dateStr.trim();
        try {
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
            Date d = sdf.parse(s);
            return new java.sql.Date(d.getTime());
        } catch (Exception e) {
            try {
                SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
                Date d = sdf.parse(s);
                return new java.sql.Date(d.getTime());
            } catch (Exception e2) {
                return null;
            }
        }
    }

    /**
     * 历史回填:扫描 report_date 缺失或疑似上传时间的旧报告(且 payload 含 reportDate 字段),
     * 提取真实日期并回填;无法提取置 NULL。分批(500/批)、幂等可重跑。
     *
     * @return 本轮处理的总行数
     */
    public int backfillHistoricalReports() {
        int total = 0;
        while (true) {
            List<HealthReport> batch = healthReportMapper.selectList(
                    new LambdaQueryWrapper<HealthReport>()
                            .isNull(HealthReport::getReportDate)
                            .like(HealthReport::getPayloadJson, "reportDate")
                            .last("LIMIT 500"));
            if (batch.isEmpty()) {
                break;
            }
            for (HealthReport report : batch) {
                try {
                    java.sql.Date realDate = extractReportDate(report.getPayloadJson());
                    if (realDate != null) {
                        report.setReportDate(realDate);
                        healthReportMapper.updateById(report);
                        log.info("回填报告真实日期 reportId={}, reportDate={}", report.getId(), realDate);
                    } else {
                        // 置 NULL 并清 payload 中可提取性:无法提取的不再反复处理
                        report.setReportDate(null);
                        healthReportMapper.updateById(report);
                        log.info("报告日期无法提取,置 NULL reportId={}", report.getId());
                    }
                    total++;
                } catch (Exception e) {
                    log.warn("回填报告日期失败 reportId={}: {}", report.getId(), e.getMessage());
                }
            }
        }
        log.info("ReportDateExtractor 历史回填完成,共处理 {} 行", total);
        return total;
    }

    /** 每日 03:10 自动回填(幂等可重跑,连跑两次结果一致) */
    @Scheduled(cron = "0 10 3 * * ?")
    public void scheduledBackfill() {
        backfillHistoricalReports();
    }
}

步骤 4:运行测试确认通过

运行:cd cfc-backend && mvn test -Dtest=ReportDateExtractorTest 预期:Tests run: 4, Failures: 0, Errors: 0

步骤 5:验证编译 + Commit

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

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:编写失败测试

package com.etotem.cfc.util;

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class SpearmanUtilTest {

    @Test
    void perfectPositiveRankCorrelation() {
        // x 与 y 完全同序 → rho = 1
        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
        double rho = SpearmanUtil.spearmanRho(x, y);
        assertEquals(1.0, rho, 1e-9);
    }

    @Test
    void perfectNegativeRankCorrelation() {
        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
        List<BigDecimal> y = Arrays.asList(new BigDecimal("4"), new BigDecimal("3"), new BigDecimal("2"), new BigDecimal("1"));
        double rho = SpearmanUtil.spearmanRho(x, y);
        assertEquals(-1.0, rho, 1e-9);
    }

    @Test
    void tiedRanksHandled() {
        // 有并列值时使用平均秩;x 与 y 逐项同值(含并列) → rho = 1
        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
        double rho = SpearmanUtil.spearmanRho(x, y);
        assertEquals(1.0, rho, 1e-9);
    }

    @Test
    void shortSeriesReturnsZero() {
        // 少于 3 个配对点不计算(对应设计:单对象 ≥3 配对才参与)
        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
        assertEquals(0.0, SpearmanUtil.spearmanRho(x, y), 1e-9);
    }

    @Test
    void fisherZAverageRestoresPositiveRho() {
        // 两个 rho=0.6 的对象 z 均值还原 → 仍约 0.6
        double avg = SpearmanUtil.fisherZAverage(new double[]{0.6, 0.6});
        assertEquals(0.6, avg, 1e-6);
    }

    @Test
    void fisherZAverageHandlesExtremeRho() {
        // rho 接近 ±1 时 z 变换仍有限(这里验证不抛异常且符号正确)
        double avg = SpearmanUtil.fisherZAverage(new double[]{0.99, 0.95});
        assertTrue(avg > 0.9);
    }
}

步骤 2:运行确认失败

运行:cd cfc-backend && mvn test -Dtest=SpearmanUtilTest 预期:BUILD FAILURE,SpearmanUtil 不存在

步骤 3:实现 SpearmanUtil

package com.etotem.cfc.util;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 秩相关计算(纯函数,无外部依赖)。
 * 菌群丰度偏态分布,Spearman 比 Pearson 稳健;Java 8 无内置实现,此处手工实现,约 40 行。
 */
public final class SpearmanUtil {

    private SpearmanUtil() {
    }

    /** 计算 Spearman 秩相关系数;配对点 < 3 返回 0(样本不足不误导)。 */
    public static double spearmanRho(List<BigDecimal> x, List<BigDecimal> y) {
        if (x == null || y == null || x.size() != y.size() || x.size() < 3) {
            return 0.0;
        }
        double[] rankX = rank(x);
        double[] rankY = rank(y);
        double sumD2 = 0;
        int n = x.size();
        for (int i = 0; i < n; i++) {
            double d = rankX[i] - rankY[i];
            sumD2 += d * d;
        }
        double denom = (double) n * (n * n - 1);
        if (denom == 0) {
            return 0.0;
        }
        double rho = 1 - (6 * sumD2) / denom;
        if (rho > 1) rho = 1;
        if (rho < -1) rho = -1;
        return rho;
    }

    /** 平均秩;并列值取平均(tie handling)。 */
    private static double[] rank(List<BigDecimal> values) {
        int n = values.size();
        double[] ranks = new double[n];
        // 按值排序的索引
        List<Integer> idx = new ArrayList<>();
        for (int i = 0; i < n; i++) idx.add(i);
        idx.sort(Comparator.comparing(values::get));
        Map<Integer, Double> rankMap = new HashMap<>();
        int i = 0;
        while (i < n) {
            int j = i;
            while (j + 1 < n && values.get(idx.get(j + 1)).compareTo(values.get(idx.get(i))) == 0) {
                j++;
            }
            // [i, j] 为并列区间,平均秩 = (i+1 + j+1) / 2
            double avgRank = ((i + 1) + (j + 1)) / 2.0;
            for (int k = i; k <= j; k++) {
                rankMap.put(idx.get(k), avgRank);
            }
            i = j + 1;
        }
        for (int k = 0; k < n; k++) {
            ranks[k] = rankMap.get(k);
        }
        return ranks;
    }

    /**
     * Fisher z 聚合:各对象 rho → z = artanh(rho) → 均值 → rho = tanh(z̄)。
     * 设计文档 4.6:每对象算 rho → z 变换 → 全对象 z 均值 → 还原为平均 rho。
     */
    public static double fisherZAverage(double[] rhos) {
        if (rhos == null || rhos.length == 0) {
            return 0.0;
        }
        double sumZ = 0;
        for (double r : rhos) {
            double v = Math.max(-0.999999, Math.min(0.999999, r));
            sumZ += 0.5 * Math.log((1 + v) / (1 - v));
        }
        double zAvg = sumZ / rhos.length;
        return Math.tanh(zAvg);
    }
}

步骤 4:运行测试确认通过

运行:cd cfc-backend && mvn test -Dtest=SpearmanUtilTest 预期:Tests run: 6, Failures: 0, Errors: 0

步骤 5:Commit

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:编写失败测试

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

package com.etotem.cfc.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.IndicatorDefinition;
import com.etotem.cfc.entity.IndicatorMappingRule;
import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;

/**
 * 指标归一化映射:raw_name → definition_id。
 * 解析顺序:exact 命中映射规则 → fuzzy(CJK 二元组相似度)→ AI 兜底(登记 pending)→ 惰性注册新定义。
 * 单一写者模式的「身份解析」环节;规则表是权威,管理端可 review 修正。
 */
@Service
public class IndicatorMappingService {

    private static final Logger log = LoggerFactory.getLogger(IndicatorMappingService.class);

    @Resource
    private IndicatorMappingRuleMapper ruleMapper;

    @Resource
    private IndicatorDefinitionMapper definitionMapper;

    @Resource
    private AiGateway aiGateway;

    @Resource
    private ObjectMapper objectMapper;

    /** fuzzy 相似度阈值(CJK 二元组 Dice 系数),低于则视为未命中 */
    private static final double FUZZY_THRESHOLD = 0.5;

    /**
     * 解析原始指标名为 definition_id。
     *
     * @param sourceType 报告类型(gut_flora 等)
     * @param rawName    报告原始名称(指标名/菌名/食材名,已 trim)
     */
    public Long resolve(String sourceType, String rawName) {
        if (sourceType == null || rawName == null || rawName.trim().isEmpty()) {
            return null;
        }
        String name = rawName.trim();

        // 1) exact 命中规则表
        IndicatorMappingRule rule = ruleMapper.selectOne(new LambdaQueryWrapper<IndicatorMappingRule>()
                .eq(IndicatorMappingRule::getSourceType, sourceType)
                .eq(IndicatorMappingRule::getRawName, name)
                .orderByDesc(IndicatorMappingRule::getPriority)
                .last("LIMIT 1"));
        if (rule != null && rule.getDefinitionId() != null) {
            return rule.getDefinitionId();
        }

        // 2) fuzzy:与同来源全部启用规则比较 CJK 二元组相似度
        List<IndicatorMappingRule> candidates = ruleMapper.selectList(new LambdaQueryWrapper<IndicatorMappingRule>()
                .eq(IndicatorMappingRule::getSourceType, sourceType)
                .eq(IndicatorMappingRule::getStatus, 1));
        IndicatorMappingRule best = null;
        double bestScore = 0;
        for (IndicatorMappingRule c : candidates) {
            double s = bigramSimilarity(name, c.getRawName());
            if (s > bestScore) {
                bestScore = s;
                best = c;
            }
        }
        if (best != null && bestScore >= FUZZY_THRESHOLD) {
            return best.getDefinitionId();
        }

        // 3) AI 兜底:只建议,登记 pending(status=0)规则待管理端确认
        String aiJson = aiGateway != null
                ? aiGateway.classifyIndicator(sourceType, name, Collections.emptyList())
                : null;
        if (aiJson != null) {
            try {
                JsonNode suggestion = objectMapper.readTree(aiJson);
                if (suggestion != null && suggestion.hasNonNull("code")) {
                    IndicatorDefinition def = definitionMapper.selectOne(new LambdaQueryWrapper<IndicatorDefinition>()
                            .eq(IndicatorDefinition::getCode, suggestion.get("code").asText())
                            .last("LIMIT 1"));
                    if (def != null) {
                        registerRule(sourceType, name, def.getId(), "ai_fallback", 0);
                        log.info("AI 兜底建议:sourceType={}, raw={} → defId={}(pending 待确认)",
                                sourceType, name, def.getId());
                        return def.getId();
                    }
                }
            } catch (Exception e) {
                log.warn("AI 归类建议解析失败 raw={}: {}", name, e.getMessage());
            }
        }

        // 4) 惰性注册:新 definition + auto_created 规则
        return lazyRegister(sourceType, name);
    }

    /** 惰性注册新定义(code 规则:indicator.<cat>.<name> / bacteria.<name> / food.<name>) */
    private Long lazyRegister(String sourceType, String rawName) {
        DefinitionCode code = buildCode(sourceType, rawName);
        IndicatorDefinition def = new IndicatorDefinition();
        def.setDomain("physical");
        def.setCategory(code.category);
        def.setCode(code.code);
        def.setName(rawName);
        def.setDataType("number");
        def.setSourceKind(code.sourceKind);
        def.setReportTypeScope(sourceType);
        def.setValueType(inferValueType(sourceType));
        def.setGoodDirection("neutral");
        def.setSortOrder(0);
        def.setStatus(1);
        def.setCreatedAt(new Date());
        def.setUpdatedAt(new Date());
        definitionMapper.insert(def);
        registerRule(sourceType, rawName, def.getId(), "auto_created", 1);
        log.info("惰性注册指标定义 sourceType={}, raw={} → defId={}", sourceType, rawName, def.getId());
        return def.getId();
    }

    private void registerRule(String sourceType, String rawName, Long definitionId, String matchType, int status) {
        IndicatorMappingRule rule = new IndicatorMappingRule();
        rule.setSourceType(sourceType);
        rule.setRawName(rawName);
        rule.setDefinitionId(definitionId);
        rule.setMatchType(matchType);
        rule.setPriority(0);
        rule.setStatus(status);
        rule.setCreatedAt(new Date());
        rule.setUpdatedAt(new Date());
        try {
            ruleMapper.insert(rule);
        } catch (Exception e) {
            log.warn("登记映射规则冲突(已存在?),忽略 sourceType={}, raw={}: {}", sourceType, rawName, e.getMessage());
        }
    }

    // ---------- 内部工具 ----------

    private static class DefinitionCode {
        String code;
        String category;
        String sourceKind;
    }

    private DefinitionCode buildCode(String sourceType, String rawName) {
        DefinitionCode dc = new DefinitionCode();
        if ("food_recommend".equals(sourceType)) {
            dc.sourceKind = "food_index";
            dc.category = "食材";
            dc.code = "food." + rawName;
        } else if (isBacteriaContext(sourceType)) {
            dc.sourceKind = "bacteria";
            dc.category = "菌属";
            dc.code = "bacteria." + rawName;
        } else {
            dc.sourceKind = "report_indicator";
            dc.category = "报告指标";
            dc.code = "indicator." + rawName;
        }
        // code 有唯一约束,过长/含特殊字符时安全截断
        if (dc.code.length() > 190) {
            dc.code = dc.code.substring(0, 190);
        }
        return dc;
    }

    private boolean isBacteriaContext(String sourceType) {
        return sourceType != null && sourceType.startsWith("gut_flora");
    }

    private String inferValueType(String sourceType) {
        return isBacteriaContext(sourceType) ? "numeric" : "numeric";
    }

    /**
     * CJK 二元组 Dice 相似度:中文按字符二元组,ASCII 按词/字符二元组混合。
     * 0~1;1 = 完全相同(但 exact 已先行命中)。
     */
    double bigramSimilarity(String a, String b) {
        if (a == null || b == null) {
            return 0;
        }
        if (a.equals(b)) {
            return 1;
        }
        List<String> gramsA = bigrams(a);
        List<String> gramsB = bigrams(b);
        if (gramsA.isEmpty() || gramsB.isEmpty()) {
            return 0;
        }
        int common = 0;
        for (String g : gramsA) {
            if (gramsB.contains(g)) {
                common++;
            }
        }
        return (2.0 * common) / (gramsA.size() + gramsB.size());
    }

    private List<String> bigrams(String s) {
        if (s == null || s.length() < 2) {
            List<String> single = new ArrayList<>();
            if (s != null && !s.isEmpty()) {
                single.add(s);
            }
            return single;
        }
        List<String> result = new ArrayList<>();
        for (int i = 0; i < s.length() - 1; i++) {
            result.add(s.substring(i, i + 2));
        }
        return result;
    }

    /** 数值型解析:去掉 %/~/>/< 与空格后尝试 BigDecimal */
    public BigDecimal parseNumericValue(String raw) {
        if (raw == null) {
            return null;
        }
        String cleaned = raw.replaceAll("[%<>~\\s]", "").trim();
        if (cleaned.isEmpty()) {
            return null;
        }
        try {
            return new BigDecimal(cleaned);
        } catch (Exception e) {
            return null;
        }
    }

    /** 批量种子导入(管理端调用):seed 列表 [{rawName, definitionId?, category?, unit?}] */
    public int importSeeds(String sourceType, List<Map<String, Object>> seeds) {
        int count = 0;
        for (Map<String, Object> seed : seeds) {
            String rawName = seed.get("rawName") != null ? seed.get("rawName").toString() : null;
            if (rawName == null || rawName.trim().isEmpty()) {
                continue;
            }
            Object defIdObj = seed.get("definitionId");
            Long defId;
            if (defIdObj instanceof Number) {
                defId = ((Number) defIdObj).longValue();
            } else if (defIdObj != null) {
                defId = Long.valueOf(defIdObj.toString());
            } else {
                defId = lazyRegister(sourceType, rawName);
            }
            registerRule(sourceType, rawName.trim(), defId, "exact", 1);
            count++;
        }
        log.info("种子导入完成 sourceType={}, seeds={}", sourceType, count);
        return count;
    }
}

步骤 4:运行测试确认通过

运行:cd cfc-backend && mvn test -Dtest=IndicatorMappingServiceTest 预期:Tests run: 4, Failures: 0, Errors: 0

步骤 5:验证编译 + Commit

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

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:编写失败测试

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

package com.etotem.cfc.service;

import com.etotem.cfc.dto.ParsedReportPayload;
import com.etotem.cfc.entity.FoodRecommendIndex;
import com.etotem.cfc.entity.HealthReport;
import com.etotem.cfc.entity.IndicatorValue;
import com.etotem.cfc.mapper.IndicatorValueMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.math.BigDecimal;
import java.sql.Date;
import java.util.List;

/**
 * 归一化管道 —— 统一指标层「单一写者」。
 * 报告入库/编辑流程照旧写 health_indicators(不动现有消费者),
 * 本管道在同流程/同事务内将 指标+菌属+食材 幂等写入 indicator_values。
 * 唯一键 (source_type, source_id, definition_id) 保证重解析只覆盖更新。
 */
@Service
public class NormalizeIndicatorPipeline {

    private static final Logger log = LoggerFactory.getLogger(NormalizeIndicatorPipeline.class);

    @Resource
    private IndicatorMappingService mappingService;

    @Resource
    private IndicatorValueMapper valueMapper;

    /**
     * 报告指标 + 菌属归一化写入。
     * 调用点:insertReport()(subjectId 块后)/ updateReportFromPayload()(步骤 5 后)。
     * 无 subject 的报告不入时序(无法归属观测对象)。
     *
     * @param report  已入库/已更新的报告(reportDate 须为真实日期或 null)
     * @param payload 解析载荷(gutFlora 从 payload 读;indicators 从 payload 读,与 health_indicators 同源)
     */
    public void processReport(HealthReport report, ParsedReportPayload.Payload payload) {
        if (report == null || report.getSubjectId() == null || report.getId() == null) {
            return;
        }
        Long subjectId = report.getSubjectId();
        Long familyId = report.getFamilyId();
        Long reportId = report.getId();
        String sourceType = report.getReportType();
        Date reportDate = report.getReportDate() != null
                ? new Date(report.getReportDate().getTime()) : null;

        int written = 0;
        if (payload != null && payload.getIndicators() != null) {
            for (ParsedReportPayload.Indicator ind : payload.getIndicators()) {
                if (ind.getIndicatorName() == null || ind.getIndicatorName().trim().isEmpty()) {
                    continue;
                }
                Long defId = mappingService.resolve(sourceType, ind.getIndicatorName().trim());
                if (defId == null) {
                    continue;
                }
                IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
                v.setValue(ind.getIndicatorValue());
                v.setNumericValue(mappingService.parseNumericValue(ind.getIndicatorValue()));
                v.setUnit(ind.getUnit());
                v.setStatus(ind.getStatus());
                valueMapper.upsertMapping(v);
                written++;
            }
        }
        if (payload != null && payload.getGutFlora() != null) {
            for (ParsedReportPayload.Flora flora : payload.getGutFlora()) {
                if (flora.getBacteriaName() == null || flora.getBacteriaName().trim().isEmpty()) {
                    continue;
                }
                Long defId = mappingService.resolve(sourceType, flora.getBacteriaName().trim());
                if (defId == null) {
                    continue;
                }
                IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
                v.setValue(flora.getBacteriaValue());
                v.setNumericValue(mappingService.parseNumericValue(flora.getBacteriaValue()));
                v.setUnit("%");
                v.setStatus(flora.getStatus());
                valueMapper.upsertMapping(v);
                written++;
            }
        }
        log.info("归一化管道 reportId={}, subjectId={}, type={},写入 {} 条观测", reportId, subjectId, sourceType, written);
    }

    /**
     * 食材推荐指数全量快照(钩子 3:FoodRecommendService.recalculateForUser 尾部调用)。
     * source_type=food_recommend,source_id=food_recommend_idx.id,幂等覆盖。
     */
    public void snapshotFoodIndices(Long userId, List<FoodRecommendIndex> indices) {
        if (userId == null || indices == null || indices.isEmpty()) {
            return;
        }
        for (FoodRecommendIndex idx : indices) {
            if (idx.getFoodName() == null || idx.getFoodName().trim().isEmpty()) {
                continue;
            }
            Long defId = mappingService.resolve("food_recommend", idx.getFoodName().trim());
            if (defId == null) {
                continue;
            }
            IndicatorValue v = baseValue("food_recommend", idx.getId(), defId, userId, null, null);
            v.setValue(idx.getIndexScore() != null ? idx.getIndexScore().toString() : null);
            v.setNumericValue(idx.getIndexScore() != null
                    ? BigDecimal.valueOf(idx.getIndexScore()) : null);
            v.setReportId(idx.getHealthReportId());
            valueMapper.upsertMapping(v);
        }
        log.info("食材指数快照 userId={}, 共 {} 条", userId, indices.size());
    }

    private IndicatorValue baseValue(String sourceType, Long sourceId, Long defId,
                                     Long subjectId, Long familyId, Date reportDate) {
        IndicatorValue v = new IndicatorValue();
        v.setSourceType(sourceType);
        v.setSourceId(sourceId);
        v.setDefinitionId(defId);
        v.setSubjectId(subjectId);
        v.setFamilyId(familyId);
        v.setReportDate(reportDate);
        return v;
    }
}

步骤 4:三个钩子接线

钩子 1 — ReportCollectService.insertReport()(L596-652):在 reportBlockService.save(...) try 块(L641-646)之后、markCollectCompleted(L648)之前插入:

        // 归一化管道:统一指标层幂等写入(同流程,subjectId 非空才有效)
        try {
            normalizeIndicatorPipeline.processReport(created, payload);
        } catch (Exception e) {
            log.warn("归一化管道写入失败 reportId={}: {}", created.getId(), e.getMessage());
        }

并在类顶部加注入(与既有 @Resource 并列):

    @Resource
    private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;

钩子 2 — HealthReportService.updateReportFromPayload():在步骤 6(payload 快照,L1886-1904)之后、方法结束前插入:

        // 归一化管道:编辑后重写统一指标层(同事务;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 之后、} 之前)插入:

        // 归一化管道:全量快照最新食材推荐指数到 indicator_values(幂等覆盖)
        try {
            List<FoodRecommendIndex> finalIndices = foodRecommendIndexMapper.selectList(
                    new LambdaQueryWrapper<FoodRecommendIndex>()
                            .eq(FoodRecommendIndex::getUserId, userId));
            normalizeIndicatorPipeline.snapshotFoodIndices(userId, finalIndices);
        } catch (Exception e) {
            log.warn("食材指数归一化快照失败 userId={}: {}", userId, e.getMessage());
        }

并补注入字段:

    @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

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()):

    /**
     * 指标归类兜底:将未命中的原始指标名交给 LangGraph 归类为已有/新定义。
     * @return 建议 JSON 字符串(含 code/name)或 null(关闭/熔断/失败)
     */
    public String classifyIndicator(String sourceType, String rawName, java.util.List<String> candidates) {
        if (!enabled || isCircuitOpen()) {
            return null;
        }
        try {
            ObjectNode body = objectMapper.createObjectNode();
            body.put("source_type", sourceType);
            body.put("raw_name", rawName);
            if (candidates != null) {
                com.fasterxml.jackson.databind.node.ArrayNode arr = body.putArray("candidates");
                candidates.forEach(arr::add);
            }
            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
            String url = baseUrl + "/api/v1/indicator/classify";
            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
                consecutiveFailures.set(0);
                return response.getBody();
            }
            return null;
        } catch (Exception e) {
            log.warn("AiGateway classifyIndicator 调用失败: {}", e.getMessage());
            recordFailure();
            return null;
        }
    }

(若文件顶部未 import ObjectNode/ArrayNode/HttpEntity/ResponseEntity,与既有方法共用全限定名或补充 import;与 generatePortrait 使用一致即可。)

步骤 2:创建 LangGraph endpoint

cfc-langgraph/app/api/indicator_classify.py(完整文件,沿用 report_parse.py 的 router + pydantic 模式;LLM 调用沿用 self_check_analysis_graph 的 get_llm().ainvoke 模式):

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 起)追加注册:

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

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:编写失败测试

package com.etotem.cfc.service;

import com.etotem.cfc.entity.IndicatorValue;
import com.etotem.cfc.mapper.IndicatorValueMapper;
import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;

import java.math.BigDecimal;
import java.sql.Date;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

class IndicatorTrendServiceTest {

    private IndicatorTrendService service;
    private IndicatorValueMapper valueMapper;

    @BeforeEach
    void setUp() {
        service = new IndicatorTrendService();
        valueMapper = mock(IndicatorValueMapper.class);
        ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
    }

    private IndicatorValue value(Long defId, String date, String val) {
        IndicatorValue v = new IndicatorValue();
        v.setDefinitionId(defId);
        v.setReportDate(Date.valueOf(date));
        v.setValue(val);
        v.setNumericValue(new BigDecimal(val));
        return v;
    }

    @Test
    void trendReturnsAscendingPoints() {
        List<IndicatorValue> rows = new ArrayList<>();
        rows.add(value(1L, "2026-08-15", "10"));
        rows.add(value(1L, "2026-10-01", "12.8"));
        when(valueMapper.selectList(any())).thenReturn(rows);

        Map<String, Object> data = service.trend(3L, Arrays.asList(1L), 2L);
        List<?> items = (List<?>) data.get("items");
        assertEquals(1, items.size());
        Map<?, ?> item = (Map<?, ?>) items.get(0);
        assertEquals("1", String.valueOf(item.get("definitionId")));
        List<?> points = (List<?>) item.get("points");
        assertEquals(2, points.size());
        Map<?, ?> first = (Map<?, ?>) points.get(0);
        assertEquals("2026-08-15", first.get("reportDate"));
    }

    @Test
    void changeRankingAdjacentComputesDeltaAndTrendLabel() {
        // 双歧杆菌属 higher_is_better:升幅 → trendLabel=改善
        List<IndicatorValue> rows = new ArrayList<>();
        rows.add(value(1L, "2026-08-15", "10"));
        rows.add(value(1L, "2026-10-01", "12.8"));
        when(valueMapper.selectList(any())).thenReturn(rows);

        java.util.Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L);
        List<?> items = (List<?>) data.get("items");
        assertEquals(1, items.size());
        Map<?, ?> item = (Map<?, ?>) items.get(0);
        assertEquals("up", item.get("movement"));
        assertEquals("改善", item.get("trendLabel"));
        Map<?, ?> overall = (Map<?, ?>) data.get("overall");
        assertEquals(1, ((Number) overall.get("improvedCount")).intValue());
    }

    @Test
    void quickLookupReturnsItemsAndDimensions() {
        List<IndicatorValue> rows = new ArrayList<>();
        rows.add(value(1L, "2026-10-01", "12.8"));
        when(valueMapper.selectList(any())).thenReturn(rows);

        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L);
        assertTrue(data.containsKey("items"));
        assertTrue(data.containsKey("categories"));
        assertEquals(1, ((List<?>) data.get("items")).size());
    }
}

步骤 2:运行确认失败

运行:cd cfc-backend && mvn test -Dtest=IndicatorTrendServiceTest 预期:BUILD FAILURE,IndicatorTrendService 不存在

步骤 3:实现 IndicatorTrendService

package com.etotem.cfc.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.IndicatorDefinition;
import com.etotem.cfc.entity.IndicatorValue;
import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
import com.etotem.cfc.mapper.IndicatorValueMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * 指标视图服务:trend(曲线)/ change-ranking(变化排名)/ quick-lookup(速查)。
 * 只读 indicator_values;五维/七维评分不写时序,quick-lookup API 层单独合入 dimensionScores 小节。
 */
@Service
public class IndicatorTrendService {

    private static final Logger log = LoggerFactory.getLogger(IndicatorTrendService.class);

    @Resource
    private IndicatorValueMapper valueMapper;

    @Resource
    private IndicatorDefinitionMapper definitionMapper;

    @Resource
    private FiveDimensionScoreMapper fiveDimensionScoreMapper;

    @Resource
    private HealthDimensionScoreMapper healthDimensionScoreMapper;

    /** 指标曲线:按 definitionId 分组、report_date 升序返回点序列 */
    public Map<String, Object> trend(Long subjectId, List<Long> definitionIds, Long familyId) {
        Map<String, Object> data = new LinkedHashMap<>();
        List<Map<String, Object>> items = new ArrayList<>();
        if (definitionIds == null || definitionIds.isEmpty() || subjectId == null) {
            data.put("items", items);
            return data;
        }
        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
                .eq(IndicatorValue::getSubjectId, subjectId)
                .in(IndicatorValue::getDefinitionId, definitionIds)
                .isNotNull(IndicatorValue::getReportDate)
                .orderByAsc(IndicatorValue::getReportDate));

        Map<Long, List<IndicatorValue>> byDef = rows.stream()
                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
                        LinkedHashMap::new, Collectors.toList()));

        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("definitionId", e.getKey());
            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
                    id -> definitionMapper.selectById(id));
            item.put("name", def != null ? def.getName() : "指标" + e.getKey());
            item.put("unit", def != null ? def.getUnit() : null);
            List<Map<String, Object>> points = new ArrayList<>();
            for (IndicatorValue v : e.getValue()) {
                Map<String, Object> p = new LinkedHashMap<>();
                p.put("reportDate", v.getReportDate() != null
                        ? new SimpleDateFormat("yyyy-MM-dd").format(v.getReportDate()) : null);
                p.put("value", v.getValue());
                p.put("numericValue", v.getNumericValue());
                p.put("unit", v.getUnit());
                p.put("status", v.getStatus());
                points.add(p);
            }
            item.put("points", points);
            items.add(item);
        }
        data.put("items", items);
        return data;
    }

    /** 变化幅度排名(设计 6.2)。scope=adjacent 相邻两次(默认)/ first-vs-latest 首尾对比。 */
    public Map<String, Object> changeRanking(Long subjectId, String reportType, String category,
                                             String scope, String orderBy, Integer limit, Long familyId) {
        Map<String, Object> data = new LinkedHashMap<>();
        List<Map<String, Object>> items = new ArrayList<>();
        int improved = 0;
        int worsened = 0;
        int flat = 0;

        LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
                .eq(IndicatorValue::getSubjectId, subjectId)
                .isNotNull(IndicatorValue::getReportDate);
        if (reportType != null && !reportType.isEmpty()) {
            qw.eq(IndicatorValue::getSourceType, reportType);
        }
        List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));

        // 按 definitionId 分组
        Map<Long, List<IndicatorValue>> byDef = rows.stream()
                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
        Map<Long, IndicatorDefinition> defCache = new HashMap<>();

        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
            List<IndicatorValue> sorted = e.getValue().stream()
                    .sorted((a, b) -> a.getReportDate().compareTo(b.getReportDate()))
                    .collect(Collectors.toList());
            if (sorted.size() < 2) {
                continue;
            }
            IndicatorValue before = sorted.get(0);
            IndicatorValue after = sorted.get(sorted.size() - 1);
            if ("adjacent".equals(scope)) {
                before = sorted.get(sorted.size() - 2);
            }
            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
                    id -> definitionMapper.selectById(id));
            String defCategory = def != null ? def.getCategory() : null;
            if (category != null && !category.isEmpty() && !category.equals(defCategory)) {
                continue;
            }
            BigDecimal bv = before.getNumericValue();
            BigDecimal av = after.getNumericValue();
            if (bv == null || av == null) {
                continue;
            }
            BigDecimal delta = av.subtract(bv);
            BigDecimal deltaPct = bv.compareTo(BigDecimal.ZERO) != 0
                    ? delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
                    : BigDecimal.ZERO;
            String movement = "flat";
            if (delta.compareTo(BigDecimal.ZERO) > 0) movement = "up";
            else if (delta.compareTo(BigDecimal.ZERO) < 0) movement = "down";

            String trendLabel = trendLabel(def, movement, delta);
            if ("改善".equals(trendLabel)) improved++;
            else if ("恶化".equals(trendLabel)) worsened++;
            else flat++;

            Map<String, Object> item = new LinkedHashMap<>();
            item.put("definitionId", e.getKey());
            item.put("name", def != null ? def.getName() : "指标" + e.getKey());
            item.put("category", defCategory);
            item.put("sourceKind", def != null ? def.getSourceKind() : null);
            item.put("unit", def != null ? def.getUnit() : null);
            item.put("goodDirection", def != null ? def.getGoodDirection() : "neutral");
            Map<String, Object> beforeMap = new LinkedHashMap<>();
            beforeMap.put("reportDate", fmtDate(before.getReportDate()));
            beforeMap.put("value", before.getValue());
            Map<String, Object> afterMap = new LinkedHashMap<>();
            afterMap.put("reportDate", fmtDate(after.getReportDate()));
            afterMap.put("value", after.getValue());
            item.put("before", beforeMap);
            item.put("after", afterMap);
            item.put("delta", delta);
            item.put("deltaPct", deltaPct);
            item.put("movement", movement);
            item.put("trendLabel", trendLabel);
            items.add(item);
        }

        // 排序:magnitude 按 Δ 绝对值降序(默认);up/down 按 Δ 正/负降序
        if ("up".equals(orderBy)) {
            items.sort((a, b) -> ((BigDecimal) b.get("delta")).compareTo((BigDecimal) a.get("delta")));
        } else if ("down".equals(orderBy)) {
            items.sort((a, b) -> ((BigDecimal) a.get("delta")).compareTo((BigDecimal) b.get("delta")));
        } else {
            items.sort((a, b) -> ((BigDecimal) b.get("delta")).abs()
                    .compareTo(((BigDecimal) a.get("delta")).abs()));
        }

        int limitVal = limit != null && limit > 0 ? limit : 50;
        if (items.size() > limitVal) {
            items = new ArrayList<>(items.subList(0, limitVal));
        }

        data.put("items", items);
        Map<String, Object> overall = new LinkedHashMap<>();
        overall.put("improvedCount", improved);
        overall.put("worsenedCount", worsened);
        overall.put("flatCount", flat);
        data.put("overall", overall);
        return data;
    }

    /** 指标速查(设计 6.3):分类分页 + 五维/七维评分小节 */
    public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
                                           Integer pageSize, Long familyId) {
        Map<String, Object> data = new LinkedHashMap<>();
        int pageVal = page != null && page > 0 ? page : 1;
        int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 30;

        // 该成员全部观测(按时序取最新一条)
        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
                .eq(IndicatorValue::getSubjectId, subjectId)
                .isNotNull(IndicatorValue::getDefinitionId));
        Map<Long, List<IndicatorValue>> byDef = rows.stream()
                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
        List<Map<String, Object>> allItems = new ArrayList<>();

        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
                    id -> definitionMapper.selectById(id));
            if (def == null) {
                continue;
            }
            if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
                continue;
            }
            List<IndicatorValue> sorted = e.getValue().stream()
                    .sorted((a, b) -> a.getReportDate() == null ? -1
                            : (b.getReportDate() == null ? 1 : b.getReportDate().compareTo(a.getReportDate())))
                    .collect(Collectors.toList());
            IndicatorValue latest = sorted.get(0);
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("definitionId", e.getKey());
            item.put("name", def.getName());
            item.put("category", def.getCategory());
            item.put("value", latest.getValue());
            item.put("unit", latest.getUnit() != null ? latest.getUnit() : def.getUnit());
            item.put("refRange", def.getRefRange());
            item.put("status", latest.getStatus());
            item.put("trend", trendOf(sorted));
            item.put("trendLabel", trendLabel(def, trendOf(sorted), deltaOf(sorted)));
            item.put("latestReportDate", latest.getReportDate() != null
                    ? new SimpleDateFormat("yyyy-MM-dd").format(latest.getReportDate()) : null);
            item.put("pointsCount", sorted.size());
            allItems.add(item);
        }

        // categories 汇总(含 total)
        List<Map<String, Object>> categories = new ArrayList<>();
        Map<String, Long> catCount = allItems.stream()
                .collect(Collectors.groupingBy(i -> (String) i.get("category"), Collectors.counting()));
        catCount.forEach((name, total) -> {
            Map<String, Object> c = new LinkedHashMap<>();
            c.put("name", name);
            c.put("total", total);
            categories.add(c);
        });

        // 分页
        int fromIndex = Math.min((pageVal - 1) * sizeVal, allItems.size());
        int toIndex = Math.min(fromIndex + sizeVal, allItems.size());
        List<Map<String, Object>> pageItems = allItems.isEmpty()
                ? new ArrayList<>()
                : new ArrayList<>(allItems.subList(fromIndex, toIndex));

        data.put("categories", categories);
        data.put("items", pageItems);
        data.put("page", pageVal);
        data.put("pageSize", sizeVal);
        data.put("total", allItems.size());
        data.put("dimensionScores", loadDimensionScores(subjectId));
        return data;
    }

    /** 五维/七维评分小节(不写 indicator_values,API 层 join 原表) */
    private List<Map<String, Object>> loadDimensionScores(Long subjectId) {
        List<Map<String, Object>> result = new ArrayList<>();
        // 五维:five_dimension_scores 按 memberId + assessedAt 最新
        try {
            result.addAll(fiveDimensionScoreMapper.selectList(
                    new LambdaQueryWrapper<com.etotem.cfc.entity.FiveDimensionScore>()
                            .eq(com.etotem.cfc.entity.FiveDimensionScore::getMemberId, subjectId)
                            .orderByDesc(com.etotem.cfc.entity.FiveDimensionScore::getAssessedAt)
                            .last("LIMIT 10"))
                    .stream().map(s -> {
                        Map<String, Object> m = new LinkedHashMap<>();
                        m.put("dimensionCode", s.getDimensionCode());
                        m.put("score", s.getScore());
                        m.put("assessedAt", s.getAssessedAt() != null
                                ? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessedAt()) : null);
                        m.put("source", "five_dimension");
                        return m;
                    }).collect(Collectors.toList()));
        } catch (Exception e) {
            log.warn("加载五维评分失败 subjectId={}: {}", subjectId, e.getMessage());
        }
        // 七维:health_dimension_scores 按 memberId + assessDate 最新
        try {
            result.addAll(healthDimensionScoreMapper.selectList(
                    new LambdaQueryWrapper<com.etotem.cfc.entity.HealthDimensionScore>()
                            .eq(com.etotem.cfc.entity.HealthDimensionScore::getMemberId, subjectId)
                            .orderByDesc(com.etotem.cfc.entity.HealthDimensionScore::getAssessDate)
                            .last("LIMIT 10"))
                    .stream().map(s -> {
                        Map<String, Object> m = new LinkedHashMap<>();
                        m.put("dimensionCode", s.getDimension());
                        m.put("score", s.getScore());
                        m.put("assessedAt", s.getAssessDate() != null
                                ? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessDate()) : null);
                        m.put("source", "health_dimension");
                        return m;
                    }).collect(Collectors.toList()));
        } catch (Exception e) {
            log.warn("加载七维评分失败 subjectId={}: {}", subjectId, e.getMessage());
        }
        return result;
    }

    // ---------- 工具 ----------

    private String fmtDate(Date d) {
        return d != null ? new SimpleDateFormat("yyyy-MM-dd").format(d) : null;
    }

    /** 序列趋势:末尾两条比较 */
    private String trendOf(List<IndicatorValue> sorted) {
        if (sorted.size() < 2) {
            return "flat";
        }
        IndicatorValue latest = sorted.get(0);
        IndicatorValue prev = sorted.get(1);
        if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
            return "flat";
        }
        int cmp = latest.getNumericValue().compareTo(prev.getNumericValue());
        return cmp > 0 ? "up" : (cmp < 0 ? "down" : "flat");
    }

    private BigDecimal deltaOf(List<IndicatorValue> sorted) {
        if (sorted.size() < 2) {
            return BigDecimal.ZERO;
        }
        IndicatorValue latest = sorted.get(0);
        IndicatorValue prev = sorted.get(1);
        if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
            return BigDecimal.ZERO;
        }
        return latest.getNumericValue().subtract(prev.getNumericValue());
    }

    /**
     * 变化着色/标签规则(设计第 8 节):
     * good_direction × 数值变化方向 → 改善/恶化/中性。
     */
    private String trendLabel(IndicatorDefinition def, String movement, BigDecimal delta) {
        if ("flat".equals(movement) || delta == null || delta.compareTo(BigDecimal.ZERO) == 0) {
            return "中性";
        }
        if (def == null || def.getGoodDirection() == null) {
            return "中性";
        }
        boolean up = "up".equals(movement);
        switch (def.getGoodDirection()) {
            case "higher_is_better":
                return up ? "改善" : "恶化";
            case "lower_is_better":
                return up ? "恶化" : "改善";
            case "in_range_is_better":
                // 一期简化:无 refRange 解析,回落为中性
                return "中性";
            default:
                return "中性";
        }
    }
}

需要 HealthDimensionScoreMapper(若不存在则先创建空 BaseMapper;FiveDimensionScoreMapper 若已有则复用)。若 health_dimension_scores 表访问导致编译失败,先确认两个 mapper 是否存在:

package com.etotem.cfc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.HealthDimensionScore;

public interface HealthDimensionScoreMapper extends BaseMapper<HealthDimensionScore> {
}

步骤 4:运行测试确认通过

运行:cd cfc-backend && mvn test -Dtest=IndicatorTrendServiceTest 预期:Tests run: 3, Failures: 0, Errors: 0

步骤 5:验证编译 + Commit

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

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:编写失败测试

package com.etotem.cfc.service;

import com.etotem.cfc.entity.IndicatorCorrelation;
import com.etotem.cfc.entity.IndicatorDefinition;
import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

class IndicatorCorrelationServiceTest {

    private IndicatorCorrelationService service;
    private IndicatorCorrelationMapper correlationMapper;
    private IndicatorDefinitionMapper definitionMapper;

    @BeforeEach
    void setUp() {
        service = new IndicatorCorrelationService();
        correlationMapper = mock(IndicatorCorrelationMapper.class);
        definitionMapper = mock(IndicatorDefinitionMapper.class);
        ReflectionTestUtils.setField(service, "correlationMapper", correlationMapper);
        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
    }

    @Test
    void saveOrdersByCodeLexicographic() {
        // 定义 A code="bacteria.bb" 应存 a;定义 B code="bacteria.aa" 应存 a
        IndicatorDefinition defAA = new IndicatorDefinition();
        defAA.setId(1L);
        defAA.setCode("bacteria.aa");
        IndicatorDefinition defBB = new IndicatorDefinition();
        defBB.setId(2L);
        defBB.setCode("bacteria.bb");
        when(definitionMapper.selectById(1L)).thenReturn(defAA);
        when(definitionMapper.selectById(2L)).thenReturn(defBB);

        service.saveCorrelation(1L, 2L, "positive", new BigDecimal("0.72"),
                "描述", false, "来源", 9L);

        org.mockito.ArgumentCaptor<IndicatorCorrelation> captor =
                org.mockito.ArgumentCaptor.forClass(IndicatorCorrelation.class);
        verify(correlationMapper).insert(captor.capture());
        IndicatorCorrelation saved = captor.getValue();
        // 1L 的 code("bacteria.aa") 字典序更小 → 应存 definition_id_a
        assertEquals(1L, saved.getDefinitionIdA());
        assertEquals(2L, saved.getDefinitionIdB());
        // effective_strength 初始 = manual(未校准)
        assertEquals(0, new BigDecimal("0.72").compareTo(saved.getEffectiveStrength()));
    }
}

步骤 2:运行确认失败

运行:cd cfc-backend && mvn test -Dtest=IndicatorCorrelationServiceTest 预期:BUILD FAILURE,IndicatorCorrelationService 不存在

步骤 3:实现 IndicatorCorrelationService

package com.etotem.cfc.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.IndicatorCorrelation;
import com.etotem.cfc.entity.IndicatorDefinition;
import com.etotem.cfc.entity.IndicatorValue;
import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
import com.etotem.cfc.mapper.IndicatorValueMapper;
import com.etotem.cfc.util.SpearmanUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * 指标关联:人工定义(候选对+方向+人工强度)+ 真实数据校准(Spearman)。
 * 对序规则:写入前按 definition.code 字典序比较,小的存 a、大的存 b(唯一键对称)。
 * effective_strength:有效样本 >= 30 取 computed,否则取 manual;manual_locked=1 时校准不覆盖。
 */
@Service
public class IndicatorCorrelationService {

    private static final Logger log = LoggerFactory.getLogger(IndicatorCorrelationService.class);

    @Resource
    private IndicatorCorrelationMapper correlationMapper;

    @Resource
    private IndicatorDefinitionMapper definitionMapper;

    @Resource
    private IndicatorValueMapper valueMapper;

    /** 有效样本阈值:>=30 才用 computed 覆盖 manual(设计 4.6) */
    private static final int EFFECTIVE_SAMPLE_THRESHOLD = 30;

    /** 人工定义/编辑关联对(强制对序) */
    public void saveCorrelation(Long defA, Long defB, String direction, BigDecimal manualStrength,
                                String description, Boolean manualLocked, String sourceNote, Long createdBy) {
        if (defA == null || defB == null || defA.equals(defB)) {
            throw new IllegalArgumentException("关联对不能为空且不能相同");
        }
        IndicatorDefinition da = definitionMapper.selectById(defA);
        IndicatorDefinition db = definitionMapper.selectById(defB);
        if (da == null || db == null) {
            throw new IllegalArgumentException("指标定义不存在: " + defA + "/" + defB);
        }
        Long a = da.getCode().compareTo(db.getCode()) <= 0 ? defA : defB;
        Long b = da.getCode().compareTo(db.getCode()) <= 0 ? defB : defA;

        // 已存在则该对更新
        IndicatorCorrelation existing = correlationMapper.selectOne(new LambdaQueryWrapper<IndicatorCorrelation>()
                .eq(IndicatorCorrelation::getDefinitionIdA, a)
                .eq(IndicatorCorrelation::getDefinitionIdB, b)
                .last("LIMIT 1"));
        IndicatorCorrelation c = existing != null ? existing : new IndicatorCorrelation();
        c.setDefinitionIdA(a);
        c.setDefinitionIdB(b);
        if (c.getRelationType() == null) c.setRelationType("correlation");
        c.setDirection(direction);
        c.setManualStrength(manualStrength);
        c.setEffectiveStrength(manualStrength); // 未校准时 = manual
        if (manualLocked != null) c.setManualLocked(manualLocked ? 1 : 0);
        if (c.getManualLocked() == null) c.setManualLocked(0);
        c.setDescription(description);
        c.setSourceNote(sourceNote);
        c.setCreatedBy(createdBy);
        if (c.getCohortScope() == null) c.setCohortScope("subject");
        if (c.getStatus() == null || !c.getStatus().equals("inactive")) c.setStatus("active");
        if (existing != null) {
            c.setUpdatedAt(new Date());
            correlationMapper.updateById(c);
        } else {
            c.setCreatedAt(new Date());
            c.setUpdatedAt(new Date());
            correlationMapper.insert(c);
        }
        log.info("关联对已保存: {} ({} ↔ {})", a, b, direction);
    }

    /** 关联查询(设计 6.4):返回该指标的全部关联 + 当前成员的配对证据 */
    public Map<String, Object> listRelations(Long subjectId, Long definitionId) {
        Map<String, Object> data = new LinkedHashMap<>();
        List<Map<String, Object>> relations = new ArrayList<>();
        if (definitionId == null) {
            data.put("relations", relations);
            return data;
        }
        List<IndicatorCorrelation> pairs = correlationMapper.selectList(new LambdaQueryWrapper<IndicatorCorrelation>()
                .and(w -> w.eq(IndicatorCorrelation::getDefinitionIdA, definitionId)
                        .or().eq(IndicatorCorrelation::getDefinitionIdB, definitionId))
                .eq(IndicatorCorrelation::getStatus, "active"));

        for (IndicatorCorrelation pair : pairs) {
            Long otherId = pair.getDefinitionIdA().equals(definitionId)
                    ? pair.getDefinitionIdB() : pair.getDefinitionIdA();
            IndicatorDefinition other = definitionMapper.selectById(otherId);
            Map<String, Object> rel = new LinkedHashMap<>();
            rel.put("pairId", pair.getId());
            Map<String, Object> indA = new LinkedHashMap<>();
            indA.put("definitionId", pair.getDefinitionIdA());
            IndicatorDefinition defA = definitionMapper.selectById(pair.getDefinitionIdA());
            indA.put("name", defA != null ? defA.getName() : null);
            rel.put("indicatorA", indA);
            Map<String, Object> indB = new LinkedHashMap<>();
            indB.put("definitionId", pair.getDefinitionIdB());
            indB.put("name", other != null ? other.getName() : null);
            rel.put("indicatorB", indB);
            rel.put("direction", pair.getDirection());
            rel.put("effectiveStrength", pair.getEffectiveStrength());
            rel.put("strengthSource", pair.getComputedStrength() != null
                    && pair.getSampleSize() != null && pair.getSampleSize() >= EFFECTIVE_SAMPLE_THRESHOLD
                    ? "computed" : "manual");
            rel.put("sampleSize", pair.getSampleSize());
            rel.put("confidence", pair.getConfidence());
            rel.put("manualLocked", pair.getManualLocked() != null && pair.getManualLocked() == 1);
            rel.put("description", pair.getDescription());

            // 该成员自己的配对数据支撑(如有)
            Map<String, Object> evidence = subjectEvidence(subjectId, pair.getDefinitionIdA(), pair.getDefinitionIdB());
            rel.put("subjectEvidence", evidence);
            relations.add(rel);
        }
        data.put("relations", relations);
        return data;
    }

    /** 成员级配对证据:点数 + 个人 rho + 是否足够(>=3) */
    private Map<String, Object> subjectEvidence(Long subjectId, Long defA, Long defB) {
        Map<String, Object> evidence = new LinkedHashMap<>();
        evidence.put("points", 0);
        evidence.put("personalRho", 0.0);
        evidence.put("hasEnough", false);
        if (subjectId == null) {
            return evidence;
        }
        List<IndicatorValue> rowsA = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
                .eq(IndicatorValue::getSubjectId, subjectId)
                .eq(IndicatorValue::getDefinitionId, defA)
                .isNotNull(IndicatorValue::getReportDate)
                .orderByAsc(IndicatorValue::getReportDate));
        List<IndicatorValue> rowsB = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
                .eq(IndicatorValue::getSubjectId, subjectId)
                .eq(IndicatorValue::getDefinitionId, defB)
                .isNotNull(IndicatorValue::getReportDate)
                .orderByAsc(IndicatorValue::getReportDate));
        if (rowsA.size() < 3 || rowsB.size() < 3) {
            return evidence;
        }
        // 按 report_date 对齐:累计最近 12 个共同日期点
        List<java.math.BigDecimal> xs = new ArrayList<>();
        List<java.math.BigDecimal> ys = new ArrayList<>();
        int i = 0;
        int j = 0;
        while (i < rowsA.size() && j < rowsB.size() && xs.size() < 12) {
            int cmp = rowsA.get(i).getReportDate().compareTo(rowsB.get(j).getReportDate());
            if (cmp == 0) {
                if (rowsA.get(i).getNumericValue() != null && rowsB.get(j).getNumericValue() != null) {
                    xs.add(rowsA.get(i).getNumericValue());
                    ys.add(rowsB.get(j).getNumericValue());
                }
                i++;
                j++;
            } else if (cmp < 0) {
                i++;
            } else {
                j++;
            }
        }
        if (xs.size() >= 3) {
            evidence.put("points", xs.size());
            evidence.put("personalRho", SpearmanUtil.spearmanRho(xs, ys));
            evidence.put("hasEnough", true);
        }
        return evidence;
    }

    /**
     * 全量校准(管理端手动触发 / @Scheduled 每日):
     * 对 status=active 且 manual_locked=0 的关联对,按 subject 分组配对序列 → Spearman → Fisher z 聚合。
     */
    public int calibrateAll() {
        List<IndicatorCorrelation> pairs = correlationMapper.selectList(new LambdaQueryWrapper<IndicatorCorrelation>()
                .eq(IndicatorCorrelation::getStatus, "active"));
        int updated = 0;
        for (IndicatorCorrelation pair : pairs) {
            if (pair.getManualLocked() != null && pair.getManualLocked() == 1) {
                continue;
            }
            if (calibratePair(pair)) {
                updated++;
            }
        }
        log.info("指标关联校准完成,共更新 {} 对", updated);
        return updated;
    }

    private boolean calibratePair(IndicatorCorrelation pair) {
        // 全部观测(含所有 subject),按 (subject_id, report_date) 分组配对
        List<IndicatorValue> rowsA = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
                .eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdA())
                .isNotNull(IndicatorValue::getReportDate));
        List<IndicatorValue> rowsB = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
                .eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdB())
                .isNotNull(IndicatorValue::getReportDate));
        // key = subjectId + '-' + reportDate
        Map<String, IndicatorValue> mapA = rowsA.stream().filter(v -> v.getSubjectId() != null)
                .collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x));
        Map<String, IndicatorValue> mapB = rowsB.stream().filter(v -> v.getSubjectId() != null)
                .collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x));

        // 按 subject 分组配对点
        Map<Long, List<java.math.BigDecimal[]>> bySubject = new java.util.HashMap<>();
        int sampleSize = 0;
        for (Map.Entry<String, IndicatorValue> e : mapA.entrySet()) {
            IndicatorValue vb = mapB.get(e.getKey());
            if (vb == null) continue;
            IndicatorValue va = e.getValue();
            if (va.getNumericValue() == null || vb.getNumericValue() == null) continue;
            bySubject.computeIfAbsent(va.getSubjectId(), k -> new ArrayList<>())
                    .add(new java.math.BigDecimal[]{va.getNumericValue(), vb.getNumericValue()});
            sampleSize++;
        }

        if (sampleSize < 3) {
            return false;
        }
        List<Double> rhos = new ArrayList<>();
        for (List<java.math.BigDecimal[]> points : bySubject.values()) {
            if (points.size() < 3) {
                continue;
            }
            List<java.math.BigDecimal> xs = points.stream().map(p -> p[0]).collect(Collectors.toList());
            List<java.math.BigDecimal> ys = points.stream().map(p -> p[1]).collect(Collectors.toList());
            rhos.add(SpearmanUtil.spearmanRho(xs, ys));
        }
        if (rhos.isEmpty()) {
            return false;
        }
        double avgRho = SpearmanUtil.fisherZAverage(rhos.stream().mapToDouble(Double::doubleValue).toArray());
        String confidence = sampleSize >= 30 ? "high" : (sampleSize >= 10 ? "medium" : "low");

        if (sampleSize >= EFFECTIVE_SAMPLE_THRESHOLD) {
            pair.setEffectiveStrength(BigDecimal.valueOf(avgRho).setScale(3, RoundingMode.HALF_UP));
        }
        pair.setComputedStrength(BigDecimal.valueOf(avgRho).setScale(3, RoundingMode.HALF_UP));
        pair.setMethod("spearman");
        pair.setSampleSize(sampleSize);
        pair.setConfidence(confidence);
        pair.setComputedAt(new Date());
        pair.setUpdatedAt(new Date());
        correlationMapper.updateById(pair);
        log.info("校准关联对 {}: rho={}, n={}, confidence={}",
                pair.getId(), avgRho, sampleSize, confidence);
        return true;
    }
}

步骤 4:运行测试确认通过

运行:cd cfc-backend && mvn test -Dtest=IndicatorCorrelationServiceTest 预期:Tests run: 1, Failures: 0, Errors: 0

步骤 5:验证编译 + Commit

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

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 归属校验)

package com.etotem.cfc.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.FamilyMember;
import com.etotem.cfc.mapper.FamilyMemberMapper;
import com.etotem.cfc.service.IndicatorCorrelationService;
import com.etotem.cfc.service.IndicatorTrendService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/indicator")
public class IndicatorController {

    @Resource
    private IndicatorTrendService indicatorTrendService;

    @Resource
    private IndicatorCorrelationService indicatorCorrelationService;

    @Resource
    private FamilyMemberMapper familyMemberMapper;

    /** 校验 subjectId 属于当前用户家庭(familyId 由 JwtInterceptor 注入,必存在) */
    private Result<Void> checkSubjectBelongs(Long subjectId, Long familyId) {
        if (subjectId == null) {
            return Result.error("subjectId不能为空");
        }
        FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
                .eq(FamilyMember::getId, subjectId)
                .eq(FamilyMember::getFamilyId, familyId)
                .last("LIMIT 1"));
        if (member == null) {
            return Result.error("成员不属于当前家庭");
        }
        return null;
    }

    /** 指标曲线(设计 6.1) */
    @PostMapping("/trend")
    public Result<?> trend(@RequestBody Map<String, Object> params,
                           @RequestAttribute("familyId") Long familyId) {
        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
        if (check != null) {
            return check;
        }
        @SuppressWarnings("unchecked")
        List<Object> rawIds = (List<Object>) params.get("definitionIds");
        List<Long> definitionIds = new ArrayList<>();
        if (rawIds != null) {
            for (Object o : rawIds) {
                if (o instanceof Number) {
                    definitionIds.add(((Number) o).longValue());
                } else if (o != null) {
                    definitionIds.add(Long.valueOf(o.toString()));
                }
            }
        }
        return Result.success(indicatorTrendService.trend(subjectId, definitionIds, familyId));
    }

    /** 变化幅度排名(设计 6.2) */
    @PostMapping("/change-ranking")
    public Result<?> changeRanking(@RequestBody Map<String, Object> params,
                                   @RequestAttribute("familyId") Long familyId) {
        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
        if (check != null) {
            return check;
        }
        String reportType = params.get("reportType") != null ? params.get("reportType").toString() : null;
        String category = params.get("category") != null ? params.get("category").toString() : null;
        String scope = params.get("scope") != null ? params.get("scope").toString() : "adjacent";
        String orderBy = params.get("orderBy") != null ? params.get("orderBy").toString() : "magnitude";
        Integer limit = params.get("limit") != null
                ? Integer.valueOf(params.get("limit").toString()) : 50;
        return Result.success(indicatorTrendService.changeRanking(
                subjectId, reportType, category, scope, orderBy, limit, familyId));
    }

    /** 指标速查(设计 6.3) */
    @PostMapping("/quick-lookup")
    public Result<?> quickLookup(@RequestBody Map<String, Object> params,
                                 @RequestAttribute("familyId") Long familyId) {
        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
        if (check != null) {
            return check;
        }
        String category = params.get("category") != null ? params.get("category").toString() : null;
        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 30;
        return Result.success(indicatorTrendService.quickLookup(subjectId, category, page, pageSize, familyId));
    }

    /** 指标关联查询(设计 6.4) */
    @PostMapping("/correlation/list")
    public Result<?> correlationList(@RequestBody Map<String, Object> params,
                                     @RequestAttribute("familyId") Long familyId) {
        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
        Long definitionId = com.etotem.cfc.util.ParamUtils.getLong(params.get("definitionId"));
        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
        if (check != null) {
            return check;
        }
        return Result.success(indicatorCorrelationService.listRelations(subjectId, definitionId));
    }
}

备注:FamilyMemberMapper 与 FamilyMember 实体若已存在则直接复用;若不存在,按既有实体/Mapper 模式创建一个最小版本(id/familyId 字段 + BaseMapper)。

步骤 2:IndicatorCorrelationAdminController

package com.etotem.cfc.controller.admin;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.IndicatorCorrelation;
import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
import com.etotem.cfc.service.IndicatorCorrelationService;
import com.etotem.cfc.util.ParamUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.Map;

@RestController
@RequestMapping("/api/admin/indicator/correlation")
public class IndicatorCorrelationAdminController {

    @Resource
    private IndicatorCorrelationService indicatorCorrelationService;

    @Resource
    private IndicatorCorrelationMapper indicatorCorrelationMapper;

    /** 人工定义/编辑关联对 */
    @PostMapping("/save")
    public Result<?> save(@RequestBody Map<String, Object> params,
                          @RequestAttribute("userId") Long userId,
                          @RequestAttribute("role") String role) {
        if (!"admin".equals(role)) {
            return Result.error("无权操作");
        }
        Long defA = ParamUtils.getLong(params.get("definitionIdA"));
        Long defB = ParamUtils.getLong(params.get("definitionIdB"));
        if (defA == null || defB == null) {
            return Result.error("definitionIdA/definitionIdB 不能为空");
        }
        String direction = params.get("direction") != null ? params.get("direction").toString() : "positive";
        BigDecimal manualStrength = params.get("manualStrength") != null
                ? new BigDecimal(params.get("manualStrength").toString()) : BigDecimal.ZERO;
        String description = params.get("description") != null ? params.get("description").toString() : null;
        Boolean manualLocked = params.get("manualLocked") != null
                && Boolean.parseBoolean(params.get("manualLocked").toString());
        String sourceNote = params.get("sourceNote") != null ? params.get("sourceNote").toString() : null;
        try {
            indicatorCorrelationService.saveCorrelation(
                    defA, defB, direction, manualStrength, description, manualLocked, sourceNote, userId);
            return Result.success(null);
        } catch (IllegalArgumentException e) {
            return Result.error(e.getMessage());
        }
    }

    /** 关联对分页列表(含校准状态) */
    @PostMapping("/list")
    public Result<Page<IndicatorCorrelation>> list(@RequestBody Map<String, Object> params,
                                                    @RequestAttribute("role") String role) {
        if (!"admin".equals(role)) {
            return Result.error("无权操作");
        }
        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
        Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
        Page<IndicatorCorrelation> p = new Page<>(page, size);
        indicatorCorrelationMapper.selectPage(p,
                new LambdaQueryWrapper<IndicatorCorrelation>()
                        .orderByDesc(IndicatorCorrelation::getUpdatedAt));
        return Result.success(p);
    }

    /** 手动触发全校准或单对校准(单对:body 传 definitionIdA/definitionIdB 或 pairId) */
    @PostMapping("/calibrate")
    public Result<?> calibrate(@RequestBody Map<String, Object> params,
                               @RequestAttribute("role") String role) {
        if (!"admin".equals(role)) {
            return Result.error("无权操作");
        }
        Long pairId = ParamUtils.getLong(params.get("pairId"));
        if (pairId != null) {
            IndicatorCorrelation pair = indicatorCorrelationMapper.selectById(pairId);
            if (pair == null) {
                return Result.error("关联对不存在");
            }
            // 单对校准通过全量校准的按对逻辑实现(此处简化为触发全量,全量内部逐对幂等)
            int n = indicatorCorrelationService.calibrateAll();
            return Result.success("校准完成,共更新 " + n + " 对");
        }
        int n = indicatorCorrelationService.calibrateAll();
        return Result.success("校准完成,共更新 " + n + " 对");
    }
}

步骤 3:IndicatorMappingAdminController

package com.etotem.cfc.controller.admin;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.IndicatorMappingRule;
import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
import com.etotem.cfc.service.IndicatorMappingService;
import com.etotem.cfc.util.ParamUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/admin/indicator/mapping")
public class IndicatorMappingAdminController {

    @Resource
    private IndicatorMappingService indicatorMappingService;

    @Resource
    private IndicatorMappingRuleMapper indicatorMappingRuleMapper;

    /** 批量导入映射种子(JSON/CSV 数组:[{rawName, definitionId?}]) */
    @PostMapping("/import")
    public Result<?> importSeeds(@RequestBody Map<String, Object> params,
                                 @RequestAttribute("role") String role) {
        if (!"admin".equals(role)) {
            return Result.error("无权操作");
        }
        String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
        if (sourceType == null || sourceType.isEmpty()) {
            return Result.error("sourceType 不能为空");
        }
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> seeds = (List<Map<String, Object>>) params.get("seeds");
        if (seeds == null || seeds.isEmpty()) {
            return Result.error("seeds 不能为空");
        }
        int count = indicatorMappingService.importSeeds(sourceType, seeds);
        return Result.success("导入完成,共 " + count + " 条");
    }

    /** 映射规则分页(含未匹配 raw 的 review 列表:status=0 为 ai_fallback 待确认) */
    @PostMapping("/list")
    public Result<Page<IndicatorMappingRule>> list(@RequestBody Map<String, Object> params,
                                                    @RequestAttribute("role") String role) {
        if (!"admin".equals(role)) {
            return Result.error("无权操作");
        }
        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
        Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
        Page<IndicatorMappingRule> p = new Page<>(page, size);
        LambdaQueryWrapper<IndicatorMappingRule> qw = new LambdaQueryWrapper<IndicatorMappingRule>()
                .orderByAsc(IndicatorMappingRule::getSourceType)
                .orderByAsc(IndicatorMappingRule::getRawName);
        Object status = params.get("status");
        if (status != null) {
            qw.eq(IndicatorMappingRule::getStatus, Integer.valueOf(status.toString()));
        }
        Object sourceType = params.get("sourceType");
        if (sourceType != null && !sourceType.toString().isEmpty()) {
            qw.eq(IndicatorMappingRule::getSourceType, sourceType.toString());
        }
        indicatorMappingRuleMapper.selectPage(p, qw);
        return Result.success(p);
    }
}

步骤 4:IndicatorCalibrationJob(@Scheduled 每日)

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/* 重复。

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:全量后端验证

运行:

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

git add docs/superpowers/api/API_REFERENCE.md
git commit -m "docs(api): 同步指标统一层 9 个新接口契约"

自检(完成计划后必须逐项核对)

1. 规格覆盖度(对照设计文档 第 10 节文件清单 后端部分):

  • DatabaseInitializer 迁移 ✓(任务 1:356~362)
  • schema.sql 同步 4 处 DDL ✓(任务 1 步骤 6)
  • IndicatorDefinition / IndicatorValue 实体 ✓(任务 2)
  • IndicatorMappingRule / IndicatorCorrelation 实体 + Mapper ✓(任务 2)
  • IndicatorMappingService ✓(任务 5)
  • NormalizeIndicatorPipeline ✓(任务 6)
  • IndicatorTrendService ✓(任务 8)
  • IndicatorCorrelationService ✓(任务 9)
  • ReportDateExtractor ✓(任务 3)
  • IndicatorController + 2 admin 控制器 ✓(任务 10)
  • FoodRecommendService 钩子 ✓(任务 6 步骤 4 钩子 3)
  • AiGateway + LangGraph endpoint ✓(任务 7)
  • IndicatorCalibrationJob ✓(任务 10 步骤 4)
  • API_REFERENCE.md ✓(任务 11)

2. 验收标准映射:

  • 验收#5 report_date = PDF 真实日期、回填幂等 ✓(任务 3;scheduledBackfill 连跑两次:已回填行 report_date 非空不再命中 isNull 筛选 → 结果一致)
  • 验收#6 归一化幂等 ✓(任务 6:uk_source_definition + upsertMapping ON DUPLICATE KEY)
  • 验收#7 expected_indicators 与种子导入后 100% 命中 ✓(任务 5 importSeeds 支持管理端批量导入;exact 先行)
  • 验收#8 mvn clean compile 通过 ✓(每个任务含编译验证步骤)
  • 验收#4 关联校准 computed/manual 切换 ✓(任务 9:EFFECTIVE_SAMPLE_THRESHOLD=30,manual_locked 防护)

3. 类型一致性:

  • IndicatorValue.reportDate 用 java.sql.Date,与 IndicatorValueMapper.upsertMapping 的 #{v.reportDate} 及 trend/changeRanking 的 fmtDate() 参数类型一致
  • IndicatorMappingService.resolve(sourceType, rawName) 三处调用(pipeline 指标/菌属/食材)签名一致
  • NormalizeIndicatorPipeline.processReport(HealthReport, ParsedReportPayload.Payload) 在钩子 1 传入 (created, payload)、钩子 2 传入 (oldReport, editedPayload),两处均与签名一致
  • IndicatorTrendService.changeRanking 返回 Map<String,Object>(items/overall),IndicatorController 直接 Result.success(map) 透传

4. 占位符扫描:

  • 全计划无「待定/TODO/后续实现」,每个代码块为可直接粘贴的完整代码
  • HealthDimensionScoreMapper/FiveDimensionScoreMapper/FamilyMemberMapper 若项目中已存在则直接复用,不存在时按任务内提供的创建模板补齐(任务 8 步骤 3 注释中已给出 HealthDimensionScoreMapper 模板)