Przeglądaj źródła

merge(ATT-S2): 阶段二核心闭环B1-B4 + 非阻塞改进O2/O3/O5/O7 合入 cfclub

- 阶段一数据地基 A1-A4(plan_type迁移/问卷模板种子/常模表/红线模板)
- 阶段二核心闭环 B1-B4(归因引擎+条件问卷路由+自然语言调整+七章干预方案)
- 非阻塞改进 O2/E12开关补盖 survey端点 + O3 文档采集项对齐45 + O5 种子group去实名 + O7 gate 4001测试(O1留痕延后阶段三)
- C1 对齐基线 34f1091c4;O4 编号对齐(纯注释/日志标签)
Agnes Agent 2 tygodni temu
rodzic
commit
88572214e5
55 zmienionych plików z 7037 dodań i 4 usunięć
  1. 246 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/config/PromptConfig.java
  3. 183 0
      cfc-backend/src/main/java/com/etotem/cfc/constant/AttributionRedlines.java
  4. 86 0
      cfc-backend/src/main/java/com/etotem/cfc/constant/AttributionWeights.java
  5. 175 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/AttributionController.java
  6. 262 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/SurveyController.java
  7. 62 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/AdjustResult.java
  8. 46 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/AttributionInput.java
  9. 48 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/AttributionReportVO.java
  10. 63 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ConfirmQuestionVO.java
  11. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyPlanVO.java
  12. 72 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/AttributionNormValue.java
  13. 69 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/AttributionReport.java
  14. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthPlan.java
  15. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/AttributionNormValueMapper.java
  16. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/AttributionReportMapper.java
  17. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AttributionAdjustService.java
  18. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AttributionAnalysisService.java
  19. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AttributionPlanService.java
  20. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ConditionEvaluatorService.java
  21. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/service/SurveyService.java
  22. 194 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/AttributionNarrativeService.java
  23. 305 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/AttributionReportBuilder.java
  24. 117 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/FalsificationEngine.java
  25. 137 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/FiveDimAttributionEngine.java
  26. 167 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/IndicatorInterpretEngine.java
  27. 81 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/MainCauseChainEngine.java
  28. 285 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/NormCompareEngine.java
  29. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/CauseLink.java
  30. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/CompareResult.java
  31. 38 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/FalsifiedItem.java
  32. 79 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/IndicatorReading.java
  33. 57 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/MemberAttribution.java
  34. 55 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/MemberProfile.java
  35. 41 0
      cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/NormEntry.java
  36. 342 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/AttributionAdjustServiceImpl.java
  37. 731 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/AttributionAnalysisServiceImpl.java
  38. 513 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/AttributionPlanServiceImpl.java
  39. 319 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/ConditionEvaluatorServiceImpl.java
  40. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/SurveyServiceImpl.java
  41. 121 0
      cfc-backend/src/main/java/com/etotem/cfc/util/AttributionRoleUtil.java
  42. 13 0
      cfc-backend/src/main/resources/application.yml
  43. 22 0
      cfc-backend/src/main/resources/norm/attribution_norm_gut_flora.json
  44. 43 0
      cfc-backend/src/main/resources/schema.sql
  45. 168 0
      cfc-backend/src/main/resources/survey/family_attribution_questions.json
  46. 176 0
      cfc-backend/src/test/java/com/etotem/cfc/config/AttributionStage1FoundationTest.java
  47. 40 0
      cfc-backend/src/test/java/com/etotem/cfc/controller/AttributionControllerTest.java
  48. 161 0
      cfc-backend/src/test/java/com/etotem/cfc/controller/AttributionSurveyRoutingTest.java
  49. 172 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/AttributionAdjustServiceTest.java
  50. 520 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/AttributionPipelineMockTest.java
  51. 193 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/AttributionPlanServiceTest.java
  52. 105 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/AttributionRedlinesTest.java
  53. 135 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ConditionEvaluatorServiceTest.java
  54. 60 0
      docs/superpowers/api/API_REFERENCE.md
  55. 2 2
      docs/superpowers/specs/2026-10-28-family-attribution-architecture-stage1-2.md

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

@@ -2,6 +2,9 @@ package com.etotem.cfc.config;
 
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.service.CfLegacyLedgerMigrationService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
 import javax.annotation.Resource;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -9,6 +12,7 @@ import org.slf4j.LoggerFactory;
 import lombok.var;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.CommandLineRunner;
+import org.springframework.core.io.ClassPathResource;
 import org.springframework.core.io.ResourceLoader;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Component;
@@ -3358,6 +3362,17 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         migrateIndicatorValuesTimelineColumns();
         // 迁移423: 体脂秤测量记录表(sourceId 由 memberId 改为 recordId 的前置表)
         createBodyFatScaleRecordsTable();
+
+        // 迁移424: health_plans 加 plan_type(区分健康方案/家庭归因干预方案;G5)
+        migrateHealthPlansPlanType();
+        // 迁移425: 家庭归因常模值表(G10,菌群 710 常模落点)
+        createAttributionNormValuesTable();
+        // 迁移425-导入: 菌群常模可配置 JSON 导入(空壳文件导入 0 行,不报错)
+        importAttributionNormGutFlora();
+        // 迁移425-种子: 家庭归因确认问卷模板种子(第八章 16 条,幂等)
+        seedFamilyAttributionSurveyTemplate();
+        // 迁移426: 家庭归因报告表 attribution_reports(B1 核心闭环③,report_json+intermediate_state+redline_hits+status)
+        createAttributionReportsTable();
     }
 
     /**
@@ -14275,4 +14290,235 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移420: 创建 ppoint_consumption_log 表失败(可能已存在): {}", e.getMessage());
         }
     }
+
+    // ==================== 迁移424:家庭归因阶段一·数据地基 ====================
+
+    /**
+     * 迁移424: health_plans 加 plan_type,区分健康方案与家庭归因干预方案(G5)。
+     * 默认 'health_plan',存量行自动命中默认值;幂等(列已存在则跳过)。
+     */
+    void migrateHealthPlansPlanType() {
+        ensureColumn("health_plans", "plan_type",
+                "VARCHAR(32) DEFAULT 'health_plan' COMMENT '方案类型: health_plan=健康方案, family_attribution_plan=家庭归因方案'");
+    }
+
+    // ==================== 迁移425:家庭归因常模值表(G10) ====================
+
+    /** 菌群常模 JSON 种子路径(classpath) */
+    static final String NORM_GUT_FLORA_CLASSPATH = "norm/attribution_norm_gut_flora.json";
+
+    /** 家庭归因确认问卷模板 JSON 种子路径(classpath) */
+    static final String SURVEY_FAMILY_ATTRIBUTION_CLASSPATH = "survey/family_attribution_questions.json";
+
+    /** 家庭归因确认问卷模板标题(幂等判重键) */
+    static final String SURVEY_FAMILY_ATTRIBUTION_TITLE = "家庭归因分析问卷(第八章16条)";
+
+    /**
+     * 迁移425: 家庭归因常模值表 attribution_norm_values。
+     * 菌群 710 常模落点;DAN 常模优先复用 cognitive_norms / health_norm_reference,不重复建。
+     */
+    void createAttributionNormValuesTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `attribution_norm_values` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`norm_type` VARCHAR(20) NOT NULL COMMENT '常模类型: gut_flora(菌群)/dan(DAN测评)', "
+                    + "`definition_id` BIGINT NULL COMMENT 'FK→indicator_definitions.id(归一化后关联,可空)', "
+                    + "`code` VARCHAR(64) NOT NULL COMMENT '指标编码(菌种学名如Bifidobacterium 或 DAN维度key如emotionScore)', "
+                    + "`display_name` VARCHAR(100) COMMENT '显示名', "
+                    + "`member_age_band` VARCHAR(32) NOT NULL COMMENT '年龄段带: 0-3/4-6/7-12/13-18/19-45/46-65/66+/all', "
+                    + "`gender` VARCHAR(10) DEFAULT 'all' COMMENT '性别: male/female/all', "
+                    + "`p5` DECIMAL(10,4) NULL COMMENT 'P5百分位值', "
+                    + "`p25` DECIMAL(10,4) NULL COMMENT 'P25百分位值', "
+                    + "`p50` DECIMAL(10,4) NULL COMMENT 'P50百分位值(中位数)', "
+                    + "`p75` DECIMAL(10,4) NULL COMMENT 'P75百分位值', "
+                    + "`p95` DECIMAL(10,4) NULL COMMENT 'P95百分位值', "
+                    + "`unit` VARCHAR(20) COMMENT '单位(如 %)', "
+                    + "`norm_version` VARCHAR(20) NOT NULL DEFAULT 'v1' COMMENT '常模版本', "
+                    + "`source` VARCHAR(200) COMMENT '数据来源(谷禾710常模/DAN常模/导入文件名)', "
+                    + "`status` TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "UNIQUE KEY `uk_type_code_band_gender_ver` (`norm_type`, `code`, `member_age_band`, `gender`, `norm_version`), "
+                    + "INDEX `idx_type_code` (`norm_type`, `code`), "
+                    + "INDEX `idx_def` (`definition_id`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭归因常模值'");
+            log.info("迁移425: 已创建 attribution_norm_values 表");
+        } catch (Exception e) {
+            log.warn("迁移425: 创建 attribution_norm_values 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移425-导入: 菌群常模可配置 JSON 导入(norm_type=gut_flora)。
+     * 空壳模板(placeholder=true / data 为空)→ 0 行导入且不报错;
+     * 真实数据到位后填充 classpath:norm/attribution_norm_gut_flora.json 即可增量导入。
+     */
+    void importAttributionNormGutFlora() {
+        try {
+            ClassPathResource resource = new ClassPathResource(NORM_GUT_FLORA_CLASSPATH);
+            if (!resource.exists()) {
+                log.warn("迁移425: 未找到常模种子文件 {},跳过导入", NORM_GUT_FLORA_CLASSPATH);
+                return;
+            }
+            String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
+            if (content.trim().isEmpty()) {
+                log.info("迁移425: 常模种子文件为空,0 行导入");
+                return;
+            }
+            ObjectMapper mapper = new ObjectMapper();
+            JsonNode root = mapper.readTree(content);
+            if (root == null || !root.isObject()) {
+                log.warn("迁移425: 常模种子文件格式非法(期望 JSON 对象),0 行导入");
+                return;
+            }
+            if (root.path("placeholder").asBoolean(false)) {
+                log.info("迁移425: 常模种子为占位模板(placeholder=true),0 行导入,等待业务侧填充真实常模");
+                return;
+            }
+            JsonNode data = root.get("data");
+            if (data == null || !data.isArray() || data.size() == 0) {
+                log.info("迁移425: 常模种子 data 为空,0 行导入");
+                return;
+            }
+            int inserted = 0;
+            for (JsonNode row : data) {
+                String code = text(row, "code");
+                if (code == null || code.trim().isEmpty()) {
+                    continue;
+                }
+                String ageBand = defaultIfBlank(text(row, "memberAgeBand"), "all");
+                String gender = defaultIfBlank(text(row, "gender"), "all");
+                String normVersion = defaultIfBlank(text(row, "normVersion"), "v1");
+                jdbcTemplate.update(
+                        "INSERT INTO attribution_norm_values "
+                                + "(norm_type, definition_id, code, display_name, member_age_band, gender, "
+                                + "p5, p25, p50, p75, p95, unit, norm_version, source, status, created_at, updated_at) "
+                                + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NOW(), NOW()) "
+                                + "ON DUPLICATE KEY UPDATE definition_id=VALUES(definition_id), display_name=VALUES(display_name), "
+                                + "p5=VALUES(p5), p25=VALUES(p25), p50=VALUES(p50), p75=VALUES(p75), p95=VALUES(p95), "
+                                + "unit=VALUES(unit), source=VALUES(source), status=VALUES(status), updated_at=NOW()",
+                        "gut_flora",
+                        longOrNull(row.get("definitionId")),
+                        code.trim(),
+                        text(row, "displayName"),
+                        ageBand,
+                        gender,
+                        decimalOrNull(row.get("p5")),
+                        decimalOrNull(row.get("p25")),
+                        decimalOrNull(row.get("p50")),
+                        decimalOrNull(row.get("p75")),
+                        decimalOrNull(row.get("p95")),
+                        text(row, "unit"),
+                        normVersion,
+                        text(row, "source"));
+                inserted++;
+            }
+            log.info("迁移425: 菌群常模导入完成,共 {} 行(norm_type=gut_flora,幂等 upsert)", inserted);
+        } catch (Exception e) {
+            log.warn("迁移425: 菌群常模导入失败(忽略): {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移425-种子: 家庭归因确认问卷模板种子(A2:第八章 16 条 / 45 采集项)。
+     * 幂等:按 title 判重;已存在则只确保 enabled=1(不覆盖管理员改过的 questions_json);
+     * questionsJson 存 §3.2 扩展结构(schemaVersion/target/questions)。
+     */
+    void seedFamilyAttributionSurveyTemplate() {
+        try {
+            ClassPathResource resource = new ClassPathResource(SURVEY_FAMILY_ATTRIBUTION_CLASSPATH);
+            if (!resource.exists()) {
+                log.warn("迁移425-种子: 未找到问卷模板种子 {},跳过", SURVEY_FAMILY_ATTRIBUTION_CLASSPATH);
+                return;
+            }
+            Integer cnt = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM survey_templates WHERE title = ?",
+                    Integer.class, SURVEY_FAMILY_ATTRIBUTION_TITLE);
+            if (cnt != null && cnt > 0) {
+                // 已存在:幂等启用(不覆盖管理员可能改过的 questions_json)
+                jdbcTemplate.update(
+                        "UPDATE survey_templates SET enabled = 1, updated_at = NOW() WHERE title = ?",
+                        SURVEY_FAMILY_ATTRIBUTION_TITLE);
+                log.info("迁移425-种子: 家庭归因问卷模板已存在(title={}),幂等启用(enabled=1)", SURVEY_FAMILY_ATTRIBUTION_TITLE);
+                return;
+            }
+            String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
+            // 校验 JSON 合法后再落库,避免脏种子进库
+            ObjectMapper mapper = new ObjectMapper();
+            JsonNode root = mapper.readTree(content);
+            if (root == null || !root.isObject() || root.get("questions") == null) {
+                log.warn("迁移425-种子: 问卷模板种子格式非法,跳过");
+                return;
+            }
+            jdbcTemplate.update(
+                    "INSERT INTO survey_templates (title, dimension, questions_json, cycle_type, ai_generated, enabled, created_at, updated_at) "
+                            + "VALUES (?, ?, ?, ?, 0, 1, NOW(), NOW())",
+                    SURVEY_FAMILY_ATTRIBUTION_TITLE,
+                    "family_attribution",
+                    content,
+                    "once");
+            log.info("迁移425-种子: 已写入家庭归因问卷模板种子 title={} questions={}(enabled=1,已启用)",
+                    SURVEY_FAMILY_ATTRIBUTION_TITLE, root.get("questions").size());
+        } catch (Exception e) {
+            log.warn("迁移425-种子: 家庭归因问卷模板种子失败(忽略): {}", e.getMessage());
+        }
+    }
+
+    // ==================== 迁移426:家庭归因报告表(B1 核心闭环③) ====================
+
+    /**
+     * 迁移426: 家庭归因报告表 attribution_reports。
+     * 存十节结构 report_json + 归因中间态 intermediate_state(供 B2 条件路由 / B3 回填)
+     * + 红线命中 redline_hits + 状态机 status(ANALYZING/READY/ADJUSTING/ADJUSTED/CONFIRMED/GENERATED/INVALIDATED)。
+     * 幂等:CREATE TABLE IF NOT EXISTS。
+     */
+    void createAttributionReportsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `attribution_reports` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`family_id` BIGINT NOT NULL COMMENT '家庭ID', "
+                    + "`member_ids` VARCHAR(200) NULL COMMENT '目标成员ID列表(逗号分隔;E6: 妈妈/女儿/儿子)', "
+                    + "`plan_id` BIGINT NULL COMMENT '关联健康方案ID(health_plans.id)', "
+                    + "`status` VARCHAR(20) NOT NULL DEFAULT 'ANALYZING' "
+                    + "COMMENT '状态机: ANALYZING/READY/ADJUSTING/ADJUSTED/CONFIRMED/GENERATED/INVALIDATED', "
+                    + "`report_json` MEDIUMTEXT NULL COMMENT '十节结构化归因报告(JSON)', "
+                    + "`redline_hits` VARCHAR(255) NULL COMMENT '红线命中编号(JSON数组, 如 [\"RL02\"])', "
+                    + "`intermediate_state` MEDIUMTEXT NULL COMMENT '归因中间态(JSON, 供B2条件路由/B3回填)', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "INDEX `idx_family` (`family_id`), "
+                    + "INDEX `idx_status` (`status`), "
+                    + "INDEX `idx_plan` (`plan_id`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭归因分析报告(阶段二核心闭环)'");
+            log.info("迁移426: 已创建 attribution_reports 表");
+        } catch (Exception e) {
+            log.warn("迁移426: 创建 attribution_reports 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
+    private static String text(JsonNode row, String field) {
+        if (row == null) return null;
+        JsonNode n = row.get(field);
+        if (n == null || n.isNull()) return null;
+        String s = n.asText();
+        return s == null || s.trim().isEmpty() ? null : s;
+    }
+
+    private static String defaultIfBlank(String v, String def) {
+        return (v == null || v.trim().isEmpty()) ? def : v;
+    }
+
+    private static Long longOrNull(JsonNode n) {
+        if (n == null || n.isNull()) return null;
+        return n.isNumber() ? n.longValue() : null;
+    }
+
+    private static java.math.BigDecimal decimalOrNull(JsonNode n) {
+        if (n == null || n.isNull() || n.asText().trim().isEmpty()) return null;
+        try {
+            return new java.math.BigDecimal(n.asText().trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/PromptConfig.java

@@ -24,4 +24,7 @@ public class PromptConfig {
 
     /** 问卷版本号 */
     private String questionnaireVersion = "1";
+
+    /** 家庭归因教练 prompt(七层 Socratic:澄清→探因→假设→证据→替代→行动→复盘) */
+    private String attributionCoach = "";
 }

+ 183 - 0
cfc-backend/src/main/java/com/etotem/cfc/constant/AttributionRedlines.java

@@ -0,0 +1,183 @@
+package com.etotem.cfc.constant;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 家庭归因合规红线模板(A4 / G9,10 条,P0/P1/P2 分级)。
+ *
+ * <p><b>合规即服务</b>:红线文案由规则层硬注入,AI 只产叙述,不负责合规。
+ * <p><b>E7 品牌级口径</b>:品牌露出不附带功效承诺 —— 可展示 Enagic 还原水/姜黄、
+ * 瓜尔豆益生元 PHGG、益生菌株名(长双歧 BB536、乳双歧 HN019、布拉氏酵母菌),
+ * 但必须同时携带 RL05「产品非治疗」口径。
+ * <p><b>E4 弱提示</b>:高风险项(Q11/Q12/Q13)命中仅标记「建议专业评估 + 转介信息露出 +
+ * 家长确认」,问卷与流程<b>不阻断</b>。
+ *
+ * @author 家庭归因阶段一(ATT-S1-A4)
+ */
+public final class AttributionRedlines {
+
+    /** 红线分级 */
+    public enum Level { P0, P1, P2 }
+
+    /**
+     * 单条红线。
+     *
+     * @param code    红线编号 RL01..RL10
+     * @param level   分级 P0/P1/P2
+     * @param title   标题
+     * @param message 品牌级口径文案(注入报告/方案/任务)
+     * @param scope   适用范围:阶段标记(③④/⑤⑥/⑤)或「全部」
+     */
+    public static final class Redline {
+        public final String code;
+        public final Level level;
+        public final String title;
+        public final String message;
+        public final String scope;
+
+        public Redline(String code, Level level, String title, String message, String scope) {
+            this.code = code;
+            this.level = level;
+            this.title = title;
+            this.message = message;
+            this.scope = scope;
+        }
+
+        public String getCode() { return code; }
+
+        public Level getLevel() { return level; }
+
+        public String getTitle() { return title; }
+
+        public String getMessage() { return message; }
+
+        public String getScope() { return scope; }
+
+        @Override
+        public String toString() {
+            return code + "(" + level + ") " + title;
+        }
+    }
+
+    /** message 禁词(合规硬约束,禁词库可由合规侧扩充) */
+    public static final Set<String> FORBIDDEN_WORDS =
+            Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList("治愈", "根治")));
+
+    // 10 条红线(按 §3.4 明细硬编码;文案保持品牌级口径)
+    private static final List<Redline> ALL = buildAll();
+
+    private AttributionRedlines() {
+    }
+
+    private static List<Redline> buildAll() {
+        List<Redline> list = new ArrayList<>(Arrays.asList(
+                new Redline("RL01", Level.P0, "测评≠诊断",
+                        "菌群「相似度」是模式比对,不是疾病;DAN 不是智力测验也不是临床诊断。所有关联均为关联,非因果。",
+                        "全部"),
+                new Redline("RL02", Level.P0, "情绪红线",
+                        "0.9 百分位的情绪总分不是谈心能处理的量级;出现自伤、拒学、持续失眠等信号时,建议专业评估并露出转介信息,"
+                                + "由家长确认后跟进 —— 问卷与流程不因此阻断。本方案不做情绪处理,角色是「识别 + 转介」。",
+                        "③④"),
+                new Redline("RL03", Level.P0, "儿童补剂红线",
+                        "儿童补铁补锌必须由儿科/儿保科按体重与检验结果定剂量,铁过量有毒,严禁自行加量。"
+                                + "方案不出现儿童剂量数字,只写「医生定」。",
+                        "⑤⑥"),
+                new Redline("RL04", Level.P0, "糖尿病红线",
+                        "不调药、不荐药、不设血糖目标值。随访节奏、用药、减重目标全部由内分泌科定;"
+                                + "教练只做生活方式支持与「让他去随访」。",
+                        "③⑤"),
+                new Redline("RL05", Level.P1, "产品非治疗",
+                        "Enagic 还原水/姜黄、瓜尔豆益生元 PHGG、益生菌均为膳食或食品级支持,不宣称治疗任何疾病、不替代药物;"
+                                + "有特殊病史(胆结石、抗凝药、肾功异常等)先咨询医生。",
+                        "⑤"),
+                new Redline("RL06", Level.P1, "标签红线",
+                        "不在孩子面前使用「多动症」「抑郁」「天才」「学霸」等定性词。",
+                        "③⑤⑥"),
+                new Redline("RL07", Level.P1, "先医后补",
+                        "所有涉及查血、补剂的动作都「先医后补」,不自行下诊断、不给剂量。P0 动作全部是查血/评估/随访,而非补。",
+                        "⑤⑥"),
+                new Redline("RL08", Level.P1, "教练禁忌",
+                        "永远不对家长说「你缺某一维」;不替家长做决定;不用评判式语言;不硬套模型。",
+                        "④"),
+                new Redline("RL09", Level.P2, "服药间隔纪律",
+                        "还原水不与药物同服(间隔≥1h);益生菌与抗生素间隔≥2h;PHGG 不与抗生素同服,"
+                                + "用后停纤维 3-5 天再做菌群重建。",
+                        "⑤"),
+                new Redline("RL10", Level.P2, "铁餐纪律",
+                        "铁餐与奶/茶/咖啡错开≥2h、配维 C。",
+                        "⑤")
+        ));
+        return Collections.unmodifiableList(list);
+    }
+
+    /** 全部 10 条红线(不可变副本顺序) */
+    public static List<Redline> all() {
+        return ALL;
+    }
+
+    /** 按分级取红线 */
+    public static List<Redline> byLevel(Level level) {
+        List<Redline> out = new ArrayList<>();
+        if (level == null) {
+            return out;
+        }
+        for (Redline r : ALL) {
+            if (r.level == level) {
+                out.add(r);
+            }
+        }
+        return Collections.unmodifiableList(out);
+    }
+
+    /**
+     * 按适用范围取红线。
+     *
+     * <p>scope=「全部」的红线对任何范围都适用;其余按阶段标记精确匹配,
+     * 例如 {@code byScope("⑤")} 返回⑤⑥/⑤/全部组,{@code byScope("③")} 返回③④/③⑤⑥/全部组。
+     *
+     * @param scope 阶段标记(③④/⑤⑥/⑤/…)或 null / 「全部」
+     * @return 匹配的红线列表(不可变)
+     */
+    public static List<Redline> byScope(String scope) {
+        List<Redline> out = new ArrayList<>();
+        if (scope == null || scope.trim().isEmpty() || "全部".equals(scope.trim())) {
+            return ALL;
+        }
+        String s = scope.trim();
+        for (Redline r : ALL) {
+            if ("全部".equals(r.scope) || r.scope.contains(s)) {
+                out.add(r);
+            }
+        }
+        return Collections.unmodifiableList(out);
+    }
+
+    /** 按编号取红线 */
+    public static Redline byCode(String code) {
+        if (code == null) {
+            return null;
+        }
+        for (Redline r : ALL) {
+            if (r.code.equals(code)) {
+                return r;
+            }
+        }
+        return null;
+    }
+
+    /** 编号 → 红线 视图(注入 reportJson/planJson 时按编号引用) */
+    public static Map<String, Redline> codeMap() {
+        Map<String, Redline> map = new LinkedHashMap<>();
+        for (Redline r : ALL) {
+            map.put(r.code, r);
+        }
+        return Collections.unmodifiableMap(map);
+    }
+}

+ 86 - 0
cfc-backend/src/main/java/com/etotem/cfc/constant/AttributionWeights.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.constant;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 五维归因权重(B1,规则层集中配置)。
+ *
+ * <p>源文档仅给出「教练定位用相对刻度(0-100)」与「主因维」概念,未给分维权重表。
+ * 本类为保守默认值(按源文档语义:身/行 双主因示例),具体数值待教练侧确认
+ * (阶段四 D1 或后续校准),当前用保守默认(§5.2)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+public final class AttributionWeights {
+
+    /** 五维 */
+    public static final List<String> DIMENSIONS =
+            Collections.unmodifiableList(Arrays.asList("身", "心", "智", "行", "富"));
+
+    /** 各维基础分(0-100 教练相对刻度) */
+    public static final Map<String, Double> BASE_SCORE = buildMap(new Object[][]{
+            {"身", 72.0}, {"心", 74.0}, {"智", 76.0}, {"行", 70.0}, {"富", 78.0}
+    });
+
+    /** 各维权重因子(相对重要度) */
+    public static final Map<String, Double> DIM_WEIGHT = buildMap(new Object[][]{
+            {"身", 1.0}, {"心", 1.0}, {"智", 0.9}, {"行", 0.8}, {"富", 0.8}
+    });
+
+    /** 异常读数扣分(按百分位带) */
+    public static final double PENALTY_VERY = 14.0;
+    public static final double PENALTY_LOW = 7.0;
+
+    /** 短板阈值:低于该分判定为该维短板 */
+    public static final double WEAK_THRESHOLD = 65.0;
+
+    /** 最多短板数(主因维候选) */
+    public static final int MAX_WEAK = 2;
+
+    /** 维度 → 触发维度归属的关键词(解读段归因用;身/心/智/行/富) */
+    public static final Map<String, List<String>> DIM_KEYWORDS = buildDimKeywords();
+
+    private AttributionWeights() {
+    }
+
+    private static Map<String, Double> buildMap(Object[][] arr) {
+        Map<String, Double> m = new LinkedHashMap<>();
+        for (Object[] e : arr) {
+            m.put((String) e[0], (Double) e[1]);
+        }
+        return Collections.unmodifiableMap(m);
+    }
+
+    private static Map<String, List<String>> buildDimKeywords() {
+        Map<String, List<String>> m = new LinkedHashMap<>();
+        m.put("身", Arrays.asList("双歧", "乳杆", "乳酸", "菌", "diversity", "多样性", "大肠", "肠", "铁", "锌",
+                "血糖", "血脂", "体重", "免疫", "炎症", "gaba", "gut", "微生物", "bifido", "lacto", "bacteroid", "prevotella"));
+        m.put("心", Arrays.asList("emotion", "情绪", "resilience", "韧性", "stress", "压力", "mood", "抑郁",
+                "焦虑", "自我认知", "self", "bigfive", "neuroticism", "extraversion", "agreeableness", "openness", "conscientiousness"));
+        m.put("智", Arrays.asList("attention", "注意力", "focus", "专注", "memory", "记忆", "logic", "逻辑",
+                "perception", "感知", "spatial", "空间", "processing", "加工", "language", "语言", "认知"));
+        m.put("行", Arrays.asList("睡眠", "sleep", "运动", "步数", "排便", "bowel", "作息", "锻炼", "snoring", "打鼾"));
+        m.put("富", Arrays.asList("饮食", "营养", "早餐", "外食", "夜宵", "外卖", "厨房", "收入", "economic", "food"));
+        return Collections.unmodifiableMap(m);
+    }
+
+    /** 命中关键词 → 归属维度(首个命中维;未命中归「身」兜底) */
+    public static String dimensionOf(String text) {
+        if (text == null) {
+            return "身";
+        }
+        String t = text.toLowerCase();
+        for (Map.Entry<String, List<String>> e : DIM_KEYWORDS.entrySet()) {
+            for (String kw : e.getValue()) {
+                if (t.contains(kw.toLowerCase())) {
+                    return e.getKey();
+                }
+            }
+        }
+        return "身";
+    }
+}

+ 175 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/AttributionController.java

@@ -0,0 +1,175 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.AdjustResult;
+import com.etotem.cfc.dto.AttributionInput;
+import com.etotem.cfc.dto.AttributionReportVO;
+import com.etotem.cfc.dto.FamilyPlanVO;
+import com.etotem.cfc.service.AttributionAdjustService;
+import com.etotem.cfc.service.AttributionAnalysisService;
+import com.etotem.cfc.service.AttributionPlanService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 家庭归因分析问卷核心闭环接口(阶段二 B1/B3/B4)。
+ *
+ * <p>闭环链路:③ 归因分析({@code /analyze})→ ② 确认问卷({@code /api/survey/attribution/confirm-questions})
+ * → ④ 自然语言调整({@code /adjust} / {@code /confirm-adjust})→ ⑤ 七章干预方案({@code /plan})。
+ *
+ * <p><b>E12 合规后置</b>:全流程入口受 {@code app.attribution.enabled} 开关控制,默认 false,
+ * 仅测试/mock 数据环境开启;真实用户放行 = 阶段四 D1 完成日。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1/B3/B4)
+ */
+@Tag(name = "家庭归因", description = "家庭归因分析问卷核心闭环(归因分析/调整确认/干预方案)")
+@RestController
+@RequestMapping("/api/attribution")
+public class AttributionController {
+
+    /** 归因流程开关(E12:默认关闭,仅测试/mock 环境开启) */
+    @Value("${app.attribution.enabled:false}")
+    private boolean attributionEnabled;
+
+    @Resource
+    private AttributionAnalysisService attributionAnalysisService;
+
+    @Resource
+    private AttributionAdjustService attributionAdjustService;
+
+    @Resource
+    private AttributionPlanService attributionPlanService;
+
+    /**
+     * ③ 归因分析:档案 + 确认问卷 + 检测数据 + 常模 → 十节归因报告。
+     *
+     * <p>Body: AttributionInput { familyId?, memberIds[], gutReportIds[], danResultIds[],
+     * confirmSurveyRecordId?, planId?, mock?, mockFlags? }
+     */
+    @Operation(summary = "家庭归因分析(十节结构报告)")
+    @PostMapping("/analyze")
+    public Result<AttributionReportVO> analyze(
+            @RequestBody AttributionInput input,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Result<AttributionReportVO> gate = gate();
+        if (gate != null) {
+            return gate;
+        }
+        if (input == null) {
+            return Result.error(4000, "归因入参不能为空");
+        }
+        if (input.getFamilyId() == null) {
+            input.setFamilyId(familyId);
+        }
+        if (input.getFamilyId() == null) {
+            return Result.error(4000, "缺少家庭ID(familyId)");
+        }
+        return Result.success(attributionAnalysisService.analyze(input));
+    }
+
+    /**
+     * ④ 自然语言调整:家长一句话调整归因(复用对话通道 + 归因教练 prompt)。
+     *
+     * <p>Body: { attributionId, message, conversationId? }
+     * <br>Resp: AdjustResult { answer, fieldDeltas, intermediateState, redlineTerminate, referral, ... }
+     */
+    @Operation(summary = "归因自然语言调整(回填中间态)")
+    @PostMapping("/adjust")
+    public Result<AdjustResult> adjust(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Result<AdjustResult> gate = gate();
+        if (gate != null) {
+            return gate;
+        }
+        Long attributionId = ParamUtils.getLong(params.get("attributionId"));
+        if (attributionId == null) {
+            return Result.error(4000, "缺少 attributionId");
+        }
+        String message = params.get("message") == null ? null : String.valueOf(params.get("message"));
+        if (message == null || message.trim().isEmpty()) {
+            return Result.error(4000, "缺少 message");
+        }
+        String conversationId = params.get("conversationId") == null
+                ? null : String.valueOf(params.get("conversationId"));
+        AdjustResult result = attributionAdjustService.adjust(attributionId, userId, message, conversationId);
+        if (result == null) {
+            return Result.error(4000, "归因报告不存在");
+        }
+        return Result.success(result);
+    }
+
+    /**
+     * ④ 用户确认调整:归因结论锁定(status=CONFIRMED)。
+     *
+     * <p>Body: { attributionId }
+     */
+    @Operation(summary = "确认归因调整(结论锁定)")
+    @PostMapping("/confirm-adjust")
+    public Result<AdjustResult> confirmAdjust(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Result<AdjustResult> gate = gate();
+        if (gate != null) {
+            return gate;
+        }
+        Long attributionId = ParamUtils.getLong(params.get("attributionId"));
+        if (attributionId == null) {
+            return Result.error(4000, "缺少 attributionId");
+        }
+        AdjustResult result = attributionAdjustService.confirm(attributionId, userId);
+        if (result == null) {
+            return Result.error(4000, "归因报告不存在");
+        }
+        return Result.success(result);
+    }
+
+    /**
+     * ⑤ 七章干预方案:归因结论(CONFIRMED)→ planJson(妈妈/女儿/儿子,E6)。
+     *
+     * <p>Body: { attributionId }
+     */
+    @Operation(summary = "生成全家干预方案(7 章)")
+    @PostMapping("/plan")
+    public Result<FamilyPlanVO> plan(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Result<FamilyPlanVO> gate = gate();
+        if (gate != null) {
+            return gate;
+        }
+        Long attributionId = ParamUtils.getLong(params.get("attributionId"));
+        if (attributionId == null) {
+            return Result.error(4000, "缺少 attributionId");
+        }
+        FamilyPlanVO vo;
+        try {
+            vo = attributionPlanService.generateFamilyPlan(attributionId, userId);
+        } catch (IllegalStateException e) {
+            return Result.error(4000, e.getMessage());
+        }
+        if (vo == null) {
+            return Result.error(4000, "归因报告不存在");
+        }
+        return Result.success(vo);
+    }
+
+    /**
+     * 功能开关闸门(E12 合规后置):未开启时返回 4001(权限/放行类)。
+     *
+     * @return 未开启时返回错误结果;开启返回 null
+     */
+    private <T> Result<T> gate() {
+        if (!attributionEnabled) {
+            return Result.error(4001, "家庭归因功能未开放(E12 合规后置,仅测试/mock 数据环境可用)");
+        }
+        return null;
+    }
+}

+ 262 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/SurveyController.java

@@ -1,27 +1,69 @@
 package com.etotem.cfc.controller;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ConfirmQuestionVO;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.SurveyRecord;
 import com.etotem.cfc.entity.SurveyTemplate;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.SurveyTemplateMapper;
+import com.etotem.cfc.service.ConditionEvaluatorService;
 import com.etotem.cfc.service.SurveyService;
+import com.etotem.cfc.util.AttributionRoleUtil;
+import com.etotem.cfc.util.ParamUtils;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import com.etotem.cfc.util.ParamUtils;
 
+/**
+ * 定期调研接口(B2 扩展:家庭归因动态确认问卷条件路由)。
+ */
 @Tag(name = "定期调研", description = "小程序端定期调研接口")
 @RestController
 @RequestMapping("/api/survey")
 public class SurveyController {
 
+    private static final Logger log = LoggerFactory.getLogger(SurveyController.class);
+
+    /** family_attribution 模板维度(与迁移425-种子 维度一致) */
+    private static final String FAMILY_ATTRIBUTION_DIMENSION = "family_attribution";
+
+    /** 家庭级对象分组(恒发) */
+    private static final List<String> FAMILY_LEVEL_GROUPS = java.util.Arrays.asList("四人", "全家");
+
+    /** 归因流程开关(E12 合规后置:默认关闭,仅测试/mock 环境开启;与 /api/attribution/* 同一开关) */
+    @Value("${app.attribution.enabled:false}")
+    private boolean attributionEnabled;
+
     @Resource
     private SurveyService surveyService;
 
+    @Resource
+    private SurveyTemplateMapper surveyTemplateMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private ConditionEvaluatorService conditionEvaluatorService;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
     @Operation(summary = "查询调研待办状态")
     @PostMapping("/status")
     public Result<Object> status(
@@ -38,6 +80,39 @@ public class SurveyController {
         return Result.success(surveyService.getActiveTemplates());
     }
 
+    /**
+     * 家庭归因动态确认问卷(B2):按对象/年龄/结论条件下发第八章 16 条子集。
+     *
+     * <p>Body: { familyId, memberId, attributionId, answeredSummary? }
+     * <br>Resp: { templateId, questions: [ {id,code,group,title,type,options,unit,required,riskLevel,refRange} ] }
+     */
+    @Operation(summary = "家庭归因动态确认问卷(按条件下发)")
+    @PostMapping("/attribution/confirm-questions")
+    public Result<ConfirmQuestionVO> confirmQuestions(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "userId", required = false) Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        // E12 合规后置:与 /api/attribution/* 同一开关(该端点只下发题目、不出归因报告数据)
+        Result<ConfirmQuestionVO> gate = gate();
+        if (gate != null) {
+            return gate;
+        }
+        Long familyIdParam = ParamUtils.getLong(params.get("familyId"), familyId);
+        Long memberId = ParamUtils.getLong(params.get("memberId"));
+        Long attributionId = ParamUtils.getLong(params.get("attributionId"));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> answeredSummary = params.get("answeredSummary") instanceof Map
+                ? (Map<String, Object>) params.get("answeredSummary") : null;
+
+        SurveyTemplate template = findFamilyAttributionTemplate();
+        if (template == null) {
+            return Result.error(4000, "家庭归因问卷模板未就绪(迁移425-种子 缺失)");
+        }
+
+        ConfirmQuestionVO vo = routeQuestions(template, familyIdParam, memberId, attributionId, answeredSummary);
+        return Result.success(vo);
+    }
+
     @Operation(summary = "提交调研答案")
     @PostMapping("/submit")
     public Result<SurveyRecord> submit(
@@ -49,7 +124,10 @@ public class SurveyController {
         String answersJson = (String) params.get("answersJson");
         Date ps = params.get("periodStart") != null ? new Date(ParamUtils.getLong(params.get("periodStart"))) : null;
         Date pe = params.get("periodEnd") != null ? new Date(ParamUtils.getLong(params.get("periodEnd"))) : null;
-        return Result.success(surveyService.submitSurvey(userId, memberId, templateId, answersJson, ps, pe));
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> riskHits = params.get("riskHits") instanceof List
+                ? (List<Map<String, Object>>) params.get("riskHits") : null;
+        return Result.success(surveyService.submitSurvey(userId, memberId, templateId, answersJson, ps, pe, riskHits));
     }
 
     @Operation(summary = "查询调研历史")
@@ -61,4 +139,186 @@ public class SurveyController {
         Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
         return Result.success(surveyService.getSurveyHistory(userId, memberId));
     }
+
+    // ==================== B2 条件路由 ====================
+
+    /** 取 family_attribution 模板(维度匹配优先,标题兜底) */
+    SurveyTemplate findFamilyAttributionTemplate() {
+        try {
+            List<SurveyTemplate> list = surveyTemplateMapper.selectList(
+                    new LambdaQueryWrapper<SurveyTemplate>()
+                            .eq(SurveyTemplate::getDimension, FAMILY_ATTRIBUTION_DIMENSION)
+                            .last("LIMIT 1"));
+            if (list != null && !list.isEmpty()) {
+                return list.get(0);
+            }
+        } catch (Exception e) {
+            log.warn("查询 family_attribution 模板失败: {}", e.getMessage());
+        }
+        return null;
+    }
+
+    /**
+     * 条件路由(纯规则,可单测):
+     * 1. 家庭级题目(四人/全家)恒发;
+     * 2. 对象级题目:仅该对象命中(姓名匹配或 groupRoles 语义角色匹配);
+     * 3. condition 逐条求值(condition==null 恒真;未知 var 保守 false)。
+     */
+    ConfirmQuestionVO routeQuestions(SurveyTemplate template, Long familyId, Long memberId,
+                                     Long attributionId, Map<String, Object> answeredSummary) {
+        ConfirmQuestionVO vo = new ConfirmQuestionVO();
+        vo.setTemplateId(template.getId());
+        vo.setTemplateTitle(template.getTitle());
+        vo.setTarget(FAMILY_ATTRIBUTION_DIMENSION);
+
+        JsonNode root;
+        try {
+            root = objectMapper.readTree(template.getQuestionsJson());
+        } catch (Exception e) {
+            log.warn("解析 questionsJson 失败 templateId={}: {}", template.getId(), e.getMessage());
+            return vo;
+        }
+        if (root == null || !root.isObject()) {
+            return vo;
+        }
+        JsonNode questionsNode = root.get("questions");
+        if (questionsNode == null || !questionsNode.isArray()) {
+            return vo;
+        }
+
+        Map<String, Object> context = conditionEvaluatorService.buildContext(familyId, memberId, attributionId);
+        if (answeredSummary != null) {
+            context.putAll(answeredSummary);
+        }
+        Map<String, String> groupRoleMap = resolveGroupRoles(root.get("groupRoles"));
+        MemberGroup memberGroup = resolveMemberGroup(memberId);
+
+        for (JsonNode q : questionsNode) {
+            String group = text(q, "group");
+            if (!matchesGroup(group, memberGroup, groupRoleMap)) {
+                continue;
+            }
+            JsonNode condition = q.get("condition");
+            if (!conditionEvaluatorService.evaluate(condition, context)) {
+                continue;
+            }
+            ConfirmQuestionVO.ConfirmQuestion cq = toQuestion(q);
+            vo.getQuestions().add(cq);
+            if ("high".equals(cq.getRiskLevel())) {
+                vo.setHasHighRisk(true);
+                vo.getHighRiskQuestionIds().add(cq.getId());
+            }
+        }
+        return vo;
+    }
+
+    /**
+     * group → 语义角色映射:模板 JSON 可用 {@code groupRoles} 覆盖,否则用源文档默认映射
+     * (孟凡锐→mom / 马智强→dad / 马昕源→daughter / 马安扬→son)。
+     */
+    Map<String, String> resolveGroupRoles(JsonNode groupRolesNode) {
+        Map<String, String> map = new LinkedHashMap<>();
+        if (groupRolesNode != null && groupRolesNode.isObject()) {
+            Iterator<Map.Entry<String, JsonNode>> it = groupRolesNode.fields();
+            while (it.hasNext()) {
+                Map.Entry<String, JsonNode> e = it.next();
+                map.put(e.getKey(), e.getValue().asText());
+            }
+        }
+        if (map.isEmpty()) {
+            map.putAll(AttributionRoleUtil.DEFAULT_GROUP_ROLES);
+        }
+        return map;
+    }
+
+    private boolean matchesGroup(String group, MemberGroup memberGroup, Map<String, String> groupRoleMap) {
+        if (group == null || group.trim().isEmpty()) {
+            return true;
+        }
+        String g = group.trim();
+        if (FAMILY_LEVEL_GROUPS.contains(g)) {
+            return true;
+        }
+        if (memberGroup == null) {
+            return false;
+        }
+        // 姓名精确匹配(源文档四人名)
+        if (memberGroup.name != null && g.equals(memberGroup.name.trim())) {
+            return true;
+        }
+        // 语义角色匹配(groupRoles 映射,如 马昕源→daughter)
+        String role = groupRoleMap.get(g);
+        return role != null && role.equals(memberGroup.role);
+    }
+
+    /** 成员 → 姓名 + 语义角色 */
+    private MemberGroup resolveMemberGroup(Long memberId) {
+        if (memberId == null) {
+            return null;
+        }
+        FamilyMember fm = null;
+        try {
+            fm = familyMemberMapper.selectById(memberId);
+        } catch (Exception e) {
+            log.warn("读取家庭成员失败 memberId={}: {}", memberId, e.getMessage());
+        }
+        if (fm == null) {
+            return null;
+        }
+        MemberGroup g = new MemberGroup();
+        g.name = fm.getNickname();
+        g.role = AttributionRoleUtil.roleOf(fm);
+        return g;
+    }
+
+    /** 成员分组解析结果 */
+    static class MemberGroup {
+        String name;
+        String role;
+    }
+
+    private ConfirmQuestionVO.ConfirmQuestion toQuestion(JsonNode q) {
+        ConfirmQuestionVO.ConfirmQuestion cq = new ConfirmQuestionVO.ConfirmQuestion();
+        cq.setId(text(q, "id"));
+        cq.setCode(text(q, "code"));
+        cq.setGroup(text(q, "group"));
+        cq.setTitle(text(q, "title"));
+        cq.setType(text(q, "type"));
+        cq.setUnit(text(q, "unit"));
+        cq.setRequired(q.path("required").asBoolean(true));
+        cq.setRiskLevel(text(q, "riskLevel") == null ? "none" : text(q, "riskLevel"));
+        cq.setRefRange(text(q, "refRange"));
+        JsonNode options = q.get("options");
+        List<String> opts = new ArrayList<>();
+        if (options != null && options.isArray()) {
+            for (JsonNode o : options) {
+                opts.add(o.asText());
+            }
+        }
+        cq.setOptions(opts);
+        return cq;
+    }
+
+    private static String text(JsonNode n, String field) {
+        if (n == null) {
+            return null;
+        }
+        JsonNode v = n.get(field);
+        if (v == null || v.isNull()) {
+            return null;
+        }
+        return v.asText();
+    }
+
+    /**
+     * 功能开关闸门(E12 合规后置):未开启时返回 4001(权限/放行类),与 AttributionController.gate() 一致。
+     *
+     * @return 未开启时返回错误结果;开启返回 null
+     */
+    private <T> Result<T> gate() {
+        if (!attributionEnabled) {
+            return Result.error(4001, "家庭归因功能未开放(E12 合规后置,仅测试/mock 数据环境可用)");
+        }
+        return null;
+    }
 }

+ 62 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/AdjustResult.java

@@ -0,0 +1,62 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 自然语言调整结果视图(B3)。
+ *
+ * <p>RL02 命中时 {@code redlineTerminate=true}(E4 弱提示)——
+ * <b>仅标记建议专业评估 + 转介信息露出 + 家长确认,流程不阻断</b>。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B3)
+ */
+@Data
+public class AdjustResult implements Serializable {
+
+    /** 归因报告ID */
+    private Long attributionId;
+
+    /** AI 回答文本(AI 失败时为规则兜底话术) */
+    private String answer;
+
+    /** 解析出的结构化增量([ATTR_DELTA]{...}),已合并进中间态与报告字段 */
+    private Map<String, Object> fieldDeltas = new LinkedHashMap<>();
+
+    /** 合并后的归因中间态快照 */
+    private Map<String, Object> intermediateState = new LinkedHashMap<>();
+
+    /** 报告状态(ADJUSTING/ADJUSTED/CONFIRMED) */
+    private String status;
+
+    /** RL02 命中标记(true=建议专业评估 + 转介信息露出 + 家长确认;流程不阻断) */
+    private boolean redlineTerminate;
+
+    /** 是否阻断流程(E4:恒为 false,弱提示不阻断) */
+    private boolean blocking = false;
+
+    /** 转介信息露出(RL02 命中时非空) */
+    private ReferralInfo referral;
+
+    /** 新增红线命中 */
+    private List<String> redlineHits = new ArrayList<>();
+
+    /** 关联方案是否已作废(⑤ 失效重生成) */
+    private boolean planInvalidated;
+
+    /** 转介信息 */
+    @Data
+    public static class ReferralInfo implements Serializable {
+        /** 建议专业评估 */
+        private String advice = "建议专业评估 + 转介信息露出 + 家长确认";
+        /** 转介通道提示 */
+        private String channels = "儿童精神科/心理科门诊、学校心理辅导、专业评估机构(由家长自行选择)";
+        /** 需要家长确认 */
+        private String parentConfirm = "请家长确认知悉并跟进(E4 弱提示,流程不阻断)";
+    }
+}

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/AttributionInput.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 归因分析入参(B1)。
+ *
+ * <p>数据来源桥接(阶段三 C3 完善跨成员绑定,本阶段按显式 ID 列表注入):
+ * <ul>
+ *   <li>{@code gutReportIds} → {@code health_gut_flora.report_id}(菌群报告)</li>
+ *   <li>{@code danResultIds} → {@code dan_assessment_results.id}(DAN 结果)</li>
+ *   <li>{@code confirmSurveyRecordId} → {@code survey_records.id}(确认问卷记录)</li>
+ * </ul>
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Data
+public class AttributionInput implements Serializable {
+
+    /** 家庭ID */
+    private Long familyId;
+
+    /** 目标成员ID列表(E6:妈妈/女儿/儿子 3 人;爸爸链外) */
+    private List<Long> memberIds;
+
+    /** 菌群报告ID列表(health_reports.id) */
+    private List<Long> gutReportIds;
+
+    /** DAN 测评结果ID列表(dan_assessment_results.id) */
+    private List<Long> danResultIds;
+
+    /** 确认问卷记录ID(survey_records.id;② 阶段回收) */
+    private Long confirmSurveyRecordId;
+
+    /** 关联健康方案ID(可空) */
+    private Long planId;
+
+    /** 是否为 mock/测试数据(E12:仅 mock/测试环境放行) */
+    private Boolean mock;
+
+    /** mock/测试数据补充标志(E12;仅测试环境使用,如 ironLow/hasDiabetes/riskSelfHarm 等,生产不传) */
+    private java.util.Map<String, Object> mockFlags;
+}

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/AttributionReportVO.java

@@ -0,0 +1,48 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 归因分析报告视图(B1)。
+ *
+ * <p>{@code reportJson} 为十节结构(结论先行/数据速览/逐人归因/家庭系统归因/应对方案/
+ * 12周目标/教练提问序列/问诊清单/红线与转介/一句话收尾)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Data
+public class AttributionReportVO implements Serializable {
+
+    /** 归因报告ID(attribution_reports.id) */
+    private Long attributionId;
+
+    /** 家庭ID */
+    private Long familyId;
+
+    /** 目标成员ID列表 */
+    private List<Long> memberIds = new ArrayList<>();
+
+    /** 关联方案ID */
+    private Long planId;
+
+    /** 状态机(ANALYZING/READY/ADJUSTING/ADJUSTED/CONFIRMED/GENERATED/INVALIDATED) */
+    private String status;
+
+    /** 十节结构化报告 JSON */
+    private String reportJson;
+
+    /** 红线命中编号(如 ["RL01","RL02"]) */
+    private List<String> redlineHits = new ArrayList<>();
+
+    /** 归因中间态(供 B2 条件路由 / B3 回填) */
+    private Map<String, Object> intermediateState = new LinkedHashMap<>();
+
+    /** 非阻断性告警(如常模缺失「待补」、成员被跳过等) */
+    private List<String> warnings = new ArrayList<>();
+}

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ConfirmQuestionVO.java

@@ -0,0 +1,63 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 动态确认问卷下发视图(B2)。
+ *
+ * <p>按对象/年龄/结论条件下发 family_attribution 模板的子集。
+ * 高风险题(Q11/Q12/Q13)带 {@code riskLevel=high},前端据此标记
+ * 「建议专业评估 + 转介信息露出 + 家长确认」(E4 弱提示,<b>不阻断</b>)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B2)
+ */
+@Data
+public class ConfirmQuestionVO implements Serializable {
+
+    /** 模板ID(survey_templates.id) */
+    private Long templateId;
+
+    /** 模板标题 */
+    private String templateTitle;
+
+    /** 目标(family_attribution) */
+    private String target;
+
+    /** 下发题目(已按对象 + 条件过滤) */
+    private List<ConfirmQuestion> questions = new ArrayList<>();
+
+    /** 是否包含高风险题(Q11/Q12/Q13)——前端露出转介信息与家长确认 */
+    private boolean hasHighRisk;
+
+    /** 高风险题编号 */
+    private List<String> highRiskQuestionIds = new ArrayList<>();
+
+    /** 单题 */
+    @Data
+    public static class ConfirmQuestion implements Serializable {
+        /** 问题稳定标识(Q1a/Q11…) */
+        private String id;
+        /** 采集变量名(条件求值键) */
+        private String code;
+        /** 对象分组(源文档四人名 / 四人 / 全家) */
+        private String group;
+        /** 题面 */
+        private String title;
+        /** 题型 */
+        private String type;
+        /** 选项 */
+        private List<String> options;
+        /** 单位 */
+        private String unit;
+        /** 是否必答 */
+        private boolean required;
+        /** 风险等级:none/low/high */
+        private String riskLevel;
+        /** 参考范围提示 */
+        private String refRange;
+    }
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyPlanVO.java

@@ -0,0 +1,29 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 全家干预方案视图(B4)。
+ *
+ * <p>{@code planJson} 为 7 章结构(头部摘要 + 一数据速览 + 二主线产品 + 三分人方案 +
+ * 四家庭共享 + 五 12 周节奏 + 六红线 + 七附录)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B4)
+ */
+@Data
+public class FamilyPlanVO implements Serializable {
+
+    /** 方案ID(health_plans.id) */
+    private Long planId;
+
+    /** 归因报告ID */
+    private Long attributionId;
+
+    /** 方案类型(family_attribution_plan) */
+    private String planType = "family_attribution_plan";
+
+    /** 7 章方案 JSON */
+    private String planJson;
+}

+ 72 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/AttributionNormValue.java

@@ -0,0 +1,72 @@
+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.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 家庭归因常模值(A3 / G10)。
+ *
+ * <p>菌群 710 常模落此表;DAN 常模优先复用既有 cognitive_norms / health_norm_reference,不重复建。
+ */
+@Data
+@TableName("attribution_norm_values")
+public class AttributionNormValue implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 常模类型: gut_flora(菌群)/dan(DAN测评) */
+    private String normType;
+
+    /** FK→indicator_definitions.id(归一化后关联,可空) */
+    private Long definitionId;
+
+    /** 指标编码(菌种学名如Bifidobacterium 或 DAN维度key如emotionScore) */
+    private String code;
+
+    /** 显示名 */
+    private String displayName;
+
+    /** 年龄段带: 0-3/4-6/7-12/13-18/19-45/46-65/66+/all */
+    private String memberAgeBand;
+
+    /** 性别: male/female/all */
+    private String gender;
+
+    /** P5百分位值 */
+    private BigDecimal p5;
+
+    /** P25百分位值 */
+    private BigDecimal p25;
+
+    /** P50百分位值(中位数) */
+    private BigDecimal p50;
+
+    /** P75百分位值 */
+    private BigDecimal p75;
+
+    /** P95百分位值 */
+    private BigDecimal p95;
+
+    /** 单位(如 %) */
+    private String unit;
+
+    /** 常模版本 */
+    private String normVersion;
+
+    /** 数据来源(谷禾710常模/DAN常模/导入文件名) */
+    private String source;
+
+    /** 0=禁用 1=启用 */
+    private Integer status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 69 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/AttributionReport.java

@@ -0,0 +1,69 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 家庭归因分析报告聚合根(B1)。
+ *
+ * <p>存十节结构化 report_json + 归因中间态 intermediate_state(供 B2 条件路由 / B3 回填)
+ * + 红线命中 redline_hits + 状态机 status(对齐 §4.2 状态机)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Data
+@TableName("attribution_reports")
+public class AttributionReport implements Serializable {
+
+    // ===== 状态机(§4.2) =====
+    public static final String STATUS_ANALYZING = "ANALYZING";
+    public static final String STATUS_READY = "READY";
+    public static final String STATUS_ADJUSTING = "ADJUSTING";
+    public static final String STATUS_ADJUSTED = "ADJUSTED";
+    public static final String STATUS_CONFIRMED = "CONFIRMED";
+    public static final String STATUS_GENERATED = "GENERATED";
+    public static final String STATUS_INVALIDATED = "INVALIDATED";
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 家庭ID */
+    @TableField("family_id")
+    private Long familyId;
+
+    /** 目标成员ID列表(逗号分隔;E6:妈妈/女儿/儿子) */
+    @TableField("member_ids")
+    private String memberIds;
+
+    /** 关联健康方案ID(B4 生成 7 章方案后回填;可空) */
+    @TableField("plan_id")
+    private Long planId;
+
+    /** 状态机:ANALYZING/READY/ADJUSTING/ADJUSTED/CONFIRMED/GENERATED/INVALIDATED */
+    @TableField("status")
+    private String status;
+
+    /** 十节结构化报告 JSON */
+    @TableField("report_json")
+    private String reportJson;
+
+    /** 红线命中(JSON 数组,如 ["RL02"];规则层硬注入,非 AI) */
+    @TableField("redline_hits")
+    private String redlineHits;
+
+    /** 归因中间态 JSON(供 B2 条件路由 / B3 回填:l.reuteriAbnormal/ironLow/hasDan/isFemaleTeen/hasDiabetes…) */
+    @TableField("intermediate_state")
+    private String intermediateState;
+
+    @TableField("created_at")
+    private Date createdAt;
+
+    @TableField("updated_at")
+    private Date updatedAt;
+}

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthPlan.java

@@ -21,6 +21,8 @@ public class HealthPlan implements Serializable {
     @TableField("plan_content") private String planContent;
     @TableField("member_name") private String memberName;
     @TableField("plan_json") private String planJson;
+    /** 方案类型: health_plan(健康方案)/family_attribution_plan(家庭归因干预方案) */
+    @TableField("plan_type") private String planType;
     @TableField("status") private String status;
     @TableField("reviewed_by") private Long reviewedBy;
     @TableField("reviewed_at") private Date reviewedAt;

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/AttributionNormValueMapper.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.AttributionNormValue;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 家庭归因常模值 Mapper(A3)。沿用现有 Mapper 空接口范式。
+ */
+@Mapper
+public interface AttributionNormValueMapper extends BaseMapper<AttributionNormValue> {
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/AttributionReportMapper.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.AttributionReport;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 家庭归因报告 Mapper(B1)。
+ */
+@Mapper
+public interface AttributionReportMapper extends BaseMapper<AttributionReport> {
+}

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AttributionAdjustService.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.AdjustResult;
+
+/**
+ * 家庭归因自然语言调整编排服务(B3)。
+ *
+ * <p>复用对话通道(AiGateway)+ 归因教练 prompt(七层 Socratic),把用户消息解析为
+ * 结构化增量并回填 {@code attribution_reports};RL02 命中 → 标记建议专业评估 +
+ * 转介信息露出 + 家长确认(E4 弱提示,<b>流程不阻断</b>)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B3)
+ */
+public interface AttributionAdjustService {
+
+    /**
+     * 对话式调整:用户消息 → AI 回答 + 结构化回填(可空增量)。
+     *
+     * @param attributionId 归因报告ID
+     * @param userId        用户ID(对话通道身份)
+     * @param message       用户消息
+     * @param conversationId 会话ID(可空)
+     * @return 调整结果;报告不存在返回 null
+     */
+    AdjustResult adjust(Long attributionId, Long userId, String message, String conversationId);
+
+    /**
+     * 用户确认调整:回填生效 → 报告 CONFIRMED(归因结论锁定)。
+     *
+     * @param attributionId 归因报告ID
+     * @param userId        用户ID
+     * @return 确认结果;报告不存在返回 null
+     */
+    AdjustResult confirm(Long attributionId, Long userId);
+}

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AttributionAnalysisService.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.AttributionInput;
+import com.etotem.cfc.dto.AttributionReportVO;
+
+import java.util.Map;
+
+/**
+ * 家庭归因分析服务(B1)。
+ *
+ * <p>门面服务:内部 5 段确定性规则引擎(常模比对 → 指标解读 → 五维归因 → 主因链 → 被证伪)
+ * + 1 段 AI 叙述({@code AttributionNarrativeService},失败规则兜底)。
+ * 合规红线 RL01-RL10 由规则层硬注入,AI 不负责合规。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+public interface AttributionAnalysisService {
+
+    /**
+     * 核心入口:档案 + 确认问卷 + 检测数据 + 常模 → 十节归因报告。
+     *
+     * @param input 归因入参
+     * @return 归因报告视图(status=READY)
+     */
+    AttributionReportVO analyze(AttributionInput input);
+
+    /**
+     * 取报告(含十节结构)。
+     *
+     * @param attributionId 归因报告ID
+     * @return 报告视图;不存在返回 null
+     */
+    AttributionReportVO getReport(Long attributionId);
+
+    /**
+     * ④调整后重算(B3 回填后调用;作废 ⑤ 由 AttributionPlanService 处理)。
+     *
+     * @param attributionId 归因报告ID
+     * @param fieldDeltas   字段增量(回填后的中间态增量)
+     * @return 重算后的报告视图;不存在返回 null
+     */
+    AttributionReportVO recompute(Long attributionId, Map<String, Object> fieldDeltas);
+}

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AttributionPlanService.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.FamilyPlanVO;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 全家干预方案 7 章生成服务(B4)。
+ *
+ * <p>归因结论(CONFIRMED)→ 七章 planJson,存 {@code health_plans}(plan_type=family_attribution_plan)。
+ * E6 人范围 = 妈妈/女儿/儿子;爸爸链外。E7 品牌级(Enagic/PHGG/益生菌株名)+ RL05 不宣称治疗。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B4)
+ */
+public interface AttributionPlanService {
+
+    /**
+     * 归因结论(attributionId)→ planJson(7 章结构),存 health_plans。
+     *
+     * @param attributionId 归因报告ID
+     * @param userId        用户ID
+     * @return 方案视图;报告不存在/状态未确认返回 null
+     */
+    FamilyPlanVO generateFamilyPlan(Long attributionId, Long userId);
+
+    /**
+     * 按层级提取动作表,供 ⑥ 筛选确认成任务(阶段三复用)。
+     *
+     * @param planJson 7 章方案 JSON
+     * @param level    层级(P0/P1/daily/P2),null=全部
+     * @return 动作条目(member/level/action/product/how/reviewHook)
+     */
+    List<Map<String, Object>> extractActionTable(String planJson, String level);
+}

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ConditionEvaluatorService.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.util.Map;
+
+/**
+ * 家庭归因确认问卷条件求值服务(G2)。
+ *
+ * <p>条件表达式为纯 JSON 约定(见规格 §3.2 / A2),示例:
+ * <pre>
+ * { "op": "==",   "var": "l.reuteriAbnormal", "val": true }
+ * { "op": "anyOf", "items": [ { "op": "==", "var": "ironLow", "val": true },
+ *                             { "op": "==", "var": "zincDeficient", "val": true } ] }
+ * </pre>
+ *
+ * <p>求值语义:
+ * <ul>
+ *   <li>condition == null → 恒真(无条件恒发)</li>
+ *   <li>未知 var / 未知 op / 非法结构 → 保守 false(避免误发敏感题)</li>
+ *   <li>缺省组合语义为 AND({"items":[...]} 或 allOf),OR 用 anyOf/or</li>
+ * </ul>
+ */
+public interface ConditionEvaluatorService {
+
+    /**
+     * 求值单条条件表达式。
+     *
+     * @param condition 条件表达式(可为 null,表示恒真)
+     * @param context   上下文键值(对象档案 + 归因中间态 + 已作答)
+     * @return 条件是否成立
+     */
+    boolean evaluate(JsonNode condition, Map<String, Object> context);
+
+    /**
+     * 组装条件求值上下文:role / ageBand / gender / 归因中间态等。
+     *
+     * @param familyId     家庭ID(可空)
+     * @param memberId     家庭成员ID(可空)
+     * @param attributionId 归因报告ID(可空;阶段二 B1 产出 intermediate_state)
+     * @return 上下文键值(LinkedHashMap,键与 §3.2 中间态键一致)
+     */
+    Map<String, Object> buildContext(Long familyId, Long memberId, Long attributionId);
+}

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SurveyService.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.entity.SurveyTemplate;
 import com.etotem.cfc.entity.SurveyRecord;
 import java.util.Date;
 import java.util.List;
+import java.util.Map;
 
 public interface SurveyService {
     SurveyTemplate saveTemplate(SurveyTemplate template, Long adminId);
@@ -13,6 +14,20 @@ public interface SurveyService {
     List<SurveyTemplate> getActiveTemplatesByDimension(String dimension);
     Object getSurveyStatus(Long userId, Long memberId);
     SurveyRecord submitSurvey(Long userId, Long memberId, Long templateId, String answersJson, Date periodStart, Date periodEnd);
+
+    /**
+     * 提交调研答案(B2 扩展:携带 riskHits 命中标记,E4 弱提示不阻断)。
+     *
+     * <p>riskHits 为确认问卷中 Q11/Q12/Q13 高风险题的命中标记(如
+     * [{@code questionId:"Q11d", code:"selfHarmIdea", value:"有", riskLevel:"high"}]),
+     * 会并入 answersJson 的 {@code _attributionRiskHits} 键落库,供 B1 归因红线检测。
+     *
+     * @return 默认实现不存储 riskHits(向后兼容);{@code SurveyServiceImpl} 覆盖为合并落库
+     */
+    default SurveyRecord submitSurvey(Long userId, Long memberId, Long templateId, String answersJson,
+                                      Date periodStart, Date periodEnd, List<Map<String, Object>> riskHits) {
+        return submitSurvey(userId, memberId, templateId, answersJson, periodStart, periodEnd);
+    }
     List<SurveyRecord> getSurveyHistory(Long userId, Long memberId);
     String aiGenerateQuestions(String dimension, String topic);
 }

+ 194 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/AttributionNarrativeService.java

@@ -0,0 +1,194 @@
+package com.etotem.cfc.service.attribution;
+
+import com.etotem.cfc.config.PromptConfig;
+import com.etotem.cfc.service.AiGateway;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 归因 Pipeline 段 ⑥:AI 叙述服务(复用 AiGateway,失败规则兜底,永不阻断)。
+ *
+ * <p>只负责叙述(结论先行 / 逐人润色 / 教练提问序列 / 一句话收尾),
+ * <b>不负责合规</b>——红线 RL01-RL10 由规则层硬注入(§1.3 合规即服务)。
+ * AI 关闭/熔断/超时 → 规则模板兜底。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Component
+public class AttributionNarrativeService {
+
+    private static final Logger log = LoggerFactory.getLogger(AttributionNarrativeService.class);
+
+    @Resource
+    private AiGateway aiGateway;
+
+    @Resource
+    private PromptConfig promptConfig;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    /** 归因教练 prompt(七层 Socratic:澄清→探因→假设→证据→替代→行动→复盘) */
+    public static final String DEFAULT_COACH_PROMPT =
+            "你是家庭归因分析教练,遵循七层 Socratic 对话(澄清→探因→假设→证据→替代→行动→复盘)。"
+                    + "请基于下面的归因结构给出:① 结论先行(一句话,如「四条表象,两个主因维」);"
+                    + "② 逐人归因的叙述润色(不改事实);③ 教练提问序列(最多 5 个问题,按七层推进);"
+                    + "④ 一句话收尾。禁止自行添加疗效/诊断承诺,禁止使用「多动症/抑郁/天才/学霸」等定性词。";
+
+    /**
+     * 叙述主入口。
+     *
+     * @param context 归因结构化上下文(attributions/familyMap/links/falsified/readings,已序列化)
+     * @param userId  用户ID(AI 通道身份)
+     * @return 叙述片段:conclusionHeadline / perMember(JSON) / coachQuestions(JSON) / closingLine
+     */
+    public Map<String, Object> narrate(Map<String, Object> context, Long userId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        String prompt = buildPrompt(context);
+        String aiText = callAi(prompt, userId);
+        if (aiText != null && !aiText.trim().isEmpty()) {
+            result.put("conclusionHeadline", extractField(aiText, "conclusionHeadline",
+                    fallbackHeadline(context)));
+            result.put("closingLine", extractField(aiText, "closingLine", fallbackClosing(context)));
+            result.put("coachQuestions", parseQuestions(aiText, context));
+            result.put("narratedBy", "ai");
+        } else {
+            result.put("conclusionHeadline", fallbackHeadline(context));
+            result.put("closingLine", fallbackClosing(context));
+            result.put("coachQuestions", fallbackQuestions(context));
+            result.put("narratedBy", "rule");
+        }
+        return result;
+    }
+
+    private String callAi(String prompt, Long userId) {
+        try {
+            Map<String, Object> inputs = new LinkedHashMap<>();
+            inputs.put("scene", "family_attribution");
+            Map<String, Object> resp = aiGateway.chatForAnalysis(prompt, userId, null, inputs);
+            if (resp == null) {
+                return null;
+            }
+            Object ans = resp.get("answer");
+            return ans == null ? null : String.valueOf(ans);
+        } catch (Exception e) {
+            log.warn("归因叙述 AI 调用失败,走规则兜底: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private String buildPrompt(Map<String, Object> context) {
+        String template = promptConfig.getAttributionCoach();
+        if (template == null || template.trim().isEmpty()) {
+            template = DEFAULT_COACH_PROMPT;
+        }
+        try {
+            String json = objectMapper.writeValueAsString(context);
+            return template + "\n\n【归因结构】\n" + json;
+        } catch (Exception e) {
+            return template;
+        }
+    }
+
+    // ==================== 规则兜底 ====================
+
+    @SuppressWarnings("unchecked")
+    private String fallbackHeadline(Map<String, Object> context) {
+        List<Map<String, Object>> attributions = (List<Map<String, Object>>) context.get("attributions");
+        Map<String, Object> familyMap = (Map<String, Object>) context.get("familyMap");
+        List<String> main = familyMap != null && familyMap.get("mainDimensions") instanceof List
+                ? (List<String>) familyMap.get("mainDimensions") : new ArrayList<>();
+        int abnormal = 0;
+        if (attributions != null) {
+            for (Map<String, Object> a : attributions) {
+                Object n = a.get("abnormalCount");
+                if (n instanceof Number) {
+                    abnormal += ((Number) n).intValue();
+                }
+            }
+        }
+        String dims = main.isEmpty() ? "暂不明确(待补)" : String.join("、", main);
+        return abnormal + " 项表象," + dims + " 为主因维";
+    }
+
+    @SuppressWarnings("unchecked")
+    private String fallbackClosing(Map<String, Object> context) {
+        List<String> main = (List<String>) ((Map<String, Object>) context.get("familyMap")).get("mainDimensions");
+        String dims = main == null || main.isEmpty() ? "暂不明确" : String.join("、", main);
+        return "先共享基座后精准;主因维集中在 " + dims + ",12 周可复盘检验。";
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> fallbackQuestions(Map<String, Object> context) {
+        List<String> q = new ArrayList<>();
+        List<Map<String, Object>> attributions = (List<Map<String, Object>>) context.get("attributions");
+        if (attributions != null) {
+            for (Map<String, Object> a : attributions) {
+                if (q.size() >= 5) {
+                    break;
+                }
+                String role = a.get("roleLabel") == null ? "成员" : String.valueOf(a.get("roleLabel"));
+                String essence = a.get("essence") == null ? "" : String.valueOf(a.get("essence"));
+                if (!essence.trim().isEmpty()) {
+                    q.add("「" + role + "」" + essence + "——最近两周的作息与饮食,能描述一下吗?");
+                }
+            }
+        }
+        if (q.isEmpty()) {
+            q.add("家庭厨房与饮水目前是怎样的?一周外食/夜宵几次?");
+            q.add("孩子近两周睡眠与排便是否规律?");
+            q.add("有没有已经做过的查血/评估?结果如何?");
+        }
+        return q;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> parseQuestions(String aiText, Map<String, Object> context) {
+        // 优先按行解析 AI 回答中的「?」问句,最多 5 个;失败回退规则
+        List<String> out = new ArrayList<>();
+        String[] lines = aiText.split("\\r?\\n");
+        for (String line : lines) {
+            String t = line.trim();
+            if (t.contains("?") || t.contains("?")) {
+                if (t.length() > 4 && t.length() < 120) {
+                    out.add(t);
+                }
+            }
+            if (out.size() >= 5) {
+                break;
+            }
+        }
+        return out.isEmpty() ? fallbackQuestions(context) : out;
+    }
+
+    private String extractField(String aiText, String key, String fallback) {
+        // 宽松提取:优先从 JSON 标记中提取,否则整段作为 head/closing
+        try {
+            if (aiText.contains("{")) {
+                int start = aiText.indexOf('{');
+                int end = aiText.lastIndexOf('}');
+                if (end > start) {
+                    com.fasterxml.jackson.databind.JsonNode node = objectMapper.readTree(aiText.substring(start, end + 1));
+                    com.fasterxml.jackson.databind.JsonNode v = node.get(key);
+                    if (v != null && v.isTextual() && !v.asText().trim().isEmpty()) {
+                        return v.asText();
+                    }
+                }
+            }
+        } catch (Exception ignored) {
+            // 非 JSON 结构,走 fallback 或整段
+        }
+        String firstLine = aiText.split("\\r?\\n")[0].trim();
+        return firstLine.isEmpty() ? fallback : firstLine;
+    }
+}

+ 305 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/AttributionReportBuilder.java

@@ -0,0 +1,305 @@
+package com.etotem.cfc.service.attribution;
+
+import com.etotem.cfc.constant.AttributionRedlines;
+import com.etotem.cfc.dto.AttributionInput;
+import com.etotem.cfc.entity.AttributionReport;
+import com.etotem.cfc.service.attribution.model.CauseLink;
+import com.etotem.cfc.service.attribution.model.CompareResult;
+import com.etotem.cfc.service.attribution.model.FalsifiedItem;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
+import com.etotem.cfc.service.attribution.model.MemberProfile;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 归因报告 JSON 构建器(B1,纯规则、无 DB 依赖,可单测)。
+ *
+ * <p><b>合规即服务</b>:红线 RL01-RL10 由本规则层硬注入,AI 只产叙述,不负责合规
+ * (§1.3)。报告阶段注入 RL01-RL08,RL09/RL10 属方案阶段⑤)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Component
+public class AttributionReportBuilder {
+
+    private static final Logger log = LoggerFactory.getLogger(AttributionReportBuilder.class);
+
+    /** 报告十节标题(§3.5) */
+    public static final String[] SECTION_TITLES = {
+            "一、结论先行", "二、四人数据速览", "三、逐人归因", "四、家庭系统归因", "五、应对方案",
+            "六、12 周可检验目标", "七、教练提问序列", "八、问诊清单", "九、红线与转介", "十、一句话收尾"
+    };
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    // ==================== 红线硬注入(规则层,AI 不可越权) ====================
+
+    /** 计算红线命中编号 */
+    public List<String> computeRedlineHits(FiveDimAttributionEngine.FiveDimResult five,
+                                    Map<String, Object> state, List<MemberProfile> members) {
+        Set<String> hits = new LinkedHashSet<>();
+        // RL01 测评≠诊断:归因报告恒成立
+        hits.add("RL01");
+        // RL02 情绪红线:情绪维短板 或 确认问卷风险标记命中
+        boolean moodRisk = truthy(state.get("riskMoodLow")) || truthy(state.get("riskInsomnia"))
+                || truthy(state.get("riskSchoolRefusal")) || truthy(state.get("riskSelfHarm"));
+        for (MemberAttribution a : five.attributions) {
+            if (a.getWeakDimensions().contains("心")) {
+                moodRisk = true;
+            }
+        }
+        if (moodRisk) {
+            hits.add("RL02");
+        }
+        // RL03 儿童补剂红线:有未成年成员
+        for (MemberProfile m : members) {
+            if (Boolean.TRUE.equals(m.getMinor())) {
+                hits.add("RL03");
+                break;
+            }
+        }
+        // RL04 糖尿病红线
+        if (truthy(state.get("hasDiabetes"))) {
+            hits.add("RL04");
+        }
+        // RL06 标签红线:有未成年成员(儿童结论不贴标签)
+        for (MemberProfile m : members) {
+            if (Boolean.TRUE.equals(m.getMinor())) {
+                hits.add("RL06");
+                break;
+            }
+        }
+        // RL07 先医后补:存在待补/异常读数(补剂动作后置)
+        for (MemberAttribution a : five.attributions) {
+            if (a.getAbnormalCount() > 0) {
+                hits.add("RL07");
+                break;
+            }
+        }
+        return new ArrayList<>(hits);
+    }
+
+    // ==================== 报告 JSON ====================
+
+    public String buildReportJson(FiveDimAttributionEngine.FiveDimResult five, List<CauseLink> links,
+                                   List<FalsifiedItem> falsified, CompareResult compare,
+                                   Map<String, Object> narrative, List<String> redlineHits,
+                                   AttributionInput input) {
+        ObjectNode root = objectMapper.createObjectNode();
+        root.put("schemaVersion", "1.0");
+        root.put("target", "family_attribution");
+        root.put("generatedAt", new Date().toInstant().toString());
+        root.put("sectionCount", SECTION_TITLES.length);
+        ArrayNode titles = root.putArray("sectionTitles");
+        for (String t : SECTION_TITLES) {
+            titles.add(t);
+        }
+
+        ObjectNode sections = root.putObject("sections");
+
+        // 一、结论先行
+        ObjectNode conclusion = sections.putObject("conclusion");
+        conclusion.put("title", SECTION_TITLES[0]);
+        conclusion.put("headline", str(narrative.get("conclusionHeadline"), "本轮归因以规则结论为准"));
+        conclusion.put("narratedBy", str(narrative.get("narratedBy"), "rule"));
+        ArrayNode mainDims = conclusion.putArray("mainDimensions");
+        Object mainDimsObj = five.familyMap.get("mainDimensions");
+        if (mainDimsObj instanceof List) {
+            for (Object o : (List<?>) mainDimsObj) {
+                mainDims.add(String.valueOf(o));
+            }
+        }
+
+        // 二、四人数据速览
+        ArrayNode overview = sections.putArray("overview");
+        int limit = 0;
+        for (MemberAttribution a : five.attributions) {
+            for (IndicatorReading r : a.getReadings()) {
+                if (limit++ >= 60) {
+                    break;
+                }
+                ObjectNode row = overview.addObject();
+                row.put("memberId", a.getMemberId());
+                row.put("memberName", a.getName());
+                row.put("roleLabel", a.getRoleLabel());
+                row.put("indicator", r.getDisplayName() == null ? r.getCode() : r.getDisplayName());
+                row.put("value", r.getValue());
+                row.put("unit", r.getUnit());
+                row.put("percentile", r.getPercentile());
+                row.put("status", r.getStatus());
+                row.put("normSource", r.getNormSource());
+                row.put("meaning", r.getMeaning() == null ? "" : r.getMeaning());
+            }
+        }
+
+        // 三、逐人归因
+        ArrayNode perMember = sections.putArray("perMember");
+        for (MemberAttribution a : five.attributions) {
+            ObjectNode m = perMember.addObject();
+            m.put("memberId", a.getMemberId());
+            m.put("name", a.getName());
+            m.put("role", a.getRole());
+            m.put("roleLabel", a.getRoleLabel());
+            m.put("surface", a.getSurface());
+            m.put("essence", a.getEssence());
+            ObjectNode dims = m.putObject("fiveDim");
+            for (Map.Entry<String, Double> e : a.getFiveDim().entrySet()) {
+                dims.put(e.getKey(), e.getValue());
+            }
+            ArrayNode weak = m.putArray("weakDimensions");
+            for (String w : a.getWeakDimensions()) {
+                weak.add(w);
+            }
+            ArrayNode actions = m.putArray("actions");
+            for (String act : a.getActions()) {
+                actions.add(act);
+            }
+            m.put("abnormalCount", a.getAbnormalCount());
+            m.put("retractableCount", a.getRetractableCount());
+        }
+
+        // 四、家庭系统归因(五维地图 + 主因链 + 被证伪项)
+        ObjectNode family = sections.putObject("familyMap");
+        for (Map.Entry<String, Object> e : five.familyMap.entrySet()) {
+            Object v = e.getValue();
+            if (v instanceof Number) {
+                family.put(e.getKey(), ((Number) v).doubleValue());
+            }
+        }
+        ArrayNode mainDimsArr = family.putArray("mainDimensions");
+        if (mainDimsObj instanceof List) {
+            for (Object o : (List<?>) mainDimsObj) {
+                mainDimsArr.add(String.valueOf(o));
+            }
+        }
+        ArrayNode chain = sections.putArray("mainCauseChain");
+        for (CauseLink l : links) {
+            ObjectNode c = chain.addObject();
+            c.put("from", l.getFrom());
+            c.put("to", l.getTo());
+            c.put("dimension", l.getDimension());
+            c.put("evidence", l.getEvidence());
+            c.put("note", l.getNote());
+        }
+        ArrayNode falsifiedArr = sections.putArray("falsified");
+        for (FalsifiedItem f : falsified) {
+            ObjectNode f2 = falsifiedArr.addObject();
+            f2.put("item", f.getItem());
+            f2.put("reason", f.getReason());
+            f2.put("evidence", f.getEvidence());
+            f2.put("action", f.getAction());
+        }
+
+        // 五、应对方案(三条通道)
+        ArrayNode channels = sections.putArray("channels");
+        channels.addObject().put("channel", "共享基座通道").put("minStep", "全屋饮水/厨房基座统一").put("owner", "妈妈").put("reviewHook", "一周执行率自评");
+        channels.addObject().put("channel", "查血与评估通道").put("minStep", "先查血/评估,不自行补剂").put("owner", "妈妈").put("reviewHook", "4-8 周复查");
+        channels.addObject().put("channel", "日常节律通道").put("minStep", "作息/排便/情绪记录").put("owner", "全家").put("reviewHook", "2 周自评");
+
+        // 六、12 周可检验目标
+        ArrayNode goals = sections.putArray("goals");
+        int goalLimit = 0;
+        for (MemberAttribution a : five.attributions) {
+            for (IndicatorReading r : a.getReadings()) {
+                if (!r.isAbnormal() || goalLimit++ >= 8) {
+                    continue;
+                }
+                ObjectNode g = goals.addObject();
+                g.put("indicator", (r.getDisplayName() == null ? r.getCode() : r.getDisplayName())
+                        + "(" + a.getRoleLabel() + ")");
+                g.put("now", r.getValue() + (r.getPercentile() == null ? "" : " " + r.getPercentile()));
+                if (IndicatorReading.STATUS_LOW.equals(r.getStatus())) {
+                    g.put("target", "12 周内回升至 P25 以上(缺常模时以复测结果为准)");
+                } else {
+                    g.put("target", "12 周内回落至 P75 以下(缺常模时以复测结果为准)");
+                }
+                g.put("owner", a.getRoleLabel());
+                g.put("reviewHook", "12 周复测");
+            }
+        }
+
+        // 七、教练提问序列
+        ArrayNode coach = sections.putArray("coachQuestions");
+        Object coachObj = narrative.get("coachQuestions");
+        if (coachObj instanceof List) {
+            for (Object o : (List<?>) coachObj) {
+                coach.add(String.valueOf(o));
+            }
+        }
+
+        // 八、问诊清单
+        ObjectNode questionnaire = sections.putObject("questionnaire");
+        questionnaire.put("title", SECTION_TITLES[7]);
+        questionnaire.put("templateTarget", "family_attribution");
+        questionnaire.put("confirmSurveyRecordId", input.getConfirmSurveyRecordId());
+        questionnaire.put("collected", input.getConfirmSurveyRecordId() != null);
+
+        // 九、红线与转介(RL01-RL08 规则层硬注入;RL09/RL10 属方案阶段⑤)
+        ArrayNode redlines = sections.putArray("redlines");
+        for (AttributionRedlines.Redline r : AttributionRedlines.all()) {
+            if (r.code.compareTo("RL09") >= 0) {
+                continue;
+            }
+            ObjectNode rn = redlines.addObject();
+            rn.put("code", r.code);
+            rn.put("level", r.level.name());
+            rn.put("title", r.title);
+            rn.put("message", r.message);
+            rn.put("scope", r.scope);
+            rn.put("hit", redlineHits.contains(r.code));
+        }
+        ObjectNode referral = sections.putObject("referral");
+        boolean rl02 = redlineHits.contains("RL02");
+        referral.put("suggested", rl02);
+        referral.put("advice", rl02 ? "建议专业评估 + 转介信息露出 + 家长确认(E4 弱提示,流程不阻断)" : "暂无");
+        referral.put("blocking", false);
+
+        // 十、一句话收尾
+        ObjectNode closing = sections.putObject("closing");
+        closing.put("title", SECTION_TITLES[9]);
+        closing.put("line", str(narrative.get("closingLine"), "先共享基座后精准,12 周可复盘检验。"));
+
+        // 中间态快照(供 B2/B3 引用;与中间态列一致)
+        root.set("intermediateState", objectMapper.valueToTree(compare.getIntermediateState()));
+        root.put("status", AttributionReport.STATUS_READY);
+
+        try {
+            return objectMapper.writeValueAsString(root);
+        } catch (Exception e) {
+            log.error("归因报告 JSON 序列化失败", e);
+            return "{}";
+        }
+    }
+
+    private static boolean truthy(Object v) {
+        if (v == null) {
+            return false;
+        }
+        if (v instanceof Boolean) {
+            return (Boolean) v;
+        }
+        return Boolean.parseBoolean(String.valueOf(v));
+    }
+
+    private static String str(Object v, String fallback) {
+        if (v == null) {
+            return fallback;
+        }
+        String s = String.valueOf(v);
+        return s.trim().isEmpty() ? fallback : s;
+    }
+}

+ 117 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/FalsificationEngine.java

@@ -0,0 +1,117 @@
+package com.etotem.cfc.service.attribution;
+
+import com.etotem.cfc.service.attribution.model.FalsifiedItem;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 归因 Pipeline 段 ⑤:被证伪项引擎(确定性、纯 Java 规则)。
+ *
+ * <p>「证伪」= 表面像因但经规则判定不成立 / 需人工确认 / 可撤回的归因:
+ * <ul>
+ *   <li>可撤回项(罗伊氏乳杆菌补充剂印记)→ 撤除后复测确认</li>
+ *   <li>同伴依恋项判定无效(问卷风险标记 riskPeerInvalid)→ 需人工确认</li>
+ *   <li>情绪高分被「谈心替代就医」证伪(riskMoodLow 等命中)→ 角色是识别+转介</li>
+ *   <li>常模缺失项 → 不下结论,补齐后再定</li>
+ * </ul>
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Component
+public class FalsificationEngine {
+
+    /**
+     * 被证伪项主入口(纯规则)。
+     *
+     * @param attributions 逐人五维归因
+     * @param readings     常模比对读数
+     * @param flags        确认问卷风险标记 / 附加标志
+     * @return 被证伪项列表
+     */
+    public List<FalsifiedItem> falsify(List<MemberAttribution> attributions, List<IndicatorReading> readings,
+                                       Map<String, Object> flags) {
+        Map<String, Object> f = flags == null ? new LinkedHashMap<>() : flags;
+        List<FalsifiedItem> out = new ArrayList<>();
+
+        // 1) 可撤回项(补充剂印记)
+        List<String> retractable = new ArrayList<>();
+        for (IndicatorReading r : readings) {
+            if (r.isRetractable()) {
+                retractable.add((r.getMemberName() == null ? "" : r.getMemberName()) + " "
+                        + (r.getDisplayName() == null ? r.getCode() : r.getDisplayName()));
+            }
+        }
+        if (!retractable.isEmpty()) {
+            out.add(new FalsifiedItem("补充剂印记类异常(罗伊氏乳杆菌)",
+                    "含益生菌补充剂痕迹,属可撤回项而非结构性问题(无论偏高/偏低,均先撤除后复测)",
+                    String.join(";", retractable),
+                    "先撤除补充剂,间隔后复测菌群再确认"));
+        }
+
+        // 2) 同伴依恋项判定无效
+        if (truthy(f.get("riskPeerInvalid")) || truthy(f.get("riskBullying"))) {
+            out.add(new FalsifiedItem("同伴依恋项判定无效",
+                    "同伴亲近分低缺少问卷佐证(或存在排挤/欺凌信号),不可直接归因于「不会交朋友」",
+                    "确认问卷 Q12 命中风险标记",
+                    "交由家长确认后,按「识别 + 转介」处理,不下定性标签(RL06)"));
+        }
+
+        // 3) 情绪项不能靠「谈心」处理
+        if (truthy(f.get("riskMoodLow")) || truthy(f.get("riskInsomnia"))
+                || truthy(f.get("riskSchoolRefusal")) || truthy(f.get("riskSelfHarm"))) {
+            out.add(new FalsifiedItem("「多聊聊就能好」假设被证伪",
+                    "情绪/睡眠/拒学/自伤信号已达专业评估量级,不是谈心能处理的",
+                    "确认问卷 Q11 命中风险标记",
+                    "标记建议专业评估 + 露出转介信息 + 家长确认(E4 弱提示,流程不阻断)"));
+        }
+
+        // 4) 常模缺失项不下结论
+        int pending = 0;
+        List<String> pendingNames = new ArrayList<>();
+        for (IndicatorReading r : readings) {
+            if (IndicatorReading.STATUS_PENDING.equals(r.getStatus())) {
+                pending++;
+                if (pendingNames.size() < 5) {
+                    pendingNames.add(r.getDisplayName() == null ? r.getCode() : r.getDisplayName());
+                }
+            }
+        }
+        if (pending > 0) {
+            out.add(new FalsifiedItem("常模缺失项归因无效",
+                    "无常模可比对,不猜值、不下结论",
+                    pending + " 项待补:" + String.join(";", pendingNames),
+                    "补齐常模或复测数据后,再纳入主因链"));
+        }
+
+        // 5) 无人异常 → 明确不下结论
+        if (attributions != null) {
+            int abnormal = 0;
+            for (MemberAttribution a : attributions) {
+                abnormal += a.getAbnormalCount();
+            }
+            if (abnormal == 0) {
+                out.add(new FalsifiedItem("「本次全正常」不等于「没问题」",
+                        "无异常读数时归因引擎不下结论,避免漏判",
+                        "异常读数 0 条",
+                        "按「待补」项列出,复测/补查后再评估"));
+            }
+        }
+        return out;
+    }
+
+    private static boolean truthy(Object v) {
+        if (v == null) {
+            return false;
+        }
+        if (v instanceof Boolean) {
+            return (Boolean) v;
+        }
+        return Boolean.parseBoolean(String.valueOf(v));
+    }
+}

+ 137 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/FiveDimAttributionEngine.java

@@ -0,0 +1,137 @@
+package com.etotem.cfc.service.attribution;
+
+import com.etotem.cfc.constant.AttributionWeights;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
+import com.etotem.cfc.service.attribution.model.MemberProfile;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 归因 Pipeline 段 ③:五维归因引擎(确定性、纯 Java 规则)。
+ *
+ * <p>以「基础分 − 异常扣分 × 维度权重」计算每人 0-100 教练相对刻度;
+ * 短板(&lt;60)为候选主因维;家庭五维 = 逐人均值,主因维 = 家庭均值最低维。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Component
+public class FiveDimAttributionEngine {
+
+    /**
+     * 五维归因主入口(纯规则)。
+     *
+     * @param attributions 解读段产出的逐人结果(原地补五维分)
+     * @param members      成员档案
+     * @return 五维归因结果(逐人 + 家庭地图)
+     */
+    public FiveDimResult attribute(List<MemberAttribution> attributions, List<MemberProfile> members) {
+        for (MemberAttribution attr : attributions) {
+            Map<String, Double> scores = new LinkedHashMap<>();
+            for (String dim : AttributionWeights.DIMENSIONS) {
+                double penalty = 0;
+                List<String> evidences = attr.getDimensionEvidence().get(dim);
+                if (evidences != null) {
+                    for (IndicatorReading r : attr.getReadings()) {
+                        if (!r.isAbnormal()) {
+                            continue;
+                        }
+                        String d = AttributionWeights.dimensionOf(
+                                (r.getCode() == null ? "" : r.getCode()) + " " + (r.getDisplayName() == null ? "" : r.getDisplayName()));
+                        if (!d.equals(dim)) {
+                            continue;
+                        }
+                        if (IndicatorReading.BAND_VERY_LOW.equals(r.getBand())
+                                || IndicatorReading.BAND_VERY_HIGH.equals(r.getBand())) {
+                            penalty += AttributionWeights.PENALTY_VERY;
+                        } else {
+                            penalty += AttributionWeights.PENALTY_LOW;
+                        }
+                    }
+                }
+                double base = AttributionWeights.BASE_SCORE.getOrDefault(dim, 70.0);
+                double weight = AttributionWeights.DIM_WEIGHT.getOrDefault(dim, 1.0);
+                double score = base - penalty * weight;
+                scores.put(dim, clamp(score));
+            }
+            attr.setFiveDim(scores);
+            attr.setWeakDimensions(pickWeak(scores));
+        }
+        return new FiveDimResult(attributions, buildFamilyMap(attributions));
+    }
+
+    private static List<String> pickWeak(Map<String, Double> scores) {
+        List<Map.Entry<String, Double>> sorted = new ArrayList<>(scores.entrySet());
+        sorted.sort(Comparator.comparingDouble(Map.Entry::getValue));
+        List<String> weak = new ArrayList<>();
+        for (Map.Entry<String, Double> e : sorted) {
+            if (e.getValue() < AttributionWeights.WEAK_THRESHOLD && weak.size() < AttributionWeights.MAX_WEAK) {
+                weak.add(e.getKey());
+            }
+        }
+        return weak;
+    }
+
+    private Map<String, Object> buildFamilyMap(List<MemberAttribution> attributions) {
+        Map<String, Object> familyMap = new LinkedHashMap<>();
+        Map<String, Double> sum = new LinkedHashMap<>();
+        for (String dim : AttributionWeights.DIMENSIONS) {
+            sum.put(dim, 0.0);
+        }
+        if (attributions == null || attributions.isEmpty()) {
+            for (String dim : AttributionWeights.DIMENSIONS) {
+                familyMap.put(dim, 0.0);
+            }
+            familyMap.put("mainDimensions", new ArrayList<String>());
+            return familyMap;
+        }
+        for (MemberAttribution attr : attributions) {
+            for (String dim : AttributionWeights.DIMENSIONS) {
+                Double s = attr.getFiveDim().get(dim);
+                sum.put(dim, sum.get(dim) + (s == null ? 0.0 : s));
+            }
+        }
+        List<Map.Entry<String, Double>> avg = new ArrayList<>();
+        for (String dim : AttributionWeights.DIMENSIONS) {
+            double v = sum.get(dim) / attributions.size();
+            familyMap.put(dim, round1(v));
+            avg.add(new java.util.AbstractMap.SimpleEntry<>(dim, v));
+        }
+        avg.sort(Comparator.comparingDouble(Map.Entry::getValue));
+        List<String> mainDims = new ArrayList<>();
+        for (Map.Entry<String, Double> e : avg) {
+            if (e.getValue() < AttributionWeights.WEAK_THRESHOLD && mainDims.size() < AttributionWeights.MAX_WEAK) {
+                mainDims.add(e.getKey());
+            }
+        }
+        if (mainDims.isEmpty()) {
+            mainDims.add(avg.get(0).getKey());
+        }
+        familyMap.put("mainDimensions", mainDims);
+        return familyMap;
+    }
+
+    private static double clamp(double v) {
+        return Math.max(0, Math.min(100, v));
+    }
+
+    private static double round1(double v) {
+        return Math.round(v * 10.0) / 10.0;
+    }
+
+    /** 五维归因输出(逐人 + 家庭地图) */
+    public static class FiveDimResult {
+        public final List<MemberAttribution> attributions;
+        public final Map<String, Object> familyMap;
+
+        public FiveDimResult(List<MemberAttribution> attributions, Map<String, Object> familyMap) {
+            this.attributions = attributions;
+            this.familyMap = familyMap;
+        }
+    }
+}

+ 167 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/IndicatorInterpretEngine.java

@@ -0,0 +1,167 @@
+package com.etotem.cfc.service.attribution;
+
+import com.etotem.cfc.constant.AttributionWeights;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
+import com.etotem.cfc.service.attribution.model.MemberProfile;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 归因 Pipeline 段 ②:指标解读引擎(确定性、纯 Java 规则)。
+ *
+ * <p>把「常模比对后的读数」翻译为五维含义:
+ * <ul>
+ *   <li>异常项 → 归属维度 + 干预含义</li>
+ *   <li>可撤回项(补充剂印记等)→ 标记 retractable,不作为结构性问题处理</li>
+ *   <li>生成「表象 / 本质 / 应对方向」初稿(AI 只做润色,不改事实)</li>
+ * </ul>
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Component
+public class IndicatorInterpretEngine {
+
+    /**
+     * 解读主入口(纯规则)。
+     *
+     * @param readings 常模比对后的读数
+     * @param members  成员档案
+     * @return 逐人解读结果
+     */
+    public List<MemberAttribution> interpret(List<IndicatorReading> readings, List<MemberProfile> members) {
+        Map<Long, MemberAttribution> byMember = new LinkedHashMap<>();
+        if (members != null) {
+            for (MemberProfile m : members) {
+                MemberAttribution attr = new MemberAttribution();
+                attr.setMemberId(m.getMemberId());
+                attr.setName(m.getName());
+                attr.setRole(m.getRole());
+                attr.setRoleLabel(m.getRoleLabel());
+                byMember.put(m.getMemberId(), attr);
+            }
+        }
+        if (readings != null) {
+            for (IndicatorReading r : readings) {
+                MemberAttribution attr = byMember.get(r.getMemberId());
+                if (attr == null) {
+                    attr = new MemberAttribution();
+                    attr.setMemberId(r.getMemberId());
+                    attr.setName(r.getMemberName());
+                    attr.setRole("unknown");
+                    attr.setRoleLabel("成员");
+                    byMember.put(r.getMemberId(), attr);
+                }
+                attr.getReadings().add(r);
+                if (r.isAbnormal()) {
+                    attr.setAbnormalCount(attr.getAbnormalCount() + 1);
+                    attachEvidence(attr, r);
+                    if (isRetractable(r)) {
+                        r.setRetractable(true);
+                        attr.setRetractableCount(attr.getRetractableCount() + 1);
+                    }
+                }
+            }
+        }
+        for (MemberAttribution attr : byMember.values()) {
+            compose(attr);
+        }
+        return new ArrayList<>(byMember.values());
+    }
+
+    /** 补充剂印记类异常 → 可撤回(罗伊氏乳杆菌异常高等) */
+    boolean isRetractable(IndicatorReading r) {
+        String text = ((r.getCode() == null ? "" : r.getCode()) + " " + (r.getDisplayName() == null ? "" : r.getDisplayName()))
+                .toLowerCase();
+        if (text.contains("reuteri") || text.contains("罗伊氏")) {
+            return true;
+        }
+        return false;
+    }
+
+    private void attachEvidence(MemberAttribution attr, IndicatorReading r) {
+        String dim = AttributionWeights.dimensionOf((r.getCode() == null ? "" : r.getCode())
+                + " " + (r.getDisplayName() == null ? "" : r.getDisplayName()));
+        String evidence = (r.getDisplayName() == null ? r.getCode() : r.getDisplayName())
+                + " " + (r.getPercentile() == null ? "" : r.getPercentile())
+                + "(" + r.getStatus() + ")";
+        attr.getDimensionEvidence().computeIfAbsent(dim, k -> new ArrayList<>()).add(evidence);
+        if (r.getMeaning() == null || r.getMeaning().trim().isEmpty()) {
+            r.setMeaning(buildMeaning(dim, r));
+        }
+    }
+
+    private String buildMeaning(String dim, IndicatorReading r) {
+        String name = r.getDisplayName() == null ? r.getCode() : r.getDisplayName();
+        if ("心".equals(dim)) {
+            return name + " 偏离 → 优先看情绪与压力负荷,先识别再转介,不做情绪处理";
+        }
+        if ("智".equals(dim)) {
+            return name + " 偏离 → 需要区分注意力问题与营养缺口,不下定性标签";
+        }
+        if ("行".equals(dim)) {
+            return name + " 偏离 → 行为与作息层面可先动,12 周内可复盘";
+        }
+        if ("富".equals(dim)) {
+            return name + " 偏离 → 与家庭厨房/饮食结构相关,属于共享基座";
+        }
+        return name + " 偏离 → 与肠道环境/营养缺口相关,先医后补";
+    }
+
+    /** 生成表象/本质/应对初稿 */
+    private void compose(MemberAttribution attr) {
+        List<String> evidence = new ArrayList<>();
+        for (List<String> list : attr.getDimensionEvidence().values()) {
+            evidence.addAll(list);
+        }
+        if (evidence.isEmpty()) {
+            attr.setSurface(attr.getRoleLabel() + ":本轮无明确异常读数,缺少常模的指标按「待补」处理");
+            attr.setEssence("待补项补齐后再定位,当前不下结论");
+            attr.getActions().add("补齐缺失常模/复测数据后再评估");
+            return;
+        }
+        StringBuilder surface = new StringBuilder(attr.getRoleLabel()).append(":");
+        for (int i = 0; i < evidence.size() && i < 4; i++) {
+            if (i > 0) surface.append(";");
+            surface.append(evidence.get(i));
+        }
+        if (evidence.size() > 4) {
+            surface.append(";等共").append(evidence.size()).append("项");
+        }
+        attr.setSurface(surface.toString());
+
+        StringBuilder essence = new StringBuilder();
+        for (Map.Entry<String, List<String>> e : attr.getDimensionEvidence().entrySet()) {
+            if (essence.length() > 0) essence.append(";");
+            essence.append(e.getKey()).append("维 ").append(e.getValue().size()).append(" 项偏离");
+        }
+        attr.setEssence(essence.toString());
+
+        for (Map.Entry<String, List<String>> e : attr.getDimensionEvidence().entrySet()) {
+            attr.getActions().add(switchAction(e.getKey(), attr.getRoleLabel()));
+        }
+        if (attr.getRetractableCount() > 0) {
+            attr.getActions().add("含可撤回项(补充剂印记),撤除后再复测确认");
+        }
+    }
+
+    private String switchAction(String dim, String roleLabel) {
+        if ("心".equals(dim)) {
+            return roleLabel + ":先做情绪与压力识别,必要时露出转介信息并请家长确认";
+        }
+        if ("智".equals(dim)) {
+            return roleLabel + ":补齐注意力/睡眠/营养三项证据后,再谈认知支持";
+        }
+        if ("行".equals(dim)) {
+            return roleLabel + ":作息与排便节律先落日常动作,2 周自评";
+        }
+        if ("富".equals(dim)) {
+            return roleLabel + ":先改家庭厨房与饮水基座,再谈个体补剂";
+        }
+        return roleLabel + ":查血/复测先行(P0),补剂动作后置";
+    }
+}

+ 81 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/MainCauseChainEngine.java

@@ -0,0 +1,81 @@
+package com.etotem.cfc.service.attribution;
+
+import com.etotem.cfc.service.attribution.model.CauseLink;
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 归因 Pipeline 段 ④:主因链引擎(确定性、纯 Java 规则)。
+ *
+ * <p>把四人问题串成一条链:上游(家庭共享基座)→ 中游(个体营养/肠道)→ 下游(表现/情绪/认知)。
+ * 主因维来自家庭五维地图的 mainDimensions。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Component
+public class MainCauseChainEngine {
+
+    /**
+     * 主因链主入口(纯规则)。
+     *
+     * @param attributions 逐人五维归因
+     * @param familyMap    家庭五维地图(含 mainDimensions)
+     * @return 主因链链路(按 order 升序:上游 → 下游)
+     */
+    public List<CauseLink> chain(List<MemberAttribution> attributions, Map<String, Object> familyMap) {
+        List<CauseLink> links = new ArrayList<>();
+        @SuppressWarnings("unchecked")
+        List<String> mainDims = familyMap != null && familyMap.get("mainDimensions") instanceof List
+                ? (List<String>) familyMap.get("mainDimensions") : new ArrayList<String>();
+
+        for (MemberAttribution attr : attributions) {
+            List<String> weak = attr.getWeakDimensions();
+            if (weak == null || weak.isEmpty()) {
+                continue;
+            }
+            for (String dim : weak) {
+                CauseLink link = buildLink(attr, dim);
+                if (link != null) {
+                    links.add(link);
+                }
+            }
+        }
+        // 主因维补充说明(家庭级)
+        if (!mainDims.isEmpty()) {
+            links.add(new CauseLink("家庭共享基座", "全家人" + mainDims + " 维共同短板",
+                    mainDims.get(0), "五维地图均值最低", "主因维:先共享基座后精准", 0));
+        }
+        links.sort((a, b) -> Integer.compare(a.getOrder(), b.getOrder()));
+        return links;
+    }
+
+    private CauseLink buildLink(MemberAttribution attr, String dim) {
+        String name = attr.getRoleLabel() != null ? attr.getRoleLabel() : attr.getName();
+        if ("富".equals(dim)) {
+            return new CauseLink("家庭厨房与饮水基座", name + "(饮食结构)", "富",
+                    attr.getRoleLabel() + " 富维偏离", "家庭共餐/外食/水质是共享基座,先改基座", 5);
+        }
+        if ("行".equals(dim)) {
+            return new CauseLink("作息与排便节律", name, "行",
+                    attr.getRoleLabel() + " 行维偏离", "睡眠/排便节律可先落日常动作,2 周自评", 4);
+        }
+        if ("身".equals(dim)) {
+            return new CauseLink(name + "(肠道环境/营养缺口)", name + "(表象)", "身",
+                    attr.getRoleLabel() + " 身维偏离", "先医后补:查血/复测先行(P0),补剂后置", 3);
+        }
+        if ("智".equals(dim)) {
+            return new CauseLink(name + "(注意力/认知支持)", name + "(表现)", "智",
+                    attr.getRoleLabel() + " 智维偏离", "区分注意力问题与营养缺口,不下定性标签", 2);
+        }
+        if ("心".equals(dim)) {
+            return new CauseLink(name + "(情绪压力负荷)", "识别+转介", "心",
+                    attr.getRoleLabel() + " 心维偏离", "先识别再转介,不做情绪处理(RL02 弱提示)", 1);
+        }
+        return null;
+    }
+}

+ 285 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/NormCompareEngine.java

@@ -0,0 +1,285 @@
+package com.etotem.cfc.service.attribution;
+
+import com.etotem.cfc.service.attribution.model.CompareResult;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.MemberProfile;
+import com.etotem.cfc.service.attribution.model.NormEntry;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 归因 Pipeline 段 ①:常模比对引擎(确定性、纯 Java 规则)。
+ *
+ * <p>对每条原始读数按「指标编码 × 年龄段带 × 性别」找常模:
+ * <ul>
+ *   <li>命中 → 计算百分位带(veryLow/low/mid/high/veryHigh)与状态(偏低/正常/偏高)</li>
+ *   <li>常模缺失 → <b>状态=待补,不猜值</b>(§8 共享知识·常模口径)</li>
+ *   <li>菌群读数自带人群水平(populationLevel 如 "35%")时优先用之</li>
+ * </ul>
+ *
+ * <p>同时产出归因中间态键(l.reuteriAbnormal/ironLow/hasDan/isFemaleTeen/hasDiabetes 等),
+ * 供 B2 条件路由与 B3 回填引用。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Component
+public class NormCompareEngine {
+
+    private static final Logger log = LoggerFactory.getLogger(NormCompareEngine.class);
+
+    /**
+     * 常模比对主入口(纯规则,不访问数据库)。
+     *
+     * @param members 成员档案(已解析)
+     * @param raw     原始读数(value/numericValue 已填,band/status 未填)
+     * @param norms   归一化常模条目
+     * @param flags   附加标志(如 confirm 问卷风险标记)
+     * @return 常模比对结果(读数 + 中间态 + 告警)
+     */
+    public CompareResult compare(List<MemberProfile> members, List<IndicatorReading> raw,
+                                 List<NormEntry> norms, Map<String, Object> flags) {
+        CompareResult result = new CompareResult();
+        List<IndicatorReading> readings = new ArrayList<>();
+        List<String> warnings = new ArrayList<>();
+
+        if (raw == null) {
+            raw = new ArrayList<>();
+        }
+        for (IndicatorReading r : raw) {
+            IndicatorReading banded = applyNorm(r, norms);
+            readings.add(banded);
+            if (IndicatorReading.STATUS_PENDING.equals(banded.getStatus())) {
+                warnings.add("常模缺失: " + banded.getDisplayName() + "(" + banded.getMemberName()
+                        + ")→ 待补,不猜值");
+            }
+        }
+
+        result.setReadings(readings);
+        result.setIntermediateState(deriveIntermediateState(readings, members, flags));
+        result.setWarnings(warnings);
+        return result;
+    }
+
+    // ==================== 常模查找与分位 ====================
+
+    private IndicatorReading applyNorm(IndicatorReading r, List<NormEntry> norms) {
+        NormEntry norm = findNorm(norms, r.getCode());
+        // 菌群人群水平优先:populationLevel 本身即人群百分位(如 "35%")
+        Double populationPercent = parsePopulationPercent(r.getPopulationLevel());
+        // 常模必须含 p5/p95 完整断点才可分位(部分常模不猜值)
+        if (norm != null && norm.getP5() != null && norm.getP95() != null) {
+            applyBandFromNorm(r, norm);
+        } else if (populationPercent != null) {
+            applyBandFromPopulation(r, populationPercent);
+            r.setNormSource("populationLevel");
+        } else {
+            r.setStatus(IndicatorReading.STATUS_PENDING);
+            r.setBand(IndicatorReading.BAND_UNKNOWN);
+            r.setPercentile(null);
+        }
+        return r;
+    }
+
+    private static void applyBandFromNorm(IndicatorReading r, NormEntry norm) {
+        double v = r.getNumericValue() == null ? Double.NaN : r.getNumericValue();
+        if (Double.isNaN(v)) {
+            r.setStatus(IndicatorReading.STATUS_PENDING);
+            r.setBand(IndicatorReading.BAND_UNKNOWN);
+            return;
+        }
+        r.setNormSource(norm.getSource());
+        double p5 = nz(norm.getP5()), p25 = nz(norm.getP25()), p50 = nz(norm.getP50()),
+                p75 = nz(norm.getP75()), p95 = nz(norm.getP95());
+        String band;
+        String status;
+        if (v < p5) {
+            band = IndicatorReading.BAND_VERY_LOW;
+            status = IndicatorReading.STATUS_LOW;
+        } else if (v < p25) {
+            band = IndicatorReading.BAND_LOW;
+            status = IndicatorReading.STATUS_LOW;
+        } else if (v <= p75) {
+            band = IndicatorReading.BAND_MID;
+            status = IndicatorReading.STATUS_NORMAL;
+        } else if (v <= p95) {
+            band = IndicatorReading.BAND_HIGH;
+            status = IndicatorReading.STATUS_HIGH;
+        } else {
+            band = IndicatorReading.BAND_VERY_HIGH;
+            status = IndicatorReading.STATUS_HIGH;
+        }
+        r.setBand(band);
+        r.setStatus(status);
+        r.setPercentile(roughPercentile(v, p5, p25, p50, p75, p95));
+    }
+
+    private static void applyBandFromPopulation(IndicatorReading r, double pop) {
+        String band;
+        String status;
+        if (pop <= 5) {
+            band = IndicatorReading.BAND_VERY_LOW;
+            status = IndicatorReading.STATUS_LOW;
+        } else if (pop <= 25) {
+            band = IndicatorReading.BAND_LOW;
+            status = IndicatorReading.STATUS_LOW;
+        } else if (pop <= 75) {
+            band = IndicatorReading.BAND_MID;
+            status = IndicatorReading.STATUS_NORMAL;
+        } else if (pop <= 95) {
+            band = IndicatorReading.BAND_HIGH;
+            status = IndicatorReading.STATUS_HIGH;
+        } else {
+            band = IndicatorReading.BAND_VERY_HIGH;
+            status = IndicatorReading.STATUS_HIGH;
+        }
+        r.setBand(band);
+        r.setStatus(status);
+        r.setPercentile("P" + Math.round(pop));
+    }
+
+    private static double nz(Double d) {
+        return d == null ? 0.0 : d;
+    }
+
+    /** 粗略百分位:在 p5/p25/p50/p75/p95 断点间线性插值 */
+    static String roughPercentile(double v, double p5, double p25, double p50, double p75, double p95) {
+        double[] ps = {p5, p25, p50, p75, p95};
+        double[] cents = {5, 25, 50, 75, 95};
+        if (v <= ps[0]) return "P≤5";
+        if (v >= ps[4]) return "P≥95";
+        for (int i = 0; i < ps.length - 1; i++) {
+            if (v >= ps[i] && v <= ps[i + 1]) {
+                double span = ps[i + 1] - ps[i];
+                if (span <= 0) return "P" + cents[i];
+                double pct = cents[i] + (v - ps[i]) / span * (cents[i + 1] - cents[i]);
+                return "P" + Math.round(pct);
+            }
+        }
+        return "P50";
+    }
+
+    /** 按 code 精确匹配,其次 code 忽略大小写;带 all 泛化回退 */
+    NormEntry findNorm(List<NormEntry> norms, String code) {
+        if (norms == null || code == null) {
+            return null;
+        }
+        for (NormEntry n : norms) {
+            if (code.equals(n.getCode())) {
+                return n;
+            }
+        }
+        for (NormEntry n : norms) {
+            if (code.equalsIgnoreCase(n.getCode())) {
+                return n;
+            }
+        }
+        // 中文名匹配:code 可能是学名,displayName 可能是中文,允许互查
+        for (NormEntry n : norms) {
+            if (code.equals(n.getDisplayName())) {
+                return n;
+            }
+        }
+        return null;
+    }
+
+    static Double parsePopulationPercent(String s) {
+        if (s == null || s.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            String t = s.replace("%", "").replace("%", "").trim();
+            return Double.valueOf(t);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    // ==================== 归因中间态 ====================
+
+    /**
+     * 推导归因中间态。缺失信号保守为 false(不猜值,避免误发敏感题)。
+     * 键集合固定包含:l.reuteriAbnormal/ironLow/hasDan/isFemaleTeen/hasDiabetes,
+     * 另含可推导键:zincDeficient/gutDiversityAbnormal/bowelBarrierAbnormal/bileAcidHigh/gasHigh/risk*。
+     */
+    Map<String, Object> deriveIntermediateState(List<IndicatorReading> readings, List<MemberProfile> members,
+                                                Map<String, Object> flags) {
+        Map<String, Object> state = new LinkedHashMap<>();
+        Map<String, Object> f = flags == null ? new LinkedHashMap<>() : flags;
+
+        state.put("l.reuteriAbnormal", anyAbnormal(readings, "reuteri", "罗伊氏"));
+        state.put("ironLow", anyAbnormal(readings, "铁", "iron", "ferritin", "ferrum", "fe"));
+        state.put("hasDan", Boolean.TRUE.equals(f.get("hasDan")));
+        state.put("isFemaleTeen", hasFemaleTeen(members));
+        state.put("hasDiabetes", anyAbnormal(readings, "血糖", "糖化", "hba1c", "糖尿病", "glucose")
+                || Boolean.TRUE.equals(f.get("hasDiabetes")));
+
+        // 可推导键(保守 false)
+        state.put("zincDeficient", anyAbnormal(readings, "锌", "zinc"));
+        state.put("gutDiversityAbnormal", anyAbnormal(readings, "多样性", "diversity", "shannon"));
+        state.put("bowelBarrierAbnormal", anyAbnormal(readings, "肠屏障", "屏障", "barrier", "zonulin"));
+        state.put("bileAcidHigh", anyHigh(readings, "胆汁酸", "bile"));
+        state.put("gasHigh", anyHigh(readings, "产气", "gas", "氢气"));
+
+        // mockFlags 显式覆盖(E12:测试环境可注入中间态,生产不传)
+        for (String key : new String[]{"l.reuteriAbnormal", "ironLow", "hasDan", "isFemaleTeen", "hasDiabetes",
+                "zincDeficient", "gutDiversityAbnormal", "bowelBarrierAbnormal", "bileAcidHigh", "gasHigh",
+                "riskMoodLow", "riskInsomnia", "riskSchoolRefusal", "riskSelfHarm", "riskBullying",
+                "riskEating", "riskPeerInvalid"}) {
+            if (flags != null && flags.containsKey(key)) {
+                state.put(key, flags.get(key));
+            }
+        }
+
+        // 确认问卷风险标记(B2 提交后由 B1 归并;RL02 检测用)
+        for (String key : new String[]{"riskMoodLow", "riskInsomnia", "riskSchoolRefusal", "riskSelfHarm",
+                "riskBullying", "riskEating", "riskPeerInvalid"}) {
+            state.put(key, Boolean.TRUE.equals(f.get(key)) || (f.get(key) != null && Boolean.parseBoolean(String.valueOf(f.get(key)))));
+        }
+        // 年龄段带/角色也进入上下文(B2 路由直接用)
+        if (members != null && !members.isEmpty()) {
+            MemberProfile first = members.get(0);
+            state.put("role", first.getRole());
+            state.put("ageBand", first.getAgeBand());
+        }
+        return state;
+    }
+
+    private static boolean anyAbnormal(List<IndicatorReading> readings, String... keys) {
+        for (IndicatorReading r : readings) {
+            if (!r.isAbnormal()) continue;
+            if (matchesAny(r, keys)) return true;
+        }
+        return false;
+    }
+
+    private static boolean anyHigh(List<IndicatorReading> readings, String... keys) {
+        for (IndicatorReading r : readings) {
+            if (!IndicatorReading.STATUS_HIGH.equals(r.getStatus())) continue;
+            if (matchesAny(r, keys)) return true;
+        }
+        return false;
+    }
+
+    private static boolean matchesAny(IndicatorReading r, String... keys) {
+        String hay = (r.getCode() == null ? "" : r.getCode().toLowerCase())
+                + "|" + (r.getDisplayName() == null ? "" : r.getDisplayName().toLowerCase());
+        for (String k : keys) {
+            if (hay.contains(k.toLowerCase())) return true;
+        }
+        return false;
+    }
+
+    private static boolean hasFemaleTeen(List<MemberProfile> members) {
+        if (members == null) return false;
+        for (MemberProfile m : members) {
+            if (Boolean.TRUE.equals(m.getFemaleTeen())) return true;
+        }
+        return false;
+    }
+}

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/CauseLink.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.service.attribution.model;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 主因链链路(归因引擎内部模型)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Data
+public class CauseLink implements Serializable {
+
+    /** 链路起点(如「厨房与饮食」) */
+    private String from;
+
+    /** 链路终点(如「妈妈铁吸收」) */
+    private String to;
+
+    /** 关联维度(身/心/智/行/富) */
+    private String dimension;
+
+    /** 证据(读数/指标) */
+    private String evidence;
+
+    /** 说明 */
+    private String note;
+
+    /** 链序(越大越靠上游) */
+    private int order;
+
+    public CauseLink() {
+    }
+
+    public CauseLink(String from, String to, String dimension, String evidence, String note, int order) {
+        this.from = from;
+        this.to = to;
+        this.dimension = dimension;
+        this.evidence = evidence;
+        this.note = note;
+        this.order = order;
+    }
+}

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/CompareResult.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.service.attribution.model;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 常模比对段输出(归因引擎内部模型)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Data
+public class CompareResult implements Serializable {
+
+    /** 常模比对后的读数(band/status/percentile 已填充) */
+    private List<IndicatorReading> readings = new ArrayList<>();
+
+    /** 归因中间态(供 B2 条件路由 / B3 回填) */
+    private Map<String, Object> intermediateState = new LinkedHashMap<>();
+
+    /** 非阻断性告警(如常模缺失) */
+    private List<String> warnings = new ArrayList<>();
+}

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/FalsifiedItem.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.service.attribution.model;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 被证伪项(归因引擎内部模型)。
+ *
+ * <p>「证伪」= 表面像因但经规则判定不成立 / 需人工确认 / 可撤回的归因。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Data
+public class FalsifiedItem implements Serializable {
+
+    /** 被证伪的项(如「同伴依恋项判定」「乳杆菌异常=补充剂印记」) */
+    private String item;
+
+    /** 证伪理由 */
+    private String reason;
+
+    /** 证据 */
+    private String evidence;
+
+    /** 后续处理方式 */
+    private String action;
+
+    public FalsifiedItem() {
+    }
+
+    public FalsifiedItem(String item, String reason, String evidence, String action) {
+        this.item = item;
+        this.reason = reason;
+        this.evidence = evidence;
+        this.action = action;
+    }
+}

+ 79 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/IndicatorReading.java

@@ -0,0 +1,79 @@
+package com.etotem.cfc.service.attribution.model;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 指标解读读数(归因引擎内部模型,贯穿 Pipeline 五段)。
+ *
+ * <p><b>常模缺失 → status="待补",不猜值</b>(§8 共享知识·常模口径)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Data
+public class IndicatorReading implements Serializable {
+
+    /** 状态:待补/偏低/正常/偏高 */
+    public static final String STATUS_PENDING = "待补";
+    public static final String STATUS_LOW = "偏低";
+    public static final String STATUS_NORMAL = "正常";
+    public static final String STATUS_HIGH = "偏高";
+
+    /** 百分位带:veryLow/low/mid/high/veryHigh/unknown */
+    public static final String BAND_VERY_LOW = "veryLow";
+    public static final String BAND_LOW = "low";
+    public static final String BAND_MID = "mid";
+    public static final String BAND_HIGH = "high";
+    public static final String BAND_VERY_HIGH = "veryHigh";
+    public static final String BAND_UNKNOWN = "unknown";
+
+    /** 成员ID */
+    private Long memberId;
+
+    /** 成员名称 */
+    private String memberName;
+
+    /** 数据来源类型:gut_flora/dan/blood/profile */
+    private String source;
+
+    /** 指标编码(菌种名 / DAN 维度 key / 血液指标 key) */
+    private String code;
+
+    /** 指标显示名 */
+    private String displayName;
+
+    /** 原始值文本 */
+    private String value;
+
+    /** 可解析数值(不可解析为 null) */
+    private Double numericValue;
+
+    /** 单位 */
+    private String unit;
+
+    /** 百分位文本(如 "P3");无常模为 null */
+    private String percentile;
+
+    /** 百分位带 */
+    private String band = BAND_UNKNOWN;
+
+    /** 状态(待补/偏低/正常/偏高) */
+    private String status = STATUS_PENDING;
+
+    /** 常模来源:attribution_norm_values/cognitive_norms/health_norm_reference/none */
+    private String normSource = "none";
+
+    /** 原始人群水平文本(菌群 populationLevel,如 "35%") */
+    private String populationLevel;
+
+    /** 是否为「可撤回项」(如补充剂印记造成的异常) */
+    private boolean retractable;
+
+    /** 干预含义 */
+    private String meaning;
+
+    public boolean isAbnormal() {
+        return STATUS_LOW.equals(status) || STATUS_HIGH.equals(status);
+    }
+}

+ 57 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/MemberAttribution.java

@@ -0,0 +1,57 @@
+package com.etotem.cfc.service.attribution.model;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 单人归因结论(归因引擎内部模型)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Data
+public class MemberAttribution implements Serializable {
+
+    /** 成员ID */
+    private Long memberId;
+
+    /** 成员名称 */
+    private String name;
+
+    /** 语义角色 mom/dad/daughter/son/unknown */
+    private String role;
+
+    /** 角色中文标签 */
+    private String roleLabel;
+
+    /** 表象(家长看到的症状/表现) */
+    private String surface = "";
+
+    /** 本质(归因结论) */
+    private String essence = "";
+
+    /** 五维归因分(0-100 教练相对刻度):身/心/智/行/富 */
+    private Map<String, Double> fiveDim = new LinkedHashMap<>();
+
+    /** 五维短板标签 */
+    private List<String> weakDimensions = new ArrayList<>();
+
+    /** 应对方向 */
+    private List<String> actions = new ArrayList<>();
+
+    /** 维度证据:维度 → 证据条目(解读段产出,归因段据此打分) */
+    private Map<String, List<String>> dimensionEvidence = new LinkedHashMap<>();
+
+    /** 异常读数条数 */
+    private int abnormalCount;
+
+    /** 可撤回读数条数 */
+    private int retractableCount;
+
+    /** 该成员读数快照 */
+    private List<IndicatorReading> readings = new ArrayList<>();
+}

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/MemberProfile.java

@@ -0,0 +1,55 @@
+package com.etotem.cfc.service.attribution.model;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 归因成员档案(归因引擎内部模型)。
+ *
+ * <p>角色为语义角色(mom/dad/daughter/son/unknown),用于 E6 人范围裁剪
+ * (爸爸链外)与问卷 group 匹配。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Data
+public class MemberProfile implements Serializable {
+
+    /** 成员ID(family_members.id) */
+    private Long memberId;
+
+    /** 成员名称 */
+    private String name;
+
+    /** 语义角色:mom/dad/daughter/son/unknown */
+    private String role = "unknown";
+
+    /** 角色中文标签:妈妈/爸爸/女儿/儿子/成员 */
+    private String roleLabel = "成员";
+
+    /** 性别 male/female */
+    private String gender;
+
+    /** 年龄 */
+    private Integer age;
+
+    /** 年龄段带:0-3/4-6/7-12/13-18/19-45/46-65/66+ */
+    private String ageBand = "all";
+
+    /** 辈分值(0=创建者,-n=向下n代,+n=向上n代) */
+    private Integer generation;
+
+    /** 是否配偶 */
+    private Boolean spouse = Boolean.FALSE;
+
+    /** 是否未成年 */
+    private Boolean minor = Boolean.FALSE;
+
+    /** 是否女性青少年(13-18 岁女性) */
+    private Boolean femaleTeen = Boolean.FALSE;
+
+    /** 是否在方案人范围内(E6:妈妈/女儿/儿子;爸爸为 false) */
+    public boolean inPlanScope() {
+        return "mom".equals(role) || "daughter".equals(role) || "son".equals(role);
+    }
+}

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/NormEntry.java

@@ -0,0 +1,41 @@
+package com.etotem.cfc.service.attribution.model;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 常模条目(归因引擎内部模型;由 attribution_norm_values / cognitive_norms / health_norm_reference 归一化而来)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Data
+public class NormEntry implements Serializable {
+
+    /** 常模类型:gut_flora/dan */
+    private String normType;
+
+    /** 指标编码(菌种学名 / DAN 维度 key) */
+    private String code;
+
+    /** 显示名 */
+    private String displayName;
+
+    /** 年龄段带:0-3/4-6/7-12/13-18/19-45/46-65/66+/all */
+    private String ageBand;
+
+    /** 性别 male/female/all */
+    private String gender;
+
+    private Double p5;
+    private Double p25;
+    private Double p50;
+    private Double p75;
+    private Double p95;
+
+    /** 单位 */
+    private String unit;
+
+    /** 常模来源表:attribution_norm_values/cognitive_norms/health_norm_reference */
+    private String source;
+}

+ 342 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/AttributionAdjustServiceImpl.java

@@ -0,0 +1,342 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.config.PromptConfig;
+import com.etotem.cfc.constant.AttributionRedlines;
+import com.etotem.cfc.dto.AdjustResult;
+import com.etotem.cfc.entity.AttributionReport;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.mapper.AttributionReportMapper;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.service.AttributionAdjustService;
+import com.etotem.cfc.service.AiGateway;
+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.util.ArrayList;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * {@link AttributionAdjustService} 默认实现(B3)。
+ *
+ * <p>对话通道复用 AiGateway + 归因教练 prompt(七层 Socratic);AI 失败规则兜底、流程永不阻断。
+ * RL02 命中 → 标记建议专业评估 + 转介信息露出 + 家长确认(E4 弱提示)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B3)
+ */
+@Service
+public class AttributionAdjustServiceImpl implements AttributionAdjustService {
+
+    private static final Logger log = LoggerFactory.getLogger(AttributionAdjustServiceImpl.class);
+
+    /** 结构化结论标记:[ATTR_DELTA]{...} */
+    private static final Pattern ATTR_DELTA = Pattern.compile("\\[ATTR_DELTA\\]\\s*(\\{.*?\\})", Pattern.DOTALL);
+
+    /** 七层 Socratic 骨架提示(PromptConfig.attributionCoach 可覆盖) */
+    private static final String DEFAULT_ADJUST_PROMPT =
+            "你是家庭归因分析教练。请按七层 Socratic(澄清→探因→假设→证据→替代→行动→复盘)回应家长,"
+                    + "并允许家长调整归因中间态。若家长明确修正了某项归因(如「其实没有在吃罗伊氏乳杆菌」"
+                    + "「铁没那么低」「孩子其实没自伤念头」),请在你的回答末尾输出结构化标记:"
+                    + "[ATTR_DELTA]{\"l.reuteriAbnormal\":false,\"ironLow\":false,\"riskSelfHarm\":false}。"
+                    + "禁止输出疗效/诊断承诺,禁止使用「多动症/抑郁/天才/学霸」等定性词(RL06/RL08)。";
+
+    @Resource
+    private AiGateway aiGateway;
+
+    @Resource
+    private PromptConfig promptConfig;
+
+    @Resource
+    private AttributionReportMapper attributionReportMapper;
+
+    @Resource
+    private HealthPlanMapper healthPlanMapper;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    @Override
+    public AdjustResult adjust(Long attributionId, Long userId, String message, String conversationId) {
+        AttributionReport report = attributionReportMapper.selectById(attributionId);
+        if (report == null) {
+            return null;
+        }
+        AdjustResult result = new AdjustResult();
+        result.setAttributionId(attributionId);
+        result.setStatus(AttributionReport.STATUS_ADJUSTING);
+
+        String prompt = buildPrompt(report);
+        Map<String, Object> aiResp = callAi(prompt, userId, conversationId);
+        String answer = aiResp == null ? null : String.valueOf(aiResp.getOrDefault("answer", ""));
+
+        Map<String, Object> deltas = new LinkedHashMap<>();
+        if (answer != null && !answer.trim().isEmpty()) {
+            deltas = parseDeltas(answer);
+        }
+        if (answer == null || answer.trim().isEmpty()) {
+            // AI 失败 → 规则兜底话术,流程不阻断
+            answer = fallbackAnswer(report);
+        }
+
+        result.setAnswer(answer);
+        result.setFieldDeltas(deltas);
+        result.setIntermediateState(applyDeltas(report, deltas));
+        result.setRedlineHits(new ArrayList<>(parseStringList(report.getRedlineHits())));
+
+        // RL02 红线校验(E4 弱提示:仅标记建议专业评估 + 转介信息露出 + 家长确认,不阻断)
+        boolean rl02 = checkRl02(result.getIntermediateState(), result.getRedlineHits());
+        if (rl02) {
+            result.setRedlineTerminate(true);
+            result.setBlocking(false);
+            AdjustResult.ReferralInfo referral = new AdjustResult.ReferralInfo();
+            result.setReferral(referral);
+            addRedlineHit(result.getRedlineHits(), "RL02");
+        }
+
+        // ⑤ 失效重生成:若已关联方案且 status=GENERATED → INVALIDATED
+        boolean invalidated = invalidateGeneratedPlan(report);
+        result.setPlanInvalidated(invalidated);
+
+        report.setStatus(AttributionReport.STATUS_ADJUSTED);
+        report.setRedlineHits(toJsonArray(result.getRedlineHits()));
+        report.setIntermediateState(toJsonMap(result.getIntermediateState()));
+        report.setUpdatedAt(new Date());
+        attributionReportMapper.updateById(report);
+        result.setStatus(AttributionReport.STATUS_ADJUSTED);
+        return result;
+    }
+
+    @Override
+    public AdjustResult confirm(Long attributionId, Long userId) {
+        AttributionReport report = attributionReportMapper.selectById(attributionId);
+        if (report == null) {
+            return null;
+        }
+        AdjustResult result = new AdjustResult();
+        result.setAttributionId(attributionId);
+        result.setFieldDeltas(new LinkedHashMap<>());
+        result.setIntermediateState(parseMap(report.getIntermediateState()));
+        result.setRedlineHits(parseStringList(report.getRedlineHits()));
+        result.setRedlineTerminate(result.getRedlineHits().contains("RL02"));
+        result.setBlocking(false);
+        if (result.isRedlineTerminate()) {
+            result.setReferral(new AdjustResult.ReferralInfo());
+        }
+        result.setAnswer("已确认归因结论,后续可生成全家干预方案。");
+        result.setPlanInvalidated(false);
+
+        // 归因结论锁定:status=CONFIRMED + report_json 标记 conclusionLocked
+        report.setStatus(AttributionReport.STATUS_CONFIRMED);
+        report.setUpdatedAt(new Date());
+        try {
+            JsonNode node = objectMapper.readTree(report.getReportJson());
+            if (node instanceof com.fasterxml.jackson.databind.node.ObjectNode) {
+                com.fasterxml.jackson.databind.node.ObjectNode root =
+                        (com.fasterxml.jackson.databind.node.ObjectNode) node;
+                root.put("status", AttributionReport.STATUS_CONFIRMED);
+                root.put("conclusionLocked", true);
+                root.put("confirmedAt", new Date().toInstant().toString());
+                report.setReportJson(objectMapper.writeValueAsString(root));
+            }
+        } catch (Exception e) {
+            log.warn("confirm: 报告 JSON 锁定失败 attributionId={}: {}", attributionId, e.getMessage());
+        }
+        attributionReportMapper.updateById(report);
+        result.setStatus(AttributionReport.STATUS_CONFIRMED);
+        return result;
+    }
+
+    // ==================== 内部 ====================
+
+    private String buildPrompt(AttributionReport report) {
+        String template = promptConfig.getAttributionCoach();
+        if (template == null || template.trim().isEmpty()) {
+            template = DEFAULT_ADJUST_PROMPT;
+        }
+        StringBuilder sb = new StringBuilder(template);
+        sb.append("\n\n【归因报告】\n").append(report.getReportJson() == null ? "{}" : report.getReportJson());
+        AttributionRedlines.Redline rl08 = AttributionRedlines.byCode("RL08");
+        sb.append("\n【红线提示】\n").append(rl08 == null ? "" : rl08.message);
+        return sb.toString();
+    }
+
+    private Map<String, Object> callAi(String prompt, Long userId, String conversationId) {
+        try {
+            Map<String, Object> inputs = new LinkedHashMap<>();
+            inputs.put("scene", "family_attribution_adjust");
+            return aiGateway.chatForAnalysis(prompt, userId, conversationId, inputs);
+        } catch (Exception e) {
+            log.warn("归因调整 AI 调用失败,走规则兜底: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    Map<String, Object> parseDeltas(String answer) {
+        Map<String, Object> deltas = new LinkedHashMap<>();
+        if (answer == null) {
+            return deltas;
+        }
+        Matcher m = ATTR_DELTA.matcher(answer);
+        if (m.find()) {
+            try {
+                JsonNode node = objectMapper.readTree(m.group(1));
+                if (node != null && node.isObject()) {
+                    Iterator<Map.Entry<String, JsonNode>> it = node.fields();
+                    while (it.hasNext()) {
+                        Map.Entry<String, JsonNode> e = it.next();
+                        deltas.put(e.getKey(), toJava(e.getValue()));
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("解析 [ATTR_DELTA] 失败,忽略增量: {}", e.getMessage());
+            }
+        }
+        return deltas;
+    }
+
+    private Map<String, Object> applyDeltas(AttributionReport report, Map<String, Object> deltas) {
+        Map<String, Object> state = parseMap(report.getIntermediateState());
+        if (deltas != null && !deltas.isEmpty()) {
+            state.putAll(deltas);
+            log.info("归因调整回填 attributionId={} deltas={}", report.getId(), deltas);
+        }
+        return state;
+    }
+
+    private boolean checkRl02(Map<String, Object> state, List<String> hits) {
+        if (hits.contains("RL02")) {
+            return true;
+        }
+        return truthy(state.get("riskSelfHarm")) || truthy(state.get("riskSchoolRefusal"))
+                || truthy(state.get("riskInsomnia")) || truthy(state.get("riskMoodLow"));
+    }
+
+    private boolean invalidateGeneratedPlan(AttributionReport report) {
+        if (report.getPlanId() == null) {
+            return false;
+        }
+        try {
+            HealthPlan plan = healthPlanMapper.selectById(report.getPlanId());
+            if (plan != null && "GENERATED".equals(plan.getStatus())) {
+                plan.setStatus("INVALIDATED");
+                plan.setUpdatedAt(new Date());
+                healthPlanMapper.updateById(plan);
+                log.info("归因调整:关联方案已作废 planId={}(⑤ 失效重生成)", report.getPlanId());
+                return true;
+            }
+        } catch (Exception e) {
+            log.warn("归因调整:方案作废检查失败 planId={}: {}", report.getPlanId(), e.getMessage());
+        }
+        return false;
+    }
+
+    private String fallbackAnswer(AttributionReport report) {
+        return "(AI 暂不可用,按规则提示)已记录你的调整方向。"
+                + "如需修改归因中间态,请明确说「铁没偏低」「没在吃罗伊氏乳杆菌」「孩子没有自伤念头」等,"
+                + "或直接确认生成方案。";
+    }
+
+    private void addRedlineHit(List<String> hits, String code) {
+        if (!hits.contains(code)) {
+            hits.add(code);
+        }
+    }
+
+    // ==================== 工具 ====================
+
+    String toJsonArray(List<String> list) {
+        try {
+            return objectMapper.writeValueAsString(list == null ? new ArrayList<String>() : list);
+        } catch (Exception e) {
+            return "[]";
+        }
+    }
+
+    String toJsonMap(Map<String, Object> map) {
+        try {
+            return objectMapper.writeValueAsString(map == null ? new LinkedHashMap<String, Object>() : map);
+        } catch (Exception e) {
+            return "{}";
+        }
+    }
+
+    Map<String, Object> parseMap(String json) {
+        Map<String, Object> out = new LinkedHashMap<>();
+        if (json == null || json.trim().isEmpty()) {
+            return out;
+        }
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node != null && node.isObject()) {
+                Iterator<Map.Entry<String, JsonNode>> it = node.fields();
+                while (it.hasNext()) {
+                    Map.Entry<String, JsonNode> e = it.next();
+                    out.put(e.getKey(), toJava(e.getValue()));
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析 JSON 对象失败: {}", e.getMessage());
+        }
+        return out;
+    }
+
+    List<String> parseStringList(String json) {
+        List<String> out = new ArrayList<>();
+        if (json == null || json.trim().isEmpty()) {
+            return out;
+        }
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node != null && node.isArray()) {
+                for (JsonNode n : node) {
+                    out.add(n.asText());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析 JSON 数组失败: {}", e.getMessage());
+        }
+        return out;
+    }
+
+    private Object toJava(JsonNode node) {
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        if (node.isBoolean()) {
+            return node.booleanValue();
+        }
+        if (node.isNumber()) {
+            return node.numberValue();
+        }
+        if (node.isTextual()) {
+            return node.textValue();
+        }
+        if (node.isArray()) {
+            List<Object> list = new ArrayList<>();
+            for (JsonNode item : node) {
+                list.add(toJava(item));
+            }
+            return list;
+        }
+        return node.toString();
+    }
+
+    private static boolean truthy(Object v) {
+        if (v == null) {
+            return false;
+        }
+        if (v instanceof Boolean) {
+            return (Boolean) v;
+        }
+        return Boolean.parseBoolean(String.valueOf(v));
+    }
+}

+ 731 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/AttributionAnalysisServiceImpl.java

@@ -0,0 +1,731 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.AttributionInput;
+import com.etotem.cfc.dto.AttributionReportVO;
+import com.etotem.cfc.entity.AttributionReport;
+import com.etotem.cfc.entity.AttributionNormValue;
+import com.etotem.cfc.entity.CognitiveNorm;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthGutFlora;
+import com.etotem.cfc.entity.HealthNormReference;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.SurveyRecord;
+import com.etotem.cfc.mapper.AttributionNormValueMapper;
+import com.etotem.cfc.mapper.AttributionReportMapper;
+import com.etotem.cfc.mapper.CognitiveNormMapper;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthGutFloraMapper;
+import com.etotem.cfc.mapper.HealthNormReferenceMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.SurveyRecordMapper;
+import com.etotem.cfc.service.AttributionAnalysisService;
+import com.etotem.cfc.service.attribution.AttributionNarrativeService;
+import com.etotem.cfc.service.attribution.AttributionReportBuilder;
+import com.etotem.cfc.service.attribution.FalsificationEngine;
+import com.etotem.cfc.service.attribution.FiveDimAttributionEngine;
+import com.etotem.cfc.service.attribution.IndicatorInterpretEngine;
+import com.etotem.cfc.service.attribution.MainCauseChainEngine;
+import com.etotem.cfc.service.attribution.NormCompareEngine;
+import com.etotem.cfc.service.attribution.model.CauseLink;
+import com.etotem.cfc.service.attribution.model.CompareResult;
+import com.etotem.cfc.service.attribution.model.FalsifiedItem;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
+import com.etotem.cfc.service.attribution.model.MemberProfile;
+import com.etotem.cfc.service.attribution.model.NormEntry;
+import com.etotem.cfc.util.AttributionRoleUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * {@link AttributionAnalysisService} 默认实现(B1)。
+ *
+ * <p>Pipeline:常模比对 → 指标解读 → 五维归因 → 主因链 → 被证伪 → AI 叙述(规则兜底)→ 红线硬注入 → 落库。
+ * 全程确定性规则优先;AI 只负责叙述且失败不阻断。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1)
+ */
+@Service
+public class AttributionAnalysisServiceImpl implements AttributionAnalysisService {
+
+    private static final Logger log = LoggerFactory.getLogger(AttributionAnalysisServiceImpl.class);
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private HealthGutFloraMapper healthGutFloraMapper;
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Resource
+    private AttributionNormValueMapper attributionNormValueMapper;
+
+    @Resource
+    private CognitiveNormMapper cognitiveNormMapper;
+
+    @Resource
+    private HealthNormReferenceMapper healthNormReferenceMapper;
+
+    @Resource
+    private SurveyRecordMapper surveyRecordMapper;
+
+    @Resource
+    private AttributionReportMapper attributionReportMapper;
+
+    @Resource
+    private NormCompareEngine normCompareEngine;
+
+    @Resource
+    private IndicatorInterpretEngine indicatorInterpretEngine;
+
+    @Resource
+    private FiveDimAttributionEngine fiveDimAttributionEngine;
+
+    @Resource
+    private MainCauseChainEngine mainCauseChainEngine;
+
+    @Resource
+    private FalsificationEngine falsificationEngine;
+
+    @Resource
+    private AttributionNarrativeService attributionNarrativeService;
+
+    @Resource
+    private AttributionReportBuilder attributionReportBuilder;
+
+    // ==================== 主入口 ====================
+
+    @Override
+    public AttributionReportVO analyze(AttributionInput input) {
+        if (input == null) {
+            throw new IllegalArgumentException("归因入参不能为空");
+        }
+        List<MemberProfile> members = resolveMembers(input);
+        List<IndicatorReading> raw = loadRawReadings(input, members);
+        List<NormEntry> norms = loadNorms();
+        Map<String, Object> flags = loadFlags(input);
+
+        CompareResult compare = normCompareEngine.compare(members, raw, norms, flags);
+        List<MemberAttribution> attributions = indicatorInterpretEngine.interpret(compare.getReadings(), members);
+        FiveDimAttributionEngine.FiveDimResult five = fiveDimAttributionEngine.attribute(attributions, members);
+        List<CauseLink> links = mainCauseChainEngine.chain(five.attributions, five.familyMap);
+        List<FalsifiedItem> falsified = falsificationEngine.falsify(five.attributions, compare.getReadings(), flags);
+
+        Map<String, Object> narrative = narrate(five, links, falsified, compare, input);
+
+        List<String> redlineHits = attributionReportBuilder.computeRedlineHits(
+                five, compare.getIntermediateState(), members);
+        String reportJson = attributionReportBuilder.buildReportJson(
+                five, links, falsified, compare, narrative, redlineHits, input);
+
+        AttributionReport report = new AttributionReport();
+        report.setFamilyId(input.getFamilyId());
+        report.setMemberIds(joinIds(input.getMemberIds()));
+        report.setPlanId(input.getPlanId());
+        report.setStatus(AttributionReport.STATUS_ANALYZING);
+        report.setReportJson(reportJson);
+        report.setRedlineHits(toJsonArray(redlineHits));
+        report.setIntermediateState(toJsonMap(compare.getIntermediateState()));
+        report.setCreatedAt(new Date());
+        report.setUpdatedAt(new Date());
+        attributionReportMapper.insert(report);
+        // ANALYZING → READY(状态机 §4.2)
+        report.setStatus(AttributionReport.STATUS_READY);
+        report.setUpdatedAt(new Date());
+        attributionReportMapper.updateById(report);
+
+        AttributionReportVO vo = toVO(report);
+        vo.setWarnings(compare.getWarnings());
+        return vo;
+    }
+
+    @Override
+    public AttributionReportVO getReport(Long attributionId) {
+        if (attributionId == null) {
+            return null;
+        }
+        AttributionReport report = attributionReportMapper.selectById(attributionId);
+        if (report == null) {
+            return null;
+        }
+        return toVO(report);
+    }
+
+    /**
+     * ATT-S1/O1 留痕:此方法为阶段一遗留死代码(B3 调整→方案同步的中间态回填逻辑)。
+     * 主理人已裁定 C2(B3 调整→方案同步)延后至阶段三,本轮保留不删不搬。
+     * 上线前无需调用;如 B3 在阶段三恢复,直接启用即可。
+     */
+    @Override
+    public AttributionReportVO recompute(Long attributionId, Map<String, Object> fieldDeltas) {
+        AttributionReport report = attributionReportMapper.selectById(attributionId);
+        if (report == null) {
+            return null;
+        }
+        Map<String, Object> state = parseMap(report.getIntermediateState());
+        if (fieldDeltas != null) {
+            state.putAll(fieldDeltas);
+        }
+        report.setIntermediateState(toJsonMap(state));
+        report.setStatus(AttributionReport.STATUS_ADJUSTED);
+        report.setUpdatedAt(new Date());
+        // 回填后同步报告 JSON 内的中间态与状态标记
+        try {
+            JsonNode node = objectMapper.readTree(report.getReportJson());
+            if (node instanceof ObjectNode) {
+                ObjectNode root = (ObjectNode) node;
+                root.set("intermediateState", objectMapper.valueToTree(state));
+                root.put("status", AttributionReport.STATUS_ADJUSTED);
+                root.put("updatedAt", report.getUpdatedAt().toInstant().toString());
+                report.setReportJson(objectMapper.writeValueAsString(root));
+            }
+        } catch (Exception e) {
+            log.warn("recompute: 报告 JSON 同步失败 attributionId={}: {}", attributionId, e.getMessage());
+        }
+        attributionReportMapper.updateById(report);
+        return toVO(report);
+    }
+
+    // ==================== 成员解析 ====================
+
+    private List<MemberProfile> resolveMembers(AttributionInput input) {
+        List<MemberProfile> members = new ArrayList<>();
+        List<Long> ids = input.getMemberIds();
+        if (ids == null || ids.isEmpty()) {
+            return members;
+        }
+        for (Long id : ids) {
+            if (id == null) {
+                continue;
+            }
+            FamilyMember fm = null;
+            try {
+                fm = familyMemberMapper.selectById(id);
+            } catch (Exception e) {
+                log.warn("读取家庭成员失败 memberId={}: {}", id, e.getMessage());
+            }
+            MemberProfile p = new MemberProfile();
+            p.setMemberId(id);
+            if (fm != null) {
+                p.setName(fm.getNickname());
+                p.setGender(fm.getGender());
+                int age = fm.getAge();
+                p.setAge(age);
+                p.setAgeBand(AttributionRoleUtil.ageBand(age));
+                p.setGeneration(fm.getGeneration());
+                p.setSpouse(Boolean.TRUE.equals(fm.getIsSpouse()));
+                p.setMinor(age < 18);
+                p.setFemaleTeen("female".equalsIgnoreCase(fm.getGender()) && age >= 13 && age <= 18);
+                String role = AttributionRoleUtil.roleOf(fm);
+                p.setRole(role);
+                p.setRoleLabel(AttributionRoleUtil.roleLabel(role));
+            } else {
+                p.setName("成员" + id);
+                p.setRole("unknown");
+                p.setRoleLabel("成员");
+            }
+            members.add(p);
+        }
+        return members;
+    }
+
+    // ==================== 原始读数加载 ====================
+
+    private List<IndicatorReading> loadRawReadings(AttributionInput input, List<MemberProfile> members) {
+        List<IndicatorReading> readings = new ArrayList<>();
+        Map<Long, MemberProfile> byId = new LinkedHashMap<>();
+        for (MemberProfile m : members) {
+            byId.put(m.getMemberId(), m);
+        }
+
+        // 菌群读数(health_gut_flora ← health_reports.subject_id = 成员ID)
+        List<Long> gutIds = input.getGutReportIds();
+        if (gutIds != null && !gutIds.isEmpty()) {
+            Map<Long, Long> reportToMember = new LinkedHashMap<>();
+            try {
+                List<HealthReport> reports = healthReportMapper.selectBatchIds(gutIds);
+                for (HealthReport r : reports) {
+                    if (r.getSubjectId() != null) {
+                        reportToMember.put(r.getId(), r.getSubjectId());
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("读取菌群报告失败: {}", e.getMessage());
+            }
+            try {
+                List<HealthGutFlora> flora = healthGutFloraMapper.selectList(
+                        new LambdaQueryWrapper<HealthGutFlora>().in(HealthGutFlora::getReportId, gutIds));
+                for (HealthGutFlora f : flora) {
+                    IndicatorReading r = new IndicatorReading();
+                    r.setSource("gut_flora");
+                    r.setCode(f.getBacteriaName());
+                    r.setDisplayName(f.getBacteriaName());
+                    r.setValue(f.getBacteriaValue());
+                    r.setNumericValue(parseDouble(f.getBacteriaValue()));
+                    r.setPopulationLevel(f.getPopulationLevel());
+                    r.setUnit(f.getBacteriaValue() != null && f.getBacteriaValue().contains("%") ? "%" : null);
+                    Long memberId = reportToMember.get(f.getReportId());
+                    r.setMemberId(memberId);
+                    MemberProfile p = memberId == null ? null : byId.get(memberId);
+                    r.setMemberName(p != null ? p.getName() : null);
+                    readings.add(r);
+                }
+            } catch (Exception e) {
+                log.warn("读取菌群明细失败: {}", e.getMessage());
+            }
+        }
+
+        // DAN 读数(dan_assessment_results.family_member_id = 成员ID)
+        List<Long> danIds = input.getDanResultIds();
+        if (danIds != null && !danIds.isEmpty()) {
+            try {
+                List<DanAssessmentResult> dans = danAssessmentResultMapper.selectBatchIds(danIds);
+                for (DanAssessmentResult d : dans) {
+                    readings.addAll(toDanReadings(d, byId));
+                }
+            } catch (Exception e) {
+                log.warn("读取 DAN 结果失败: {}", e.getMessage());
+            }
+        }
+        return readings;
+    }
+
+    private List<IndicatorReading> toDanReadings(DanAssessmentResult d, Map<Long, MemberProfile> byId) {
+        List<IndicatorReading> out = new ArrayList<>();
+        MemberProfile p = d.getFamilyMemberId() == null ? null : byId.get(d.getFamilyMemberId());
+        String name = p != null ? p.getName() : d.getFamilyMemberName();
+
+        addDan(out, d, byId, name, "emotionScore", "情绪商数", d.getEmotionScore());
+        addDan(out, d, byId, name, "resilienceScore", "心理韧性", d.getResilienceScore());
+        addDan(out, d, byId, name, "stressCopingScore", "压力应对", d.getStressCopingScore());
+        addDan(out, d, byId, name, "selfAwarenessScore", "自我认知", d.getSelfAwarenessScore());
+        addDan(out, d, byId, name, "attentionScore", "注意力", d.getAttentionScore());
+        addDan(out, d, byId, name, "focusScore", "专注力", d.getFocusScore());
+        addDan(out, d, byId, name, "memoryScore", "记忆力", d.getMemoryScore());
+        addDan(out, d, byId, name, "logicScore", "逻辑思维", d.getLogicScore());
+        addDan(out, d, byId, name, "perceptionScore", "感知", d.getPerceptionScore());
+        addDan(out, d, byId, name, "spatialScore", "空间思维", d.getSpatialScore());
+        addDan(out, d, byId, name, "processingSpeedScore", "加工速度", d.getProcessingSpeedScore());
+        addDan(out, d, byId, name, "languageScore", "语言表达", d.getLanguageScore());
+        addDan(out, d, byId, name, "bigFiveOverallScore", "大五综合", d.getBigFiveOverallScore());
+        addDan(out, d, byId, name, "neuroticismScore", "神经质", d.getNeuroticismScore());
+        addDan(out, d, byId, name, "extraversionScore", "外向性", d.getExtraversionScore());
+        return out;
+    }
+
+    private void addDan(List<IndicatorReading> out, DanAssessmentResult d, Map<Long, MemberProfile> byId,
+                        String name, String code, String display, Integer score) {
+        if (score == null) {
+            return;
+        }
+        IndicatorReading r = new IndicatorReading();
+        r.setSource("dan");
+        r.setCode(code);
+        r.setDisplayName(display);
+        r.setValue(String.valueOf(score));
+        r.setNumericValue(score.doubleValue());
+        r.setUnit("分");
+        r.setMemberId(d.getFamilyMemberId());
+        r.setMemberName(name);
+        out.add(r);
+    }
+
+    // ==================== 常模加载 ====================
+
+    /** DAN 常模优先复用 cognitive_norms / health_norm_reference;菌群走 attribution_norm_values */
+    private List<NormEntry> loadNorms() {
+        List<NormEntry> norms = new ArrayList<>();
+        // 菌群常模
+        try {
+            List<AttributionNormValue> values = attributionNormValueMapper.selectList(
+                    new LambdaQueryWrapper<AttributionNormValue>().eq(AttributionNormValue::getNormType, "gut_flora"));
+            for (AttributionNormValue v : values) {
+                NormEntry n = new NormEntry();
+                n.setNormType("gut_flora");
+                n.setCode(v.getCode());
+                n.setDisplayName(v.getDisplayName());
+                n.setAgeBand(v.getMemberAgeBand());
+                n.setGender(v.getGender());
+                n.setP5(toDouble(v.getP5()));
+                n.setP25(toDouble(v.getP25()));
+                n.setP50(toDouble(v.getP50()));
+                n.setP75(toDouble(v.getP75()));
+                n.setP95(toDouble(v.getP95()));
+                n.setUnit(v.getUnit());
+                n.setSource("attribution_norm_values");
+                norms.add(n);
+            }
+        } catch (Exception e) {
+            log.warn("读取 attribution_norm_values 失败(表可能未建立): {}", e.getMessage());
+        }
+        // DAN 认知常模(cognitive_norms)
+        try {
+            List<CognitiveNorm> cognitive = cognitiveNormMapper.selectList(null);
+            for (CognitiveNorm c : cognitive) {
+                NormEntry n = new NormEntry();
+                n.setNormType("dan");
+                n.setCode(c.getDimensionKey());
+                n.setDisplayName(c.getDimensionKey());
+                n.setAgeBand(null);
+                n.setGender(c.getGender());
+                n.setP5(toDouble(c.getPercentile5()));
+                n.setP25(toDouble(c.getPercentile25()));
+                n.setP50(toDouble(c.getPercentile50()));
+                n.setP75(toDouble(c.getPercentile75()));
+                n.setP95(toDouble(c.getPercentile95()));
+                n.setUnit("分");
+                n.setSource("cognitive_norms");
+                norms.add(n);
+            }
+        } catch (Exception e) {
+            log.warn("读取 cognitive_norms 失败: {}", e.getMessage());
+        }
+        // DAN 情绪/大五等维度常模(health_norm_reference)
+        try {
+            List<HealthNormReference> refs = healthNormReferenceMapper.selectList(null);
+            for (HealthNormReference h : refs) {
+                NormEntry n = new NormEntry();
+                n.setNormType("dan");
+                n.setCode(h.getDimension());
+                n.setDisplayName(h.getDimension());
+                n.setAgeBand(null);
+                n.setGender(h.getGender());
+                n.setP5(toDouble(h.getPercentile5()));
+                n.setP25(toDouble(h.getPercentile25()));
+                n.setP50(toDouble(h.getPercentile50()));
+                n.setP75(toDouble(h.getPercentile75()));
+                n.setP95(toDouble(h.getPercentile95()));
+                n.setUnit("分");
+                n.setSource("health_norm_reference");
+                norms.add(n);
+            }
+        } catch (Exception e) {
+            log.warn("读取 health_norm_reference 失败: {}", e.getMessage());
+        }
+        return norms;
+    }
+
+    // ==================== 标志位(确认问卷 / mock) ====================
+
+    private Map<String, Object> loadFlags(AttributionInput input) {
+        Map<String, Object> flags = new LinkedHashMap<>();
+        // hasDan:提供了 DAN 结果ID 即视为家中有 DAN 测评数据(B1 中间态键)
+        if (input.getDanResultIds() != null && !input.getDanResultIds().isEmpty()) {
+            flags.put("hasDan", true);
+        }
+        // 确认问卷答案 → 风险标记(Q11/Q12/Q13)
+        if (input.getConfirmSurveyRecordId() != null) {
+            try {
+                SurveyRecord record = surveyRecordMapper.selectById(input.getConfirmSurveyRecordId());
+                if (record != null && record.getAnswersJson() != null) {
+                    flags.putAll(parseSurveyFlags(record.getAnswersJson()));
+                }
+            } catch (Exception e) {
+                log.warn("读取确认问卷记录失败: {}", e.getMessage());
+            }
+        }
+        // mock/测试补充标志(E12)
+        if (input.getMockFlags() != null) {
+            flags.putAll(input.getMockFlags());
+        }
+        return flags;
+    }
+
+    /** 解析确认问卷答案为风险标记 */
+    Map<String, Object> parseSurveyFlags(String answersJson) {
+        Map<String, Object> flags = new LinkedHashMap<>();
+        try {
+            JsonNode root = objectMapper.readTree(answersJson);
+            if (root == null || !root.isObject()) {
+                return flags;
+            }
+            JsonNode answers = root.has("answers") && root.get("answers").isObject() ? root.get("answers") : root;
+            flags.put("riskMoodLow", hit(answers, "mood3m", "明显低落", "低落"));
+            flags.put("riskInsomnia", hit(answers, "sleep3m", "持续失眠", "频繁失眠"));
+            flags.put("riskSchoolRefusal", hit(answers, "schoolRefusal", "频繁"));
+            flags.put("riskSelfHarm", hit(answers, "selfHarmIdea", "有"));
+            flags.put("riskBullying", hit(answers, "bullied", "频繁"));
+            flags.put("riskEating", hit(answers, "dietingConcern", "正在节食", "明显在意"));
+            flags.put("riskPeerInvalid", hit(answers, "peerRelation", "无效", "待确认"));
+            // 已下发并作答的条件题 → 反推结论标志(Q4 只在 ironLow 时下发;Q5 只在 hasDiabetes 时下发)
+            flags.put("ironLow", Boolean.TRUE.equals(flags.get("ironLow"))
+                    || answers.has("ironMealWithMilkTea"));
+            flags.put("hasDiabetes", Boolean.TRUE.equals(flags.get("hasDiabetes"))
+                    || answers.has("diabetesDuration"));
+            // B2 submit 并入的 riskHits(Q11/Q12/Q13 命中标记)→ 风险标记
+            JsonNode riskHits = root.get("_attributionRiskHits");
+            if (riskHits != null && riskHits.isArray()) {
+                for (JsonNode rh : riskHits) {
+                    String code = rh.path("code").asText("");
+                    String value = rh.path("value").asText("");
+                    applyRiskHit(flags, code, value);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析确认问卷答案失败: {}", e.getMessage());
+        }
+        return flags;
+    }
+
+    private static boolean hit(JsonNode answers, String code, String... values) {
+        JsonNode node = answers.get(code);
+        if (node == null || node.isNull()) {
+            return false;
+        }
+        String text = node.asText();
+        for (String v : values) {
+            if (text.contains(v)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** riskHits 命中 → 风险标记(E4 弱提示:仅标记,不阻断) */
+    private static void applyRiskHit(Map<String, Object> flags, String code, String value) {
+        if (code == null) {
+            return;
+        }
+        String v = value == null ? "" : value;
+        if ("selfHarmIdea".equals(code)) {
+            flags.put("riskSelfHarm", v.contains("有"));
+        } else if ("schoolRefusal".equals(code)) {
+            flags.put("riskSchoolRefusal", v.contains("频繁"));
+        } else if ("sleep3m".equals(code)) {
+            flags.put("riskInsomnia", v.contains("持续失眠") || v.contains("频繁失眠"));
+        } else if ("mood3m".equals(code)) {
+            flags.put("riskMoodLow", v.contains("低落"));
+        } else if ("bullied".equals(code)) {
+            flags.put("riskBullying", v.contains("频繁"));
+        } else if ("dietingConcern".equals(code) || "bodyWeightConcern".equals(code)) {
+            flags.put("riskEating", v.contains("节食") || v.contains("明显在意"));
+        } else if ("peerRelation".equals(code)) {
+            flags.put("riskPeerInvalid", true);
+        }
+    }
+
+    // ==================== 叙述 ====================
+
+    private Map<String, Object> narrate(FiveDimAttributionEngine.FiveDimResult five, List<CauseLink> links,
+                                       List<FalsifiedItem> falsified, CompareResult compare, AttributionInput input) {
+        Map<String, Object> context = new LinkedHashMap<>();
+        List<Map<String, Object>> attrView = new ArrayList<>();
+        for (MemberAttribution a : five.attributions) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("memberId", a.getMemberId());
+            m.put("name", a.getName());
+            m.put("roleLabel", a.getRoleLabel());
+            m.put("surface", a.getSurface());
+            m.put("essence", a.getEssence());
+            m.put("fiveDim", a.getFiveDim());
+            m.put("weakDimensions", a.getWeakDimensions());
+            m.put("abnormalCount", a.getAbnormalCount());
+            attrView.add(m);
+        }
+        context.put("attributions", attrView);
+        context.put("familyMap", five.familyMap);
+        context.put("mainCauseChain", links);
+        context.put("falsified", falsified);
+        context.put("intermediateState", compare.getIntermediateState());
+        try {
+            return attributionNarrativeService.narrate(context, null);
+        } catch (Exception e) {
+            log.warn("归因叙述失败,走规则兜底: {}", e.getMessage());
+            Map<String, Object> fallback = new LinkedHashMap<>();
+            fallback.put("conclusionHeadline", "本轮归因以规则结论为准");
+            fallback.put("closingLine", "先共享基座后精准,12 周可复盘检验。");
+            fallback.put("coachQuestions", new ArrayList<String>());
+            fallback.put("narratedBy", "rule");
+            return fallback;
+        }
+    }
+
+    // ==================== 工具 ====================
+
+    AttributionReportVO toVO(AttributionReport report) {
+        AttributionReportVO vo = new AttributionReportVO();
+        vo.setAttributionId(report.getId());
+        vo.setFamilyId(report.getFamilyId());
+        vo.setPlanId(report.getPlanId());
+        vo.setStatus(report.getStatus());
+        vo.setReportJson(report.getReportJson());
+        vo.setMemberIds(parseIds(report.getMemberIds()));
+        vo.setRedlineHits(parseStringList(report.getRedlineHits()));
+        vo.setIntermediateState(parseMap(report.getIntermediateState()));
+        return vo;
+    }
+
+    String toJsonArray(List<String> list) {
+        try {
+            return objectMapper.writeValueAsString(list == null ? new ArrayList<String>() : list);
+        } catch (Exception e) {
+            return "[]";
+        }
+    }
+
+    String toJsonMap(Map<String, Object> map) {
+        try {
+            return objectMapper.writeValueAsString(map == null ? new LinkedHashMap<String, Object>() : map);
+        } catch (Exception e) {
+            return "{}";
+        }
+    }
+
+    Map<String, Object> parseMap(String json) {
+        Map<String, Object> out = new LinkedHashMap<>();
+        if (json == null || json.trim().isEmpty()) {
+            return out;
+        }
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node != null && node.isObject()) {
+                Iterator<Map.Entry<String, JsonNode>> it = node.fields();
+                while (it.hasNext()) {
+                    Map.Entry<String, JsonNode> e = it.next();
+                    out.put(e.getKey(), toJava(e.getValue()));
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析 JSON 对象失败: {}", e.getMessage());
+        }
+        return out;
+    }
+
+    List<String> parseStringList(String json) {
+        List<String> out = new ArrayList<>();
+        if (json == null || json.trim().isEmpty()) {
+            return out;
+        }
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node != null && node.isArray()) {
+                for (JsonNode n : node) {
+                    out.add(n.asText());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析 JSON 数组失败: {}", e.getMessage());
+        }
+        return out;
+    }
+
+    private Object toJava(JsonNode node) {
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        if (node.isBoolean()) {
+            return node.booleanValue();
+        }
+        if (node.isNumber()) {
+            return node.numberValue();
+        }
+        if (node.isTextual()) {
+            return node.textValue();
+        }
+        if (node.isArray()) {
+            List<Object> list = new ArrayList<>();
+            for (JsonNode item : node) {
+                list.add(toJava(item));
+            }
+            return list;
+        }
+        return node.toString();
+    }
+
+    private List<Long> parseIds(String csv) {
+        List<Long> out = new ArrayList<>();
+        if (csv == null || csv.trim().isEmpty()) {
+            return out;
+        }
+        for (String s : csv.split(",")) {
+            try {
+                if (!s.trim().isEmpty()) {
+                    out.add(Long.valueOf(s.trim()));
+                }
+            } catch (NumberFormatException ignored) {
+                // 跳过非法 ID
+            }
+        }
+        return out;
+    }
+
+    private String joinIds(List<Long> ids) {
+        if (ids == null || ids.isEmpty()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        for (Long id : ids) {
+            if (id == null) {
+                continue;
+            }
+            if (sb.length() > 0) {
+                sb.append(',');
+            }
+            sb.append(id);
+        }
+        return sb.toString();
+    }
+
+    private static Double parseDouble(String s) {
+        if (s == null) {
+            return null;
+        }
+        try {
+            return Double.valueOf(s.replace("%", "").replace("%", "").trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static Double toDouble(java.math.BigDecimal d) {
+        return d == null ? null : d.doubleValue();
+    }
+
+    private static Double toDouble(Integer i) {
+        return i == null ? null : i.doubleValue();
+    }
+
+    private static boolean truthy(Object v) {
+        if (v == null) {
+            return false;
+        }
+        if (v instanceof Boolean) {
+            return (Boolean) v;
+        }
+        return Boolean.parseBoolean(String.valueOf(v));
+    }
+
+    private static String str(Object v, String fallback) {
+        if (v == null) {
+            return fallback;
+        }
+        String s = String.valueOf(v);
+        return s.trim().isEmpty() ? fallback : s;
+    }
+}

+ 513 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/AttributionPlanServiceImpl.java

@@ -0,0 +1,513 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.constant.AttributionRedlines;
+import com.etotem.cfc.dto.FamilyPlanVO;
+import com.etotem.cfc.entity.AttributionReport;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.mapper.AttributionReportMapper;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.service.AttributionPlanService;
+import com.etotem.cfc.util.AttributionRoleUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * {@link AttributionPlanService} 默认实现(B4)。
+ *
+ * <p>归因结论(CONFIRMED)→ 七章 planJson。规则表驱动(不调 AI):
+ * 产品映射(E7 品牌级)+ 分人×层级动作表 + 共享基座 + 12 周节奏 + 红线前置(RL01-RL10)。
+ * E6:仅妈妈/女儿/儿子;爸爸跳过并记 warning。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B4)
+ */
+@Service
+public class AttributionPlanServiceImpl implements AttributionPlanService {
+
+    private static final Logger log = LoggerFactory.getLogger(AttributionPlanServiceImpl.class);
+
+    public static final String PLAN_TYPE = "family_attribution_plan";
+
+    @Resource
+    private AttributionReportMapper attributionReportMapper;
+
+    @Resource
+    private HealthPlanMapper healthPlanMapper;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    @Override
+    public FamilyPlanVO generateFamilyPlan(Long attributionId, Long userId) {
+        AttributionReport report = attributionReportMapper.selectById(attributionId);
+        if (report == null) {
+            return null;
+        }
+        if (!AttributionReport.STATUS_CONFIRMED.equals(report.getStatus())
+                && !AttributionReport.STATUS_GENERATED.equals(report.getStatus())) {
+            throw new IllegalStateException("归因结论尚未确认(status=" + report.getStatus()
+                    + "),请先完成 ④ 调整确认(confirm-adjust)后再生成方案");
+        }
+
+        JsonNode reportJson = parseJson(report.getReportJson());
+        JsonNode sections = reportJson != null ? reportJson.get("sections") : null;
+
+        // E6 人范围裁剪:仅妈妈/女儿/儿子;爸爸跳过
+        List<Map<String, Object>> inScope = new ArrayList<>();
+        List<String> warnings = new ArrayList<>();
+        List<Long> inScopeIds = new ArrayList<>();
+        JsonNode perMember = sections != null ? sections.get("perMember") : null;
+        if (perMember != null && perMember.isArray()) {
+            for (JsonNode m : perMember) {
+                String role = m.path("role").asText("unknown");
+                if (!AttributionRoleUtil.inPlanScope(role)) {
+                    warnings.add("成员「" + m.path("name").asText("") + "」(" + m.path("roleLabel").asText(role)
+                            + ")不在方案人范围(E6:妈妈/女儿/儿子),跳过");
+                    continue;
+                }
+                Map<String, Object> row = new LinkedHashMap<>();
+                row.put("memberId", m.path("memberId").asLong());
+                row.put("name", m.path("name").asText(""));
+                row.put("role", role);
+                row.put("roleLabel", m.path("roleLabel").asText(role));
+                row.put("surface", m.path("surface").asText(""));
+                row.put("essence", m.path("essence").asText(""));
+                row.put("weakDimensions", stringList(m.get("weakDimensions")));
+                row.put("actions", actionsForRole(role));
+                inScope.add(row);
+                inScopeIds.add(m.path("memberId").asLong());
+            }
+        }
+
+        ObjectNode plan = buildPlan(report, sections, inScope, warnings);
+
+        // 存 health_plans(plan_type=family_attribution_plan,status=GENERATED)
+        HealthPlan hp = new HealthPlan();
+        hp.setFamilyId(report.getFamilyId());
+        hp.setMemberIds(joinIds(inScopeIds));
+        hp.setDimensions("family_attribution");
+        hp.setGoal("全家归因干预:妈妈/女儿/儿子 12 周基线(先共享基座后精准)");
+        hp.setPlanType(PLAN_TYPE);
+        hp.setPlanJson(plan.toString());
+        hp.setStatus("GENERATED");
+        hp.setTotalDays(84);
+        hp.setCreatedAt(new Date());
+        hp.setUpdatedAt(new Date());
+        healthPlanMapper.insert(hp);
+
+        // 报告回填 planId + status=GENERATED
+        report.setPlanId(hp.getId());
+        report.setStatus(AttributionReport.STATUS_GENERATED);
+        report.setUpdatedAt(new Date());
+        try {
+            if (reportJson instanceof ObjectNode) {
+                ((ObjectNode) reportJson).put("status", AttributionReport.STATUS_GENERATED);
+                ((ObjectNode) reportJson).put("planId", hp.getId());
+                report.setReportJson(objectMapper.writeValueAsString(reportJson));
+            }
+        } catch (Exception e) {
+            log.warn("generateFamilyPlan: 报告 JSON 同步失败 attributionId={}: {}", attributionId, e.getMessage());
+        }
+        attributionReportMapper.updateById(report);
+
+        FamilyPlanVO vo = new FamilyPlanVO();
+        vo.setPlanId(hp.getId());
+        vo.setAttributionId(attributionId);
+        vo.setPlanType(PLAN_TYPE);
+        vo.setPlanJson(plan.toString());
+        return vo;
+    }
+
+    @Override
+    public List<Map<String, Object>> extractActionTable(String planJson, String level) {
+        List<Map<String, Object>> out = new ArrayList<>();
+        if (planJson == null || planJson.trim().isEmpty()) {
+            return out;
+        }
+        try {
+            JsonNode root = objectMapper.readTree(planJson);
+            JsonNode chapters = root.get("chapters");
+            if (chapters == null || !chapters.isArray()) {
+                return out;
+            }
+            for (JsonNode ch : chapters) {
+                JsonNode members = ch.get("members");
+                if (members == null || !members.isArray()) {
+                    continue;
+                }
+                for (JsonNode m : members) {
+                    String member = m.path("member").asText("");
+                    JsonNode actions = m.get("actions");
+                    if (actions == null || !actions.isArray()) {
+                        continue;
+                    }
+                    for (JsonNode a : actions) {
+                        String lv = a.path("level").asText("");
+                        if (level != null && !level.trim().isEmpty() && !lv.equals(level.trim())) {
+                            continue;
+                        }
+                        Map<String, Object> row = new LinkedHashMap<>();
+                        row.put("member", member);
+                        row.put("level", lv);
+                        row.put("action", a.path("action").asText(""));
+                        row.put("product", a.hasNonNull("product") ? a.get("product").asText() : null);
+                        row.put("how", a.path("how").asText(""));
+                        row.put("reviewHook", a.path("reviewHook").asText(""));
+                        out.add(row);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("extractActionTable 解析失败: {}", e.getMessage());
+        }
+        return out;
+    }
+
+    // ==================== 七章构建 ====================
+
+    private ObjectNode buildPlan(AttributionReport report, JsonNode sections, List<Map<String, Object>> inScope,
+                                 List<String> warnings) {
+        ObjectNode plan = objectMapper.createObjectNode();
+        plan.put("schemaVersion", "1.0");
+        plan.put("planType", PLAN_TYPE);
+        plan.put("generatedAt", new Date().toInstant().toString());
+
+        // header
+        ObjectNode header = plan.putObject("header");
+        header.put("summary", buildSummary(sections));
+        ArrayNode scope = header.putArray("scope");
+        for (String s : new String[]{"妈妈", "女儿", "儿子"}) {
+            scope.add(s);
+        }
+        ArrayNode dataSources = header.putArray("dataSources");
+        dataSources.addAll(buildDataSources(sections));
+        header.put("relation", "归因报告负责定位+转介;本方案负责可执行的产品+营养+生活方式");
+        header.put("order", "先共享基座后精准(第 4 章先做,第 3 章精准)");
+        ArrayNode headerRedlines = header.putArray("redlines");
+        for (String code : new String[]{"RL01", "RL03", "RL05"}) {
+            headerRedlines.add(code);
+        }
+        if (!warnings.isEmpty()) {
+            ArrayNode warns = header.putArray("warnings");
+            for (String w : warnings) {
+                warns.add(w);
+            }
+        }
+
+        ArrayNode chapters = plan.putArray("chapters");
+
+        // 第 1 章:三人干预相关数据速览
+        ObjectNode ch1 = chapters.addObject();
+        ch1.put("no", 1);
+        ch1.put("title", "三人干预相关数据速览");
+        ArrayNode rows = ch1.putArray("rows");
+        addDataRows(rows, sections);
+
+        // 第 2 章:主线产品
+        ObjectNode ch2 = chapters.addObject();
+        ch2.put("no", 2);
+        ch2.put("title", "主线产品:作用原理·合规口径·用法");
+        ArrayNode products = ch2.putArray("products");
+        products.add(product("Enagic 还原水", "Enagic", "电解还原水(日常饮水与冲泡介质)",
+                "三人均存在水合与冲泡介质需求", "日常直饮+做饭+冲泡(≤40℃)", new String[]{"RL05", "RL09"}));
+        products.add(product("Enagic 姜黄", "Enagic", "姜黄(食品级膳食支持)",
+                "成人(妈妈)炎症/胆汁酸维度的膳食辅助", "按产品说明使用;不替代药物", new String[]{"RL05"}));
+        products.add(product("瓜尔豆益生元 PHGG", "瓜尔豆", "部分水解瓜尔胶(水溶性益生元)",
+                "肠道菌群重建与多样性支持", "成人 5g/天起;儿童剂量由医生定", new String[]{"RL05", "RL09"}));
+        products.add(product("益生菌组合(长双歧 BB536 / 乳双歧 HN019 / 布拉氏酵母菌)", "益生菌株",
+                "特定菌株膳食支持(不宣称治疗)", "分人方案按菌株对应补充", "成人按产品说明;儿童剂量医生定", new String[]{"RL05", "RL09"}));
+
+        // 第 3 章:分人方案
+        ObjectNode ch3 = chapters.addObject();
+        ch3.put("no", 3);
+        ch3.put("title", "分人方案(妈妈/女儿/儿子)");
+        ArrayNode members = ch3.putArray("members");
+        for (Map<String, Object> m : inScope) {
+            ObjectNode mn = members.addObject();
+            mn.put("member", String.valueOf(m.get("roleLabel")));
+            mn.put("direction", direction((String) m.get("role")));
+            mn.put("surface", String.valueOf(m.get("surface")));
+            mn.put("essence", String.valueOf(m.get("essence")));
+            @SuppressWarnings("unchecked")
+            List<Map<String, String>> actions = (List<Map<String, String>>) m.get("actions");
+            ArrayNode acts = mn.putArray("actions");
+            for (Map<String, String> a : actions) {
+                ObjectNode an = acts.addObject();
+                an.put("level", a.get("level"));
+                an.put("action", a.get("action"));
+                if (a.get("product") != null) {
+                    an.put("product", a.get("product"));
+                } else {
+                    an.putNull("product");
+                }
+                an.put("how", a.get("how"));
+                an.put("reviewHook", a.get("reviewHook"));
+            }
+        }
+
+        // 第 4 章:家庭共享基座(先做)
+        ObjectNode ch4 = chapters.addObject();
+        ch4.put("no", 4);
+        ch4.put("title", "家庭厨房与饮水改造(共享基座)");
+        ch4.put("order", "先做");
+        ArrayNode channels = ch4.putArray("channels");
+        channels.addObject().put("channel", "饮水通道").put("minStep", "全屋切换还原水").put("owner", "妈妈").put("reviewHook", "一周饮水量自评");
+        channels.addObject().put("channel", "厨房通道").put("minStep", "油炸/外食 ≤1 次/周;铁餐与奶/茶/咖啡错开≥2h 配维C").put("owner", "妈妈").put("reviewHook", "每周菜单复盘");
+        channels.addObject().put("channel", "作息通道").put("minStep", "固定睡眠时段与排便记录").put("owner", "全家").put("reviewHook", "2 周自评");
+
+        // 第 5 章:12 周节奏与可检验目标
+        ObjectNode ch5 = chapters.addObject();
+        ch5.put("no", 5);
+        ch5.put("title", "12 周执行节奏与可检验目标");
+        ArrayNode weeks = ch5.putArray("weeks");
+        weeks.addObject().put("week", "第 1-2 周").put("family", "还原水入户+厨房改造启动").put("mom", "查血(先医后补)").put("daughter", "情绪/睡眠复核").put("son", "儿科查铁锌");
+        weeks.addObject().put("week", "第 3-8 周").put("family", "PHGG/菌株按人引入(儿童剂量医生定)").put("mom", "铁餐纪律+还原水日常").put("daughter", "双歧重建").put("son", "排便节律");
+        weeks.addObject().put("week", "第 9-12 周").put("family", "执行复盘+复测排期").put("mom", "4-8 周复查血").put("daughter", "12 周复测菌群").put("son", "医生定复查");
+        ArrayNode goals = ch5.putArray("goals");
+        addGoals(goals, sections);
+
+        // 第 6 章:红线与转介(再强调)
+        ObjectNode ch6 = chapters.addObject();
+        ch6.put("no", 6);
+        ch6.put("title", "红线与转介(再强调)");
+        ArrayNode redlineArr = ch6.putArray("redlines");
+        for (String code : new String[]{"RL01", "RL03", "RL04", "RL05", "RL06", "RL09", "RL10"}) {
+            ObjectNode rn = redlineArr.addObject();
+            AttributionRedlines.Redline r = AttributionRedlines.byCode(code);
+            rn.put("code", code);
+            rn.put("title", r == null ? code : r.title);
+            rn.put("message", r == null ? "" : r.message);
+        }
+
+        // 第 7 章:附录
+        ObjectNode ch7 = chapters.addObject();
+        ch7.put("no", 7);
+        ch7.put("title", "附录:产品选购与用法提示");
+        ArrayNode appendix = ch7.putArray("appendix");
+        appendix.addObject().put("product", "Enagic 还原水").put("select", "电解还原水机(品牌级展示,不附功效承诺)").put("use", "日常直饮+做饭+冲泡(≤40℃);不与药物同服,间隔≥1h").put("pitfall", "有特殊病史先咨询医生(RL05)");
+        appendix.addObject().put("product", "瓜尔豆益生元 PHGG").put("select", "食品级部分水解瓜尔胶").put("use", "成人 5g/天起;儿童剂量由医生定").put("pitfall", "不与抗生素同服;用后停纤维 3-5 天再做菌群重建(RL09)");
+        appendix.addObject().put("product", "益生菌株").put("select", "长双歧 BB536 / 乳双歧 HN019 / 布拉氏酵母菌").put("use", "成人按产品说明;儿童剂量医生定").put("pitfall", "与抗生素间隔≥2h(RL09);不宣称治疗(RL05)");
+        return plan;
+    }
+
+    // ==================== 数据速览 / 目标 / 摘要 ====================
+
+    private void addDataRows(ArrayNode rows, JsonNode sections) {
+        if (sections == null) {
+            return;
+        }
+        JsonNode perMember = sections.get("perMember");
+        if (perMember == null || !perMember.isArray()) {
+            return;
+        }
+        int limit = 0;
+        for (JsonNode m : perMember) {
+            String role = m.path("role").asText("unknown");
+            if (!AttributionRoleUtil.inPlanScope(role)) {
+                continue;
+            }
+            String label = m.path("roleLabel").asText(role);
+            JsonNode dims = m.get("fiveDim");
+            if (dims != null && dims.isObject()) {
+                ObjectNode row = rows.addObject();
+                row.put("indicator", label + " 五维短板");
+                row.put("mom", role.equals("mom") ? String.join("/", stringList(m.get("weakDimensions"))) : "—");
+                row.put("daughter", role.equals("daughter") ? String.join("/", stringList(m.get("weakDimensions"))) : "—");
+                row.put("son", role.equals("son") ? String.join("/", stringList(m.get("weakDimensions"))) : "—");
+                row.put("meaning", label + " 主因维(" + String.join("/", stringList(m.get("weakDimensions"))) + ")");
+                if (limit++ >= 20) {
+                    break;
+                }
+            }
+        }
+        JsonNode overview = sections.get("overview");
+        if (overview != null && overview.isArray() && limit < 20) {
+            Map<String, String> rowMap = new LinkedHashMap<>();
+            for (JsonNode o : overview) {
+                String label = o.path("roleLabel").asText("成员");
+                String key = "mom".equals(label) ? "mom" : "daughter".equals(label) ? "daughter"
+                        : "son".equals(label) ? "son" : null;
+                if (key == null) {
+                    continue;
+                }
+                String cell = o.path("indicator").asText("") + " " + o.path("percentile").asText("")
+                        + "(" + o.path("status").asText("") + ")";
+                rowMap.merge("indicator" + o.path("indicator").asText(""), cell, (a, b) -> a + ";" + b);
+            }
+            for (Map.Entry<String, String> e : rowMap.entrySet()) {
+                if (limit++ >= 20) {
+                    break;
+                }
+                ObjectNode row = rows.addObject();
+                row.put("indicator", e.getKey().replaceFirst("^indicator", ""));
+                row.put("meaning", e.getValue());
+            }
+        }
+    }
+
+    private void addGoals(ArrayNode goals, JsonNode sections) {
+        if (sections == null) {
+            return;
+        }
+        JsonNode reportGoals = sections.get("goals");
+        int limit = 0;
+        if (reportGoals != null && reportGoals.isArray()) {
+            for (JsonNode g : reportGoals) {
+                if (limit++ >= 8) {
+                    break;
+                }
+                ObjectNode goal = goals.addObject();
+                goal.put("indicator", g.path("indicator").asText(""));
+                goal.put("now", g.path("now").asText(""));
+                goal.put("target", g.path("target").asText(""));
+                goal.put("owner", g.path("owner").asText(""));
+            }
+        }
+    }
+
+    private String buildSummary(JsonNode sections) {
+        String headline = sections != null && sections.has("conclusion")
+                ? sections.get("conclusion").path("headline").asText("") : "";
+        return "归因结论:" + (headline.isEmpty() ? "待补项补齐后再定位" : headline)
+                + " → 妈妈/女儿/儿子 12 周干预基线(先共享基座后精准)";
+    }
+
+    private ArrayNode buildDataSources(JsonNode sections) {
+        ArrayNode arr = objectMapper.createArrayNode();
+        int gut = 0;
+        int dan = 0;
+        JsonNode overview = sections != null ? sections.get("overview") : null;
+        if (overview != null && overview.isArray()) {
+            for (JsonNode o : overview) {
+                String src = o.path("normSource").asText("");
+                if (src.contains("cognitive_norms") || src.contains("health_norm_reference") || src.contains("dan")) {
+                    dan++;
+                } else if (src.contains("populationLevel") || src.contains("attribution_norm_values")) {
+                    gut++;
+                }
+            }
+        }
+        if (gut > 0) {
+            arr.add("谷禾菌群读数 " + gut + " 项");
+        }
+        if (dan > 0) {
+            arr.add("DAN 读数 " + dan + " 项");
+        }
+        arr.add("父亲进阶方案(背景,不生成方案)");
+        return arr;
+    }
+
+    // ==================== 产品 / 动作模板 ====================
+
+    private ObjectNode product(String name, String brand, String what, String why, String how, String[] compliance) {
+        ObjectNode p = objectMapper.createObjectNode();
+        p.put("name", name);
+        p.put("brand", brand);
+        p.put("what", what);
+        p.put("why", why);
+        p.put("how", how);
+        ArrayNode comp = p.putArray("compliance");
+        for (String c : compliance) {
+            comp.add(c);
+        }
+        return p;
+    }
+
+    /** 分人×层级动作表(规则模板驱动;儿童动作不含剂量数字,剂量一律「医生定」) */
+    private List<Map<String, String>> actionsForRole(String role) {
+        List<Map<String, String>> out = new ArrayList<>();
+        if ("mom".equals(role)) {
+            out.add(action("P0", "查血不自行补铁", null, "血常规+血清铁+铁蛋白;先医后补,不给剂量", "4-8 周复查"));
+            out.add(action("P1", "降胆汁酸/肠道环境", "厨房改造+PHGG", "油炸/外食 ≤1 次/周;PHGG 成人 5g/天起", "12 周复测"));
+            out.add(action("daily", "还原水日常直饮+做饭冲泡", "Enagic 还原水", "日常直饮+冲泡(≤40℃);不与药物同服间隔≥1h", "一周≥5 天执行"));
+            out.add(action("P2", "益生菌株补充(如无在服)", "长双歧 BB536", "按产品说明;与抗生素间隔≥2h(RL09)", "4 周自评"));
+        } else if ("daughter".equals(role)) {
+            out.add(action("P0", "情绪/睡眠/拒学/自伤信号复核", null, "若命中 → 建议专业评估+转介信息露出+家长确认(E4 弱提示不阻断)", "2 周复评"));
+            out.add(action("P0", "月经相关铁锌查血", null, "儿科/儿保科按体重与检验结果定;剂量医生定", "医生定复查时间"));
+            out.add(action("P1", "双歧重建", "长双歧 BB536", "按儿科建议服用;剂量医生定", "12 周复测双歧"));
+            out.add(action("daily", "饮水与睡眠节律", "Enagic 还原水", "日常直饮;固定睡眠时段", "睡眠记录周自评"));
+            out.add(action("P2", "益生菌株替换评估(如有在服)", "乳双歧 HN019", "与现有补充剂间隔足够时间;剂量医生定", "4 周自评"));
+        } else if ("son".equals(role)) {
+            out.add(action("P0", "儿科查铁锌", null, "儿科/儿保科按体重与检验结果定;剂量医生定", "医生定复查时间"));
+            out.add(action("P1", "菌群重建(如有抗生素史)", "布拉氏酵母菌+PHGG", "按儿科建议;剂量医生定;PHGG 不与抗生素同服", "12 周复测"));
+            out.add(action("daily", "饮食与排便节律", "Enagic 还原水", "日常直饮;排便习惯记录", "2 周自评"));
+            out.add(action("P2", "益生菌株补充", "乳双歧 HN019", "按儿科建议;剂量医生定", "4 周自评"));
+        }
+        return out;
+    }
+
+    private Map<String, String> action(String level, String action, String product, String how, String reviewHook) {
+        Map<String, String> m = new LinkedHashMap<>();
+        m.put("level", level);
+        m.put("action", action);
+        m.put("product", product);
+        m.put("how", how);
+        m.put("reviewHook", reviewHook);
+        return m;
+    }
+
+    private String direction(String role) {
+        if ("mom".equals(role)) {
+            return "减负担+通吸收";
+        }
+        if ("daughter".equals(role)) {
+            return "双歧重建+情绪与经期复核";
+        }
+        if ("son".equals(role)) {
+            return "铁锌查漏+菌群重建";
+        }
+        return "";
+    }
+
+    // ==================== 工具 ====================
+
+    private JsonNode parseJson(String json) {
+        if (json == null || json.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            return objectMapper.readTree(json);
+        } catch (Exception e) {
+            log.warn("解析 JSON 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private List<String> stringList(JsonNode node) {
+        List<String> out = new ArrayList<>();
+        if (node != null && node.isArray()) {
+            for (JsonNode n : node) {
+                out.add(n.asText());
+            }
+        }
+        return out;
+    }
+
+    private String joinIds(List<Long> ids) {
+        if (ids == null || ids.isEmpty()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        for (Long id : ids) {
+            if (sb.length() > 0) {
+                sb.append(',');
+            }
+            sb.append(id);
+        }
+        return sb.toString();
+    }
+}

+ 319 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/ConditionEvaluatorServiceImpl.java

@@ -0,0 +1,319 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.ConditionEvaluatorService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * {@link ConditionEvaluatorService} 默认实现:纯规则求值,无框架依赖,可单测。
+ */
+@Service
+public class ConditionEvaluatorServiceImpl implements ConditionEvaluatorService {
+
+    private static final Logger log = LoggerFactory.getLogger(ConditionEvaluatorServiceImpl.class);
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    @Override
+    public boolean evaluate(JsonNode condition, Map<String, Object> context) {
+        // null = 无条件恒发
+        if (condition == null || condition.isNull()) {
+            return true;
+        }
+        if (!condition.isObject()) {
+            return false;
+        }
+        Map<String, Object> ctx = (context == null) ? new LinkedHashMap<>() : context;
+
+        JsonNode opNode = condition.get("op");
+        String op = (opNode == null || opNode.isNull()) ? null : opNode.asText();
+
+        // 组合运算:anyOf/or = OR(items);and/allOf = AND(items);无 op 时 items 缺省 AND
+        if ("anyOf".equals(op) || "or".equals(op)) {
+            return evaluateItems(firstArray(condition, "items"), ctx, false);
+        }
+        if ("and".equals(op) || "allOf".equals(op)) {
+            return evaluateItems(firstArray(condition, "items"), ctx, true);
+        }
+        if (op == null) {
+            JsonNode items = firstArray(condition, "items");
+            if (items != null) {
+                return evaluateItems(items, ctx, true);
+            }
+            // 无 op 又无组合键:结构不完整,保守 false
+            return false;
+        }
+        JsonNode varNode = condition.get("var");
+        String var = (varNode == null || varNode.isNull()) ? null : varNode.asText();
+
+        if ("exists".equals(op)) {
+            if (var == null) return false;
+            return exists(ctx, var);
+        }
+        if (var == null) {
+            return false;
+        }
+        if (!ctx.containsKey(var)) {
+            // 未知 var:保守 false(避免误发敏感题)
+            return false;
+        }
+        JsonNode valNode = condition.get("val");
+        Object actual = ctx.get(var);
+
+        if ("==".equals(op)) return equalsValues(actual, valNode);
+        if ("!=".equals(op)) return !equalsValues(actual, valNode);
+        if ("contains".equals(op)) return contains(actual, valNode);
+        if ("in".equals(op)) return in(actual, valNode);
+        if (">".equals(op) || ">=".equals(op) || "<".equals(op) || "<=".equals(op)) {
+            return compareNumbers(actual, valNode, op);
+        }
+        // 未知 op:保守 false
+        return false;
+    }
+
+    /** AND:全部命中才成立;OR:任一命中即成立;空数组保守 false */
+    private boolean evaluateItems(JsonNode items, Map<String, Object> ctx, boolean and) {
+        if (items == null || !items.isArray() || items.size() == 0) {
+            return false;
+        }
+        for (JsonNode item : items) {
+            boolean r = evaluate(item, ctx);
+            if (and && !r) {
+                return false;
+            }
+            if (!and && r) {
+                return true;
+            }
+        }
+        return and;
+    }
+
+    private static JsonNode firstArray(JsonNode node, String... names) {
+        for (String name : names) {
+            JsonNode child = node.get(name);
+            if (child != null && child.isArray()) {
+                return child;
+            }
+        }
+        return null;
+    }
+
+    /** exists:var 在 context 且非 null、非空串 */
+    private boolean exists(Map<String, Object> ctx, String var) {
+        if (!ctx.containsKey(var)) return false;
+        Object v = ctx.get(var);
+        if (v == null) return false;
+        if (v instanceof String) return !((String) v).trim().isEmpty();
+        return true;
+    }
+
+    private boolean equalsValues(Object actual, JsonNode expected) {
+        if (expected == null || expected.isNull()) {
+            return actual == null;
+        }
+        if (actual == null) {
+            return false;
+        }
+        Object a = actual instanceof JsonNode ? toJava((JsonNode) actual) : actual;
+        if (expected.isBoolean() || a instanceof Boolean) {
+            boolean e = expected.asBoolean();
+            boolean av = (a instanceof Boolean) ? (Boolean) a : Boolean.parseBoolean(String.valueOf(a));
+            return e == av;
+        }
+        if (expected.isNumber() && a instanceof Number) {
+            return toDecimal(a).compareTo(expected.decimalValue()) == 0;
+        }
+        if (expected.isArray() || a instanceof List || a instanceof Object[]) {
+            return false;
+        }
+        String bs = expected.asText();
+        String as = String.valueOf(a);
+        if (isNumeric(as) && isNumeric(bs)) {
+            return new BigDecimal(as).compareTo(new BigDecimal(bs)) == 0;
+        }
+        return as.equals(bs);
+    }
+
+    private boolean contains(Object actual, JsonNode valNode) {
+        if (actual == null || valNode == null) return false;
+        Object a = actual instanceof JsonNode ? toJava((JsonNode) actual) : actual;
+        if (a instanceof Object[] || a instanceof Iterable) {
+            List<Object> list = new ArrayList<>();
+            if (a instanceof Object[]) {
+                for (Object o : (Object[]) a) list.add(o);
+            } else {
+                for (Iterator<?> it = ((Iterable<?>) a).iterator(); it.hasNext(); ) list.add(it.next());
+            }
+            for (Object o : list) {
+                if (equalsValues(o, valNode)) return true;
+            }
+            return false;
+        }
+        if (a instanceof String) {
+            return ((String) a).contains(valNode.asText());
+        }
+        return false;
+    }
+
+    private boolean in(Object actual, JsonNode valNode) {
+        if (actual == null || valNode == null || !valNode.isArray()) return false;
+        for (JsonNode item : valNode) {
+            if (equalsValues(actual, item)) return true;
+        }
+        return false;
+    }
+
+    private boolean compareNumbers(Object actual, JsonNode expected, String op) {
+        if (expected == null || expected.isNull() || actual == null) return false;
+        Object a = actual instanceof JsonNode ? toJava((JsonNode) actual) : actual;
+        BigDecimal x;
+        BigDecimal y;
+        try {
+            x = toDecimal(a);
+            y = expected.isNumber() ? expected.decimalValue() : new BigDecimal(expected.asText().trim());
+        } catch (Exception e) {
+            return false;
+        }
+        int c = x.compareTo(y);
+        if (">".equals(op)) return c > 0;
+        if (">=".equals(op)) return c >= 0;
+        if ("<".equals(op)) return c < 0;
+        if ("<=".equals(op)) return c <= 0;
+        return false;
+    }
+
+    private BigDecimal toDecimal(Object a) {
+        if (a instanceof Number) return new BigDecimal(String.valueOf(a));
+        return new BigDecimal(String.valueOf(a).trim());
+    }
+
+    private boolean isNumeric(String s) {
+        if (s == null || s.trim().isEmpty()) return false;
+        try {
+            new BigDecimal(s.trim());
+            return true;
+        } catch (NumberFormatException e) {
+            return false;
+        }
+    }
+
+    private Object toJava(JsonNode node) {
+        if (node == null || node.isNull()) return null;
+        if (node.isBoolean()) return node.booleanValue();
+        if (node.isNumber()) return node.numberValue();
+        if (node.isTextual()) return node.textValue();
+        if (node.isArray()) {
+            List<Object> list = new ArrayList<>();
+            for (JsonNode item : node) list.add(toJava(item));
+            return list;
+        }
+        if (node.isObject()) return node.toString();
+        return node.toString();
+    }
+
+    @Override
+    public Map<String, Object> buildContext(Long familyId, Long memberId, Long attributionId) {
+        Map<String, Object> ctx = new LinkedHashMap<>();
+        if (familyId != null) ctx.put("familyId", familyId);
+        if (memberId != null) ctx.put("memberId", memberId);
+
+        // 归因中间态(阶段二 B1 写入 attribution_reports.intermediate_state)
+        ctx.putAll(loadIntermediateState(attributionId));
+
+        if (memberId != null) {
+            FamilyMember member = null;
+            try {
+                member = familyMemberMapper.selectById(memberId);
+            } catch (Exception e) {
+                log.warn("buildContext: 读取家庭成员失败 memberId={}: {}", memberId, e.getMessage());
+            }
+            if (member != null) {
+                int age = 0;
+                try {
+                    age = member.getAge();
+                } catch (Exception ignored) {
+                    // birthday 为 null 时 getAge() 返回 0
+                }
+                ctx.put("gender", member.getGender());
+                ctx.put("age", age);
+                ctx.put("ageBand", ageBand(age));
+                ctx.put("isMinor", age < 18);
+                ctx.put("isFemaleTeen", "female".equalsIgnoreCase(member.getGender()) && age >= 13 && age <= 18);
+                ctx.put("role", roleOf(member));
+                ctx.put("generation", member.getGeneration());
+            }
+        }
+        return ctx;
+    }
+
+    /** 年龄段带(与 attribution_norm_values.member_age_band 口径一致) */
+    static String ageBand(int age) {
+        if (age <= 3) return "0-3";
+        if (age <= 6) return "4-6";
+        if (age <= 12) return "7-12";
+        if (age <= 18) return "13-18";
+        if (age <= 45) return "19-45";
+        if (age <= 65) return "46-65";
+        return "66+";
+    }
+
+    /** 角色:0=家庭创建者(self),负数=向下n代(child),正数=向上n代(elder),配偶=spouse */
+    static String roleOf(FamilyMember m) {
+        if (Boolean.TRUE.equals(m.getIsSpouse())) return "spouse";
+        Integer generation = m.getGeneration();
+        if (generation == null) return "self";
+        if (generation < 0) return "child";
+        if (generation > 0) return "elder";
+        return "self";
+    }
+
+    /**
+     * 读取归因中间态。表未建立/无行时静默返回空(阶段一无 attribution_reports 表)。
+     */
+    private Map<String, Object> loadIntermediateState(Long attributionId) {
+        Map<String, Object> out = new LinkedHashMap<>();
+        if (attributionId == null) {
+            return out;
+        }
+        try {
+            String state = jdbcTemplate.queryForObject(
+                    "SELECT intermediate_state FROM attribution_reports WHERE id = ?", String.class, attributionId);
+            if (state == null || state.trim().isEmpty()) {
+                return out;
+            }
+            JsonNode node = objectMapper.readTree(state);
+            if (node != null && node.isObject()) {
+                Iterator<Map.Entry<String, JsonNode>> it = node.fields();
+                while (it.hasNext()) {
+                    Map.Entry<String, JsonNode> e = it.next();
+                    out.put(e.getKey(), e.getValue());
+                }
+            }
+        } catch (Exception e) {
+            log.debug("buildContext: 归因中间态不可用(attribution_reports 未建或无数据)attributionId={}: {}",
+                    attributionId, e.getMessage());
+        }
+        return out;
+    }
+}

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/SurveyServiceImpl.java

@@ -9,6 +9,7 @@ import com.etotem.cfc.mapper.SurveyTemplateMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.service.AiGateway;
 import com.etotem.cfc.service.SurveyService;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
@@ -176,6 +177,37 @@ public class SurveyServiceImpl implements SurveyService {
         return surveyRecordMapper.selectById(record.getId());
     }
 
+    @Override
+    public SurveyRecord submitSurvey(Long userId, Long memberId, Long templateId, String answersJson,
+                                     Date periodStart, Date periodEnd, List<Map<String, Object>> riskHits) {
+        String merged = mergeRiskHits(answersJson, riskHits);
+        return submitSurvey(userId, memberId, templateId, merged, periodStart, periodEnd);
+    }
+
+    /**
+     * 把 riskHits 并入 answersJson({@code _attributionRiskHits} 键),供 B1 归因红线检测。
+     * 合并失败时退回原始 answersJson,不阻断提交。
+     */
+    private String mergeRiskHits(String answersJson, List<Map<String, Object>> riskHits) {
+        if (riskHits == null || riskHits.isEmpty()) {
+            return answersJson;
+        }
+        try {
+            ObjectMapper mapper = new ObjectMapper();
+            com.fasterxml.jackson.databind.JsonNode root = (answersJson == null || answersJson.trim().isEmpty())
+                    ? mapper.createObjectNode() : mapper.readTree(answersJson);
+            if (!root.isObject()) {
+                root = mapper.createObjectNode();
+            }
+            ((com.fasterxml.jackson.databind.node.ObjectNode) root)
+                    .set("_attributionRiskHits", mapper.valueToTree(riskHits));
+            return mapper.writeValueAsString(root);
+        } catch (Exception e) {
+            log.warn("合并 riskHits 失败,按原始答案提交: {}", e.getMessage());
+            return answersJson;
+        }
+    }
+
     @Override
     public List<SurveyRecord> getSurveyHistory(Long userId, Long memberId) {
         memberId = resolveMemberId(userId, memberId);

+ 121 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/AttributionRoleUtil.java

@@ -0,0 +1,121 @@
+package com.etotem.cfc.util;
+
+import com.etotem.cfc.entity.FamilyMember;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 家庭归因角色工具(B1/B2/B4 共用)。
+ *
+ * <p>E6 人范围 = 妈妈/女儿/儿子;爸爸链外。角色来源优先「姓名匹配」(源文档四人名),
+ * 其次按 family_relationships/generation/性别启发式推断;用户可主动确认角色(阶段三前端加确认步),
+ * 本工具<b>不默认系统推断</b>,仅作保守回退(§5.5 / §8 人范围)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1/B2/B4)
+ */
+public final class AttributionRoleUtil {
+
+    /** 源文档四人名 → 语义角色(B2 问卷 group 匹配用) */
+    public static final Map<String, String> DEFAULT_GROUP_ROLES = buildDefaultGroupRoles();
+
+    private AttributionRoleUtil() {
+    }
+
+    private static Map<String, String> buildDefaultGroupRoles() {
+        Map<String, String> m = new LinkedHashMap<>();
+        m.put("孟凡锐", "mom");
+        m.put("马智强", "dad");
+        m.put("马昕源", "daughter");
+        m.put("马安扬", "son");
+        return m;
+    }
+
+    /** 姓名 → 语义角色(源文档四人名);未命中返回 null */
+    public static String roleOfName(String name) {
+        if (name == null) {
+            return null;
+        }
+        return DEFAULT_GROUP_ROLES.get(name.trim());
+    }
+
+    /**
+     * 语义角色解析:姓名匹配优先,其次辈分/性别启发式。
+     */
+    public static String roleOf(FamilyMember m) {
+        if (m == null) {
+            return "unknown";
+        }
+        String byName = roleOfName(m.getNickname());
+        if (byName != null) {
+            return byName;
+        }
+        String gender = m.getGender();
+        boolean female = "female".equalsIgnoreCase(gender);
+        Integer generation = m.getGeneration();
+        boolean spouse = Boolean.TRUE.equals(m.getIsSpouse());
+        if (generation != null && generation < 0) {
+            return female ? "daughter" : "son";
+        }
+        if (spouse) {
+            return female ? "mom" : "dad";
+        }
+        if (generation != null && generation > 0) {
+            // 长辈:保守按性别给 mom/dad 标签,但 B4 按「非妈妈/女儿/儿子」排除
+            return female ? "mom" : "dad";
+        }
+        // generation==0 或 null:家庭创建者或未分层,保守按性别
+        return female ? "mom" : "dad";
+    }
+
+    /** 语义角色中文标签 */
+    public static String roleLabel(String role) {
+        if (role == null) {
+            return "成员";
+        }
+        switch (role) {
+            case "mom":
+                return "妈妈";
+            case "dad":
+                return "爸爸";
+            case "daughter":
+                return "女儿";
+            case "son":
+                return "儿子";
+            case "spouse":
+                return "配偶";
+            case "elder":
+                return "长辈";
+            default:
+                return "成员";
+        }
+    }
+
+    /** 年龄段带(与 attribution_norm_values.member_age_band 口径一致) */
+    public static String ageBand(int age) {
+        if (age <= 3) {
+            return "0-3";
+        }
+        if (age <= 6) {
+            return "4-6";
+        }
+        if (age <= 12) {
+            return "7-12";
+        }
+        if (age <= 18) {
+            return "13-18";
+        }
+        if (age <= 45) {
+            return "19-45";
+        }
+        if (age <= 65) {
+            return "46-65";
+        }
+        return "66+";
+    }
+
+    /** 是否为方案人范围(E6:妈妈/女儿/儿子;爸爸与未知均排除) */
+    public static boolean inPlanScope(String role) {
+        return "mom".equals(role) || "daughter".equals(role) || "son".equals(role);
+    }
+}

+ 13 - 0
cfc-backend/src/main/resources/application.yml

@@ -204,6 +204,8 @@ logging:
 # 应用配置
 app:
   init-test-data: false  # 是否初始化测试/种子数据,生产环境应关闭
+  attribution:
+    enabled: false  # 家庭归因全流程开关(E12 合规后置:默认 false,仅测试/mock 数据;真实用户放行=阶段四 D1)
 
 # Admin 登录配置
 admin:
@@ -350,3 +352,14 @@ ai:
       确保 JSON 格式正确,所有题目覆盖三个维度,共 15-18 题。
     tongue-system: |
       你是资深中医舌诊专家。根据用户上传的舌象图片,输出结构化 JSON,不要输出任何 JSON 之外的文字。indicators 数组的 code 字段必须严格从以下 7 个值中选择,禁止自创或改写:tongue_color(舌色)、coating_color(苔色)、coating_texture(苔质)、fissure(裂纹)、teeth_mark(齿痕)、sublingual_vein(舌下络脉)、constitution(体质)。JSON 格式:{"overall_assessment": "整体舌象评估结论", "indicators": [{"code": "tongue_color", "value": "淡红"}, {"code": "coating_color", "value": "薄白"}]}指标值用简短中文描述,如舌色「淡红」、苔色「薄白」、裂纹「无」、齿痕「轻」。
+    attribution-coach: |
+      你是家庭归因分析教练(七层 Socratic:澄清→探因→假设→证据→替代→行动→复盘),
+      服务对象是正在做全家归因调整的家长。输出规范:
+      1. 结论先行:一句话(如「四条表象,两个主因维」),只描述已由规则层算出的归因结构,不改事实。
+      2. 逐人归因叙述润色:基于下发的归因结构做口语化重述,禁止新增/删除任何读数、百分位、结论。
+      3. 教练提问序列:最多 5 个问题,按七层 Socratic 顺序推进,不质问、不定性。
+      4. 一句话收尾:落在「先共享基座后精准,12 周可复盘检验」一类可执行动作上。
+      5. 若家长明确修正某项归因,在回答末尾输出 [ATTR_DELTA]{"键":新值}(如
+         {"l.reuteriAbnormal":false,"ironLow":false,"riskSelfHarm":false})。
+      红线(必须遵守):禁止自行添加疗效/诊断承诺;禁止使用「多动症/抑郁/天才/学霸」等定性词
+      (RL06/RL08);产品只做品牌级展示不附功效承诺(RL05)。AI 只产叙述/调整,不负责合规判定。

+ 22 - 0
cfc-backend/src/main/resources/norm/attribution_norm_gut_flora.json

@@ -0,0 +1,22 @@
+{
+  "schemaVersion": "1.0",
+  "normType": "gut_flora",
+  "placeholder": true,
+  "note": "真实710条待业务侧填充",
+  "fields": [
+    { "name": "code", "type": "string", "required": true, "description": "指标编码:菌种学名,如 Bifidobacterium / Lactobacillus(同一 code 在不同年龄段带/性别下可有多条)" },
+    { "name": "displayName", "type": "string", "required": false, "description": "显示名,如 双歧杆菌属" },
+    { "name": "memberAgeBand", "type": "string", "required": true, "description": "年龄段带:0-3 / 4-6 / 7-12 / 13-18 / 19-45 / 46-65 / 66+ / all" },
+    { "name": "gender", "type": "string", "required": true, "description": "性别:male / female / all(默认 all)" },
+    { "name": "p5", "type": "number", "required": false, "description": "P5 百分位值" },
+    { "name": "p25", "type": "number", "required": false, "description": "P25 百分位值" },
+    { "name": "p50", "type": "number", "required": false, "description": "P50 百分位值(中位数)" },
+    { "name": "p75", "type": "number", "required": false, "description": "P75 百分位值" },
+    { "name": "p95", "type": "number", "required": false, "description": "P95 百分位值" },
+    { "name": "unit", "type": "string", "required": false, "description": "单位,如 %" },
+    { "name": "source", "type": "string", "required": false, "description": "数据来源,如 谷禾710常模 / 导入文件名" },
+    { "name": "normVersion", "type": "string", "required": false, "description": "常模版本,默认 v1" },
+    { "name": "definitionId", "type": "number", "required": false, "description": "indicator_definitions.id 关联(可空)" }
+  ],
+  "data": []
+}

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

@@ -5089,6 +5089,7 @@ CREATE TABLE IF NOT EXISTS health_plans (
     member_name   VARCHAR(50)  COMMENT '成员名称摘要',
     created_at    DATETIME DEFAULT CURRENT_TIMESTAMP,
     plan_json     TEXT         COMMENT '结构化方案内容(JSON)',
+    plan_type     VARCHAR(32)  DEFAULT 'health_plan' COMMENT '方案类型: health_plan=健康方案, family_attribution_plan=家庭归因方案',
     total_days    INT          DEFAULT NULL COMMENT '方案周期天数,驱动服务任务周期边界与截止日期(默认14)',
     status        VARCHAR(20)  DEFAULT 'draft' COMMENT '方案状态: draft(草稿)/confirmed(已确认)',
     reviewed_by   BIGINT DEFAULT NULL COMMENT '审核人ID(规划师/营养师)',
@@ -5100,6 +5101,48 @@ CREATE TABLE IF NOT EXISTS health_plans (
     INDEX idx_created (created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康方案汇总记录';
 
+-- 家庭归因常模值(A3:菌群710常模落点;DAN常模复用 cognitive_norms / health_norm_reference,不重复建)
+CREATE TABLE IF NOT EXISTS attribution_norm_values (
+    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
+    norm_type       VARCHAR(20) NOT NULL COMMENT '常模类型: gut_flora(菌群)/dan(DAN测评)',
+    definition_id   BIGINT NULL COMMENT 'FK→indicator_definitions.id(归一化后关联,可空)',
+    code            VARCHAR(64) NOT NULL COMMENT '指标编码(菌种学名如Bifidobacterium 或 DAN维度key如emotionScore)',
+    display_name    VARCHAR(100) COMMENT '显示名',
+    member_age_band VARCHAR(32) NOT NULL COMMENT '年龄段带: 0-3/4-6/7-12/13-18/19-45/46-65/66+/all',
+    gender          VARCHAR(10) DEFAULT 'all' COMMENT '性别: male/female/all',
+    p5              DECIMAL(10,4) NULL COMMENT 'P5百分位值',
+    p25             DECIMAL(10,4) NULL COMMENT 'P25百分位值',
+    p50             DECIMAL(10,4) NULL COMMENT 'P50百分位值(中位数)',
+    p75             DECIMAL(10,4) NULL COMMENT 'P75百分位值',
+    p95             DECIMAL(10,4) NULL COMMENT 'P95百分位值',
+    unit            VARCHAR(20) COMMENT '单位(如 %)',
+    norm_version    VARCHAR(20) NOT NULL DEFAULT 'v1' COMMENT '常模版本',
+    source          VARCHAR(200) COMMENT '数据来源(谷禾710常模/DAN常模/导入文件名)',
+    status          TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用',
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_type_code_band_gender_ver (norm_type, code, member_age_band, gender, norm_version),
+    INDEX idx_type_code (norm_type, code),
+    INDEX idx_def (definition_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭归因常模值';
+
+-- 家庭归因分析报告(B1 核心闭环③:十节结构 + 中间态 + 红线命中 + 状态机)
+CREATE TABLE IF NOT EXISTS attribution_reports (
+    id                BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id         BIGINT NOT NULL COMMENT '家庭ID',
+    member_ids        VARCHAR(200) NULL COMMENT '目标成员ID列表(逗号分隔;E6: 妈妈/女儿/儿子)',
+    plan_id           BIGINT NULL COMMENT '关联健康方案ID(health_plans.id)',
+    status            VARCHAR(20) NOT NULL DEFAULT 'ANALYZING' COMMENT '状态机: ANALYZING/READY/ADJUSTING/ADJUSTED/CONFIRMED/GENERATED/INVALIDATED',
+    report_json       MEDIUMTEXT NULL COMMENT '十节结构化归因报告(JSON)',
+    redline_hits      VARCHAR(255) NULL COMMENT '红线命中编号(JSON数组, 如 ["RL02"])',
+    intermediate_state MEDIUMTEXT NULL COMMENT '归因中间态(JSON, 供B2条件路由/B3回填)',
+    created_at        DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at        DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_family (family_id),
+    INDEX idx_status (status),
+    INDEX idx_plan (plan_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭归因分析报告(阶段二核心闭环)';
+
 -- 定期调研模板
 CREATE TABLE IF NOT EXISTS survey_templates (
     id            BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 168 - 0
cfc-backend/src/main/resources/survey/family_attribution_questions.json

@@ -0,0 +1,168 @@
+{
+  "schemaVersion": "1.1",
+  "target": "family_attribution",
+  "audience": ["四人", "家长A", "家长B", "学员A", "学员B", "全家"],
+  "groupRoles": {"家长A":"mom","家长B":"dad","学员A":"daughter","学员B":"son"},
+  "questions": [
+    { "id": "Q1a", "code": "sampleDate", "group": "四人", "title": "采样日期",
+      "type": "date", "options": null, "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+    { "id": "Q1b", "code": "antibioticUsed30d", "group": "四人", "title": "采样前 1 个月是否用过抗生素",
+      "type": "single", "options": ["是", "否"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": "决定菌群失衡是结构问题还是药物印记" },
+    { "id": "Q1c", "code": "acidSuppressantUsed30d", "group": "四人", "title": "采样前 1 个月是否用过抑酸药",
+      "type": "single", "options": ["是", "否"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+    { "id": "Q1d", "code": "laxativeUsed30d", "group": "四人", "title": "采样前 1 个月是否用过泻药",
+      "type": "single", "options": ["是", "否"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+
+    { "id": "Q2", "code": "l.reuteriSupp", "group": "家长A", "title": "是否在服含罗伊氏乳杆菌的益生菌?",
+      "type": "single", "options": ["是", "否"], "unit": null, "required": true,
+      "condition": { "op": "==", "var": "l.reuteriAbnormal", "val": true },
+      "riskLevel": "none", "refRange": "乳杆菌 7.268% 异常高,可能是补充剂印记,属可撤回项" },
+    { "id": "Q2b", "code": "l.reuteriDuration", "group": "家长A", "title": "服了多久?",
+      "type": "text", "options": null, "unit": "月", "required": false,
+      "condition": { "op": "==", "var": "l.reuteriSupp", "val": "是" },
+      "riskLevel": "none", "refRange": null },
+
+    { "id": "Q3a", "code": "fatigue", "group": "家长A", "title": "疲劳程度",
+      "type": "scale", "options": ["无", "轻度", "中度", "重度"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": "铁 8 是否已有体征" },
+    { "id": "Q3b", "code": "bloating", "group": "家长A", "title": "腹胀程度",
+      "type": "scale", "options": ["无", "轻度", "中度", "重度"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": "产气 94 是否已有体征" },
+    { "id": "Q3c", "code": "bowelFreq", "group": "家长A", "title": "排便频率",
+      "type": "single", "options": ["<1次/天", "1次/天", "2-3次/天", ">3次/天"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+    { "id": "Q3d", "code": "bowelForm", "group": "家长A", "title": "排便形态(Bristol)",
+      "type": "single", "options": ["1", "2", "3", "4", "5", "6", "7"], "unit": "Bristol分", "required": true,
+      "condition": null, "riskLevel": "none", "refRange": "3-4 为理想" },
+    { "id": "Q3e", "code": "headacheRash", "group": "家长A", "title": "头痛/皮疹",
+      "type": "multi", "options": ["头痛", "皮疹", "无"], "unit": null, "required": false,
+      "condition": null, "riskLevel": "none", "refRange": null },
+    { "id": "Q3f", "code": "allergyHx", "group": "家长A", "title": "过敏史",
+      "type": "text", "options": null, "unit": null, "required": false,
+      "condition": null, "riskLevel": "none", "refRange": null },
+
+    { "id": "Q4a", "code": "ironMealWithMilkTea", "group": "家长A", "title": "含铁餐是否与奶/茶/咖啡同餐",
+      "type": "single", "options": ["是", "否"], "unit": null, "required": true,
+      "condition": { "op": "==", "var": "ironLow", "val": true },
+      "riskLevel": "none", "refRange": "决定铁吸收是否被自己堵死;铁餐与奶/茶/咖啡错开≥2h、配维C" },
+    { "id": "Q4b", "code": "breakfastComposition", "group": "家长A", "title": "早餐结构",
+      "type": "text", "options": null, "unit": null, "required": false,
+      "condition": { "op": "==", "var": "ironLow", "val": true },
+      "riskLevel": "none", "refRange": null },
+
+    { "id": "Q5a", "code": "diabetesDuration", "group": "家长B", "title": "糖尿病病程",
+      "type": "number", "options": null, "unit": "年", "required": true,
+      "condition": { "op": "==", "var": "hasDiabetes", "val": true },
+      "riskLevel": "low", "refRange": "⚠️临床项:决定 P0 随访紧迫度(本文不调药)" },
+    { "id": "Q5b", "code": "diabetesMeds", "group": "家长B", "title": "目前用药",
+      "type": "text", "options": null, "unit": null, "required": true,
+      "condition": { "op": "==", "var": "hasDiabetes", "val": true },
+      "riskLevel": "low", "refRange": null },
+    { "id": "Q5c", "code": "hba1cLatest", "group": "家长B", "title": "最近一次糖化血红蛋白",
+      "type": "number", "options": null, "unit": "%", "required": true,
+      "condition": { "op": "==", "var": "hasDiabetes", "val": true },
+      "riskLevel": "low", "refRange": "⚠️临床项" },
+
+    { "id": "Q6a", "code": "heightCm", "group": "家长B", "title": "身高",
+      "type": "number", "options": null, "unit": "cm", "required": true,
+      "condition": null, "riskLevel": "none", "refRange": "体重目标由医生定,但基线必须有" },
+    { "id": "Q6b", "code": "weightKg", "group": "家长B", "title": "体重",
+      "type": "number", "options": null, "unit": "kg", "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+    { "id": "Q6c", "code": "waistCm", "group": "家长B", "title": "腰围",
+      "type": "number", "options": null, "unit": "cm", "required": true,
+      "condition": null, "riskLevel": "none", "refRange": "算 BMI 与中心性肥胖" },
+
+    { "id": "Q7a", "code": "alcoholFreq", "group": "家长B", "title": "饮酒频率",
+      "type": "single", "options": ["从不", "<1次/周", "1-3次/周", "≥4次/周"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+    { "id": "Q7b", "code": "lateNightTakeoutFreq", "group": "家长B", "title": "夜宵/外卖频率",
+      "type": "number", "options": null, "unit": "次/周", "required": true,
+      "condition": null, "riskLevel": "none", "refRange": "高脂高蛋白主要来源" },
+    { "id": "Q7c", "code": "banquetPattern", "group": "家长B", "title": "应酬结构",
+      "type": "text", "options": null, "unit": null, "required": false,
+      "condition": null, "riskLevel": "none", "refRange": null },
+
+    { "id": "Q8a", "code": "sleepHours", "group": "家长B", "title": "睡眠时长",
+      "type": "number", "options": null, "unit": "h", "required": true,
+      "condition": null, "riskLevel": "none", "refRange": "与血糖控制、日间疲劳强相关" },
+    { "id": "Q8b", "code": "snoringApnea", "group": "家长B", "title": "是否打鼾/憋气(OSA 线索)",
+      "type": "single", "options": ["从不", "偶尔", "经常", "几乎每天"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+
+    { "id": "Q9", "code": "danConsent", "group": "家长B", "title": "是否愿意做 DAN(情绪+认知)",
+      "type": "single", "options": ["愿意", "暂缓", "不愿意"], "unit": null, "required": true,
+      "condition": { "op": "==", "var": "hasDan", "val": false },
+      "riskLevel": "none", "refRange": "补家庭系统最后一角;⚠️意愿项" },
+
+    { "id": "Q10a", "code": "menarcheAge", "group": "学员A", "title": "初潮时间",
+      "type": "number", "options": null, "unit": "岁", "required": true,
+      "condition": { "op": "==", "var": "isFemaleTeen", "val": true },
+      "riskLevel": "none", "refRange": "14岁女性,铁42但需排除月经相关波动" },
+    { "id": "Q10b", "code": "menstrualRegularity", "group": "学员A", "title": "周期规律",
+      "type": "single", "options": ["规律", "不规律"], "unit": null, "required": true,
+      "condition": { "op": "==", "var": "isFemaleTeen", "val": true },
+      "riskLevel": "none", "refRange": null },
+
+    { "id": "Q11a", "code": "mood3m", "group": "学员A", "title": "近3个月情绪状态",
+      "type": "scale", "options": ["平稳", "波动", "低落", "明显低落"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "high", "refRange": "⚠️高风险红线:P0 转介判断依据" },
+    { "id": "Q11b", "code": "sleep3m", "group": "学员A", "title": "近3个月睡眠",
+      "type": "scale", "options": ["正常", "偶尔失眠", "频繁失眠", "持续失眠"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "high", "refRange": "持续失眠→立即就医(E4弱提示+家长确认)" },
+    { "id": "Q11c", "code": "schoolRefusal", "group": "学员A", "title": "是否拒学",
+      "type": "single", "options": ["无", "偶尔", "频繁"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "high", "refRange": "拒学→立即就医" },
+    { "id": "Q11d", "code": "selfHarmIdea", "group": "学员A", "title": "是否有自伤念头",
+      "type": "single", "options": ["无", "有"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "high", "refRange": "自伤→立即就医,不靠产品硬扛" },
+
+    { "id": "Q12a", "code": "schoolStressors", "group": "学员A", "title": "学校压力源",
+      "type": "multi", "options": ["学业", "同伴", "师生", "家庭", "无"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "high", "refRange": "解释同伴亲近 3.3%ile" },
+    { "id": "Q12b", "code": "peerRelation", "group": "学员A", "title": "同伴关系",
+      "type": "text", "options": null, "unit": null, "required": true,
+      "condition": null, "riskLevel": "high", "refRange": "同伴依恋项判定无效,需人工确认" },
+    { "id": "Q12c", "code": "bullied", "group": "学员A", "title": "是否被排挤/欺凌",
+      "type": "single", "options": ["无", "偶尔", "频繁"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "high", "refRange": "⚠️敏感项" },
+
+    { "id": "Q13a", "code": "dietingConcern", "group": "学员A", "title": "是否节食/在意体重",
+      "type": "single", "options": ["无", "轻度在意", "明显在意", "正在节食"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "high", "refRange": "排除进食行为风险(躯体外貌 5.4%ile)" },
+    { "id": "Q13b", "code": "bodyWeightConcern", "group": "学员A", "title": "体重关注程度",
+      "type": "scale", "options": ["0", "1", "2", "3", "4", "5"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "high", "refRange": "⚠️进食风险筛查" },
+
+    { "id": "Q14a", "code": "birthMode", "group": "学员B", "title": "出生方式",
+      "type": "single", "options": ["顺产", "剖腹产"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": "解释铁锌缺乏与 GABA 1" },
+    { "id": "Q14b", "code": "feedingHx", "group": "学员B", "title": "喂养史",
+      "type": "single", "options": ["母乳", "混合", "配方"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+    { "id": "Q14c", "code": "antibioticHx", "group": "学员B", "title": "抗生素史",
+      "type": "single", "options": ["无", "有"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+    { "id": "Q14d", "code": "eczemaRhinAsthma", "group": "学员B", "title": "湿疹/鼻炎/哮喘",
+      "type": "multi", "options": ["湿疹", "鼻炎", "哮喘", "无"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+
+    { "id": "Q15a", "code": "teacherFeedback", "group": "学员B", "title": "老师反馈(注意力)",
+      "type": "text", "options": null, "unit": null, "required": false,
+      "condition": null, "riskLevel": "low", "refRange": "区分注意力问题与营养缺口" },
+    { "id": "Q15b", "code": "bowelHabit", "group": "学员B", "title": "排便习惯",
+      "type": "single", "options": ["<1次/天", "1次/天", "2-3次/天", ">3次/天"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+    { "id": "Q15c", "code": "pickyFoodList", "group": "学员B", "title": "挑食清单",
+      "type": "multi", "options": ["蔬菜", "肉", "蛋", "奶", "豆", "无"], "unit": null, "required": true,
+      "condition": null, "riskLevel": "none", "refRange": null },
+
+    { "id": "Q16", "code": "weeklyMealPhotos", "group": "全家", "title": "一周典型三餐照片(连拍7天)",
+      "type": "photoBatch", "options": null, "unit": "张", "required": false,
+      "condition": null, "riskLevel": "none", "refRange": "验证一个厨房的口味基线;负担最重,建议放宽为尽量" }
+  ]
+}

+ 176 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/AttributionStage1FoundationTest.java

@@ -0,0 +1,176 @@
+package com.etotem.cfc.config;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * ATT-S1-A1/A2/A3:数据地基种子与迁移校验。
+ *
+ * <ul>
+ *   <li>A1:plan_type 迁移为幂等 ALTER ADD COLUMN</li>
+ *   <li>A2:家庭归因问卷模板种子按 title 判重,questionsJson 为 §3.2 扩展结构</li>
+ *   <li>A3:attribution_norm_values 建表含唯一键;空壳常模导入 0 行不报错</li>
+ * </ul>
+ */
+@ExtendWith(MockitoExtension.class)
+class AttributionStage1FoundationTest {
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
+    @InjectMocks
+    private DatabaseInitializer initializer;
+
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    // ---------------- A1:plan_type 迁移 ----------------
+
+    @Test
+    void A1_planType迁移为幂等加列() {
+        initializer.migrateHealthPlansPlanType();
+
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).execute(sql.capture());
+        assertTrue(sql.getValue().contains("ALTER TABLE health_plans ADD COLUMN plan_type"),
+                "应执行 health_plans.plan_type 幂等加列:" + sql.getValue());
+        assertTrue(sql.getValue().contains("DEFAULT 'health_plan'"), "plan_type 默认值应为 health_plan");
+        assertTrue(sql.getValue().contains("family_attribution_plan"), "plan_type 注释应含家庭归因方案枚举值");
+    }
+
+    // ---------------- A2:问卷模板种子 ----------------
+
+    @Test
+    void A2_模板已存在时幂等启用() {
+        when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any())).thenReturn(1);
+
+        initializer.seedFamilyAttributionSurveyTemplate();
+
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).update(sql.capture(), any(Object.class));
+        assertTrue(sql.getValue().contains("UPDATE survey_templates SET enabled = 1"),
+                "已存在时只幂等启用,不覆盖 questions_json:" + sql.getValue());
+    }
+
+    @Test
+    void A2_模板缺失时写入种子且JSON结构符合3_2() throws Exception {
+        when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any())).thenReturn(0);
+
+        initializer.seedFamilyAttributionSurveyTemplate();
+
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<Object> arg = ArgumentCaptor.forClass(Object.class);
+        verify(jdbcTemplate).update(sql.capture(), arg.capture(), arg.capture(), arg.capture(), arg.capture());
+
+        assertTrue(sql.getValue().contains("INSERT INTO survey_templates"), "应写入 survey_templates");
+        assertTrue(sql.getValue().contains("VALUES (?, ?, ?, ?, 0, 1, NOW(), NOW())"),
+                "模板种子应默认启用(enabled=1):" + sql.getValue());
+        List<Object> args = arg.getAllValues();
+        assertEquals(DatabaseInitializer.SURVEY_FAMILY_ATTRIBUTION_TITLE, args.get(0), "按 title 判重");
+        String questionsJson = (String) args.get(2);
+
+        JsonNode root = mapper.readTree(questionsJson);
+        assertEquals("1.1", root.get("schemaVersion").asText(), "应含 schemaVersion");
+        assertEquals("family_attribution", root.get("target").asText(), "target 应为 family_attribution");
+        JsonNode questions = root.get("questions");
+        assertNotNull(questions);
+        assertTrue(questions.isArray());
+        assertEquals(45, questions.size(), "第八章 16 条展开为 45 个采集项");
+
+        for (JsonNode q : questions) {
+            for (String field : new String[]{"id", "code", "group", "title", "type", "required", "riskLevel"}) {
+                assertTrue(q.has(field), "每题必须含字段 " + field + ":" + q.get("id"));
+            }
+            assertTrue(q.has("options") && q.has("unit") && q.has("condition") && q.has("refRange"),
+                    "每题必须含 options/unit/condition/refRange(可为 null):" + q.get("id"));
+            String risk = q.get("riskLevel").asText();
+            assertTrue("none".equals(risk) || "low".equals(risk) || "high".equals(risk),
+                    "riskLevel 枚举非法:" + q.get("id") + "=" + risk);
+        }
+
+        // 高风险题(Q11/Q12/Q13)必须标记 high(E4 弱提示)
+        for (JsonNode q : questions) {
+            String id = q.get("id").asText();
+            if (id.startsWith("Q11") || id.startsWith("Q12") || id.startsWith("Q13")) {
+                assertEquals("high", q.get("riskLevel").asText(), id + " 应为 high 风险级别");
+            }
+        }
+    }
+
+    @Test
+    void A2_种子文件本身可解析且含16条分组() throws Exception {
+        JsonNode root = mapper.readTree(
+                org.springframework.util.StreamUtils.copyToString(
+                        getClass().getResourceAsStream("/survey/family_attribution_questions.json"),
+                        java.nio.charset.StandardCharsets.UTF_8));
+        assertEquals("family_attribution", root.get("target").asText());
+        JsonNode questions = root.get("questions");
+        java.util.Set<String> groups = new java.util.LinkedHashSet<>();
+        for (JsonNode q : questions) {
+            groups.add(q.get("group").asText());
+        }
+        assertEquals(6, groups.size(), "应覆盖 四人/家长A/家长B/学员A/学员B/全家 6 个分组(O5 去实名):" + groups);
+        assertFalse(root.get("audience").isEmpty(), "应含 audience 列表");
+    }
+
+    // ---------------- A3:常模表与导入 ----------------
+
+    @Test
+    void A3_常模表建表语句含唯一键() {
+        initializer.createAttributionNormValuesTable();
+
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).execute(sql.capture());
+        String ddl = sql.getValue();
+        assertTrue(ddl.contains("CREATE TABLE IF NOT EXISTS `attribution_norm_values`"), "应幂等建表");
+        assertTrue(ddl.contains("uk_type_code_band_gender_ver"), "应含唯一键 uk_type_code_band_gender_ver");
+        assertTrue(ddl.contains("`norm_type`") && ddl.contains("`member_age_band`") && ddl.contains("`gender`"),
+                "唯一键字段应齐全");
+    }
+
+    @Test
+    void A3_空壳常模导入0行且不报错() {
+        initializer.importAttributionNormGutFlora();
+
+        assertNoUpdateCalled();
+    }
+
+    @Test
+    void A3_空壳常模文件含placeholder标记与字段说明() throws Exception {
+        JsonNode root = mapper.readTree(
+                org.springframework.util.StreamUtils.copyToString(
+                        getClass().getResourceAsStream("/norm/attribution_norm_gut_flora.json"),
+                        java.nio.charset.StandardCharsets.UTF_8));
+        assertTrue(root.get("placeholder").asBoolean(), "空壳模板应标记 placeholder=true");
+        assertEquals("真实710条待业务侧填充", root.get("note").asText());
+        assertTrue(root.get("fields").size() >= 10, "应含字段说明");
+        assertTrue(root.get("data").isArray() && root.get("data").size() == 0, "真实常模数据为空(待业务侧填充)");
+    }
+
+    /** 断言未发生任何写库调用(不依赖 varargs matcher 的参数个数) */
+    private void assertNoUpdateCalled() {
+        for (org.mockito.invocation.Invocation invocation
+                : org.mockito.Mockito.mockingDetails(jdbcTemplate).getInvocations()) {
+            assertFalse("update".equals(invocation.getMethod().getName()),
+                    "不应执行写库操作:" + invocation);
+        }
+    }
+}

+ 40 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/AttributionControllerTest.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.AttributionInput;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * {@link AttributionController} E12 合规开关 gate() 单测(ATT-S2-O7)。
+ *
+ * <p>E12 合规后置:{@code app.attribution.enabled} 默认 false(仅测试/mock 数据环境开启)。
+ * 开关关闭时,闭环入口 {@code /api/attribution/*} 应被 gate() 统一拦截,
+ * 返回 code=4001「家庭归因功能未开放(E12 合规后置…)」,且不触达下层 service。
+ *
+ * @author 家庭归因阶段二(ATT-S2-O7)
+ */
+class AttributionControllerTest {
+
+    @Test
+    void 开关关闭时analyze返回4001家庭归因功能未开放() {
+        AttributionController controller = new AttributionController();
+        // 显式置 false 断言「关闭态」(@Value 默认即 false,此处明确意图,防未来默认值漂移)
+        ReflectionTestUtils.setField(controller, "attributionEnabled", false);
+
+        AttributionInput input = new AttributionInput();
+        input.setFamilyId(1L);
+
+        // gate() 短路,service 不会触达;仍以非空入参走真实入口路径
+        Result<?> result = controller.analyze(input, 1L, 1L);
+
+        assertNotNull(result, "开关关闭时不应返回 null");
+        assertEquals(4001, result.getCode(), "开关关闭时应返回 code=4001(权限/放行类)");
+        assertNotNull(result.getMessage(), "应返回错误消息");
+        assertTrue(result.getMessage().contains("家庭归因功能未开放"),
+                "消息应含「家庭归因功能未开放」:" + result.getMessage());
+        System.out.println("✅ O7 验收:开关关闭 → /analyze 被 gate() 拦截,code=4001「家庭归因功能未开放」");
+    }
+}

+ 161 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/AttributionSurveyRoutingTest.java

@@ -0,0 +1,161 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.dto.ConfirmQuestionVO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.SurveyTemplate;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.SurveyTemplateMapper;
+import com.etotem.cfc.service.ConditionEvaluatorService;
+import com.etotem.cfc.service.impl.ConditionEvaluatorServiceImpl;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 家庭归因 B2 动态确认问卷条件路由 Mock 测试(ATT-S2-B2)。
+ *
+ * <p>验收:女儿成员 → Q10-Q13(riskLevel=high);铁低(ironLow)→ Q4a/Q4b;
+ * 无 DAN(hasDan=false)→ Q9;对象级题目只下发给该对象(对象路由)。\u200b
+ */
+class AttributionSurveyRoutingTest {
+
+    private SurveyController controller;
+    private SurveyTemplateMapper surveyTemplateMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    private static final String TEMPLATE_JSON;
+
+    static {
+        String json;
+        try (InputStream in = AttributionSurveyRoutingTest.class
+                .getResourceAsStream("/survey/family_attribution_questions.json")) {
+            json = org.springframework.util.StreamUtils.copyToString(in, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw new IllegalStateException("读取 family_attribution_questions.json 失败", e);
+        }
+        TEMPLATE_JSON = json;
+    }
+
+    @BeforeEach
+    void setUp() {
+        controller = new SurveyController();
+        surveyTemplateMapper = mock(SurveyTemplateMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        ReflectionTestUtils.setField(controller, "surveyTemplateMapper", surveyTemplateMapper);
+        ReflectionTestUtils.setField(controller, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(controller, "objectMapper", objectMapper);
+        ReflectionTestUtils.setField(controller, "conditionEvaluatorService", new FixedContextEvaluator());
+    }
+
+    /** 固定中间态的 ConditionEvaluator:由测试显式注入(等价于 attribution_reports.intermediate_state) */
+    static class FixedContextEvaluator extends ConditionEvaluatorServiceImpl {
+        private final Map<String, Object> ctx = new LinkedHashMap<>();
+
+        FixedContextEvaluator() {
+            ctx.put("ironLow", true);
+            ctx.put("hasDan", false);
+            ctx.put("hasDiabetes", false);
+            ctx.put("l.reuteriAbnormal", true);
+            ctx.put("isFemaleTeen", true);
+        }
+
+        @Override
+        public Map<String, Object> buildContext(Long familyId, Long memberId, Long attributionId) {
+            return new LinkedHashMap<>(ctx);
+        }
+    }
+
+    private SurveyTemplate template() {
+        SurveyTemplate t = new SurveyTemplate();
+        t.setId(101L);
+        t.setTitle("家庭归因确认问卷(第八章)");
+        t.setDimension("family_attribution");
+        t.setQuestionsJson(TEMPLATE_JSON);
+        return t;
+    }
+
+    private FamilyMember member(Long id, String name, String gender, int age, int gen, boolean spouse) {
+        FamilyMember fm = new FamilyMember();
+        fm.setId(id);
+        fm.setNickname(name);
+        fm.setGender(gender);
+        fm.setAge(age);
+        fm.setGeneration(gen);
+        fm.setIsSpouse(spouse);
+        return fm;
+    }
+
+    @Test
+    void 女儿下发Q10到Q13且高风险题带riskLevelHigh() {
+        FamilyMember daughter = member(3L, "马昕源", "female", 14, -1, false);
+        when(familyMemberMapper.selectById(3L)).thenReturn(daughter);
+
+        ConfirmQuestionVO vo = controller.routeQuestions(template(), 1L, 3L, 7L, null);
+
+        Set<String> ids = new LinkedHashSet<>();
+        for (ConfirmQuestionVO.ConfirmQuestion q : vo.getQuestions()) {
+            ids.add(q.getId());
+        }
+        // Q10-Q13 全量下发(10a/10b/11a-d/12a-c/13a-b)
+        assertTrue(ids.contains("Q10a") && ids.contains("Q10b"), "Q10 应下发");
+        assertTrue(ids.contains("Q11a") && ids.contains("Q11b") && ids.contains("Q11c") && ids.contains("Q11d"), "Q11 应下发");
+        assertTrue(ids.contains("Q12a") && ids.contains("Q12b") && ids.contains("Q12c"), "Q12 应下发");
+        assertTrue(ids.contains("Q13a") && ids.contains("Q13b"), "Q13 应下发");
+        // 对象路由:女儿不应收到妈妈的 Q4(对象级题目只下发给该对象)
+        assertFalse(ids.contains("Q4a"), "Q4(孟凡锐=妈妈)不应下发女儿");
+        assertFalse(ids.contains("Q2"), "Q2(孟凡锐=妈妈)不应下发女儿");
+        // 家庭级恒发
+        assertTrue(ids.contains("Q1a") && ids.contains("Q16"), "家庭级题目(四人/全家)恒发");
+        // Q11-Q13 高风险
+        assertTrue(vo.isHasHighRisk());
+        for (ConfirmQuestionVO.ConfirmQuestion q : vo.getQuestions()) {
+            if (q.getId().startsWith("Q11") || q.getId().startsWith("Q12") || q.getId().startsWith("Q13")) {
+                assertEquals("high", q.getRiskLevel(), q.getId() + " 应为 high");
+            }
+        }
+        assertEquals(Arrays.asList("Q11a","Q11b","Q11c","Q11d","Q12a","Q12b","Q12c","Q13a","Q13b"),
+                vo.getHighRiskQuestionIds());
+        System.out.println("✅ B2 验收:女儿下发 Q10-Q13,Q11-Q13 riskLevel=high");
+    }
+
+    @Test
+    void 铁低时妈妈下发Q4() {
+        FamilyMember mom = member(1L, "孟凡锐", "female", 38, 0, false);
+        when(familyMemberMapper.selectById(1L)).thenReturn(mom);
+
+        ConfirmQuestionVO vo = controller.routeQuestions(template(), 1L, 1L, 7L, null);
+        Set<String> ids = new LinkedHashSet<>();
+        for (ConfirmQuestionVO.ConfirmQuestion q : vo.getQuestions()) {
+            ids.add(q.getId());
+        }
+        assertTrue(ids.contains("Q4a") && ids.contains("Q4b"), "ironLow=true → Q4a/Q4b 应下发(孟凡锐=妈妈)");
+        assertTrue(ids.contains("Q2"), "l.reuteriAbnormal=true → Q2 应下发");
+        assertFalse(ids.contains("Q10a"), "妈妈不应收到女儿 Q10");
+        System.out.println("✅ B2 验收:铁低 → 妈妈下发 Q4");
+    }
+
+    @Test
+    void 无DAN时爸爸下发Q9() {
+        FamilyMember dad = member(2L, "马智强", "male", 41, 0, true);
+        when(familyMemberMapper.selectById(2L)).thenReturn(dad);
+
+        ConfirmQuestionVO vo = controller.routeQuestions(template(), 1L, 2L, 7L, null);
+        Set<String> ids = new LinkedHashSet<>();
+        for (ConfirmQuestionVO.ConfirmQuestion q : vo.getQuestions()) {
+            ids.add(q.getId());
+        }
+        assertTrue(ids.contains("Q9"), "hasDan=false → Q9 应下发(马智强=爸爸)");
+        assertFalse(ids.contains("Q5a"), "hasDiabetes=false → Q5 不应下发");
+        System.out.println("✅ B2 验收:无 DAN → 爸爸下发 Q9");
+    }
+}

+ 172 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AttributionAdjustServiceTest.java

@@ -0,0 +1,172 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.config.PromptConfig;
+import com.etotem.cfc.dto.AdjustResult;
+import com.etotem.cfc.entity.AttributionReport;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.mapper.AttributionReportMapper;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.service.AttributionAdjustService;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.impl.AttributionAdjustServiceImpl;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 家庭归因 B3 自然语言调整 Mock 测试(ATT-S2-B3)。
+ *
+ * <p>验收:调整后 intermediate_state 变化;确认后结论锁定(status=CONFIRMED);
+ * RL02 命中 → redlineTerminate=true 但流程不阻断(E4 弱提示)+ 转介信息露出。\u200b
+ *
+ * <p>E12:不访问真实数据库;AI 通道用 Mockito 桩(AiGateway 不可用时规则兜底流程不阻断)。
+ */
+class AttributionAdjustServiceTest {
+
+    private AttributionAdjustService service;
+    private AttributionReportMapper reportMapper;
+    private HealthPlanMapper healthPlanMapper;
+    private AiGateway aiGateway;
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    @BeforeEach
+    void setUp() {
+        AttributionAdjustServiceImpl impl = new AttributionAdjustServiceImpl();
+        reportMapper = mock(AttributionReportMapper.class);
+        healthPlanMapper = mock(HealthPlanMapper.class);
+        aiGateway = mock(AiGateway.class);
+        ReflectionTestUtils.setField(impl, "aiGateway", aiGateway);
+        ReflectionTestUtils.setField(impl, "promptConfig", new PromptConfig());
+        ReflectionTestUtils.setField(impl, "attributionReportMapper", reportMapper);
+        ReflectionTestUtils.setField(impl, "healthPlanMapper", healthPlanMapper);
+        ReflectionTestUtils.setField(impl, "objectMapper", objectMapper);
+        service = impl;
+    }
+
+    private AttributionReport reportWith(String state) {
+        AttributionReport r = new AttributionReport();
+        r.setId(7L);
+        r.setFamilyId(1L);
+        r.setMemberIds("1,3,4");
+        r.setStatus(AttributionReport.STATUS_READY);
+        r.setIntermediateState(state);
+        r.setRedlineHits("[\"RL01\",\"RL03\",\"RL06\"]");
+        r.setReportJson("{\"sections\":{\"perMember\":[]}}");
+        return r;
+    }
+
+    @Test
+    void 调整后中间态回填并写回报告() {
+        AttributionReport report = reportWith("{\"l.reuteriAbnormal\":true,\"ironLow\":true}");
+        when(reportMapper.selectById(7L)).thenReturn(report);
+        // AI 回答携带 [ATTR_DELTA] 结构化增量
+        Map<String, Object> reply = new LinkedHashMap<>();
+        reply.put("answer", "收到。已更正罗伊氏乳杆菌异常为补充剂印记。\n[ATTR_DELTA]{\"l.reuteriAbnormal\":false}");
+        when(aiGateway.chatForAnalysis(anyString(), anyLong(), nullable(String.class), anyMap()))
+                .thenReturn(reply);
+
+        AdjustResult r = service.adjust(7L, 1L, "其实没在吃罗伊氏乳杆菌", null);
+
+        assertNotNull(r);
+        // 验收:intermediate_state 变化(l.reuteriAbnormal true→false)
+        assertEquals(Boolean.FALSE, r.getIntermediateState().get("l.reuteriAbnormal"));
+        assertEquals(Boolean.TRUE, r.getIntermediateState().get("ironLow"), "不受影响的键保持原值");
+        assertTrue(r.getFieldDeltas().containsKey("l.reuteriAbnormal"), "结构化增量应被解析");
+        assertEquals(AttributionReport.STATUS_ADJUSTED, r.getStatus());
+        // 写回 DB
+        ArgumentCaptor<AttributionReport> cap = ArgumentCaptor.forClass(AttributionReport.class);
+        verify(reportMapper).updateById(cap.capture());
+        assertEquals(AttributionReport.STATUS_ADJUSTED, cap.getValue().getStatus());
+        assertTrue(cap.getValue().getIntermediateState().contains("\"l.reuteriAbnormal\":false"));
+        System.out.println("✅ B3 验收:调整后 intermediate_state 变化(" + r.getFieldDeltas() + ")");
+    }
+
+    @Test
+    void RL02命中只标记不阻断并露出转介信息() {
+        AttributionReport report = reportWith(
+                "{\"l.reuteriAbnormal\":true,\"ironLow\":true,\"riskSelfHarm\":true}");
+        when(reportMapper.selectById(7L)).thenReturn(report);
+        Map<String, Object> reply = new LinkedHashMap<>();
+        reply.put("answer", "收到,先记录。");
+        when(aiGateway.chatForAnalysis(anyString(), anyLong(), nullable(String.class), anyMap()))
+                .thenReturn(reply);
+
+        AdjustResult r = service.adjust(7L, 1L, "孩子最近情绪很低落", null);
+
+        // 验收:RL02 标记 + 不阻断 + 转介信息露出 + 家长确认
+        assertTrue(r.isRedlineTerminate(), "RL02 命中应标记 redlineTerminate=true");
+        assertFalse(r.isBlocking(), "E4 弱提示:流程不阻断");
+        assertNotNull(r.getReferral(), "转介信息露出");
+        assertTrue(r.getRedlineHits().contains("RL02"), "redlineHits 应含 RL02");
+        assertTrue(r.getReferral().getAdvice().contains("建议专业评估"));
+        assertTrue(r.getReferral().getParentConfirm().contains("家长确认"));
+        System.out.println("✅ B3 验收:RL02 命中 redlineTerminate=true,blocking=false,转介信息露出(不阻断)");
+    }
+
+    @Test
+    void AI不可用时规则兜底流程不阻断() {
+        AttributionReport report = reportWith("{\"l.reuteriAbnormal\":true}");
+        when(reportMapper.selectById(7L)).thenReturn(report);
+        when(aiGateway.chatForAnalysis(anyString(), anyLong(), nullable(String.class), anyMap()))
+                .thenReturn(null);
+
+        AdjustResult r = service.adjust(7L, 1L, "女儿最近睡眠不好", null);
+
+        assertNotNull(r);
+        assertTrue(r.getAnswer().contains("AI"), "AI 不可用 → 规则兜底话术");
+        assertTrue(r.getFieldDeltas().isEmpty(), "无增量时不影响中间态");
+        assertFalse(r.isBlocking());
+        System.out.println("✅ B3 验收:AI 不可用 → 规则兜底,流程不阻断");
+    }
+
+    @Test
+    void 确认后结论锁定() throws Exception {
+        AttributionReport report = reportWith("{\"l.reuteriAbnormal\":false,\"ironLow\":true}");
+        report.setStatus(AttributionReport.STATUS_ADJUSTED);
+        when(reportMapper.selectById(7L)).thenReturn(report);
+
+        AdjustResult r = service.confirm(7L, 1L);
+
+        assertNotNull(r);
+        assertEquals(AttributionReport.STATUS_CONFIRMED, r.getStatus());
+        ArgumentCaptor<AttributionReport> cap = ArgumentCaptor.forClass(AttributionReport.class);
+        verify(reportMapper).updateById(cap.capture());
+        assertEquals(AttributionReport.STATUS_CONFIRMED, cap.getValue().getStatus());
+        JsonNode node = objectMapper.readTree(cap.getValue().getReportJson());
+        assertTrue(node.path("conclusionLocked").asBoolean(), "确认后结论锁定");
+        System.out.println("✅ B3 验收:confirm → status=CONFIRMED,conclusionLocked=true(结论锁定)");
+    }
+
+    @Test
+    void 调整作废已生成的方案() {
+        AttributionReport report = reportWith("{\"l.reuteriAbnormal\":true}");
+        report.setPlanId(88L);
+        when(reportMapper.selectById(7L)).thenReturn(report);
+        HealthPlan plan = new HealthPlan();
+        plan.setId(88L);
+        plan.setStatus("GENERATED");
+        when(healthPlanMapper.selectById(88L)).thenReturn(plan);
+        Map<String, Object> reply = new LinkedHashMap<>();
+        reply.put("answer", "已记录调整。");
+        when(aiGateway.chatForAnalysis(anyString(), anyLong(), nullable(String.class), anyMap()))
+                .thenReturn(reply);
+
+        AdjustResult r = service.adjust(7L, 1L, "取消罗伊氏乳杆菌补充", null);
+
+        assertTrue(r.isPlanInvalidated(), "关联已生成方案应被作废(⑤ 失效重生成)");
+        ArgumentCaptor<HealthPlan> cap = ArgumentCaptor.forClass(HealthPlan.class);
+        verify(healthPlanMapper).updateById(cap.capture());
+        assertEquals("INVALIDATED", cap.getValue().getStatus());
+        System.out.println("✅ B3 验收:调整作废已生成方案(plan.status=INVALIDATED)");
+    }
+}

+ 520 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AttributionPipelineMockTest.java

@@ -0,0 +1,520 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.constant.AttributionRedlines;
+import com.etotem.cfc.dto.AttributionInput;
+import com.etotem.cfc.dto.AttributionReportVO;
+import com.etotem.cfc.entity.AttributionReport;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthGutFlora;
+import com.etotem.cfc.entity.HealthNormReference;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.AttributionNormValueMapper;
+import com.etotem.cfc.mapper.AttributionReportMapper;
+import com.etotem.cfc.mapper.CognitiveNormMapper;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthGutFloraMapper;
+import com.etotem.cfc.mapper.HealthNormReferenceMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.SurveyRecordMapper;
+import com.etotem.cfc.service.AttributionAnalysisService;
+import com.etotem.cfc.service.attribution.AttributionNarrativeService;
+import com.etotem.cfc.service.attribution.AttributionReportBuilder;
+import com.etotem.cfc.service.attribution.FalsificationEngine;
+import com.etotem.cfc.service.attribution.FiveDimAttributionEngine;
+import com.etotem.cfc.service.attribution.IndicatorInterpretEngine;
+import com.etotem.cfc.service.attribution.MainCauseChainEngine;
+import com.etotem.cfc.service.attribution.NormCompareEngine;
+import com.etotem.cfc.service.attribution.model.CauseLink;
+import com.etotem.cfc.service.attribution.model.CompareResult;
+import com.etotem.cfc.service.attribution.model.FalsifiedItem;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
+import com.etotem.cfc.service.attribution.model.MemberProfile;
+import com.etotem.cfc.service.attribution.model.NormEntry;
+import com.etotem.cfc.service.impl.AttributionAnalysisServiceImpl;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 家庭归因 B1 核心闭环 Mock 测试(ATT-S2-B1)。
+ *
+ * <p>两阶段验收:
+ * <ol>
+ *   <li>纯引擎流水线:四人家庭合成数据 → 十节结构 reportJson + 中间态 5 键 + RL01/RL02 命中</li>
+ *   <li>Service 层 analyze():Mock DB → 插入 ANALYZING → 更新 READY,VO 携带十节报告</li>
+ * </ol>
+ *
+ * <p>E12:仅 mock/测试数据;不需真实数据库,依赖纯 Java 规则引擎 + Mockito。
+ */
+class AttributionPipelineMockTest {
+
+    // ---- 共享依赖 ----
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    // ---- 纯引擎(真实实例,可单测) ----
+    private NormCompareEngine normCompareEngine;
+    private IndicatorInterpretEngine indicatorInterpretEngine;
+    private FiveDimAttributionEngine fiveDimEngine;
+    private MainCauseChainEngine chainEngine;
+    private FalsificationEngine falsificationEngine;
+    private AttributionNarrativeService narrativeService;
+    private AttributionReportBuilder reportBuilder;
+
+    // ---- Mock DB ----
+    private FamilyMemberMapper familyMemberMapper;
+    private HealthGutFloraMapper gutFloraMapper;
+    private HealthReportMapper healthReportMapper;
+    private DanAssessmentResultMapper danMapper;
+    private AttributionNormValueMapper normValueMapper;
+    private CognitiveNormMapper cognitiveNormMapper;
+    private HealthNormReferenceMapper healthNormMapper;
+    private SurveyRecordMapper surveyRecordMapper;
+    private AttributionReportMapper reportMapper;
+
+    // ---- Service ----
+    private AttributionAnalysisService service;
+
+    @BeforeEach
+    void setUp() {
+        // 纯引擎实例
+        normCompareEngine = new NormCompareEngine();
+        indicatorInterpretEngine = new IndicatorInterpretEngine();
+        fiveDimEngine = new FiveDimAttributionEngine();
+        chainEngine = new MainCauseChainEngine();
+        falsificationEngine = new FalsificationEngine();
+        narrativeService = new AttributionNarrativeService(); // AiGateway = null → 规则兜底
+        setField(narrativeService, "objectMapper", objectMapper);
+        setField(narrativeService, "promptConfig", new com.etotem.cfc.config.PromptConfig());
+        // AttributionReportBuilder
+        reportBuilder = new AttributionReportBuilder();
+        setField(reportBuilder, "objectMapper", objectMapper);
+
+        // Mock DB
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        gutFloraMapper = mock(HealthGutFloraMapper.class);
+        healthReportMapper = mock(HealthReportMapper.class);
+        danMapper = mock(DanAssessmentResultMapper.class);
+        normValueMapper = mock(AttributionNormValueMapper.class);
+        cognitiveNormMapper = mock(CognitiveNormMapper.class);
+        healthNormMapper = mock(HealthNormReferenceMapper.class);
+        surveyRecordMapper = mock(SurveyRecordMapper.class);
+        reportMapper = mock(AttributionReportMapper.class);
+
+        // Service 实例(手工注入,不走 Spring)
+        AttributionAnalysisServiceImpl svc = new AttributionAnalysisServiceImpl();
+        setField(svc, "objectMapper", objectMapper);
+        setField(svc, "familyMemberMapper", familyMemberMapper);
+        setField(svc, "healthGutFloraMapper", gutFloraMapper);
+        setField(svc, "healthReportMapper", healthReportMapper);
+        setField(svc, "danAssessmentResultMapper", danMapper);
+        setField(svc, "attributionNormValueMapper", normValueMapper);
+        setField(svc, "cognitiveNormMapper", cognitiveNormMapper);
+        setField(svc, "healthNormReferenceMapper", healthNormMapper);
+        setField(svc, "surveyRecordMapper", surveyRecordMapper);
+        setField(svc, "attributionReportMapper", reportMapper);
+        setField(svc, "normCompareEngine", normCompareEngine);
+        setField(svc, "indicatorInterpretEngine", indicatorInterpretEngine);
+        setField(svc, "fiveDimAttributionEngine", fiveDimEngine);
+        setField(svc, "mainCauseChainEngine", chainEngine);
+        setField(svc, "falsificationEngine", falsificationEngine);
+        setField(svc, "attributionNarrativeService", narrativeService);
+        setField(svc, "attributionReportBuilder", reportBuilder);
+        service = svc;
+    }
+
+    // ============================================================
+    // 1. 纯引擎流水线:四人家庭合成数据 → 十节结构报告
+    // ============================================================
+    @Test
+    void pipeline_4PersonMock_reportHas10Sections() throws Exception {
+        // --- 4 人档案(源文档:孟凡锐/马智强/马昕源/马安扬)---
+        List<MemberProfile> members = buildFourPersonProfiles();
+
+        // --- 合成读数(模拟菌群 + DAN)---
+        List<IndicatorReading> raw = buildSyntheticReadings();
+
+        // --- 常模(仅覆盖合成读数用到的指标;其余“待补”)---
+        List<NormEntry> norms = buildSyntheticNorms();
+
+        // --- Flags(mockFlags 覆盖 E12:hasDan/ironLow)---
+        Map<String, Object> flags = new LinkedHashMap<>();
+        flags.put("hasDan", true);
+        flags.put("ironLow", true);
+
+        // --- 1) 常模比对 ---
+        CompareResult compare = normCompareEngine.compare(members, raw, norms, flags);
+        // 验收:中间态包含 5 个关键键
+        assertTrue(compare.getIntermediateState().containsKey("l.reuteriAbnormal"), "l.reuteriAbnormal");
+        assertTrue(compare.getIntermediateState().containsKey("ironLow"), "ironLow");
+        assertTrue(compare.getIntermediateState().containsKey("hasDan"), "hasDan");
+        assertTrue(compare.getIntermediateState().containsKey("isFemaleTeen"), "isFemaleTeen");
+        assertTrue(compare.getIntermediateState().containsKey("hasDiabetes"), "hasDiabetes");
+        // 验收:具体值
+        assertEquals(Boolean.TRUE, compare.getIntermediateState().get("l.reuteriAbnormal"));
+        assertEquals(Boolean.TRUE, compare.getIntermediateState().get("ironLow"));
+        assertEquals(Boolean.TRUE, compare.getIntermediateState().get("hasDan"));
+        assertEquals(Boolean.TRUE, compare.getIntermediateState().get("isFemaleTeen"));
+        assertEquals(Boolean.FALSE, compare.getIntermediateState().get("hasDiabetes"));
+
+        // 常模缺失 → 待补(不猜值),并产生告警
+        IndicatorReading pending = compare.getReadings().stream()
+                .filter(r -> IndicatorReading.STATUS_PENDING.equals(r.getStatus()))
+                .findFirst().orElse(null);
+        assertNotNull(pending, "无常模指标应为待补");
+        assertEquals("维生素D", pending.getCode());
+        assertFalse(compare.getWarnings().isEmpty(), "常模缺失应有告警");
+
+        // --- 2) 指标解读 ---
+        List<MemberAttribution> attributions = indicatorInterpretEngine.interpret(compare.getReadings(), members);
+        assertEquals(4, attributions.size()); // 4 人
+        // 妈妈:罗伊氏异常+铁低 = 2 异常(可撤回 1)
+        MemberAttribution mom = findByRole(attributions, "mom");
+        assertTrue(mom.getAbnormalCount() >= 2, "mom abnormalCount>=2");
+        assertTrue(mom.getRetractableCount() >= 1, "mom retractableCount>=1");
+
+        // 女儿:DAN 情绪低
+        MemberAttribution daughter = findByRole(attributions, "daughter");
+        assertTrue(daughter.getAbnormalCount() >= 1, "daughter abnormalCount>=1");
+
+        // 儿子:双歧极低
+        MemberAttribution son = findByRole(attributions, "son");
+        assertTrue(son.getAbnormalCount() >= 1, "son abnormalCount>=1");
+
+        // --- 3) 五维归因 ---
+        FiveDimAttributionEngine.FiveDimResult five = fiveDimEngine.attribute(attributions, members);
+        assertEquals(4, five.attributions.size());
+        // 女儿应有 心 短板
+        assertTrue(daughter.getWeakDimensions().contains("心"), "daughter weak 心");
+
+        // --- 4) 主因链 ---
+        List<CauseLink> links = chainEngine.chain(five.attributions, five.familyMap);
+        assertFalse(links.isEmpty(), "主因链非空");
+        // 第 0 环应为家庭共享基座(主因维)
+        assertEquals("家庭共享基座", links.get(0).getFrom());
+
+        // --- 5) 被证伪项 ---
+        List<FalsifiedItem> falsified = falsificationEngine.falsify(five.attributions, compare.getReadings(), flags);
+        // 应含“补充剂印记类异常”
+        assertTrue(falsified.stream().anyMatch(f -> f.getItem().contains("补充剂印记")), "有补充剂印记被证伪项");
+        // 应含“常模缺失项归因无效”
+        assertTrue(falsified.stream().anyMatch(f -> f.getItem().contains("常模缺失")), "有常模缺失被证伪项");
+
+        // --- 6) AI 叙述(规则兜底,AI 关闭)---
+        Map<String, Object> narrativeCtx = new LinkedHashMap<>();
+        narrativeCtx.put("attributions", toAttributionView(five.attributions));
+        narrativeCtx.put("familyMap", five.familyMap);
+        narrativeCtx.put("mainCauseChain", links);
+        narrativeCtx.put("falsified", falsified);
+        narrativeCtx.put("intermediateState", compare.getIntermediateState());
+        Map<String, Object> narrative = narrativeService.narrate(narrativeCtx, null);
+        assertEquals("rule", narrative.get("narratedBy"));
+        assertNotNull(narrative.get("conclusionHeadline"));
+        assertNotNull(narrative.get("coachQuestions"));
+        assertNotNull(narrative.get("closingLine"));
+
+        // --- 7) 报告 JSON 构建(十节)---
+        AttributionInput input = new AttributionInput();
+        input.setFamilyId(1L);
+        input.setMemberIds(Arrays.asList(1L, 2L, 3L, 4L));
+        String reportJson = reportBuilder.buildReportJson(
+                five, links, falsified, compare, narrative,
+                reportBuilder.computeRedlineHits(five, compare.getIntermediateState(), members),
+                input);
+
+        // 验收:十节结构
+        JsonNode root = objectMapper.readTree(reportJson);
+        assertEquals("1.0", root.path("schemaVersion").asText());
+        assertEquals(10, root.path("sectionCount").asInt());
+        JsonNode titles = root.path("sectionTitles");
+        assertEquals(10, titles.size());
+        for (int i = 0; i < 10; i++) {
+            assertEquals(AttributionReportBuilder.SECTION_TITLES[i], titles.get(i).asText());
+        }
+
+        JsonNode sections = root.path("sections");
+        // 1 结论先行
+        assertNotNull(sections.get("conclusion"));
+        assertNotNull(sections.get("conclusion").path("headline").asText());
+        // 2 数据速览
+        assertTrue(sections.get("overview").isArray());
+        // 3 逐人归因
+        JsonNode perMember = sections.get("perMember");
+        assertEquals(4, perMember.size());
+        // 4 家庭系统归因
+        assertNotNull(sections.get("familyMap"));
+        assertNotNull(sections.get("mainCauseChain"));
+        assertNotNull(sections.get("falsified"));
+        // 5 应对方案(三条通道)
+        JsonNode channels = sections.get("channels");
+        assertEquals(3, channels.size());
+        // 6 12 周目标
+        assertTrue(sections.get("goals").isArray());
+        // 7 教练提问序列
+        assertTrue(sections.get("coachQuestions").isArray());
+        // 8 问诊清单
+        assertNotNull(sections.get("questionnaire"));
+        // 9 红线与转介
+        JsonNode redlines = sections.get("redlines");
+        assertTrue(redlines.isArray());
+        // RL01-RL08 共 8 条
+        Set<String> codes = new LinkedHashSet<>();
+        for (JsonNode r : redlines) {
+            codes.add(r.path("code").asText());
+        }
+        for (String code : new String[]{"RL01","RL02","RL03","RL04","RL05","RL06","RL07","RL08"}) {
+            assertTrue(codes.contains(code), "红线缺失: " + code);
+        }
+        // RL02 hit(女儿 心 短板)→ 命中
+        boolean rl02hit = false;
+        for (JsonNode r : redlines) {
+            if ("RL02".equals(r.path("code").asText())) {
+                rl02hit = r.path("hit").asBoolean();
+                break;
+            }
+        }
+        assertTrue(rl02hit, "RL02 应命中(女儿情绪短板 + riskMoodLow)");
+        // 转介建议
+        JsonNode referral = sections.get("referral");
+        assertTrue(referral.path("suggested").asBoolean(), "RL02 命中 → 建议专业评估");
+        assertFalse(referral.path("blocking").asBoolean(), "E4 弱提示不阻断");
+        // 10 一句话收尾
+        assertNotNull(sections.get("closing"));
+        assertTrue(sections.get("closing").path("line").asText().contains("共享基座"));
+
+        // 中间态快照存在
+        JsonNode intermediate = root.path("intermediateState");
+        for (String key : new String[]{"l.reuteriAbnormal","ironLow","hasDan","isFemaleTeen","hasDiabetes"}) {
+            assertTrue(intermediate.has(key), "中间态缺键: " + key);
+        }
+        // 状态 READY
+        assertEquals(AttributionReport.STATUS_READY, root.path("status").asText());
+
+        System.out.println("✅ B1 验收通过:十节结构 + 中间态 5 键 + RL01/RL02 命中 + status=READY");
+    }
+
+    // ============================================================
+    // 2. Service 层 analyze():Mock DB → ANALYZING → READY
+    // ============================================================
+    @Test
+    void analyze_persistsAnlyzingThenReady() {
+        // Mock FamilyMember for 4 members
+        FamilyMember mom = member(1L, "孟凡锐", "female", 38, 0, false);
+        FamilyMember dad = member(2L, "马智强", "male", 41, 0, true);
+        FamilyMember daughter = member(3L, "马昕源", "female", 14, -1, false);
+        FamilyMember son = member(4L, "马安扬", "male", 9, -1, false);
+        when(familyMemberMapper.selectById(1L)).thenReturn(mom);
+        when(familyMemberMapper.selectById(2L)).thenReturn(dad);
+        when(familyMemberMapper.selectById(3L)).thenReturn(daughter);
+        when(familyMemberMapper.selectById(4L)).thenReturn(son);
+
+        // Empty readings / norms / survey
+        when(gutFloraMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(healthReportMapper.selectBatchIds(any())).thenReturn(Collections.emptyList());
+        when(danMapper.selectBatchIds(any())).thenReturn(Collections.emptyList());
+        when(normValueMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(cognitiveNormMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(healthNormMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(surveyRecordMapper.selectById(any())).thenReturn(null);
+
+        // 快照插入/更新时的 status(同一实例后续会被改写为 READY,故需在调用时刻取快照)
+        final java.util.concurrent.atomic.AtomicReference<String> insertedStatus =
+                new java.util.concurrent.atomic.AtomicReference<>();
+        final java.util.concurrent.atomic.AtomicReference<String> updatedStatus =
+                new java.util.concurrent.atomic.AtomicReference<>();
+        when(reportMapper.insert(any(AttributionReport.class))).thenAnswer(inv -> {
+            insertedStatus.set(((AttributionReport) inv.getArgument(0)).getStatus());
+            return 0;
+        });
+        when(reportMapper.updateById(any(AttributionReport.class))).thenAnswer(inv -> {
+            updatedStatus.set(((AttributionReport) inv.getArgument(0)).getStatus());
+            return 0;
+        });
+
+        AttributionInput input = new AttributionInput();
+        input.setFamilyId(1L);
+        input.setMemberIds(Arrays.asList(1L, 2L, 3L, 4L));
+        input.setMock(true);
+        Map<String, Object> mockFlags = new LinkedHashMap<>();
+        mockFlags.put("hasDan", true);
+        mockFlags.put("ironLow", true);
+        input.setMockFlags(mockFlags);
+
+        // Call
+        AttributionReportVO vo = service.analyze(input);
+
+        // Verify insert with ANALYZING
+        verify(reportMapper).insert(any(AttributionReport.class));
+        assertEquals(AttributionReport.STATUS_ANALYZING, insertedStatus.get());
+
+        // Verify update with READY
+        verify(reportMapper).updateById(any(AttributionReport.class));
+        assertEquals(AttributionReport.STATUS_READY, updatedStatus.get());
+
+        // VO asserts
+        assertEquals(AttributionReport.STATUS_READY, vo.getStatus());
+        assertNotNull(vo.getReportJson());
+        assertNotNull(vo.getIntermediateState());
+        for (String k : new String[]{"l.reuteriAbnormal","ironLow","hasDan","isFemaleTeen","hasDiabetes"}) {
+            assertTrue(vo.getIntermediateState().containsKey(k), "VO 缺中间态键: " + k);
+        }
+        assertTrue(vo.getRedlineHits().contains("RL01"), "RL01 命中");
+        assertTrue(vo.getRedlineHits().contains("RL03"), "RL03 命中(未成年)");
+        // RL02: 心短板需要异常读数;无读数则不命中,但 RL01/RL03/RL06/RL07 存在
+        System.out.println("✅ B1 DB 流转验收:ANALYZING → READY, 中间态 5 键, 红线 " + vo.getRedlineHits());
+    }
+
+    // ---- Helpers ----
+
+    private List<MemberProfile> buildFourPersonProfiles() {
+        List<MemberProfile> list = new ArrayList<>();
+        list.add(profile(1L, "孟凡锐", "mom", "妈妈", "female", 38, 0, false));
+        list.add(profile(2L, "马智强", "dad", "爸爸", "male", 41, 0, true));
+        list.add(profile(3L, "马昕源", "daughter", "女儿", "female", 14, -1, false));
+        list.add(profile(4L, "马安扬", "son", "儿子", "male", 9, -1, false));
+        return list;
+    }
+
+    private MemberProfile profile(Long id, String name, String role, String label,
+                                  String gender, int age, int gen, boolean spouse) {
+        MemberProfile p = new MemberProfile();
+        p.setMemberId(id);
+        p.setName(name);
+        p.setRole(role);
+        p.setRoleLabel(label);
+        p.setGender(gender);
+        p.setAge(age);
+        p.setAgeBand("19-45".equals(label) ? "19-45" : age <= 18 ? "13-18" : "19-45");
+        p.setGeneration(gen);
+        p.setSpouse(spouse);
+        p.setMinor(age < 18);
+        p.setFemaleTeen("female".equals(gender) && age >= 13 && age <= 18);
+        return p;
+    }
+
+    private List<IndicatorReading> buildSyntheticReadings() {
+        List<IndicatorReading> out = new ArrayList<>();
+
+        // 妈妈:罗伊氏乳杆菌 populationLevel 7.3% → 偏高(可撤回)
+        IndicatorReading r1 = new IndicatorReading();
+        r1.setMemberId(1L); r1.setMemberName("孟凡锐");
+        r1.setSource("gut_flora"); r1.setCode("Lactobacillus reuteri"); r1.setDisplayName("罗伊氏乳杆菌");
+        r1.setValue("7.3%"); r1.setPopulationLevel("7.3%");
+        out.add(r1);
+
+        // 妈妈:血清铁 8(p5=9,p25=11,p50=14,p75=17,p95=20 → veryLow)
+        IndicatorReading r2 = new IndicatorReading();
+        r2.setMemberId(1L); r2.setMemberName("孟凡锐");
+        r2.setSource("blood"); r2.setCode("血清铁"); r2.setDisplayName("血清铁");
+        r2.setValue("8"); r2.setNumericValue(8.0); r2.setUnit("μmol/L");
+        out.add(r2);
+
+        // 女儿:DAN 情绪商数 35(p5=40,p25=55,p50=70,p75=80,p95=90 → veryLow)
+        IndicatorReading r3 = new IndicatorReading();
+        r3.setMemberId(3L); r3.setMemberName("马昕源");
+        r3.setSource("dan"); r3.setCode("emotionScore"); r3.setDisplayName("情绪商数");
+        r3.setValue("35"); r3.setNumericValue(35.0); r3.setUnit("分");
+        out.add(r3);
+
+        // 儿子:双歧杆菌 populationLevel 2.1% → veryLow
+        IndicatorReading r4 = new IndicatorReading();
+        r4.setMemberId(4L); r4.setMemberName("马安扬");
+        r4.setSource("gut_flora"); r4.setCode("Bifidobacterium"); r4.setDisplayName("双歧杆菌");
+        r4.setValue("2.1%"); r4.setPopulationLevel("2.1%");
+        out.add(r4);
+
+        // 妈妈:维生素D 无常模 → 待补(不猜值)
+        IndicatorReading r5 = new IndicatorReading();
+        r5.setMemberId(1L); r5.setMemberName("孟凡锐");
+        r5.setSource("blood"); r5.setCode("维生素D"); r5.setDisplayName("维生素D");
+        r5.setValue("18"); r5.setNumericValue(18.0); r5.setUnit("ng/mL");
+        out.add(r5);
+
+        return out;
+    }
+
+    private List<NormEntry> buildSyntheticNorms() {
+        List<NormEntry> out = new ArrayList<>();
+        // 血清铁 p5=9
+        NormEntry n1 = new NormEntry();
+        n1.setNormType("blood"); n1.setCode("血清铁"); n1.setDisplayName("血清铁");
+        n1.setAgeBand("19-45"); n1.setGender("female");
+        n1.setP5(9.0); n1.setP25(11.0); n1.setP50(14.0); n1.setP75(17.0); n1.setP95(20.0);
+        n1.setUnit("μmol/L"); n1.setSource("attribution_norm_values");
+        out.add(n1);
+        // DAN 情绪商数
+        NormEntry n2 = new NormEntry();
+        n2.setNormType("dan"); n2.setCode("emotionScore"); n2.setDisplayName("情绪商数");
+        n2.setAgeBand("13-18"); n2.setGender("female");
+        n2.setP5(40.0); n2.setP25(55.0); n2.setP50(70.0); n2.setP75(80.0); n2.setP95(90.0);
+        n2.setUnit("分"); n2.setSource("cognitive_norms");
+        out.add(n2);
+        return out;
+    }
+
+    private MemberAttribution findByRole(List<MemberAttribution> list, String role) {
+        for (MemberAttribution a : list) {
+            if (role.equals(a.getRole())) {
+                return a;
+            }
+        }
+        return null;
+    }
+
+    private List<Map<String, Object>> toAttributionView(List<MemberAttribution> attrs) {
+        List<Map<String, Object>> out = new ArrayList<>();
+        for (MemberAttribution a : attrs) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("memberId", a.getMemberId());
+            m.put("name", a.getName());
+            m.put("roleLabel", a.getRoleLabel());
+            m.put("surface", a.getSurface());
+            m.put("essence", a.getEssence());
+            m.put("fiveDim", a.getFiveDim());
+            m.put("weakDimensions", a.getWeakDimensions());
+            m.put("abnormalCount", a.getAbnormalCount());
+            out.add(m);
+        }
+        return out;
+    }
+
+    private FamilyMember member(Long id, String name, String gender, int age, int generation, boolean spouse) {
+        FamilyMember fm = new FamilyMember();
+        fm.setId(id);
+        fm.setNickname(name);
+        fm.setGender(gender);
+        fm.setAge(age);
+        fm.setGeneration(generation);
+        fm.setIsSpouse(spouse);
+        return fm;
+    }
+
+    private static void setField(Object target, String fieldName, Object value) {
+        try {
+            java.lang.reflect.Field f = findField(target.getClass(), fieldName);
+            f.setAccessible(true);
+            f.set(target, value);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static java.lang.reflect.Field findField(Class<?> clazz, String name) {
+        for (Class<?> c = clazz; c != Object.class; c = c.getSuperclass()) {
+            try {
+                return c.getDeclaredField(name);
+            } catch (NoSuchFieldException ignored) {
+            }
+        }
+        throw new IllegalArgumentException("Field not found: " + name);
+    }
+}

+ 193 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AttributionPlanServiceTest.java

@@ -0,0 +1,193 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.dto.FamilyPlanVO;
+import com.etotem.cfc.entity.AttributionReport;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.mapper.AttributionReportMapper;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.service.AttributionPlanService;
+import com.etotem.cfc.service.impl.AttributionPlanServiceImpl;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 家庭归因 B4 七章干预方案 Mock 测试(ATT-S2-B4)。
+ *
+ * <p>验收:planJson 含 7 章 + header;每动作带 level/reviewHook;人范围仅妈妈/女儿/儿子(无爸爸);
+ * 产品品牌级(Enagic 还原水/PHGG/菌株名)且带 RL05 合规口径;儿童动作无剂量数字(「医生定」)。\u200b
+ */
+class AttributionPlanServiceTest {
+
+    private AttributionPlanService service;
+    private AttributionReportMapper reportMapper;
+    private HealthPlanMapper healthPlanMapper;
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    @BeforeEach
+    void setUp() {
+        AttributionPlanServiceImpl impl = new AttributionPlanServiceImpl();
+        reportMapper = mock(AttributionReportMapper.class);
+        healthPlanMapper = mock(HealthPlanMapper.class);
+        ReflectionTestUtils.setField(impl, "attributionReportMapper", reportMapper);
+        ReflectionTestUtils.setField(impl, "healthPlanMapper", healthPlanMapper);
+        ReflectionTestUtils.setField(impl, "objectMapper", objectMapper);
+        when(healthPlanMapper.insert(any(HealthPlan.class))).thenAnswer(inv -> {
+            HealthPlan p = inv.getArgument(0);
+            p.setId(100L);
+            return 1;
+        });
+        service = impl;
+    }
+
+    /** 四人家庭归因报告 JSON(爸爸在范围内,但 E6 要求方案不含爸爸) */
+    private String reportJson4Person() {
+        return "{"
+                + "\"sections\":{"
+                + "\"conclusion\":{\"headline\":\"4 项表象,身 维为主因维\"},"
+                + "\"overview\":["
+                + "{\"memberId\":1,\"roleLabel\":\"妈妈\",\"indicator\":\"血清铁\",\"percentile\":\"P≤5\",\"status\":\"偏低\",\"normSource\":\"attribution_norm_values\"},"
+                + "{\"memberId\":3,\"roleLabel\":\"女儿\",\"indicator\":\"情绪商数\",\"percentile\":\"P≤5\",\"status\":\"偏低\",\"normSource\":\"cognitive_norms\"},"
+                + "{\"memberId\":4,\"roleLabel\":\"儿子\",\"indicator\":\"双歧杆菌\",\"percentile\":\"P≤5\",\"status\":\"偏低\",\"normSource\":\"populationLevel\"}"
+                + "],"
+                + "\"perMember\":["
+                + "{\"memberId\":1,\"name\":\"孟凡锐\",\"role\":\"mom\",\"roleLabel\":\"妈妈\",\"surface\":\"疲劳\",\"essence\":\"身维 2 项偏离\",\"fiveDim\":{\"身\":51,\"心\":74},\"weakDimensions\":[\"身\"]},"
+                + "{\"memberId\":2,\"name\":\"马智强\",\"role\":\"dad\",\"roleLabel\":\"爸爸\",\"surface\":\"睡眠\",\"essence\":\"行维 1 项偏离\",\"fiveDim\":{\"行\":63},\"weakDimensions\":[\"行\"]},"
+                + "{\"memberId\":3,\"name\":\"马昕源\",\"role\":\"daughter\",\"roleLabel\":\"女儿\",\"surface\":\"情绪波动\",\"essence\":\"心维 1 项偏离\",\"fiveDim\":{\"心\":60},\"weakDimensions\":[\"心\"]},"
+                + "{\"memberId\":4,\"name\":\"马安扬\",\"role\":\"son\",\"roleLabel\":\"儿子\",\"surface\":\"偏食\",\"essence\":\"身维 1 项偏离\",\"fiveDim\":{\"身\":58},\"weakDimensions\":[\"身\"]}"
+                + "],"
+                + "\"familyMap\":{\"身\":63.2,\"心\":70.5,\"mainDimensions\":[\"身\"]},"
+                + "\"mainCauseChain\":[],\"falsified\":[],"
+                + "\"goals\":[{\"indicator\":\"血清铁(妈妈)\",\"now\":\"8 P≤5\",\"target\":\"12 周内回升至 P25 以上\",\"owner\":\"妈妈\"}]"
+                + "}}";
+    }
+
+    @Test
+    void 方案含七章与头部且人范围仅三人() throws Exception {
+        AttributionReport report = new AttributionReport();
+        report.setId(9L);
+        report.setFamilyId(1L);
+        report.setStatus(AttributionReport.STATUS_CONFIRMED);
+        report.setReportJson(reportJson4Person());
+        report.setIntermediateState("{}");
+        when(reportMapper.selectById(9L)).thenReturn(report);
+
+        FamilyPlanVO vo = service.generateFamilyPlan(9L, 1L);
+        assertNotNull(vo);
+        JsonNode plan = objectMapper.readTree(vo.getPlanJson());
+
+        // 验收:header + 7 章
+        assertTrue(plan.has("header"), "缺 header");
+        assertNotNull(plan.get("header").get("summary"));
+        assertEquals(7, plan.get("chapters").size(), "应为 7 章");
+        String[] titles = {"三人干预相关数据速览", "主线产品:作用原理·合规口径·用法", "分人方案(妈妈/女儿/儿子)",
+                "家庭厨房与饮水改造(共享基座)", "12 周执行节奏与可检验目标", "红线与转介(再强调)", "附录:产品选购与用法提示"};
+        for (int i = 0; i < 7; i++) {
+            assertEquals(i + 1, plan.get("chapters").get(i).path("no").asInt());
+            assertEquals(titles[i], plan.get("chapters").get(i).path("title").asText());
+        }
+
+        // 验收:人范围仅妈妈/女儿/儿子,无爸爸
+        JsonNode members = plan.get("chapters").get(2).get("members");
+        assertEquals(3, members.size(), "应为 3 人");
+        List<String> names = new ArrayList<>();
+        for (JsonNode m : members) {
+            names.add(m.path("member").asText());
+            // 验收:每动作带 level / reviewHook
+            JsonNode actions = m.get("actions");
+            assertTrue(actions.size() > 0, m.path("member").asText() + " 应有动作");
+            for (JsonNode a : actions) {
+                assertNotNull(a.get("level"), "动作缺 level");
+                assertFalse(a.path("level").asText().trim().isEmpty(), "动作 level 为空");
+                assertFalse(a.path("reviewHook").asText().trim().isEmpty(), "动作缺 reviewHook");
+                assertFalse(a.path("action").asText().trim().isEmpty(), "动作内容为空");
+            }
+        }
+        assertTrue(names.contains("妈妈") && names.contains("女儿") && names.contains("儿子"));
+        assertFalse(names.contains("爸爸"), "E6:爸爸链外,不得出现在方案中");
+        assertTrue(plan.get("header").path("warnings").toString().contains("爸爸"), "应记录跳过爸爸的告警");
+
+        // 验收:儿童补剂动作写「医生定」且不含剂量数字(E3/RL03:还原水等非补剂日常动作不要求)
+        for (JsonNode m : members) {
+            String member = m.path("member").asText();
+            if (!"女儿".equals(member) && !"儿子".equals(member)) {
+                continue;
+            }
+            for (JsonNode a : m.get("actions")) {
+                String how = a.path("how").asText();
+                String productText = textOf(a, "product");
+                boolean isSupplement = productText != null
+                        && (productText.contains("双歧") || productText.contains("HN019")
+                        || productText.contains("布拉氏") || productText.contains("PHGG")
+                        || productText.contains("益生菌"));
+                if (isSupplement) {
+                    assertTrue(how.contains("医生定"), member + " 补剂动作必须写「医生定」:" + how);
+                }
+                // 剂量数字禁词(g/mg/ml/毫克/克/粒/片)
+                assertFalse(how.matches(".*\\d+(\\.\\d+)?\\s*(g|mg|ml|毫克|克|毫升|粒|片).*"),
+                        member + " 动作不得出现剂量数字:" + how);
+            }
+        }
+
+        // 验收:品牌级产品 + RL05 合规口径
+        JsonNode products = plan.get("chapters").get(1).get("products");
+        String productNames = products.toString();
+        assertTrue(productNames.contains("Enagic 还原水"), "缺 Enagic 还原水");
+        assertTrue(productNames.contains("Enagic 姜黄"), "缺 Enagic 姜黄");
+        assertTrue(productNames.contains("瓜尔豆益生元 PHGG"), "缺 PHGG");
+        assertTrue(productNames.contains("长双歧 BB536"), "缺菌株 BB536");
+        boolean rl05 = false;
+        for (JsonNode p : products) {
+            if (p.get("compliance").toString().contains("RL05")) {
+                rl05 = true;
+            }
+        }
+        assertTrue(rl05, "产品须携带 RL05(不宣称治疗)合规口径");
+        assertTrue(plan.get("chapters").get(5).path("redlines").toString().contains("RL05"), "第 6 章须再强调 RL05");
+
+        // 落库:health_plans.plan_type=family_attribution_plan
+        ArgumentCaptor<HealthPlan> cap = ArgumentCaptor.forClass(HealthPlan.class);
+        verify(healthPlanMapper).insert(cap.capture());
+        HealthPlan hp = cap.getValue();
+        assertEquals("family_attribution_plan", hp.getPlanType());
+        assertEquals("GENERATED", hp.getStatus());
+        assertFalse(hp.getMemberIds().contains("2"), "member_ids 不应含爸爸");
+        assertTrue(hp.getMemberIds().contains("1") && hp.getMemberIds().contains("3") && hp.getMemberIds().contains("4"));
+
+        System.out.println("✅ B4 验收:7 章 + header,人范围 3 人(无爸爸),动作带 level/reviewHook,儿童「医生定」,RL05 合规");
+    }
+
+    /** 取 JSON 字段文本:null/缺失 → null(Jackson 对 JSON null 的 asText() 返回 "null") */
+    private static String textOf(JsonNode parent, String field) {
+        JsonNode node = parent.get(field);
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        String s = node.asText();
+        return s == null || s.trim().isEmpty() ? null : s;
+    }
+
+    @Test
+    void 结论未确认时拒绝生成方案() {
+        AttributionReport report = new AttributionReport();
+        report.setId(9L);
+        report.setStatus(AttributionReport.STATUS_READY);
+        when(reportMapper.selectById(9L)).thenReturn(report);
+
+        IllegalStateException ex = assertThrows(IllegalStateException.class,
+                () -> service.generateFamilyPlan(9L, 1L));
+        assertTrue(ex.getMessage().contains("尚未确认"), "应提示先确认结论");
+        verify(healthPlanMapper, never()).insert(any(HealthPlan.class));
+        System.out.println("✅ B4 验收:结论未 CONFIRMED → 拒绝生成方案");
+    }
+}

+ 105 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AttributionRedlinesTest.java

@@ -0,0 +1,105 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.constant.AttributionRedlines;
+import com.etotem.cfc.constant.AttributionRedlines.Level;
+import com.etotem.cfc.constant.AttributionRedlines.Redline;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * ATT-S1-A4:合规红线模板 10 条硬编码校验。
+ *
+ * 校验点:10 条完整、P0×4/P1×4/P2×2、message 非空且不含「治愈/根治」等禁词。
+ */
+class AttributionRedlinesTest {
+
+    @Test
+    void 红线共10条且编号连续() {
+        List<Redline> all = AttributionRedlines.all();
+        assertEquals(10, all.size(), "红线总数应为 10 条");
+
+        List<String> codes = all.stream().map(Redline::getCode).collect(Collectors.toList());
+        List<String> expected = java.util.stream.IntStream.rangeClosed(1, 10)
+                .mapToObj(i -> String.format("RL%02d", i))
+                .collect(Collectors.toList());
+        assertEquals(expected, codes, "红线编号应为 RL01..RL10 连续编号");
+    }
+
+    @Test
+    void 分级分布为P0四条P1四条P2两条() {
+        assertEquals(4, AttributionRedlines.byLevel(Level.P0).size());
+        assertEquals(4, AttributionRedlines.byLevel(Level.P1).size());
+        assertEquals(2, AttributionRedlines.byLevel(Level.P2).size());
+    }
+
+    @Test
+    void P0红线为RL01至RL04() {
+        List<String> p0 = AttributionRedlines.byLevel(Level.P0).stream()
+                .map(Redline::getCode).collect(Collectors.toList());
+        assertEquals(java.util.Arrays.asList("RL01", "RL02", "RL03", "RL04"), p0);
+    }
+
+    @Test
+    void 每条红线字段完整且文案不含禁词() {
+        for (Redline r : AttributionRedlines.all()) {
+            assertNotNull(r.getCode(), "code 非空");
+            assertNotNull(r.getLevel(), "level 非空");
+            assertNotNull(r.getTitle(), "title 非空");
+            assertNotNull(r.getMessage(), "message 非空");
+            assertNotNull(r.getScope(), "scope 非空");
+            assertFalse(r.getMessage().trim().isEmpty(), r.getCode() + " message 不能为空");
+            assertFalse(r.getTitle().trim().isEmpty(), r.getCode() + " title 不能为空");
+            for (String banned : AttributionRedlines.FORBIDDEN_WORDS) {
+                assertFalse(r.getMessage().contains(banned),
+                        r.getCode() + " message 不得含禁词「" + banned + "」:" + r.getMessage());
+            }
+        }
+    }
+
+    @Test
+    void E7品牌口径_产品类红线不含功效承诺() {
+        Redline rl05 = AttributionRedlines.byCode("RL05");
+        assertNotNull(rl05);
+        assertEquals(Level.P1, rl05.getLevel());
+        assertTrue(rl05.getMessage().contains("不宣称治疗"), "RL05 必须写明不宣称治疗");
+        assertTrue(rl05.getMessage().contains("Enagic") || rl05.getMessage().contains("PHGG"),
+                "RL05 应覆盖品牌露出(Enagic/PHGG)");
+    }
+
+    @Test
+    void E4弱提示_情绪红线写明不阻断与建议专业评估() {
+        Redline rl02 = AttributionRedlines.byCode("RL02");
+        assertNotNull(rl02);
+        assertEquals(Level.P0, rl02.getLevel());
+        assertTrue(rl02.getMessage().contains("建议专业评估"), "RL02 必须含「建议专业评估」");
+        assertTrue(rl02.getMessage().contains("家长确认"), "RL02 必须含「家长确认」");
+        assertTrue(rl02.getMessage().contains("不阻断") || rl02.getMessage().contains("不因此阻断"),
+                "RL02 必须标明不阻断(E4 弱提示)");
+    }
+
+    @Test
+    void byScope按阶段标记过滤() {
+        assertEquals(10, AttributionRedlines.byScope(null).size(), "null scope 视作全部");
+        assertEquals(10, AttributionRedlines.byScope("全部").size());
+        // scope 含 ⑤ 或 scope=全部:RL01(全部)/RL03(⑤⑥)/RL04(③⑤)/RL05(⑤)/RL06(③⑤⑥)/RL07(⑤⑥)/RL09(⑤)/RL10(⑤) = 8
+        assertEquals(8, AttributionRedlines.byScope("⑤").size());
+        // scope 含 ④ 或 scope=全部:RL01(全部)/RL02(③④)/RL08(④) = 3
+        assertEquals(3, AttributionRedlines.byScope("④").size());
+    }
+
+    @Test
+    void 各分级文案可被引用() {
+        assertEquals("RL09", AttributionRedlines.byCode("RL09").getCode());
+        assertTrue(AttributionRedlines.codeMap().containsKey("RL01"));
+        assertTrue(AttributionRedlines.all().size() >= AttributionRedlines.byLevel(Level.P0).size()
+                + AttributionRedlines.byLevel(Level.P1).size()
+                + AttributionRedlines.byLevel(Level.P2).size());
+    }
+}

+ 135 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ConditionEvaluatorServiceTest.java

@@ -0,0 +1,135 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.service.ConditionEvaluatorService;
+import com.etotem.cfc.service.impl.ConditionEvaluatorServiceImpl;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * ATT-S1-A2:ConditionEvaluatorService 条件求值单测。
+ * 覆盖 10 种操作符 + null 恒真 + 未知 var 保守 false。
+ */
+class ConditionEvaluatorServiceTest {
+
+    private ConditionEvaluatorService service;
+    private ObjectMapper mapper;
+    private Map<String, Object> context;
+
+    @BeforeEach
+    void setUp() {
+        service = new ConditionEvaluatorServiceImpl();
+        mapper = new ObjectMapper();
+        context = new HashMap<>();
+        context.put("l.reuteriAbnormal", true);
+        context.put("ironLow", false);
+        context.put("age", 14);
+        context.put("sleepHours", 7.5);
+        context.put("danConsent", "愿意");
+        context.put("stressors", Arrays.asList("学业", "同伴"));
+        context.put("emptyValue", "");
+    }
+
+    private JsonNode json(String raw) {
+        try {
+            return mapper.readTree(raw);
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    @Test
+    void null条件恒真() {
+        assertTrue(service.evaluate(null, context));
+    }
+
+    @Test
+    void 等于操作符命中() {
+        assertTrue(service.evaluate(json("{\"op\":\"==\",\"var\":\"l.reuteriAbnormal\",\"val\":true}"), context));
+        assertFalse(service.evaluate(json("{\"op\":\"==\",\"var\":\"l.reuteriAbnormal\",\"val\":false}"), context));
+    }
+
+    @Test
+    void 不等于操作符() {
+        assertTrue(service.evaluate(json("{\"op\":\"!=\",\"var\":\"ironLow\",\"val\":true}"), context));
+        assertFalse(service.evaluate(json("{\"op\":\"!=\",\"var\":\"l.reuteriAbnormal\",\"val\":true}"), context));
+    }
+
+    @Test
+    void 大于小于比较操作符() {
+        assertTrue(service.evaluate(json("{\"op\":\">\",\"var\":\"age\",\"val\":10}"), context));
+        assertFalse(service.evaluate(json("{\"op\":\">\",\"var\":\"age\",\"val\":18}"), context));
+        assertTrue(service.evaluate(json("{\"op\":\">=\",\"var\":\"age\",\"val\":14}"), context));
+        assertTrue(service.evaluate(json("{\"op\":\"<\",\"var\":\"sleepHours\",\"val\":8}"), context));
+        assertTrue(service.evaluate(json("{\"op\":\"<=\",\"var\":\"sleepHours\",\"val\":7.5}"), context));
+        assertFalse(service.evaluate(json("{\"op\":\"<=\",\"var\":\"sleepHours\",\"val\":7}"), context));
+    }
+
+    @Test
+    void 包含操作符匹配数组元素() {
+        assertTrue(service.evaluate(json("{\"op\":\"contains\",\"var\":\"stressors\",\"val\":\"学业\"}"), context));
+        assertFalse(service.evaluate(json("{\"op\":\"contains\",\"var\":\"stressors\",\"val\":\"家庭\"}"), context));
+    }
+
+    @Test
+    void in操作符匹配候选集() {
+        assertTrue(service.evaluate(json("{\"op\":\"in\",\"var\":\"danConsent\",\"val\":[\"愿意\",\"暂缓\"]}"), context));
+        assertFalse(service.evaluate(json("{\"op\":\"in\",\"var\":\"danConsent\",\"val\":[\"不愿意\"]}"), context));
+    }
+
+    @Test
+    void exists操作符() {
+        assertTrue(service.evaluate(json("{\"op\":\"exists\",\"var\":\"ironLow\"}"), context));
+        assertFalse(service.evaluate(json("{\"op\":\"exists\",\"var\":\"notExistsVar\"}"), context));
+    }
+
+    @Test
+    void anyOf为或语义() {
+        JsonNode cond = json("{\"op\":\"anyOf\",\"items\":["
+                + "{\"op\":\"==\",\"var\":\"l.reuteriAbnormal\",\"val\":true},"
+                + "{\"op\":\"==\",\"var\":\"ironLow\",\"val\":true}]}");
+        assertTrue(service.evaluate(cond, context));
+
+        JsonNode none = json("{\"op\":\"anyOf\",\"items\":["
+                + "{\"op\":\"==\",\"var\":\"l.reuteriAbnormal\",\"val\":false},"
+                + "{\"op\":\"==\",\"var\":\"ironLow\",\"val\":true}]}");
+        assertFalse(service.evaluate(none, context));
+    }
+
+    @Test
+    void 缺省items为与语义() {
+        JsonNode cond = json("{\"items\":["
+                + "{\"op\":\"==\",\"var\":\"l.reuteriAbnormal\",\"val\":true},"
+                + "{\"op\":\"==\",\"var\":\"ironLow\",\"val\":false}]}");
+        assertTrue(service.evaluate(cond, context));
+
+        JsonNode fail = json("{\"items\":["
+                + "{\"op\":\"==\",\"var\":\"l.reuteriAbnormal\",\"val\":true},"
+                + "{\"op\":\"==\",\"var\":\"ironLow\",\"val\":true}]}");
+        assertFalse(service.evaluate(fail, context));
+    }
+
+    @Test
+    void 未知变量保守为false() {
+        assertFalse(service.evaluate(json("{\"op\":\"==\",\"var\":\"notExistsVar\",\"val\":true}"), context));
+        assertFalse(service.evaluate(json("{\"op\":\">\",\"var\":\"notExistsVar\",\"val\":1}"), context));
+    }
+
+    @Test
+    void 未知操作符保守为false() {
+        assertFalse(service.evaluate(json("{\"op\":\"unknownOp\",\"var\":\"age\",\"val\":14}"), context));
+    }
+
+    @Test
+    void 空字符串不被视为存在() {
+        assertFalse(service.evaluate(json("{\"op\":\"exists\",\"var\":\"emptyValue\"}"), context));
+    }
+}

+ 60 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -2308,3 +2308,63 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 
 *文档更新:2026-10-25(后台素材库:/api/admin/file-record/* 上传入库 + 标签字典 + 统计;file_type 口径统一为 image/video/audio/document/other)*
 
+
+---
+
+## 4.49 家庭归因核心闭环(`/api/attribution/*` + `/api/survey/attribution/*`)
+
+> 阶段二(ATT-S2,B1–B4):「归因分析 → 条件问卷 → 自然语言调整 → 七章干预方案」。
+> **E12 合规后置**:全流程由 `app.attribution.enabled`(默认 `false`)开关控制,仅测试/mock 数据环境开启;
+> 真实用户放行 = 阶段四 D1 完成日。未开启时返回 `Result.error(4001, …)`。
+> **合规即服务**:红线 RL01–RL10 由规则层硬注入,AI 只产叙述/调整,不负责合规判定(§1.3)。
+
+### `POST /api/attribution/analyze` — ③ 归因分析(十节结构报告)
+
+- **Body**:`{ familyId?, memberIds[], gutReportIds[], danResultIds[], confirmSurveyRecordId?, planId?, mock?, mockFlags? }`
+  (`mockFlags` 仅 E12 测试环境,可显式注入中间态键)
+- **Resp** `data`:`AttributionReportVO { id, familyId, status, reportJson, intermediateState, redlineHits, conclusionLocked, createdAt }`
+  - `reportJson`:十节结构(一结论先行/二四人数据速览/三逐人归因/四家庭系统归因/五应对方案/六12周可检验目标/七教练提问序列/八问诊清单/九红线与转介/十一句话收尾)
+  - `intermediateState`:含 `l.reuteriAbnormal/ironLow/hasDan/isFemaleTeen/hasDiabetes` 等键(B1 中间态)
+  - `redlineHits`:RL01–RL08(阶段③④注入);RL09/RL10 属方案阶段⑤
+  - 状态流转:`ANALYZING`(插入)→ `READY`(回填报告后更新)
+- **前置**:常模缺失指标 → 「待补」,不猜值(`warnings` 携带说明)
+
+### `POST /api/survey/attribution/confirm-questions` — ② 条件问卷路由
+
+- **Body**:`{ familyId, memberId, attributionId, answeredSummary? }`
+- **Resp** `data`:`ConfirmQuestionVO { templateId, questions[], hasHighRisk, highRiskQuestionIds }`
+  - `questions[]`:按 对象×年龄×结论 条件下发 `family_attribution` 模板子集(16 条中)
+  - 对象级题目只下发给该对象;家庭级(四人/全家)恒发
+  - Q11–Q13 携带 `riskLevel=high`;命中 → `hasHighRisk=true`(E4 弱提示,不阻断)
+
+### `POST /api/survey/submit` — ② 提交(B2 扩展:riskHits)
+
+- **Body**:`{ userId, memberId, templateId, answersJson, periodStart?, periodEnd?, riskHits? }`
+  - `riskHits`(Q11/Q12/Q13 命中标记)并入 `answersJson._attributionRiskHits` 落库,供 B1 红线检测
+  - E4:高风险命中不阻断提交,仅标记「建议专业评估」
+
+### `POST /api/attribution/adjust` — ④ 自然语言调整
+
+- **Body**:`{ attributionId, message, conversationId? }`
+- **Resp** `data`:`AdjustResult { attributionId, status, answer, fieldDeltas, intermediateState, redlineHits, redlineTerminate, blocking, referral, planInvalidated }`
+  - AI 回答可携带 `[ATTR_DELTA]{"键":新值}` 结构化增量 → 回填 `intermediate_state`(七层 Socratic,PRD §C ④)
+  - RL02 命中 → `redlineTerminate=true`、`blocking=false`(E4 弱提示不阻断)、`referral` 露出转介信息 + 家长确认
+  - 若已关联方案且 `status=GENERATED` → 作废为 `INVALIDATED`(⑤ 失效重生成)
+  - AI 不可用 → 规则兜底话术,流程不阻断
+
+### `POST /api/attribution/confirm-adjust` — ④ 确认调整
+
+- **Body**:`{ attributionId }`
+- **Resp** `data`:`AdjustResult { status=CONFIRMED, conclusionLocked=true }`(结论锁定,B4 前置条件)
+
+### `POST /api/attribution/plan` — ⑤ 七章干预方案
+
+- **Body**:`{ attributionId }`
+- **前置**:归因结论须 `CONFIRMED`(否则 `Result.error(4000, …)`)
+- **Resp** `data`:`FamilyPlanVO { planId, planJson, totalDays, status }`
+  - `planJson`:`header` + 7 章(数据速览/主线产品/分人方案/家庭共享/12周节奏/红线与转介/附录)
+  - 人范围:仅 妈妈/女儿/儿子(E6,爸爸链外);每动作带 `level`(P0/P1/daily/P2) + `reviewHook`(复盘钩子)
+  - 儿童补剂动作仅写「医生定」,无剂量数字(RL03);产品品牌级(Enagic 还原水/姜黄、PHGG、菌株名)且带 RL05 合规口径
+  - 落库 `health_plans`,`plan_type=family_attribution_plan`,`status=GENERATED`(⑤)
+
+*文档更新:2026-10-28(家庭归因核心闭环 B1–B4:/api/attribution/analyze、/api/attribution/adjust、/api/attribution/confirm-adjust、/api/attribution/plan、/api/survey/attribution/confirm-questions;/api/survey/submit 扩展 riskHits)*

+ 2 - 2
docs/superpowers/specs/2026-10-28-family-attribution-architecture-stage1-2.md

@@ -339,7 +339,7 @@ private void migrateHealthPlansPlanType() {
 }
 ```
 
-> **说明**:源文档「16 条」按变量展开为 39 个采集项(`Q1a`…`Q16`);每条仍保留源文档的 `Q` 编号归属,`code` 为条件求值键。条件依赖的归因中间态键(`l.reuteriAbnormal` / `ironLow` / `hasDan` / `isFemaleTeen` / `hasDiabetes`)由 B1 常模比对段计算后注入 context(B2)。
+> **说明**:源文档「16 条」按变量展开为 45 个采集项(`Q1a`…`Q16`,与 `family_attribution_questions.json` 实际 45 条对齐);每条仍保留源文档的 `Q` 编号归属,`code` 为条件求值键。条件依赖的归因中间态键(`l.reuteriAbnormal` / `ironLow` / `hasDan` / `isFemaleTeen` / `hasDiabetes`)由 B1 常模比对段计算后注入 context(B2)。
 
 ### 3.3 A3:`attribution_norm_values` 常模表(G10)
 
@@ -908,7 +908,7 @@ stateDiagram-v2
 - **目标文件**:`cfc-backend/src/main/resources/`(或 `config/`)下新增问题模板种子(`family_attribution_questions.json` 或经 `runMigrations()` 幂等写入 `survey_templates` 一行 `target='family_attribution'`)、`service/ConditionEvaluatorService.java`(接口+impl,含 ops 求值)
 - **实现要点**:
   1. 按 §3.2 定义扩展 JSON 结构(`schemaVersion/target/questions`,字段 `id/code/group/title/type/options/unit/required/condition/riskLevel/refRange`);
-  2. 写入 16 条(39 采集项)完整 JSON 样例(§3.2 代码块,可直接入库 `questionsJson`);
+  2. 写入 16 条(45 采集项)完整 JSON 样例(§3.2 代码块,可直接入库 `questionsJson`);
   3. `ConditionEvaluatorService`:`evaluate(JsonNode,Map)` 支持 `==/!=/>/>=/</<=/contains/in/exists/anyOf`;`buildContext` 组装 role/ageBand/intermediateState;
   4. 幂等种子:`survey_templates` 按 `title`/`target` 判重,避免重复插入。
 - **验收标准**:① 模板 `questionsJson` 解析为 §3.2 结构;② `ConditionEvaluatorService.evaluate` 对 `{var:"l.reuteriAbnormal",op:"==",val:true}` 在 context 命中时返回 true;③ 单测覆盖 6 种操作符 + null 恒真 + 未知 var 保守 false。