ソースを参照

chore(ATT-S2): 非阻塞改进 O2/E12开关补盖 survey端点 + O3 文档采集项对齐45 + O5 种子group去实名 + O7 gate 4001测试(O1留痕延后阶段三)

O2: SurveyController.confirm-questions 接入 app.attribution.enabled 开关
- 新增 @Value 字段 + private gate() 方法,与 AttributionController.gate() 一致
- confirmQuestions 入口短路返回 4001「家庭归因功能未开放」
- 最小改动,不改 gate 形态(手写判断对齐)

O3: 架构文档§3.2/§7「39 采集项」→ 45 采集项
- §3.2 说明块:39 → 45,补充「与 family_attribution_questions.json 实际 45 条对齐」
- §7 ATT-S1-A2 实现要点:39 → 45

O5: family_attribution_questions.json group 字段去实名
- 孟凡锐→家长A,马智强→家长B,马昕源→学员A,马安扬→学员B(共 40 处)
- audience 同步匿名化
- 新增 groupRoles 映射(家长A→mom 等),SurveyController 路由无需改动
- AttributionStage1FoundationTest 失败消息同步更新(去实名)

O7: AttributionController gate() 4001 分支单测
- 新增 AttributionControllerTest(1 用例)
- 断言关闭时 /analyze 返回 code=4001「家庭归因功能未开放」

O1 留痕:AttributionAnalysisServiceImpl.recompute() 上方注释标记 C2 延后阶段三

验证:mvn compile 通过;7 个归因测试类 28 tests 全绿(0 fail/errors)
O6 的 10 个存量失败(ArticleControllerTest/FamilyControllerTest)本轮跳过另开单
Sisyphus 2 週間 前
親
コミット
cb785e9303

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/SurveyController.java

@@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
@@ -44,6 +45,10 @@ public class SurveyController {
     /** 家庭级对象分组(恒发) */
     /** 家庭级对象分组(恒发) */
     private static final List<String> FAMILY_LEVEL_GROUPS = java.util.Arrays.asList("四人", "全家");
     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
     @Resource
     private SurveyService surveyService;
     private SurveyService surveyService;
 
 
@@ -87,6 +92,11 @@ public class SurveyController {
             @RequestBody Map<String, Object> params,
             @RequestBody Map<String, Object> params,
             @RequestAttribute(value = "userId", required = false) Long userId,
             @RequestAttribute(value = "userId", required = false) Long userId,
             @RequestAttribute(value = "familyId", required = false) Long familyId) {
             @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 familyIdParam = ParamUtils.getLong(params.get("familyId"), familyId);
         Long memberId = ParamUtils.getLong(params.get("memberId"));
         Long memberId = ParamUtils.getLong(params.get("memberId"));
         Long attributionId = ParamUtils.getLong(params.get("attributionId"));
         Long attributionId = ParamUtils.getLong(params.get("attributionId"));
@@ -299,4 +309,16 @@ public class SurveyController {
         }
         }
         return v.asText();
         return v.asText();
     }
     }
+
+    /**
+     * 功能开关闸门(E12 合规后置):未开启时返回 4001(权限/放行类),与 AttributionController.gate() 一致。
+     *
+     * @return 未开启时返回错误结果;开启返回 null
+     */
+    private <T> Result<T> gate() {
+        if (!attributionEnabled) {
+            return Result.error(4001, "家庭归因功能未开放(E12 合规后置,仅测试/mock 数据环境可用)");
+        }
+        return null;
+    }
 }
 }

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

@@ -177,6 +177,11 @@ public class AttributionAnalysisServiceImpl implements AttributionAnalysisServic
         return toVO(report);
         return toVO(report);
     }
     }
 
 
+    /**
+     * ATT-S1/O1 留痕:此方法为阶段一遗留死代码(B3 调整→方案同步的中间态回填逻辑)。
+     * 主理人已裁定 C2(B3 调整→方案同步)延后至阶段三,本轮保留不删不搬。
+     * 上线前无需调用;如 B3 在阶段三恢复,直接启用即可。
+     */
     @Override
     @Override
     public AttributionReportVO recompute(Long attributionId, Map<String, Object> fieldDeltas) {
     public AttributionReportVO recompute(Long attributionId, Map<String, Object> fieldDeltas) {
         AttributionReport report = attributionReportMapper.selectById(attributionId);
         AttributionReport report = attributionReportMapper.selectById(attributionId);

+ 42 - 41
cfc-backend/src/main/resources/survey/family_attribution_questions.json

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

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

@@ -127,7 +127,7 @@ class AttributionStage1FoundationTest {
         for (JsonNode q : questions) {
         for (JsonNode q : questions) {
             groups.add(q.get("group").asText());
             groups.add(q.get("group").asText());
         }
         }
-        assertEquals(6, groups.size(), "应覆盖 四人/孟凡锐/马智强/马昕源/马安扬/全家 6 个分组:" + groups);
+        assertEquals(6, groups.size(), "应覆盖 四人/家长A/家长B/学员A/学员B/全家 6 个分组(O5 去实名):" + groups);
         assertFalse(root.get("audience").isEmpty(), "应含 audience 列表");
         assertFalse(root.get("audience").isEmpty(), "应含 audience 列表");
     }
     }
 
 

+ 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「家庭归因功能未开放」");
+    }
+}

+ 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)
 ### 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 求值)
 - **目标文件**:`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`);
   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;
   3. `ConditionEvaluatorService`:`evaluate(JsonNode,Map)` 支持 `==/!=/>/>=/</<=/contains/in/exists/anyOf`;`buildContext` 组装 role/ageBand/intermediateState;
   4. 幂等种子:`survey_templates` 按 `title`/`target` 判重,避免重复插入。
   4. 幂等种子:`survey_templates` 按 `title`/`target` 判重,避免重复插入。
 - **验收标准**:① 模板 `questionsJson` 解析为 §3.2 结构;② `ConditionEvaluatorService.evaluate` 对 `{var:"l.reuteriAbnormal",op:"==",val:true}` 在 context 命中时返回 true;③ 单测覆盖 6 种操作符 + null 恒真 + 未知 var 保守 false。
 - **验收标准**:① 模板 `questionsJson` 解析为 §3.2 结构;② `ConditionEvaluatorService.evaluate` 对 `{var:"l.reuteriAbnormal",op:"==",val:true}` 在 context 命中时返回 true;③ 单测覆盖 6 种操作符 + null 恒真 + 未知 var 保守 false。