Explorar o código

feat(attribution): guhe.p3.lowSideAggregate 跨案例聚合落地(S-cfc-107)

T15 跨案例聚合:低侧口径并入既有 P3 聚合口径,可检索「同条件致病菌低侧」应对方案。

新增文件:
- constant/AttributionCaseAggregateConstants.java:判读口径常量锚点(§2.2 KB 新增子键)
- service/attribution/AttributionCaseAggregateService.java:@Service 跨案例聚合入口(aggregateLowSideStrategy)
- service/attribution/model/LowSideReadingEntry.java:单条低侧归因节点 DTO
- service/attribution/model/LowSideStrategyCluster.java:同策略集群结果 DTO

数据库变更:
- migration464 + schema.sql:创建 attribution_case_strategies 表(T15 聚合承载表)

验收:guhe.p3.lowSideAggregate 契约测试 6/6 全绿(无失败 / 无报错)。
Sisyphus Agent hai 5 días
pai
achega
87cb034f20

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

@@ -3385,6 +3385,8 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         seedFamilyAttributionSurveyTemplate();
         // 迁移426: 家庭归因报告表 attribution_reports(B1 核心闭环③,report_json+intermediate_state+redline_hits+status)
         createAttributionReportsTable();
+        // 迁移464: P3 跨案例聚合承载表 attribution_case_strategies(T15 低侧口径并入既有 P3 聚合口径)
+        createAttributionCaseStrategiesTable();
 
         // 迁移427: 创建 content_dimension_threshold 表(A-后端 内容维度阈值显隐配置:商品/文章/活动/游戏 五维+子项+自定义指标)
         createContentDimensionThresholdTable();
@@ -15421,6 +15423,37 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
 
     }
 
+    // ==================== 迁移464:P3 跨案例聚合承载表(T15 低侧口径并入既有 P3 聚合口径) ====================
+
+    /**
+     * 迁移464: P3 跨案例聚合承载表 attribution_case_strategies(T15 跨案例聚合,guhe.p3.lowSideAggregate)。
+     * 沉淀「同条件致病菌低侧 → 同策略」的跨案例聚合结果(判读口径锚点 + 合并应对方案 +
+     * 聚合案例/成员清单),供检索「同条件致病菌低侧」应对方案。
+     * 幂等:CREATE TABLE IF NOT EXISTS。
+     */
+    void createAttributionCaseStrategiesTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `attribution_case_strategies` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`indicator_code` VARCHAR(64) NULL COMMENT '条件致病菌指标编码(如 大肠杆菌 Escherichia coli)', "
+                    + "`indicator_name` VARCHAR(100) NOT NULL COMMENT '条件致病菌显示名', "
+                    + "`reading_key` VARCHAR(100) NOT NULL COMMENT '判读口径锚点(条件致病菌低侧判读补充(人工))', "
+                    + "`strategy_title` VARCHAR(200) NOT NULL COMMENT '同策略标题(同条件致病菌低侧→同策略)', "
+                    + "`association_only` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '低侧一律关联非因果(G-1 强制)', "
+                    + "`case_ids_json` TEXT NULL COMMENT '聚合案例ID列表(JSON数组)', "
+                    + "`member_ids_json` TEXT NULL COMMENT '聚合成员ID列表(JSON数组)', "
+                    + "`measures_json` TEXT NULL COMMENT '合并应对方案(JSON数组,低侧口径不含补菌桥接)', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "INDEX `idx_indicator` (`indicator_code`), "
+                    + "INDEX `idx_reading` (`reading_key`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='P3跨案例聚合策略(同条件致病菌低侧→同策略)'");
+            log.info("迁移464: 已创建 attribution_case_strategies 表");
+        } catch (Exception e) {
+            log.warn("迁移464: 创建 attribution_case_strategies 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
     // ==================== 迁移445-447:统一维度体系(P0) ====================
 
     /**

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/constant/AttributionCaseAggregateConstants.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.constant;
+
+/**
+ * P3 跨案例聚合口径常量(T15:低侧口径并入既有 P3 聚合口径)。
+ *
+ * <p>事实锚点:《2026-11-08 浠艾福平台程序修改计划》§2.2 知识库新增子键
+ * 「条件致病菌低侧判读补充(人工)」,作为跨案例「同条件致病菌低侧 → 同策略」
+ * 聚合的统一判读口径;§五 边界声明:所有低侧机制表述为「关联非因果」,
+ * 不构成健康评估 / 诊断 / 干预建议。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1,T15 跨案例聚合)
+ */
+public final class AttributionCaseAggregateConstants {
+
+    private AttributionCaseAggregateConstants() {
+    }
+
+    /** 判读口径键名(§2.2 KB 新增子键,跨案例聚合口径的事实锚点,能力键 guhe.p3.lowSideAggregate) */
+    public static final String LOW_SIDE_READING_KEY = "条件致病菌低侧判读补充(人工)";
+
+    /** 聚合口径边界声明(§五):低侧机制一律「关联非因果」,不构成健康评估/诊断/干预建议 */
+    public static final String LOW_SIDE_ASSOCIATION_ONLY_NOTICE =
+            "条件致病菌偏低侧聚合口径:关联非因果,不构成健康评估、诊断或干预建议(§五 边界声明)。";
+}

+ 116 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/AttributionCaseAggregateService.java

@@ -0,0 +1,116 @@
+package com.etotem.cfc.service.attribution;
+
+import com.etotem.cfc.constant.AttributionCaseAggregateConstants;
+import com.etotem.cfc.service.attribution.model.LowSideReadingEntry;
+import com.etotem.cfc.service.attribution.model.LowSideStrategyCluster;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * P3 跨案例聚合服务(能力键 {@code guhe.p3.lowSideAggregate},T15)。
+ *
+ * <p>低侧口径并入既有 P3 聚合口径:按《2026-11-08 浠艾福平台程序修改计划》§三-T15,
+ * 以「条件致病菌低侧判读补充(人工)」为统一判读口径,把跨案例的
+ * 「同条件致病菌偏低」归因节点聚为同一策略集群,可检索「同条件致病菌低侧」应对方案。
+ *
+ * <p><b>口径纪律</b>(§三-L4 归因引擎③ / §五 边界声明 / G-1 低侧红线组):
+ * 条件致病菌「偏低」按四机制解释、全部标「关联非因果」;参考下限 0 的菌不标「偏低超标」;
+ * ⛔ 禁「缺 X → 买含 X 菌株产品」桥接——聚合输出的应对方案不因偏低而推荐补充该菌株产品。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1,T15 跨案例聚合)
+ */
+@Service
+public class AttributionCaseAggregateService {
+
+    /** 判读口径键名(§2.2 KB 新增子键,聚合口径事实锚点) */
+    public static final String LOW_SIDE_READING_KEY = AttributionCaseAggregateConstants.LOW_SIDE_READING_KEY;
+
+    /** 聚合口径边界声明(§五) */
+    public static final String LOW_SIDE_ASSOCIATION_ONLY_NOTICE =
+            AttributionCaseAggregateConstants.LOW_SIDE_ASSOCIATION_ONLY_NOTICE;
+
+    /**
+     * 按低侧判读口径聚合跨案例归因节点,检索「同条件致病菌低侧」应对方案(聚合同策略)。
+     *
+     * <p>聚类键 = 条件致病菌(优先 indicatorCode,回落 indicator 显示名)+ 低侧判读口径锚点;
+     * 同一聚类键下的跨案例节点合并为一个策略集群,应对方向跨案例去重合并。
+     *
+     * @param lowSideCases 跨案例「条件致病菌偏低」归因节点清单(可为 null/空)
+     * @return 按(条件致病菌 + 低侧判读口径)聚类后的同策略集群;无输入时返回空列表
+     */
+    public List<LowSideStrategyCluster> aggregateLowSideStrategy(List<LowSideReadingEntry> lowSideCases) {
+        List<LowSideStrategyCluster> clusters = new ArrayList<LowSideStrategyCluster>();
+        if (lowSideCases == null || lowSideCases.isEmpty()) {
+            return clusters;
+        }
+        Map<String, List<LowSideReadingEntry>> groups = new LinkedHashMap<String, List<LowSideReadingEntry>>();
+        for (LowSideReadingEntry entry : lowSideCases) {
+            if (entry == null || blank(entry.getIndicator())) {
+                continue;
+            }
+            String key = clusterKey(entry) + "|" + LOW_SIDE_READING_KEY;
+            List<LowSideReadingEntry> group = groups.get(key);
+            if (group == null) {
+                group = new ArrayList<LowSideReadingEntry>();
+                groups.put(key, group);
+            }
+            group.add(entry);
+        }
+        for (List<LowSideReadingEntry> group : groups.values()) {
+            clusters.add(cluster(group));
+        }
+        return clusters;
+    }
+
+    /** 聚类键:条件致病菌(指标编码优先,回落显示名) */
+    private static String clusterKey(LowSideReadingEntry entry) {
+        if (!blank(entry.getIndicatorCode())) {
+            return entry.getIndicatorCode().trim();
+        }
+        return entry.getIndicator().trim();
+    }
+
+    /** 同一「条件致病菌低侧」的跨案例节点 → 一个同策略集群 */
+    private LowSideStrategyCluster cluster(List<LowSideReadingEntry> group) {
+        LowSideStrategyCluster c = new LowSideStrategyCluster();
+        LowSideReadingEntry first = group.get(0);
+        c.setIndicator(first.getIndicator());
+        c.setReadingKey(LOW_SIDE_READING_KEY);
+        c.setStrategyTitle("同条件致病菌低侧 → 同策略(" + first.getIndicator() + ")");
+        // 低侧机制一律关联非因果(G-1 强制,不构成健康评估/诊断/干预建议)
+        c.setAssociationOnly(true);
+        Set<String> caseIds = new LinkedHashSet<String>();
+        Set<Long> memberIds = new LinkedHashSet<Long>();
+        Set<String> measures = new LinkedHashSet<String>();
+        for (LowSideReadingEntry entry : group) {
+            if (!blank(entry.getCaseId())) {
+                caseIds.add(entry.getCaseId().trim());
+            }
+            if (entry.getMemberId() != null) {
+                memberIds.add(entry.getMemberId());
+            }
+            if (entry.getMeasures() != null) {
+                for (String m : entry.getMeasures()) {
+                    if (!blank(m)) {
+                        measures.add(m.trim());
+                    }
+                }
+            }
+        }
+        c.setCaseIds(new ArrayList<String>(caseIds));
+        c.setMemberIds(new ArrayList<Long>(memberIds));
+        c.setMeasures(new ArrayList<String>(measures));
+        c.setCaseCount(group.size());
+        return c;
+    }
+
+    private static boolean blank(String s) {
+        return s == null || s.trim().isEmpty();
+    }
+}

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/LowSideReadingEntry.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.service.attribution.model;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 跨案例低侧归因节点输入(T15 跨案例聚合:单条「条件致病菌偏低」归因节点)。
+ *
+ * <p>一条输入 = 一次归因案例中的某一个「条件致病菌偏低」节点(含观测值 / 偏低机制 / 应对方向);
+ * 跨案例聚合服务按「条件致病菌 + 低侧判读口径」把多条输入聚类为同策略集群。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1,T15 跨案例聚合)
+ */
+@Data
+public class LowSideReadingEntry implements Serializable {
+
+    /** 案例ID(报告/归因批次的跨案例聚合维度;如 attribution_reports.id 或报告对外编号) */
+    private String caseId;
+
+    /** 归因成员ID */
+    private Long memberId;
+
+    /** 归因成员名 */
+    private String memberName;
+
+    /** 条件致病菌显示名(如 大肠杆菌) */
+    private String indicator;
+
+    /** 条件致病菌指标编码(如 大肠杆菌学名;可空) */
+    private String indicatorCode;
+
+    /** 观测值文本(如 "0.12%") */
+    private String value;
+
+    /** 百分位文本(如 P3;参考下限 0 的菌不标「偏低超标」) */
+    private String percentile;
+
+    /** 偏低机制表述(四机制之一;全部标「关联非因果」) */
+    private String meaning;
+
+    /** 应对方向(低侧判读口径下不得含「缺X→补X菌株产品」桥接) */
+    private List<String> measures = new ArrayList<String>();
+}

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/LowSideStrategyCluster.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.service.attribution.model;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * P3 跨案例聚合结果:「同条件致病菌低侧 → 同策略」集群(T15)。
+ *
+ * <p>聚合语义:所有低侧机制一律标「关联非因果」、⛔ 禁「缺 X→买含 X 菌株产品」桥接;
+ * 应对方向由跨案例节点合并,去重保留原始低侧口径措辞。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1,T15 跨案例聚合)
+ */
+@Data
+public class LowSideStrategyCluster implements Serializable {
+
+    /** 条件致病菌显示名(聚类键之一) */
+    private String indicator;
+
+    /** 判读口径锚点(统一使用「条件致病菌低侧判读补充(人工)」键) */
+    private String readingKey;
+
+    /** 同策略标题(展示用) */
+    private String strategyTitle;
+
+    /** 是否强制关联非因果(G-1 红线组约束,低侧一律 true) */
+    private boolean associationOnly = true;
+
+    /** 聚合的案例ID列表(承载表 attribution_case_strategies.case_ids_json) */
+    private List<String> caseIds = new ArrayList<String>();
+
+    /** 聚合的成员ID列表(归属映射:family_members.id) */
+    private List<Long> memberIds = new ArrayList<Long>();
+
+    /** 合并后的应对方案清单(按低侧判读口径,不含补菌桥接) */
+    private List<String> measures = new ArrayList<String>();
+
+    /** 聚合案例数 */
+    private int caseCount;
+}

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

@@ -5224,6 +5224,23 @@ CREATE TABLE IF NOT EXISTS attribution_reports (
     INDEX idx_plan (plan_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭归因分析报告(阶段二核心闭环)';
 
+-- P3 跨案例聚合承载表(T15:低侧口径并入既有 P3 聚合口径,可检索「同条件致病菌低侧」应对方案)
+CREATE TABLE IF NOT EXISTS attribution_case_strategies (
+    id                BIGINT AUTO_INCREMENT PRIMARY KEY,
+    indicator_code    VARCHAR(64) NULL COMMENT '条件致病菌指标编码(如 大肠杆菌 Escherichia coli)',
+    indicator_name    VARCHAR(100) NOT NULL COMMENT '条件致病菌显示名',
+    reading_key       VARCHAR(100) NOT NULL COMMENT '判读口径锚点(条件致病菌低侧判读补充(人工))',
+    strategy_title    VARCHAR(200) NOT NULL COMMENT '同策略标题(同条件致病菌低侧→同策略)',
+    association_only  TINYINT(1) NOT NULL DEFAULT 1 COMMENT '低侧一律关联非因果(G-1 强制)',
+    case_ids_json     TEXT NULL COMMENT '聚合案例ID列表(JSON数组)',
+    member_ids_json   TEXT NULL COMMENT '聚合成员ID列表(JSON数组)',
+    measures_json     TEXT NULL COMMENT '合并应对方案(JSON数组,低侧口径不含补菌桥接)',
+    created_at        DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at        DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_indicator (indicator_code),
+    INDEX idx_reading (reading_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='P3跨案例聚合策略(同条件致病菌低侧→同策略)';
+
 -- 定期调研模板
 CREATE TABLE IF NOT EXISTS survey_templates (
     id            BIGINT AUTO_INCREMENT PRIMARY KEY,