浏览代码

feat(backend): 迁移356-362 升级统一指标层(列/键/两新表/维度种子)

iwt 3 天之前
父节点
当前提交
1824ae295e

+ 157 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2984,11 +2984,20 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         runSystemMigrations();
         registerCollectExpectedIndicators();
 
-        // 减脂专题表创建
+        // 减脂专题表创建(远程 fatloss 功能)
         createFatWeightRecordTable();
         createFatDietRecordTable();
         createFatExerciseRecordTable();
         createFatGoalTable();
+
+        // 指标统一层迁移(356~362)
+        migrateIndicatorDefinitionsColumns();
+        migrateIndicatorValuesColumns();
+        addIndicatorValuesKeys();
+        createIndicatorMappingRulesTable();
+        createIndicatorCorrelationsTable();
+        ensureIndicatorCollectionDate();
+        seedDimensionDefinitions();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -12080,6 +12089,153 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // 迁移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 列");
+    }
+
+    // 迁移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 列");
+    }
+
+    // 迁移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 已存在,跳过");
+        }
+    }
+
+    // 迁移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());
+        }
+    }
+
+    // 迁移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());
+            }
+        }
+    }
+
     // 迁移355: 创建减脂专题体重记录表
     private void createFatWeightRecordTable() {
         try {

+ 57 - 1
cfc-backend/src/main/resources/schema.sql

@@ -2508,6 +2508,11 @@ CREATE TABLE IF NOT EXISTS indicator_definitions (
     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,
@@ -2523,9 +2528,17 @@ CREATE TABLE IF NOT EXISTS indicator_values (
     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_definition (definition_id),
+    INDEX idx_subject_def_date (subject_id, definition_id, report_date)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标值';
 
 -- ========== 舌诊主记录表 ==========
@@ -6187,3 +6200,46 @@ CREATE TABLE IF NOT EXISTS sso_ticket (
     UNIQUE KEY uk_ticket (ticket),
     INDEX idx_expire (expire_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据';
+
+-- 迁移359: 指标归一化映射规则(连接各来源原始名与统一 indicator_definitions)
+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='指标归一化映射规则';
+
+-- 迁移360: 指标关联关系(人工定义 + 数据校准)
+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='指标关联关系(人工定义+数据校准)';