Browse Source

feat(attribution): 阶段一数据地基 A1-A4(plan_type迁移/问卷模板种子/常模表/红线模板)

- A1: health_plans.plan_type 迁移(424),schema.sql + HealthPlan 实体同步
- A2: survey_templates 问卷模板种子(426, enabled=0) + ConditionEvaluatorService(10种操作符)
- A3: attribution_norm_values 建表(425) + 菌群常模可配置JSON导入(空壳0行)
- A4: AttributionRedlines 10条红线常量类(P0×4/P1×4/P2×2, E7品牌级/E4弱提示)
- 单测27例: 条件求值12 + 红线8 + 地基种子7
Sisyphus 2 weeks ago
parent
commit
385a9083b0

+ 207 - 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.mapper.*;
 import com.etotem.cfc.service.CfLegacyLedgerMigrationService;
 import com.etotem.cfc.service.CfLegacyLedgerMigrationService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
@@ -9,6 +12,7 @@ import org.slf4j.LoggerFactory;
 import lombok.var;
 import lombok.var;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.CommandLineRunner;
 import org.springframework.boot.CommandLineRunner;
+import org.springframework.core.io.ClassPathResource;
 import org.springframework.core.io.ResourceLoader;
 import org.springframework.core.io.ResourceLoader;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
@@ -3339,6 +3343,15 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         migrateIndicatorValuesTimelineColumns();
         migrateIndicatorValuesTimelineColumns();
         // 迁移423: 体脂秤测量记录表(sourceId 由 memberId 改为 recordId 的前置表)
         // 迁移423: 体脂秤测量记录表(sourceId 由 memberId 改为 recordId 的前置表)
         createBodyFatScaleRecordsTable();
         createBodyFatScaleRecordsTable();
+
+        // 迁移424: health_plans 加 plan_type(区分健康方案/家庭归因干预方案;G5)
+        migrateHealthPlansPlanType();
+        // 迁移425: 家庭归因常模值表(G10,菌群 710 常模落点)
+        createAttributionNormValuesTable();
+        // 迁移425-导入: 菌群常模可配置 JSON 导入(空壳文件导入 0 行,不报错)
+        importAttributionNormGutFlora();
+        // 迁移426: 家庭归因确认问卷模板种子(第八章 16 条,幂等)
+        seedFamilyAttributionSurveyTemplate();
     }
     }
 
 
     /**
     /**
@@ -14256,4 +14269,198 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移420: 创建 ppoint_consumption_log 表失败(可能已存在): {}", e.getMessage());
             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());
+        }
+    }
+
+    /**
+     * 迁移426: 家庭归因确认问卷模板种子(A2:第八章 16 条 / 39 采集项)。
+     * 幂等:按 title 判重,已存在则跳过;questionsJson 存 §3.2 扩展结构(schemaVersion/target/questions)。
+     */
+    void seedFamilyAttributionSurveyTemplate() {
+        try {
+            ClassPathResource resource = new ClassPathResource(SURVEY_FAMILY_ATTRIBUTION_CLASSPATH);
+            if (!resource.exists()) {
+                log.warn("迁移426: 未找到问卷模板种子 {},跳过", 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) {
+                log.info("迁移426: 家庭归因问卷模板已存在(title={}),跳过种子", 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("迁移426: 问卷模板种子格式非法,跳过");
+                return;
+            }
+            jdbcTemplate.update(
+                    "INSERT INTO survey_templates (title, dimension, questions_json, cycle_type, ai_generated, enabled, created_at, updated_at) "
+                            + "VALUES (?, ?, ?, ?, 0, 0, NOW(), NOW())",
+                    SURVEY_FAMILY_ATTRIBUTION_TITLE,
+                    "family_attribution",
+                    content,
+                    "once");
+            log.info("迁移426: 已写入家庭归因问卷模板种子 title={} questions={}(enabled=0,阶段二启用)",
+                    SURVEY_FAMILY_ATTRIBUTION_TITLE, root.get("questions").size());
+        } catch (Exception e) {
+            log.warn("迁移426: 家庭归因问卷模板种子失败(忽略): {}", 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;
+        }
+    }
 }
 }

+ 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);
+    }
+}

+ 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;
+}

+ 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("plan_content") private String planContent;
     @TableField("member_name") private String memberName;
     @TableField("member_name") private String memberName;
     @TableField("plan_json") private String planJson;
     @TableField("plan_json") private String planJson;
+    /** 方案类型: health_plan(健康方案)/family_attribution_plan(家庭归因干预方案) */
+    @TableField("plan_type") private String planType;
     @TableField("status") private String status;
     @TableField("status") private String status;
     @TableField("reviewed_by") private Long reviewedBy;
     @TableField("reviewed_by") private Long reviewedBy;
     @TableField("reviewed_at") private Date reviewedAt;
     @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> {
+}

+ 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);
+}

+ 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;
+    }
+}

+ 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": []
+}

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

@@ -5089,6 +5089,7 @@ CREATE TABLE IF NOT EXISTS health_plans (
     member_name   VARCHAR(50)  COMMENT '成员名称摘要',
     member_name   VARCHAR(50)  COMMENT '成员名称摘要',
     created_at    DATETIME DEFAULT CURRENT_TIMESTAMP,
     created_at    DATETIME DEFAULT CURRENT_TIMESTAMP,
     plan_json     TEXT         COMMENT '结构化方案内容(JSON)',
     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)',
     total_days    INT          DEFAULT NULL COMMENT '方案周期天数,驱动服务任务周期边界与截止日期(默认14)',
     status        VARCHAR(20)  DEFAULT 'draft' COMMENT '方案状态: draft(草稿)/confirmed(已确认)',
     status        VARCHAR(20)  DEFAULT 'draft' COMMENT '方案状态: draft(草稿)/confirmed(已确认)',
     reviewed_by   BIGINT DEFAULT NULL COMMENT '审核人ID(规划师/营养师)',
     reviewed_by   BIGINT DEFAULT NULL COMMENT '审核人ID(规划师/营养师)',
@@ -5100,6 +5101,31 @@ CREATE TABLE IF NOT EXISTS health_plans (
     INDEX idx_created (created_at)
     INDEX idx_created (created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康方案汇总记录';
 ) 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='家庭归因常模值';
+
 -- 定期调研模板
 -- 定期调研模板
 CREATE TABLE IF NOT EXISTS survey_templates (
 CREATE TABLE IF NOT EXISTS survey_templates (
     id            BIGINT AUTO_INCREMENT PRIMARY KEY,
     id            BIGINT AUTO_INCREMENT PRIMARY KEY,

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

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

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

@@ -0,0 +1,171 @@
+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();
+
+        assertNoUpdateCalled();
+    }
+
+    @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");
+        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(), "应覆盖 四人/孟凡锐/马智强/马昕源/马安扬/全家 6 个分组:" + 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);
+        }
+    }
+}

+ 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));
+    }
+}