Bläddra i källkod

feat(report): 每类报告定义采集指标项并接入采集完整性校验

- report_type_registry 新增 expected_indicators 列(迁移354),按 测评数据_优化提取.csv/已解析菌群报告为 10 个类型(dan_a1/a2/b2/b3/b4/b5/b6/c1、gut_flora、gut_flora_beijing)种子应采集指标项
- 幂等补注册 dan_a1/b2/b3/b4/b5/b6/c1 与 gut_flora_beijing 类型行(parser_path 空,不干扰指纹路由)
- ReportCollectService 采集完成后按定义校验(归一化+包含+CJK二元组重叠匹配),结果写入 health_report_drafts.collect_validation(typeId/expected/matched/pass/missing)
- schema.sql 同步两处新列
liaoxg 4 dagar sedan
förälder
incheckning
1e2fc417c8

+ 150 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2982,6 +2982,7 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         }
 
         runSystemMigrations();
+        registerCollectExpectedIndicators();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -11849,4 +11850,153 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移353: 创建 task_reminder_config 表失败: {}", e.getMessage());
         }
     }
+
+    // 迁移354: 报告采集指标项定义(report_type_registry.expected_indicators)+ 草稿采集校验结果列(health_report_drafts.collect_validation)
+    // 每个报告类型定义「应采集指标项」清单(JSON 数组),ReportCollectService 采集完成后按此校验完整性,结果写入草稿 collect_validation。
+    // DAN 指标项按 测评数据_优化提取.csv 各报告类型列(填充率>=30%)总结;菌群指标项按 docs/参考资料 已解析报告的指标/区块总结。
+    private void registerCollectExpectedIndicators() {
+        ensureColumn("report_type_registry", "expected_indicators",
+                "TEXT NULL COMMENT '该报告类型应采集的指标项定义(JSON数组),用于数据采集完整性校验'");
+        ensureColumn("health_report_drafts", "collect_validation",
+                "TEXT NULL COMMENT '采集指标校验结果(JSON: typeId/expected/matched/pass/missing)'");
+
+        // 确保注册表存在各 DAN 类型 + 北京菌群类型的行(parser_path 空 = 保持走 LangGraph 检测,不影响既有路由)
+        ensureReportTypeRow("dan_a1", "dan", "素质测评A1", "核心认知发展评估报告:感知觉/注意力/记忆力/推理能力/空间能力/加工速度");
+        ensureReportTypeRow("dan_b2", "dan", "素质测评B2", "儿童行为评估报告:自我概念/行为问题");
+        ensureReportTypeRow("dan_b3", "dan", "素质测评B3", "学习能力评估报告:执行功能/学习动机/学习策略/自我效能感");
+        ensureReportTypeRow("dan_b4", "dan", "素质测评B4", "核心素养评估报告:认知能力/成长型思维/自我概念");
+        ensureReportTypeRow("dan_b5", "dan", "素质测评B5", "青春期发展评估报告:情绪调节/人际关系/睡眠/运动/网络依赖/学业压力");
+        ensureReportTypeRow("dan_b6", "dan", "素质测评B6", "职业发展评估报告:霍兰德兴趣/多元智能/职业价值观");
+        ensureReportTypeRow("dan_c1", "dan", "素质测评C1", "校园版核心素养评估报告:认知能力/大五人格/自我概念/学习动机");
+        ensureReportTypeRow("gut_flora_beijing", "gut_flora", "北京菌群检测", "北京肠道菌群检测报告(FMT移植前/后评估)");
+
+        // 种子各类型「应采集指标项」定义(每次启动覆盖,保证与代码一致)
+        seedExpectedIndicator("dan_a1", jsonArray(
+                "感知觉_pct", "感知觉_score", "注意力_pct", "注意力_score",
+                "记忆力_pct", "记忆力_score", "推理能力_pct", "推理能力_score",
+                "空间能力_pct", "空间能力_score", "加工速度_pct", "加工速度_score",
+                "总分", "百分位"));
+        seedExpectedIndicator("dan_a2", jsonArray(
+                "BMI", "感知觉_pct", "记忆力_pct", "注意力_pct",
+                "外倾性", "宜人性", "开放性", "责任心", "神经质",
+                "情绪总分", "焦虑-安详", "抑郁-愉快", "无力感-掌控感", "自卑-自尊",
+                "与同伴亲近", "与同伴信任", "与同伴沟通",
+                "与母亲亲近", "与母亲信任", "与母亲沟通",
+                "与父亲亲近", "与父亲信任", "与父亲沟通",
+                "身高", "体重", "睡眠_小时", "运动_小时", "饮食_小时",
+                "总分", "百分位"));
+        seedExpectedIndicator("dan_b2", jsonArray(
+                "自我概念_合群", "自我概念_幸福与满足", "自我概念_情绪状态", "自我概念_能力与学校",
+                "自我概念_行为表现", "自我概念_躯体外貌",
+                "行为_品行问题", "行为_学习问题", "行为_情绪问题", "行为_生活习惯", "行为_社交问题"));
+        seedExpectedIndicator("dan_b3", jsonArray(
+                "工作记忆_pct", "认知灵活性_pct", "抑制控制_pct",
+                "表面动机", "深层动机", "学习自我调节", "表面方法与策略", "深层方法与策略",
+                "自我效能感", "总分"));
+        seedExpectedIndicator("dan_b4", jsonArray(
+                "感知觉_pct", "感知觉_score", "注意力_pct", "注意力_score",
+                "记忆力_pct", "记忆力_score", "推理能力_pct", "推理能力_score",
+                "空间能力_pct", "空间能力_score", "加工速度_pct", "加工速度_score",
+                "成长型思维",
+                "自我概念_合群", "自我概念_幸福与满足", "自我概念_情绪状态", "自我概念_能力与学校",
+                "自我概念_行为表现", "自我概念_躯体外貌",
+                "自主性", "胜任感", "归属感",
+                "总分", "百分位"));
+        seedExpectedIndicator("dan_b5", jsonArray(
+                "表达抑制", "认知重评",
+                "人际_父亲关系", "人际_父亲关系_过度期待", "人际_父亲关系_价值冲突",
+                "人际_父亲关系_控制压迫", "人际_父亲关系_情感疏离", "人际_父亲关系_双重标准",
+                "人际_母亲关系", "人际_母亲关系_过度期待", "人际_母亲关系_价值冲突",
+                "人际_母亲关系_控制压迫", "人际_母亲关系_情感疏离", "人际_母亲关系_双重标准",
+                "人际_师生关系", "人际_师生关系_敌对型", "人际_师生关系_高控制型",
+                "人际_师生关系_关系疏离型", "人际_师生关系_回避型",
+                "人际_同伴关系", "人际_同伴关系_被排斥型", "人际_同伴关系_边缘型",
+                "人际_同伴关系_攻击型", "人际_同伴关系_回避型", "人际_同伴关系_特殊因素型",
+                "社交_表达影响", "社交_冲突解决", "社交_情感表达", "社交_情感支持", "社交_主动交往",
+                "入睡时间_min", "睡眠_催眠药物", "睡眠_日间功能", "睡眠_时长", "睡眠_效率",
+                "睡眠_障碍", "睡眠_主观质量",
+                "网络依赖_戒断反应", "网络依赖_强迫使用", "网络依赖_时间管理", "网络依赖_总分",
+                "学业压力_家庭期望", "学业压力_师生关系", "学业压力_同伴竞争", "学业压力_学业负担", "学业压力_自我期望",
+                "运动_步行_频率", "运动_步行_时长", "运动_高强度_频率", "运动_高强度_时长",
+                "运动_久坐_频率", "运动_久坐_时长", "运动_中等强度_频率", "运动_中等强度_时长",
+                "总分", "百分位"));
+        seedExpectedIndicator("dan_b6", jsonArray(
+                "能力_内省", "能力_人际关系", "能力_音乐", "能力_自然",
+                "兴趣_常规型", "兴趣_社会型", "兴趣_事业型", "兴趣_现实型", "兴趣_研究型", "兴趣_艺术型",
+                "职业价值观"));
+        seedExpectedIndicator("dan_c1", jsonArray(
+                "BMI",
+                "感知觉_pct", "感知觉_score", "注意力_pct", "注意力_score",
+                "记忆力_pct", "记忆力_score", "推理能力_pct", "推理能力_score",
+                "空间能力_pct", "空间能力_score", "加工速度_pct", "加工速度_score",
+                "外倾性", "宜人性", "开放性", "责任心", "神经质", "情绪总分",
+                "自我概念_合群", "自我概念_幸福与满足", "自我概念_情绪状态", "自我概念_能力与学校",
+                "自我概念_行为表现", "自我概念_躯体外貌",
+                "表面动机", "深层动机", "学习自我调节", "表面方法与策略", "深层方法与策略", "自我效能感",
+                "自主性", "胜任感", "归属感",
+                "与同伴亲近", "与同伴信任", "与同伴沟通",
+                "与母亲亲近", "与母亲信任", "与母亲沟通",
+                "与父亲亲近", "与父亲信任", "与父亲沟通",
+                "身高", "体重", "睡眠时长", "运动时间",
+                "总分", "百分位"));
+        seedExpectedIndicator("gut_flora", jsonArray(
+                "肠道预测年龄", "肠型", "肠道菌群平衡", "菌群多样性", "有益菌", "有害菌", "核心菌属",
+                "健康总分", "菌群健康", "慢病控制", "营养均衡",
+                "疾病风险评估", "主要营养评估", "氨基酸评估", "维生素评估", "微量元素评估",
+                "主要消化道致病菌", "抗生素风险评估", "肠道屏障及代谢物", "短链脂肪酸",
+                "神经递质及激素", "菌群检出详细列表", "个体化食物推荐表"));
+        seedExpectedIndicator("gut_flora_beijing", jsonArray(
+                "肠型", "肠道微生物健康指数", "肠道菌群平衡评估", "肠道黏膜屏障", "肠道菌群多样性",
+                "肠道菌群表型评估", "肠道抗炎能力", "肠道免疫力", "肠道膳食纤维需求", "短链脂肪酸合成能力",
+                "核心菌属综合评分", "有益菌综合评分", "有害菌综合评分",
+                "疾病风险评估", "营养物质及营养素代谢评估", "抗生素风险评估",
+                "毒性物质清除能力评估", "胃肠道感染病原体评估", "趣味肠菌评估",
+                "菌群检出详细列表", "个体化食物推荐表"));
+    }
+
+    /** 注册报告类型行(INSERT IF 不存在,幂等;parser_path=null 保持走 LangGraph 检测) */
+    private void ensureReportTypeRow(String typeId, String family, String displayName, String description) {
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM report_type_registry WHERE type_id = ?", Integer.class, typeId);
+            if (count == null || count == 0) {
+                jdbcTemplate.update(
+                        "INSERT INTO report_type_registry (type_id, family, display_name, description, parser_path, " +
+                        "min_confidence, is_active, source, review_status, version, created_at, updated_at) " +
+                        "VALUES (?, ?, ?, ?, NULL, 30, 1, 'prebuilt', 'approved', '1.0', NOW(), NOW())",
+                        typeId, family, displayName, description);
+                log.info("迁移354: 已注册报告类型 {}", typeId);
+            }
+        } catch (Exception e) {
+            log.warn("迁移354: 注册报告类型 {} 失败: {}", typeId, e.getMessage());
+        }
+    }
+
+    /** 写入类型「应采集指标项」定义(每次启动覆盖,保证与代码一致) */
+    private void seedExpectedIndicator(String typeId, String expectedJson) {
+        try {
+            int updated = jdbcTemplate.update(
+                    "UPDATE report_type_registry SET expected_indicators = ?, updated_at = NOW() WHERE type_id = ?",
+                    expectedJson, typeId);
+            if (updated == 0) {
+                log.warn("迁移354: 类型 {} 不存在,跳过指标项定义", typeId);
+            } else {
+                log.info("迁移354: 已为类型 {} 写入指标项定义", typeId);
+            }
+        } catch (Exception e) {
+            log.warn("迁移354: 为类型 {} 写入指标项定义失败: {}", typeId, e.getMessage());
+        }
+    }
+
+    /** 简易 JSON 字符串数组序列化(指标项均为中文/ASCII,无转义风险) */
+    private String jsonArray(String... items) {
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < items.length; i++) {
+            if (i > 0) {
+                sb.append(',');
+            }
+            sb.append('"').append(items[i]).append('"');
+        }
+        return sb.append(']').toString();
+    }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportDraft.java

@@ -74,6 +74,9 @@ public class HealthReportDraft implements Serializable {
 /** 代上传备注(由管理员或服务端填写) */
     private String remark;
 
+    /** 采集指标校验结果(JSON: typeId/expected/matched/pass/missing),由 ReportCollectService 采集后写入 */
+    private String collectValidation;
+
     private Date createdAt;
     private Date updatedAt;
 }

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTypeRegistry.java

@@ -27,6 +27,10 @@ public class ReportTypeRegistry implements Serializable {
     private String version;
     private Long createdBy;
     private String scriptStatus;
+
+    /** 该报告类型应采集的指标项定义(JSON 数组字符串),用于数据采集完整性校验 */
+    private String expectedIndicators;
+
     private Date createdAt;
     private Date updatedAt;
 

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java

@@ -243,4 +243,15 @@ public class HealthReportDraftService {
           .set(HealthReportDraft::getUpdatedAt, new Date());
         draftMapper.update(null, uw);
     }
+
+    /**
+     * 记录采集指标校验结果(由 report_type_registry.expected_indicators 定义驱动的完整性校验)
+     */
+    public void updateCollectValidation(Long draftId, String collectValidation) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getCollectValidation, collectValidation)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
 }

+ 304 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -210,6 +210,13 @@ public class ReportCollectService {
             }
 
             harmReportPayloadValid(payload);
+            // 采集指标完整性校验(report_type_registry.expected_indicators 定义驱动)
+            String validationTypeId = (!"unknown".equals(typeId)) ? typeId : draft.getReportType();
+            try {
+                runCollectValidation(draftId, validationTypeId, payload);
+            } catch (Exception e) {
+                log.warn("采集指标校验失败 draftId={}, typeId={}", draftId, validationTypeId, e);
+            }
             healthReportDraftService.updateParseMethod(draftId, method);
             healthReportDraftService.updatePayload(draftId, objectMapper.writeValueAsString(payload));
 
@@ -291,6 +298,9 @@ public class ReportCollectService {
                 return;
             }
 
+            // 采集指标完整性校验(expected_indicators 定义驱动)
+            runCollectValidation(draftId, registry.getTypeId(), parsed);
+
             // 草稿闭环:reportType 置为识别类型(前端按 dan_* 跳转)+ completed
             healthReportDraftService.updateReportType(draftId, registry.getTypeId());
             healthReportDraftService.markCollectCompleted(draftId, upload.getId());
@@ -938,4 +948,298 @@ public class ReportCollectService {
         headers.set("Authorization", "Basic " + token);
         return headers;
     }
+
+    // ======================== 采集指标完整性校验(report_type_registry.expected_indicators 定义驱动) ========================
+
+    /**
+     * 健康(GUT/混检)管道校验入口:汇总 payload 中全部可采集指标名称后按定义比对
+     */
+    private void runCollectValidation(Long draftId, String typeId, ParsedReportPayload.Payload payload) {
+        runCollectValidation(draftId, typeId, collectHealthNames(payload));
+    }
+
+    /**
+     * DAN 管道校验入口:汇总解析 items 的 name/code 后按定义比对
+     */
+    private void runCollectValidation(Long draftId, String typeId, DanReportParseService.DanParsedReport parsed) {
+        Set<String> names = new HashSet<>();
+        if (parsed != null && parsed.getItems() != null) {
+            for (DanReportParseService.DataItem item : parsed.getItems()) {
+                addCollectName(names, item.getName());
+                addCollectName(names, item.getCode());
+            }
+        }
+        runCollectValidation(draftId, typeId, names);
+    }
+
+    /**
+     * 采集完整性校验:按 report_type_registry.expected_indicators 定义,校验本次采集结果是否覆盖应采集指标项。
+     * 该类型无指标项定义(expected_indicators 为空 / 类型未注册)时跳过,不影响采集主流程。
+     * 结果 JSON(typeId/expected/matched/pass/missing)写入草稿 collect_validation,缺失项记入 WARN 日志。
+     */
+    private void runCollectValidation(Long draftId, String typeId, Set<String> collectedNames) {
+        if (draftId == null || typeId == null || collectedNames == null || collectedNames.isEmpty()) {
+            return;
+        }
+        List<String> expected = loadExpectedIndicators(typeId);
+        if (expected == null || expected.isEmpty()) {
+            return;
+        }
+        List<String> missing = new ArrayList<>();
+        int matched = 0;
+        for (String exp : expected) {
+            if (indicatorMatches(exp, collectedNames)) {
+                matched++;
+            } else {
+                missing.add(exp);
+            }
+        }
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("typeId", typeId);
+        result.put("expected", expected.size());
+        result.put("matched", matched);
+        result.put("pass", missing.isEmpty());
+        result.put("missing", missing.size() > 20 ? missing.subList(0, 20) : missing);
+        try {
+            healthReportDraftService.updateCollectValidation(draftId, objectMapper.writeValueAsString(result));
+            if (missing.isEmpty()) {
+                log.info("采集校验通过 draftId={}, typeId={}, expected={}", draftId, typeId, expected.size());
+            } else {
+                log.warn("采集校验未通过 draftId={}, typeId={}, matched={}/{}, 缺失[{}]",
+                        draftId, typeId, matched, expected.size(), String.join(", ", missing));
+            }
+        } catch (Exception e) {
+            log.warn("记录采集校验结果失败 draftId={}, typeId={}", draftId, typeId, e);
+        }
+    }
+
+    /** 读取类型指标项定义(report_type_registry.expected_indicators JSON 数组);未定义返回 null */
+    private List<String> loadExpectedIndicators(String typeId) {
+        try {
+            ReportTypeRegistry registry = reportTypeRegistryMapper.selectOne(
+                    new LambdaQueryWrapper<ReportTypeRegistry>()
+                            .eq(ReportTypeRegistry::getTypeId, typeId)
+                            .last("LIMIT 1"));
+            if (registry == null || registry.getExpectedIndicators() == null
+                    || registry.getExpectedIndicators().isEmpty()) {
+                return null;
+            }
+            return objectMapper.readValue(registry.getExpectedIndicators(),
+                    new TypeReference<List<String>>() {});
+        } catch (Exception e) {
+            log.warn("读取类型指标项定义失败 typeId={}", typeId, e);
+            return null;
+        }
+    }
+
+    /** 健康管道:汇总 payload 中全部可采集指标名称(含区块信号),用于与定义比对 */
+    private Set<String> collectHealthNames(ParsedReportPayload.Payload payload) {
+        Set<String> names = new HashSet<>();
+        if (payload == null) {
+            return names;
+        }
+        ParsedReportPayload.Summary s = payload.getSummary();
+        if (s != null) {
+            addMappedName(names, "健康总分", s.getOverallScore());
+            addMappedName(names, "菌群健康", s.getGutHealthScore());
+            addMappedName(names, "慢病控制", s.getChronicDiseaseScore());
+            addMappedName(names, "营养均衡", s.getNutritionScore());
+            addMappedName(names, "肠道菌群平衡", s.getBalanceScore());
+            addMappedName(names, "菌群多样性", s.getDiversityScore());
+            addMappedName(names, "有益菌", s.getBeneficialScore());
+            addMappedName(names, "有害菌", s.getHarmfulScore());
+            addMappedName(names, "核心菌属", s.getCoreGenusScore());
+            addMappedName(names, "肠道预测年龄", s.getGutAge());
+            addMappedName(names, "肠型", s.getGutType());
+            addMappedName(names, "菌群多样性", s.getShannonIndex());
+        }
+        if (payload.getIndicators() != null) {
+            for (ParsedReportPayload.Indicator ind : payload.getIndicators()) {
+                addCollectName(names, ind.getIndicatorName());
+                addCollectName(names, ind.getCategory());
+                addHealthBlockSignals(names, ind.getIndicatorName(), ind.getCategory());
+            }
+        }
+        addFloraNames(names, payload.getGutFlora());
+        addFloraNames(names, payload.getProbioticSpecies());
+        addFloraNames(names, payload.getPathogenGenus());
+        addFloraNames(names, payload.getPathogenDetection());
+        addFloraNames(names, payload.getTaxonomyClass());
+        addFloraNames(names, payload.getTaxonomyOrder());
+        addFloraNames(names, payload.getTaxonomyFamily());
+        addFloraNames(names, payload.getTaxonomyGenus());
+        addFloraNames(names, payload.getTaxonomySpecies());
+        if (hasFlora(payload)) {
+            names.add("菌群检出详细列表");
+        }
+        if (payload.getDiseaseRisks() != null && !payload.getDiseaseRisks().isEmpty()) {
+            names.add("疾病风险评估");
+            for (ParsedReportPayload.DiseaseRisk dr : payload.getDiseaseRisks()) {
+                addCollectName(names, dr.getDiseaseName());
+            }
+        }
+        if (payload.getFoods() != null && !payload.getFoods().isEmpty()) {
+            names.add("个体化食物推荐表");
+            for (ParsedReportPayload.FoodItem f : payload.getFoods()) {
+                addCollectName(names, f.getName());
+            }
+        }
+        return names;
+    }
+
+    private boolean hasFlora(ParsedReportPayload.Payload payload) {
+        return payload != null && (isNotEmpty(payload.getGutFlora()) || isNotEmpty(payload.getProbioticSpecies())
+                || isNotEmpty(payload.getPathogenGenus()) || isNotEmpty(payload.getPathogenDetection())
+                || isNotEmpty(payload.getTaxonomyClass()) || isNotEmpty(payload.getTaxonomyOrder())
+                || isNotEmpty(payload.getTaxonomyFamily()) || isNotEmpty(payload.getTaxonomyGenus())
+                || isNotEmpty(payload.getTaxonomySpecies()));
+    }
+
+    private boolean isNotEmpty(List<?> list) {
+        return list != null && !list.isEmpty();
+    }
+
+    private void addFloraNames(Set<String> names, List<ParsedReportPayload.Flora> flora) {
+        if (flora == null) {
+            return;
+        }
+        for (ParsedReportPayload.Flora f : flora) {
+            addCollectName(names, f.getBacteriaName());
+            addCollectName(names, f.getCategory());
+        }
+    }
+
+    /** 菌群报告区块归类:指标名/分类命中关键词 → 记入对应区块信号(容忍「区块名 vs 条目名」表述差异,如 短链脂肪酸 vs 丁酸) */
+    private static final String[][] HEALTH_BLOCK_KEYWORDS = {
+        {"主要营养评估", "碳水化合物", "蛋白质", "脂肪", "纤维素", "乳制品"},
+        {"氨基酸评估", "氨基酸"},
+        {"维生素评估", "维生素"},
+        {"微量元素评估", "微量元素", "铁", "锌"},
+        {"主要消化道致病菌", "致病菌", "幽门螺杆菌"},
+        {"抗生素风险评估", "抗生素"},
+        {"肠道屏障及代谢物", "屏障", "渗透"},
+        {"短链脂肪酸", "短链脂肪酸", "丁酸", "乙酸", "丙酸"},
+        {"神经递质及激素", "神经递质", "血清素", "多巴胺", "5-羟色胺", "激素"},
+        {"肠道微生物健康指数", "微生物健康指数"},
+        {"肠道菌群平衡评估", "菌群平衡"},
+        {"肠道黏膜屏障", "黏膜屏障"},
+        {"肠道菌群多样性", "菌群多样性"},
+        {"肠道菌群表型评估", "菌群表型", "表型评估"},
+        {"肠道抗炎能力", "抗炎"},
+        {"肠道免疫力", "免疫"},
+        {"肠道膳食纤维需求", "膳食纤维"},
+        {"短链脂肪酸合成能力", "短链脂肪酸合成"},
+        {"核心菌属综合评分", "核心菌属综合评分", "核心菌属评分"},
+        {"有益菌综合评分", "有益菌综合评分", "有益菌评分"},
+        {"有害菌综合评分", "有害菌综合评分", "有害菌评分"},
+        {"毒性物质清除能力评估", "毒性", "清除能力", "毒素"},
+        {"胃肠道感染病原体评估", "病原体", "感染"},
+        {"趣味肠菌评估", "趣味", "人格", "认知功能", "职场心理"}
+    };
+
+    /** 指标名/分类文本命中区块关键词 → 记入该区块信号名 */
+    private void addHealthBlockSignals(Set<String> names, String... sources) {
+        for (String src : sources) {
+            if (src == null) {
+                continue;
+            }
+            for (String[] pair : HEALTH_BLOCK_KEYWORDS) {
+                for (int k = 1; k < pair.length; k++) {
+                    if (src.contains(pair[k])) {
+                        names.add(pair[0]);
+                        break;
+                    }
+                }
+            }
+        }
+    }
+
+    /** 名称归一化:去除后缀差异(_pct/_score/百分位/得分 等),中文取核心短语;结果为空则保留原串 */
+    private String normalizeIndicatorName(String s) {
+        if (s == null) {
+            return "";
+        }
+        String t = s.trim();
+        t = t.replaceFirst("(_pct|_score|_小时|_分钟|_min|_max)\\s*$", "");
+        t = t.replaceFirst("(百分位|得分|评估|指数|综合评分|评分)\\s*$", "");
+        if (t.isEmpty()) {
+            t = s.trim();
+        }
+        return t.toLowerCase(Locale.ROOT);
+    }
+
+    /** 提取中文二元组(用于容忍 表面方法与策略 vs 表面策略 类表述差异) */
+    private Set<String> cjkBigrams(String s) {
+        if (s == null || s.isEmpty()) {
+            return Collections.emptySet();
+        }
+        String cjk = s.replaceAll("[^\\u4e00-\\u9fa5]", "");
+        Set<String> set = new HashSet<>();
+        if (cjk.length() == 1) {
+            set.add(cjk);
+            return set;
+        }
+        for (int i = 0; i + 1 < cjk.length(); i++) {
+            set.add(cjk.substring(i, i + 2));
+        }
+        return set;
+    }
+
+    /** 指标匹配:归一化后相等 / 互相包含 / 中文二元组重叠 >= 50% 即视为命中 */
+    private boolean indicatorMatches(String expected, Set<String> collectedNames) {
+        if (expected == null || collectedNames == null || collectedNames.isEmpty()) {
+            return false;
+        }
+        String normExp = normalizeIndicatorName(expected);
+        if (normExp.isEmpty()) {
+            return true;
+        }
+        for (String name : collectedNames) {
+            String norm = normalizeIndicatorName(name);
+            if (norm.isEmpty()) {
+                continue;
+            }
+            if (normExp.equals(norm) || normExp.contains(norm) || norm.contains(normExp)) {
+                return true;
+            }
+            if (cjkBigramOverlap(normExp, norm)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean cjkBigramOverlap(String a, String b) {
+        Set<String> bigramsA = cjkBigrams(a);
+        Set<String> bigramsB = cjkBigrams(b);
+        if (bigramsA.isEmpty() || bigramsB.isEmpty()) {
+            return false;
+        }
+        Set<String> smaller = bigramsA.size() <= bigramsB.size() ? bigramsA : bigramsB;
+        Set<String> larger = bigramsA.size() <= bigramsB.size() ? bigramsB : bigramsA;
+        int hit = 0;
+        for (String bg : smaller) {
+            if (larger.contains(bg)) {
+                hit++;
+            }
+        }
+        return (double) hit / smaller.size() >= 0.5;
+    }
+
+    private void addCollectName(Set<String> names, String raw) {
+        if (raw != null && !raw.trim().isEmpty()) {
+            names.add(raw.trim());
+        }
+    }
+
+    /** 字段值非空信号:字段有值(非 null 非空白)时,把映射名计入已采集集合 */
+    private void addMappedName(Set<String> names, String mappedName, Object value) {
+        if (value == null) {
+            return;
+        }
+        String v = String.valueOf(value).trim();
+        if (!v.isEmpty()) {
+            names.add(mappedName);
+        }
+    }
 }

+ 2 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1889,6 +1889,7 @@ CREATE TABLE IF NOT EXISTS health_report_drafts (
     expire_at DATETIME COMMENT '草稿过期时间',
     confirmed_report_id BIGINT COMMENT 'confirm阶段入库后的健康报告ID',
     remark VARCHAR(500) DEFAULT NULL COMMENT '备注(如代上传时的说明文字)',
+    collect_validation TEXT DEFAULT NULL COMMENT '采集指标校验结果(JSON: typeId/expected/matched/pass/missing)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_user_id (user_id),
@@ -4775,6 +4776,7 @@ CREATE TABLE IF NOT EXISTS report_type_registry (
     version VARCHAR(20) DEFAULT '1.0' COMMENT '版本',
     created_by BIGINT DEFAULT NULL COMMENT '创建人ID',
     script_status VARCHAR(20) DEFAULT NULL COMMENT '采集脚本生成状态: generating/ready/failed',
+    expected_indicators TEXT DEFAULT NULL COMMENT '该报告类型应采集的指标项定义(JSON数组),用于数据采集完整性校验',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_type_id (type_id)