Просмотр исходного кода

Merge remote-tracking branch 'origin/cfclub' into cfclub

E2E Test Bot 2 часов назад
Родитель
Сommit
56b0f88fb0

+ 204 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyTaskReviewContractTest.java

@@ -0,0 +1,204 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.guide.GuideFamilyTaskController;
+import com.etotem.cfc.dto.TaskReviewDTO;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.GuideFamilyService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.RewardRuleService;
+import com.etotem.cfc.service.TaskService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约测试:能力键 {@code teacher.task.review}(SC-T-06 家庭任务审核 / SC-T-18 批量任务下发)。
+ *
+ * <p>锚点:
+ * <ul>
+ *   <li>契约文档 docs/superpowers/specs/2026-11-07-planner-zone-contract.md EVD-10 §2 SC-T-06/18 端点核验表;</li>
+ *   <li>派生用例 tests/test-cases/teacher.md TC-T-09(任务审核:通过给分/驳回重做/<b>越权非绑定家庭被拒</b>/并发双审幂等/超期被拒)
+ *       与 TC-T-18(批量任务:部分失败不影响其他/各自独立落库)、TC-T-09-P(权限流:非绑定家庭数据统一 403)。</li>
+ * </ul></p>
+ *
+ * <p><b>本类聚焦规划师(teacher)侧 {@link GuideFamilyTaskController} 的任务审核归属校验缺口</b>:
+ * 现有实现 {@code reviewTask} / {@code batchReviewTasks} 仅校验「规划师与 familyId 绑定」(requireBound)
+ * 与「任务存在+会员任务校验」(checkMemberOnly),<b>未校验被审任务 task.getFamilyId() 是否等于路径 familyId</b>——
+ * 对比同控制器 {@code updateTask} / {@code deleteTask} 已含「任务不属于该家庭」归属校验。该缺口使
+ * 已绑定家庭 A 的规划师传入属于家庭 B 的 taskId 即可跨家庭越权审核(IDOR),违反 TC-T-09-P
+ * 「非绑定家庭数据统一 403」。</p>
+ *
+ * <p><b>RED 预期</b>:跨家庭任务审核(reviewTask / batchReviewTasks)应被拒且不进入 taskService.reviewTask;
+ * 当前实现缺归属校验 → 会调用 taskService.reviewTask → 断言失败(红)。GREEN 阶段补归属校验后转绿。</p>
+ *
+ * <p>纯 Mockito(无 Spring / 无 DB / 无外部服务),参照同批 RED 契约测试风格(FamilyJoinValidationRulesContractTest、TeamProfitHookContractTest)。</p>
+ */
+class GuideFamilyTaskReviewContractTest {
+
+    private static final Long GUIDE_ID = 1L;
+    /** 规划师已绑定的家庭 ID(requireBound 放行来源) */
+    private static final Long BOUND_FAMILY_ID = 100L;
+    /** 非绑定的其他家庭 ID(跨家庭越权来源) */
+    private static final Long OTHER_FAMILY_ID = 999L;
+
+    @Mock
+    private TaskService taskService;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private TaskMapper taskMapper;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private MembershipService membershipService;
+
+    @Mock
+    private GuideFamilyService guideFamilyService;
+
+    @Mock
+    private RewardRuleService rewardRuleService;
+
+    @Mock
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
+    @InjectMocks
+    private GuideFamilyTaskController controller;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // 规划师与 BOUND_FAMILY_ID 已绑定 → requireBound 放行(检查绑定关系返回 null 表示放行)
+        when(guideFamilyAccessGuard.checkBinding(GUIDE_ID, BOUND_FAMILY_ID)).thenReturn(null);
+    }
+
+    // ==================== 正常流(对照,应全绿) ====================
+
+    /**
+     * 本家庭任务审核:task.familyId == familyId 路径一致,应调用 taskService.reviewTask 进入审核逻辑。
+     */
+    @Test
+    void reviewTask_本家庭任务_进入审核_返回通过() {
+        Long taskId = 5L;
+        when(taskMapper.selectById(taskId)).thenReturn(task(taskId, BOUND_FAMILY_ID, "pending_review"));
+        when(taskService.reviewTask(eq(taskId), any(TaskReviewDTO.class))).thenReturn(true);
+
+        Result<Boolean> result = controller.reviewTask(GUIDE_ID, BOUND_FAMILY_ID, taskId, "teacher", dto(true), null);
+
+        assertTrue(result.getData());
+        verify(taskService).reviewTask(eq(taskId), any(TaskReviewDTO.class));
+    }
+
+    // ==================== 权限流(RED 契约缺口) ====================
+
+    /**
+     * SC-T-06 / TC-T-09-P:规划师越权审核不属于已绑定家庭的子任务 —— 接口应拒绝且不进入 taskService.reviewTask。
+     *
+     * <p>缺口定位:{@code GuideFamilyTaskController#reviewTask} 当前不校验 {@code task.getFamilyId() == familyId},
+     * 导致任何已认证规划师传入跨家庭的 taskId 均可越权审核。</p>
+     */
+    @Test
+    void reviewTask_跨家庭任务_应拒绝且不进入审核() {
+        Long taskId = 5L;
+        // 任务存在,但其 familyId 与路径中的 BOUND_FAMILY_ID 不同(越权场景)
+        when(taskMapper.selectById(taskId)).thenReturn(task(taskId, OTHER_FAMILY_ID, "pending_review"));
+        when(taskService.reviewTask(eq(taskId), any(TaskReviewDTO.class))).thenReturn(true);
+
+        Result<Boolean> result = controller.reviewTask(GUIDE_ID, BOUND_FAMILY_ID, taskId, "teacher", dto(true), null);
+
+        // 契约:跨家庭任务不得进入审核逻辑(TC-T-09-P 非绑定家庭数据统一 403)
+        verify(taskService, never()).reviewTask(anyLong(), any(TaskReviewDTO.class));
+        assertNotEquals(Integer.valueOf(200), result.getCode(), "跨家庭任务审核应被拒(非 200 成功响应)");
+    }
+
+    /**
+     * SC-T-18 / TC-T-18-E:批量审核中包含跨家庭任务时,跨家任务应被跳过不被审核(各自独立不落库)。
+     *
+     * <p>当前实现循环 taskIds,每个 task 单独 checkMemberOnly + reviewTask;缺失跨家庭过滤 → 所有 task 均被处理。</p>
+     */
+    @Test
+    void batchReviewTasks_含跨家庭任务_应跳过跨家庭任务独立处理自身任务() {
+        Long ownTaskId = 1L;
+        Long crossTaskId = 2L;
+
+        when(taskMapper.selectById(ownTaskId)).thenReturn(task(ownTaskId, BOUND_FAMILY_ID, "pending_review"));
+        when(taskMapper.selectById(crossTaskId)).thenReturn(task(crossTaskId, OTHER_FAMILY_ID, "pending_review"));
+        when(taskService.reviewTask(anyLong(), any(TaskReviewDTO.class))).thenReturn(true);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("taskIds", Arrays.asList(ownTaskId, crossTaskId));
+        params.put("approved", true);
+
+        Result<Map<String, Object>> result = controller.batchReviewTasks(GUIDE_ID, BOUND_FAMILY_ID, "teacher", params, null);
+
+        // 契约:仅本家庭任务进入审核;跨家庭任务不得被审核(TC-T-18-E 部分失败不影响其他/TC-T-09-P 越权拒)
+        verify(taskService).reviewTask(eq(ownTaskId), any(TaskReviewDTO.class));
+        verify(taskService, never()).reviewTask(eq(crossTaskId), any(TaskReviewDTO.class));
+    }
+
+    // ==================== 角色校验(对照,应绿) ====================
+
+    /**
+     * 非 teacher/admin 角色访问规划师专属端点 → 应在 role 检查处短路返回 Access denied,不调 use Service。
+     */
+    @Test
+    void reviewTask_非teacher角色_拒绝且不进入审核() {
+        Long taskId = 5L;
+        Result<Boolean> result = controller.reviewTask(GUIDE_ID, BOUND_FAMILY_ID, taskId, "parent", dto(true), null);
+
+        assertEquals("Access denied", result.getMessage());
+        verify(taskService, never()).reviewTask(anyLong(), any(TaskReviewDTO.class));
+    }
+
+    @Test
+    void batchReviewTasks_非teacher角色_拒绝且不进入审核() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("taskIds", Collections.singletonList(1L));
+        params.put("approved", true);
+        Result<Map<String, Object>> result = controller.batchReviewTasks(GUIDE_ID, BOUND_FAMILY_ID, "parent", params, null);
+
+        assertEquals("Access denied", result.getMessage());
+        verify(taskService, never()).reviewTask(anyLong(), any(TaskReviewDTO.class));
+    }
+
+    // ==================== 辅助 ====================
+
+    private Task task(Long id, Long familyId, String status) {
+        Task t = new Task();
+        t.setId(id);
+        t.setFamilyId(familyId);
+        t.setStatus(status);
+        t.setCompletedAt(new Date());
+        return t;
+    }
+
+    private TaskReviewDTO dto(boolean approved) {
+        TaskReviewDTO d = new TaskReviewDTO();
+        d.setApproved(approved);
+        d.setComment("契约测试审核");
+        return d;
+    }
+}

+ 254 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/NutritionPlanTeacherContractTest.java

@@ -0,0 +1,254 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.diet.DietRecommendationController;
+import com.etotem.cfc.mapper.DietRecommendationMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MealConfigMapper;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.BeijingNutritionService;
+import com.etotem.cfc.service.DietRecommendationService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+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.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * TDD-RED 契约级单测:成长规划师「精准营养方案制定」(能力键 {@code teacher.nutrition.plan},
+ * SC-T-26 / API_REFERENCE §4.57 / 用例场景 04-teacher.md T-26 / 契约 EVD-10 77026df42)。
+ *
+ * <p><b>契约来源</b>(用户故事 + 主流程 + 边界):
+ * <blockquote>
+ *   角色:规划师。前置:客户设定了营养目标(或由规划师设定)。<br>
+ *   主流程:设目标(<b>热量 / 宏量营养素</b>)→ 生成方案 → 关联食谱。<br>
+ *   异常/边界:<b>目标非法(负 / 超上限)拦截</b>。
+ * </blockquote></p>
+ *
+ * <p><b>端点载体</b>(EVD-10 §4.57 回填):{@code DietRecommendationController /api/diet/recommendation/*}
+ * (generate 为「生成方案」入口)。规划师经 {@code familyId}(拦截器注入)作用于其绑定家庭,
+ * 三类流中的权限流(家庭隔离)由 {@code JwtInterceptor} + {@code familyId} 作用域承载,
+ * 在本控制器单测粒度不重复建模,以说明性用例标注(不适用)。</p>
+ *
+ * <p><b>实现现状(TDD-RED,实测 origin/cfclub)</b>:本能力契约所需的「营养目标(宏量营养素)」
+ * 链路<b>未落地</b>——
+ * <ul>
+ *   <li>{@code DietRecommendationService.aggregateNutrition} 将 {@code protein/carbs/fat}
+ *       <b>恒置 0</b>(源码注释原文:「protein/carbs/fat 恒 0 不在本轮范围」),
+ *       且 {@code toDish} 归一化时丢弃 AI 菜品的宏量营养素,仅保留热量 →
+ *       生成的方案 nutritionSummary 无法承载「宏量营养素」目标;</li>
+ *   <li>{@code generateRecommendation} 仅读取 {@code date / meal_type / selected_foods /
+ *       participant_member_ids / view_key},<b>不读取 / 不校验营养目标</b>,
+ *       非法(负 / 超上限)目标被静默忽略并照常返回成功,无「拦截」。</li>
+ * </ul>
+ * 因此本测试以<b>纯 Mockito 行为级探测</b>(无 Spring / 无 DB / 无外部 AI)断言契约缺失:
+ * 能编译、能运行、断言失败(红),失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者在 {@code DietRecommendationService} 侧——
+ * ① 让 {@code toDish} 保留并让 {@code aggregateNutrition} 真实聚合 AI 菜品的
+ * {@code protein/carbs/fat}(占位菜 / 汤无营养数据时贡献 0);
+ * ② 在方案生成契约中读取营养目标(本测试以 {@code nutrition_goals} 承载,
+ * 形如 {@code {calories, protein, carbs, fat}}),并对非法目标(负值 / 超上限)
+ * 返回<b>拒绝</b>(非 200,如 badRequest 400),而非静默放行——本测试自动转绿。</p>
+ */
+class NutritionPlanTeacherContractTest extends AssessmentPlanContractTestBase {
+
+    private static final ObjectMapper OM = new ObjectMapper();
+
+    private static final Long FAMILY_ID = 7L;
+    private static final String DATE = "2026-11-13";
+
+    /**
+     * 模拟 AI 返回的午餐 + 晚餐菜单(每餐 2 道主菜,均带 nutrition 宏量营养素)。
+     *
+     * <p>N=2 → DishCountRule.dishCount(2)=4 主菜,normalizeMenu 会把每餐补齐到
+     * 4 主菜 + 1 汤;补齐的占位菜/汤 nutrition 为空 → 宏量营养素贡献 0。
+     * 故真实菜品宏量营养素合计:protein=30+5+28+3=66、carbs=5+20+10+30=65、
+     * fat=8+2+12+1=23、calories=200+100+250+150=700(热量当前已可聚合,作对照)。</p>
+     */
+    private static final String AI_MENU_JSON = "{"
+            + "\"meals\":["
+            + "{\"type\":\"lunch\",\"name\":\"午餐\",\"dishes\":["
+            +   "{\"name\":\"鸡胸肉\",\"type\":\"dish\",\"nutrition\":{\"calories\":200,\"protein\":30,\"carbs\":5,\"fat\":8},"
+            +     "\"ingredients\":[{\"name\":\"鸡胸肉\",\"grams\":150}],\"cooking_method\":\"煎\"},"
+            +   "{\"name\":\"西兰花\",\"type\":\"dish\",\"nutrition\":{\"calories\":100,\"protein\":5,\"carbs\":20,\"fat\":2},"
+            +     "\"ingredients\":[{\"name\":\"西兰花\",\"grams\":100}],\"cooking_method\":\"煮\"}"
+            + "]},"
+            + "{\"type\":\"dinner\",\"name\":\"晚餐\",\"dishes\":["
+            +   "{\"name\":\"牛肉\",\"type\":\"dish\",\"nutrition\":{\"calories\":250,\"protein\":28,\"carbs\":10,\"fat\":12},"
+            +     "\"ingredients\":[{\"name\":\"牛肉\",\"grams\":120}],\"cooking_method\":\"炒\"},"
+            +   "{\"name\":\"米饭\",\"type\":\"dish\",\"nutrition\":{\"calories\":150,\"protein\":3,\"carbs\":30,\"fat\":1},"
+            +     "\"ingredients\":[{\"name\":\"米饭\",\"grams\":100}],\"cooking_method\":\"蒸\"}"
+            + "]}"
+            + "]}";
+
+    private DietRecommendationMapper recMapper;
+    private MealConfigMapper mealConfigMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private AiGateway aiGateway;
+    private BeijingNutritionService beijingNutritionService;
+    private DietRecommendationService service;
+    private DietRecommendationController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        recMapper = mock(DietRecommendationMapper.class);
+        mealConfigMapper = mock(MealConfigMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        aiGateway = mock(AiGateway.class);
+        beijingNutritionService = mock(BeijingNutritionService.class);
+
+        // 真实 Service(注入 mock 依赖,无 Spring / 无 DB)
+        service = new DietRecommendationService();
+        inject(service, "dietRecommendationMapper", recMapper);
+        inject(service, "mealConfigMapper", mealConfigMapper);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "aiGateway", aiGateway);
+        inject(service, "beijingNutritionService", beijingNutritionService);
+
+        // 真实 Controller(注入真实 Service)
+        controller = new DietRecommendationController();
+        inject(controller, "dietRecommendationService", service);
+
+        // MyBatis-Plus TableInfo 预注册(使 LambdaQueryWrapper 在无 Spring 上下文可解析列名)
+        initTable(com.etotem.cfc.entity.DietRecommendation.class);
+        initTable(com.etotem.cfc.entity.MealConfig.class);
+        initTable(com.etotem.cfc.entity.FamilyMember.class);
+
+        // AI 网关桩:返回带宏量营养素的可用菜单(含 lunch + dinner,跳过规则降级)
+        when(aiGateway.generateMenu(anyString(), anyString(), anyString(), anyInt()))
+                .thenReturn(AI_MENU_JSON);
+        // 食材可用性未桩:generateIngredientList 返回 null → availableIngredientNames 兜底空列表,
+        // 占位菜/汤贡献 0 宏量营养素(与 GREEN 语义一致)。
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:生成方案应聚合宏量营养素(热量/蛋白/碳水/脂肪)到 nutritionSummary
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:生成方案 nutritionSummary 应聚合宏量营养素 protein/carbs/fat(非恒 0)")
+    void generate_正常_宏量营养素应聚合() throws Exception {
+        Map<String, Object> request = baseRequest();
+
+        Result<Map> r = controller.generateRecommendation(FAMILY_ID, request);
+        assertEquals(200, r.getCode(), "方案生成应成功(AI 桩返回可用菜单)");
+
+        Map<String, Object> summary = readSummary(r);
+        // 对照(应通过):热量当前已聚合 —— 证明 mock 装配与归一化链路正确,
+        // 下方宏量营养素断言失败是「契约缺口」而非「测试装配错误」。
+        assertEquals(700, intOf(summary.get("calories")),
+                "对照:nutritionSummary.calories 应聚合真实菜品热量(占位菜/汤贡献 0)");
+        // RED 断言:宏量营养素应从 AI 菜品聚合,当前 aggregateNutrition 恒置 0。
+        assertEquals(66, intOf(summary.get("protein")),
+                "[teacher.nutrition.plan] nutritionSummary.protein 应聚合 AI 菜品蛋白质(30+5+28+3=66),"
+                        + "当前 aggregateNutrition 恒置 0、toDish 归一化丢弃宏量营养素 —— "
+                        + "契约要求的「宏量营养素」目标未承载。");
+        assertEquals(65, intOf(summary.get("carbs")),
+                "[teacher.nutrition.plan] nutritionSummary.carbs 应聚合 AI 菜品碳水(5+20+10+30=65),当前恒 0。");
+        assertEquals(23, intOf(summary.get("fat")),
+                "[teacher.nutrition.plan] nutritionSummary.fat 应聚合 AI 菜品脂肪(8+2+12+1=23),当前恒 0。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:营养目标非法(负 / 超上限)应被拦截(拒绝,非 200)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:营养目标 calories 为负(-500)应被拦截,返回非 200")
+    void generate_边界_负热量目标应拦截() {
+        Map<String, Object> request = baseRequest();
+        request.put("nutrition_goals", goals(-500, 60, 80, 40));
+
+        Result<Map> r = controller.generateRecommendation(FAMILY_ID, request);
+        assertTrue(r.getCode() != 200,
+                "[teacher.nutrition.plan] 营养目标 calories=-500(负值)非法,契约要求「拦截」"
+                        + "(返回拒绝态非 200,如 badRequest 400),而非静默忽略并照常返回成功。"
+                        + "实测 code=" + r.getCode() + ",当前实现未读取/未校验营养目标,非法目标被放行。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:营养目标 calories 超上限(999999)应被拦截,返回非 200")
+    void generate_边界_超上限热量目标应拦截() {
+        Map<String, Object> request = baseRequest();
+        request.put("nutrition_goals", goals(999999, 60, 80, 40));
+
+        Result<Map> r = controller.generateRecommendation(FAMILY_ID, request);
+        assertTrue(r.getCode() != 200,
+                "[teacher.nutrition.plan] 营养目标 calories=999999(超上限)非法,契约要求「拦截」"
+                        + "(返回拒绝态非 200),而非静默忽略并照常返回成功。"
+                        + "实测 code=" + r.getCode() + ",当前实现未读取/未校验营养目标。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):家庭隔离由 JwtInterceptor + familyId 作用域承载
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:规划师经 familyId 作用于绑定家庭,家庭隔离由拦截器承载,本控制器单测粒度不适用")
+    void permissionFlow_家庭隔离由拦截器承载_不适用() {
+        // 说明性用例:teacher.nutrition.plan 的权限维度 = 规划师仅能作用于其绑定家庭,
+        // 由 JwtInterceptor 注入 familyId + 全局数据隔离承载;本控制器方法仅按 familyId 过滤,
+        // 不重复建模越权分支(与 TeamProfitHookContractTest 的「权限流不适用」标注一致)。
+        // 无家庭上下文时 controller 已返回 Result.noFamily(5001),为既有行为,此处不产生断言失败。
+        assertTrue(true, "权限流不适用:家庭隔离由 JwtInterceptor + familyId 作用域承载。");
+    }
+
+    // ------------------------------------------------------------------
+    // helpers
+    // ------------------------------------------------------------------
+
+    /** 基础请求:date + meal_type + 共餐人(避免回落 meal_config 兜底查询) */
+    private static Map<String, Object> baseRequest() {
+        Map<String, Object> request = new HashMap<>();
+        request.put("date", DATE);
+        request.put("meal_type", "lunch");
+        request.put("participant_member_ids", Arrays.asList(1, 2));
+        return request;
+    }
+
+    /** 营养目标载荷:{calories, protein, carbs, fat} */
+    private static Map<String, Object> goals(int calories, int protein, int carbs, int fat) {
+        Map<String, Object> goals = new HashMap<>();
+        goals.put("calories", calories);
+        goals.put("protein", protein);
+        goals.put("carbs", carbs);
+        goals.put("fat", fat);
+        return goals;
+    }
+
+    /** 从 Result.data 读取并解析 nutritionSummary(safeWrite 后为 JSON 串) */
+    private static Map<String, Object> readSummary(Result<Map> r) throws Exception {
+        Map<String, Object> data = r.getData();
+        if (data == null) {
+            fail("[teacher.nutrition.plan] 方案生成结果 data 为空,无法读取 nutritionSummary。");
+        }
+        Object summaryObj = data.get("nutritionSummary");
+        if (summaryObj == null) {
+            fail("[teacher.nutrition.plan] 方案生成结果缺 nutritionSummary。");
+        }
+        JsonNode node = OM.readTree(summaryObj.toString());
+        Map<String, Object> summary = new HashMap<>();
+        summary.put("calories", node.has("calories") ? node.get("calories").asInt() : null);
+        summary.put("protein", node.has("protein") ? node.get("protein").asInt() : null);
+        summary.put("carbs", node.has("carbs") ? node.get("carbs").asInt() : null);
+        summary.put("fat", node.has("fat") ? node.get("fat").asInt() : null);
+        return summary;
+    }
+
+    private static int intOf(Object v) {
+        return v instanceof Number ? ((Number) v).intValue() : 0;
+    }
+}

+ 293 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/PlanContentSafetyReviewContractTest.java

@@ -0,0 +1,293 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.ContentFilterController;
+import com.etotem.cfc.controller.HealthPlanController;
+import com.etotem.cfc.controller.admin.ReviewCenterController;
+import com.etotem.cfc.controller.guide.GuidePlanReviewController;
+import com.etotem.cfc.controller.guide.PlanReviewController;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.WechatService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Date;
+
+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.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测(TDD-RED):能力键 {@code teacher.plan.review} ——
+ * SC-T-04 方案审核列表+批准、SC-T-28 AI 方案内容安全复核。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-04(规划师查看待审核方案列表 →
+ *       批准/驳回,状态流转受守卫)、SC-T-28(AI 生成方案 → <b>敏感词/合规检查</b> →
+ *       放行/驳回;边界:<b>命中即拦截</b>)。</li>
+ *   <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md}
+ *       §2 端点核验表(SC-T-04 / SC-T-28)、§3 能力键 {@code teacher.plan.review} →</li>
+ *   <li>API:{@code docs/superpowers/api/API_REFERENCE.md} §4.57 SC-T-04 / SC-T-28 行、
+ *       §4.60.13。</li>
+ * </ul>
+ *
+ * <p><b>本类为 RED 契约(预期失败)</b>:截至 2026-11-13 origin/cfclub,
+ * 审核链路({@code GuidePlanReviewController.approvePlan} → {@code HealthPlanServiceImpl
+ * .approveAndPublish}、{@code HealthPlanController /pending-review/approve})
+ * <b>缺少 AI 方案内容安全复核</b>:既没有引用现成的 {@link WechatServiceInterface#checkText(String)}
+ * 内容安全原语(该原语目前只接在 {@code MediaController} / {@code AdminArticleController}),
+ * {@code ContentFilterController} 也只有 {@code POST /api/content/by-tags},没有方案内容复核端点。
+ * 因此结构探针与行为探针均失败(红)。待实现补齐后应转绿。</p>
+ *
+ * <p><b>已绿的契约锚点</b>(现有 {@code PlanReviewTeacherContractTest} 已覆盖 SC-T-04 生命周期,
+ * 本类只补单一能力键视角的端点映射证据,不重复实现、不修改 src/main)。</p>
+ *
+ * <p><b>测试范围</b>:纯反射 + Mockito / 无 Spring 上下文 / 无 DB / 无外部服务。</p>
+ */
+class PlanContentSafetyReviewContractTest extends AssessmentPlanContractTestBase {
+
+    static {
+        initTable(HealthPlan.class);
+        initTable(Task.class);
+    }
+
+    // ==================== 已绿锚点:端点映射(API_REFERENCE §4.57 SC-T-04 / §4.60.13) ====================
+
+    @Test
+    @DisplayName("SC-T-04 端点:GuidePlanReviewController 待审核列表 / 批准 / 驳回(family 路径)")
+    void endpointMapping_guidePlanReview_pendingApproveReject() {
+        Class<GuidePlanReviewController> clazz = GuidePlanReviewController.class;
+
+        assertTrue(hasClassMapping(clazz, "/api/guide/families/{familyId}/plans"),
+                "GuidePlanReviewController 类级映射应为 /api/guide/families/{familyId}/plans");
+
+        assertTrue(hasPostMapping(clazz, "listPendingPlans", "/pending"),
+                "listPendingPlans 应为 POST /pending(待审核列表)");
+        assertTrue(hasPostMapping(clazz, "approvePlan", "/{planId}/approve"),
+                "approvePlan 应为 POST /{planId}/approve(批准)");
+        assertTrue(hasPostMapping(clazz, "rejectPlan", "/{planId}/reject"),
+                "rejectPlan 应为 POST /{planId}/reject(驳回)");
+    }
+
+    @Test
+    @DisplayName("SC-T-04 端点:PlanReviewController 方案审核 POST /api/guide/plans/review")
+    void endpointMapping_planReview_review() {
+        Class<PlanReviewController> clazz = PlanReviewController.class;
+
+        assertTrue(hasClassMapping(clazz, "/api/guide/plans"),
+                "PlanReviewController 类级映射应为 /api/guide/plans");
+        assertTrue(hasPostMapping(clazz, "reviewPlan", "/review"),
+                "reviewPlan 应为 POST /review(批准/驳回)");
+    }
+
+    @Test
+    @DisplayName("SC-T-04 端点:ReviewCenterController 审核中心汇总 POST /api/admin/review-center/summary")
+    void endpointMapping_reviewCenter_summary() {
+        Class<ReviewCenterController> clazz = ReviewCenterController.class;
+
+        assertTrue(hasClassMapping(clazz, "/api/admin/review-center"),
+                "ReviewCenterController 类级映射应为 /api/admin/review-center");
+        assertTrue(hasPostMapping(clazz, "summary", "/summary"),
+                "summary 应为 POST /summary(审核中心计数)");
+    }
+
+    // ==================== 已绿锚点:待复核内容载体 / 现有安全原语 ====================
+
+    @Test
+    @DisplayName("SC-T-28 载体:HealthPlan 持有 planContent / planJson(AI 方案正文,复核对象)")
+    void contract_healthPlanContentFields() throws Exception {
+        assertNotNull(HealthPlan.class.getDeclaredField("planContent"),
+                "HealthPlan 应持有 planContent(AI 生成的方案正文)");
+        assertNotNull(HealthPlan.class.getDeclaredField("planJson"),
+                "HealthPlan 应持有 planJson(AI 生成的方案结构)");
+        assertTrue("health_plans".equals(tableName(HealthPlan.class)),
+                "HealthPlan 应映射 health_plans 表");
+    }
+
+    @Test
+    @DisplayName("SC-T-28 原语:WechatServiceInterface.checkText(String) 可用于方案内容复核")
+    void contract_wechatCheckTextPrimitive() throws Exception {
+        Method m = WechatServiceInterface.class.getMethod("checkText", String.class);
+        assertEquals(boolean.class, m.getReturnType(), "checkText 应返回 boolean(true=安全)");
+        assertTrue(WechatServiceInterface.class.isAssignableFrom(WechatService.class),
+                "WechatService 应实现 WechatServiceInterface,可复用于方案内容复核");
+    }
+
+    // ==================== RED 探针 A:审核链路缺少内容安全依赖 ====================
+
+    @Test
+    @DisplayName("SC-T-28 RED:health_plans 审核链路必须接入内容安全依赖(checkText 能力)")
+    void structure_approveChain_hasContentSafetyDependency() {
+        Class<?>[] approveChain = new Class<?>[]{
+                HealthPlanServiceImpl.class,
+                GuidePlanReviewController.class,
+                HealthPlanController.class
+        };
+
+        StringBuilder missing = new StringBuilder();
+        boolean found = false;
+        for (Class<?> clazz : approveChain) {
+            if (declaresContentSafetyDependency(clazz)) {
+                found = true;
+                break;
+            }
+            missing.append(clazz.getSimpleName()).append(" ");
+        }
+
+        assertTrue(found,
+                "SC-T-28:AI 方案审核链路必须接入内容安全复核(依赖类型需暴露 checkText(String)),"
+                        + "当前缺失于:" + missing.toString().trim());
+    }
+
+    // ==================== RED 探针 B:缺少方案内容复核端点 ====================
+
+    @Test
+    @DisplayName("SC-T-28 RED:ContentFilterController 必须提供方案内容安全复核端点(而非仅 /by-tags)")
+    void structure_contentFilter_hasPlanSafetyEndpoint() {
+        Class<ContentFilterController> clazz = ContentFilterController.class;
+
+        boolean hasPlanSafetyEndpoint = Arrays.stream(clazz.getDeclaredMethods())
+                .filter(m -> m.isAnnotationPresent(PostMapping.class))
+                .flatMap(m -> Arrays.stream(m.getAnnotation(PostMapping.class).value()))
+                .anyMatch(path -> path.toLowerCase().contains("plan"));
+
+        assertTrue(hasPlanSafetyEndpoint,
+                "SC-T-28:方案内容安全复核需要一个可调用端点(如 POST /api/content/plan-safety),"
+                        + "当前 ContentFilterController 仅有 " + postPaths(clazz));
+    }
+
+    // ==================== RED 探针 C:命中即拦截(行为) ====================
+
+    @Test
+    @DisplayName("SC-T-28 RED:approveAndPublish 命中内容安全 → 拦截发布,不落库")
+    void behavior_approveAndPublish_sensitiveContent_blocked() throws Exception {
+        HealthPlanMapper planMapper = mock(HealthPlanMapper.class);
+        TaskMapper taskMapper = mock(TaskMapper.class);
+
+        HealthPlan plan = healthPlan(901L, "pending_review",
+                "祖传秘方,包治百病,七天根治自闭症,无需运动与饮食干预。");
+        when(planMapper.selectById(901L)).thenReturn(plan);
+
+        HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
+        inject(svc, "healthPlanMapper", planMapper);
+        inject(svc, "taskMapper", taskMapper);
+
+        try {
+            svc.approveAndPublish(901L, 7L, "同意");
+        } catch (RuntimeException expected) {
+            // 命中即拦截:允许以业务异常方式拒绝发布
+        }
+
+        assertFalse("published".equals(plan.getStatus()),
+                "SC-T-28 边界「命中即拦截」:内容安全复核未通过的方案不得置为 published");
+        verify(planMapper, never()).updateById(any(HealthPlan.class));
+    }
+
+    // ==================== RED 探针 D:审核入口不得在复核前变更状态 ====================
+
+    @Test
+    @DisplayName("SC-T-28 RED:GuidePlanReviewController.approvePlan 链路需在发布前完成复核(状态不变更)")
+    void behavior_guidePlanReviewApprove_doesNotPublishBeforeSafetyCheck() throws Exception {
+        HealthPlanMapper planMapper = mock(HealthPlanMapper.class);
+        HealthPlan plan = healthPlan(902L, "pending_review", "无资质医疗建议:停用处方药改用本方案。");
+        when(planMapper.selectById(902L)).thenReturn(plan);
+
+        HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
+        inject(svc, "healthPlanMapper", planMapper);
+        inject(svc, "taskMapper", mock(TaskMapper.class));
+
+        try {
+            svc.approveAndPublish(902L, 7L, null);
+        } catch (RuntimeException expected) {
+            // 同上:拦截即符合契约
+        }
+
+        // 未复核不得发布;已发布说明审核链路跳过了内容安全闸门
+        assertEquals("pending_review", plan.getStatus(),
+                "SC-T-28:审核链路必须先做内容安全复核,未通过前 plan 状态应保持 pending_review");
+    }
+
+    // ==================== helpers ====================
+
+    /** 类级 @RequestMapping 是否包含指定路径 */
+    private static boolean hasClassMapping(Class<?> clazz, String expected) {
+        return Arrays.stream(clazz.getDeclaredAnnotations())
+                .filter(a -> a instanceof RequestMapping)
+                .flatMap(a -> Arrays.stream(((RequestMapping) a).value()))
+                .anyMatch(expected::equals);
+    }
+
+    /** 指定方法是否为 POST + 指定路径 */
+    private static boolean hasPostMapping(Class<?> clazz, String methodName, String expectedPath) {
+        return Arrays.stream(clazz.getDeclaredMethods())
+                .filter(m -> m.getName().equals(methodName))
+                .filter(m -> m.isAnnotationPresent(PostMapping.class))
+                .flatMap(m -> Arrays.stream(m.getAnnotation(PostMapping.class).value()))
+                .anyMatch(expectedPath::equals);
+    }
+
+    /** 收集类上所有 POST 路径,用于失败信息可读性 */
+    private static String postPaths(Class<?> clazz) {
+        String[] paths = Arrays.stream(clazz.getDeclaredMethods())
+                .filter(m -> m.isAnnotationPresent(PostMapping.class))
+                .flatMap(m -> Arrays.stream(m.getAnnotation(PostMapping.class).value()))
+                .toArray(String[]::new);
+        return Arrays.toString(paths);
+    }
+
+    /** 沿类层次查找字段,判断是否存在类型暴露 checkText(String) 的依赖(内容安全闸门) */
+    private static boolean declaresContentSafetyDependency(Class<?> clazz) {
+        for (Class<?> c = clazz; c != null && c != Object.class; c = c.getSuperclass()) {
+            for (Field f : c.getDeclaredFields()) {
+                if (exposesCheckText(f.getType())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 类型(接口/实现类)是否暴露 checkText(String) */
+    private static boolean exposesCheckText(Class<?> type) {
+        try {
+            type.getMethod("checkText", String.class);
+            return true;
+        } catch (NoSuchMethodException e) {
+            return false;
+        }
+    }
+
+    private static String tableName(Class<?> clazz) {
+        com.baomidou.mybatisplus.annotation.TableName annotation =
+                clazz.getAnnotation(com.baomidou.mybatisplus.annotation.TableName.class);
+        return annotation == null ? null : annotation.value();
+    }
+
+    private static HealthPlan healthPlan(Long id, String status, String planContent) {
+        HealthPlan p = new HealthPlan();
+        p.setId(id);
+        p.setFamilyId(7L);
+        p.setStatus(status);
+        p.setPlanContent(planContent);
+        p.setPlanJson("{\"sections\":[]}");
+        p.setCreatedAt(new Date());
+        p.setUpdatedAt(new Date());
+        return p;
+    }
+}

+ 249 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherDataIsolationScT13ContractTest.java

@@ -0,0 +1,249 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.guide.PlanReviewController;
+import com.etotem.cfc.entity.TaskPlanInstance;
+import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.GuideFamilyService;
+import com.etotem.cfc.service.PlanActivationService;
+import com.etotem.cfc.service.PlannerCertificationGuard;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:规划师视角数据隔离(能力键 {@code teacher.data.isolation},SC-T-13)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-13(规划师视角数据隔离——仅见绑定家庭;
+ *       边界:非绑定家庭 403;模块:控制器内 role 检查)</li>
+ *   <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md}(EVD-10 §2 SC-T-13 端点核验表 +
+ *       §5.1 权限流建议:teacher 越权访问非绑定家庭 {@code {familyId}} 端点应 403/拒绝)</li>
+ *   <li>API:{@code docs/superpowers/api/API_REFERENCE.md} §4.57 SC-T-13——授权源
+ *       {@code guide_families.status='binding'}({@link GuideFamilyService#isBound})+ 角色隔离;
+ *       {@code PlanReviewController family-list}(teacher 按 JWT familyId 过滤)</li>
+ * </ul>
+ *
+ * <p><b>实测缺口(RED 依据,origin/cfclub @ e03ec423a 工作树)</b></p>
+ * <p>{@code PlanReviewController#listFamilyPlans}({@code POST /api/guide/plans/family-list})对
+ * teacher 的处理是「body.familyId 优先、JWT familyId 兜底」,<b>无任何 isBound 校验</b>——
+ * 控制器仅注入 {@code planMapper/userMapper/planActivationService/plannerCertificationGuard},
+ * 未注入全项目唯一授权判定点 {@link GuideFamilyAccessGuard}(其 javadoc 自述「全项目唯一判定点,
+ * 避免各控制器散写导致口径漂移」)。因此 teacher 在 body 中传任意非绑定 familyId 即可列出该家庭全部方案
+ * (IDOR 数据泄漏),与契约「teacher 按 JWT familyId 过滤 / 非绑定家庭 403」相悖。SC-T-13 其余三个载体
+ * ({@code GuideFamilyQueryController /my-families}、{@code GuideFamilyTaskController /bound-families +
+ * /{familyId}/*} 的 isBound 校验)已由 {@code GuideBindingAccessContractTest} / {@code PlanReviewTeacherContractTest}
+ * 覆盖,本类专补 family-list 隔离缺口。</p>
+ *
+ * <p><b>RED 预期</b>:权限流(越权 403 / 不泄漏非绑定家庭方案 / 越权不查询 planMapper)与
+ * 结构流(注入统一授权守卫)在实现补齐前应失败(红);正常流/异常流/观察为基线钉住(绿)。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者在 {@code PlanReviewController} 注入
+ * {@code GuideFamilyAccessGuard}(或 {@code GuideFamilyService}),在 {@code listFamilyPlans} 的
+ * teacher 分支先做 {@code checkFamilyAccess(userId, filterFamilyId)} 校验(非绑定 → 403),
+ * 并将家族过滤收敛到绑定/JWT 家庭集合后,本类自动转绿。</p>
+ */
+class TeacherDataIsolationScT13ContractTest extends AssessmentPlanContractTestBase {
+
+    private static final Long TEACHER = 7L;
+    /** JWT familyId(默认家庭上下文,按契约即 teacher 的绑定/JWT 家庭) */
+    private static final Long OWN_FAMILY = 7L;
+    /** 非绑定家庭:teacher 越权目标(契约要求 403/拒绝) */
+    private static final Long FOREIGN_FAMILY = 99L;
+
+    private TaskPlanInstanceMapper planMapper;
+    private PlanReviewController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // 无 Spring 上下文:预注册 TableInfo,使 listFamilyPlans 内 LambdaQueryWrapper 可用
+        initTable(TaskPlanInstance.class);
+        planMapper = mock(TaskPlanInstanceMapper.class);
+        controller = new PlanReviewController();
+        inject(controller, "planMapper", planMapper);
+        inject(controller, "userMapper", mock(UserMapper.class));
+        inject(controller, "planActivationService", mock(PlanActivationService.class));
+        inject(controller, "plannerCertificationGuard", mock(PlannerCertificationGuard.class));
+    }
+
+    // ==================== 正常流 / 异常流(基线钉住,当前实现已符合契约) ====================
+
+    @Test
+    @DisplayName("SC-T-13 正常流-1:admin 指定任务家庭查方案列表 → 200 放行(运营兜底)")
+    void familyList_正常_admin放行() {
+        when(planMapper.selectList(any())).thenReturn(foreignPlans());
+
+        Result<List<TaskPlanInstance>> r =
+                controller.listFamilyPlans(bodyWithFamily(FOREIGN_FAMILY), 1L, "admin", 2L, null);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+    }
+
+    @Test
+    @DisplayName("SC-T-13 异常流-1:teacher 无 familyId 且无 JWT 家庭 → 5001 请先创建或加入家庭")
+    void familyList_异常_teacher无家庭上下文() {
+        Result<List<TaskPlanInstance>> r =
+                controller.listFamilyPlans(new HashMap<String, Object>(), TEACHER, "teacher", null, null);
+
+        assertEquals(5001, r.getCode());
+        assertTrue(r.getMessage().contains("请先创建或加入家庭"));
+    }
+
+    @Test
+    @DisplayName("SC-T-13 异常流-2:非 teacher/admin 角色 → 拒绝(无权限)")
+    void familyList_异常_非teacher角色拒绝() {
+        Result<List<TaskPlanInstance>> r =
+                controller.listFamilyPlans(bodyWithFamily(OWN_FAMILY), 1L, "parent", 7L, null);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("无权限"));
+    }
+
+    // ==================== 权限流(RED:family-list 数据隔离缺口,当前实现越权可及) ====================
+
+    @Test
+    @DisplayName("SC-T-13 权限流-1:teacher 越权访问非绑定家庭(99L) family-list → 应 403 拒绝")
+    void familyList_权限_teacher越权非绑定家庭_403() {
+        when(planMapper.selectList(any())).thenReturn(foreignPlans());
+
+        Result<List<TaskPlanInstance>> r =
+                controller.listFamilyPlans(bodyWithFamily(FOREIGN_FAMILY), TEACHER, "teacher", OWN_FAMILY, null);
+
+        // 契约(EVD-10 §5.1 / API_REFERENCE §4.57 SC-T-13):teacher 越权访问非绑定家庭应 403/拒绝。
+        // 当前实现无 isBound 校验,直接以 body.familyId 查询并返回该家庭方案(200 + data)→ 断言失败(红)。
+        assertEquals(403, r.getCode(),
+                "[teacher.data.isolation] family-list 应拒绝 teacher 越权访问非绑定家庭(99L):"
+                        + "需按 guide_families(status='binding') isBound 校验(SC-T-13)。");
+    }
+
+    @Test
+    @DisplayName("SC-T-13 权限流-2:teacher 携带 body.familyId 不得返回非绑定家庭方案(按 JWT 家庭隔离)")
+    void familyList_权限_不泄漏跨家庭方案() {
+        when(planMapper.selectList(any())).thenReturn(foreignPlans());
+
+        Map<String, Object> body = bodyWithFamily(FOREIGN_FAMILY);
+        Result<List<TaskPlanInstance>> r =
+                controller.listFamilyPlans(body, TEACHER, "teacher", OWN_FAMILY, null);
+
+        // 契约:teacher 查询范围 = 绑定/JWT 家庭;99L 非绑定 → 结果不得含 99L 方案。
+        // 当前实现 body.familyId 直接透传 → 返回 foreignPlans(含 99L)→ 断言失败(红)。
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        for (TaskPlanInstance plan : r.getData()) {
+            assertTrue(!FOREIGN_FAMILY.equals(plan.getFamilyId()),
+                    "[teacher.data.isolation] family-list 泄漏非绑定家庭(99L)方案:"
+                            + "teacher 应按绑定/JWT 家庭过滤,禁止跨家庭返回(SC-T-13)。");
+        }
+    }
+
+    @Test
+    @DisplayName("SC-T-13 权限流-3:teacher 越权非绑定家庭时 planMapper.selectList 不得被查询(拒绝先行)")
+    void familyList_权限_越权不查询planMapper() {
+        when(planMapper.selectList(any())).thenReturn(foreignPlans());
+
+        Map<String, Object> body = bodyWithFamily(FOREIGN_FAMILY);
+        controller.listFamilyPlans(body, TEACHER, "teacher", OWN_FAMILY, null);
+
+        // 契约(GuideFamilyAccessGuard 全项目唯一判定点语义):隔离校验先行,
+        // 非绑定家庭不触达方案查询层。当前实现无校验、直接 selectList → verify(never) 失败(红)。
+        verify(planMapper, never()).selectList(any());
+    }
+
+    // ==================== 结构流(RED:family-list 未接入统一授权判定点) ====================
+
+    @Test
+    @DisplayName("SC-T-13 结构流-1:PlanReviewController 应注入 GuideFamilyAccessGuard/GuideFamilyService(统一判定点)")
+    void familyList_结构_应注入统一授权守卫() {
+        boolean hasGuard = false;
+        for (Field f : PlanReviewController.class.getDeclaredFields()) {
+            String type = f.getType().getName();
+            if (GuideFamilyAccessGuard.class.getName().equals(type)
+                    || GuideFamilyService.class.getName().equals(type)) {
+                hasGuard = true;
+                break;
+            }
+        }
+        assertTrue(hasGuard,
+                "[teacher.data.isolation] PlanReviewController 未注入 GuideFamilyAccessGuard/GuideFamilyService:"
+                        + "契约要求 family-list 对 teacher 复用全项目唯一授权判定点(guide_families status='binding' "
+                        + "isBound 校验,SC-T-13 / EVD-10 §5.1),越权访问非绑定家庭应 403。当前未落地。");
+    }
+
+    @Test
+    @DisplayName("SC-T-13 结构流-2(基线):授权源 guide_families 表 + status='binding' 语义应存在于 schema")
+    void familyList_结构_授权源表存在() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("guide_families"),
+                "[teacher.data.isolation] schema.sql 应含 guide_families 表(授权源,SC-T-13 契约 §2)。"
+                        + "GREEN 实现依赖该表 status='binding' 做 isBound 判定。");
+        assertTrue(schema.contains("binding"),
+                "[teacher.data.isolation] schema.sql 的 guide_families 应含 status='binding' 状态语义"
+                        + "(GuideFamilyService.isBound 授权源,SC-T-13)。");
+    }
+
+    // ==================== 观察(钉住现状,不修实现) ====================
+
+    @Test
+    @DisplayName("SC-T-13 观察-1:planDetail 无角色/归属校验入参(观察钉住,SC-T-13 契约面为 family-list)")
+    void pinsObserved_planDetail无隔离校验() throws Exception {
+        Method m = PlanReviewController.class.getDeclaredMethod("planDetail", Map.class);
+        assertEquals(1, m.getParameterCount(),
+                "planDetail 仅接收 body(无 role/userId 归属校验入参)。观察钉住现状:"
+                        + "SC-T-13 契约面为 family-list(teacher 按 JWT familyId 过滤),detail 无隔离不在本键断言范围。");
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private Map<String, Object> bodyWithFamily(Long familyId) {
+        Map<String, Object> body = new HashMap<String, Object>();
+        body.put("familyId", familyId);
+        return body;
+    }
+
+    private List<TaskPlanInstance> foreignPlans() {
+        TaskPlanInstance p = new TaskPlanInstance();
+        p.setId(999L);
+        p.setFamilyId(FOREIGN_FAMILY);
+        p.setName("非绑定家庭方案");
+        p.setStatus("generated");
+        p.setCreatedAt(new Date());
+        return Collections.singletonList(p);
+    }
+
+    private String readSchemaSql() {
+        try (java.io.InputStream is =
+                     getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (java.util.Scanner scanner =
+                         new java.util.Scanner(is, java.nio.charset.StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}

+ 295 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherDimensionScoreContractTest.java

@@ -0,0 +1,295 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.family.ManualDimensionScoreController;
+import com.etotem.cfc.dto.ManualScoreSubmitDTO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FiveDimensionScore;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
+import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
+import com.etotem.cfc.service.FiveDimensionSelfCheckService;
+import com.etotem.cfc.service.impl.ManualDimensionScoreServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:成长规划师五维变化录入/打分(能力键 {@code teacher.dimension.score})。
+ *
+ * <p><b>需求/设计锚点(EVD-10 契约,2026-11-07)</b>:
+ * {@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} SC-T-30/31/32
+ * + {@code docs/superpowers/api/API_REFERENCE.md} §4.57 成长规划师域 + sc-key-map.md
+ * (SC-T-30/31/32 均「未跑通」,本测试为其补契约 JUnit 证据)。</p>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li>SC-T-30 测评录入触发五维维度变化:{@code DanAssessmentController /result/record}
+ *       → {@code AssessmentService.recordResult} → {@link com.etotem.cfc.service.DimensionScoreService#refreshFromDanAssessment}</li>
+ *   <li>SC-T-31 手动维度打分覆盖/调整:{@code ManualDimensionScoreController /api/family/manual-score/*}
+ *       (submit/latest/history)→ 落 {@code five_dimension_scores}(source_type=manual)</li>
+ *   <li>SC-T-32 五维自评录入与变化:{@code FiveDimensionSelfCheckController /api/family/self-check/*}
+ *       (questions/submit/latest/history/status/ignore/generate-plan/trend-analysis/ai-generate-questions)
+ *       → <b>自评分数应桥接落五维变化</b>(契约原文「五维自评 → 变化落 EnergyController」)</li>
+ * </ul>
+ *
+ * <p><b>实现现状(origin/cfclub 实测核实)</b>:
+ * SC-T-30/31 载体已落地(测评桥接钩子、手动打分 controller+service 均存在);
+ * <b>SC-T-32 的「自评 → 五维变化桥接」未落地</b>——{@code FiveDimensionSelfCheckService}
+ * 仅声明 {@code selfCheckMapper/familyMemberMapper/wuxingSourcingService/onboardingService/
+ * ignoreMapper/selfCheckAnalysisService/selfCheckTrendService} 等字段,<b>无任何五维变化落点
+ * (DimensionScoreService / EnergyService / FiveDimensionScoreMapper)引用</b>;
+ * schema.sql 中 {@code five_dimension_scores.source_type} 枚举注释
+ * (periodic/task/assessment/manual)也<b>无 selfcheck 来源</b>。故 SC-T-32 桥接用例预期红。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,Mockito + 反射结构探测,无 Spring / 无 DB / 无外部服务。</p>
+ */
+class TeacherDimensionScoreContractTest {
+
+    /** SC-T-31 五维编码(契约口径 body/mind/wisdom/action/wealth) */
+    private static final String[] DIMENSIONS = {"body", "mind", "wisdom", "action", "wealth"};
+
+    // ==================== SC-T-31 行为级绿测试的 mock ====================
+
+    @Mock
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
+    @Mock
+    private FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private ManualDimensionScoreServiceImpl manualScoreService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流-1(SC-T-31):手动维度打分端点 submit/latest/history 应存在
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1(SC-T-31):ManualDimensionScoreController 应暴露 submit/latest/history 端点")
+    void manualScore_controllerExposesSubmitLatestHistory() {
+        Class<?> clazz = ManualDimensionScoreController.class;
+        String[] requiredPaths = {"/submit", "/latest", "/history"};
+        for (String path : requiredPaths) {
+            boolean found = false;
+            for (Method m : clazz.getDeclaredMethods()) {
+                org.springframework.web.bind.annotation.PostMapping pm =
+                        m.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+                if (pm != null && java.util.Arrays.asList(pm.value()).contains(path)) {
+                    found = true;
+                    break;
+                }
+            }
+            assertTrue(found,
+                    "[teacher.dimension.score] ManualDimensionScoreController 应暴露 POST "
+                            + path + " 端点(契约 SC-T-31 手动维度打分覆盖/调整)。");
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流-2(SC-T-31):手动打分 submit 应落 five_dimension_scores(source_type=manual)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-2(SC-T-31):手动打分 submit 应写 five_dimension_scores(source_type=manual)")
+    void manualScore_submitPersistsManualSourceRecords() {
+        FamilyMember member = new FamilyMember();
+        member.setId(88001L);
+        member.setFamilyId(9001L);
+        member.setNickname("契约之子");
+
+        Map<String, Integer> scores = new LinkedHashMap<>();
+        scores.put("body", 80);
+        scores.put("mind", 60);
+        scores.put("wisdom", 70);
+        scores.put("action", 50);
+        scores.put("wealth", 90);
+
+        ManualScoreSubmitDTO dto = new ManualScoreSubmitDTO();
+        dto.setMemberId(88001L);
+        dto.setScores(scores);
+
+        when(familyMemberMapper.selectById(88001L)).thenReturn(member);
+
+        manualScoreService.submit(dto, 10001L);
+
+        ArgumentCaptor<FiveDimensionScore> captor = ArgumentCaptor.forClass(FiveDimensionScore.class);
+        verify(fiveDimensionScoreMapper, times(5)).insert(captor.capture());
+        Set<String> codes = new HashSet<>();
+        for (FiveDimensionScore record : captor.getAllValues()) {
+            codes.add(record.getDimensionCode());
+            assertTrue("manual".equals(record.getSourceType()),
+                    "[teacher.dimension.score] 手动打分应落 source_type=manual,实际="
+                            + record.getSourceType() + "(契约 SC-T-31)。");
+        }
+        for (String dim : DIMENSIONS) {
+            assertTrue(codes.contains(dim),
+                    "[teacher.dimension.score] 手动打分应覆盖五维编码 " + dim + "(契约 SC-T-31)。");
+        }
+        // 自检记录同步落表(复用 five_dimension_self_checks 记录手动填分)
+        verify(selfCheckMapper, times(1)).insert(any());
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流-3(SC-T-30):测评录入应触发五维维度变化(桥接钩子存在)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-3(SC-T-30):DimensionScoreService 应提供 refreshFromDanAssessment 桥接")
+    void dimensionScoreService_exposesDanAssessmentRefreshHook() throws Exception {
+        Class<?> ds = Class.forName("com.etotem.cfc.service.DimensionScoreService");
+        boolean hasHook = false;
+        for (Method m : ds.getDeclaredMethods()) {
+            if ("refreshFromDanAssessment".equals(m.getName())) {
+                hasHook = true;
+                break;
+            }
+        }
+        assertTrue(hasHook,
+                "[teacher.dimension.score] DimensionScoreService 应提供 refreshFromDanAssessment"
+                        + "(契约 SC-T-30 测评录入触发五维维度变化)。");
+
+        // AssessmentService 应注入该桥接(recordResult 触发点)
+        Class<?> as = Class.forName("com.etotem.cfc.service.AssessmentService");
+        boolean injected = false;
+        for (Field f : as.getDeclaredFields()) {
+            if ("com.etotem.cfc.service.DimensionScoreService".equals(f.getType().getName())) {
+                injected = true;
+                break;
+            }
+        }
+        assertTrue(injected,
+                "[teacher.dimension.score] AssessmentService 应注入 DimensionScoreService"
+                        + "(recordResult 录入测评结果时触发五维维度变化,契约 SC-T-30)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流-1(SC-T-32,预期红):五维自评应桥接落五维变化(契约「自评 → 变化落 EnergyController」)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1(SC-T-32,红):五维自评提交应桥接五维分数/能量变化(当前未落地)")
+    void selfCheck_submitShouldBridgeDimensionScoreChange() {
+        Class<?> clazz = FiveDimensionSelfCheckService.class;
+        String[] bridgeTypes = {
+                "com.etotem.cfc.service.DimensionScoreService", // 维度分桥接(refactor 同 SC-T-30)
+                "com.etotem.cfc.service.EnergyService",          // 能量落点(契约「变化落 EnergyController」)
+                "com.etotem.cfc.mapper.FiveDimensionScoreMapper" // 直接落 five_dimension_scores
+        };
+        String found = null;
+        for (Field f : clazz.getDeclaredFields()) {
+            String type = f.getType().getName();
+            for (String t : bridgeTypes) {
+                if (t.equals(type)) {
+                    found = t;
+                    break;
+                }
+            }
+            if (found != null) {
+                break;
+            }
+        }
+        assertNotNull(found,
+                "[teacher.dimension.score][SC-T-32] 五维自评提交后应触发五维变化落点:"
+                        + "FiveDimensionSelfCheckService 未注入任何维度分/能量桥接服务(DimensionScoreService / "
+                        + "EnergyService / FiveDimensionScoreMapper 均无)。契约要求「五维自评 → 变化落 "
+                        + "EnergyController」,当前自评只写 five_dimension_self_checks 表,"
+                        + "未桥接 five_dimension_scores / 五维能量。实现未落地,测试预期红。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流-2(SC-T-32,预期红):five_dimension_scores.source_type 应有自评来源
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-2(SC-T-32,红):five_dimension_scores.source_type 应含 selfcheck 来源(当前缺)")
+    void schema_sourceType_shouldSupportSelfCheck() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("five_dimension_scores"),
+                "[teacher.dimension.score] schema.sql 缺少 five_dimension_scores 建表。");
+
+        // 定位 five_dimension_scores 建表段的 source_type 枚举注释
+        int idx = schema.indexOf("CREATE TABLE IF NOT EXISTS five_dimension_scores");
+        assertTrue(idx >= 0,
+                "[teacher.dimension.score] schema.sql 未找到 five_dimension_scores 建表语句。");
+        int end = schema.indexOf("ENGINE=InnoDB", idx);
+        String ddl = end > idx ? schema.substring(idx, end) : schema.substring(idx);
+        assertTrue(ddl.contains("selfcheck"),
+                "[teacher.dimension.score][SC-T-32] five_dimension_scores.source_type 枚举注释"
+                        + "(periodic/task/assessment/manual)缺少五维自评来源(selfcheck):"
+                        + "契约要求「五维自评 → 变化落 EnergyController」,自评分数应能写为五维分数变化,"
+                        + "当前建表定义无对应来源。实现未落地,测试预期红。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:teacher.dimension.score 为双角色业务域能力,无独立角色断言
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:手动打分/自评端点走 JWT 登录态,规划师双角色无独立角色断言")
+    void permissionFlow_teacherDualRoleNoSeparateRoleAssertion() {
+        // 说明性用例:SC-T-30/31/32 端点经 JwtInterceptor 登录态保护(@RequestAttribute("userId")),
+        // 规划师(role=teacher)与家长共用同端点;契约未定义独立 teacher-only 角色分支(与
+        // FamilyJoinValidationRulesContractTest 权限流口径一致)。不产生断言失败。
+        assertTrue(true,
+                "权限流说明:teacher.dimension.score 端点面向登录用户(含 role=teacher 双角色),"
+                        + "无独立角色门禁断言。");
+    }
+
+    // ------------------------------------------------------------------
+    // 辅助
+    // ------------------------------------------------------------------
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+
+    @SuppressWarnings("unused")
+    private Method findMethod(Class<?> clazz, String name) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                return m;
+            }
+        }
+        return null;
+    }
+}

+ 394 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherLoginSwitchContractTest.java

@@ -0,0 +1,394 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.controller.auth.AuthController;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.dto.SwitchRoleDTO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+
+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.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:成长规划师登录/角色切换(能力键 {@code teacher.login.switch},SC-T-01 / SC-T-20)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/tdd/orchestration/sc-key-map.md} SC-T-01(规划师登录+切回家长)、
+ *       SC-T-20(退出/切换账号)</li>
+ *   <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} §2 SC-T-01/T-20 +
+ *       §3 能力键映射(teacher.login.switch → AuthController switch-role);
+ *       {@code docs/superpowers/api/API_REFERENCE.md} §4.57 成长规划师域</li>
+ * </ul>
+ *
+ * <p><b>契约行为规范(SC-T-01/T-20)</b></p>
+ * <ul>
+ *   <li><b>正常流</b>:{@code POST /api/auth/switch-role} 接受 {@code SwitchRoleDTO.role}
+ *       ({"parent"/"child"/"teacher"}),对已持有该角色的用户更新 {@code user.role} 并返回
+ *       含新 JWT 的 {@link LoginResultDTO}。</li>
+ *   <li><b>权限流</b>:</li>
+ *   <ul>
+ *     <li>未登录(userId null/≤0)→ 401 "请先登录"</li>
+ *     <li>当前角色为 admin → "管理员角色不允许切换"</li>
+ *     <li>目标 role 非 parent/child/teacher → "不支持切换到该角色"</li>
+ *     <li>切换 teacher 但用户无 teacher 角色 → "您不是成长规划师,无法切换为规划师模式"</li>
+ *     <li>目标 role 不在 {@code user.roles} 中且不等当前角色 → "您没有该角色权限"</li>
+ *   </ul>
+ *   <li><b>结构流</b>:</li>
+ *   <ul>
+ *     <li>{@code AuthController} 含 {@code @PostMapping("/switch-role")} 方法</li>
+ *     <li>{@code JwtInterceptor#LOGIN_REQUIRED_PATHS} 包含 "/api/auth/switch-role"(必须鉴权,非公开)</li>
+ *   </ul>
+ * </ul>
+ *
+ * <p><b>实测现状(origin/cfclub @ 4e1497e16)</b>:</p>
+ * <ul>
+ *   <li>✅ {@code AuthController#switchRole}(POST /api/auth/switch-role)已落地(L332-L344)</li>
+ *   <li>✅ {@code UserService#switchRoleForLogin} 含 admin 守卫、login-role 白名单(parent/child/teacher)、
+ *       teacher 角色所有权校验、通用角色拥有性校验(L668-L706)</li>
+ *   <li>✅ {@code JwtInterceptor} L99 将 /api/auth/switch-role 列入 {@code LOGIN_REQUIRED_PATHS}</li>
+ *   <li>✅ {@code SwitchRoleDTO} 存在,字段 {@code role} 覆盖所有目标角色</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:若上述任一契约行为缺失/默认值未实现,对应断言失败(红);
+ * 若全部落地则全绿,测试作为契约证据留存。</p>
+ */
+class TeacherLoginSwitchContractTest extends AssessmentPlanContractTestBase {
+
+    // ==================== Controller & Service stubs ====================
+
+    private UserService userService;
+    private AuthController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        userService = mock(UserService.class);
+        controller = new AuthController();
+        inject(controller, "userService", userService);
+        inject(controller, "userMapper", mock(UserMapper.class));
+    }
+
+    // ==================== 正常流:teacher↔parent 角色切换 ====================
+
+    @Test
+    @DisplayName("SC-T-01 正常-1:teacher 切回 parent → 200 含新 token(role=parent)")
+    void switchRole_teacherToParent_正常() {
+        Long userId = 7L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken("jwt-new-token");
+        result.setUserId(userId);
+        result.setRole("parent");
+        when(userService.switchRoleForLogin(userId, "parent")).thenReturn(result);
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertEquals("jwt-new-token", r.getData().getToken());
+        assertEquals("parent", r.getData().getRole());
+        verify(userService).switchRoleForLogin(userId, "parent");
+    }
+
+    @Test
+    @DisplayName("SC-T-01 正常-2:parent 切到 teacher(持有 teacher 角色)→ 200 含新 token(role=teacher)")
+    void switchRole_parentToTeacher_正常() {
+        Long userId = 8L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("teacher");
+
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken("jwt-teacher-token");
+        result.setUserId(userId);
+        result.setRole("teacher");
+        when(userService.switchRoleForLogin(userId, "teacher")).thenReturn(result);
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertEquals("teacher", r.getData().getRole());
+        verify(userService).switchRoleForLogin(userId, "teacher");
+    }
+
+    // ==================== 权限流:未登录 → 401 ====================
+
+    @Test
+    @DisplayName("SC-T-20 权限-1:未登录(userId=null)→ 401 请先登录")
+    void switchRole_unauthenticated_nullUserId_401() {
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, null);
+
+        assertEquals(401, r.getCode());
+        assertTrue(r.getMessage().contains("请先登录"));
+        verify(userService, org.mockito.Mockito.never()).switchRoleForLogin(anyLong(), org.mockito.ArgumentMatchers.anyString());
+    }
+
+    @Test
+    @DisplayName("SC-T-20 权限-2:未登录(userId=0/负数)→ 401 请先登录")
+    void switchRole_unauthenticated_invalidUserId_401() {
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, 0L);
+        assertEquals(401, r.getCode());
+        assertTrue(r.getMessage().contains("请先登录"));
+
+        r = controller.switchRole(dto, -1L);
+        assertEquals(401, r.getCode());
+        assertTrue(r.getMessage().contains("请先登录"));
+
+        verify(userService, org.mockito.Mockito.never()).switchRoleForLogin(anyLong(), org.mockito.ArgumentMatchers.anyString());
+    }
+
+    // ==================== 权限流:admin 禁止切换 ====================
+
+    @Test
+    @DisplayName("SC-T-20 权限-3:admin 角色禁止切换 → 500 '管理员角色不允许切换'")
+    void switchRole_adminBlocked_500() {
+        Long userId = 99L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        when(userService.switchRoleForLogin(userId, "parent"))
+                .thenThrow(new RuntimeException("管理员角色不允许切换"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("管理员角色不允许切换"));
+    }
+
+    // ==================== 权限流:目标角色白名单校验 ====================
+
+    @Test
+    @DisplayName("SC-T-20 权限-4:目标 role 非 parent/child/teacher(admin/operator)→ 500 拒绝")
+    void switchRole_targetRoleNotInWhitelist_500() {
+        Long userId = 10L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("operator");
+
+        when(userService.switchRoleForLogin(userId, "operator"))
+                .thenThrow(new RuntimeException("不支持切换到该角色"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("不支持切换到该角色"));
+    }
+
+    // ==================== 权限流:teacher 角色拥有性校验 ====================
+
+    @Test
+    @DisplayName("SC-T-01 权限-5:无 teacher 角色的用户请求切到 teacher → 500 '您不是成长规划师'")
+    void switchRole_notTeacher_requestingTeacher_500() {
+        Long userId = 11L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("teacher");
+
+        when(userService.switchRoleForLogin(userId, "teacher"))
+                .thenThrow(new RuntimeException("您不是成长规划师,无法切换为规划师模式"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("成长规划师"));
+    }
+
+    @Test
+    @DisplayName("SC-T-01 权限-6:无 teacher 角色的用户请求切到 parent → 成功(普通 parent 可切回 parent)")
+    void switchRole_notTeacher_requestingParent_ok() {
+        Long userId = 12L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken("jwt-parent-token");
+        result.setRole("parent");
+        when(userService.switchRoleForLogin(userId, "parent")).thenReturn(result);
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(200, r.getCode());
+        assertEquals("parent", r.getData().getRole());
+    }
+
+    // ==================== 权限流:用户不持有目标角色 → 500 ====================
+
+    @Test
+    @DisplayName("SC-T-01 权限-7:用户 roles 不含 target role 且 target≠current → 500 无权限")
+    void switchRole_userMissingTargetRole_500() {
+        Long userId = 13L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("child");
+
+        when(userService.switchRoleForLogin(userId, "child"))
+                .thenThrow(new RuntimeException("您没有该角色权限"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("没有该角色权限"));
+    }
+
+    // ==================== 异常流:业务异常透传 ====================
+
+    @Test
+    @DisplayName("SC-T-20 异常-1:UserService 抛出运行时异常 → controller 返回 500 与消息透传")
+    void switchRole_serviceThrows_errorMessagePassedThrough() {
+        Long userId = 14L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("parent");
+
+        when(userService.switchRoleForLogin(userId, "parent"))
+                .thenThrow(new RuntimeException("系统内部错误,请稍后重试"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertEquals("系统内部错误,请稍后重试", r.getMessage());
+    }
+
+    // ==================== 结构流:端点映射 + JwtInterceptor 路径 ====================
+
+    @Test
+    @DisplayName("SC-T-01/T-20 结构-1:AuthController 应含 POST /api/auth/switch-role 映射")
+    void structure_authControllerHasSwitchRoleMapping() throws Exception {
+        Method m = findSwitchRoleMethod(AuthController.class);
+        assertNotNull(m,
+                "[teacher.login.switch] AuthController 缺少 POST /api/auth/switch-role 端点映射:"
+                        + "契约要求 SC-T-01/T-20 的角色切换入口。");
+
+        org.springframework.web.bind.annotation.PostMapping post = m.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+        assertNotNull(post, "switchRole 应为 @PostMapping(AGENTS.md 禁止 GET/PUT/DELETE)");
+        assertTrue(Arrays.stream(post.value()).anyMatch(p -> p.equals("/switch-role")),
+                "switchRole 映射路径应为 /switch-role(完整路径 /api/auth/switch-role)");
+    }
+
+    @Test
+    @DisplayName("SC-T-01/T-20 结构-2:JwtInterceptor#LOGIN_REQUIRED_PATHS 须包含 /api/auth/switch-role")
+    void structure_jwtInterceptorRequiresLoginForSwitchRole() throws Exception {
+        Field field = JwtInterceptor.class.getDeclaredField("LOGIN_REQUIRED_PATHS");
+        field.setAccessible(true);
+        String[] paths = (String[]) field.get(null);
+        assertNotNull(paths, "JwtInterceptor.LOGIN_REQUIRED_PATHS 不应为 null");
+
+        boolean hasSwitchRole = false;
+        for (String p : paths) {
+            if ("/api/auth/switch-role".equals(p)) {
+                hasSwitchRole = true;
+                break;
+            }
+        }
+        assertTrue(hasSwitchRole,
+                "[teacher.login.switch] JwtInterceptor.LOGIN_REQUIRED_PATHS 未包含 '/api/auth/switch-role':"
+                        + "契约要求角色切换端点必须登录(JWT Bearer Token),不得落入公开白名单。");
+    }
+
+    @Test
+    @DisplayName("SC-T-01/T-20 结构-3:SwitchRoleDTO 应存在并含 role 字段(契约接口契约)")
+    void structure_switchRoleDto_existsWithRoleField() throws Exception {
+        Class<?> clazz = loadClass("com.etotem.cfc.dto.SwitchRoleDTO");
+        assertNotNull(clazz,
+                "[teacher.login.switch] SwitchRoleDTO 类不存在:契约要求 POST body 携带 SwitchRoleDTO(role) 入参。");
+
+        boolean hasRoleField = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if ("role".equals(f.getName())) {
+                hasRoleField = true;
+                break;
+            }
+        }
+        assertTrue(hasRoleField,
+                "[teacher.login.switch] SwitchRoleDTO 应含 role 字段,用于承载目标角色(parent/child/teacher)");
+    }
+
+    // ==================== 边界流 ====================
+
+    @Test
+    @DisplayName("SC-T-01 边界:切换为 child 角色(合法目标)→ 通过白名单校验")
+    void boundary_switchToChild_allowedByWhitelist() {
+        Long userId = 15L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole("child");
+
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken("jwt-child-token");
+        result.setRole("child");
+        when(userService.switchRoleForLogin(userId, "child")).thenReturn(result);
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(200, r.getCode());
+        assertEquals("child", r.getData().getRole());
+    }
+
+    @Test
+    @DisplayName("SC-T-01 边界:target role 为 null → 由 service 层守卫,controller 层透传 500")
+    void boundary_targetRoleNull_serviceThrows_500() {
+        Long userId = 16L;
+        SwitchRoleDTO dto = new SwitchRoleDTO();
+        dto.setRole(null);
+
+        when(userService.switchRoleForLogin(userId, null))
+                .thenThrow(new RuntimeException("不支持切换到该角色"));
+
+        Result<LoginResultDTO> r = controller.switchRole(dto, userId);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("不支持切换到该角色"));
+    }
+
+    @Test
+    @DisplayName("SC-T-20 观察:服务端无 logout 端点——JWT 无状态设计,前端删除 token 即退出(符合契约)")
+    void observation_noServerLogoutEndpoint_intentional() throws Exception {
+        boolean hasLogoutMethod = Arrays.stream(AuthController.class.getDeclaredMethods())
+                .anyMatch(m -> m.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                        && (m.getName().toLowerCase().contains("logout")
+                            || Arrays.stream(m.getAnnotationsByType(org.springframework.web.bind.annotation.PostMapping.class))
+                                    .anyMatch(a -> Arrays.asList(a.value()).stream().anyMatch(v -> v.toLowerCase().contains("logout")))));
+        assertFalse(hasLogoutMethod,
+                "[teacher.login.switch] AuthController 无独立/logout 端点——此为预期的 JWT 无状态设计(SC-T-20 观察项),"
+                        + "前端清理 token 即为退出,不属契约缺口。");
+    }
+
+    // ==================== helpers ====================
+
+    private Method findSwitchRoleMethod(Class<?> clazz) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("switchRole")) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+}

+ 512 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherMessagePushContractTest.java

@@ -0,0 +1,512 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.UserNotificationController;
+import com.etotem.cfc.controller.guide.TeacherMessageController;
+import com.etotem.cfc.entity.TeacherMessage;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.TeacherMessageMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.PlannerCertificationGuard;
+import com.etotem.cfc.service.PlannerCertificationService;
+import com.etotem.cfc.service.TeacherMessageService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+
+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.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:能力键 {@code teacher.message.push} —— 规划师→客户消息推送(SC-T-19)。
+ *
+ * <p><b>需求溯源</b></p>
+ * <ul>
+ *   <li>《应用场景总览》SC-T-19「消息推送客户」;sc-key-map 台账 SC-T-19(成长规划师·业务域 L)。</li>
+ *   <li>EVD-10 契约(77026df42)→ API 参考 §4.57 端点载体回填实测:
+ *       {@code TeacherMessageController /api/guide/messages/*}(send/reply/list/unread-count/my)
+ *       + {@code UserNotificationController /api/notification/*}。</li>
+ *   <li>API 参考 §5.4 接单闸门:{@code TeacherMessageController.reply} 需 {@code planner} 类别认证
+ *       ({@code PlannerCertificationGuard.assertCertified(teacherId, CATEGORY_PLANNER)})。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li>正常流:家庭主账号 {@code POST /api/guide/messages/send} 携带 familyId+teacherId+content
+ *       → 成功返回 messageId;规划师 {@code POST /api/guide/messages/{messageId}/reply}(teacher 角色 + 认证通过)
+ *       → 成功返回 true;{@code /list}(teacher 角色)返回分页 records/total;{@code /my} 回填规划师昵称;
+ *       {@code /unread-count}(teacher 角色)返回未读数。</li>
+ *   <li>异常流:{@code /send} 无 familyId → noFamily(code=5001)且不落库;缺 teacherId / 空 content
+ *       → error 且不落库;{@code /reply} 目标消息非本人(service 返回 false)→ 不写库。</li>
+ *   <li>权限流:{@code /reply}、{@code /list}、{@code /unread-count} 非 teacher 角色一律拒绝
+ *       ({@code RolesUtil.hasTeacherRole} 门禁);{@code /reply} 认证闸门拒绝时不调 service。</li>
+ *   <li>结构契约:Controller 端点映射、实体字段、schema.sql 建表。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 反射,无 Spring 上下文 / 无 DB / 无外部服务。</p>
+ *
+ * <p><b>实现现状说明</b>:本契约端点载体在 src/main 已落地(TeacherMessageController /
+ * TeacherMessageService / TeacherMessage 实体 / teacher_messages 表 / UserNotificationController /
+ * PlannerCertificationGuard 均已存在),因此本类契约用例应全绿;
+ * 若编排侧判定为「契约新增能力(RED)」,则以本类为契约基准——任何端点缺失、角色门禁缺失、
+ * 认证闸门缺失、落库参数错误都会使对应用例转红。</p>
+ */
+class TeacherMessagePushContractTest {
+
+    private static final Long PARENT_ID = 1001L;
+    private static final Long FAMILY_ID = 2002L;
+    private static final Long TEACHER_ID = 3003L;
+    private static final Long MESSAGE_ID = 404L;
+    private static final String CONTENT = "孩子最近注意力不集中";
+    private static final String REPLY_CONTENT = "建议预约一次专注力测评";
+
+    private TeacherMessageController controller;
+    private TeacherMessageService service;
+    private TeacherMessageMapper mapper;
+    private UserMapper userMapper;
+    private PlannerCertificationGuard guard;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new TeacherMessageController();
+        service = new TeacherMessageService();
+        mapper = mock(TeacherMessageMapper.class);
+        userMapper = mock(UserMapper.class);
+        guard = mock(PlannerCertificationGuard.class);
+        inject(controller, "teacherMessageService", service);
+        inject(controller, "userMapper", userMapper);
+        inject(controller, "plannerCertificationGuard", guard);
+        inject(service, "teacherMessageMapper", mapper);
+    }
+
+    // ==================================================================
+    // 正常流:send(家庭主账号发送咨询)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流:send 携带 familyId+teacherId+content → 成功返回 messageId 并落库")
+    void send_valid_persistsAndReturnsId() {
+        when(mapper.insert(any(TeacherMessage.class))).thenAnswer(inv -> {
+            TeacherMessage m = inv.getArgument(0);
+            m.setId(MESSAGE_ID);
+            return 1;
+        });
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("teacherId", TEACHER_ID);
+        body.put("content", CONTENT);
+        Result<Long> r = controller.sendMessage(PARENT_ID, FAMILY_ID, body);
+
+        assertEquals(200, r.getCode());
+        assertEquals(MESSAGE_ID, r.getData());
+        verify(mapper).insert(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("正常流:send 落库的消息初始状态为 pending、含创建时间、归属正确")
+    void send_persistedMessage_hasPendingStatusAndOwnership() {
+        final TeacherMessage[] captured = new TeacherMessage[1];
+        when(mapper.insert(any(TeacherMessage.class))).thenAnswer(inv -> {
+            TeacherMessage m = inv.getArgument(0);
+            captured[0] = m;
+            m.setId(MESSAGE_ID);
+            return 1;
+        });
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("teacherId", TEACHER_ID);
+        body.put("content", CONTENT);
+        controller.sendMessage(PARENT_ID, FAMILY_ID, body);
+
+        assertNotNull(captured[0]);
+        assertEquals(FAMILY_ID, captured[0].getFamilyId());
+        assertEquals(PARENT_ID, captured[0].getParentId());
+        assertEquals(TEACHER_ID, captured[0].getTeacherId());
+        assertEquals(CONTENT, captured[0].getContent());
+        assertEquals("pending", captured[0].getStatus());
+        assertNotNull(captured[0].getCreatedAt());
+    }
+
+    // ==================================================================
+    // 异常流:send 参数门禁
+    // ==================================================================
+
+    @Test
+    @DisplayName("异常流:send 无 familyId → noFamily(5001)且不落库")
+    void send_noFamily_rejectedNoPersist() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("teacherId", TEACHER_ID);
+        body.put("content", CONTENT);
+
+        Result<Long> r = controller.sendMessage(PARENT_ID, null, body);
+
+        assertEquals(5001, r.getCode());
+        verify(mapper, never()).insert(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("异常流:send 缺 teacherId → error 且不落库")
+    void send_missingTeacherId_rejectedNoPersist() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("content", CONTENT);
+
+        Result<Long> r = controller.sendMessage(PARENT_ID, FAMILY_ID, body);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("teacherId"));
+        verify(mapper, never()).insert(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("异常流:send 空 content → error 且不落库")
+    void send_blankContent_rejectedNoPersist() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("teacherId", TEACHER_ID);
+        body.put("content", "   ");
+
+        Result<Long> r = controller.sendMessage(PARENT_ID, FAMILY_ID, body);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("内容"));
+        verify(mapper, never()).insert(any(TeacherMessage.class));
+    }
+
+    // ==================================================================
+    // 正常流:reply(规划师回复,§5.4 认证闸门)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流:reply teacher 角色 + 认证通过 → 成功并调 service.replyMessage")
+    void reply_teacherCertified_succeeds() {
+        when(guard.assertCertified(TEACHER_ID, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(null);
+        when(mapper.selectById(MESSAGE_ID)).thenReturn(buildMessage(TEACHER_ID));
+        when(mapper.updateById(any(TeacherMessage.class))).thenReturn(1);
+
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+        Result<Boolean> r = controller.replyMessage(TEACHER_ID, "teacher", MESSAGE_ID, body, null);
+
+        assertEquals(200, r.getCode());
+        assertTrue(Boolean.TRUE.equals(r.getData()));
+        verify(mapper).updateById(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("正常流:reply 认证通过后 service 将消息置为 replied、含回复时间")
+    void reply_marksRepliedWithTime() {
+        when(guard.assertCertified(TEACHER_ID, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(null);
+        TeacherMessage msg = buildMessage(TEACHER_ID);
+        when(mapper.selectById(MESSAGE_ID)).thenReturn(msg);
+
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+        controller.replyMessage(TEACHER_ID, "teacher", MESSAGE_ID, body, null);
+
+        assertEquals("replied", msg.getStatus());
+        assertEquals(REPLY_CONTENT, msg.getReplyContent());
+        assertNotNull(msg.getRepliedAt());
+    }
+
+    // ==================================================================
+    // 异常流:reply 越权与消息归属
+    // ==================================================================
+
+    @Test
+    @DisplayName("异常流:reply 目标消息非本人(teacherId 不匹配)→ service 返回 false 且不写库")
+    void reply_notOwnedMessage_returnsFalseNoPersist() {
+        when(guard.assertCertified(TEACHER_ID, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(null);
+        // 消息归属另一个规划师
+        when(mapper.selectById(MESSAGE_ID)).thenReturn(buildMessage(9999L));
+
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+        Result<Boolean> r = controller.replyMessage(TEACHER_ID, "teacher", MESSAGE_ID, body, null);
+
+        assertEquals(200, r.getCode());
+        assertFalse(Boolean.TRUE.equals(r.getData()));
+        verify(mapper, never()).updateById(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("异常流:reply 目标消息不存在 → service 返回 false 且不写库")
+    void reply_missingMessage_returnsFalseNoPersist() {
+        when(guard.assertCertified(TEACHER_ID, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(null);
+        when(mapper.selectById(MESSAGE_ID)).thenReturn(null);
+
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+        Result<Boolean> r = controller.replyMessage(TEACHER_ID, "teacher", MESSAGE_ID, body, null);
+
+        assertFalse(Boolean.TRUE.equals(r.getData()));
+        verify(mapper, never()).updateById(any(TeacherMessage.class));
+    }
+
+    // ==================================================================
+    // 权限流:非 teacher 角色拒绝 / 认证闸门拒绝
+    // ==================================================================
+
+    @Test
+    @DisplayName("权限流:reply 非 teacher 角色 → error『只有成长规划师可以回复』且不调闸门/不落库")
+    void reply_nonTeacherRole_rejected() {
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+
+        Result<Boolean> r = controller.replyMessage(TEACHER_ID, "parent", MESSAGE_ID, body, null);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("只有成长规划师"));
+        verify(guard, never()).assertCertified(anyLong(), anyString());
+        verify(mapper, never()).updateById(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("权限流:reply 认证闸门拒绝 → 透传闸门 code/message 且不调 service")
+    void reply_guardDenied_propagatesAndSkipsService() {
+        when(guard.assertCertified(TEACHER_ID, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(Result.error(403, "规划师未完成成长规划师基础认证,暂不可提供服务"));
+
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+        Result<Boolean> r = controller.replyMessage(TEACHER_ID, "teacher", MESSAGE_ID, body, null);
+
+        assertEquals(403, r.getCode());
+        assertTrue(r.getMessage().contains("规划师未完成"));
+        verify(mapper, never()).selectById(anyLong());
+        verify(mapper, never()).updateById(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("权限流:list 非 teacher 角色 → error『只有成长规划师可以查看』且不调 service")
+    void list_nonTeacherRole_rejected() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("page", 1);
+        params.put("size", 10);
+
+        Result<?> r = controller.getMessages(TEACHER_ID, "parent", params, null);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("只有成长规划师"));
+        verify(mapper, never()).selectPage(any(), any());
+    }
+
+    @Test
+    @DisplayName("权限流:unread-count 非 teacher 角色 → error 且不调 service")
+    void unreadCount_nonTeacherRole_rejected() {
+        Result<Long> r = controller.getUnreadCount(TEACHER_ID, "parent", null);
+
+        assertEquals(500, r.getCode());
+        verify(mapper, never()).selectCount(any());
+    }
+
+    // ==================================================================
+    // 正常流:list / unread-count / my
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流:list teacher 角色 → 返回 records/total 分页结构")
+    void list_teacherRole_returnsPagedStructure() {
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<TeacherMessage> page =
+                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(1, 10);
+        page.setRecords(Collections.singletonList(buildMessage(TEACHER_ID)));
+        page.setTotal(1);
+        when(mapper.selectPage(any(), any())).thenReturn(page);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("page", 1);
+        params.put("size", 10);
+
+        Result<?> r = controller.getMessages(TEACHER_ID, "teacher", params, null);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        Map<?, ?> data = (Map<?, ?>) r.getData();
+        assertTrue(data.containsKey("records"));
+        assertTrue(data.containsKey("total"));
+        assertTrue(data.containsKey("pages"));
+        assertTrue(data.containsKey("current"));
+    }
+
+    @Test
+    @DisplayName("正常流:unread-count teacher 角色 → 返回未读数")
+    void unreadCount_teacherRole_returnsCount() {
+        when(mapper.selectCount(any())).thenReturn(3L);
+
+        Result<Long> r = controller.getUnreadCount(TEACHER_ID, "teacher", null);
+
+        assertEquals(200, r.getCode());
+        assertEquals(3L, r.getData());
+    }
+
+    @Test
+    @DisplayName("正常流:my → 回填规划师昵称(teacherName)")
+    void my_fillsTeacherName() {
+        TeacherMessage msg = buildMessage(TEACHER_ID);
+        when(mapper.selectList(any())).thenReturn(Collections.singletonList(msg));
+        User teacher = new User();
+        teacher.setId(TEACHER_ID);
+        teacher.setNickname("王老师");
+        when(userMapper.selectById(TEACHER_ID)).thenReturn(teacher);
+
+        Result<?> r = controller.getMyMessages(PARENT_ID);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertNotNull(((java.util.List<?>) r.getData()).get(0));
+        TeacherMessage m = (TeacherMessage) ((java.util.List<?>) r.getData()).get(0);
+        assertEquals("王老师", m.getTeacherName());
+    }
+
+    // ==================================================================
+    // 结构契约:端点映射 / 实体字段 / 建表
+    // ==================================================================
+
+    @Test
+    @DisplayName("结构契约:TeacherMessageController 暴露 send/reply/list/unread-count/my 五个端点")
+    void controller_exposesContractEndpoints() {
+        Map<String, Boolean> found = new HashMap<>();
+        found.put("send", false);
+        found.put("reply", false);
+        found.put("list", false);
+        found.put("unread-count", false);
+        found.put("my", false);
+        for (Method m : TeacherMessageController.class.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            for (String p : pm.value()) {
+                for (String key : found.keySet()) {
+                    if (p != null && p.contains(key)) {
+                        found.put(key, true);
+                    }
+                }
+            }
+        }
+        for (Map.Entry<String, Boolean> e : found.entrySet()) {
+            assertTrue(e.getValue(),
+                    "[teacher.message.push] TeacherMessageController 缺少端点包含 '" + e.getKey()
+                            + "':契约 §4.57 SC-T-19 要求 send/reply/list/unread-count/my 五端点。");
+        }
+    }
+
+    @Test
+    @DisplayName("结构契约:UserNotificationController 暴露 /api/notification/* 基础路径 + list/read")
+    void notificationController_exposesContractEndpoints() {
+        RequestMapping base = UserNotificationController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(base, "[teacher.message.push] UserNotificationController 缺少 @RequestMapping。");
+        assertTrue(base.value()[0].equals("/api/notification"),
+                "[teacher.message.push] UserNotificationController 基础路径应为 /api/notification,实际 "
+                        + base.value()[0]);
+
+        boolean hasList = false;
+        boolean hasRead = false;
+        for (Method m : UserNotificationController.class.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            for (String p : pm.value()) {
+                if (p != null && p.contains("list")) {
+                    hasList = true;
+                }
+                if (p != null && p.contains("read")) {
+                    hasRead = true;
+                }
+            }
+        }
+        assertTrue(hasList, "[teacher.message.push] UserNotificationController 缺少 /list 端点。");
+        assertTrue(hasRead, "[teacher.message.push] UserNotificationController 缺少 /read 端点。");
+    }
+
+    @Test
+    @DisplayName("结构契约:TeacherMessage 实体应承载消息推送核心字段")
+    void entity_carriesContractFields() {
+        assertTrue(hasField("familyId"), "[teacher.message.push] TeacherMessage 缺 familyId。");
+        assertTrue(hasField("parentId"), "[teacher.message.push] TeacherMessage 缺 parentId。");
+        assertTrue(hasField("teacherId"), "[teacher.message.push] TeacherMessage 缺 teacherId。");
+        assertTrue(hasField("content"), "[teacher.message.push] TeacherMessage 缺 content。");
+        assertTrue(hasField("replyContent"), "[teacher.message.push] TeacherMessage 缺 replyContent。");
+        assertTrue(hasField("status"), "[teacher.message.push] TeacherMessage 缺 status(pending/replied)。");
+        assertTrue(hasField("createdAt"), "[teacher.message.push] TeacherMessage 缺 createdAt。");
+        assertTrue(hasField("repliedAt"), "[teacher.message.push] TeacherMessage 缺 repliedAt。");
+    }
+
+    @Test
+    @DisplayName("结构契约:schema.sql 应落地 teacher_messages 与 user_notifications 建表")
+    void schemaSql_containsContractTables() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("teacher_messages"),
+                "[teacher.message.push] schema.sql 缺少 teacher_messages 建表(§4.57 SC-T-19)。");
+        assertTrue(schema.contains("user_notifications"),
+                "[teacher.message.push] schema.sql 缺少 user_notifications 建表(§4.57 通知载体)。");
+    }
+
+    // ==================================================================
+    // 辅助构造
+    // ==================================================================
+
+    private TeacherMessage buildMessage(Long teacherId) {
+        TeacherMessage m = new TeacherMessage();
+        m.setId(MESSAGE_ID);
+        m.setFamilyId(FAMILY_ID);
+        m.setParentId(PARENT_ID);
+        m.setTeacherId(teacherId);
+        m.setContent(CONTENT);
+        m.setStatus("pending");
+        return m;
+    }
+
+    private void inject(Object target, String fieldName, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private boolean hasField(String name) {
+        for (Field f : TeacherMessage.class.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}

+ 257 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherMindRecordContractTest.java

@@ -0,0 +1,257 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:规划师舌诊/情绪测评录入(能力键 {@code teacher.mind.record},SC-T-25)。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>需求:sc-key-map.md SC-T-25「舌诊/情绪测评录入(规划师)」(成长规划师域,EVD-10)</li>
+ *   <li>契约:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} §2(SC-T-25 端点核验)
+ *       + §5.5(舌诊链入库语义:parse-preview{@code type=tongue} → confirm{@code type=tongue} 入库,
+ *       <b>非 tongue 类型不落入舌诊链</b>)</li>
+ *   <li>设计:API_REFERENCE §4.57 建议能力键映射 {@code teacher.mind.record}(T-25)——“统一
+ *       {@code MindAssessmentController} 舌诊/情绪测评录入(规划师)”;模块结构参考
+ *       {@code docs/backup/superpowers/plans/2026-06-02-care-family-club-design.md}(mind 域
+ *       {@code controller/mind/MindAssessmentController} + {@code service/MindAssessmentService}
+ *       + {@code entity/MindPsychologicalAssessment},设计方法
+ *       {@code createAssessment(assessment)} 插入入库、{@code getAssessments(...)} 按
+ *       {@code assessmentDate} 倒序)</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(统一录入链路,规划师代客户录入)</b></p>
+ * <ul>
+ *   <li>{@code MindAssessmentController}(package {@code controller.mind})应作为规划师端
+ *       舌诊/情绪测评录入统一入口:提供 {@code create}(代录,受评客户按 memberId 归属)与
+ *       {@code list}(按客户成员查看)端点;</li>
+ *   <li>{@code MindAssessmentService} 应桥接既有双类载体(舌诊链 {@code TongueDiagnosisService}
+ *       + 情绪链 {@code EmotionCheckinService}),提供 {@code createAssessment} 入库与
+ *       {@code getAssessments} 查询;</li>
+ *   <li>{@code MindPsychologicalAssessment} 实体应含受评客户归属({@code memberId},遵循
+ *       用户模型统一口径,不用 userId/childId)、录入规划师归属({@code guideId})、测评类型
+ *       ({@code assessmentType} = tongue/emotion 双类)、状态机({@code status},默认
+ *       {@code draft} → {@code confirmed},对齐舌诊链)与测评时间({@code assessmentDate})。</li>
+ * </ul>
+ *
+ * <p><b>实现现状</b>:本分支 src/main 中 {@code MindAssessmentController} /
+ * {@code MindAssessmentService} / {@code MindPsychologicalAssessment} 均<b>未落地</b>
+ * (三者全库 0 引用,grep 仅命中 backup 设计文档;舌诊/情绪双类现有载体
+ * {@code HealthReportController tongue 链} 与 {@code EmotionCheckinController} 为分散端点,
+ * 尚无统一规划师录入入口)。因此本测试以「反射结构契约」探测实现缺失:能编译、能运行、
+ * 断言失败(红),失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者按设计落地 {@code MindAssessmentController}(create/list)、
+ * {@code MindAssessmentService}(createAssessment/getAssessments)、
+ * {@code MindPsychologicalAssessment} 实体(含 memberId/guideId/assessmentType/status/
+ * assessmentDate 字段,status 默认 draft)后,本测试自动转绿;届时可将结构探测升级为
+ * 行为级 Mockito 验证(verify 入库 + state 流转 + 非 tongue 不落舌诊链)。</p>
+ *
+ * <p><b>权限流说明</b>:规划师(role=teacher)角色隔离由 {@code JwtInterceptor} +
+ * 既有 SC-T-13「规划师视角数据隔离」契约(guide_families isBound)覆盖,本键专注统一录入
+ * 链路自身契约,权限流以说明性用例标注「不适用」,不重复断言。</p>
+ *
+ * <p><b>RED 预期</b>:三个契约宿主类均未实现,全部用例在类探测/方法探测/字段探测处失败(红),
+ * 断言原文为「[teacher.mind.record] ... 未实现(实现未落地)」及同类文案。</p>
+ */
+class TeacherMindRecordContractTest {
+
+    /** 统一录入控制器(设计锚点:controller/mind 包,API_REFERENCE §4.57)。 */
+    private static final String HOST_CONTROLLER = "com.etotem.cfc.controller.mind.MindAssessmentController";
+
+    /** 统一录入服务(桥接 TongueDiagnosisService 舌诊链 + EmotionCheckinService 情绪链)。 */
+    private static final String HOST_SERVICE = "com.etotem.cfc.service.MindAssessmentService";
+
+    /** 心理测评记录实体(含受评客户/录入规划师归属与状态机)。 */
+    private static final String HOST_ENTITY = "com.etotem.cfc.entity.MindPsychologicalAssessment";
+
+    // ------------------------------------------------------------------
+    // 正常流:统一录入控制器存在并提供 代录/查看 端点
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:MindAssessmentController 类应存在(规划师舌诊/情绪测评录入统一入口)")
+    void controller_mindAssessmentController_classExists() {
+        assertNotNull(loadClass(HOST_CONTROLLER),
+                "[teacher.mind.record] MindAssessmentController 类不存在(controller/mind 包):"
+                        + "契约要求规划师端舌诊/情绪测评录入统一入口(API_REFERENCE §4.57 / EVD-10 SC-T-25)。"
+                        + "实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:MindAssessmentController 应提供 create 方法(规划师代录,受评客户按 memberId 归属)")
+    void controller_createEndpoint_exists() {
+        Class<?> clazz = loadClass(HOST_CONTROLLER);
+        if (clazz == null) {
+            fail("[teacher.mind.record] MindAssessmentController 类不存在,无法验证 create 代录入口。");
+        }
+        assertTrue(hasMethod(clazz, "create"),
+                "[teacher.mind.record] MindAssessmentController 应提供 create 方法:"
+                        + "规划师代客户录入舌诊/情绪测评(受评客户按 memberId 归属,不落 userId/childId)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:MindAssessmentController 应提供 list 方法(规划师按客户成员查看测评记录)")
+    void controller_listEndpoint_exists() {
+        Class<?> clazz = loadClass(HOST_CONTROLLER);
+        if (clazz == null) {
+            fail("[teacher.mind.record] MindAssessmentController 类不存在,无法验证 list 查看入口。");
+        }
+        assertTrue(hasMethod(clazz, "list"),
+                "[teacher.mind.record] MindAssessmentController 应提供 list 方法:"
+                        + "规划师按受评客户成员查看舌诊/情绪测评记录。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:统一录入服务存在并提供 入库/查询 能力
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-4:MindAssessmentService 类应存在(统一录入服务,桥接舌诊链 + 情绪链)")
+    void service_mindAssessmentService_classExists() {
+        assertNotNull(loadClass(HOST_SERVICE),
+                "[teacher.mind.record] MindAssessmentService 类不存在:契约要求统一录入服务"
+                        + "桥接 TongueDiagnosisService 舌诊链 + EmotionCheckinService 情绪链"
+                        + "(API_REFERENCE §4.57 / EVD-10 SC-T-25)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:MindAssessmentService 应提供 createAssessment 方法(测评记录入库)")
+    void service_createAssessment_methodExists() {
+        Class<?> clazz = loadClass(HOST_SERVICE);
+        if (clazz == null) {
+            fail("[teacher.mind.record] MindAssessmentService 类不存在,无法验证 createAssessment 入库。");
+        }
+        assertTrue(hasMethod(clazz, "createAssessment"),
+                "[teacher.mind.record] MindAssessmentService 应提供 createAssessment(...) 方法:"
+                        + "测评记录入库(设计 createAssessment(assessment) → mapper.insert)。");
+    }
+
+    @Test
+    @DisplayName("正常流-6:MindAssessmentService 应提供 getAssessments 方法(按 assessmentDate 倒序查询)")
+    void service_getAssessments_methodExists() {
+        Class<?> clazz = loadClass(HOST_SERVICE);
+        if (clazz == null) {
+            fail("[teacher.mind.record] MindAssessmentService 类不存在,无法验证 getAssessments 查询。");
+        }
+        assertTrue(hasMethod(clazz, "getAssessments"),
+                "[teacher.mind.record] MindAssessmentService 应提供 getAssessments(...) 方法:"
+                        + "按受评客户成员查询测评记录,orderByDesc(assessmentDate)(设计口径)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:测评记录实体承载归属/类型/状态机(含默认值契约)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:MindPsychologicalAssessment 实体应存在(心理测评记录载体)")
+    void entity_mindPsychologicalAssessment_classExists() {
+        assertNotNull(loadClass(HOST_ENTITY),
+                "[teacher.mind.record] MindPsychologicalAssessment 实体不存在:契约要求测评记录实体"
+                        + "承载归属、类型与状态机(设计 mind 域实体)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:实体应含 memberId 字段(受评客户归属,用户模型统一口径)")
+    void entity_hasMemberIdField() {
+        assertField(HOST_ENTITY, "memberId",
+                "实体应含 memberId 字段:受评客户家庭成员归属(遵循用户模型统一口径,"
+                        + "禁止回退 userId/childId 表达受评主体)");
+    }
+
+    @Test
+    @DisplayName("边界流-3:实体应含 guideId 字段(录入规划师归属,代录链路)")
+    void entity_hasGuideIdField() {
+        assertField(HOST_ENTITY, "guideId",
+                "实体应含 guideId 字段:录入规划师(role=teacher)归属,代录链路审计必备");
+    }
+
+    @Test
+    @DisplayName("边界流-4:实体应含 assessmentType 字段(tongue/emotion 双类,SC-T-25)")
+    void entity_hasAssessmentTypeField() {
+        assertField(HOST_ENTITY, "assessmentType",
+                "实体应含 assessmentType 字段:测评类型 tongue(舌诊)/ emotion(情绪)双类,"
+                        + "对应 SC-T-25 两类录入;非 tongue 类型不得落入舌诊链(契约 §5.5)");
+    }
+
+    @Test
+    @DisplayName("边界流-5:实体应含 status 字段且新建默认 draft(draft→confirmed 状态机,对齐舌诊链)")
+    void entity_hasStatusFieldWithDefaultDraft() {
+        assertField(HOST_ENTITY, "status",
+                "实体应含 status 字段:测评记录状态机默认 draft(新建未确认)→ confirmed(规划师确认入库),"
+                        + "对齐舌诊链 draft→confirmed 语义(契约 §5.5);默认值未落地即视为缺陷");
+    }
+
+    @Test
+    @DisplayName("边界流-6:实体应含 assessmentDate 字段(测评时间,倒序查询排序键)")
+    void entity_hasAssessmentDateField() {
+        assertField(HOST_ENTITY, "assessmentDate",
+                "实体应含 assessmentDate 字段:测评时间,getAssessments 按此倒序返回(设计口径)");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):角色隔离由 JwtInterceptor + SC-T-13 契约覆盖
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:规划师角色隔离由 JwtInterceptor + SC-T-13 契约覆盖,本键不重复断言(说明性)")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:teacher.mind.record 为规划师代录统一链路契约;访问边界(仅绑定家庭客户可查)
+        // 由 JwtInterceptor 角色隔离 + 既有「teacher.data.isolation」(SC-T-13)契约承担,本键不再重复。
+        assertTrue(true, "权限流不适用:teacher.mind.record 角色隔离由 JwtInterceptor + SC-T-13 契约覆盖。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助(锚定契约宿主,避免编译期依赖不存在类)
+    // ------------------------------------------------------------------
+
+    private void assertField(String fqcn, String fieldName, String contractText) {
+        Class<?> clazz = loadClass(fqcn);
+        if (clazz == null) {
+            fail("[teacher.mind.record] " + simpleName(fqcn) + " 类不存在,无法验证字段 "
+                    + fieldName + ":" + contractText + "。实现未落地。");
+        }
+        assertTrue(hasField(clazz, fieldName),
+                "[teacher.mind.record] " + simpleName(fqcn) + " 应含 " + fieldName + " 字段:"
+                        + contractText + "。");
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private boolean hasMethod(Class<?> clazz, String name) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean hasField(Class<?> clazz, String name) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private String simpleName(String fqcn) {
+        int idx = fqcn.lastIndexOf('.');
+        return idx >= 0 ? fqcn.substring(idx + 1) : fqcn;
+    }
+}

+ 42 - 0
docs/tdd/orchestration/report-deploy-2026-11-13-ca56f8699.md

@@ -0,0 +1,42 @@
+# 部署回执:ca56f8699 → testing(2026-11-13 10:35:43+)
+
+## 结论
+
+**目标提交 `ca56f8699f75d259587af97088d5ffa77f685db0`(feat(membership): implement membership.plan.purchase (S-cfc-176))的改动已真实运行在 testing 环境。**
+
+testing 当前线上版本为 **`dae706c7d569d490b0de359c2eada25b78150846`**(部署链路于 2026-11-13 10:35:43 写入 state),
+其纯净构建源为 `dae706c7d`。`git merge-base --is-ancestor ca56f8699 c0e3028bf` → 成立,
+即目标提交的全部改动已随该轮构建真实打进线上 JAR 并过健康检查,**无需降级重部署**。
+
+## 证据
+
+| 项 | 值 |
+|----|----|
+| 目标提交 | `ca56f8699f75d259587af97088d5ffa77f685db0`(S-cfc-176:实现 `membership.plan.purchase`) |
+| 线上 commit(state) | `dae706c7d569d490b0de359c2eada25b78150846`(10:35:43 写入 state,auto-bump 产物) |
+| 构建源 commit | `dae706c7d`(最新的 origin/cfclub) |
+| 祖先关系 | `git merge-base --is-ancestor ca56f8699 dae706c7d` → 成立(目标改动已包含在当前线上代码中) |
+| 源码一致性 | `git diff ca56f8699..dae706c7d -- cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java` → 0 行(字节一致,目标版本完整进 JAR) |
+| JAR 内容校验 | 线上 JAR 内 `BOOT-INF/classes/com/etotem/cfc/controller/MembershipController.class` 存在 `/plan/purchase` 路径;`BOOT-INF/classes/com/etotem/cfc/service/MembershipService.class` 存在 `purchasePlan` / `findReusablePendingOrder` 等方法(`ca56f8699` 更改被 `S-cfc-179` 修复/增强叠加)。 |
+| 部署时间轴 | 08:15:23 (9afaa6e46 部署) → ... → 10:26:51 (4e1497e16 部署) → 10:35:43 (dae706c7d 部署) |
+| 部署 state | `/home/iwt/.cfc-deploy-state`: `last_commit=dae706c7d...` `backend_jar_md5=5616dcf9f439c94dbe395d0f821dbc25` |
+| 运行进程 | PID 3082677,`java -Xms512m -Xmx1024m -jar /sc-data/springboot/cfc/cfc-backend-1.0.0.jar`,启动于 10:26:19 |
+
+## 健康回执(回执出具时直连探测)
+
+| 目标 | 探测 | 结果 |
+|------|------|------|
+| 后端(部署基线) | `POST http://localhost:9082/api/admin/foods/list` | 401(无 token 预期)✓ |
+| 登录端点(PUBLIC_PATHS 例外) | `POST http://localhost:9082/api/admin-auth/login` | 200 ✓ |
+| 管理端 | `http://localhost/admin/`(Host: cfc.iwintrue.com) | 200 ✓ |
+| 小程序 H5 | `http://localhost/frontend/`(Host: cfc.iwintrue.com) | 200 ✓ |
+
+## 说明
+
+- 按编排要求,本次**未创建/修改** `.workbuddy/tasks.json`,未写 `last_deployed`;
+  `--deploy-done testing --commit dae706c7d`(实际部署成功的 commit hash)由编排侧/负责人在
+  **主分支目录**统一落盘。
+- 部署链路:`/home/iwt/cfc-deploy-251.sh`(origin/cfclub 更新检测 → 纯净 worktree 构建 →
+  JAR 校验 → 停旧进程 → 装新 JAR → 启动健康检查 → 前端/H5 产物部署 → 最终验证)。
+- 目标 `ca56f8699` 为**后端**改动,已包含在当前线上版本 `dae706c7d` 中,
+  其代码已通过 JAR 字节码验证并运行在 testing 环境。

+ 190 - 0
docs/tdd/orchestration/report-dev-2026-11-13-0941.md

@@ -0,0 +1,190 @@
+# 研发负责人工作汇报(第 151 轮)
+
+> 角色:研发负责人(自动化 298dcf47,错峰轮)|周期:2026-11-13 09:41 ~ 10:11|负责范围:CFC 全量(SRS/设计/用户场景实现闭环)
+> 交付物:本报告由单文件 `git hash-object` 旁路推 `origin/cfclub`,**未触碰主工作树 728 处其他会话 WIP**(禁 `add -A`)。
+
+---
+
+## 基本信息
+
+| 项目 | 内容 |
+|------|------|
+| 汇报人 | 研发负责人5(自动化 298dcf47,错峰轮) |
+| 汇报周期 | 2026-11-13 09:41 ~ 2026-11-13 10:11(第 151 轮;上轮 150 由 ce26ad68 错峰轮 @09:30,state.json 更新于 09:24:55,距本轮启动 >5min,互斥放行) |
+| 负责模块/单元范围 | CFC 全量(cfc-backend / cfc-frontend / cfc-web / danshop / cfc-langgraph) |
+
+---
+
+## 一、本轮工作数据汇总
+
+### 1.1 问题接收与处置
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **收到的测试反馈问题数** | `0` 个 | 本轮 `--inspect` 无已登记阻塞(`docs/tdd/test/blockers.md` 无新增);测试侧 5 失败均为 P-2 MapContract 预期数据偏差(测试侧口径,非研发回归) |
+| **已解决问题数** | `0` 个 | 无新增研发侧问题;瓶颈仍在测试 YELLOW→GREEN 校验 |
+| **待解决研发问题数** | `0` 个 | 目标池 0 阻塞;17 个 RED 全部已派发在途 |
+
+### 1.2 任务分发与会话管理
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **分派任务数** | `15` 个 | 续派上轮 pending 的 7 个(S-cfc-182~188)+ 新登记并派发 8 个(S-cfc-189~196,T 域 closure 批次),均经 `--auto --role 开发 --limit N` 派发至 5 个 opencode 实例 |
+| **恢复智能体会话数** | `0` 个 | `--resume --dry-run` 识别 2 个 mid-work 候选(mes-local-45278「电脑频繁重启」、pub-iwintrue「TDD 部署 testing」),均非本轮回合新增中断;不强制恢复以免干扰其他错峰轮 |
+| **当前在用会话数** | `17` 个 | 17 个 RED 契约任务全部 dispatched(TDD-RED 在途写契约测试) |
+| **会话平均利用率** | `满(双侧填满)` % | 任务账本 dispatched=17 / pending=0 / done=179 / failed=1;0 空闲任务,符合「双侧填满才收敛」 |
+
+### 1.2b 各会话当前工作状态列表
+
+| 会话标签 | 当前状态 | 当前任务 | 备注 |
+|----------|----------|----------|------|
+| lan-189 | `running` | S-cfc-191 / S-cfc-195 | 本轮新派发 TDD-RED |
+| lan-204 | `running` | S-cfc-196 | 本轮新派发 TDD-RED |
+| local-lan-4090 | `running` | S-cfc-189 / S-cfc-192 | 本轮新派发 TDD-RED |
+| mes-local-45278 | `running` | S-cfc-190 / S-cfc-193 | 本轮新派发 TDD-RED(已知 flaky,已派工) |
+| pub-iwintrue | `running` | S-cfc-194 | 本轮新派发 TDD-RED |
+| production(ses_f719c9326) | `idle` | — | 生产部署会话,registered |
+| testing(ses_e4804838) | `idle` | — | 测试部署会话,registered |
+
+> 5 个 opencode 实例全部 busy/running、interrupted=0、配额充足(剩余 6~9)。
+
+### 1.3 变更感知与新任务建立
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **感知需求/设计变更数** | `235` 文件 | 近 7 天 `--changes`:需求/设计 126 + 测试用例 35 + 其他 86(2026-11-11~11-13);**新增能力键 0** |
+| **新增入池任务数** | `8` 个 | 池空续派(按用户指令「根据优先级设计新任务」):T 域 closure 批次 S-cfc-189~196(见附录 C);另续派上轮 pending S-cfc-182~188(7 个)已于本轮派发 |
+| **既有键状态流转数** | `+8` 键 | 状态机 95 → 104 键:新增 8 个 RED 契约键(teacher.login.switch / family.bind / message.push / nutrition.plan / client.export / dimension.score / mind.record / plan.create);YELLOW 72 / GREEN 8 / DONE 15 零偏移 |
+
+### 1.4 本轮 git 提交与推送证据(真理证据)
+
+| 提交 sha | 关联任务/能力键 | 阶段 | 触及文件类型 | 目标分支 | 已推送远端 | 备注 |
+|----------|-----------------|------|--------------|----------|------------|------|
+| 72 个 YELLOW 键 yellow_sha | 见状态机 | YELLOW | 实现/契约 | cfclub | 是 | `merge-base --is-ancestor` 66 IN_CHAIN / 0 NOT_IN_CHAIN / 6 NO_SHA(O22 豁免键 red_sha 均 IN_CHAIN,实现真实性门禁通过) |
+| 17 个 RED 键(S-cfc-180~196) | teacher.* / family.family.autoCreateForPurchase | RED(契约在途) | — | 各实例 worktree | 在途 | 本轮全部派发,待会话写契约测试 + push 后回填 red_sha |
+
+> 实现真实性核验:104 键中 RED 17(T 域契约待产出)、YELLOW 72(66 已填 yellow_sha + 6 O22 豁免)、GREEN 8、DONE 15。
+
+### 1.5 状态机阶段分布与部署状态
+
+| 阶段 | 键数 | 备注 |
+|------|------|------|
+| RED | `17` | 本轮新增 8(S-cfc-189~196)+ 上轮 9(S-cfc-180~188,T 域 8 + family.family.autoCreateForPurchase 1),全部 dispatched 在途 |
+| YELLOW | `72` | 待测试校验确认(66 已填 yellow_sha + 6 O22 豁免) |
+| GREEN | `8` | achievement.grid.fiveCol / admin.auth.login / dan.assessment.getActiveMaterial / growth.history.view / health.report.createReport / health.report.upload 等 |
+| DONE | `15` | auth.guest.jwtGuard / auth.guest.wechatLogin / dan.report.parseFormat / dimension.registry.getTree 等 |
+
+| 部署项 | 值 | 说明 |
+|--------|-----|------|
+| 本轮部署次数 | `≥30` 次 | `--auto` 自动派发 YELLOW 键 testing 部署验证(family.familyMembers.removeMember 等) |
+| pending / confirmed | `63` / `77` | 本轮 YELLOW 自动验证扩列(较 150 轮 19 增 44,归 O-24 测试/部署侧收口) |
+| GREEN 部署链验证 | 部分待收口 | last_deployed 2e1e5d32ef(tasks.json 快照 a1ead33a1),双账本差异延续,归部署侧 |
+
+### 1.6 数据源健康度
+
+| 数据源 | 读取键数 | 是否正常 | 备注 |
+|--------|----------|----------|------|
+| 需求侧(⏳) | `0` | 正常(稳态) | 无新缺口键 |
+| 契约常量(契约) | `0` | 正常 | — |
+| 测试用例引用(用例∩⏳) | `0` | 正常 | ⏳∩用例引用=0 |
+| 执行结果回填(case_results) | `0` | 正常 | — |
+
+> **场景覆盖矩阵(增量)**:SC 台账 241 = 已跑通 15 + 有键未跑通 18 + 无键未契约化 208(P 域 80 / T 域 0(本轮 closure)/ A 24 / C 25 / X 22 / B 10 / S 8 / H 7)。**本轮增量 = T 域(规划师)16 能力键全部入池并派发**(S-cfc-180~187 + S-cfc-189~196),覆盖 SC-T-01~32 中 29 个(剩余 14/15/22 方案编辑/套餐任务项/方案归档 3 个 SC 待下一批);**P 域 80 无键 SC 为下一主攻方向(健康报告/DAN 预约/AI 方案/心愿/能量归因集群)**。15/241 无漂移,241/241 前不得报开发完成。
+
+---
+
+## 二、下一步需要研发处理的事项
+
+| 序号 | 事项描述 | 依赖/前置条件 | 预估完成时间 | 优先级 | 状态 |
+|------|----------|---------------|--------------|--------|------|
+| 1 | 17 个 RED T 域契约任务产出核验(S-cfc-180~196)→ 回填 red_sha → 交测试侧 mark-red 三类流 | 会话 push 证据 | 持续 | P1 | 进行中(全部在途) |
+| 2 | 推动 72 个 YELLOW 键测试 GREEN 校验 + 241 用户场景三类流证据(O-7) | 测试团队协同 | 持续 | P1 | 进行中(瓶颈在测试侧) |
+| 3 | 63 条 pending 部署经 `--deploy-done testing` 收口(O-24) | 部署会话回执 | 持续 | P2 | 进行中 |
+| 4 | T 域 closure 收尾:剩余 3 个 SC(T-14/15/22 方案编辑/套餐任务项/方案归档)补键入池 | sc-key-map + API_REFERENCE 锚点 | 下轮 | P2 | 待办 |
+| 5 | **P 域 80 无键 SC 契约批次设计**(健康报告/DAN 预约/AI 方案/心愿/能量归因)——需参照 EVD-10 模式立项 EVD 契约文档后批量入池 | 主理人确认 P 域契约锚点 | 评估后 | P1 | 待主理人确认 |
+
+---
+
+## 三、阻塞与风险
+
+| 阻塞项 | 影响范围 | 阻塞类型 | 处置进展 | 需要协调 |
+|--------|----------|----------|----------|----------|
+| 主工作树 728 处他人 WIP(非本轮产生) | 主分支目录 | 研发(环境) | 已隔离:全程临时索引/单文件 hash-object 旁路,零接触 | 无需 |
+| mes-local-45278 离线/重启风险 | 部署/测试会话 | 测试 | 已派工 S-cfc-190/193,若节点 flaky 失败则下轮改派 lan-189/pub-iwintrue | 无需 |
+| 测试侧 5 失败(P-2 MapContract 预期数据偏差) | 测试报表 | 测试(口径) | 非研发回归,归测试侧修正预期 | 无需 |
+| P 域 80 无键 SC(健康报告/DAN 预约/AI 方案等) | 241 场景目标 | 需求(覆盖缺口) | T 域已 closure;P 域为下一主攻方向,需主理人确认契约锚点后立项 | 建议主理人确认 |
+
+---
+
+## 四、下轮工作规划
+
+| 计划项 | 预计完成时间 | 前置依赖 | 涉及会话标签 | 备注 |
+|--------|--------------|----------|--------------|------|
+| 错峰巡检(每时45分) | 下一窗口 | state.json 距本轮 >5min | 研发负责人5 | 互斥放行即接管 |
+| 17 个 RED 契约产出核验 | 持续 | 会话 push 证据 | 5 节点 | red_sha 回填 + 转测试 |
+| 续派 T 域 closure 收尾 3 键(T-14/15/22) | 下轮 | sc-key-map 锚点 | 5 节点 | 池双侧填满才收敛 |
+| **P 域契约批次立项(EVD 模式)** | 主理人确认后 | sc-key-map + API_REFERENCE | — | 80 无键 SC 主攻方向 |
+| YELLOW→GREEN 测试协同推进 | 持续 | 测试团队 | 测试负责人 | 72 YELLOW 校验 |
+
+---
+
+## 附录
+
+### 附录 A:收到的测试反馈问题明细
+
+| 问题 ID | 能力键/任务号 | 问题类型 | 来源 | 接收时间 | 处置状态 | 关联 sha |
+|---------|---------------|----------|------|----------|----------|----------|
+| 无 | — | — | — | — | — | — |
+
+### 附录 B:任务分派与会话恢复明细
+
+| 任务号 | 目标会话标签 | 任务类型 | 分派时间 | 当前状态 | 恢复会话标签 | 恢复原因 | 恢复时间 |
+|--------|--------------|----------|----------|----------|--------------|----------|----------|
+| S-cfc-182 | lan-189 | RED(契约) | 2026-11-13 09:41 | dispatched/在途 | — | — | — |
+| S-cfc-183 | lan-204 | RED(契约) | 2026-11-13 09:41 | dispatched/在途 | — | — | — |
+| S-cfc-184 | lan-204 | RED(契约) | 2026-11-13 09:46 | dispatched/在途 | — | — | — |
+| S-cfc-185 | local-lan-4090 | RED(契约) | 2026-11-13 09:46 | dispatched/在途 | — | — | — |
+| S-cfc-186 | mes-local-45278 | RED(契约) | 2026-11-13 09:46 | dispatched/在途 | — | — | — |
+| S-cfc-187 | lan-189 | RED(契约) | 2026-11-13 09:46 | dispatched/在途 | — | — | — |
+| S-cfc-188 | lan-204 | RED(契约) | 2026-11-13 09:46 | dispatched/在途 | — | — | — |
+| S-cfc-189 | local-lan-4090(ses_e2d54b22dffeKXjM20uGaxFUn9) | RED(契约) | 2026-11-13 10:08 | dispatched/在途 | — | — | — |
+| S-cfc-190 | mes-local-45278(ses_e2d54aee0ffeIyrUNA8ltoHiFR) | RED(契约) | 2026-11-13 10:08 | dispatched/在途 | — | — | — |
+| S-cfc-191 | lan-189(ses_e2d546641ffeh65W9zDhVVJOL8) | RED(契约) | 2026-11-13 10:08 | dispatched/在途 | — | — | — |
+| S-cfc-192 | local-lan-4090(ses_e2d54381fffeF3pnuFL0R2ZM1T) | RED(契约) | 2026-11-13 10:08 | dispatched/在途 | — | — | — |
+| S-cfc-193 | mes-local-45278(ses_e2d543d85ffeD1X3dzW2OyD0Ei) | RED(契约) | 2026-11-13 10:08 | dispatched/在途 | — | — | — |
+| S-cfc-194 | pub-iwintrue(ses_e2d53ffb1ffeiit1SiRhur7WuX) | RED(契约) | 2026-11-13 10:08 | dispatched/在途 | — | — | — |
+| S-cfc-195 | lan-189(ses_e2d53cd5dffeB0VL4QwmEd9lPi) | RED(契约) | 2026-11-13 10:08 | dispatched/在途 | — | — | — |
+| S-cfc-196 | lan-204(ses_e2d538c6dffepSIPcJnyp9gU0f) | RED(契约) | 2026-11-13 10:08 | dispatched/在途 | — | — | — |
+
+### 附录 C:新增入池任务明细
+
+| 任务号 | 能力键 | 需求溯源 | 设计溯源 | 创建时间 | 状态 |
+|--------|--------|----------|----------|----------|------|
+| S-cfc-189 | teacher.login.switch | sc-key-map SC-T-01/SC-T-20;EVD-10 契约(77026df42) | API_REFERENCE §4.57 GuideAuthController 规划师登录/角色切换 | 2026-11-13 10:05 | dispatched |
+| S-cfc-190 | teacher.family.bind | sc-key-map SC-T-02/SC-T-24;EVD-10 契约(77026df42) | API_REFERENCE §4.57 GuideFamilyController 绑定/解绑/重分配 | 2026-11-13 10:05 | dispatched |
+| S-cfc-191 | teacher.message.push | sc-key-map SC-T-19;EVD-10 契约(77026df42) | API_REFERENCE §4.57 MessageController 规划师→客户消息推送 | 2026-11-13 10:05 | dispatched |
+| S-cfc-192 | teacher.nutrition.plan | sc-key-map SC-T-26;EVD-10 契约(77026df42) | API_REFERENCE §4.57 NutritionPlanController 精准营养方案制定 | 2026-11-13 10:05 | dispatched |
+| S-cfc-193 | teacher.client.export | sc-key-map SC-T-29;EVD-10 契约(77026df42) | API_REFERENCE §4.57 FamilyExportController 客户档案数据导出(合规) | 2026-11-13 10:05 | dispatched |
+| S-cfc-194 | teacher.dimension.score | sc-key-map SC-T-30/SC-T-31/SC-T-32;EVD-10 契约(77026df42) | API_REFERENCE §4.57 DimensionScoreController 五维变化录入/打分 | 2026-11-13 10:05 | dispatched |
+| S-cfc-195 | teacher.mind.record | sc-key-map SC-T-25;EVD-10 契约(77026df42) | API_REFERENCE §4.57 MindAssessmentController 舌诊/情绪测评录入 | 2026-11-13 10:05 | dispatched |
+| S-cfc-196 | teacher.plan.create | sc-key-map SC-T-08;EVD-10 契约(77026df42) | API_REFERENCE §4.57 GuidePlanController 成长计划/套餐创建 | 2026-11-13 10:05 | dispatched |
+
+### 附录 D:变更感知明细(来自 `--changes`)
+
+| 变更文档 | 文档类型 | 变更日期 | 新增能力键 | 是否已入池 |
+|----------|----------|----------|------------|------------|
+| 近 7 天 235 文件(需求/设计 126 + 用例 35 + 其他 86) | 需求/设计/用例 | 2026-11-11~11-13 | 0 | 是(既有键状态变化,无需入池) |
+
+---
+
+## 主理人裁决(自裁)
+
+- O-1~O-24 全部已裁决生效(pending.md 状态列均已裁决),本轮**无新增待裁决项**。
+- **裁决 1(T 域 closure 续派)**:执行用户指令「当任务池没有新的任务时,根据优先级设计新任务」——上轮 pending 的 6 个 teacher.* 契约任务(S-cfc-182~187)+ family.family.autoCreateForPurchase(S-cfc-188)本轮全部派发;并新登记 T 域 closure 批次 8 键(S-cfc-189~196,覆盖 SC-T-01~32 中 29 个),使 **T 域 16 能力键全部入池并 dispatched(dispatched=17 / pending=0)**,双侧填满。
+- **裁决 2(P 域契约批次建议)**:剩余最大覆盖缺口为 **P 域 80 无键 SC**(健康报告/DAN 预约/AI 方案/心愿/能量归因集群),需参照 EVD-10 模式立项 EVD 契约文档(锚定 API_REFERENCE §4.57 + 各 Controller)后批量入池;建议主理人确认 P 域契约锚点,下轮启动(不擅自扩 241 基线,维持 O-14 口径)。
+- O-22 豁免键保持 done 不重派(red_sha 均 IN_CHAIN,实现真实);O-18 稳态「不凭空造开发任务」口径延续——本轮回填的 8 键均为已识别的场景缺口(sc-key-map 无键 SC),非凭空造任务。
+
+---
+
+**模板版本**:v1.1|**适用对象**:研发负责人 / 开发角色会话
+**汇报生成**:自动化 298dcf47(错峰轮)· 2026-11-13 10:11

+ 176 - 0
docs/tdd/orchestration/report-dev-2026-11-13-1051.md

@@ -0,0 +1,176 @@
+# 研发负责人工作汇报(第 152 轮)
+
+> 角色:研发负责人(自动化 05193ba0,错峰轮)|周期:2026-11-13 10:18 ~ 10:51|负责范围:CFC 全量(SRS/设计/用户场景实现闭环)
+> 交付物:本报告由单文件 `git hash-object` 旁路推 `origin/cfclub`,**未触碰主工作树 730 处其他会话 WIP**(禁 `add -A`)。
+
+---
+
+## 基本信息
+
+| 项目 | 内容 |
+|------|------|
+| 汇报人 | 研发负责人(自动化 05193ba0,错峰轮) |
+| 汇报周期 | 2026-11-13 10:18 ~ 2026-11-13 10:51(第 152 轮;上轮 151 由自动化 298dcf47 错峰轮 @10:11 收口,state.json 更新于 10:11:20,距本轮启动 >5min,**互斥放行**;sleep 45s 复查仍 10:11:20 无持续写入) |
+| 负责模块/单元范围 | CFC 全量(cfc-backend / cfc-frontend / cfc-web / danshop / cfc-langgraph) |
+
+---
+
+## 一、本轮工作数据汇总
+
+### 1.1 问题接收与处置
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **收到的测试反馈问题数** | `0` 个 | 本轮 `--inspect` 0 已登记阻塞(`docs/tdd/test/blockers.md` 无新增);测试侧无研发类阻塞回流 |
+| **已解决问题数** | `0` 个 | 无新增研发侧问题;YELLOW→GREEN 校验仍瓶颈在测试侧 |
+| **待解决研发问题数** | `0` 个 | 目标池 0 阻塞;17 个 RED 全部已派发在途(非研发实现缺口) |
+
+### 1.2 任务分发与会话管理
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **分派任务数** | `0` 个(本回合) | 17 个 RED 契约任务(S-cfc-180~196)已由他轮错峰 / 测试侧完成派发(09:24~10:11),5 节点全挂 session 在途;本轮仅做**核验巡检**,不重复派发(防双会话) |
+| **恢复智能体会话数** | `0` 个 | `--resume --dry-run` 0 候选;prod-etotem 生产跳过(默认) |
+| **当前在用会话数** | `17` 个 | 17 个 RED 契约任务全部 dispatched 在途 |
+| **会话平均利用率** | `满(双侧填满)` % | 任务账本 dispatched=17 / pending=0 / done=179 / failed=1;0 空闲任务 |
+
+### 1.2b 各会话当前工作状态列表
+
+| 会话标签 | 当前状态 | 当前任务 | 备注 |
+|----------|----------|----------|------|
+| lan-189 | `busy=3` | S-cfc-180 / 182 / 183 / 187 / 191 / 195 | 在途 RED 契约 |
+| lan-204 | `busy=3` | S-cfc-181 / 184 / 188 / 196 | 在途 RED 契约 |
+| local-lan-4090 | `busy=2` | S-cfc-185 / 189 / 192 | 在途 RED 契约 |
+| mes-local-45278 | `busy=4` | S-cfc-186 / 190 / 193 | 在途 RED 契约(flaky 已派工) |
+| pub-iwintrue | `busy=4` | S-cfc-194 | 在途 RED 契约 |
+| production(ses_f719c9326) | `idle` | — | 生产部署会话,registered |
+| testing(ses_e4804838) | `idle` | — | 测试部署会话,registered |
+
+> 5 个 opencode 实例全部 busy>0、interrupted=0、配额充足(剩余 6~8)。
+
+### 1.3 变更感知与新任务建立
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **感知需求/设计变更数** | `235` 文件 | 近 7 天 `--changes`:需求/设计 126 + 测试用例 35 + 其他 86(2026-11-11~11-13);**新增能力键 0** |
+| **新增入池任务数** | `0` 个 | dispatched 17 + pending 0 池满,无空闲;`--changes` 0 新增能力键 → 按 O-7/O-25 防假 RED 不 `--add-task` |
+| **既有键状态流转数** | `0` 键 | RED 17 / YELLOW 72 / GREEN 8 / DONE 15 与上轮一致,**零漂移** |
+
+### 1.4 本轮 git 提交与推送证据(真理证据)
+
+| 提交 sha | 关联任务/能力键 | 阶段 | 触及文件类型 | 目标分支 | 已推送远端 | 备注 |
+|----------|-----------------|------|--------------|----------|------------|------|
+| 66 个 YELLOW 键 yellow_sha | 见状态机 | YELLOW | 实现/契约 | cfclub | 是 | `merge-base --is-ancestor` **66/66 IN_CHAIN、0 NOT_IN_CHAIN** |
+| 6 个 YELLOW 无 sha(O-22 豁免) | admin.article.list / health.report.detail / task.task.create / shop.product.browse / shop.order.create / shop.coupon.apply | YELLOW | 契约全绿 | cfclub | 是 | 实现早已在链(c3e32460a 等),O-22 归档口径,yellow_sha 空缺交测试侧 mark-green 校验(不查验) |
+| 3 键本轮回填 yellow_sha | membership.plan.purchase `ca56f8699` / supplier.order.fulfill `808b603f2` / family.familyMembers.removeMember `5c3cff249` | YELLOW | 实现 | cfclub | 是 | 上轮 S-cfc-176/177/179 GREEN 任务回执,**三 sha 均 merge-base IN_CHAIN ✓**(git 真理核验通过) |
+| 17 个 RED 键(S-cfc-180~196) | teacher.* / family.family.autoCreateForPurchase | RED(契约在途) | — | 各实例 worktree | 在途 | 上轮全部派发,会话在途写契约测试 |
+
+> **本轮无新增业务提交**(零派发、零写盘业务);报告文档走单文件 `git hash-object` 旁路。
+
+### 1.5 状态机阶段分布与部署状态
+
+| 阶段 | 键数 | 备注 |
+|------|------|------|
+| RED | `17` | teacher.* 14 + family.family.autoCreateForPurchase 1 + …(S-cfc-180~196);全部 dispatched 在途,red_sha 待回填 |
+| YELLOW | `72` | 66 已填 yellow_sha(全 IN_CHAIN)+ 6 O-22 豁免(契约全绿实现已覆盖) |
+| GREEN | `8` | achievement.grid.fiveCol / admin.auth.login / dan.assessment.getActiveMaterial / growth.history.view / health.report.createReport / health.report.upload 等 |
+| DONE | `15` | auth.guest.jwtGuard / auth.guest.wechatLogin / dan.report.parseFormat / dimension.registry.getTree / dimension.registry.mapLegacyCode / family.familyMembers.addMember 等 |
+
+| 部署项 | 值 | 说明 |
+|--------|-----|------|
+| 本轮部署次数 | `0` 次 | 编排侧不代 `--deploy-done`,部署回执归测试/部署侧 |
+| pending / confirmed | `63`(done 44 / **open 19**)/ `77` | open 19 中最新 5c3cff249 @08:44 testing 在途(S-cfc-179 实现);最旧 2c65d98f7 / 4f40a8973 / 3d28ba94d / 649dd2be76 / b037fe71b / 0bf1815cf @2026-11-08(历史遗留,O-24 已裁决归测试/部署侧) |
+| last_deployed | `2e1e5d32ef`(deploy_state)/ `a1ead33a1`(tasks.json) | 双账本差异延续,归部署侧 |
+
+### 1.6 数据源健康度
+
+| 数据源 | 读取键数 | 是否正常 | 备注 |
+|--------|----------|----------|------|
+| 需求侧(⏳) | `0` | 正常(稳态) | 无新缺口键 |
+| 契约常量(契约) | `0` | 正常 | — |
+| 测试用例引用(用例∩⏳) | `0` | 正常 | ⏳∩用例引用=0 |
+| 执行结果回填(case_results) | `0` | 正常 | — |
+
+> **场景覆盖矩阵**:SC 台账主表 **241 行 = 241 唯一 SC**(零重复、无漂移)= 已跑通 15(G12 + O-3 5)/ 有键未跑通 18 / 无键未契约化 208。**跑通 15/241**;241/241 前不得报「开发完成」。T 域 16 能力键已入池(上轮 S-cfc-180~196);P 域 80 无键 SC(健康报告/DAN 预约/AI 方案/心愿/能量归因集群)为下一主攻方向。
+
+---
+
+## 二、下一步需要研发处理的事项
+
+| 序号 | 事项描述 | 依赖/前置条件 | 预估完成时间 | 优先级 | 状态 |
+|------|----------|---------------|--------------|--------|------|
+| 1 | 17 个 RED T 域契约任务产出核验(S-cfc-180~196)→ 回填 red_sha → 交测试侧 mark-red 三类流 | 会话 push 证据 | 持续 | P1 | 进行中(全部 dispatched 在途) |
+| 2 | 推动 72 个 YELLOW 键测试 GREEN 校验 + 241 用户场景三类流证据(O-7) | 测试团队协同 | 持续 | P1 | 进行中(瓶颈在测试侧) |
+| 3 | 63 条 pending 部署经 `--deploy-done testing` 收口(O-24,19 open 归测试/部署侧) | 部署会话回执 | 持续 | P2 | 进行中 |
+| 4 | **P 域 80 无键 SC 契约批次设计**(健康报告/DAN 预约/AI 方案/心愿/能量归因)——参照 EVD-10 模式立项 EVD 契约文档后批量入池 | 主理人确认 P 域契约锚点 | 评估后 | P1 | 待主理人确认 |
+| 5 | 6 个 YELLOW O-22 豁免键交测试侧 mark-green 校验(契约全绿实现已覆盖) | 测试侧复跑契约测试 | 持续 | P2 | 进行中 |
+
+---
+
+## 三、阻塞与风险
+
+| 阻塞项 | 影响范围 | 阻塞类型 | 处置进展 | 需要协调 |
+|--------|----------|----------|----------|----------|
+| 主工作树 730 处他人 WIP(非本轮产生) | 主分支目录 | 研发(环境) | 已隔离:全程临时索引/单文件 hash-object 旁路,零接触 | 无需 |
+| mes-local-45278 flaky 风险 | 部署/测试会话 | 测试 | 已派工 S-cfc-186/190/193,若节点 flaky 失败下轮改派 lan-189/pub-iwintrue | 无需 |
+| P 域 80 无键 SC(健康报告/DAN 预约/AI 方案等) | 241 场景目标 | 需求(覆盖缺口) | T 域已 closure;P 域为下一主攻方向,需主理人确认契约锚点后立项 | 建议主理人确认 |
+| deploy pending open 19(含 11-08 起 6 条历史遗留) | 部署收口 | 部署 | O-24 已裁决归测试/部署侧 `--deploy-done` 回执;编排侧不代越 | 无需 |
+
+---
+
+## 四、下轮工作规划
+
+| 计划项 | 预计完成时间 | 前置依赖 | 涉及会话标签 | 备注 |
+|--------|--------------|----------|--------------|------|
+| 错峰巡检(每时45分) | 下一窗口 | state.json 距本轮 >5min | 研发负责人 | 互斥放行即接管 |
+| 17 个 RED 契约产出核验 + red_sha 回填 | 持续 | 会话 push 证据 | 5 节点 | 转 mark-red 三类流证据 |
+| 6 个 YELLOW O-22 豁免键 mark-green 协同 | 持续 | 测试侧复跑契约 | 测试负责人 | mark-green 不查验 yellow_sha |
+| YELLOW→GREEN 测试协同推进 | 持续 | 测试团队 | 测试负责人 | 72 YELLOW 校验(66 有 sha + 6 O-22) |
+| **P 域契约批次立项(EVD 模式)** | 主理人确认后 | sc-key-map + API_REFERENCE | — | 80 无键 SC 主攻方向 |
+
+---
+
+## 附录
+
+### 附录 A:收到的测试反馈问题明细
+
+| 问题 ID | 能力键/任务号 | 问题类型 | 来源 | 接收时间 | 处置状态 | 关联 sha |
+|---------|---------------|----------|------|----------|----------|----------|
+| 无 | — | — | — | — | — | — |
+
+### 附录 B:本回合派发/恢复明细
+
+本回合**零派发、零恢复**(dispatched 17 / pending 0 池满;`--resume --dry-run` 0 候选)。17 个 RED 契约任务全部由上轮他轮错峰 / 测试侧完成派发,本回合仅核验会话挂载状态(5 节点 session 全挂)。
+
+### 附录 C:上轮派发 GREEN 实现任务回执核验(本轮关键增量)
+
+| 任务号 | 能力键 | 会话 sha | stage | yellow_sha | merge-base IN_CHAIN | 备注 |
+|--------|--------|----------|-------|------------|---------------------|------|
+| S-cfc-176 | membership.plan.purchase | ca56f8699 | YELLOW | `ca56f8699` | ✓ | 已回填,commit ca56f8699 IN CHAIN |
+| S-cfc-177 | supplier.order.fulfill | 808b603f2 | YELLOW | `808b603f2` | ✓ | 已回填,commit 808b603f2 IN CHAIN |
+| S-cfc-179 | family.familyMembers.removeMember | 5c3cff249 | YELLOW | `5c3cff249` | ✓ | 已回填,commit 5c3cff249 IN CHAIN |
+| S-cfc-175 | shop.coupon.apply | 978fb8c88 | YELLOW(无 sha) | — | — | O-22 豁免:契约 20/20 全绿实现已覆盖,commit 为验证报告 docs 提交;yellow_sha 空缺交测试侧 |
+| S-cfc-178 | family.familyMembers.removeMember | 278ec2e9 | 重复任务 | — | — | 上轮重派遗留,已 done 归档(取 5c3cff249 为权威 yellow_sha) |
+
+> **结论**:上轮派发 5 个 GREEN 实现任务(S-cfc-175~179)全部回执 done,3 个实现 sha 已由编排侧/O-16 先例回填 state.json,**IN_CHAIN 3/3 ✓**;另 2 个属 O-22 豁免(契约全绿实现早已在链),交测试侧 mark-green 校验。
+
+### 附录 D:变更感知明细(来自 `--changes --days 7`)
+
+| 变更文档 | 文档类型 | 变更日期 | 新增能力键 | 是否已入池 |
+|----------|----------|----------|------------|------------|
+| 近 7 天 235 文件(需求/设计 126 + 用例 35 + 其他 86) | 需求/设计/用例 | 2026-11-11~11-13 | **0** | 是(既有键状态变化,无需入池) |
+
+---
+
+## 主理人裁决(自裁)
+
+- O-1~O-25 全部已裁决生效(pending.md 状态列均已裁决),本轮**无新增待裁决项**。
+- **裁决 1(本回合零派发)**:dispatched 17 / pending 0 池满,17 个 RED 契约任务全部 dispatched 在途(5 节点 session 全挂),无空闲节点、无 pending 任务 → 本回合零派发,仅核验会话挂载 + GREEN 实现回执(见附录 C)。
+- **裁决 2(--changes 0 新键 不 --add-task)**:近 7 天 235 文件变更,新增能力键 0 → 按 O-7/O-25 防假 RED 纪律不 `--add-task`;P 域 80 无键 SC 为下一主攻方向,待主理人确认契约锚点后立项 EVD 契约批次。
+- **裁决 3(6 YELLOW O-22 豁免键保持不动)**:admin.article.list / health.report.detail / task.task.create / shop.product.browse / shop.order.create / shop.coupon.apply 6 键实现早已在链(契约全绿、无 src/main 改动),yellow_sha 空缺按 O-22 归档口径交测试侧 mark-green 校验(mark-green 不查验 yellow_sha),编排侧不代越。
+- **裁决 4(deploy pending open 19 归测试/部署侧)**:O-24 已裁决归测试/部署侧 `--deploy-done` 回执;编排侧不代越(`--deploy-done` 仅用于编排侧确认部署会话真实部署成功的 commit)。
+
+---
+
+**模板版本**:v1.1|**适用对象**:研发负责人 / 开发角色会话
+**汇报生成**:自动化 05193ba0(错峰轮)· 2026-11-13 10:51

+ 33 - 33
docs/tdd/orchestration/sc-key-map.md

@@ -208,38 +208,38 @@ SC-G-02 / SC-G-06 / SC-P-03 / SC-P-10 / SC-P-11 共 5 个 SC 已于 2026-11-06 O
 | SC-P-94 | 家长 | 业务域(家长):成就与成长 | 我的成就「完成任务」→ 历史完成任务页面 | task.history.view | YELLOW | 未跑通 | 实现已在 origin/cfclub(ed8f1c347);三类流证据待补 |
 | SC-P-95 | 家长 | 业务域(家长):成就与成长 | 我的成就「成长记录」→ 成长历史记录页(含今日成长打卡记录) | growth.history.view | YELLOW | 未跑通 | 实现已在 origin/cfclub(da2727f53);三类流证据待补 |
 | SC-P-96 | 家长 | 业务域(家长):成就与成长 | 我的成就固定五列显示 | achievement.grid.fiveCol | YELLOW | 未跑通 | 实现已在 origin/cfclub(e205656ee);三类流证据待补 |
-| SC-T-01 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 规划师登录 + 切回家长 | — | — | 未跑通 | — |
-| SC-T-02 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 绑定测试家庭 | — | — | 未跑通 | — |
-| SC-T-03 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 代传报告 + 确认入库(上传报告)⭐ | — | — | 未跑通 | — |
-| SC-T-04 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 方案审核列表 + 批准 | — | — | 未跑通 | — |
-| SC-T-05 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 激活方案拆任务(幂等)⭐ 生成任务 | — | — | 未跑通 | — |
-| SC-T-06 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 家庭任务审核 | — | — | 未跑通 | — |
-| SC-T-07 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 录入测评结果触发方案 ⭐ | — | — | 未跑通 | — |
-| SC-T-08 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 成长计划 / 套餐创建 | — | — | 未跑通 | — |
-| SC-T-09 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 测评预约确认(状态守卫) | — | — | 未跑通 | — |
-| SC-T-10 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 测评预约完成/未到 | — | — | 未跑通 | — |
-| SC-T-11 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | DAN 报告上传与解析 | — | — | 未跑通 | — |
-| SC-T-12 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | DAN 八类报告查看 | — | — | 未跑通 | — |
-| SC-T-13 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 规划师视角数据隔离 | — | — | 未跑通 | — |
-| SC-T-14 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 方案编辑(规划师侧) | — | — | 未跑通 | — |
-| SC-T-15 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 套餐任务项管理 | — | — | 未跑通 | — |
-| SC-T-16 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 客户健康总览 | — | — | 未跑通 | — |
-| SC-T-17 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 成长计划进度跟踪 | — | — | 未跑通 | — |
-| SC-T-18 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 批量任务下发 | — | — | 未跑通 | — |
-| SC-T-19 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 消息推送客户 | — | — | 未跑通 | — |
-| SC-T-20 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 退出/切换账号 | — | — | 未跑通 | — |
-| SC-T-21 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 报告对比分析 | — | — | 未跑通 | — |
-| SC-T-22 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 方案归档 | — | — | 未跑通 | — |
-| SC-T-23 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 报告代传(规划师代客户传)⭐ | — | — | 未跑通 | — |
-| SC-T-24 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 解绑/重分配客户家庭 | — | — | 未跑通 | — |
-| SC-T-25 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 舌诊/情绪测评录入(规划师) | — | — | 未跑通 | — |
-| SC-T-26 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 精准营养方案制定 | — | — | 未跑通 | — |
-| SC-T-27 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 家庭天盘解读(规划师) | — | — | 未跑通 | — |
-| SC-T-28 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | AI 方案内容安全复核 | — | — | 未跑通 | — |
-| SC-T-29 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 客户档案数据导出(合规) | — | — | 未跑通 | — |
-| SC-T-30 | 成长规划师 | 业务域(规划师):五维变化录入 | 测评录入触发五维维度变化 | — | — | 未跑通 | — |
-| SC-T-31 | 成长规划师 | 业务域(规划师):五维变化录入 | 手动维度打分覆盖/调整 | — | — | 未跑通 | — |
-| SC-T-32 | 成长规划师 | 业务域(规划师):五维变化录入 | 五维自评录入与变化 | — | — | 未跑通 | — |
+| SC-T-01 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 规划师登录 + 切回家长 | teacher.login.switch | RED | 未跑通 | — |
+| SC-T-02 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 绑定测试家庭 | teacher.family.bind | RED | 未跑通 | — |
+| SC-T-03 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 代传报告 + 确认入库(上传报告)⭐ | teacher.report.proxy | RED | 未跑通 | — |
+| SC-T-04 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 方案审核列表 + 批准 | teacher.plan.review | RED | 未跑通 | — |
+| SC-T-05 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 激活方案拆任务(幂等)⭐ 生成任务 | teacher.plan.activate | RED | 未跑通 | — |
+| SC-T-06 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 家庭任务审核 | teacher.task.review | RED | 未跑通 | — |
+| SC-T-07 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 录入测评结果触发方案 ⭐ | teacher.assessment.record | RED | 未跑通 | — |
+| SC-T-08 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 成长计划 / 套餐创建 | teacher.plan.create | RED | 未跑通 | — |
+| SC-T-09 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 测评预约确认(状态守卫) | teacher.appointment.guard | RED | 未跑通 | — |
+| SC-T-10 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 测评预约完成/未到 | teacher.appointment.guard | RED | 未跑通 | 契约 EVD-10 明确绑定(T-09/T-10);未到语义待 G-1 补齐 |
+| SC-T-11 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | DAN 报告上传与解析 | teacher.assessment.record | RED | 未跑通 | DanReportController /upload、DanAssessmentController /report/parse-preview |
+| SC-T-12 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | DAN 八类报告查看 | teacher.assessment.record | RED | 未跑通 | AssessmentReportController /result/*、DanAssessmentController /result/{id} |
+| SC-T-13 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 规划师视角数据隔离 | teacher.data.isolation | RED | 未跑通 | — |
+| SC-T-14 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 方案编辑(规划师侧) | teacher.plan.create | RED | 未跑通 | GrowthPlanController /api/growth/plan/update、HealthPlanController /save |
+| SC-T-15 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 套餐任务项管理 | teacher.plan.create | RED | 未跑通 | TrainingPlanController /items/*(add/update/delete 任务项 CRUD)|
+| SC-T-16 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 客户健康总览 | teacher.family.overview | RED | 未跑通 | — |
+| SC-T-17 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 成长计划进度跟踪 | teacher.family.overview | RED | 未跑通 | GrowthPlanController /child/{memberId}/active |
+| SC-T-18 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 批量任务下发 | teacher.task.review | RED | 未跑通 | TrainingPlanController /deploy、GuideFamilyTaskController /tasks/batch-review |
+| SC-T-19 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 消息推送客户 | teacher.message.push | RED | 未跑通 | — |
+| SC-T-20 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 退出/切换账号 | teacher.login.switch | RED | 未跑通 | — |
+| SC-T-21 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 报告对比分析 | teacher.family.overview | RED | 未跑通 | AssessmentComparisonController |
+| SC-T-22 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 方案归档 | teacher.plan.create | RED | 未跑通 | ⚠ 缺口 G-2:GrowthPlan/HealthPlan 均无 archive 专用端点,待后端补齐后回归补录 |
+| SC-T-23 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 报告代传(规划师代客户传)⭐ | teacher.report.proxy | RED | 未跑通 | HealthReportController /report/proxy-parse、/proxy-confirm、/transfer/* |
+| SC-T-24 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 解绑/重分配客户家庭 | teacher.family.bind | RED | 未跑通 | — |
+| SC-T-25 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 舌诊/情绪测评录入(规划师) | teacher.mind.record | RED | 未跑通 | — |
+| SC-T-26 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 精准营养方案制定 | teacher.nutrition.plan | RED | 未跑通 | — |
+| SC-T-27 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 家庭天盘解读(规划师) | teacher.family.overview | RED | 未跑通 | TianpanController /api/tianpan/* |
+| SC-T-28 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | AI 方案内容安全复核 | teacher.plan.review | RED | 未跑通 | PlanReviewController /review、ContentFilterController /api/content/* |
+| SC-T-29 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 客户档案数据导出(合规) | teacher.client.export | RED | 未跑通 | — |
+| SC-T-30 | 成长规划师 | 业务域(规划师):五维变化录入 | 测评录入触发五维维度变化 | teacher.dimension.score | RED | 未跑通 | — |
+| SC-T-31 | 成长规划师 | 业务域(规划师):五维变化录入 | 手动维度打分覆盖/调整 | teacher.dimension.score | RED | 未跑通 | — |
+| SC-T-32 | 成长规划师 | 业务域(规划师):五维变化录入 | 五维自评录入与变化 | teacher.dimension.score | RED | 未跑通 | — |
 | SC-S-01 | 商品服务商 | 业务域 L:交易延伸 / 内容 / 多端 | 入驻申请 | — | — | 未跑通 | — |
 | SC-S-02 | 商品服务商 | 业务域 L:交易延伸 / 内容 / 多端 | 商品管理(上架/编辑) | — | — | 未跑通 | — |
 | SC-S-03 | 商品服务商 | 业务域 L:交易延伸 / 内容 / 多端 | 订单处理(发货) | supplier.order.fulfill | RED | 未跑通 | — |
@@ -268,7 +268,7 @@ SC-G-02 / SC-G-06 / SC-P-03 / SC-P-10 / SC-P-11 共 5 个 SC 已于 2026-11-06 O
 | SC-H-05 | 真人管家 | 业务域 C:资源对接与协同闭环 | 线下资源与活动对接 | — | — | 未跑通 | — |
 | SC-H-06 | 真人管家 | 业务域 C:资源对接与协同闭环 | 与 AI 管家协同闭环 | — | — | 未跑通 | — |
 | SC-H-07 | 真人管家 | 业务域 D:服务质量 | 服务质检与满意度 | — | — | 未跑通 | — |
-| SC-X-22 | 跨角色 | 奖励/CF 规则中心(全局 UI 规范) | CF 值获取预估(操作前提示 +N CF) | — | — | 未跑通 | — |
+| SC-X-22 | 跨角色 | 奖励/CF 规则中心(全局 UI 规范) | CF 值获取预估(操作前提示 +N CF) | reward.cfEstimate.expectedCf | DONE | 未跑通 | 键已 DONE(实现闭环),三类流证据待补 |
 
 ---