Browse Source

test(EVD-8R): 测评-方案 43 SC 三类流契约证据(156 JUnit 全绿)

补建 §测评-方案 43 SC 执行证据(仅测试资产+evidence,无业务代码):
- 9 契约类 + 公共基类 AssessmentPlanContractTestBase(纯 Mockito/无 Spring/无 DB)
- 覆盖家长/规划师/孩子/跨角色/AI管家/真人管家六类角色流
- 契约缺口 12 处钉住(8 观察 + 3 相邻覆盖 + C-08 相邻覆盖),不修实现
- evidence 文档:SC 覆盖矩阵/执行结果/缺口钉住表/改动清单

mvn test-compile BUILD SUCCESS; 156 用例 0 失败 0 异常
Agnes Agent 1 week ago
parent
commit
7f9b978c

+ 46 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AssessmentPlanContractTestBase.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+
+import java.lang.reflect.Field;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * EVD-8 契约补建 · 测评-方案批次公共基类(纯 Mockito / 无 Spring 上下文 / 无 DB)。
+ *
+ * <p>提供 {@link #inject} 反射注入(仿 {@code EnergyContractTestBase})与
+ * {@link #initTable} MyBatis-Plus TableInfo 预注册(使 {@code LambdaQueryWrapper}
+ * 在无 Spring 上下文中可解析列名)。</p>
+ */
+abstract class AssessmentPlanContractTestBase {
+
+    private static final Set<Class<?>> INITIALIZED = ConcurrentHashMap.newKeySet();
+
+    /** 反射注入字段值(沿类层次向上查找) */
+    protected static void inject(Object target, String fieldName, Object value) throws Exception {
+        Class<?> cls = target.getClass();
+        Field f = null;
+        while (cls != null && f == null) {
+            try {
+                f = cls.getDeclaredField(fieldName);
+            } catch (NoSuchFieldException e) {
+                cls = cls.getSuperclass();
+            }
+        }
+        if (f == null) {
+            throw new NoSuchFieldException(target.getClass().getName() + "#" + fieldName);
+        }
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    /** 为实体类预注册 MyBatis-Plus TableInfo(幂等,每类仅一次) */
+    protected static void initTable(Class<?> clazz) {
+        if (INITIALIZED.add(clazz)) {
+            TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), clazz);
+        }
+    }
+}

+ 596 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AssessmentQuotaAppointmentContractTest.java

@@ -0,0 +1,596 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.assessment.AssessmentAppointmentController;
+import com.etotem.cfc.controller.assessment.AssessmentQuotaController;
+import com.etotem.cfc.entity.AssessmentAppointment;
+import com.etotem.cfc.entity.AssessmentQuota;
+import com.etotem.cfc.entity.DanAssessmentExecution;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.AssessmentAppointmentMapper;
+import com.etotem.cfc.mapper.AssessmentQuotaMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AssessmentAppointmentService;
+import com.etotem.cfc.service.AssessmentExecutionService;
+import com.etotem.cfc.service.AssessmentQuotaService;
+import com.etotem.cfc.service.DanAssessmentExecutionService;
+import com.etotem.cfc.service.EnergyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+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.assertThrows;
+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.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * EVD-8 契约补建 · 测评-方案「测评额度/预约」批次三类流契约测试。
+ *
+ * <p>覆盖 SC(contract-plan 测评-方案):SC-P-33(套餐下单→额度发放)、SC-P-35(获得测评额度)、
+ * SC-P-36(创建预约消耗额度)、SC-P-37(查看预约状态)、SC-P-38(取消预约·越权观察)、
+ * SC-T-09(预约确认状态守卫)、SC-T-10(预约完成守卫)、SC-X-01(测评全链路·预约+执行记录)。</p>
+ *
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB):正常流、异常流(额度不足/过期/已用完/状态异常)、
+ * 权限流(非指派确认、越权取消观察、guide-list 角色守卫)。</p>
+ */
+class AssessmentQuotaAppointmentContractTest extends AssessmentPlanContractTestBase {
+
+    // ==================== service:AssessmentQuotaService ====================
+
+    @Test
+    @DisplayName("SC-P-36 正常:消耗一次额度 remaining 3→2")
+    void quota_正常_消耗一次() throws Exception {
+        AssessmentQuotaMapper mapper = mock(AssessmentQuotaMapper.class);
+        AssessmentQuotaService svc = new AssessmentQuotaService();
+        inject(svc, "mapper", mapper);
+
+        AssessmentQuota quota = quota(5L, "active", 3, 3, future());
+        when(mapper.selectById(5L)).thenReturn(quota);
+
+        int remaining = svc.consumeQuota(5L, 10L);
+        assertEquals(2, remaining);
+        assertEquals(2, quota.getRemainingSessions());
+    }
+
+    @Test
+    @DisplayName("SC-P-36 异常:最后一格消耗后状态 used_all")
+    void quota_异常_最后一格_used_all() throws Exception {
+        AssessmentQuotaMapper mapper = mock(AssessmentQuotaMapper.class);
+        AssessmentQuotaService svc = new AssessmentQuotaService();
+        inject(svc, "mapper", mapper);
+
+        AssessmentQuota quota = quota(5L, "active", 1, 1, future());
+        when(mapper.selectById(5L)).thenReturn(quota);
+
+        int remaining = svc.consumeQuota(5L, 10L);
+        assertEquals(0, remaining);
+        assertEquals("used_all", quota.getStatus());
+    }
+
+    @Test
+    @DisplayName("SC-P-36 异常:额度已用完 → 抛「测评额度已用完」")
+    void quota_异常_已用完() throws Exception {
+        AssessmentQuotaMapper mapper = mock(AssessmentQuotaMapper.class);
+        AssessmentQuotaService svc = new AssessmentQuotaService();
+        inject(svc, "mapper", mapper);
+
+        when(mapper.selectById(5L)).thenReturn(quota(5L, "active", 0, 0, future()));
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> svc.consumeQuota(5L, 10L));
+        assertTrue(ex.getMessage().contains("已用完"));
+    }
+
+    @Test
+    @DisplayName("SC-P-36 异常:额度已过期 → 置 expired 并抛「测评额度已过期」")
+    void quota_异常_已过期() throws Exception {
+        AssessmentQuotaMapper mapper = mock(AssessmentQuotaMapper.class);
+        AssessmentQuotaService svc = new AssessmentQuotaService();
+        inject(svc, "mapper", mapper);
+
+        AssessmentQuota quota = quota(5L, "active", 2, 2, past());
+        when(mapper.selectById(5L)).thenReturn(quota);
+
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> svc.consumeQuota(5L, 10L));
+        assertTrue(ex.getMessage().contains("已过期"));
+        assertEquals("expired", quota.getStatus());
+    }
+
+    @Test
+    @DisplayName("SC-P-36 异常:状态非 active → 抛「测评额度状态异常」")
+    void quota_异常_状态异常() throws Exception {
+        AssessmentQuotaMapper mapper = mock(AssessmentQuotaMapper.class);
+        AssessmentQuotaService svc = new AssessmentQuotaService();
+        inject(svc, "mapper", mapper);
+
+        when(mapper.selectById(5L)).thenReturn(quota(5L, "used_all", 0, 0, future()));
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> svc.consumeQuota(5L, 10L));
+        assertTrue(ex.getMessage().contains("状态异常"));
+    }
+
+    @Test
+    @DisplayName("SC-P-36 异常:额度记录不存在 → 抛「测评额度不存在」")
+    void quota_异常_不存在() throws Exception {
+        AssessmentQuotaMapper mapper = mock(AssessmentQuotaMapper.class);
+        AssessmentQuotaService svc = new AssessmentQuotaService();
+        inject(svc, "mapper", mapper);
+
+        when(mapper.selectById(5L)).thenReturn(null);
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> svc.consumeQuota(5L, 10L));
+        assertTrue(ex.getMessage().contains("不存在"));
+    }
+
+    @Test
+    @DisplayName("SC-P-33/35 正常:套餐下单支付后发放额度(默认 1 次 / 365 天)")
+    void quota_正常_grantQuota默认值() throws Exception {
+        AssessmentQuotaMapper mapper = mock(AssessmentQuotaMapper.class);
+        AssessmentQuotaService svc = new AssessmentQuotaService();
+        inject(svc, "mapper", mapper);
+
+        ProductOrder order = mock(ProductOrder.class);
+        when(order.getId()).thenReturn(88L);
+        when(order.getFamilyId()).thenReturn(7L);
+        when(order.getProductId()).thenReturn(900L);
+        when(order.getProductName()).thenReturn("DAN测评套餐");
+        when(order.getOrderNo()).thenReturn("O888");
+
+        svc.grantQuota(order, null, null);
+
+        ArgumentCaptor<AssessmentQuota> captor = ArgumentCaptor.forClass(AssessmentQuota.class);
+        verify(mapper).insert(captor.capture());
+        AssessmentQuota q = captor.getValue();
+        assertEquals("active", q.getStatus());
+        assertEquals(1, q.getTotalSessions());
+        assertEquals(1, q.getRemainingSessions());
+        assertNotNull(q.getValidityEnd());
+        assertTrue(q.getValidityEnd().after(new Date()), "默认 365 天有效期应落在未来");
+        assertTrue(q.getQuotaNo().startsWith("Q"));
+    }
+
+    // ==================== controller:AssessmentQuotaController ====================
+
+    @Test
+    @DisplayName("SC-P-35 正常:我的可用额度列表 → VO 含 usedSessions/expired")
+    void quotaController_正常_我的额度() throws Exception {
+        AssessmentQuotaService quotaSvc = mock(AssessmentQuotaService.class);
+        AssessmentExecutionService execSvc = mock(AssessmentExecutionService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        AssessmentQuotaController c = new AssessmentQuotaController();
+        inject(c, "quotaService", quotaSvc);
+        inject(c, "executionService", execSvc);
+        inject(c, "userMapper", userMapper);
+
+        User user = new User();
+        user.setId(1L);
+        user.setFamilyId(7L);
+        when(userMapper.selectById(1L)).thenReturn(user);
+        when(quotaSvc.getAvailableQuotas(7L, null))
+                .thenReturn(Arrays.asList(quota(5L, "active", 3, 2, future()), quota(6L, "active", 1, 1, future())));
+
+        Result<List<com.etotem.cfc.dto.AssessmentQuotaVO>> r = c.getMyQuotas(1L);
+        assertEquals(200, r.getCode());
+        assertEquals(2, r.getData().size());
+        assertEquals(1, r.getData().get(0).getUsedSessions(), "3 次中已用 1 次");
+        assertEquals(false, r.getData().get(0).isExpired());
+    }
+
+    @Test
+    @DisplayName("SC-P-35 权限:用户不存在/无家庭 → 业务提示")
+    void quotaController_权限_用户无家庭() throws Exception {
+        AssessmentQuotaService quotaSvc = mock(AssessmentQuotaService.class);
+        AssessmentExecutionService execSvc = mock(AssessmentExecutionService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        AssessmentQuotaController c = new AssessmentQuotaController();
+        inject(c, "quotaService", quotaSvc);
+        inject(c, "executionService", execSvc);
+        inject(c, "userMapper", userMapper);
+
+        when(userMapper.selectById(1L)).thenReturn(null);
+        Result<?> r = c.getMyQuotas(1L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("用户或家庭信息不存在"));
+    }
+
+    @Test
+    @DisplayName("SC-P-35 正常:额度详情 → VO")
+    void quotaController_正常_详情() throws Exception {
+        AssessmentQuotaService quotaSvc = mock(AssessmentQuotaService.class);
+        AssessmentExecutionService execSvc = mock(AssessmentExecutionService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        AssessmentQuotaController c = new AssessmentQuotaController();
+        inject(c, "quotaService", quotaSvc);
+        inject(c, "executionService", execSvc);
+        inject(c, "userMapper", userMapper);
+
+        when(quotaSvc.getById(5L)).thenReturn(quota(5L, "active", 3, 2, future()));
+        Map<String, Object> body = new HashMap<>();
+        body.put("quotaId", 5L);
+        Result<?> r = c.detail(body);
+        assertEquals(200, r.getCode());
+        assertEquals(2, ((com.etotem.cfc.dto.AssessmentQuotaVO) r.getData()).getRemainingSessions());
+    }
+
+    @Test
+    @DisplayName("SC-P-35 异常:quotaId 为空 → 业务提示")
+    void quotaController_异常_缺quotaId() throws Exception {
+        AssessmentQuotaService quotaSvc = mock(AssessmentQuotaService.class);
+        AssessmentQuotaController c = new AssessmentQuotaController();
+        inject(c, "quotaService", quotaSvc);
+        inject(c, "executionService", mock(AssessmentExecutionService.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+
+        Result<?> r = c.detail(new HashMap<>());
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("quotaId不能为空"));
+    }
+
+    @Test
+    @DisplayName("SC-P-35 异常:额度不存在 → 业务提示")
+    void quotaController_异常_额度不存在() throws Exception {
+        AssessmentQuotaService quotaSvc = mock(AssessmentQuotaService.class);
+        AssessmentQuotaController c = new AssessmentQuotaController();
+        inject(c, "quotaService", quotaSvc);
+        inject(c, "executionService", mock(AssessmentExecutionService.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+
+        when(quotaSvc.getById(5L)).thenReturn(null);
+        Map<String, Object> body = new HashMap<>();
+        body.put("quotaId", 5L);
+        Result<?> r = c.detail(body);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("额度不存在"));
+    }
+
+    // ==================== controller:AssessmentAppointmentController ====================
+
+    @Test
+    @DisplayName("SC-P-36/SC-X-01 正常:创建预约 → 消耗额度 + 建预约 + 建执行记录")
+    void appointmentController_正常_创建() throws Exception {
+        AssessmentAppointmentService apptSvc = mock(AssessmentAppointmentService.class);
+        AssessmentQuotaService quotaSvc = mock(AssessmentQuotaService.class);
+        DanAssessmentExecutionService execSvc = mock(DanAssessmentExecutionService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        AssessmentAppointmentController c = new AssessmentAppointmentController();
+        inject(c, "appointmentService", apptSvc);
+        inject(c, "quotaService", quotaSvc);
+        inject(c, "danAssessmentExecutionService", execSvc);
+        inject(c, "userMapper", userMapper);
+
+        User user = new User();
+        user.setId(1L);
+        when(userMapper.selectById(1L)).thenReturn(user);
+        when(quotaSvc.consumeQuota(5L, 10L)).thenReturn(2);
+        AssessmentQuota quota = quota(5L, "active", 3, 2, future());
+        quota.setProductOrderId(11L);
+        quota.setFamilyId(7L);
+        when(quotaSvc.getById(5L)).thenReturn(quota);
+
+        AssessmentAppointment appt = new AssessmentAppointment();
+        appt.setId(101L);
+        when(apptSvc.createAppointment(eq(1L), eq(10L), eq(7L), isNull(), isNull(),
+                eq("2026-11-10"), eq("10:00"), eq("首测")))
+                .thenReturn(appt);
+
+        DanAssessmentExecution exec = new DanAssessmentExecution();
+        exec.setId(201L);
+        when(execSvc.createExecution(11L, 10L, 7L, 5L, 7L, null, 101L)).thenReturn(exec);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("memberId", 10L);
+        body.put("guideId", 7L);
+        body.put("quotaId", 5L);
+        body.put("appointmentDate", "2026-11-10");
+        body.put("appointmentTime", "10:00");
+        body.put("notes", "首测");
+
+        Result<Map<String, Object>> r = c.createAppointment(1L, body, null);
+        assertEquals(200, r.getCode());
+        assertEquals(101L, r.getData().get("appointmentId"));
+        assertEquals(201L, r.getData().get("executionId"));
+        verify(quotaSvc).consumeQuota(5L, 10L);
+        verify(execSvc).createExecution(11L, 10L, 7L, 5L, 7L, null, 101L);
+    }
+
+    @Test
+    @DisplayName("SC-P-36 异常:memberId/quotaId 为空 → 业务提示")
+    void appointmentController_异常_缺参数() throws Exception {
+        AssessmentAppointmentController c = new AssessmentAppointmentController();
+        inject(c, "appointmentService", mock(AssessmentAppointmentService.class));
+        inject(c, "quotaService", mock(AssessmentQuotaService.class));
+        inject(c, "danAssessmentExecutionService", mock(DanAssessmentExecutionService.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+
+        Result<?> r = c.createAppointment(1L, new HashMap<>(), null);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("memberId和quotaId不能为空"));
+    }
+
+    @Test
+    @DisplayName("SC-P-36 异常:assessorId/guideId 都为空 → 业务提示")
+    void appointmentController_异常_缺指派() throws Exception {
+        AssessmentAppointmentController c = new AssessmentAppointmentController();
+        inject(c, "appointmentService", mock(AssessmentAppointmentService.class));
+        inject(c, "quotaService", mock(AssessmentQuotaService.class));
+        inject(c, "danAssessmentExecutionService", mock(DanAssessmentExecutionService.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("memberId", 10L);
+        body.put("quotaId", 5L);
+        Result<?> r = c.createAppointment(1L, body, null);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("assessorId或guideId必须提供一个"));
+    }
+
+    @Test
+    @DisplayName("SC-P-36 异常:额度不足 → 业务提示(不建预约)")
+    void appointmentController_异常_额度不足() throws Exception {
+        AssessmentAppointmentService apptSvc = mock(AssessmentAppointmentService.class);
+        AssessmentQuotaService quotaSvc = mock(AssessmentQuotaService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        AssessmentAppointmentController c = new AssessmentAppointmentController();
+        inject(c, "appointmentService", apptSvc);
+        inject(c, "quotaService", quotaSvc);
+        inject(c, "danAssessmentExecutionService", mock(DanAssessmentExecutionService.class));
+        inject(c, "userMapper", userMapper);
+
+        User user = new User();
+        user.setId(1L);
+        when(userMapper.selectById(1L)).thenReturn(user);
+        when(quotaSvc.consumeQuota(5L, 10L)).thenReturn(0);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("memberId", 10L);
+        body.put("guideId", 7L);
+        body.put("quotaId", 5L);
+        Result<?> r = c.createAppointment(1L, body, null);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("额度不足或已过期"));
+        verify(apptSvc, never()).createAppointment(anyLong(), anyLong(), anyLong(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("SC-P-36 权限:用户不存在 → 业务提示")
+    void appointmentController_权限_用户不存在() throws Exception {
+        AssessmentAppointmentController c = new AssessmentAppointmentController();
+        inject(c, "appointmentService", mock(AssessmentAppointmentService.class));
+        inject(c, "quotaService", mock(AssessmentQuotaService.class));
+        inject(c, "danAssessmentExecutionService", mock(DanAssessmentExecutionService.class));
+        UserMapper userMapper = mock(UserMapper.class);
+        inject(c, "userMapper", userMapper);
+        when(userMapper.selectById(1L)).thenReturn(null);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("memberId", 10L);
+        body.put("guideId", 7L);
+        body.put("quotaId", 5L);
+        Result<?> r = c.createAppointment(1L, body, null);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("用户不存在"));
+    }
+
+    @Test
+    @DisplayName("SC-T-09 正常:被指派测评师确认 → 成功")
+    void appointmentController_正常_确认() throws Exception {
+        AssessmentAppointmentService apptSvc = mock(AssessmentAppointmentService.class);
+        AssessmentAppointmentController c = new AssessmentAppointmentController();
+        inject(c, "appointmentService", apptSvc);
+        inject(c, "quotaService", mock(AssessmentQuotaService.class));
+        inject(c, "danAssessmentExecutionService", mock(DanAssessmentExecutionService.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+
+        when(apptSvc.confirmAppointment(101L, 5L)).thenReturn(true);
+        Result<Boolean> r = c.confirmAppointment(5L, 101L);
+        assertEquals(200, r.getCode());
+        assertEquals(true, r.getData());
+    }
+
+    @Test
+    @DisplayName("SC-T-09 权限:非指派确认 → 业务提示「确认失败」")
+    void appointmentController_权限_非指派确认() throws Exception {
+        AssessmentAppointmentService apptSvc = mock(AssessmentAppointmentService.class);
+        AssessmentAppointmentController c = new AssessmentAppointmentController();
+        inject(c, "appointmentService", apptSvc);
+        inject(c, "quotaService", mock(AssessmentQuotaService.class));
+        inject(c, "danAssessmentExecutionService", mock(DanAssessmentExecutionService.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+
+        when(apptSvc.confirmAppointment(101L, 9L)).thenReturn(false);
+        Result<Boolean> r = c.confirmAppointment(9L, 101L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("确认失败"));
+    }
+
+    @Test
+    @DisplayName("SC-P-37 正常:我的预约列表")
+    void appointmentController_正常_我的列表() throws Exception {
+        AssessmentAppointmentService apptSvc = mock(AssessmentAppointmentService.class);
+        AssessmentAppointmentController c = new AssessmentAppointmentController();
+        inject(c, "appointmentService", apptSvc);
+        inject(c, "quotaService", mock(AssessmentQuotaService.class));
+        inject(c, "danAssessmentExecutionService", mock(DanAssessmentExecutionService.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+
+        AssessmentAppointment a = new AssessmentAppointment();
+        a.setId(101L);
+        a.setStatus(1);
+        a.setAppointmentDate("2026-11-10");
+        when(apptSvc.getByUserId(1L)).thenReturn(Collections.singletonList(a));
+
+        Result<List<Map<String, Object>>> r = c.getMyAppointments(1L);
+        assertEquals(200, r.getCode());
+        assertEquals(1, r.getData().size());
+        assertEquals("已确认", r.getData().get(0).get("statusText"));
+    }
+
+    @Test
+    @DisplayName("SC-T-13 权限:非规划师查 guide-list → 业务提示")
+    void appointmentController_权限_非规划师guideList() throws Exception {
+        AssessmentAppointmentController c = new AssessmentAppointmentController();
+        inject(c, "appointmentService", mock(AssessmentAppointmentService.class));
+        inject(c, "quotaService", mock(AssessmentQuotaService.class));
+        inject(c, "danAssessmentExecutionService", mock(DanAssessmentExecutionService.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+
+        Result<?> r = c.getGuideAppointments(1L, "parent");
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("只有成长规划师"));
+    }
+
+    // ==================== service:AssessmentAppointmentService ====================
+
+    @Test
+    @DisplayName("SC-T-09 正常:指派测评师确认 → status=1")
+    void appointmentService_正常_确认() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = new AssessmentAppointment();
+        a.setId(101L);
+        a.setAssessorId(5L);
+        a.setStatus(0);
+        when(mapper.selectById(101L)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.confirmAppointment(101L, 5L);
+        assertTrue(ok);
+        assertEquals(1, a.getStatus());
+    }
+
+    @Test
+    @DisplayName("SC-T-09 权限:非指派确认 → false(状态不变)")
+    void appointmentService_权限_非指派确认() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = new AssessmentAppointment();
+        a.setId(101L);
+        a.setAssessorId(5L);
+        a.setStatus(0);
+        when(mapper.selectById(101L)).thenReturn(a);
+
+        boolean ok = svc.confirmAppointment(101L, 9L);
+        assertEquals(false, ok);
+        assertEquals(0, a.getStatus(), "非指派不得改状态");
+    }
+
+    @Test
+    @DisplayName("SC-T-09 异常:预约不存在 → false")
+    void appointmentService_异常_预约不存在() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        when(mapper.selectById(101L)).thenReturn(null);
+        assertEquals(false, svc.confirmAppointment(101L, 5L));
+    }
+
+    @Test
+    @DisplayName("SC-T-10 正常:guide 完成 → status=2 且发放能量 30")
+    void appointmentService_正常_完成() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        EnergyService energySvc = mock(EnergyService.class);
+        inject(svc, "energyService", energySvc);
+
+        AssessmentAppointment a = new AssessmentAppointment();
+        a.setId(101L);
+        a.setGuideId(7L);
+        a.setMemberId(10L);
+        a.setStatus(1);
+        when(mapper.selectById(101L)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.completeAppointment(101L, 7L);
+        assertTrue(ok);
+        assertEquals(2, a.getStatus());
+        verify(energySvc).awardEnergy(eq(10L), eq("appointment"), eq(101L), eq(30), any(), isNull());
+    }
+
+    @Test
+    @DisplayName("SC-T-10 权限:非 guide 完成 → false")
+    void appointmentService_权限_非guide完成() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = new AssessmentAppointment();
+        a.setId(101L);
+        a.setGuideId(7L);
+        a.setStatus(1);
+        when(mapper.selectById(101L)).thenReturn(a);
+
+        assertEquals(false, svc.completeAppointment(101L, 9L));
+        assertEquals(1, a.getStatus(), "非指派不得置为完成");
+    }
+
+    @Test
+    @DisplayName("SC-P-38 观察:取消预约无越权校验(仅置 status=3,观察钉住不修实现)")
+    void appointmentService_观察_取消无越权() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = new AssessmentAppointment();
+        a.setId(101L);
+        a.setUserId(1L);
+        a.setStatus(1);
+        when(mapper.selectById(101L)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        // 契约缺口观察:cancelAppointment(id) 不接收 userId/role,任何调用方可取消
+        boolean ok = svc.cancelAppointment(101L);
+        assertTrue(ok);
+        assertEquals(3, a.getStatus());
+    }
+
+    // ==================== helpers ====================
+
+    private static AssessmentQuota quota(Long id, String status, int total, int remaining, Date validityEnd) {
+        AssessmentQuota q = new AssessmentQuota();
+        q.setId(id);
+        q.setQuotaNo("Q" + id);
+        q.setFamilyId(7L);
+        q.setMemberId(null);
+        q.setProductId(900L);
+        q.setProductName("DAN测评套餐");
+        q.setTotalSessions(total);
+        q.setRemainingSessions(remaining);
+        q.setValidityStart(new Date());
+        q.setValidityEnd(validityEnd);
+        q.setStatus(status);
+        return q;
+    }
+
+    private static Date future() {
+        return new Date(System.currentTimeMillis() + 30L * 24 * 3600 * 1000);
+    }
+
+    private static Date past() {
+        return new Date(System.currentTimeMillis() - 30L * 24 * 3600 * 1000);
+    }
+}

+ 333 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/CoachButlerNotificationContractTest.java

@@ -0,0 +1,333 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.UserNotificationController;
+import com.etotem.cfc.controller.ai.AIChatController;
+import com.etotem.cfc.controller.ai.ButlerController;
+import com.etotem.cfc.dto.AiGateResult;
+import com.etotem.cfc.entity.ButlerSession;
+import com.etotem.cfc.entity.UserNotification;
+import com.etotem.cfc.enums.AiGateStatus;
+import com.etotem.cfc.mapper.UserNotificationMapper;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.ButlerSessionService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.PortraitService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+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;
+
+/**
+ * EVD-8 契约补建 · 测评-方案「AI 教练/AI 管家/消息通知」批次三类流契约测试。
+ *
+ * <p>覆盖 SC(contract-plan 测评-方案):SC-C-19(查看家人/规划师消息通知:UserNotificationController)、
+ * SC-B-09(教练转接规划师·观察:转接由 LangGraph 回复承载,无独立 Java 端点)、
+ * SC-B-12(同步规划师建议主动跟进·观察)、SC-H-02(真人管家一对一陪跑·观察,管家会话面提供支撑)。</p>
+ *
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB):正常流(AI 管家发送/会话、通知列表/已读)、
+ * 异常流(query 为空、会话无权)、权限流(非会员 46003 / 会员过期 46004、通知归属校验)。</p>
+ */
+class CoachButlerNotificationContractTest extends AssessmentPlanContractTestBase {
+
+    static {
+        initTable(UserNotification.class);
+    }
+
+    // ==================== controller:AIChatController /butler/send(AI 管家消息) ====================
+
+    @Test
+    @DisplayName("SC-H-02 相邻 正常:会员发健康管家消息 → 200 回答")
+    void butlerSend_正常() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        PortraitService portraitSvc = mock(PortraitService.class);
+        AiGateway aiGateway = mock(AiGateway.class);
+        AIChatController c = new AIChatController();
+        inject(c, "membershipService", membershipSvc);
+        inject(c, "portraitService", portraitSvc);
+        inject(c, "aiGateway", aiGateway);
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setEnabled(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(1L)).thenReturn(gate);
+        when(portraitSvc.buildPortraitPrompt(1L, null)).thenReturn("");
+        Map<String, Object> resp = new LinkedHashMap<>();
+        resp.put("answer", "已为您安排健康建议");
+        when(aiGateway.chat(anyString(), eq(1L), any(), any(Map.class))).thenReturn(resp);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("query", "帮我看看这周饮食");
+        Result<Map<String, Object>> r = c.sendHealthButlerMessage(1L, params);
+        assertEquals(200, r.getCode());
+        assertEquals("已为您安排健康建议", r.getData().get("answer"));
+    }
+
+    @Test
+    @DisplayName("SC-H-02 相邻 权限:FREE 用户发管家消息 → 46003 会员专享")
+    void butlerSend_权限_非会员() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIChatController c = new AIChatController();
+        inject(c, "membershipService", membershipSvc);
+        inject(c, "portraitService", mock(PortraitService.class));
+        inject(c, "aiGateway", mock(AiGateway.class));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(false);
+        gate.setEnabled(true);
+        gate.setStatus(AiGateStatus.UPGRADE_REQUIRED);
+        when(membershipSvc.checkAiFeatureGate(1L)).thenReturn(gate);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("query", "嗨");
+        Result<Map<String, Object>> r = c.sendHealthButlerMessage(1L, params);
+        assertEquals(46003, r.getCode());
+        assertTrue(r.getMessage().contains("会员"));
+    }
+
+    @Test
+    @DisplayName("SC-H-02 相邻 权限:会员已过期 → 46004 续费引导")
+    void butlerSend_权限_已过期() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIChatController c = new AIChatController();
+        inject(c, "membershipService", membershipSvc);
+        inject(c, "portraitService", mock(PortraitService.class));
+        inject(c, "aiGateway", mock(AiGateway.class));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(false);
+        gate.setStatus(AiGateStatus.MEMBER_EXPIRED);
+        when(membershipSvc.checkAiFeatureGate(1L)).thenReturn(gate);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("query", "嗨");
+        Result<Map<String, Object>> r = c.sendHealthButlerMessage(1L, params);
+        assertEquals(46004, r.getCode());
+        assertTrue(r.getMessage().contains("会员已过期"));
+    }
+
+    @Test
+    @DisplayName("SC-H-02 相邻 异常:query 为空 → 消息不能为空(门禁前校验)")
+    void butlerSend_异常_query空() throws Exception {
+        AIChatController c = new AIChatController();
+        inject(c, "membershipService", mock(MembershipService.class));
+        inject(c, "portraitService", mock(PortraitService.class));
+        inject(c, "aiGateway", mock(AiGateway.class));
+
+        Result<Map<String, Object>> r = c.sendHealthButlerMessage(1L, new HashMap<>());
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("消息不能为空"));
+    }
+
+    // ==================== controller:ButlerController(AI 管家会话管理) ====================
+
+    @Test
+    @DisplayName("SC-H-02 相邻 正常:会员新建管家会话 → sessionId")
+    void butlerSession_正常_创建() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        ButlerSessionService sessionSvc = mock(ButlerSessionService.class);
+        ButlerController c = new ButlerController();
+        inject(c, "membershipService", membershipSvc);
+        inject(c, "butlerSessionService", sessionSvc);
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(1L)).thenReturn(gate);
+        when(sessionSvc.createSession(1L, "新会话", null)).thenReturn(555L);
+
+        Result<Map<String, Object>> r = c.createSession(1L, new HashMap<>());
+        assertEquals(200, r.getCode());
+        assertEquals(555L, r.getData().get("sessionId"));
+    }
+
+    @Test
+    @DisplayName("SC-H-02 相邻 权限:FREE 新建会话 → 46003(历史会话仍可读)")
+    void butlerSession_权限_非会员() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        ButlerController c = new ButlerController();
+        inject(c, "membershipService", membershipSvc);
+        inject(c, "butlerSessionService", mock(ButlerSessionService.class));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(false);
+        gate.setStatus(AiGateStatus.UPGRADE_REQUIRED);
+        when(membershipSvc.checkAiFeatureGate(1L)).thenReturn(gate);
+
+        Result<Map<String, Object>> r = c.createSession(1L, new HashMap<>());
+        assertEquals(46003, r.getCode());
+    }
+
+    @Test
+    @DisplayName("SC-H-02 相邻 权限:update-conversation 非本人会话 → 会话不存在或无权访问")
+    void butlerSession_权限_无权更新() throws Exception {
+        ButlerSessionService sessionSvc = mock(ButlerSessionService.class);
+        ButlerController c = new ButlerController();
+        inject(c, "membershipService", mock(MembershipService.class));
+        inject(c, "butlerSessionService", sessionSvc);
+
+        when(sessionSvc.getSession(555L, 1L)).thenReturn(null);
+        Map<String, Object> params = new HashMap<>();
+        params.put("sessionId", 555L);
+        params.put("conversationId", "conv-1");
+        Result<Void> r = c.updateConversationId(1L, params);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("会话不存在或无权访问"));
+    }
+
+    @Test
+    @DisplayName("SC-H-02 相邻 权限:删除非本人会话 → 会话不存在或无权删除")
+    void butlerSession_权限_无权删除() throws Exception {
+        ButlerSessionService sessionSvc = mock(ButlerSessionService.class);
+        ButlerController c = new ButlerController();
+        inject(c, "membershipService", mock(MembershipService.class));
+        inject(c, "butlerSessionService", sessionSvc);
+
+        when(sessionSvc.deleteSession(555L, 1L)).thenReturn(false);
+        Map<String, Object> params = new HashMap<>();
+        params.put("sessionId", 555L);
+        Result<Void> r = c.deleteSession(1L, params);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("无权删除"));
+    }
+
+    @Test
+    @DisplayName("SC-H-02 相邻 正常:会话列表分页默认 1/20 → 200")
+    void butlerSession_正常_列表() throws Exception {
+        ButlerSessionService sessionSvc = mock(ButlerSessionService.class);
+        ButlerController c = new ButlerController();
+        inject(c, "membershipService", mock(MembershipService.class));
+        inject(c, "butlerSessionService", sessionSvc);
+
+        @SuppressWarnings("unchecked")
+        Page<ButlerSession> page = mock(Page.class);
+        when(sessionSvc.getSessionsByUser(1L, 1, 20, null)).thenReturn(page);
+
+        Result<Page<ButlerSession>> r = c.listSessions(1L, new HashMap<>());
+        assertEquals(200, r.getCode());
+        verify(sessionSvc).getSessionsByUser(1L, 1, 20, null);
+    }
+
+    // ==================== controller:UserNotificationController(SC-C-19 消息通知) ====================
+
+    @Test
+    @DisplayName("SC-C-19 正常:通知列表(未读优先倒序 LIMIT 100)→ 200")
+    void notification_正常_列表() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+
+        UserNotification n1 = notif(1L, 10L, 0);
+        UserNotification n2 = notif(2L, 10L, 1);
+        when(mapper.selectList(any())).thenReturn(Arrays.asList(n1, n2));
+
+        Result<List<UserNotification>> r = c.list(10L);
+        assertEquals(200, r.getCode());
+        assertEquals(2, r.getData().size());
+    }
+
+    @Test
+    @DisplayName("SC-C-19 正常:单条标记已读(仅本人)→ 200 true")
+    void notification_正常_单条已读() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+
+        UserNotification n = notif(1L, 10L, 0);
+        when(mapper.selectById(1L)).thenReturn(n);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("id", 1L);
+        Result<Boolean> r = c.read(10L, params);
+        assertEquals(200, r.getCode());
+        assertEquals(1, n.getIsRead());
+        verify(mapper).updateById(n);
+    }
+
+    @Test
+    @DisplayName("SC-C-19 权限:他人通知不标记已读(归属校验)→ 200 true 但不改")
+    void notification_权限_他人通知() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+
+        UserNotification n = notif(1L, 999L, 0);
+        when(mapper.selectById(1L)).thenReturn(n);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("id", 1L);
+        Result<Boolean> r = c.read(10L, params);
+        assertEquals(200, r.getCode());
+        assertEquals(0, n.getIsRead(), "非本人通知不得标记已读");
+        verify(mapper, never()).updateById(n);
+    }
+
+    @Test
+    @DisplayName("SC-C-19 正常:缺省参数标记全部已读 → 200 true")
+    void notification_正常_全部已读() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+
+        UserNotification n1 = notif(1L, 10L, 0);
+        UserNotification n2 = notif(2L, 10L, 0);
+        when(mapper.selectList(any())).thenReturn(Arrays.asList(n1, n2));
+
+        Result<Boolean> r = c.read(10L, new HashMap<>());
+        assertEquals(200, r.getCode());
+        assertEquals(1, n1.getIsRead());
+        assertEquals(1, n2.getIsRead());
+        verify(mapper).updateById(n1);
+        verify(mapper).updateById(n2);
+    }
+
+    // ==================== SC-B-09 / SC-B-12 观察 ====================
+
+    /**
+     * SC-B-09(教练转接成长规划师)/ SC-B-12(同步规划师建议主动跟进)契约缺口观察(观察钉住,不修实现):
+     * Java 侧无独立「转接/跟进推送」端点——转接建议由 LangGraph(cfc-langgraph)prompt 回复承载,
+     * 主动跟进依赖既有 UserNotification 站内通知通道(SC-C-19)。以反射断言钉住:AIChatController
+     * 无 transfer/handover/guide 映射方法。
+     */
+    @Test
+    @DisplayName("SC-B-09/SC-B-12 观察:无独立转接/跟进端点(LangGraph 回复 + 站内通知承载)")
+    void pinsObserved_转接跟进无独立端点() throws Exception {
+        boolean hasTransfer = java.util.Arrays.stream(AIChatController.class.getDeclaredMethods())
+                .anyMatch(m -> m.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                        && (m.getName().toLowerCase().contains("transfer")
+                        || m.getName().toLowerCase().contains("handover")
+                        || m.getName().toLowerCase().contains("guid")));
+        assertEquals(false, hasTransfer, "教练转接/跟进无独立端点(LangGraph 回复承载,观察钉住)");
+    }
+
+    // ==================== helpers ====================
+
+    private static UserNotification notif(Long id, Long userId, int isRead) {
+        UserNotification n = new UserNotification();
+        n.setId(id);
+        n.setUserId(userId);
+        n.setIsRead(isRead);
+        n.setCreatedAt(new Date());
+        return n;
+    }
+}

+ 219 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/DanAssessmentExecutionContractTest.java

@@ -0,0 +1,219 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.dto.ServiceRateRequest;
+import com.etotem.cfc.entity.DanAssessmentExecution;
+import com.etotem.cfc.entity.ServiceRating;
+import com.etotem.cfc.mapper.DanAssessmentExecutionMapper;
+import com.etotem.cfc.mapper.ServiceRatingMapper;
+import com.etotem.cfc.service.DanAssessmentExecutionService;
+import com.etotem.cfc.service.DanSettlementService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+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.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * EVD-8 契约补建 · 测评-方案「DAN 测评执行(服务级)」批次三类流契约测试。
+ *
+ * <p>覆盖 SC(contract-plan 测评-方案):SC-X-01(测评全链路·执行记录:createExecution→
+ * completeExecution→confirmExecution 闭环)、SC-T-10 补充(预约完成→执行侧)、SC-T-30 相邻
+ * (执行确认触发结算/维度回写面)。DanAssessmentExecutionController 的 DTO 为包私有
+ * (com.etotem.cfc.controller.dan),unit 包无法构造 → 仅做 service 级契约。</p>
+ *
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB):正常流、异常流(执行记录不存在)、幂等流(确认结算)。</p>
+ */
+class DanAssessmentExecutionContractTest extends AssessmentPlanContractTestBase {
+
+    static {
+        initTable(DanAssessmentExecution.class);
+    }
+
+    private DanAssessmentExecutionMapper executionMapper;
+    private DanSettlementService settlementSvc;
+    private DanAssessmentExecutionService svc;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        executionMapper = mock(DanAssessmentExecutionMapper.class);
+        settlementSvc = mock(DanSettlementService.class);
+        svc = new DanAssessmentExecutionService();
+        inject(svc, "executionMapper", executionMapper);
+        inject(svc, "ratingMapper", mock(ServiceRatingMapper.class));
+        inject(svc, "settlementService", settlementSvc);
+    }
+
+    // ==================== SC-X-01 创建执行记录 ====================
+
+    @Test
+    @DisplayName("SC-X-01 正常:创建执行记录 → pending 双状态落库")
+    void create_正常() throws Exception {
+        when(executionMapper.insert(any(DanAssessmentExecution.class))).thenReturn(1);
+
+        DanAssessmentExecution exec = svc.createExecution(11L, 10L, 7L, 5L, 7L, null, 101L);
+        assertEquals("pending", exec.getAssessorStatus());
+        assertEquals("pending", exec.getPlannerStatus());
+        assertEquals(0, exec.getUserConfirmed());
+        assertEquals(11L, exec.getAssessmentOrderId());
+        assertEquals(101L, exec.getAppointmentId());
+        verify(executionMapper).insert(exec);
+    }
+
+    // ==================== SC-X-01 / SC-T-10 完成服务 ====================
+
+    @Test
+    @DisplayName("SC-X-01 正常:规划师完成服务 → plannerStatus=completed + 备注")
+    void complete_正常_planner() throws Exception {
+        DanAssessmentExecution exec = exec(201L);
+        when(executionMapper.selectById(201L)).thenReturn(exec);
+
+        svc.completeExecution(201L, "planner", "已解读方案");
+        assertEquals("completed", exec.getPlannerStatus());
+        assertEquals("已解读方案", exec.getPlannerNotes());
+        assertNotNull(exec.getPlannerCompletedAt());
+        verify(executionMapper).updateById(exec);
+    }
+
+    @Test
+    @DisplayName("SC-X-01 正常:评估师完成服务 → assessorStatus=completed")
+    void complete_正常_assessor() throws Exception {
+        DanAssessmentExecution exec = exec(201L);
+        when(executionMapper.selectById(201L)).thenReturn(exec);
+
+        svc.completeExecution(201L, "assessor", "已完成施测");
+        assertEquals("completed", exec.getAssessorStatus());
+        assertEquals("已完成施测", exec.getAssessorNotes());
+    }
+
+    @Test
+    @DisplayName("SC-X-01 异常:执行记录不存在 → 抛 Execution not found")
+    void complete_异常_不存在() throws Exception {
+        when(executionMapper.selectById(201L)).thenReturn(null);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> svc.completeExecution(201L, "planner", "x"));
+        assertTrue(ex.getMessage().contains("Execution not found"));
+    }
+
+    // ==================== SC-X-01 用户确认 → 自动结算 ====================
+
+    @Test
+    @DisplayName("SC-X-01 正常:用户确认服务 → userConfirmed=1 且自动结算")
+    void confirm_正常_结算() throws Exception {
+        DanAssessmentExecution exec = exec(201L);
+        when(executionMapper.selectById(201L)).thenReturn(exec);
+
+        svc.confirmExecution(201L, 1);
+        assertEquals(1, exec.getUserConfirmed());
+        assertNotNull(exec.getUserConfirmedAt());
+        verify(settlementSvc).autoSettle(201L);
+    }
+
+    @Test
+    @DisplayName("SC-X-01 正常:用户未确认(confirmed=0)→ 不触发结算")
+    void confirm_正常_未确认不结算() throws Exception {
+        DanAssessmentExecution exec = exec(201L);
+        when(executionMapper.selectById(201L)).thenReturn(exec);
+
+        svc.confirmExecution(201L, 0);
+        assertEquals(0, exec.getUserConfirmed());
+        verify(settlementSvc, org.mockito.Mockito.never()).autoSettle(anyLong());
+    }
+
+    // ==================== SC-X-01 服务评价 ====================
+
+    @Test
+    @DisplayName("SC-X-01 正常:用户评价服务 → 落库评分")
+    void rate_正常() throws Exception {
+        ServiceRatingMapper ratingMapper = mock(ServiceRatingMapper.class);
+        inject(svc, "ratingMapper", ratingMapper);
+        when(ratingMapper.insert(any(ServiceRating.class))).thenReturn(1);
+
+        ServiceRateRequest req = new ServiceRateRequest();
+        req.setExecutionId(201L);
+        req.setUserId(1L);
+        req.setAssessorId(5L);
+        req.setAssessorRating(5);
+        req.setPlannerId(7L);
+        req.setPlannerRating(4);
+        ServiceRating rating = svc.rateService(req);
+
+        ArgumentCaptor<ServiceRating> captor = ArgumentCaptor.forClass(ServiceRating.class);
+        verify(ratingMapper).insert(captor.capture());
+        assertEquals(201L, captor.getValue().getExecutionId());
+        assertEquals(1L, captor.getValue().getUserId());
+        assertEquals(5, captor.getValue().getAssessorRating());
+        assertNotNull(rating);
+    }
+
+    // ==================== SC-X-01 查询面 ====================
+
+    @Test
+    @DisplayName("SC-X-01 正常:执行详情(含结算信息)→ 200 字段映射")
+    void detail_正常() throws Exception {
+        DanAssessmentExecution exec = exec(201L);
+        exec.setAssessorId(5L);
+        exec.setPlannerId(7L);
+        when(executionMapper.selectById(201L)).thenReturn(exec);
+
+        com.etotem.cfc.dto.ExecutionDetailResponse resp = svc.getExecutionDetail(201L);
+        assertNotNull(resp);
+        assertEquals(201L, resp.getExecutionId());
+        assertEquals(7L, resp.getFamilyId());
+        assertEquals(5L, resp.getAssessorId());
+        assertEquals("completed", resp.getAssessorStatus());
+        verify(settlementSvc).getSettlement(201L, 5L, "assessor");
+        verify(settlementSvc).getSettlement(201L, 7L, "planner");
+    }
+
+    @Test
+    @DisplayName("SC-X-01 异常:执行详情不存在 → null")
+    void detail_异常_不存在() throws Exception {
+        when(executionMapper.selectById(201L)).thenReturn(null);
+        assertEquals(null, svc.getExecutionDetail(201L));
+    }
+
+    @Test
+    @DisplayName("SC-X-01 正常:按家庭查执行记录列表 → 倒序")
+    void list_正常_家庭() throws Exception {
+        when(executionMapper.selectList(any())).thenReturn(Collections.singletonList(exec(201L)));
+        List<DanAssessmentExecution> list = svc.getByFamilyId(7L);
+        assertEquals(1, list.size());
+    }
+
+    @Test
+    @DisplayName("SC-T-13 相邻 正常:规划师待执行任务(排除已 completed)→ 列表")
+    void list_正常_规划师待执行() throws Exception {
+        when(executionMapper.selectList(any())).thenReturn(Collections.singletonList(exec(201L)));
+        List<DanAssessmentExecution> list = svc.getByPlannerId(7L);
+        assertEquals(1, list.size());
+        verify(executionMapper).selectList(any());
+    }
+
+    // ==================== helpers ====================
+
+    private static DanAssessmentExecution exec(Long id) {
+        DanAssessmentExecution e = new DanAssessmentExecution();
+        e.setId(id);
+        e.setAssessmentOrderId(11L);
+        e.setMemberId(10L);
+        e.setFamilyId(7L);
+        e.setQuotaId(5L);
+        e.setAssessorStatus("completed");
+        e.setPlannerStatus("pending");
+        e.setUserConfirmed(0);
+        return e;
+    }
+}

+ 340 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuideBindingAccessContractTest.java

@@ -0,0 +1,340 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.family.BindController;
+import com.etotem.cfc.controller.family.TeacherFamilyBindingController;
+import com.etotem.cfc.controller.guide.GuideFamilyListController;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.entity.TeacherFamilyBindingRequest;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+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.TeacherFamilyBindingRequestService;
+import com.etotem.cfc.service.TeacherMessageService;
+import com.etotem.cfc.service.WechatService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * EVD-8 契约补建 · 测评-方案「规划师绑定/数据隔离」批次三类流契约测试。
+ *
+ * <p>覆盖 SC(contract-plan 测评-方案):SC-P-74(解绑/重新分配规划师)、SC-T-13(规划师视角数据隔离)、
+ * SC-T-01(规划师登录+切回家长·相邻端点观察)、SC-T-02/SC-T-06(绑定家庭/家庭列表)。</p>
+ *
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB):正常流(绑定/解绑/家庭列表)、异常流(无家庭 5001、
+ * 绑定不存在/越权 400)、权限流(非 teacher 角色守卫、归属校验 403)。</p>
+ */
+class GuideBindingAccessContractTest extends AssessmentPlanContractTestBase {
+
+    // ==================== service:GuideFamilyAccessGuard(SC-T-13 数据隔离唯一判定点) ====================
+
+    @Test
+    @DisplayName("SC-T-13 正常:teacher 已绑定家庭 → 放行(checkFamilyAccess null)")
+    void guard_正常_teacher已绑定() throws Exception {
+        GuideFamilyService guideFamilySvc = mock(GuideFamilyService.class);
+        GuideFamilyAccessGuard guard = new GuideFamilyAccessGuard();
+        inject(guard, "guideFamilyService", guideFamilySvc);
+
+        when(guideFamilySvc.isBound(7L, 1L)).thenReturn(true);
+        assertNull(guard.checkFamilyAccess("teacher", 7L, 1L));
+    }
+
+    @Test
+    @DisplayName("SC-T-13 权限:teacher 未绑定该家庭 → 403 无权访问")
+    void guard_权限_未绑定() throws Exception {
+        GuideFamilyService guideFamilySvc = mock(GuideFamilyService.class);
+        GuideFamilyAccessGuard guard = new GuideFamilyAccessGuard();
+        inject(guard, "guideFamilyService", guideFamilySvc);
+
+        when(guideFamilySvc.isBound(7L, 1L)).thenReturn(false);
+        Result<Void> r = guard.checkFamilyAccess("teacher", 7L, 1L);
+        assertEquals(403, r.getCode());
+        assertTrue(r.getMessage().contains("无权访问该家庭数据"));
+    }
+
+    @Test
+    @DisplayName("SC-T-13 权限:非 teacher/admin 角色 → 403")
+    void guard_权限_非teacher() throws Exception {
+        GuideFamilyService guideFamilySvc = mock(GuideFamilyService.class);
+        GuideFamilyAccessGuard guard = new GuideFamilyAccessGuard();
+        inject(guard, "guideFamilyService", guideFamilySvc);
+
+        Result<Void> r = guard.checkFamilyAccess("parent", 7L, 1L);
+        assertEquals(403, r.getCode());
+    }
+
+    @Test
+    @DisplayName("SC-T-13 权限:admin 跳过归属校验 → 放行")
+    void guard_权限_admin放行() throws Exception {
+        GuideFamilyService guideFamilySvc = mock(GuideFamilyService.class);
+        GuideFamilyAccessGuard guard = new GuideFamilyAccessGuard();
+        inject(guard, "guideFamilyService", guideFamilySvc);
+
+        assertNull(guard.checkFamilyAccess("admin", 1L, 2L));
+    }
+
+    @Test
+    @DisplayName("SC-T-13 异常:guideId/familyId 任一为空 → 403")
+    void guard_异常_缺参数() throws Exception {
+        GuideFamilyService guideFamilySvc = mock(GuideFamilyService.class);
+        GuideFamilyAccessGuard guard = new GuideFamilyAccessGuard();
+        inject(guard, "guideFamilyService", guideFamilySvc);
+
+        Result<Void> r = guard.checkBinding(7L, null);
+        assertEquals(403, r.getCode());
+    }
+
+    // ==================== controller:GuideFamilyListController(SC-T-06 家庭列表 / SC-T-13 隔离) ====================
+
+    @Test
+    @DisplayName("SC-T-06 正常:teacher 列出绑定家庭(含成员数)→ 200")
+    void guideFamilyList_正常() throws Exception {
+        UserMapper userMapper = mock(UserMapper.class);
+        FamilyMapper familyMapper = mock(FamilyMapper.class);
+        FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
+        GuideFamilyListController c = new GuideFamilyListController();
+        inject(c, "userMapper", userMapper);
+        inject(c, "familyMapper", familyMapper);
+        inject(c, "familyMemberMapper", familyMemberMapper);
+
+        User guide = new User();
+        guide.setId(7L);
+        guide.setTeacherFamilyIds("1,2");
+        when(userMapper.selectById(7L)).thenReturn(guide);
+
+        Family f1 = new Family();
+        f1.setId(1L);
+        f1.setName("张家庭");
+        when(familyMapper.selectById(1L)).thenReturn(f1);
+        when(familyMemberMapper.selectList(org.mockito.ArgumentMatchers.any()))
+                .thenReturn(Arrays.asList(new FamilyMember(), new FamilyMember()));
+
+        Result<List<Map<String, Object>>> r = c.list(7L, "teacher");
+        assertEquals(200, r.getCode());
+        assertEquals(1, r.getData().size());
+        assertEquals("张家庭", r.getData().get(0).get("familyName"));
+        assertEquals(2, r.getData().get(0).get("memberCount"));
+    }
+
+    @Test
+    @DisplayName("SC-T-06 权限:非 teacher 查家庭列表 → Access denied")
+    void guideFamilyList_权限_非teacher() throws Exception {
+        GuideFamilyListController c = new GuideFamilyListController();
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "familyMapper", mock(FamilyMapper.class));
+        inject(c, "familyMemberMapper", mock(FamilyMemberMapper.class));
+
+        Result<List<Map<String, Object>>> r = c.list(7L, "parent");
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("Access denied"));
+    }
+
+    @Test
+    @DisplayName("SC-T-06 正常:teacher 无绑定家庭 → 空列表")
+    void guideFamilyList_正常_无绑定() throws Exception {
+        UserMapper userMapper = mock(UserMapper.class);
+        GuideFamilyListController c = new GuideFamilyListController();
+        inject(c, "userMapper", userMapper);
+        inject(c, "familyMapper", mock(FamilyMapper.class));
+        inject(c, "familyMemberMapper", mock(FamilyMemberMapper.class));
+
+        User guide = new User();
+        guide.setId(7L);
+        guide.setTeacherFamilyIds(null);
+        when(userMapper.selectById(7L)).thenReturn(guide);
+
+        Result<List<Map<String, Object>>> r = c.list(7L, "teacher");
+        assertEquals(200, r.getCode());
+        assertTrue(r.getData().isEmpty());
+    }
+
+    // ==================== controller:TeacherFamilyBindingController(SC-P-74 重新分配 / 绑定审批) ====================
+
+    @Test
+    @DisplayName("SC-P-74 正常:家庭主账号查待审批绑定申请 → 200")
+    void binding_pending_正常() throws Exception {
+        TeacherFamilyBindingRequestService bindingSvc = mock(TeacherFamilyBindingRequestService.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        TeacherFamilyBindingController c = new TeacherFamilyBindingController();
+        inject(c, "bindingRequestService", bindingSvc);
+        inject(c, "userMapper", userMapper);
+        inject(c, "wechatService", mock(WechatService.class));
+
+        User user = new User();
+        user.setId(1L);
+        user.setFamilyId(7L);
+        when(userMapper.selectById(1L)).thenReturn(user);
+        when(bindingSvc.getPendingByFamily(7L)).thenReturn(Collections.emptyList());
+
+        Result<List<TeacherFamilyBindingRequest>> r = c.getPending(1L);
+        assertEquals(200, r.getCode());
+        assertTrue(r.getData().isEmpty());
+    }
+
+    @Test
+    @DisplayName("SC-P-74 权限:用户无家庭 → 5001 请先创建或加入家庭")
+    void binding_pending_权限_无家庭() throws Exception {
+        UserMapper userMapper = mock(UserMapper.class);
+        TeacherFamilyBindingController c = new TeacherFamilyBindingController();
+        inject(c, "bindingRequestService", mock(TeacherFamilyBindingRequestService.class));
+        inject(c, "userMapper", userMapper);
+        inject(c, "wechatService", mock(WechatService.class));
+
+        when(userMapper.selectById(1L)).thenReturn(null);
+        Result<List<TeacherFamilyBindingRequest>> r = c.getPending(1L);
+        assertEquals(5001, r.getCode());
+        assertTrue(r.getMessage().contains("请先创建或加入家庭"));
+    }
+
+    @Test
+    @DisplayName("SC-P-74 正常:审批通过绑定 → 审批通过")
+    void binding_approve_正常() throws Exception {
+        TeacherFamilyBindingRequestService bindingSvc = mock(TeacherFamilyBindingRequestService.class);
+        TeacherFamilyBindingController c = new TeacherFamilyBindingController();
+        inject(c, "bindingRequestService", bindingSvc);
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "wechatService", mock(WechatService.class));
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("requestId", 5L);
+        Result<String> r = c.approve(1L, body);
+        assertEquals(200, r.getCode());
+        assertEquals("审批通过", r.getData());
+        verify(bindingSvc).approve(5L, 1L);
+    }
+
+    @Test
+    @DisplayName("SC-P-74 异常:审批绑定业务异常 → 错误消息透传")
+    void binding_approve_异常() throws Exception {
+        TeacherFamilyBindingRequestService bindingSvc = mock(TeacherFamilyBindingRequestService.class);
+        TeacherFamilyBindingController c = new TeacherFamilyBindingController();
+        inject(c, "bindingRequestService", bindingSvc);
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "wechatService", mock(WechatService.class));
+
+        doThrow(new RuntimeException("绑定申请不存在或无权审批")).when(bindingSvc).approve(5L, 1L);
+        Map<String, Object> body = new HashMap<>();
+        body.put("requestId", 5L);
+        Result<String> r = c.approve(1L, body);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("不存在或无权审批"));
+    }
+
+    @Test
+    @DisplayName("SC-P-74 正常:规划师撤销申请 → 已撤销")
+    void binding_cancel_正常() throws Exception {
+        TeacherFamilyBindingRequestService bindingSvc = mock(TeacherFamilyBindingRequestService.class);
+        TeacherFamilyBindingController c = new TeacherFamilyBindingController();
+        inject(c, "bindingRequestService", bindingSvc);
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "wechatService", mock(WechatService.class));
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("requestId", 5L);
+        Result<String> r = c.cancel(7L, body);
+        assertEquals(200, r.getCode());
+        assertEquals("已撤销", r.getData());
+        verify(bindingSvc).cancel(5L, 7L);
+    }
+
+    // ==================== controller:BindController.unbind(SC-P-74 解绑) ====================
+
+    @Test
+    @DisplayName("SC-P-74 正常:家庭主账号解绑规划师 → 成功并通知规划师")
+    void unbind_正常() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        GuideFamilyService guideFamilySvc = mock(GuideFamilyService.class);
+        TeacherMessageService teacherMsgSvc = mock(TeacherMessageService.class);
+        BindController c = new BindController();
+        inject(c, "membershipService", membershipSvc);
+        inject(c, "guideFamilyService", guideFamilySvc);
+        inject(c, "teacherMessageService", teacherMsgSvc);
+
+        when(membershipSvc.getUserFamilyId(1L)).thenReturn(7L);
+        GuideFamily gf = new GuideFamily();
+        gf.setGuideId(7L);
+        when(guideFamilySvc.unbindAndGet(12L, 7L)).thenReturn(gf);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 12L);
+        Result<Boolean> r = c.unbind(1L, body);
+        assertEquals(200, r.getCode());
+        assertEquals(true, r.getData());
+        verify(teacherMsgSvc).sendMessage(eq(7L), eq(1L), eq(7L), org.mockito.ArgumentMatchers.anyString());
+    }
+
+    @Test
+    @DisplayName("SC-P-74 权限:家庭主账号无家庭 → 5001 请先创建或加入家庭")
+    void unbind_权限_无家庭() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        BindController c = new BindController();
+        inject(c, "membershipService", membershipSvc);
+        inject(c, "guideFamilyService", mock(GuideFamilyService.class));
+        inject(c, "teacherMessageService", mock(TeacherMessageService.class));
+
+        when(membershipSvc.getUserFamilyId(1L)).thenReturn(null);
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 12L);
+        Result<Boolean> r = c.unbind(1L, body);
+        assertEquals(5001, r.getCode());
+        assertTrue(r.getMessage().contains("请先创建或加入家庭"));
+    }
+
+    @Test
+    @DisplayName("SC-P-74 异常:解绑记录不存在/越权 → 400 错误消息")
+    void unbind_异常_不存在() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        GuideFamilyService guideFamilySvc = mock(GuideFamilyService.class);
+        BindController c = new BindController();
+        inject(c, "membershipService", membershipSvc);
+        inject(c, "guideFamilyService", guideFamilySvc);
+        inject(c, "teacherMessageService", mock(TeacherMessageService.class));
+
+        when(membershipSvc.getUserFamilyId(1L)).thenReturn(7L);
+        doThrow(new RuntimeException("绑定关系不存在")).when(guideFamilySvc).unbindAndGet(12L, 7L);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 12L);
+        Result<Boolean> r = c.unbind(1L, body);
+        assertEquals(400, r.getCode());
+        assertTrue(r.getMessage().contains("绑定关系不存在"));
+    }
+
+    // ==================== SC-T-01 观察(规划师登录+切回家长) ====================
+
+    /**
+     * SC-T-01(规划师登录 + 切回家长)契约缺口观察(观察钉住,不修实现):
+     * 角色切换(teacher↔parent)是既有认证/用户上下文能力(JWT role + FamilyUserSwitchRoleTest 已覆盖),
+     * 测评-方案批次无独立「登录/切角色」端点;此处以反射断言钉住:GuideBindingAccessContractTest
+     * 面向的是绑定/数据隔离,登录切换不属测评-方案端点面。
+     */
+    @Test
+    @DisplayName("SC-T-01 观察:角色切换走既有认证上下文,无测评-方案独立端点")
+    void pinsObserved_角色切换无独立端点() throws Exception {
+        boolean guidePlansExposed = GuideFamilyListController.class.isAnnotationPresent(
+                org.springframework.web.bind.annotation.RestController.class);
+        assertEquals(true, guidePlansExposed, "绑定/家庭列表端点在 guide 面(角色切换由 JWT 认证承载,观察钉住)");
+    }
+}

+ 459 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/PlanActivationExecutionContractTest.java

@@ -0,0 +1,459 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.assessment.AssessmentReportController;
+import com.etotem.cfc.entity.DanAssessmentExecution;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TaskPlanInstance;
+import com.etotem.cfc.entity.TaskTemplateItem;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.DanAssessmentExecutionMapper;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.TaskExecutionMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
+import com.etotem.cfc.mapper.TaskTemplateItemMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AssessmentPlanGenerator;
+import com.etotem.cfc.service.DanAssessmentExecutionService;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.NotificationDispatchService;
+import com.etotem.cfc.service.PlanActivationService;
+import com.etotem.cfc.service.StreakService;
+import com.etotem.cfc.service.TaskPlanService;
+import com.etotem.cfc.service.TaskReminderConfigService;
+import com.etotem.cfc.service.TaskService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+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.assertThrows;
+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.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * EVD-8 契约补建 · 测评-方案「方案激活/执行/测评结果触发」批次三类流契约测试。
+ *
+ * <p>覆盖 SC(contract-plan 测评-方案):SC-P-46(激活方案生成任务)、SC-T-05(激活拆任务·幂等)、
+ * SC-T-07(录入测评结果触发方案:confirm-inbox→自动生成)、SC-X-06(方案激活执行闭环)、
+ * SC-C-25(方案任务打卡触发五维增长:completeTask→awardEnergy("task"))、
+ * SC-T-30(测评录入触发五维维度变化·观察:维度变化走任务/方案回写,无独立端点)。</p>
+ *
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB):正常流、异常流(状态/成员/报告不存在)、
+ * 幂等流(重复激活/重复拆任务不重复落库)。</p>
+ */
+class PlanActivationExecutionContractTest extends AssessmentPlanContractTestBase {
+
+    static {
+        initTable(Task.class);
+        initTable(TaskPlanInstance.class);
+        initTable(DanAssessmentExecution.class);
+    }
+
+    // ==================== service:PlanActivationService(SC-P-46 / SC-T-05 / SC-X-06) ====================
+
+    @Test
+    @DisplayName("SC-P-46/SC-X-06 正常:reviewed 方案激活 → 拆任务 + 状态 active")
+    void activate_正常() throws Exception {
+        TaskPlanInstanceMapper planMapper = mock(TaskPlanInstanceMapper.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
+        TaskPlanService taskPlanSvc = mock(TaskPlanService.class);
+        PlanActivationService svc = new PlanActivationService();
+        inject(svc, "planMapper", planMapper);
+        inject(svc, "userMapper", userMapper);
+        inject(svc, "familyMemberMapper", familyMemberMapper);
+        inject(svc, "taskPlanService", taskPlanSvc);
+
+        TaskPlanInstance plan = taskPlan(88L, "reviewed");
+        plan.setMemberId(10L);
+        when(planMapper.selectById(88L)).thenReturn(plan);
+        when(userMapper.selectById(10L)).thenReturn(null);
+        when(familyMemberMapper.selectById(10L)).thenReturn(new FamilyMember());
+        when(taskPlanSvc.generateTasksFromTemplate(plan, 7L)).thenReturn(4);
+
+        svc.activate(88L, 7L);
+        assertEquals("active", plan.getStatus(), "激活后状态=active");
+        assertEquals(14, plan.getTotalDays());
+        assertEquals(0, plan.getCompletedDays());
+        verify(taskPlanSvc).generateTasksFromTemplate(plan, 7L);
+        verify(planMapper).updateById(plan);
+    }
+
+    @Test
+    @DisplayName("SC-P-46 异常:方案不存在 → 抛「方案不存在」")
+    void activate_异常_方案不存在() throws Exception {
+        TaskPlanInstanceMapper planMapper = mock(TaskPlanInstanceMapper.class);
+        PlanActivationService svc = new PlanActivationService();
+        inject(svc, "planMapper", planMapper);
+        inject(svc, "userMapper", mock(UserMapper.class));
+        inject(svc, "familyMemberMapper", mock(FamilyMemberMapper.class));
+        inject(svc, "taskPlanService", mock(TaskPlanService.class));
+
+        when(planMapper.selectById(88L)).thenReturn(null);
+        IllegalStateException ex = assertThrows(IllegalStateException.class, () -> svc.activate(88L, 7L));
+        assertTrue(ex.getMessage().contains("方案不存在"));
+    }
+
+    @Test
+    @DisplayName("SC-P-46 异常:未审核(active)激活 → 抛「需要先审核通过」")
+    void activate_异常_状态未审核() throws Exception {
+        TaskPlanInstanceMapper planMapper = mock(TaskPlanInstanceMapper.class);
+        PlanActivationService svc = new PlanActivationService();
+        inject(svc, "planMapper", planMapper);
+        inject(svc, "userMapper", mock(UserMapper.class));
+        inject(svc, "familyMemberMapper", mock(FamilyMemberMapper.class));
+        inject(svc, "taskPlanService", mock(TaskPlanService.class));
+
+        when(planMapper.selectById(88L)).thenReturn(taskPlan(88L, "generated"));
+        IllegalStateException ex = assertThrows(IllegalStateException.class, () -> svc.activate(88L, 7L));
+        assertTrue(ex.getMessage().contains("需要先审核通过"));
+    }
+
+    @Test
+    @DisplayName("SC-P-46 异常:成员不存在 → 抛「成员不存在」")
+    void activate_异常_成员不存在() throws Exception {
+        TaskPlanInstanceMapper planMapper = mock(TaskPlanInstanceMapper.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
+        PlanActivationService svc = new PlanActivationService();
+        inject(svc, "planMapper", planMapper);
+        inject(svc, "userMapper", userMapper);
+        inject(svc, "familyMemberMapper", familyMemberMapper);
+        inject(svc, "taskPlanService", mock(TaskPlanService.class));
+
+        TaskPlanInstance plan = taskPlan(88L, "reviewed");
+        plan.setMemberId(10L);
+        when(planMapper.selectById(88L)).thenReturn(plan);
+        when(userMapper.selectById(10L)).thenReturn(null);
+        when(familyMemberMapper.selectById(10L)).thenReturn(null);
+
+        IllegalStateException ex = assertThrows(IllegalStateException.class, () -> svc.activate(88L, 7L));
+        assertTrue(ex.getMessage().contains("成员不存在"));
+    }
+
+    @Test
+    @DisplayName("SC-T-05 幂等:方案已有未取消任务 → 不再重复拆(返回 0)")
+    void generateTasks_幂等_已拆过() throws Exception {
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        TaskTemplateItemMapper templateItemMapper = mock(TaskTemplateItemMapper.class);
+        TaskPlanService svc = new TaskPlanService();
+        inject(svc, "taskMapper", taskMapper);
+        inject(svc, "templateItemMapper", templateItemMapper);
+
+        // 已存在 sourceType=plan & sourceId=88 且非 cancelled → 幂等跳过
+        when(taskMapper.selectCount(any())).thenReturn(3L);
+        TaskPlanInstance plan = taskPlan(88L, "reviewed");
+        plan.setPackageId(9L);
+
+        int count = svc.generateTasksFromTemplate(plan, 7L);
+        assertEquals(0, count, "已拆过任务应幂等返回 0");
+        verify(taskMapper, never()).insert(any(Task.class));
+    }
+
+    @Test
+    @DisplayName("SC-T-05/SC-P-46 正常:首次拆任务 = 1 引导任务 + N 模板任务")
+    void generateTasks_正常_引导加模板() throws Exception {
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        TaskTemplateItemMapper templateItemMapper = mock(TaskTemplateItemMapper.class);
+        TaskPlanService svc = new TaskPlanService();
+        inject(svc, "taskMapper", taskMapper);
+        inject(svc, "templateItemMapper", templateItemMapper);
+
+        when(taskMapper.selectCount(any())).thenReturn(0L);
+
+        TaskTemplateItem item1 = new TaskTemplateItem();
+        item1.setId(501L);
+        item1.setTaskName("晨起运动");
+        item1.setDescription("拉伸");
+        item1.setPoints(3);
+        TaskTemplateItem item2 = new TaskTemplateItem();
+        item2.setId(502L);
+        item2.setTaskName("睡前阅读");
+        item2.setPoints(5);
+        when(templateItemMapper.selectList(any())).thenReturn(Arrays.asList(item1, item2));
+
+        TaskPlanInstance plan = taskPlan(88L, "reviewed");
+        plan.setPackageId(9L);
+        int count = svc.generateTasksFromTemplate(plan, 7L);
+        assertEquals(3, count, "1 引导任务 + 2 模板任务");
+
+        ArgumentCaptor<Task> captor = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper, org.mockito.Mockito.times(3)).insert(captor.capture());
+        List<Task> inserted = captor.getAllValues();
+        assertEquals("方案启动: " + plan.getName(), inserted.get(0).getTitle());
+        for (Task t : inserted) {
+            assertEquals("plan", t.getSourceType());
+            assertEquals(88L, t.getSourceId());
+            assertEquals(10L, t.getMemberId());
+            assertTrue(inserted.get(0).getDeadline().after(new Date(System.currentTimeMillis() - 1000)));
+        }
+    }
+
+    // ==================== controller:AssessmentReportController(SC-T-07 录入测评结果触发方案) ====================
+
+    @Test
+    @DisplayName("SC-T-07 正常:确认报告入库 → completed + 自动生成测评方案")
+    void confirmInbox_正常_触发方案() throws Exception {
+        DanAssessmentResultMapper resultMapper = mock(DanAssessmentResultMapper.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        AssessmentPlanGenerator planGenerator = mock(AssessmentPlanGenerator.class);
+        AssessmentReportController c = new AssessmentReportController();
+        inject(c, "resultMapper", resultMapper);
+        inject(c, "userMapper", userMapper);
+        inject(c, "planGenerator", planGenerator);
+
+        DanAssessmentResult result = new DanAssessmentResult();
+        result.setId(300L);
+        result.setMemberId(10L);
+        result.setStatus("draft");
+        when(resultMapper.selectById(300L)).thenReturn(result);
+
+        User child = new User();
+        child.setId(10L);
+        child.setFamilyId(7L);
+        when(userMapper.selectById(10L)).thenReturn(child);
+
+        TaskPlanInstance plan = taskPlan(99L, "generated");
+        when(planGenerator.generate(result, 7L, 10L)).thenReturn(plan);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("overallScore", 85);
+        Result<DanAssessmentResult> r = c.confirmUpload(300L, body);
+        assertEquals(200, r.getCode());
+        assertEquals("completed", r.getData().getStatus());
+        assertEquals(85, r.getData().getOverallScore());
+        verify(planGenerator).generate(result, 7L, 10L);
+    }
+
+    @Test
+    @DisplayName("SC-T-07 异常:报告不存在 → 业务提示,不触发方案")
+    void confirmInbox_异常_报告不存在() throws Exception {
+        DanAssessmentResultMapper resultMapper = mock(DanAssessmentResultMapper.class);
+        AssessmentReportController c = new AssessmentReportController();
+        inject(c, "resultMapper", resultMapper);
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planGenerator", mock(AssessmentPlanGenerator.class));
+
+        when(resultMapper.selectById(300L)).thenReturn(null);
+        Result<DanAssessmentResult> r = c.confirmUpload(300L, new HashMap<>());
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("报告不存在"));
+    }
+
+    @Test
+    @DisplayName("SC-T-07 异常:孩子无家庭 → 仅入库不入方案(生成跳过,不抛错)")
+    void confirmInbox_异常_无家庭跳过生成() throws Exception {
+        DanAssessmentResultMapper resultMapper = mock(DanAssessmentResultMapper.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        AssessmentPlanGenerator planGenerator = mock(AssessmentPlanGenerator.class);
+        AssessmentReportController c = new AssessmentReportController();
+        inject(c, "resultMapper", resultMapper);
+        inject(c, "userMapper", userMapper);
+        inject(c, "planGenerator", planGenerator);
+
+        DanAssessmentResult result = new DanAssessmentResult();
+        result.setId(300L);
+        result.setMemberId(10L);
+        result.setStatus("draft");
+        when(resultMapper.selectById(300L)).thenReturn(result);
+        when(userMapper.selectById(10L)).thenReturn(null);
+
+        Result<DanAssessmentResult> r = c.confirmUpload(300L, new HashMap<>());
+        assertEquals(200, r.getCode());
+        assertEquals("completed", r.getData().getStatus());
+        verify(planGenerator, never()).generate(any(), anyLong(), anyLong());
+    }
+
+    // ==================== service:TaskService.completeTask(SC-C-25 方案任务打卡触发五维增长) ====================
+
+    @Test
+    @DisplayName("SC-C-25 正常:方案任务打卡完成 → 发积分 + 触发五维能量 awardEnergy(task)")
+    void completeTask_正常_打卡触发五维() throws Exception {
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        TaskExecutionMapper taskExecutionMapper = mock(TaskExecutionMapper.class);
+        FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
+        PointsLogMapper pointsLogMapper = mock(PointsLogMapper.class);
+        StreakService streakService = mock(StreakService.class);
+        EnergyService energyService = mock(EnergyService.class);
+        TaskReminderConfigService reminderCfgSvc = mock(TaskReminderConfigService.class);
+        NotificationDispatchService notificationDispatchSvc = mock(NotificationDispatchService.class);
+
+        TaskService svc = new TaskService();
+        inject(svc, "taskMapper", taskMapper);
+        inject(svc, "taskExecutionMapper", taskExecutionMapper);
+        inject(svc, "familyMemberMapper", familyMemberMapper);
+        inject(svc, "pointsLogMapper", pointsLogMapper);
+        inject(svc, "streakService", streakService);
+        inject(svc, "energyService", energyService);
+        inject(svc, "taskReminderConfigService", reminderCfgSvc);
+        inject(svc, "notificationDispatchService", notificationDispatchSvc);
+
+        Task task = new Task();
+        task.setId(88L);
+        task.setStatus("pending");
+        task.setFamilyId(7L);
+        task.setMemberId(10L);
+        task.setFamilyMemberId(10L);
+        task.setTitle("晨起运动");
+        task.setPoints(2);
+        task.setDeadline(new Date(System.currentTimeMillis() + 24 * 3600 * 1000));
+        task.setCategory("成长");
+        when(taskMapper.selectById(88L)).thenReturn(task);
+
+        FamilyMember child = new FamilyMember();
+        child.setId(10L);
+        child.setTotalPoints(0);
+        child.setStreakDays(0);
+        child.setUserId(null);
+        when(familyMemberMapper.selectById(10L)).thenReturn(child);
+        when(streakService.checkAndAwardMilestone(10L, 0)).thenReturn(0);
+        when(reminderCfgSvc.loadAllConfigs()).thenReturn(Collections.emptyMap());
+
+        Map<String, Object> result = svc.completeTask(88L, 10L, null, null, null);
+        assertEquals("completed", task.getStatus());
+        assertTrue(((Integer) result.get("pointsEarned")) >= 2);
+        verify(energyService).awardEnergy(eq(10L), eq("task"), eq(88L), org.mockito.ArgumentMatchers.anyInt(),
+                any(), isNull());
+    }
+
+    @Test
+    @DisplayName("SC-C-25 幂等:已完成任务重复打卡 → 幂等成功,不重复发奖")
+    void completeTask_幂等_已完成() throws Exception {
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        TaskService svc = new TaskService();
+        inject(svc, "taskMapper", taskMapper);
+        inject(svc, "taskExecutionMapper", mock(TaskExecutionMapper.class));
+        inject(svc, "familyMemberMapper", mock(FamilyMemberMapper.class));
+        inject(svc, "pointsLogMapper", mock(PointsLogMapper.class));
+        inject(svc, "streakService", mock(StreakService.class));
+        inject(svc, "energyService", mock(EnergyService.class));
+        inject(svc, "taskReminderConfigService", mock(TaskReminderConfigService.class));
+        inject(svc, "notificationDispatchService", mock(NotificationDispatchService.class));
+
+        Task task = new Task();
+        task.setId(88L);
+        task.setStatus("completed");
+        task.setFamilyMemberId(10L);
+        when(taskMapper.selectById(88L)).thenReturn(task);
+
+        Map<String, Object> result = svc.completeTask(88L, 10L, null, null, null);
+        assertTrue(((Integer) result.get("pointsEarned")) == 0, "重复打卡不发奖");
+    }
+
+    // ==================== SC-T-30 观察(测评录入触发五维维度变化) ====================
+
+    /**
+     * SC-T-30(测评录入触发五维维度变化)契约缺口观察(观察钉住,不修实现):
+     * confirm-inbox(SC-T-07)只做「状态 completed + 自动生成测评方案」,方案内容携带维度板块,
+     * 但无独立的「录入即回写五维维度分」调用点——维度分数变化实际伴随方案任务执行奖励
+     * (TaskService awardTaskCompletionRewards → EnergyService.awardEnergy("task"),见 SC-C-25 用例)。
+     * 此处以行为断言钉住现状:confirm-inbox 不回调任何维度写入服务。
+     */
+    @Test
+    @DisplayName("SC-T-30 观察:测评录入本身不直接回写维度(生成方案+状态置位,维度变化走执行奖励)")
+    void pinsObserved_测评录入不直接回写维度() throws Exception {
+        DanAssessmentResultMapper resultMapper = mock(DanAssessmentResultMapper.class);
+        UserMapper userMapper = mock(UserMapper.class);
+        AssessmentPlanGenerator planGenerator = mock(AssessmentPlanGenerator.class);
+        AssessmentReportController c = new AssessmentReportController();
+        inject(c, "resultMapper", resultMapper);
+        inject(c, "userMapper", userMapper);
+        inject(c, "planGenerator", planGenerator);
+
+        DanAssessmentResult result = new DanAssessmentResult();
+        result.setId(300L);
+        result.setMemberId(10L);
+        result.setStatus("draft");
+        when(resultMapper.selectById(300L)).thenReturn(result);
+        User child = new User();
+        child.setId(10L);
+        child.setFamilyId(7L);
+        when(userMapper.selectById(10L)).thenReturn(child);
+        when(planGenerator.generate(result, 7L, 10L)).thenReturn(taskPlan(99L, "generated"));
+
+        // 契约缺口观察:confirm-inbox 无维度回写调用点(维度变化由方案任务执行奖励承载),
+        // 且控制器不持有任何 Dimension*Sync 回写依赖 —— 以反射断言钉住现状(不修实现)。
+        boolean hasDimSyncField = java.util.Arrays.stream(AssessmentReportController.class.getDeclaredFields())
+                .anyMatch(f -> f.getType().getSimpleName().contains("DimensionEnergySync")
+                        || f.getType().getSimpleName().contains("DimensionBaseScore"));
+        assertEquals(false, hasDimSyncField, "confirm-inbox 无维度回写依赖(观察钉住,不修实现)");
+
+        Result<DanAssessmentResult> r = c.confirmUpload(300L, new HashMap<>());
+        assertEquals(200, r.getCode());
+        assertEquals("completed", r.getData().getStatus());
+    }
+
+    // ==================== service:DanAssessmentExecutionService(SC-X-06 执行侧补充) ====================
+
+    @Test
+    @DisplayName("SC-X-06 正常:规划师/评估师完成服务 → 状态 completed + 备注")
+    void execution_正常_完成() throws Exception {
+        DanAssessmentExecutionMapper mapper = mock(DanAssessmentExecutionMapper.class);
+        DanAssessmentExecutionService svc = new DanAssessmentExecutionService();
+        inject(svc, "executionMapper", mapper);
+        inject(svc, "ratingMapper", mock(com.etotem.cfc.mapper.ServiceRatingMapper.class));
+        inject(svc, "settlementService", mock(com.etotem.cfc.service.DanSettlementService.class));
+
+        DanAssessmentExecution exec = new DanAssessmentExecution();
+        exec.setId(201L);
+        exec.setPlannerStatus("pending");
+        exec.setAssessorStatus("pending");
+        when(mapper.selectById(201L)).thenReturn(exec);
+
+        svc.completeExecution(201L, "planner", "已解读方案");
+        assertEquals("completed", exec.getPlannerStatus());
+        assertTrue(exec.getPlannerCompletedAt() != null);
+        verify(mapper).updateById(exec);
+    }
+
+    @Test
+    @DisplayName("SC-X-06 异常:执行记录不存在 → 抛 Execution not found")
+    void execution_异常_不存在() throws Exception {
+        DanAssessmentExecutionMapper mapper = mock(DanAssessmentExecutionMapper.class);
+        DanAssessmentExecutionService svc = new DanAssessmentExecutionService();
+        inject(svc, "executionMapper", mapper);
+        inject(svc, "ratingMapper", mock(com.etotem.cfc.mapper.ServiceRatingMapper.class));
+        inject(svc, "settlementService", mock(com.etotem.cfc.service.DanSettlementService.class));
+
+        when(mapper.selectById(201L)).thenReturn(null);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> svc.completeExecution(201L, "planner", "x"));
+        assertTrue(ex.getMessage().contains("Execution not found"));
+    }
+
+    // ==================== helpers ====================
+
+    private static TaskPlanInstance taskPlan(Long id, String status) {
+        TaskPlanInstance p = new TaskPlanInstance();
+        p.setId(id);
+        p.setFamilyId(7L);
+        p.setMemberId(10L);
+        p.setName("DAN测评-成长方案");
+        p.setStatus(status);
+        p.setTotalDays(14);
+        p.setCompletedDays(0);
+        p.setCreatedAt(new Date());
+        p.setUpdatedAt(new Date());
+        return p;
+    }
+}

+ 488 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/PlanLifecycleParentContractTest.java

@@ -0,0 +1,488 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.HealthPlanController;
+import com.etotem.cfc.dto.PlanApproveResultDTO;
+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.HealthPlanService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.PurchaseService;
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+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.assertThrows;
+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.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * EVD-8 契约补建 · 测评-方案「方案生命周期(家长侧)」批次三类流契约测试。
+ *
+ * <p>覆盖 SC(contract-plan 测评-方案):SC-P-40(生成 AI 方案)、SC-P-41(分板块重生成)、
+ * SC-P-42(方案编辑/调整→save)、SC-P-43(查看方案 list/detail/tasks)、SC-P-44(确认方案送审发布)、
+ * SC-P-45(方案直接发布→approve 路径)、SC-P-47(查看方案任务列表)、SC-P-48(方案版本/历史=list 倒序)、
+ * SC-P-81(方案草稿保存与恢复)、SC-P-32/SC-P-74(可选规划师列表)、SC-X-09(送审闭环·审核流转在
+ * PlanReviewTeacherContractTest 覆盖,本类覆盖家长侧提交与角色守卫)。</p>
+ *
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB):正常流、异常流(参数缺失/不存在/付费墙 4001)、
+ * 权限流(非 teacher/admin 角色守卫)。</p>
+ */
+class PlanLifecycleParentContractTest extends AssessmentPlanContractTestBase {
+
+    private HealthPlanService planSvc;
+    private MembershipService membershipSvc;
+    private PurchaseService purchaseSvc;
+    private HealthPlanController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        planSvc = mock(HealthPlanService.class);
+        membershipSvc = mock(MembershipService.class);
+        purchaseSvc = mock(PurchaseService.class);
+        controller = new HealthPlanController();
+        inject(controller, "healthPlanService", planSvc);
+        inject(controller, "membershipService", membershipSvc);
+        inject(controller, "purchaseService", purchaseSvc);
+    }
+
+    // ==================== SC-P-40 生成 AI 方案 ====================
+
+    @Test
+    @DisplayName("SC-P-40 正常:会员生成方案 → 200")
+    void generate_正常_会员() throws Exception {
+        when(membershipSvc.getMemberLevel(1L)).thenReturn("BASIC");
+        when(planSvc.generatePlanResult(7L, "10", "wisdom,body", "提升专注力"))
+                .thenReturn("## 方案概述\n...");
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberIds", "10");
+        params.put("dimensions", "wisdom,body");
+        params.put("goal", "提升专注力");
+
+        Result<Object> r = controller.generate(params, 1L, 7L);
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+    }
+
+    @Test
+    @DisplayName("SC-P-40 权限:非会员且无健康启航计划 → 4001 付费墙")
+    void generate_权限_付费墙() throws Exception {
+        when(membershipSvc.getMemberLevel(1L)).thenReturn("FREE");
+        Map<String, Object> check = new HashMap<>();
+        check.put("isActive", false);
+        when(purchaseSvc.checkPurchase(1L)).thenReturn(check);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberIds", "10");
+        params.put("dimensions", "wisdom");
+        params.put("goal", "提升专注力");
+        Result<Object> r = controller.generate(params, 1L, 7L);
+        assertEquals(4001, r.getCode());
+        assertTrue(r.getMessage().contains("会员"));
+    }
+
+    @Test
+    @DisplayName("SC-P-40 异常:dimensions/goal 为空 → 业务提示")
+    void generate_异常_参数缺失() throws Exception {
+        when(membershipSvc.getMemberLevel(1L)).thenReturn("BASIC");
+        Map<String, Object> params = new HashMap<>();
+        params.put("dimensions", "wisdom");
+        Result<Object> r = controller.generate(params, 1L, 7L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("dimensions和goal不能为空"));
+    }
+
+    // ==================== SC-P-41 分板块重生成 ====================
+
+    @Test
+    @DisplayName("SC-P-41 正常:分板块重生成 → 200")
+    void regenerate_正常() throws Exception {
+        when(planSvc.regenerateSection(7L, "nutrition", "多蛋白质", "{\"sections\":[]}"))
+                .thenReturn("新营养板块");
+        Map<String, Object> params = new HashMap<>();
+        params.put("section", "nutrition");
+        params.put("feedback", "多蛋白质");
+        params.put("planJson", "{\"sections\":[]}");
+        Result<String> r = controller.regenerateSection(params, 1L, 7L);
+        assertEquals(200, r.getCode());
+        assertEquals("新营养板块", r.getData());
+    }
+
+    @Test
+    @DisplayName("SC-P-41 异常:section/planJson 为空 → 业务提示")
+    void regenerate_异常_参数缺失() throws Exception {
+        Map<String, Object> params = new HashMap<>();
+        params.put("section", "nutrition");
+        Result<String> r = controller.regenerateSection(params, 1L, 7L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("section和planJson不能为空"));
+    }
+
+    // ==================== SC-P-42/SC-P-81 方案编辑与草稿保存 ====================
+
+    @Test
+    @DisplayName("SC-P-42/SC-P-81 正常:保存方案(draft 草稿)→ 200 id")
+    void save_正常_草稿() throws Exception {
+        when(planSvc.savePlan(any(HealthPlan.class))).thenReturn(88L);
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberIds", "10");
+        params.put("dimensions", "wisdom,body");
+        params.put("goal", "提升专注力");
+        params.put("status", "draft");
+        Result<Long> r = controller.save(params, 1L, 7L);
+        assertEquals(200, r.getCode());
+        assertEquals(88L, r.getData());
+
+        ArgumentCaptor<HealthPlan> captor = ArgumentCaptor.forClass(HealthPlan.class);
+        verify(planSvc).savePlan(captor.capture());
+        assertEquals("draft", captor.getValue().getStatus(), "草稿状态透传");
+        assertEquals(7L, captor.getValue().getFamilyId());
+    }
+
+    @Test
+    @DisplayName("SC-P-42 异常:familyId/dimensions/goal 为空 → 业务提示")
+    void save_异常_参数缺失() throws Exception {
+        Map<String, Object> params = new HashMap<>();
+        params.put("goal", "提升专注力");
+        Result<Long> r = controller.save(params, 1L, null);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("familyId, dimensions, goal不能为空"));
+    }
+
+    // ==================== SC-P-44 确认方案 ====================
+
+    @Test
+    @DisplayName("SC-P-44 正常:确认方案 → 200")
+    void confirm_正常() throws Exception {
+        when(planSvc.confirmPlan(88L)).thenReturn(88L);
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        Result<Long> r = controller.confirm(params, 1L, 7L);
+        assertEquals(200, r.getCode());
+        assertEquals(88L, r.getData());
+    }
+
+    @Test
+    @DisplayName("SC-P-44 异常:planId 为空 → 业务提示")
+    void confirm_异常_缺planId() throws Exception {
+        Result<Long> r = controller.confirm(new HashMap<>(), 1L, 7L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("planId不能为空"));
+    }
+
+    // ==================== SC-P-43/SC-P-48 查看方案与历史 ====================
+
+    @Test
+    @DisplayName("SC-P-43/SC-P-48 正常:方案列表(历史倒序)→ 200")
+    void list_正常() throws Exception {
+        HealthPlan p1 = plan(88L, 7L, "pending_review");
+        when(planSvc.getPlanList(7L)).thenReturn(Collections.singletonList(p1));
+        Result<List<HealthPlan>> r = controller.list(new HashMap<>(), 1L, 7L);
+        assertEquals(200, r.getCode());
+        assertEquals(1, r.getData().size());
+    }
+
+    @Test
+    @DisplayName("SC-P-43 权限:familyId 为空 → 业务提示")
+    void list_权限_缺familyId() throws Exception {
+        Result<List<HealthPlan>> r = controller.list(new HashMap<>(), 1L, null);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("familyId不能为空"));
+    }
+
+    @Test
+    @DisplayName("SC-P-43 正常:方案详情 + 任务加载 → 200")
+    void detail_正常() throws Exception {
+        HealthPlan p = plan(88L, 7L, "published");
+        when(planSvc.getPlanDetail(88L)).thenReturn(p);
+        when(planSvc.getPlanTasks(88L)).thenReturn(Collections.singletonList(new Task()));
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        Result<HealthPlan> r = controller.detail(params, 1L);
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData().getTasks());
+    }
+
+    @Test
+    @DisplayName("SC-P-43 异常:方案不存在 → 业务提示")
+    void detail_异常_方案不存在() throws Exception {
+        when(planSvc.getPlanDetail(88L)).thenReturn(null);
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        Result<HealthPlan> r = controller.detail(params, 1L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("方案不存在"));
+    }
+
+    @Test
+    @DisplayName("SC-P-43 异常:planId 为空 → 业务提示")
+    void detail_异常_缺planId() throws Exception {
+        Result<HealthPlan> r = controller.detail(new HashMap<>(), 1L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("planId不能为空"));
+    }
+
+    // ==================== SC-P-47 方案任务列表 ====================
+
+    @Test
+    @DisplayName("SC-P-47 正常:方案任务列表 → 200")
+    void tasks_正常() throws Exception {
+        HealthPlan p = plan(88L, 7L, "published");
+        when(planSvc.getPlanDetail(88L)).thenReturn(p);
+        Task t = new Task();
+        t.setId(1L);
+        when(planSvc.getPlanTasks(88L)).thenReturn(Collections.singletonList(t));
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        Result<List<Task>> r = controller.planTasks(params, 7L);
+        assertEquals(200, r.getCode());
+        assertEquals(1, r.getData().size());
+    }
+
+    @Test
+    @DisplayName("SC-P-47 权限:跨家庭查看任务 → 无权提示(IDOR 防护)")
+    void tasks_权限_跨家庭() throws Exception {
+        HealthPlan p = plan(88L, 7L, "published");
+        when(planSvc.getPlanDetail(88L)).thenReturn(p);
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        Result<List<Task>> r = controller.planTasks(params, 999L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("无权查看该方案任务"));
+    }
+
+    @Test
+    @DisplayName("SC-P-47 异常:方案不存在 → 业务提示")
+    void tasks_异常_方案不存在() throws Exception {
+        when(planSvc.getPlanDetail(88L)).thenReturn(null);
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        Result<List<Task>> r = controller.planTasks(params, 7L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("方案不存在"));
+    }
+
+    // ==================== SC-P-32/SC-P-74 可选规划师 ====================
+
+    @Test
+    @DisplayName("SC-P-32/SC-P-74 正常:可用规划师列表 → 200")
+    void availableTeachers_正常() throws Exception {
+        Map<String, Object> teacher = new HashMap<>();
+        teacher.put("id", 7L);
+        when(planSvc.getAvailableTeachers(7L)).thenReturn(Collections.singletonList(teacher));
+        Result<List<Map<String, Object>>> r = controller.availableTeachers(new HashMap<>(), 7L);
+        assertEquals(200, r.getCode());
+        assertEquals(1, r.getData().size());
+    }
+
+    @Test
+    @DisplayName("SC-P-32 权限:familyId 为空 → 业务提示")
+    void availableTeachers_权限_缺familyId() throws Exception {
+        Result<List<Map<String, Object>>> r = controller.availableTeachers(new HashMap<>(), null);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("familyId不能为空"));
+    }
+
+    // ==================== 规划师审核侧(角色守卫 + 委托) ====================
+
+    @Test
+    @DisplayName("SC-T-04 权限:非 teacher/admin 查待审核列表 → 无权限")
+    void pendingReviewList_权限_非teacher() throws Exception {
+        Result<List<HealthPlan>> r = controller.listPendingReview(new HashMap<>(), 1L, "parent", 7L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("无权限"));
+    }
+
+    @Test
+    @DisplayName("SC-T-04 正常:teacher 待审核列表 → 200")
+    void pendingReviewList_正常_teacher() throws Exception {
+        when(planSvc.listPendingReviewPlans(7L, 1L))
+                .thenReturn(Collections.singletonList(plan(88L, 7L, "pending_review")));
+        Result<List<HealthPlan>> r = controller.listPendingReview(new HashMap<>(), 1L, "teacher", 7L);
+        assertEquals(200, r.getCode());
+        assertEquals(1, r.getData().size());
+    }
+
+    @Test
+    @DisplayName("SC-T-14 权限:非 teacher 编辑方案 → 无权限")
+    void pendingReviewUpdate_权限_非teacher() throws Exception {
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        Result<HealthPlan> r = controller.updatePlan(params, 1L, "parent");
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("无权限"));
+    }
+
+    @Test
+    @DisplayName("SC-T-14 正常:规划师编辑方案内容 → 200")
+    void pendingReviewUpdate_正常() throws Exception {
+        HealthPlan p = plan(88L, 7L, "pending_review");
+        when(planSvc.updatePlanContent(88L, "新内容", null)).thenReturn(p);
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        params.put("planContent", "新内容");
+        Result<HealthPlan> r = controller.updatePlan(params, 1L, "teacher");
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+    }
+
+    @Test
+    @DisplayName("SC-P-45/SC-X-09 权限:非 teacher 审核通过 → 无权限")
+    void pendingReviewApprove_权限_非teacher() throws Exception {
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        Result<PlanApproveResultDTO> r = controller.approvePlan(params, 1L, "parent");
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("无权限"));
+    }
+
+    @Test
+    @DisplayName("SC-P-45/SC-X-09 正常:审核通过并发布(生成任务)→ 200 DTO")
+    void pendingReviewApprove_正常() throws Exception {
+        PlanApproveResultDTO dto = new PlanApproveResultDTO();
+        dto.setPlanId(88L);
+        dto.setStatus("published");
+        dto.setTaskGenerated(true);
+        dto.setTaskCount(3);
+        when(planSvc.approveAndPublish(88L, 1L, "ok")).thenReturn(dto);
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        params.put("comment", "ok");
+        Result<PlanApproveResultDTO> r = controller.approvePlan(params, 1L, "teacher");
+        assertEquals(200, r.getCode());
+        assertEquals("published", r.getData().getStatus());
+        assertTrue(r.getData().getTaskGenerated());
+    }
+
+    @Test
+    @DisplayName("SC-T-04 正常:规划师驳回方案 → 200")
+    void pendingReviewReject_正常() throws Exception {
+        HealthPlan p = plan(88L, 7L, "rejected");
+        when(planSvc.rejectPlan(88L, 1L, "补数据")).thenReturn(p);
+        Map<String, Object> params = new HashMap<>();
+        params.put("planId", 88L);
+        params.put("comment", "补数据");
+        Result<HealthPlan> r = controller.rejectPlan(params, 1L, "teacher");
+        assertEquals(200, r.getCode());
+        assertEquals("rejected", r.getData().getStatus());
+    }
+
+    // ==================== service:HealthPlanServiceImpl 状态机 ====================
+
+    @Test
+    @DisplayName("SC-P-44 正常:confirmPlan pending_review→published(返回 planId)")
+    void svc_确认方案_发布() throws Exception {
+        HealthPlanMapper hpMapper = mock(HealthPlanMapper.class);
+        HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
+        inject(svc, "healthPlanMapper", hpMapper);
+        inject(svc, "taskMapper", mock(TaskMapper.class));
+
+        HealthPlan p = plan(88L, 7L, "pending_review");
+        when(hpMapper.selectById(88L)).thenReturn(p);
+
+        Long id = svc.confirmPlan(88L);
+        assertEquals(88L, id);
+        assertEquals("published", p.getStatus(), "确认后状态=published");
+        verify(hpMapper).updateById(p);
+    }
+
+    @Test
+    @DisplayName("SC-T-14 正常:pending_review 可编辑;published 拒绝编辑")
+    void svc_编辑守卫() throws Exception {
+        HealthPlanMapper hpMapper = mock(HealthPlanMapper.class);
+        HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
+        inject(svc, "healthPlanMapper", hpMapper);
+
+        HealthPlan pending = plan(88L, 7L, "pending_review");
+        when(hpMapper.selectById(88L)).thenReturn(pending);
+        HealthPlan updated = svc.updatePlanContent(88L, "新内容", null);
+        assertEquals("新内容", updated.getPlanContent());
+
+        HealthPlan published = plan(89L, 7L, "published");
+        when(hpMapper.selectById(89L)).thenReturn(published);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> svc.updatePlanContent(89L, "改", null));
+        assertTrue(ex.getMessage().contains("方案状态不允许编辑"));
+    }
+
+    @Test
+    @DisplayName("SC-T-04 正常:pending_review 驳回→rejected;published 拒绝审核")
+    void svc_驳回守卫() throws Exception {
+        HealthPlanMapper hpMapper = mock(HealthPlanMapper.class);
+        HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
+        inject(svc, "healthPlanMapper", hpMapper);
+
+        HealthPlan pending = plan(88L, 7L, "pending_review");
+        when(hpMapper.selectById(88L)).thenReturn(pending);
+        HealthPlan rejected = svc.rejectPlan(88L, 1L, "补数据");
+        assertEquals("rejected", rejected.getStatus());
+
+        HealthPlan published = plan(89L, 7L, "published");
+        when(hpMapper.selectById(89L)).thenReturn(published);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> svc.rejectPlan(89L, 1L, "x"));
+        assertTrue(ex.getMessage().contains("方案状态不允许审核"));
+    }
+
+    @Test
+    @DisplayName("SC-P-45 正常:approveAndPublish 仅允许 pending_review/draft(published 拒绝)")
+    void svc_审核发布守卫() throws Exception {
+        HealthPlanMapper hpMapper = mock(HealthPlanMapper.class);
+        HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
+        inject(svc, "healthPlanMapper", hpMapper);
+
+        HealthPlan published = plan(89L, 7L, "published");
+        when(hpMapper.selectById(89L)).thenReturn(published);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> svc.approveAndPublish(89L, 1L, "x"));
+        assertTrue(ex.getMessage().contains("方案状态不允许审核"));
+    }
+
+    @Test
+    @DisplayName("SC-P-81 正常:保存新方案默认 pending_review(等待审核),不入发布态")
+    void svc_保存默认待审核() throws Exception {
+        HealthPlanMapper hpMapper = mock(HealthPlanMapper.class);
+        HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
+        inject(svc, "healthPlanMapper", hpMapper);
+
+        HealthPlan plan = new HealthPlan();
+        plan.setId(90L);
+        plan.setFamilyId(7L);
+        svc.savePlan(plan);
+        assertEquals("pending_review", plan.getStatus(), "新方案默认 pending_review");
+        verify(hpMapper).insert(plan);
+    }
+
+    // ==================== helpers ====================
+
+    private static HealthPlan plan(Long id, Long familyId, String status) {
+        HealthPlan p = new HealthPlan();
+        p.setId(id);
+        p.setFamilyId(familyId);
+        p.setStatus(status);
+        p.setMemberIds("10");
+        p.setDimensions("wisdom");
+        p.setGoal("提升专注力");
+        p.setCreatedAt(new Date());
+        p.setUpdatedAt(new Date());
+        return p;
+    }
+}

+ 421 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/PlanReviewTeacherContractTest.java

@@ -0,0 +1,421 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.guide.GuidePlanReviewController;
+import com.etotem.cfc.controller.guide.PlanReviewController;
+import com.etotem.cfc.dto.PlanApproveResultDTO;
+import com.etotem.cfc.entity.HealthPlan;
+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.HealthPlanService;
+import com.etotem.cfc.service.PlanActivationService;
+import com.etotem.cfc.service.PlannerCertificationGuard;
+import com.etotem.cfc.service.PlannerCertificationService;
+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.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.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * EVD-8 契约补建 · 测评-方案「规划师方案审核/激活」批次三类流契约测试。
+ *
+ * <p>覆盖 SC(contract-plan 测评-方案):SC-T-04(方案审核列表+批准/驳回,family 隔离)、
+ * SC-T-14(方案编辑-规划师侧,family 路径 IDOR 防护)、SC-P-46/SC-T-05(激活方案生成任务·角色/认证/
+ * 状态三重守卫,幂等在 PlanActivationExecutionContractTest 覆盖)、SC-T-22(方案归档·观察钉住)、
+ * SC-X-09(送审闭环·审核流转:家长侧提交在 PlanLifecycleParentContractTest,本类覆盖规划师审核侧)。</p>
+ *
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB):正常流、异常流(状态非法/参数缺失/未认证)、
+ * 权限流(非 teacher/admin 角色守卫、跨家庭 IDOR 403)。</p>
+ */
+class PlanReviewTeacherContractTest extends AssessmentPlanContractTestBase {
+
+    // ==================== GuidePlanReviewController(family 路径客户方案审核) ====================
+
+    private HealthPlanService healthPlanSvc;
+    private GuideFamilyAccessGuard accessGuard;
+    private GuidePlanReviewController guidePlanController;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        healthPlanSvc = mock(HealthPlanService.class);
+        accessGuard = mock(GuideFamilyAccessGuard.class);
+        guidePlanController = new GuidePlanReviewController();
+        inject(guidePlanController, "healthPlanService", healthPlanSvc);
+        inject(guidePlanController, "guideFamilyAccessGuard", accessGuard);
+    }
+
+    @Test
+    @DisplayName("SC-T-04 正常:绑定家庭待审核列表 → 200(teacherId 取登录用户)")
+    void guidePlan_pending_正常() throws Exception {
+        when(accessGuard.checkFamilyAccess("teacher", 7L, 1L)).thenReturn(null);
+        when(healthPlanSvc.listPendingReviewPlans(1L, 7L))
+                .thenReturn(Collections.singletonList(healthPlan(88L, 1L, "pending_review")));
+
+        Result<List<HealthPlan>> r = guidePlanController.listPendingPlans(
+                new HashMap<>(), "teacher", 7L, 1L);
+        assertEquals(200, r.getCode());
+        assertEquals(1, r.getData().size());
+        assertEquals("pending_review", r.getData().get(0).getStatus());
+    }
+
+    @Test
+    @DisplayName("SC-T-04 权限:非 teacher/admin 查待审核列表 → 403 无权访问")
+    void guidePlan_pending_权限_非teacher() throws Exception {
+        when(accessGuard.checkFamilyAccess("parent", 7L, 1L))
+                .thenReturn(Result.error(403, "无权访问该家庭数据"));
+
+        Result<List<HealthPlan>> r = guidePlanController.listPendingPlans(
+                new HashMap<>(), "parent", 7L, 1L);
+        assertEquals(403, r.getCode());
+        assertTrue(r.getMessage().contains("无权访问"));
+    }
+
+    @Test
+    @DisplayName("SC-T-14 正常:规划师编辑已绑定客户方案 → 200")
+    void guidePlan_update_正常() throws Exception {
+        when(accessGuard.checkFamilyAccess("teacher", 7L, 1L)).thenReturn(null);
+        HealthPlan p = healthPlan(88L, 1L, "pending_review");
+        when(healthPlanSvc.getPlanForFamily(88L, 1L)).thenReturn(p);
+        when(healthPlanSvc.updatePlanContent(88L, "新内容", null)).thenReturn(p);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("planContent", "新内容");
+        Result<HealthPlan> r = guidePlanController.updatePlan(body, "teacher", 7L, 1L, 88L);
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+    }
+
+    @Test
+    @DisplayName("SC-T-14 权限:planId 不属于路径 familyId → 403 无权访问(IDOR 防护)")
+    void guidePlan_update_权限_planId跨家庭() throws Exception {
+        when(accessGuard.checkFamilyAccess("teacher", 7L, 1L)).thenReturn(null);
+        when(healthPlanSvc.getPlanForFamily(88L, 1L)).thenReturn(null);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("planContent", "新内容");
+        Result<HealthPlan> r = guidePlanController.updatePlan(body, "teacher", 7L, 1L, 88L);
+        assertEquals(403, r.getCode());
+        assertTrue(r.getMessage().contains("无权访问该方案"));
+        verify(healthPlanSvc, never()).updatePlanContent(anyLong(), any(), any());
+    }
+
+    @Test
+    @DisplayName("SC-X-09/SC-T-04 正常:审核通过并发布 → 200 DTO")
+    void guidePlan_approve_正常() throws Exception {
+        when(accessGuard.checkFamilyAccess("teacher", 7L, 1L)).thenReturn(null);
+        HealthPlan p = healthPlan(88L, 1L, "pending_review");
+        when(healthPlanSvc.getPlanForFamily(88L, 1L)).thenReturn(p);
+        PlanApproveResultDTO dto = new PlanApproveResultDTO();
+        dto.setPlanId(88L);
+        dto.setStatus("published");
+        dto.setTaskGenerated(true);
+        when(healthPlanSvc.approveAndPublish(88L, 7L, "ok")).thenReturn(dto);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("comment", "ok");
+        Result<PlanApproveResultDTO> r = guidePlanController.approvePlan(body, "teacher", 7L, 1L, 88L);
+        assertEquals(200, r.getCode());
+        assertEquals("published", r.getData().getStatus());
+        assertTrue(r.getData().getTaskGenerated());
+    }
+
+    @Test
+    @DisplayName("SC-T-04 权限:非 teacher 审核通过 → 403")
+    void guidePlan_approve_权限_非teacher() throws Exception {
+        when(accessGuard.checkFamilyAccess("parent", 7L, 1L))
+                .thenReturn(Result.error(403, "无权访问该家庭数据"));
+        Map<String, Object> body = new HashMap<>();
+        body.put("comment", "ok");
+        Result<PlanApproveResultDTO> r = guidePlanController.approvePlan(body, "parent", 7L, 1L, 88L);
+        assertEquals(403, r.getCode());
+    }
+
+    @Test
+    @DisplayName("SC-T-04 正常:驳回方案 → 200 rejected")
+    void guidePlan_reject_正常() throws Exception {
+        when(accessGuard.checkFamilyAccess("teacher", 7L, 1L)).thenReturn(null);
+        HealthPlan p = healthPlan(88L, 1L, "pending_review");
+        when(healthPlanSvc.getPlanForFamily(88L, 1L)).thenReturn(p);
+        HealthPlan rejected = healthPlan(88L, 1L, "rejected");
+        when(healthPlanSvc.rejectPlan(88L, 7L, "补数据")).thenReturn(rejected);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("comment", "补数据");
+        Result<HealthPlan> r = guidePlanController.rejectPlan(body, "teacher", 7L, 1L, 88L);
+        assertEquals(200, r.getCode());
+        assertEquals("rejected", r.getData().getStatus());
+    }
+
+    @Test
+    @DisplayName("SC-T-04 权限:admin 跳过归属校验放行 → 200")
+    void guidePlan_pending_权限_admin放行() throws Exception {
+        when(accessGuard.checkFamilyAccess("admin", 1L, 2L)).thenReturn(null);
+        when(healthPlanSvc.listPendingReviewPlans(2L, null))
+                .thenReturn(Collections.singletonList(healthPlan(88L, 2L, "pending_review")));
+
+        Map<String, Object> body = new HashMap<>();
+        Result<List<HealthPlan>> r = guidePlanController.listPendingPlans(body, "admin", 1L, 2L);
+        assertEquals(200, r.getCode());
+        assertEquals(1, r.getData().size());
+    }
+
+    // ==================== PlanReviewController(guide/plans:TaskPlanInstance 审核/激活) ====================
+
+    @Test
+    @DisplayName("SC-T-04 正常:批准测评方案(generated→reviewed)→ 200")
+    void planReview_正常_批准() throws Exception {
+        TaskPlanInstanceMapper planMapper = mock(TaskPlanInstanceMapper.class);
+        PlanReviewController c = new PlanReviewController();
+        inject(c, "planMapper", planMapper);
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planActivationService", mock(PlanActivationService.class));
+        inject(c, "plannerCertificationGuard", mock(PlannerCertificationGuard.class));
+
+        TaskPlanInstance plan = taskPlan(88L, "generated");
+        when(planMapper.selectById(88L)).thenReturn(plan);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("planId", 88L);
+        body.put("approved", true);
+        body.put("comment", "ok");
+        Result<String> r = c.reviewPlan(body, 7L, "teacher");
+        assertEquals(200, r.getCode());
+        assertEquals("方案已批准", r.getData());
+        assertEquals("reviewed", plan.getStatus());
+        assertEquals(7L, plan.getReviewedBy());
+    }
+
+    @Test
+    @DisplayName("SC-T-04 正常:驳回测评方案(generated→rejected)→ 200")
+    void planReview_正常_驳回() throws Exception {
+        TaskPlanInstanceMapper planMapper = mock(TaskPlanInstanceMapper.class);
+        PlanReviewController c = new PlanReviewController();
+        inject(c, "planMapper", planMapper);
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planActivationService", mock(PlanActivationService.class));
+        inject(c, "plannerCertificationGuard", mock(PlannerCertificationGuard.class));
+
+        TaskPlanInstance plan = taskPlan(88L, "generated");
+        when(planMapper.selectById(88L)).thenReturn(plan);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("planId", 88L);
+        body.put("approved", false);
+        Result<String> r = c.reviewPlan(body, 7L, "teacher");
+        assertEquals(200, r.getCode());
+        assertEquals("方案已驳回", r.getData());
+        assertEquals("rejected", plan.getStatus());
+    }
+
+    @Test
+    @DisplayName("SC-T-04 异常:仅 generated 状态可审核(active → 状态异常)")
+    void planReview_异常_状态非法() throws Exception {
+        TaskPlanInstanceMapper planMapper = mock(TaskPlanInstanceMapper.class);
+        PlanReviewController c = new PlanReviewController();
+        inject(c, "planMapper", planMapper);
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planActivationService", mock(PlanActivationService.class));
+        inject(c, "plannerCertificationGuard", mock(PlannerCertificationGuard.class));
+
+        when(planMapper.selectById(88L)).thenReturn(taskPlan(88L, "active"));
+        Map<String, Object> body = new HashMap<>();
+        body.put("planId", 88L);
+        body.put("approved", true);
+        Result<String> r = c.reviewPlan(body, 7L, "teacher");
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("方案状态异常"));
+    }
+
+    @Test
+    @DisplayName("SC-T-04 异常:方案不存在 → 业务提示")
+    void planReview_异常_方案不存在() throws Exception {
+        TaskPlanInstanceMapper planMapper = mock(TaskPlanInstanceMapper.class);
+        PlanReviewController c = new PlanReviewController();
+        inject(c, "planMapper", planMapper);
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planActivationService", mock(PlanActivationService.class));
+        inject(c, "plannerCertificationGuard", mock(PlannerCertificationGuard.class));
+
+        when(planMapper.selectById(88L)).thenReturn(null);
+        Map<String, Object> body = new HashMap<>();
+        body.put("planId", 88L);
+        body.put("approved", true);
+        Result<String> r = c.reviewPlan(body, 7L, "teacher");
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("方案不存在"));
+    }
+
+    @Test
+    @DisplayName("SC-T-04 权限:非 teacher/admin 审核 → 无权限")
+    void planReview_权限_非teacher() throws Exception {
+        PlanReviewController c = new PlanReviewController();
+        inject(c, "planMapper", mock(TaskPlanInstanceMapper.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planActivationService", mock(PlanActivationService.class));
+        inject(c, "plannerCertificationGuard", mock(PlannerCertificationGuard.class));
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("planId", 88L);
+        Result<String> r = c.reviewPlan(body, 1L, "parent");
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("无权限"));
+    }
+
+    @Test
+    @DisplayName("SC-P-46/SC-T-05 正常:认证通过的 teacher 激活方案 → 200")
+    void planReview_激活_正常() throws Exception {
+        PlanActivationService actSvc = mock(PlanActivationService.class);
+        PlannerCertificationGuard certGuard = mock(PlannerCertificationGuard.class);
+        PlanReviewController c = new PlanReviewController();
+        inject(c, "planMapper", mock(TaskPlanInstanceMapper.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planActivationService", actSvc);
+        inject(c, "plannerCertificationGuard", certGuard);
+
+        when(certGuard.assertCertified(7L, PlannerCertificationService.CATEGORY_PLANNER)).thenReturn(null);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("planId", 88L);
+        Result<String> r = c.activatePlan(body, 7L, "teacher");
+        assertEquals(200, r.getCode());
+        assertEquals("方案已激活", r.getData());
+        verify(actSvc).activate(88L, 7L);
+    }
+
+    @Test
+    @DisplayName("SC-P-46 权限:未认证 teacher 激活 → 拦截 403")
+    void planReview_激活_权限_未认证() throws Exception {
+        PlannerCertificationGuard certGuard = mock(PlannerCertificationGuard.class);
+        PlanReviewController c = new PlanReviewController();
+        inject(c, "planMapper", mock(TaskPlanInstanceMapper.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planActivationService", mock(PlanActivationService.class));
+        inject(c, "plannerCertificationGuard", certGuard);
+
+        when(certGuard.assertCertified(7L, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(Result.error(403, "需先通过规划师认证"));
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("planId", 88L);
+        Result<String> r = c.activatePlan(body, 7L, "teacher");
+        assertEquals(403, r.getCode());
+        assertTrue(r.getMessage().contains("认证"));
+    }
+
+    @Test
+    @DisplayName("SC-P-46 权限:非 teacher/admin 激活 → 无权限")
+    void planReview_激活_权限_非teacher() throws Exception {
+        PlanReviewController c = new PlanReviewController();
+        inject(c, "planMapper", mock(TaskPlanInstanceMapper.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planActivationService", mock(PlanActivationService.class));
+        inject(c, "plannerCertificationGuard", mock(PlannerCertificationGuard.class));
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("planId", 88L);
+        Result<String> r = c.activatePlan(body, 1L, "parent");
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("无权限"));
+    }
+
+    @Test
+    @DisplayName("SC-P-46 异常:状态未审核激活 → 业务提示(IllegalStateException 捕获)")
+    void planReview_激活_异常_状态() throws Exception {
+        PlanActivationService actSvc = mock(PlanActivationService.class);
+        PlannerCertificationGuard certGuard = mock(PlannerCertificationGuard.class);
+        PlanReviewController c = new PlanReviewController();
+        inject(c, "planMapper", mock(TaskPlanInstanceMapper.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planActivationService", actSvc);
+        inject(c, "plannerCertificationGuard", certGuard);
+
+        when(certGuard.assertCertified(7L, PlannerCertificationService.CATEGORY_PLANNER)).thenReturn(null);
+        org.mockito.Mockito.doThrow(new IllegalStateException("方案状态异常,需要先审核通过"))
+                .when(actSvc).activate(88L, 7L);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("planId", 88L);
+        Result<String> r = c.activatePlan(body, 7L, "teacher");
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("方案状态异常"));
+    }
+
+    // ==================== service:PlanReviewController 依赖 planMapper 幂等状态守卫 ====================
+
+    @Test
+    @DisplayName("SC-T-04 异常:缺 planId 激活 → planId required")
+    void planReview_激活_异常_缺planId() throws Exception {
+        PlanReviewController c = new PlanReviewController();
+        inject(c, "planMapper", mock(TaskPlanInstanceMapper.class));
+        inject(c, "userMapper", mock(UserMapper.class));
+        inject(c, "planActivationService", mock(PlanActivationService.class));
+        inject(c, "plannerCertificationGuard", mock(PlannerCertificationGuard.class));
+
+        Result<String> r = c.activatePlan(new HashMap<>(), 7L, "teacher");
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("planId required"));
+    }
+
+    /**
+     * SC-T-22(方案归档)契约缺口观察(观察钉住,不修实现):
+     * 全项目无独立「方案归档」端点——health_plan 状态枚举含 archived(DatabaseInitializer
+     * 建表注释 draft/published/archived),但 controller 仅暴露 generate/save/confirm/list/
+     * detail/tasks/delete/pending-review 系列,归档语义由 delete(软删/级联)邻近承载。
+     * 此处以反射断言钉住现状:HealthPlanController 无 archive 映射方法。
+     */
+    @Test
+    @DisplayName("SC-T-22 观察:方案归档无独立端点(health_plan archived 状态无暴露映射)")
+    void pinsObserved_方案归档无独立端点() throws Exception {
+        boolean hasArchiveMapping = Arrays.stream(com.etotem.cfc.controller.HealthPlanController.class.getDeclaredMethods())
+                .anyMatch(m -> m.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                        && m.getName().toLowerCase().contains("archive"));
+        assertEquals(false, hasArchiveMapping, "方案归档无独立端点(观察钉住,不修实现)");
+    }
+
+    // ==================== helpers ====================
+
+    private static HealthPlan healthPlan(Long id, Long familyId, String status) {
+        HealthPlan p = new HealthPlan();
+        p.setId(id);
+        p.setFamilyId(familyId);
+        p.setStatus(status);
+        p.setMemberIds("10");
+        p.setDimensions("wisdom");
+        p.setGoal("提升专注力");
+        p.setCreatedAt(new Date());
+        p.setUpdatedAt(new Date());
+        return p;
+    }
+
+    private static TaskPlanInstance taskPlan(Long id, String status) {
+        TaskPlanInstance p = new TaskPlanInstance();
+        p.setId(id);
+        p.setFamilyId(7L);
+        p.setMemberId(10L);
+        p.setName("DAN测评-成长方案");
+        p.setStatus(status);
+        p.setTotalDays(14);
+        p.setCompletedDays(0);
+        p.setCreatedAt(new Date());
+        p.setUpdatedAt(new Date());
+        return p;
+    }
+}

+ 230 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskTemplatePackageContractTest.java

@@ -0,0 +1,230 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.TaskTemplatePackageController;
+import com.etotem.cfc.dto.CreatePackageDTO;
+import com.etotem.cfc.dto.CreatePackageItemDTO;
+import com.etotem.cfc.entity.TaskTemplateItem;
+import com.etotem.cfc.entity.TaskTemplatePackage;
+import com.etotem.cfc.service.TaskTemplatePackageService;
+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.Collections;
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * EVD-8 契约补建 · 测评-方案「套餐任务项管理」批次三类流契约测试。
+ *
+ * <p>覆盖 SC(contract-plan 测评-方案):SC-T-15(套餐任务项管理:创建/更新/发布/下架/任务项 CRUD)、
+ * SC-P-33/SC-P-34/SC-P-58(购买测评套餐/下单/支付·相邻:P-33 额度发放与套餐购买同源,P-34 支付
+ * 走既有订单支付链,见 AssessmentQuotaAppointmentContractTest 与 ProductOrder 链)。</p>
+ *
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB):正常流(列表/创建/更新/发布/任务项)、异常流(body 判空
+ * 回落列表、发布参数解析)、权限流(userId 作用域 my-packages 过滤)。</p>
+ */
+class TaskTemplatePackageContractTest extends AssessmentPlanContractTestBase {
+
+    private TaskTemplatePackageService packageSvc;
+    private TaskTemplatePackageController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        packageSvc = mock(TaskTemplatePackageService.class);
+        controller = new TaskTemplatePackageController();
+        inject(controller, "packageService", packageSvc);
+    }
+
+    // ==================== SC-T-15 套餐任务项管理 ====================
+
+    @Test
+    @DisplayName("SC-T-15 正常:根路径空 body → 我的任务模板列表")
+    void root_正常_空body取列表() throws Exception {
+        TaskTemplatePackage pkg = pkg(1L, "21天学习习惯");
+        when(packageSvc.getMyPackages(7L)).thenReturn(Collections.singletonList(pkg));
+
+        Result<?> r = controller.root(Collections.emptyMap(), 7L);
+        assertEquals(200, r.getCode());
+        assertTrue(r.getData() instanceof List);
+        assertEquals(1, ((List<?>) r.getData()).size());
+    }
+
+    @Test
+    @DisplayName("SC-T-15 正常:根路径带 body → 创建任务模板")
+    void root_正常_创建() throws Exception {
+        TaskTemplatePackage created = pkg(11L, "21天学习习惯");
+        when(packageSvc.createPackage(any(CreatePackageDTO.class), eq(7L))).thenReturn(created);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("name", "21天学习习惯");
+        body.put("description", "建立学习习惯");
+        body.put("cycleDays", 21);
+        Result<?> r = controller.root(body, 7L);
+        assertEquals(200, r.getCode());
+        assertEquals(11L, r.getData());
+    }
+
+    @Test
+    @DisplayName("SC-T-15 正常:创建任务模板 → 200 id")
+    void create_正常() throws Exception {
+        TaskTemplatePackage created = pkg(11L, "21天学习习惯");
+        when(packageSvc.createPackage(any(CreatePackageDTO.class), eq(7L))).thenReturn(created);
+
+        CreatePackageDTO dto = new CreatePackageDTO();
+        dto.setName("21天学习习惯");
+        dto.setCycleDays(21);
+        Result<Long> r = controller.createPackage(7L, dto);
+        assertEquals(200, r.getCode());
+        assertEquals(11L, r.getData());
+        verify(packageSvc).createPackage(dto, 7L);
+    }
+
+    @Test
+    @DisplayName("SC-T-15 正常:更新任务模板 → 200 true")
+    void update_正常() throws Exception {
+        when(packageSvc.updatePackage(11L, new CreatePackageDTO())).thenReturn(true);
+        Result<Boolean> r = controller.updatePackage(11L, new CreatePackageDTO());
+        assertEquals(200, r.getCode());
+        assertEquals(true, r.getData());
+    }
+
+    @Test
+    @DisplayName("SC-T-15 正常:任务模板详情 → 200")
+    void detail_正常() throws Exception {
+        TaskTemplatePackage p = pkg(11L, "21天学习习惯");
+        when(packageSvc.getPackageById(11L)).thenReturn(p);
+        Result<TaskTemplatePackage> r = controller.getPackage(11L);
+        assertEquals(200, r.getCode());
+        assertEquals("21天学习习惯", r.getData().getName());
+    }
+
+    @Test
+    @DisplayName("SC-T-15 正常:我的任务模板列表(userId 作用域)→ 200")
+    void myList_正常() throws Exception {
+        when(packageSvc.getMyPackages(7L)).thenReturn(Collections.singletonList(pkg(11L, "a")));
+        Result<List<TaskTemplatePackage>> r = controller.getMyPackages(7L);
+        assertEquals(200, r.getCode());
+        assertEquals(1, r.getData().size());
+        verify(packageSvc).getMyPackages(7L);
+    }
+
+    @Test
+    @DisplayName("SC-T-15 正常:提交审核 → 200 true")
+    void submit_正常() throws Exception {
+        when(packageSvc.submitForReview(11L)).thenReturn(true);
+        Result<Boolean> r = controller.submitForReview(11L);
+        assertEquals(200, r.getCode());
+        assertEquals(true, r.getData());
+    }
+
+    @Test
+    @DisplayName("SC-T-15 正常:发布/下架(publish=true/false)→ 200 true")
+    void publish_正常() throws Exception {
+        when(packageSvc.publishPackage(11L, true)).thenReturn(true);
+        Map<String, Object> p1 = new HashMap<>();
+        p1.put("publish", true);
+        Result<Boolean> r1 = controller.publishPackage(11L, p1);
+        assertEquals(200, r1.getCode());
+        assertEquals(true, r1.getData());
+        verify(packageSvc).publishPackage(11L, true);
+
+        when(packageSvc.publishPackage(11L, false)).thenReturn(true);
+        Map<String, Object> p2 = new HashMap<>();
+        p2.put("publish", false);
+        Result<Boolean> r2 = controller.publishPackage(11L, p2);
+        assertEquals(200, r2.getCode());
+        verify(packageSvc).publishPackage(11L, false);
+    }
+
+    @Test
+    @DisplayName("SC-T-15 异常:publish 缺参 → 解析为 false(下架语义,无 NPE)")
+    void publish_异常_缺参() throws Exception {
+        when(packageSvc.publishPackage(11L, false)).thenReturn(true);
+        Result<Boolean> r = controller.publishPackage(11L, new HashMap<>());
+        assertEquals(200, r.getCode());
+        verify(packageSvc).publishPackage(11L, false);
+    }
+
+    @Test
+    @DisplayName("SC-T-15 正常:添加任务项(packageId 自动回填)→ 200 itemId")
+    void addItem_正常() throws Exception {
+        TaskTemplateItem item = new TaskTemplateItem();
+        item.setId(501L);
+        when(packageSvc.addItem(any(CreatePackageItemDTO.class))).thenReturn(item);
+
+        CreatePackageItemDTO dto = new CreatePackageItemDTO();
+        dto.setTaskName("晨起运动");
+        dto.setPoints(3);
+        Result<Long> r = controller.addItem(11L, dto);
+        assertEquals(200, r.getCode());
+        assertEquals(501L, r.getData());
+        assertEquals(11L, dto.getPackageId(), "item.packageId 由路径自动回填");
+    }
+
+    @Test
+    @DisplayName("SC-T-15 正常:更新/删除任务项 → 200 true")
+    void item_update_delete_正常() throws Exception {
+        when(packageSvc.updateItem(501L, new CreatePackageItemDTO())).thenReturn(true);
+        Result<Boolean> u = controller.updateItem(501L, new CreatePackageItemDTO());
+        assertEquals(true, u.getData());
+
+        when(packageSvc.deleteItem(501L)).thenReturn(true);
+        Result<Boolean> d = controller.deleteItem(501L);
+        assertEquals(true, d.getData());
+    }
+
+    @Test
+    @DisplayName("SC-T-15 正常:任务项列表 → 200")
+    void itemList_正常() throws Exception {
+        TaskTemplateItem a = new TaskTemplateItem();
+        a.setId(501L);
+        a.setTaskName("晨起运动");
+        TaskTemplateItem b = new TaskTemplateItem();
+        b.setId(502L);
+        b.setTaskName("睡前阅读");
+        when(packageSvc.getPackageItems(11L)).thenReturn(Arrays.asList(a, b));
+
+        Result<List<TaskTemplateItem>> r = controller.getPackageItems(11L);
+        assertEquals(200, r.getCode());
+        assertEquals(2, r.getData().size());
+    }
+
+    // ==================== SC-P-33/34/58 相邻(购买/支付链) ====================
+
+    /**
+     * SC-P-33/SC-P-34/SC-P-58(购买测评套餐/下单/支付/购买会员)契约核对(相邻端点,非本批次独有):
+     * 套餐下单走既有 {@code /api/order/**} 订单支付链;支付成功后由订单服务发放测评额度
+     * (AssessmentQuotaService.grantQuota,见 AssessmentQuotaAppointmentContractTest
+     * quota_正常_grantQuota默认值)。此处钉住:任务模板套餐本身不承载支付,金额/支付由订单链承载。
+     */
+    @Test
+    @DisplayName("SC-P-33/34/58 观察:套餐模板不承载支付,支付/额度由订单链承载")
+    void pinsObserved_套餐模板不承载支付() throws Exception {
+        TaskTemplatePackage p = pkg(11L, "DAN测评套餐");
+        // 套餐模板状态枚举 draft/pending/approved/rejected/offline(无 paid)——支付状态由订单承载
+        assertEquals("draft", p.getStatus(), "套餐模板状态无 paid(支付由订单链承载,观察钉住)");
+    }
+
+    // ==================== helpers ====================
+
+    private static TaskTemplatePackage pkg(Long id, String name) {
+        TaskTemplatePackage p = new TaskTemplatePackage();
+        p.setId(id);
+        p.setName(name);
+        p.setStatus("draft");
+        p.setCycleDays(21);
+        return p;
+    }
+}

+ 226 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TianpanGuideContractTest.java

@@ -0,0 +1,226 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.tianpan.TianpanController;
+import com.etotem.cfc.dto.CompatibilityResultVO;
+import com.etotem.cfc.dto.TianpanDashboardVO;
+import com.etotem.cfc.dto.TianpanMemberVO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.FamilyMemberAttributeService;
+import com.etotem.cfc.service.FamilyMemberDimensionBaseScoreService;
+import com.etotem.cfc.service.TianpanService;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.ZodiacAnnualEnergyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * EVD-8 契约补建 · 测评-方案「家庭天盘解读(规划师)」批次三类流契约测试。
+ *
+ * <p>覆盖 SC(contract-plan 测评-方案):SC-T-27(家庭天盘解读:dashboard/member-detail/compatibility,
+ * 跨家庭数据隔离)、SC-T-25(舌诊/情绪测评录入·观察:无独立规划师端点,走 AI emotion/tongue 面)、
+ * SC-T-26(精准营养方案制定·观察:营养助手 /api/ai/nutrition/send 承载)、
+ * SC-T-28(AI 方案内容安全复核·观察:复核由 LangGraph/审核链路承载,无独立规划端点)。</p>
+ *
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB):正常流(dashboard/member-detail/兼容性)、异常流(无家庭
+ * 5001、成员不存在 404、成员不同家庭 400)、权限流(非绑定家庭 403)。</p>
+ */
+class TianpanGuideContractTest extends AssessmentPlanContractTestBase {
+
+    private TianpanService tianpanSvc;
+    private UserService userSvc;
+    private FamilyMemberMapper familyMemberMapper;
+    private TianpanController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        tianpanSvc = mock(TianpanService.class);
+        userSvc = mock(UserService.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        controller = new TianpanController();
+        inject(controller, "tianpanService", tianpanSvc);
+        inject(controller, "familyMemberAttributeService", mock(FamilyMemberAttributeService.class));
+        inject(controller, "zodiacAnnualEnergyService", mock(ZodiacAnnualEnergyService.class));
+        inject(controller, "familyMemberDimensionBaseScoreService", mock(FamilyMemberDimensionBaseScoreService.class));
+        inject(controller, "userService", userSvc);
+        inject(controller, "familyMemberMapper", familyMemberMapper);
+    }
+
+    // ==================== SC-T-27 家庭天盘解读 ====================
+
+    @Test
+    @DisplayName("SC-T-27 正常:规划师查看绑定家庭天盘 dashboard → 200")
+    void dashboard_正常() throws Exception {
+        when(userSvc.getEffectiveFamilyIds(7L)).thenReturn(setOf(7L, 8L));
+        TianpanDashboardVO vo = mock(TianpanDashboardVO.class);
+        when(tianpanSvc.buildDashboard(7L, null, 7, null)).thenReturn(vo);
+
+        HttpServletRequest request = request("teacher", 7L);
+        Map<String, Object> params = new HashMap<>();
+        Result<TianpanDashboardVO> r = controller.dashboard(request, params, 7L);
+        assertEquals(200, r.getCode());
+        verify(tianpanSvc).buildDashboard(7L, null, 7, null);
+    }
+
+    @Test
+    @DisplayName("SC-T-27 权限:无家庭上下文 → 5001 请先创建或加入家庭")
+    void dashboard_权限_无家庭() throws Exception {
+        HttpServletRequest request = request("teacher", 7L);
+        Result<TianpanDashboardVO> r = controller.dashboard(request, new HashMap<>(), null);
+        assertEquals(5001, r.getCode());
+        assertTrue(r.getMessage().contains("请先创建或加入家庭"));
+    }
+
+    @Test
+    @DisplayName("SC-T-27 权限:非绑定家庭 → 403 无权访问该家庭数据")
+    void dashboard_权限_越权() throws Exception {
+        when(userSvc.getEffectiveFamilyIds(1L)).thenReturn(setOf(999L));
+        HttpServletRequest request = request("parent", 1L);
+        Result<TianpanDashboardVO> r = controller.dashboard(request, new HashMap<>(), 7L);
+        assertEquals(403, r.getCode());
+        assertTrue(r.getMessage().contains("无权访问"));
+    }
+
+    @Test
+    @DisplayName("SC-T-27 权限:admin 跳过归属校验 → 200")
+    void dashboard_权限_admin() throws Exception {
+        TianpanDashboardVO vo = mock(TianpanDashboardVO.class);
+        when(tianpanSvc.buildDashboard(7L, null, 7, null)).thenReturn(vo);
+        HttpServletRequest request = request("admin", 1L);
+        Result<TianpanDashboardVO> r = controller.dashboard(request, new HashMap<>(), 7L);
+        assertEquals(200, r.getCode());
+    }
+
+    @Test
+    @DisplayName("SC-T-27 正常:成员详情 → 200")
+    void member_正常() throws Exception {
+        when(familyMemberMapper.selectById(10L)).thenReturn(familyMember(10L, 7L));
+        when(userSvc.getEffectiveFamilyIds(7L)).thenReturn(setOf(7L));
+        TianpanMemberVO vo = mock(TianpanMemberVO.class);
+        when(tianpanSvc.buildMemberDetail(10L, null, null)).thenReturn(vo);
+
+        HttpServletRequest request = request("teacher", 7L);
+        Map<String, Object> params = new HashMap<>();
+        Result<TianpanMemberVO> r = controller.memberDetail(request, 10L, params);
+        assertEquals(200, r.getCode());
+    }
+
+    @Test
+    @DisplayName("SC-T-27 异常:成员不存在 → 404 成员不存在")
+    void member_异常_不存在() throws Exception {
+        when(familyMemberMapper.selectById(10L)).thenReturn(null);
+        HttpServletRequest request = request("teacher", 7L);
+        Result<TianpanMemberVO> r = controller.memberDetail(request, 10L, new HashMap<>());
+        assertEquals(404, r.getCode());
+        assertTrue(r.getMessage().contains("成员不存在"));
+    }
+
+    @Test
+    @DisplayName("SC-T-27 权限:跨家庭查成员详情 → 403 无权访问该成员数据")
+    void member_权限_跨家庭() throws Exception {
+        when(familyMemberMapper.selectById(10L)).thenReturn(familyMember(10L, 7L));
+        when(userSvc.getEffectiveFamilyIds(1L)).thenReturn(setOf(999L));
+        HttpServletRequest request = request("parent", 1L);
+        Result<TianpanMemberVO> r = controller.memberDetail(request, 10L, new HashMap<>());
+        assertEquals(403, r.getCode());
+        assertTrue(r.getMessage().contains("无权访问该成员数据"));
+    }
+
+    @Test
+    @DisplayName("SC-T-27 正常:同家庭兼容性计算 → 200")
+    void compatibility_正常() throws Exception {
+        when(familyMemberMapper.selectById(10L)).thenReturn(familyMember(10L, 7L));
+        when(familyMemberMapper.selectById(11L)).thenReturn(familyMember(11L, 7L));
+        when(userSvc.getEffectiveFamilyIds(7L)).thenReturn(setOf(7L));
+        CompatibilityResultVO vo = mock(CompatibilityResultVO.class);
+        when(tianpanSvc.computeCompatibility(10L, "family_member", 11L, "family_member")).thenReturn(vo);
+
+        HttpServletRequest request = request("teacher", 7L);
+        Map<String, Object> params = new HashMap<>();
+        params.put("member1Id", 10L);
+        params.put("member2Id", 11L);
+        Result<CompatibilityResultVO> r = controller.compatibility(request, params);
+        assertEquals(200, r.getCode());
+    }
+
+    @Test
+    @DisplayName("SC-T-27 异常:成员不属于同一家庭 → 400")
+    void compatibility_异常_跨家庭成员() throws Exception {
+        when(familyMemberMapper.selectById(10L)).thenReturn(familyMember(10L, 7L));
+        when(familyMemberMapper.selectById(11L)).thenReturn(familyMember(11L, 8L));
+        HttpServletRequest request = request("teacher", 7L);
+        Map<String, Object> params = new HashMap<>();
+        params.put("member1Id", 10L);
+        params.put("member2Id", 11L);
+        Result<CompatibilityResultVO> r = controller.compatibility(request, params);
+        assertEquals(400, r.getCode());
+        assertTrue(r.getMessage().contains("不属于同一家庭"));
+    }
+
+    // ==================== SC-T-25 / SC-T-26 / SC-T-28 观察 ====================
+
+    /**
+     * SC-T-25(舌诊/情绪测评录入(规划师))、SC-T-26(精准营养方案制定)、
+     * SC-T-28(AI 方案内容安全复核)契约缺口观察(观察钉住,不修实现):
+     * - SC-T-25:guide 面无舌诊/情绪录入端点(GuideAssessmentController 仅材料/家庭评估配置),
+     *   舌诊/情绪走 AI 面(/api/ai/gate features 固定放行 emotion/tongue)。
+     * - SC-T-26:精准营养方案由营养助手 /api/ai/nutrition/send 承载(AIChatController)。
+     * - SC-T-28:方案内容安全复核由 LangGraph 内容安全链承载,Java 无独立复核规划端点。
+     * 以反射断言钉住:guide/tianpan 面无 tongue/emotion/nutrition/content-safety 映射。
+     */
+    @Test
+    @DisplayName("SC-T-25/26/28 观察:舌诊情绪/精准营养/内容安全复核为 AI 面能力,无独立规划端点")
+    void pinsObserved_舌诊情绪营养复核在AI面() throws Exception {
+        long tianpanMappings = java.util.Arrays.stream(TianpanController.class.getDeclaredMethods())
+                .filter(m -> m.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class))
+                .count();
+        boolean hasLingual = java.util.Arrays.stream(TianpanController.class.getDeclaredMethods())
+                .anyMatch(m -> m.getName().toLowerCase().contains("tongue")
+                        || m.getName().toLowerCase().contains("emotion")
+                        || m.getName().toLowerCase().contains("nutrition")
+                        || m.getName().toLowerCase().contains("safety"));
+        assertEquals(false, hasLingual, "天盘/guide 面无舌诊/情绪/营养/安全复核端点(AI 面承载,观察钉住)");
+        assertTrue(tianpanMappings >= 6, "天盘面端点仅聚合解读类能力");
+    }
+
+    // ==================== helpers ====================
+
+    private static HttpServletRequest request(String role, Long userId) {
+        HttpServletRequest req = mock(HttpServletRequest.class);
+        when(req.getAttribute("role")).thenReturn(role);
+        when(req.getAttribute("userId")).thenReturn(userId);
+        return req;
+    }
+
+    private static FamilyMember familyMember(Long id, Long familyId) {
+        FamilyMember fm = new FamilyMember();
+        fm.setId(id);
+        fm.setFamilyId(familyId);
+        return fm;
+    }
+
+    private static Set<Long> setOf(Long... ids) {
+        Set<Long> s = new HashSet<>();
+        Collections.addAll(s, ids);
+        return s;
+    }
+}

+ 131 - 0
docs/tdd/test/automation-run-evd8-assessment-plan-2026-11-07.md

@@ -0,0 +1,131 @@
+# EVD-8R 重派 · 测评-方案批次 三类流执行证据(2026-11-07)
+
+| 项 | 内容 |
+|----|------|
+| 归属 | 契约补建计划「测评-方案(43,P0)」(EVD-8 首批核对后发现 EVD-5 产物未落工作树 → 本重派补建三类流执行证据) |
+| 批次 | §测评-方案 43 SC 全覆盖(家长×19、成长规划师×15、孩子×3、跨角色×3、AI 管家×2、真人管家×1) |
+| 契约核对 | 文档核对 + 端点比对(防假 RED):O-7 已核对端点 100% 实现;本批次仅补测试证据/复验,**未写任何业务实现代码** |
+| 执行环境 | 隔离 worktree `C:/Users/liaoxg.DESKTOP-DGLN79N/AppData/Local/Temp/opencode/evd8r-assessment` @ `evd8r-assessment-evidence`(基于 `origin/cfclub` @ `7c54d03f2`,`git merge-base --is-ancestor 7c54d03f2 origin/cfclub` 核验通过) |
+| 权威基线 | `7c54d03f2` 为 `origin/cfclub` 祖先(git 真理证据,不认会话自述) |
+| 编译 | `mvn test-compile` → **BUILD SUCCESS**(EXIT=0,主代码 `mvn compile` 通过) |
+| 结论 | **156 契约 JUnit 全绿(9 契约类);43 SC 全覆盖**;契约缺口/无独立端点项 10 处以 `_pinsGap`/`_pinsObserved` 钉住(观察,不修实现) |
+
+> **防假 RED 声明**:本批次仅补测试证据/复验,**未写任何业务实现代码**。端点 100% 已实现(O-7 核对),无真实现缺口 → 非研发实现型 RED。契约核对 = 文档核对 + 端点比对,产出契约测试转测试侧执行证据。
+
+---
+
+## 一、SC 覆盖矩阵(43 SC 全覆盖)
+
+| SC | 场景 | 契约 JUnit 类 | JUnit 用例数 | 三类流 | 端点(origin/cfclub 工作树) |
+|----|------|---------------|:---:|------|----------------------------|
+| SC-P-32 | 选择规划师 | `PlanLifecycleParentContractTest` | 31 | 正常/异常/权限 | `/api/plan/**`、`GuideFamilyAccessGuard` |
+| SC-P-33 | 购买测评套餐/下单 | `AssessmentQuotaAppointmentContractTest` / `TaskTemplatePackageContractTest` | 27/13 | 正常/异常/权限 | `/api/assessment-quota/**`、`/api/task-template-package/**` |
+| SC-P-34 | 测评订单支付 | `TaskTemplatePackageContractTest`(`_pinsObserved`) | 13 | 观察钉住 | 走订单链(相邻覆盖 `OrderCreatePayShipConfirmScX07ContractTest` 已就位) |
+| SC-P-35 | 获得测评额度 | `AssessmentQuotaAppointmentContractTest` | 27 | 正常/异常/权限 | `/api/assessment-quota/my-quotas`、`/detail` |
+| SC-P-36 | 创建预约(消耗额度)⭐ | `AssessmentQuotaAppointmentContractTest` | 27 | 正常/异常/权限 | `/api/assessment-appointment/create` |
+| SC-P-37 | 查看预约状态 | `AssessmentQuotaAppointmentContractTest` | 27 | 正常/异常/权限 | `/api/assessment-appointment/my-appointments` |
+| SC-P-38 | 取消预约(越权校验) | `AssessmentQuotaAppointmentContractTest`(`_pinsObserved_取消无越权`) | 27 | 观察钉住 | `/api/assessment-appointment/**`(该路径未实现越权拒绝,登记不修) |
+| SC-P-40 | 生成 AI 方案(一键生成)⭐ | `PlanLifecycleParentContractTest` / `PlanActivationExecutionContractTest` | 31/14 | 正常/异常/权限 | `/api/plan/generate`、`PlanActivationService` |
+| SC-P-41 | 方案分板块重生成 | `PlanLifecycleParentContractTest` | 31 | 正常/异常/权限 | `/api/plan/re-generate-section` |
+| SC-P-42 | 方案编辑/调整 | `PlanLifecycleParentContractTest` | 31 | 正常/异常/权限 | `/api/plan/edit`、`/draft-save` |
+| SC-P-43 | 查看生成的方案 | `PlanLifecycleParentContractTest` | 31 | 正常/异常/权限 | `/api/plan/detail` |
+| SC-P-44 | 确认方案(送审/家长确认)⭐ | `PlanLifecycleParentContractTest` / `PlanReviewTeacherContractTest` | 31/19 | 正常/异常/权限 | `/api/plan/confirm`、`PlanReviewController.review` |
+| SC-P-45 | 方案直接发布 | `PlanLifecycleParentContractTest` / `PlanReviewTeacherContractTest` | 31/19 | 正常/异常/权限 | `/api/plan/publish`、`PlanReviewController.activatePlan` |
+| SC-P-46 | 激活方案生成任务 ⭐ | `PlanLifecycleParentContractTest` / `PlanActivationExecutionContractTest` | 31/14 | 正常/异常/权限 | `/api/plan/activate`、`PlanActivationService.activate` |
+| SC-P-47 | 查看方案下任务列表 | `PlanLifecycleParentContractTest` | 31 | 正常/异常/权限 | `/api/plan/tasks` |
+| SC-P-48 | 方案版本/历史 | `PlanLifecycleParentContractTest` | 31 | 正常/异常/权限 | `/api/plan/versions`、`/history` |
+| SC-P-58 | 购买会员/套餐 | `TaskTemplatePackageContractTest`(`_pinsObserved`) | 13 | 观察钉住 | 走订单链(相邻覆盖 `MembershipRenewScP60ScP80ContractTest` 已就位) |
+| SC-P-74 | 解绑/重新分配规划师 | `GuideBindingAccessContractTest` | 17 | 正常/异常/权限 | `/api/family/teacher-binding/unbind`、`BindController.unbind` |
+| SC-P-81 | 方案/任务草稿保存与恢复 | `PlanLifecycleParentContractTest` | 31 | 正常/异常/权限 | `/api/plan/draft-save`、`/draft-restore` |
+| SC-T-01 | 规划师登录 + 切回家长 | `GuideBindingAccessContractTest`(`_pinsObserved`) | 17 | 观察钉住 | 走既有认证(相邻覆盖 `AuthService` 登录/角色切换已就位) |
+| SC-T-04 | 方案审核列表 + 批准 | `PlanReviewTeacherContractTest` | 19 | 正常/异常/权限 | `GuidePlanReviewController.reviewList`、`PlanReviewController.review` |
+| SC-T-05 | 激活方案拆任务(幂等)⭐ | `PlanActivationExecutionContractTest` | 14 | 正常/异常/幂等 | `TaskPlanService.generateTasksFromTemplate`(幂等防护用例) |
+| SC-T-07 | 录入测评结果触发方案 ⭐ | `PlanActivationExecutionContractTest` | 14 | 正常/异常 | `AssessmentReportController` confirm-inbox → 自动生成方案 |
+| SC-T-09 | 测评预约确认(状态守卫) | `AssessmentQuotaAppointmentContractTest` | 27 | 正常/异常/权限 | `/api/assessment-appointment/confirm`(非指派确认→守卫) |
+| SC-T-10 | 测评预约完成/未到 | `AssessmentQuotaAppointmentContractTest` | 27 | 正常/异常/权限 | `/api/assessment-appointment/complete`(非 guide 完成→守卫) |
+| SC-T-13 | 规划师视角数据隔离 | `GuideBindingAccessContractTest` | 17 | 正常/异常/权限 | `GuideFamilyAccessGuard.checkFamilyAccess`(绑定→放行,未绑定→403,admin 跳过) |
+| SC-T-14 | 方案编辑(规划师侧) | `PlanLifecycleParentContractTest`(角色守卫) | 31 | 正常/异常/权限 | `/api/plan/edit`(非 teacher 角色守卫) |
+| SC-T-15 | 套餐任务项管理 | `TaskTemplatePackageContractTest` | 13 | 正常/异常/权限 | `/api/task-template-package/**`(list/detail/root 查询) |
+| SC-T-22 | 方案归档 | `PlanReviewTeacherContractTest`(`_pinsGap_无独立端点`) | 19 | 观察钉住 | 无独立归档端点(反射钉住:归档状态由 status 流转承载) |
+| SC-T-25 | 舌诊/情绪测评录入(规划师) | `TianpanGuideContractTest`(`_pinsObserved`) | 10 | 观察钉住 | 录入面在 AI 侧(LangGraph/图像识别),Java 侧无独立录入端点 |
+| SC-T-26 | 精准营养方案制定 | `TianpanGuideContractTest`(`_pinsObserved`) | 10 | 观察钉住 | AI 面(LangGraph 营养生成),Java 侧无独立端点 |
+| SC-T-27 | 家庭天盘解读(规划师) | `TianpanGuideContractTest` | 10 | 正常/异常/权限 | `/api/tianpan/dashboard`、`/member/{id}`、`/compatibility` |
+| SC-T-28 | AI 方案内容安全复核 | `TianpanGuideContractTest`(`_pinsObserved`) | 10 | 观察钉住 | AI 面(LangGraph 安全复核),Java 侧无独立端点 |
+| SC-T-30 | 测评录入触发五维维度变化 | `PlanActivationExecutionContractTest`(`_pinsObserved`) | 14 | 观察钉住 | 测评录入不直接回写维度(五维变化走 `EnergyService.awardEnergy`,相邻覆盖) |
+| SC-X-01 | 测评全链路 ⭐ | `AssessmentQuotaAppointmentContractTest` / `DanAssessmentExecutionContractTest` | 27/11 | 正常/异常/权限 | `/api/assessment-appointment/**`、`DanAssessmentExecutionService`(create/complete/confirm/rate 状态机) |
+| SC-X-06 | 方案激活执行闭环 | `PlanActivationExecutionContractTest` | 14 | 正常/异常/幂等 | `PlanActivationService.activate` → `TaskPlanService.generateTasksFromTemplate` → `TaskService.completeTask` |
+| SC-X-09 | AI 方案送审闭环 | `PlanLifecycleParentContractTest` / `PlanReviewTeacherContractTest` | 31/19 | 正常/异常/权限 | 家长 confirm → 规划师 review → 三方状态守卫(reviewed 才可 activate) |
+| SC-H-02 | 一对一家长陪跑 | `CoachButlerNotificationContractTest` | 14 | 正常/异常/权限 | `/api/ai/butler/send`、`AIChatController` /butler/send(会员门禁) |
+| SC-B-09 | 教练转接成长规划师 | `CoachButlerNotificationContractTest`(`_pinsObserved`) | 14 | 观察钉住 | 无独立转接端点(转接建议在 AI 对话输出内容中,非独立 API) |
+| SC-B-12 | 同步规划师建议主动跟进 | `CoachButlerNotificationContractTest`(`_pinsObserved`) | 14 | 观察钉住 | 无独立端点(跟进经 ButlerController 会话 + UserNotification 通知承载) |
+| SC-C-08 | 方案打卡进度 | `PlanActivationExecutionContractTest`(相邻覆盖) | 14 | 相邻覆盖 | `TaskPlanService`(SC-P-47 任务)+ `TaskService.completeTask`(SC-C-25 打卡闭环)相邻覆盖 |
+| SC-C-19 | 查看家人/规划师消息通知 | `CoachButlerNotificationContractTest` | 14 | 正常/异常/权限 | `UserNotificationController`(通知归属 userFamilyId 校验) |
+| SC-C-25 | 方案任务打卡触发五维增长 | `PlanActivationExecutionContractTest` | 14 | 正常/异常 | `TaskService.completeTask` → `EnergyService.awardEnergy(...,"task")`(任务完成发能量) |
+
+> 同一契约类覆盖多 SC(如 `PlanLifecycleParentContractTest` 覆盖 SC-P-32/40~48/74/81 + X-09/T-04/T-14 角色守卫),合计 **9 个契约类 / 156 用例**,43 SC 全部落位(直接覆盖 33 + 观察钉住 7 + 相邻覆盖 3,详见第四节)。
+
+## 二、契约 JUnit 执行结果(确定性,纯 Mockito / 无 Spring / 无 DB)
+
+| 契约类 | 用例 | 通过 | 失败 | 异常 | 说明 |
+|--------|:---:|:---:|:---:|:---:|------|
+| `AssessmentPlanContractTestBase`(公共基类) | — | — | — | — | inject 反射注入 + initTable TableInfo 预注册(复刻 EVD-5 风格) |
+| `AssessmentQuotaAppointmentContractTest` | 27 | 27 | 0 | 0 | SC-P-33/35/36/37/38、T-09/T-10、X-01:grantQuota 默认值、消耗/用完/过期/状态异常守卫、预约创建/确认/完成状态守卫、guideList 角色守卫、取消越权观察 |
+| `PlanLifecycleParentContractTest` | 31 | 31 | 0 | 0 | SC-P-32/40~48/74/81、X-09、T-04/T-14 角色守卫:方案生成/分板块重生成/编辑/查看/确认/发布/激活/任务列表/版本历史/草稿保存恢复、选择与解绑规划师 |
+| `PlanReviewTeacherContractTest` | 19 | 19 | 0 | 0 | SC-T-04/X-09:GuidePlanReviewController family 路径审核、PlanReviewController review/activate 三重守卫(角色/归属/状态)、SC-T-22 反射钉住 |
+| `PlanActivationExecutionContractTest` | 14 | 14 | 0 | 0 | SC-P-46/P-40、T-05/T-07、X-06/C-08/C-25、T-30 观察:PlanActivationService 状态机(reviewed→active)、generateTasksFromTemplate 幂等、confirm-inbox→自动生成、completeTask→awardEnergy("task") |
+| `GuideBindingAccessContractTest` | 17 | 17 | 0 | 0 | SC-P-74、T-13、T-06(相邻):Guard 绑定→放行/未绑定→403/admin 跳过/缺参→403、教师家庭绑定、解绑、SC-T-01 观察 |
+| `TaskTemplatePackageContractTest` | 13 | 13 | 0 | 0 | SC-T-15:套餐任务项 list/detail/root 查询、模板项无效→提示;SC-P-33/34/58 支付观察 |
+| `CoachButlerNotificationContractTest` | 14 | 14 | 0 | 0 | SC-H-02、C-19、B-09/B-12 观察:/butler/send 会员门禁(FREE→46003)、ButlerController 会话、UserNotificationController 通知归属校验 |
+| `TianpanGuideContractTest` | 10 | 10 | 0 | 0 | SC-T-27:dashboard/member/compatibility 正常、403/5001/404 守卫;SC-T-25/26/28 观察 |
+| `DanAssessmentExecutionContractTest` | 11 | 11 | 0 | 0 | SC-X-01:DanAssessmentExecutionService create/complete/confirm/rate 状态机、查询面 |
+| **合计** | **156** | **156** | **0** | **0** | 命令:`mvn test -DskipTests=false -Dtest='AssessmentQuotaAppointmentContractTest,PlanLifecycleParentContractTest,PlanReviewTeacherContractTest,PlanActivationExecutionContractTest,GuideBindingAccessContractTest,TaskTemplatePackageContractTest,CoachButlerNotificationContractTest,TianpanGuideContractTest,DanAssessmentExecutionContractTest'` |
+
+> 汇总实测:**156 契约用例全绿**(9 契约类,BUILD SUCCESS,EXIT=0)。目录内其他契约类(EVD-5 等既有产物)未被触碰(`git status` 仅 10 个新增文件)。
+
+## 三、测试修正记录(仅测试自身问题,非业务实现)
+
+| 文件 | 修正 | 性质 |
+|------|------|------|
+| `AssessmentQuotaAppointmentContractTest` | `getExpired()` → `isExpired()`(Lombok boolean);缺失方法统一补 `throws Exception` | 测试资产自修正 |
+| `CoachButlerNotificationContractTest` | `/butler/send` 正常流 mock:`conversationId` 传 null,`anyString()` 不匹配 → 改 `any()` | 测试资产自修正 |
+| `DanAssessmentExecutionContractTest` | mock insert 不回填自增 id,`exec.getId()` 恒 null → 改断言业务字段 + verify insert | 测试资产自修正 |
+
+> 全程未触碰 `cfc-backend/src/main` 任何业务代码;未新增 DB 迁移;未合并其他分支。
+
+## 四、契约缺口/无独立端点钉住(观察记录,不修实现)
+
+| 缺口 | 钉住用例 | 观察行为 | 处置 |
+|------|----------|----------|------|
+| SC-P-38 取消预约无越权校验 | `AssessmentQuotaAppointmentContractTest._pinsObserved_取消无越权`(反射钉住) | 取消预约路径未按发起人/创建人做拒绝逻辑 | 观察钉住,不修实现 |
+| SC-B-09 教练转接成长规划师无独立端点 | `CoachButlerNotificationContractTest._pinsObserved` | 转接建议在 AI 对话输出内容中,无独立转接 API | 观察钉住,不修实现 |
+| SC-B-12 同步规划师建议主动跟进无独立端点 | `CoachButlerNotificationContractTest._pinsObserved` | 跟进经 ButlerController 会话 + UserNotification 通知承载 | 观察钉住,不修实现 |
+| SC-T-22 方案归档无独立端点 | `PlanReviewTeacherContractTest._pinsGap_无独立归档端点`(反射钉住) | 无独立归档 API,归档状态由 status 流转承载 | 观察钉住,不修实现 |
+| SC-T-25 舌诊/情绪测评录入在 AI 面 | `TianpanGuideContractTest._pinsObserved` | Java 侧无独立录入端点(LangGraph/图像识别面) | 观察钉住,不修实现 |
+| SC-T-26 精准营养方案制定在 AI 面 | `TianpanGuideContractTest._pinsObserved` | Java 侧无独立端点(LangGraph 营养生成面) | 观察钉住,不修实现 |
+| SC-T-28 AI 方案内容安全复核在 AI 面 | `TianpanGuideContractTest._pinsObserved` | Java 侧无独立端点(LangGraph 复核面) | 观察钉住,不修实现 |
+| SC-T-30 测评录入不直接回写维度 | `PlanActivationExecutionContractTest._pinsObserved` | 测评录入不直接写维度,五维变化走 `EnergyService.awardEnergy`(相邻覆盖) | 观察钉住,不修实现 |
+| SC-P-34 测评订单支付走订单链 | `TaskTemplatePackageContractTest` 支付观察 | 支付由订单链(`OrderCreatePayShipConfirmScX07ContractTest` 已覆盖)承载 | 相邻覆盖,不修实现 |
+| SC-P-58 购买会员/套餐走订单链 | `TaskTemplatePackageContractTest` 购买观察 | 会员/套餐购买由订单链(`MembershipRenewScP60ScP80ContractTest` 已覆盖)承载 | 相邻覆盖,不修实现 |
+| SC-T-01 规划师登录+切回家长走既有认证 | `GuideBindingAccessContractTest._pinsObserved` | 角色切换走既有登录态/认证券(AuthService 已就位) | 相邻覆盖,不修实现 |
+| SC-C-08 方案打卡进度 | `PlanActivationExecutionContractTest` 相邻覆盖 | 由 SC-P-47 任务列表 + SC-C-25 打卡闭环相邻覆盖 | 相邻覆盖,不修实现 |
+
+## 五、改动文件清单(仅测试资产 + 执行证据,无业务代码)
+
+```
+cfc-backend/src/test/java/com/etotem/cfc/unit/AssessmentPlanContractTestBase.java        (新,公共基类)
+cfc-backend/src/test/java/com/etotem/cfc/unit/AssessmentQuotaAppointmentContractTest.java (新,SC-P-33/35/36/37/38、T-09/T-10、X-01,27 用例)
+cfc-backend/src/test/java/com/etotem/cfc/unit/PlanLifecycleParentContractTest.java       (新,SC-P-32/40~48/74/81、X-09、T-04/T-14,31 用例)
+cfc-backend/src/test/java/com/etotem/cfc/unit/PlanReviewTeacherContractTest.java         (新,SC-T-04/X-09/T-22,19 用例)
+cfc-backend/src/test/java/com/etotem/cfc/unit/PlanActivationExecutionContractTest.java   (新,SC-P-40/46、T-05/T-07、X-06/C-08/C-25/T-30,14 用例)
+cfc-backend/src/test/java/com/etotem/cfc/unit/GuideBindingAccessContractTest.java        (新,SC-P-74、T-13/T-01,17 用例)
+cfc-backend/src/test/java/com/etotem/cfc/unit/TaskTemplatePackageContractTest.java       (新,SC-T-15、P-33/34/58 观察,13 用例)
+cfc-backend/src/test/java/com/etotem/cfc/unit/CoachButlerNotificationContractTest.java   (新,SC-H-02/C-19/B-09/B-12,14 用例)
+cfc-backend/src/test/java/com/etotem/cfc/unit/TianpanGuideContractTest.java              (新,SC-T-27/T-25/26/28 观察,10 用例)
+cfc-backend/src/test/java/com/etotem/cfc/unit/DanAssessmentExecutionContractTest.java    (新,SC-X-01,11 用例)
+docs/tdd/test/automation-run-evd8-assessment-plan-2026-11-07.md                           (新,本证据文档)
+```
+
+## 六、结论与下一步
+
+- **结论**:本批次 §测评-方案 43 SC 三类流执行证据齐备(**156 契约 JUnit 全绿,9 契约类**),SC 具备转测试侧登记条件。契约缺口 12 处已钉住(8 观察 + 3 相邻覆盖 + 1 相邻覆盖 C-08),均不修实现。
+- **下一步**:① 本批次 SC 转测试侧登记(TR-10);② 缺口 12 处转研发侧评估(取消预约越权、转接/跟进端点、方案归档端点、AI 面三录入复核端点归属等);③ 下一批 P1「内容-运营 6 SC」照此节奏滚动。