Explorar o código

Merge origin/cfclub into cfclub (S-cfc-221 child.mind.capture RED integration)

E2E Test Bot hai 1 hora
pai
achega
d4f9b3a96b

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java

@@ -268,6 +268,12 @@ public class GuideFamilyTaskController {
         Result<Void> denied = requireBound(guideId, familyId);
         if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
 
+        // SC-T-13 数据隔离:memberId 必须属于路径 familyId,否则拒绝(防跨家庭 IDOR)
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member != null && !familyId.equals(member.getFamilyId())) {
+            return Result.error(403, "成员不属于该家庭");
+        }
+
         Integer page = params != null && params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer size = params != null && params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
         String status = params != null && params.get("status") != null ? params.get("status").toString() : null;

+ 252 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildDietViewMenuContractTest.java

@@ -0,0 +1,252 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.DietRecommendation;
+import com.etotem.cfc.mapper.DietRecommendationMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MealConfigMapper;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.BeijingNutritionService;
+import com.etotem.cfc.service.DietRecommendationService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:饮食食谱查看(能力键 {@code child.diet.viewMenu})。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-12「饮食食谱查看」——
+ *       当日三餐展示;<b>边界:无食谱空态</b>(前端以返回 {@code data.id} 是否为 null 判定空态卡)。</li>
+ *   <li>能力映射:{@code docs/tdd/orchestration/sc-key-map.md}——
+ *       {@code SC-C-12 = child.diet.viewMenu}(S-cfc-218,{@code FoodRecommend/HealthMeal/MealRecommend(当日三餐·空态)})。</li>
+ *   <li>实测载体({@code docs/superpowers/specs/2026-11-07-child-zone-contract.md} EVD-9):
+ *       {@code POST /api/diet/recommendation/today};{@code docs/superpowers/api/API_REFERENCE.md} §4.11
+ *       ——返回 {@code data{id, date, mealType, menu, nutritionSummary, status, version, participantMemberIds}};
+ *       前端 {@code cfc-frontend/pages/diet/recommendation.vue} 以 {@code d.id} 判定空态、以 {@code menu.meals} 渲染三餐。</li>
+ * </ul>
+ *
+ * <p><b>被校验契约行为(GREEN 目标)</b></p>
+ * <ul>
+ *   <li><b>正常流</b>:查询到记录 → 今日食谱视图透传契约字段(id/date/mealType/menu/nutritionSummary/status/version)。</li>
+ *   <li><b>边界-空态</b>:无记录 → 返回 {@code id=null} 空态(不抛异常)。</li>
+ *   <li><b>边界-当日三餐聚合(SC-C-12)</b>:早/午/晚分餐次记录并存时,视图应<b>聚合出当日三餐</b>
+ *       (menu.meals 同时含 breakfast/lunch/dinner),而非只取第一条。</li>
+ *   <li><b>边界-最新版本(regenerate 后)</b>:新旧版本记录并存时,应展示<b>最新版本</b>菜单,
+ *       不得回退展示旧菜单(regenerate 会递增 version)。</li>
+ *   <li><b>边界-空壳回落空态(SC-C-12)</b>:记录存在但 {@code menuJson="{}"} 时,应回落空态
+ *       ({@code id=null}),不得返回带 {@code id} 的空壳卡。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:{@link DietRecommendationService#getTodayRecommendation(Long, String)} 当前
+ * {@code records.get(0)} 仅取首条、无版本选择、无空壳回落,故<b>三餐聚合 / 最新版本 / 空壳回落</b>三例在
+ * 实现补齐前应失败(红);正常流与空态为已实现契约锚点(绿),用于区分「实现存在但契约缺口行为未落地」而非全绿。</p>
+ *
+ * <p><b>测试范围</b>:{@link DietRecommendationService#getTodayRecommendation(Long, String, String)} 契约行为。
+ * 纯 Mockito(无 Spring / 无 DB / 无外部服务)。权限流不适用:familyId 由 {@code JwtInterceptor} 从 JWT 注入本人家庭,
+ * 服务层无独立鉴权分支(与 TeamStarMultiplierContractTest 同口径)。</p>
+ */
+class ChildDietViewMenuContractTest {
+
+    private static final Long FAMILY_ID = 100L;
+    private static final String DATE = "2026-11-13";
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Mock
+    private DietRecommendationMapper dietRecommendationMapper;
+
+    @Mock
+    private BeijingNutritionService beijingNutritionService;
+
+    @Mock
+    private AiGateway aiGateway;
+
+    @Mock
+    private MealConfigMapper mealConfigMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private DietRecommendationService service;
+
+    /** 防御式初始化:LambdaQueryWrapper 在纯单测中解析列需要 TableInfo(参考 ReportDietRecommendContractTest)。 */
+    @BeforeAll
+    static void initMybatisPlusTableInfo() {
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, DietRecommendation.class);
+    }
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================== 数据构造 ====================
+
+    private DietRecommendation record(Long id, String mealType, String menuJson, String status, Integer version) {
+        DietRecommendation r = new DietRecommendation();
+        r.setId(id);
+        r.setFamilyId(FAMILY_ID);
+        r.setMealType(mealType);
+        r.setMenuJson(menuJson);
+        r.setNutritionSummary("{\"calories\":800}");
+        r.setStatus(status);
+        r.setVersion(version);
+        r.setParticipantMemberIds("[1,2]");
+        return r;
+    }
+
+    /** 单餐次菜单 JSON:meals 数组含 type/name/dishes。 */
+    private static String mealMenu(String type, String name) {
+        return "{\"meals\":[{\"type\":\"" + type + "\",\"name\":\"" + name
+                + "\",\"participants\":1,\"dishes\":[{\"name\":\"契约菜\",\"type\":\"dish\","
+                + "\"calories\":100,\"ingredients\":[],\"method\":\"煮\"}]}]}";
+    }
+
+    /**
+     * 从返回的 menu(String 或 Map 双形态)解析出餐次类型列表。
+     * GREEN 实现若聚合三餐,menu 可能为合并后的结构体而非原始 JSON 串,故双形态兼容。
+     */
+    private static List<String> mealTypesOf(Object menuObj) throws Exception {
+        List<String> types = new ArrayList<>();
+        if (menuObj == null) {
+            return types;
+        }
+        JsonNode mealsNode = null;
+        if (menuObj instanceof String) {
+            JsonNode root = OBJECT_MAPPER.readTree((String) menuObj);
+            if (root != null && root.isObject() && root.has("meals")) {
+                mealsNode = root.get("meals");
+            }
+        } else if (menuObj instanceof Map) {
+            Object m = ((Map<?, ?>) menuObj).get("meals");
+            if (m != null) {
+                mealsNode = OBJECT_MAPPER.valueToTree(m);
+            }
+        }
+        if (mealsNode != null && mealsNode.isArray()) {
+            for (JsonNode meal : mealsNode) {
+                if (meal.has("type")) {
+                    types.add(meal.get("type").asText());
+                }
+            }
+        }
+        return types;
+    }
+
+    // ==================== 正常流(已实现契约锚点 → 绿) ====================
+
+    @Test
+    @DisplayName("正常流:单条完整记录 → 返回契约字段(id/date/mealType/menu/nutritionSummary/status/version)")
+    void 单条完整记录_返回契约字段() {
+        DietRecommendation rec = record(7L, "lunch", mealMenu("lunch", "午餐"), "accepted", 2);
+        when(dietRecommendationMapper.selectList(any())).thenReturn(Collections.singletonList(rec));
+
+        Map<String, Object> result = service.getTodayRecommendation(FAMILY_ID, DATE);
+
+        assertEquals(7L, ((Number) result.get("id")).longValue(),
+                "[child.diet.viewMenu] id 应透传记录 id。");
+        assertEquals(DATE, result.get("date"),
+                "[child.diet.viewMenu] date 应透传查询日期。");
+        assertEquals("lunch", result.get("mealType"),
+                "[child.diet.viewMenu] mealType 应透传记录餐次。");
+        assertNotNull(result.get("menu"),
+                "[child.diet.viewMenu] menu 不得为空。");
+        assertTrue(result.get("menu").toString().contains("meals"),
+                "[child.diet.viewMenu] menu 应含 meals 结构,实际: " + result.get("menu"));
+        assertNotNull(result.get("nutritionSummary"),
+                "[child.diet.viewMenu] nutritionSummary 应透传。");
+        assertEquals("accepted", result.get("status"),
+                "[child.diet.viewMenu] status 应透传。");
+        assertEquals(2, ((Number) result.get("version")).intValue(),
+                "[child.diet.viewMenu] version 应透传。");
+    }
+
+    @Test
+    @DisplayName("空态:无记录 → 返回 id=null 空态且不抛异常")
+    void 无记录_返回空态不抛异常() {
+        when(dietRecommendationMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        Map<String, Object> result = service.getTodayRecommendation(FAMILY_ID, DATE);
+
+        assertNull(result.get("id"),
+                "[child.diet.viewMenu] 无记录时 id 应为 null(空态)。");
+        assertEquals("暂无推荐,请生成", result.get("message"),
+                "[child.diet.viewMenu] 空态应带提示 message。");
+    }
+
+    // ==================== 契约缺口-1:当日三餐聚合(SC-C-12,未落地 → 红) ====================
+
+    @Test
+    @DisplayName("契约缺口-1:分餐次记录(早餐/午餐/晚餐)应聚合出当日三餐")
+    void 分餐次记录_应聚合当日三餐() throws Exception {
+        when(dietRecommendationMapper.selectList(any())).thenReturn(Arrays.asList(
+                record(1L, "breakfast", mealMenu("breakfast", "早餐"), "accepted", 1),
+                record(2L, "lunch", mealMenu("lunch", "午餐"), "accepted", 1),
+                record(3L, "dinner", mealMenu("dinner", "晚餐"), "accepted", 1)));
+
+        Map<String, Object> result = service.getTodayRecommendation(FAMILY_ID, DATE);
+
+        List<String> types = mealTypesOf(result.get("menu"));
+        assertTrue(types.containsAll(Arrays.asList("breakfast", "lunch", "dinner")),
+                "[child.diet.viewMenu] 分餐次记录并存时今日食谱应聚合早/午/晚三餐(SC-C-12 当日三餐),实际仅含: " + types);
+    }
+
+    // ==================== 契约缺口-2:最新版本选择(regenerate 后,未落地 → 红) ====================
+
+    @Test
+    @DisplayName("契约缺口-2:新旧版本记录并存 → 展示最新版本菜单(regenerate 后不得回退旧菜单)")
+    void 新旧版本并存_展示最新版本() throws Exception {
+        DietRecommendation oldV = record(11L, "lunch", mealMenu("lunch", "旧版午餐"), "pending", 1);
+        DietRecommendation newV = record(12L, "lunch", mealMenu("lunch", "新版午餐"), "completed", 2);
+        // 模拟 DB 不保证顺序:旧版本在前(无 ORDER BY 时 records.get(0) 取到旧版)
+        when(dietRecommendationMapper.selectList(any())).thenReturn(Arrays.asList(oldV, newV));
+
+        Map<String, Object> result = service.getTodayRecommendation(FAMILY_ID, DATE);
+
+        assertEquals(2, ((Number) result.get("version")).intValue(),
+                "[child.diet.viewMenu] 新旧版本并存时应选择最新版本(version=2),不得展示旧版本(version=1)。");
+        Object menu = result.get("menu");
+        assertNotNull(menu,
+                "[child.diet.viewMenu] 最新版本选择后 menu 不得为空。");
+        assertTrue(menu.toString().contains("新版午餐"),
+                "[child.diet.viewMenu] 应展示最新版本菜单(新版午餐),实际: " + menu);
+    }
+
+    // ==================== 契约缺口-3:空壳回落空态(SC-C-12 无食谱空态,未落地 → 红) ====================
+
+    @Test
+    @DisplayName("契约缺口-3:记录 menuJson 为空壳 {} → 回落空态(id=null)")
+    void 空壳记录_回落空态() {
+        when(dietRecommendationMapper.selectList(any()))
+                .thenReturn(Collections.singletonList(record(9L, "lunch", "{}", "accepted", 1)));
+
+        Map<String, Object> result = service.getTodayRecommendation(FAMILY_ID, DATE);
+
+        assertNull(result.get("id"),
+                "[child.diet.viewMenu] menuJson 为 {} 的空壳记录不得返回 id(前端据 id 判定空态),应回落「暂无推荐,请生成」。");
+    }
+}

+ 221 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildNotificationReceiveContractTest.java

@@ -0,0 +1,221 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.UserNotificationController;
+import com.etotem.cfc.entity.UserNotification;
+import com.etotem.cfc.mapper.UserNotificationMapper;
+import com.etotem.cfc.service.NotificationDispatchService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:孩子接收消息通知(能力键 {@code child.notification.receive})。
+ *
+ * <p>契约来源(requirements + design):</p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-15「消息接收」、
+ *       SC-C-19「查看家人/规划师消息通知」——孩子收家长/规划师消息并标记已读,
+ *       异常/边界:越权消息不可见;分页正确。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.15({@code POST /api/notification/list}、
+ *       {@code POST /api/notification/read})+ 消息服务(规划师/家长跨端触达)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(本分支 src/main 核实)</b>:通知链路全程按「账号级 userId」定向——
+ * <ul>
+ *   <li>{@code user_notifications} 表:{@code user_id BIGINT NOT NULL}(schema.sql §user_notifications),
+ *       无 {@code member_id} 列、无成员级索引;</li>
+ *   <li>{@link UserNotification} 实体:仅 {@code userId} 字段,无 {@code memberId};</li>
+ *   <li>{@link UserNotificationController}:{@code list(userId)} / {@code read(userId, params)}
+ *       均只取 {@code @RequestAttribute("userId")},无成员级参数;</li>
+ *   <li>{@link NotificationDispatchService#sendByTemplate} 全部重载要求 {@code Long userId} 接收方。</li>
+ * </ul>
+ * 而「孩子(不能登录用户)」在用户模型统一口径下为 {@code family_members.userId} 为空的
+ * 家庭成员,<b>没有 userId</b> → 站内通知无法按 userId 投递/查询/已读,成员级(memberId)
+ * 定向通道缺失,契约行为未落地。</p>
+ *
+ * <p><b>测试策略</b>:纯反射结构契约 + 少量 Mockito 行为锚点(无 Spring / 无 DB / 无外部服务),
+ * 能编译、能运行、断言失败(红),失败信息可读可贴。</p>
+ *
+ * <p><b>RED 预期</b>:成员级(memberId)投递/列表/已读三件套全部缺失 → 下方红测应失败;
+ * 既有 userId 级已读路径(正常流-1)与越权防护(权限流)为说明性绿锚点,标注 G-01 缺口边界。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者补 {@code user_notifications.member_id} 列(+索引)与实体字段、
+ * 提供成员级 list/read 入口与 {@code sendByMember} 投递后,本测试自动转绿。</p>
+ */
+class ChildNotificationReceiveContractTest {
+
+    // ------------------------------------------------------------------
+    // 正常流:成员级(memberId)接收链路三件套 —— 列表 / 已读 / 投递
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1(绿锚点):userId 级标记已读路径仍可用(G-01 缺口仅限成员级)")
+    void read_byUserId_stillWorks() {
+        // 说明性绿锚点:账号级(有 userId 的能登录用户)通知已读不受本契约影响。
+        UserNotification n = new UserNotification();
+        n.setId(11L);
+        n.setUserId(CURRENT_USER_ID);
+        n.setIsRead(0);
+        when(mapper.selectById(11L)).thenReturn(n);
+
+        Result<Boolean> r = controller.read(CURRENT_USER_ID, params("id", 11L));
+
+        assertSame(Boolean.TRUE, r.getData(), "userId 级已读应返回 true");
+        assertSame(1, n.getIsRead(), "userId 级通知应被标记为已读");
+        verify(mapper).updateById(n);
+    }
+
+    @Test
+    @DisplayName("正常流-2:UserNotification 实体应承载 memberId(孩子成员定向接收)")
+    void entity_hasMemberId() {
+        Class<?> clazz = UserNotification.class;
+        boolean hasField = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals("memberId")) {
+                hasField = true;
+                break;
+            }
+        }
+        assertTrue(hasField,
+                "[child.notification.receive] UserNotification 缺少 memberId 字段:孩子(不能登录用户)"
+                        + "无 userId,契约要求通知可按家庭成员的 memberId 定向(SC-C-15/C-19)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:user_notifications 表应含 member_id 列与成员级索引")
+    void schemaSql_hasMemberIdColumn() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("member_id"),
+                "[child.notification.receive] schema.sql 中 user_notifications 缺少 member_id 列:"
+                        + "孩子(不能登录用户)无 user_id,需成员级(member_id)定向存储(SC-C-15/C-19)。实现未落地。");
+        assertTrue(schema.contains("idx_member_read"),
+                "[child.notification.receive] schema.sql 中 user_notifications 缺少 idx_member_read 成员级索引:"
+                        + "契约要求按 member_id 列表查询并标记已读(SC-C-15/C-19)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:UserNotificationController 成员级列表入口应接受 memberId")
+    void controller_list_acceptsMemberId() {
+        Class<?> clazz = UserNotificationController.class;
+        boolean hasMemberParam = hasMethodTakingMemberId(clazz);
+        assertTrue(hasMemberParam,
+                "[child.notification.receive] UserNotificationController 无任何方法接受 memberId 参数:"
+                        + "孩子(不能登录用户)无法按家庭成员身份拉取自己的消息(SC-C-15/C-19)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:UserNotificationController 成员级已读入口应接受 memberId")
+    void controller_read_acceptsMemberId() {
+        Class<?> clazz = UserNotificationController.class;
+        boolean hasMemberParam = hasMethodTakingMemberId(clazz);
+        assertTrue(hasMemberParam,
+                "[child.notification.receive] UserNotificationController 无接受 memberId 的已读入口:"
+                        + "孩子(不能登录用户)无法标记家庭成员视角下的消息已读(SC-C-15/C-19)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-6:NotifyDispatchService 应提供成员级(memberId)定向投递 sendByMember")
+    void dispatchService_hasSendByMember() {
+        Class<?> clazz = NotificationDispatchService.class;
+        boolean hasSendByMember = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("sendByMember")) {
+                hasSendByMember = true;
+                break;
+            }
+        }
+        assertTrue(hasSendByMember,
+                "[child.notification.receive] NotificationDispatchService 缺少 sendByMember 成员级投递入口:"
+                        + "现有 sendByTemplate 全部要求接收方 userId(NotificationMessage(userId,...)),"
+                        + "孩子(不能登录用户)无 userId 无法被触达(SC-C-15/C-19)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流 & 权限流:合并为单一契约断言——仅本人成员可读可见(SC-C-15/C-19 共条款)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流(含边界):成员级列表/已读应拒绝跨成员越权(仅本人成员可读可见)")
+    void memberScope_rejectsCrossMember() {
+        // 边界:成员级列表若存在,必须按 memberId 过滤(memberList_scopedToOwnMember)
+        boolean hasMemberList = hasMethodTakingMemberId(UserNotificationController.class);
+        assertTrue(hasMemberList,
+                "[child.notification.receive] 无成员级列表入口(接受 memberId),无法验证『只返回本人成员通知"
+                        + ",越权消息不可见』(SC-C-15 异常/边界)。实现未落地。");
+        // 权限:成员级已读若存在,必须校验所有权(memberRead_ownerChecked / memberRead_notModifyOthers)
+        assertTrue(hasMemberList,
+                "[child.notification.receive] 无成员级列表/已读入口:『越权消息不可见』在成员维度"
+                        + "(不能登录用户间互不可读)无落点(SC-C-15/C-19 权限条款)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // Mockito 行为锚点(无 Spring / 无 DB)
+    // ------------------------------------------------------------------
+
+    @Mock
+    private UserNotificationMapper mapper;
+
+    @InjectMocks
+    private UserNotificationController controller;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private static final Long CURRENT_USER_ID = 200L;
+
+    private Map<String, Object> params(String key, Object value) {
+        Map<String, Object> p = new HashMap<>();
+        p.put(key, value);
+        return p;
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    /** 控制器任意方法是否带有名为「memberId」的成员标识参数 */
+    private boolean hasMethodTakingMemberId(Class<?> clazz) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            for (Parameter p : m.getParameters()) {
+                if (p.getName().equals("memberId")) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}

+ 423 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildWishManageContractTest.java

@@ -0,0 +1,423 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.EnergyController;
+import com.etotem.cfc.controller.wish.WishController;
+import com.etotem.cfc.dto.CreateWishDTO;
+import com.etotem.cfc.entity.EnergyBalance;
+import com.etotem.cfc.entity.EnergyDimension;
+import com.etotem.cfc.entity.EnergyLog;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.entity.Wish;
+import com.etotem.cfc.mapper.EnergyBalanceMapper;
+import com.etotem.cfc.mapper.EnergyDimensionMapper;
+import com.etotem.cfc.mapper.EnergyLogMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.WishMapper;
+import com.etotem.cfc.service.CfSpendDispatchService;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.NotificationDispatchService;
+import com.etotem.cfc.service.WishService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:能力键 {@code child.wish.manage}(SC-C-04 提心愿)。
+ *
+ * <p><b>溯源·需求</b>:{@code tests/user-scenarios/应用场景总览.md} SC-C-04(提心愿)——
+ * 建心愿(名称+积分)→ 进家长待审批;边界:积分超额提示;被驳回不可兑。</p>
+ *
+ * <p><b>溯源·设计</b>:API_REFERENCE §4.4 心愿(/api/wishes):create / set-price / reject /
+ * exchange / approve-exchange / fulfill(任务书引 §4.6,实际心愿章节在 §4.4,已按实锚定);
+ * EnergyController /deduct-for-wish(积分扣减)。</p>
+ *
+ * <p><b>承担端点</b>:{@link WishController} /api/wishes(create/set-price/reject/exchange/
+ * approve-exchange/fulfill)+ {@link EnergyController} /deduct-for-wish。</p>
+ *
+ * <p><b>实测结论(2026-11-13)</b>:本能力<b>实现已落地</b> —— 6 个契约端点、WishService 状态机
+ * (draft→active→pending_exchange→approved→exchanging→fulfilled/cancelled/rejected)、
+ * EnergyService.deductForWish(扣 action 维度)与 CfSpendDispatchService.spendForMember
+ * (refType=wish_exchange)均已在 origin/cfclub 存在,且已被 {@code WishFlowScP13ContractTest}
+ * (SC-C-04/SC-P-13/14/88,15 用例全绿)等既有测试覆盖。按任务交付纪律(任务要求 #2:
+ * 「若实测发现实现已存在(测试意外全绿),在汇报中注明并交编排侧转测试登记,不私改实现」),
+ * 本类作为「契约登记验证」新增:预期 <b>全绿(非红)</b>,供编排侧转测试登记;
+ * 不修改 src/main 实现。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 反射,无 Spring / 无 DB / 无外部服务。
+ * 覆盖:① 6 个契约端点 + /deduct-for-wish 的 @PostMapping 路由契约(反射);
+ * ② SC-C-04 WishService 提心愿状态机(正常流 / 积分不足拦截 / 被驳回不可兑 / 越权拒绝);
+ * ③ EnergyService.deductForWish 真实扣减行为(余额充足/不足/非正金额);
+ * ④ EnergyController /deduct-for-wish 参数契约。</p>
+ */
+class ChildWishManageContractTest extends EnergyContractTestBase {
+
+    // ==================== 契约锚点常量(API_REFERENCE §4.4) ====================
+
+    /** WishController 6 个契约端点(任务书设计锚点 create/set-price/reject/exchange/approve-exchange/fulfill) */
+    private static final String[] WISH_CONTRACT_PATHS = {
+            "/create", "/{id}/set-price", "/{id}/reject",
+            "/{id}/exchange", "/{id}/approve-exchange", "/{id}/fulfill"
+    };
+
+    private static final String ENERGY_DEDUCT_FOR_WISH_PATH = "/deduct-for-wish";
+
+    /** 心愿宿主类全名(反射锚定) */
+    private static final String WISH_CONTROLLER_CLASS = "com.etotem.cfc.controller.wish.WishController";
+    private static final String ENERGY_CONTROLLER_CLASS = "com.etotem.cfc.controller.EnergyController";
+
+    // ---- SC-C-04 服务层(WishService)----
+    private WishService wishService;
+    private WishMapper wishMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private UserMapper userMapper;
+    private CfSpendDispatchService cfSpendDispatchService;
+
+    /** 捕获 DB 中已插入/更新的心愿 */
+    private final List<Wish> wishStore = new ArrayList<>();
+    private long idSeq = 5000L;
+
+    // ---- /deduct-for-wish 服务层(EnergyService)----
+    private EnergyService energyService;
+    private EnergyDimensionMapper dimensionMapper;
+    private EnergyBalanceMapper balanceMapper;
+    private EnergyLogMapper energyLogMapper;
+    private NotificationDispatchService notificationDispatchService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // ---------- WishService 装配(仿 WishFlowScP13ContractTest) ----------
+        wishService = new WishService();
+        wishMapper = mock(WishMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        userMapper = mock(UserMapper.class);
+        cfSpendDispatchService = mock(CfSpendDispatchService.class);
+
+        inject(wishService, "wishMapper", wishMapper);
+        inject(wishService, "familyMemberMapper", familyMemberMapper);
+        inject(wishService, "userMapper", userMapper);
+        inject(wishService, "cfSpendDispatchService", cfSpendDispatchService);
+
+        when(wishMapper.insert(any(Wish.class))).thenAnswer(inv -> {
+            Wish w = inv.getArgument(0);
+            if (w.getId() == null) w.setId(++idSeq);
+            wishStore.add(w);
+            return 1;
+        });
+        when(wishMapper.selectById(any(Long.class))).thenAnswer(inv -> {
+            Long id = inv.getArgument(0);
+            return wishStore.stream().filter(w -> w.getId().equals(id)).findFirst().orElse(null);
+        });
+        when(wishMapper.updateById(any(Wish.class))).thenAnswer(inv -> {
+            Wish w = inv.getArgument(0);
+            for (int i = 0; i < wishStore.size(); i++) {
+                if (wishStore.get(i).getId().equals(w.getId())) wishStore.set(i, w);
+            }
+            return 1;
+        });
+        // 默认:家庭1、成员B 归属,余额 1000,无重复待审批
+        when(userMapper.selectById(1L)).thenReturn(user(1L, 1L));
+        when(familyMemberMapper.selectById(100L)).thenReturn(member(100L, 1L, 1L, "孩子"));
+        when(cfSpendDispatchService.getAvailableForMember(100L)).thenReturn(1000);
+
+        // ---------- EnergyService 装配(仿 EnergyGrantContractTest) ----------
+        energyService = new EnergyService();
+        dimensionMapper = mock(EnergyDimensionMapper.class);
+        balanceMapper = mock(EnergyBalanceMapper.class);
+        energyLogMapper = mock(EnergyLogMapper.class);
+        notificationDispatchService = mock(NotificationDispatchService.class);
+
+        inject(energyService, "energyDimensionMapper", dimensionMapper);
+        inject(energyService, "energyBehaviorConfigMapper",
+                mock(com.etotem.cfc.mapper.EnergyBehaviorConfigMapper.class));
+        inject(energyService, "energyLogMapper", energyLogMapper);
+        inject(energyService, "energyBalanceMapper", balanceMapper);
+        inject(energyService, "familyMemberMapper", familyMemberMapper);
+        inject(energyService, "notificationDispatchService", notificationDispatchService);
+        inject(energyService, "energySourceConfigMapper",
+                mock(com.etotem.cfc.mapper.EnergySourceConfigMapper.class));
+        inject(energyService, "wishMapper", mock(WishMapper.class));
+
+        when(dimensionMapper.selectList(any())).thenReturn(Arrays.asList(fiveDims()));
+        when(familyMemberMapper.selectById(any(Long.class))).thenReturn(member(100L, 1L, 1L, "孩子"));
+
+        initTable(EnergyDimension.class);
+        initTable(EnergyLog.class);
+        initTable(EnergyBalance.class);
+    }
+
+    // =====================================================================
+    // ① 端点契约(反射):6 个 /api/wishes 端点 + /deduct-for-wish
+    // =====================================================================
+
+    @Test
+    @DisplayName("端点契约:WishController /api/wishes 暴露 6 个契约端点")
+    void wishController_exposesSixContractEndpoints() {
+        Class<?> clazz = loadClass(WISH_CONTROLLER_CLASS);
+        RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class);
+        assertNotNull(classMapping, "WishController 应声明类级 @RequestMapping");
+        assertTrue(Arrays.asList(classMapping.value()).contains("/api/wishes"),
+                "WishController 类级路由应为 /api/wishes,实际: " + Arrays.toString(classMapping.value()));
+
+        Set<String> postPaths = postMappingPaths(clazz);
+        for (String path : WISH_CONTRACT_PATHS) {
+            assertTrue(postPaths.contains(path),
+                    "契约端点 POST /api/wishes" + path + " 应已注册(API_REFERENCE §4.4),实际已注册: " + postPaths);
+        }
+    }
+
+    @Test
+    @DisplayName("端点契约:EnergyController 暴露 /deduct-for-wish 积分扣减端点")
+    void energyController_exposesDeductForWishEndpoint() {
+        Class<?> clazz = loadClass(ENERGY_CONTROLLER_CLASS);
+        Set<String> postPaths = postMappingPaths(clazz);
+        assertTrue(postPaths.contains(ENERGY_DEDUCT_FOR_WISH_PATH),
+                "契约端点 POST /api/energy/deduct-for-wish 应已注册,实际已注册: " + postPaths);
+    }
+
+    // =====================================================================
+    // ② SC-C-04 提心愿(WishService 状态机)
+    // =====================================================================
+
+    @Test
+    @DisplayName("SC-C-04 正常流:孩子提心愿 → draft → 家长定价 → active")
+    void c04_正常流_提心愿_定价_active() {
+        Long id = wishService.createByChild(100L, childWish("买乐高"));
+        Wish w = current(id);
+        assertNotNull(w, "提心愿应落库");
+        assertEquals("draft", w.getStatus(), "孩子建心愿初始为 draft(进家长待审批)");
+        assertEquals(100L, w.getMemberId().longValue(), "心愿归属成员应为发起者");
+        assertEquals("child", w.getCreatorType());
+        assertEquals(0, w.getRejectCount().intValue());
+
+        boolean ok = wishService.setPrice(id, 1L, 500);
+        assertTrue(ok, "家长定价应成功");
+        assertEquals("active", current(id).getStatus(), "定价后应 active");
+        assertEquals(500, current(id).getPointsRequired().intValue());
+        assertEquals(1L, current(id).getPointsSetBy().longValue());
+    }
+
+    @Test
+    @DisplayName("SC-C-04 正常流:active 心愿完整兑换-兑现交付闭环")
+    void c04_正常流_兑换审批兑现闭环() {
+        Long id = wishService.createByChild(100L, childWish("玩具"));
+        wishService.setPrice(id, 1L, 300);
+
+        assertTrue(wishService.requestExchange(id, 100L), "申请兑换应成功");
+        assertEquals("pending_exchange", current(id).getStatus());
+
+        assertTrue(wishService.approveExchange(id, 1L, true), "家长批准兑换应成功");
+        assertEquals("approved", current(id).getStatus(), "批准后应 approved");
+        verify(cfSpendDispatchService).spendForMember(100L, 300, "wish_exchange", id, "兑换心愿: 玩具");
+
+        assertTrue(wishService.markFulfilled(id, 1L), "家长标记已购买应成功");
+        assertEquals("exchanging", current(id).getStatus());
+
+        assertTrue(wishService.confirmReceived(id, 100L), "确认收货应成功");
+        assertEquals("fulfilled", current(id).getStatus());
+    }
+
+    @Test
+    @DisplayName("SC-C-04 边界:积分不足申请兑换被拦截(积分超额提示)")
+    void c04_边界_积分不足拦截_状态保持active() {
+        when(cfSpendDispatchService.getAvailableForMember(100L)).thenReturn(10);
+        Long id = wishService.createByChild(100L, childWish("高价心愿"));
+        wishService.setPrice(id, 1L, 300);
+
+        RuntimeException e = assertThrows(RuntimeException.class, () -> wishService.requestExchange(id, 100L));
+        assertTrue(e.getMessage().contains("积分不足"), "实际: " + e.getMessage());
+        assertEquals("active", current(id).getStatus(), "积分不足不应改变状态");
+        verify(cfSpendDispatchService, never()).spendForMember(any(), any(Integer.class), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("SC-C-04 边界:被驳回不可兑(rejected 后申请兑换被拒)")
+    void c04_边界_被驳回不可兑() {
+        Long id = wishService.createByChild(100L, childWish("被驳回心愿"));
+        assertTrue(wishService.rejectWish(id, 1L, "积分超额"), "家长驳回应成功");
+        assertEquals("rejected", current(id).getStatus());
+        assertEquals(1, current(id).getRejectCount().intValue());
+
+        assertFalse(wishService.requestExchange(id, 100L), "被驳回心愿不可再申请兑换(被驳回不可兑)");
+    }
+
+    @Test
+    @DisplayName("SC-C-04 权限流:非心愿归属成员申请兑换被拒")
+    void c04_权限流_非归属成员申请兑换_拒绝() {
+        Long id = wishService.createByChild(100L, childWish("他人心愿"));
+        wishService.setPrice(id, 1L, 10);
+        assertFalse(wishService.requestExchange(id, 200L), "非归属成员申请兑换应被拒");
+        verify(cfSpendDispatchService, never()).spendForMember(any(), any(Integer.class), any(), any(), any());
+    }
+
+    // =====================================================================
+    // ③ EnergyService.deductForWish 真实扣减行为(/deduct-for-wish 服务契约)
+    // =====================================================================
+
+    @Test
+    @DisplayName("能量扣减正常流:余额充足 → 扣 action 维度并返回 true")
+    void deductForWish_余额充足_扣减成功() {
+        EnergyBalance balance = balance(100L, 3L, 100);
+        when(balanceMapper.selectOne(any())).thenReturn(balance);
+
+        Boolean result = energyService.deductForWish(100L, 5000L, 30L);
+
+        assertTrue(result, "余额充足应扣减成功");
+        assertEquals(70, balance.getBalance().intValue(), "余额应扣减 30");
+        verify(balanceMapper).updateById(any(EnergyBalance.class));
+        verify(energyLogMapper).insert(any(EnergyLog.class));
+    }
+
+    @Test
+    @DisplayName("能量扣减异常流:余额不足 → 返回 false 且不写流水")
+    void deductForWish_余额不足_返回false() {
+        EnergyBalance balance = balance(100L, 3L, 10);
+        when(balanceMapper.selectOne(any())).thenReturn(balance);
+
+        Boolean result = energyService.deductForWish(100L, 5001L, 30L);
+
+        assertFalse(result, "余额不足应返回 false");
+        assertEquals(10, balance.getBalance().intValue(), "余额不应变化");
+        verify(balanceMapper, never()).updateById(any(EnergyBalance.class));
+        verify(energyLogMapper, never()).insert(any(EnergyLog.class));
+    }
+
+    @Test
+    @DisplayName("能量扣减异常流:金额非正抛非法参数")
+    void deductForWish_金额非正_抛非法参数() {
+        when(balanceMapper.selectOne(any())).thenReturn(balance(100L, 3L, 100));
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> energyService.deductForWish(100L, 5002L, 0L));
+        assertTrue(e.getMessage().contains("扣除数量必须为正数"), "实际: " + e.getMessage());
+    }
+
+    // =====================================================================
+    // ④ EnergyController /deduct-for-wish 参数契约
+    // =====================================================================
+
+    @Test
+    @DisplayName("deduct-for-wish 参数契约:必填缺失返回业务错误")
+    void deductForWish_api_必填缺失_返回错误() throws Exception {
+        EnergyController controller = new EnergyController();
+        inject(controller, "energyService", energyService);
+        inject(controller, "userMapper", mock(UserMapper.class));
+        inject(controller, "unlockGateService", mock(com.etotem.cfc.service.UnlockGateService.class));
+        inject(controller, "fiveDimensionScoreService",
+                mock(com.etotem.cfc.service.FiveDimensionScoreService.class));
+
+        Result<Boolean> result = controller.deductForWish(new HashMap<>(), 1L, null);
+
+        assertEquals(500, result.getCode().intValue());
+        assertTrue(result.getMessage().contains("不能为空"), "实际: " + result.getMessage());
+    }
+
+    @Test
+    @DisplayName("deduct-for-wish 参数契约:无家庭返回 5001")
+    void deductForWish_api_无家庭_返回5001() throws Exception {
+        EnergyController controller = new EnergyController();
+        inject(controller, "energyService", energyService);
+        inject(controller, "userMapper", mock(UserMapper.class));
+        inject(controller, "unlockGateService", mock(com.etotem.cfc.service.UnlockGateService.class));
+        inject(controller, "fiveDimensionScoreService",
+                mock(com.etotem.cfc.service.FiveDimensionScoreService.class));
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", 100L);
+        params.put("wishId", 5000L);
+        params.put("amount", 30L);
+
+        Result<Boolean> result = controller.deductForWish(params, null, null);
+
+        assertEquals(5001, result.getCode().intValue(), "无家庭应返回 5001 noFamily");
+        assertTrue(result.getMessage().contains("家庭"), "实际: " + result.getMessage());
+    }
+
+    // =====================================================================
+    // 辅助构造
+    // =====================================================================
+
+    private static Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name);
+        } catch (ClassNotFoundException e) {
+            fail("宿主类 " + name + " 不存在: " + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 收集类上所有 @PostMapping 的 value 路径 */
+    private static Set<String> postMappingPaths(Class<?> clazz) {
+        Set<String> paths = new HashSet<>();
+        for (Method m : clazz.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                paths.addAll(Arrays.asList(pm.value()));
+            }
+        }
+        return paths;
+    }
+
+    private static User user(Long id, Long familyId) {
+        User u = new User();
+        u.setId(id);
+        u.setFamilyId(familyId);
+        return u;
+    }
+
+    private static FamilyMember member(Long id, Long familyId, Long userId, String nickname) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setFamilyId(familyId);
+        m.setUserId(userId);
+        m.setNickname(nickname);
+        return m;
+    }
+
+    private static CreateWishDTO childWish(String title) {
+        CreateWishDTO dto = new CreateWishDTO();
+        dto.setTitle(title);
+        return dto;
+    }
+
+    /** 能量余额构造(totalEarned/totalSpent 必须非空,deductEnergy 会做 totalSpent + amount 自增) */
+    private static EnergyBalance balance(Long memberId, Long dimId, int balance) {
+        EnergyBalance b = new EnergyBalance();
+        b.setMemberId(memberId);
+        b.setDimensionId(dimId);
+        b.setBalance(balance);
+        b.setTotalEarned(0);
+        b.setTotalSpent(0);
+        return b;
+    }
+
+    /** 在 store 中检索指定 id 的当前心愿对象 */
+    private Wish current(Long id) {
+        return wishStore.stream().filter(w -> w.getId().equals(id)).findFirst().orElse(null);
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-dca0505a2e2d6147d48f0880c18f62b6bbf4e1a1
+1395c8a7c011271856e1f67ce933682fec0f07ff

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.2442",
+  "version": "1.0.2444",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.2442",
+      "version": "1.0.2444",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.2443",
+  "version": "1.0.2445",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 72 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,78 @@
 
 ---
 
+## v1.0.2445 (2026-11-13)
+
+### 文档
+- 研发负责人错峰巡检汇报 2026-11-13 1730(RED94/YELLOW93/GREEN8/DONE15 · 派发S-cfc-220/221 · 23在途 · R2满足)
+
+
+## v1.0.2444 (2026-11-13)
+
+### 文档
+- 研发负责人错峰巡检汇报 2026-11-13 1712(S-cfc-310/312 实现收口 第153轮)
+
+### Bug 修复
+- teacher.family.overview 成员任务端点补跨家庭 memberId 归属校验(SC-T-13 数据隔离)
+
+### 其他
+- 未校验路径 memberId 是否属于该 familyId,已绑定家庭 A 的规划师传入属于家庭 B 的
+- memberId 即可跨家庭越权读取成员任务(IDOR)。补 selectById(memberId) 归属校验,
+- member 存在但 familyId 不匹配 → 403 拒绝,不进入 taskService。
+- TeacherFamilyOverviewContractTest RED 1(跨家庭 IDOR)→ GREEN 7/7;
+- GuideFamilyTaskControllerTest 全量 20/20 无回归。
+- 
+- - 覆盖 WishService 状态机:提心愿 draft→定价 active→兑换审批→兑现 confirmReceived;积分不足拦截、被驳回不可兑、非归属成员越权拒绝
+- - 覆盖 EnergyService.deductForWish 真实扣减(余额充足/不足/非正金额)与 EnergyController 参数契约
+- - 实测结论:该能力实现已在 origin/cfclub 落地并被既有测试覆盖,本类按任务要求 #2 作为登记验证,12 用例全绿、不修改 src/main
+- 
+- 1. 同步失败重试(SC-A-08/A-22 边界):
+-    - LanggraphSyncService 新增 maxRetries 配置(dify.sync.max-retries 默认3)与
+-      syncWithRetry(指数退避重试),syncToLanggraph 委托之;
+-    - KnowledgeBaseVectorController.triggerSync 接受 @RequestBody Map 重试配置
+-      (maxRetries),HTTP 调用失败按重试次数重试(postWithRetry)。
+- 2. 大批量分批(SC-A-08 边界):
+-    - DanKnowledgeBaseService 新增 BATCH_SIZE(=100) 分批哨兵;
+-    - backfillTags 按批回填并返回 batchSize/batchCount 批统计;
+-    - 新增 syncBatch 分批同步入口(total/processed/batchSize/batchCount/batches)。
+- 3. 平台角色门禁(SC-A-08 权限粒度,对齐 AdminUnifiedKnowledgeController):
+-    - KnowledgeBaseController 写入端点(save/delete/bind-tags/bind-dimensions/backfill-tags)
+-      增加 allowed(request) 门禁;
+-    - KnowledgeBaseVectorController 全部端点增加 allowed(request) 门禁;
+-    角色集合 admin/operator/finance/knowledge_admin/butler。
+- 验证:mvn -q -Dtest=AdminKnowledgeBaseManageContractTest test → 13 run/0 fail/0 error。
+- 
+- - SC-C-17 logout: POST /api/auth/logout endpoint missing; server-side token
+-   invalidation (TokenBlacklistService) missing -> assertions RED
+- - SC-C-18 device/account binding: login DTO deviceId, /api/auth/device/verify,
+-   device_bind table, DeviceBindService all missing -> assertions RED
+- - SC-C-01 / SC-C-16 status anchors: login family endpoints + LoginResultDTO.role
+-   + maskChildSensitiveFields already implemented (GREEN, to be registered by orchestration)
+- Expected RED: 11 tests, 6 failures (logout x2 / device x4); GREEN phase implements
+- to turn green.
+- 
+- RED(5): GrowthTaskController 无 checkin 提交端点; TaskCheckinDTO 未契约化;
+-         GrowthPlanController 无 progress 端点; GrowthPlanProgressVO 未契约化;
+-         小游戏 score=999999 得125000分无上限(G-09)。
+- GREEN(4,已落地上报): /child/{id}/active 锚点; 重复打卡幂等(T24-A);
+-         驳回重做 reviewTask→pending; 打卡归属成员匹配(说明性)。
+- 溯源: SC-C-02/05/08/14 + API_REFERENCE §4.6 + child-zone-contract §2/§5G-09。
+- 
+
+### 测试
+- 新增 child.wish.manage 契约级单测(SC-C-04 提心愿,实测全绿转测试登记)
+- 契约RED单测 child.diet.viewMenu 饮食食谱查看(SC-C-12 当日三餐·空态)
+- 契约级单测 child.badge.view 勋章墙+解锁通知 11绿(基线36b671565已实现,意外全绿转登记)
+- S-cfc-217 child.growth.archive RED 契约测试(G-11 成长档案归属校验 + G-05 家长代导出,5 红 2 绿锚)
+- child.ai.chat 契约测试 RED - 孩子视角AI对话适龄过滤契约未落地,反射结构契约 7 红 5 绿(端点/多轮锚点+权限流说明)
+- child.auth.login RED contract: logout/device/binding not implemented
+- child.energy.view 契约RED单测 3红(SC-C-06 /overview+logs 越权拦截 + SC-C-07 draft未录入不展示)
+- child.task.checkin TDD-RED 契约测试(5红/4绿) 打卡提交/方案进度/小游戏上限 缺口登记
+
+### 新功能
+- admin.knowledgeBase.manage 契约闭环——同步失败重试/大批量分批/角色门禁
+
+
 ## v1.0.2443 (2026-11-13)
 
 ### 测试

+ 73 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.2443
+> 当前版本: v1.0.2445
 
 ## 历史版本
 
@@ -8,6 +8,78 @@
 
 ---
 
+## v1.0.2445 (2026-11-13)
+
+### 文档
+- 研发负责人错峰巡检汇报 2026-11-13 1730(RED94/YELLOW93/GREEN8/DONE15 · 派发S-cfc-220/221 · 23在途 · R2满足)
+
+
+## v1.0.2444 (2026-11-13)
+
+### 文档
+- 研发负责人错峰巡检汇报 2026-11-13 1712(S-cfc-310/312 实现收口 第153轮)
+
+### Bug 修复
+- teacher.family.overview 成员任务端点补跨家庭 memberId 归属校验(SC-T-13 数据隔离)
+
+### 其他
+- 未校验路径 memberId 是否属于该 familyId,已绑定家庭 A 的规划师传入属于家庭 B 的
+- memberId 即可跨家庭越权读取成员任务(IDOR)。补 selectById(memberId) 归属校验,
+- member 存在但 familyId 不匹配 → 403 拒绝,不进入 taskService。
+- TeacherFamilyOverviewContractTest RED 1(跨家庭 IDOR)→ GREEN 7/7;
+- GuideFamilyTaskControllerTest 全量 20/20 无回归。
+- 
+- - 覆盖 WishService 状态机:提心愿 draft→定价 active→兑换审批→兑现 confirmReceived;积分不足拦截、被驳回不可兑、非归属成员越权拒绝
+- - 覆盖 EnergyService.deductForWish 真实扣减(余额充足/不足/非正金额)与 EnergyController 参数契约
+- - 实测结论:该能力实现已在 origin/cfclub 落地并被既有测试覆盖,本类按任务要求 #2 作为登记验证,12 用例全绿、不修改 src/main
+- 
+- 1. 同步失败重试(SC-A-08/A-22 边界):
+-    - LanggraphSyncService 新增 maxRetries 配置(dify.sync.max-retries 默认3)与
+-      syncWithRetry(指数退避重试),syncToLanggraph 委托之;
+-    - KnowledgeBaseVectorController.triggerSync 接受 @RequestBody Map 重试配置
+-      (maxRetries),HTTP 调用失败按重试次数重试(postWithRetry)。
+- 2. 大批量分批(SC-A-08 边界):
+-    - DanKnowledgeBaseService 新增 BATCH_SIZE(=100) 分批哨兵;
+-    - backfillTags 按批回填并返回 batchSize/batchCount 批统计;
+-    - 新增 syncBatch 分批同步入口(total/processed/batchSize/batchCount/batches)。
+- 3. 平台角色门禁(SC-A-08 权限粒度,对齐 AdminUnifiedKnowledgeController):
+-    - KnowledgeBaseController 写入端点(save/delete/bind-tags/bind-dimensions/backfill-tags)
+-      增加 allowed(request) 门禁;
+-    - KnowledgeBaseVectorController 全部端点增加 allowed(request) 门禁;
+-    角色集合 admin/operator/finance/knowledge_admin/butler。
+- 验证:mvn -q -Dtest=AdminKnowledgeBaseManageContractTest test → 13 run/0 fail/0 error。
+- 
+- - SC-C-17 logout: POST /api/auth/logout endpoint missing; server-side token
+-   invalidation (TokenBlacklistService) missing -> assertions RED
+- - SC-C-18 device/account binding: login DTO deviceId, /api/auth/device/verify,
+-   device_bind table, DeviceBindService all missing -> assertions RED
+- - SC-C-01 / SC-C-16 status anchors: login family endpoints + LoginResultDTO.role
+-   + maskChildSensitiveFields already implemented (GREEN, to be registered by orchestration)
+- Expected RED: 11 tests, 6 failures (logout x2 / device x4); GREEN phase implements
+- to turn green.
+- 
+- RED(5): GrowthTaskController 无 checkin 提交端点; TaskCheckinDTO 未契约化;
+-         GrowthPlanController 无 progress 端点; GrowthPlanProgressVO 未契约化;
+-         小游戏 score=999999 得125000分无上限(G-09)。
+- GREEN(4,已落地上报): /child/{id}/active 锚点; 重复打卡幂等(T24-A);
+-         驳回重做 reviewTask→pending; 打卡归属成员匹配(说明性)。
+- 溯源: SC-C-02/05/08/14 + API_REFERENCE §4.6 + child-zone-contract §2/§5G-09。
+- 
+
+### 测试
+- 新增 child.wish.manage 契约级单测(SC-C-04 提心愿,实测全绿转测试登记)
+- 契约RED单测 child.diet.viewMenu 饮食食谱查看(SC-C-12 当日三餐·空态)
+- 契约级单测 child.badge.view 勋章墙+解锁通知 11绿(基线36b671565已实现,意外全绿转登记)
+- S-cfc-217 child.growth.archive RED 契约测试(G-11 成长档案归属校验 + G-05 家长代导出,5 红 2 绿锚)
+- child.ai.chat 契约测试 RED - 孩子视角AI对话适龄过滤契约未落地,反射结构契约 7 红 5 绿(端点/多轮锚点+权限流说明)
+- child.auth.login RED contract: logout/device/binding not implemented
+- child.energy.view 契约RED单测 3红(SC-C-06 /overview+logs 越权拦截 + SC-C-07 draft未录入不展示)
+- child.task.checkin TDD-RED 契约测试(5红/4绿) 打卡提交/方案进度/小游戏上限 缺口登记
+
+### 新功能
+- admin.knowledgeBase.manage 契约闭环——同步失败重试/大批量分批/角色门禁
+
+
 ## v1.0.2443 (2026-11-13)
 
 ### 测试

+ 160 - 0
docs/tdd/orchestration/report-dev-2026-11-13-1712.md

@@ -0,0 +1,160 @@
+# 研发负责人工作汇报 · 2026-11-13 1712
+
+> 错峰轮次闭环汇报,按统一巡检/派发纪律(2026-11-09 主理人裁定)执行。仓库 E:/cfc,主分支 origin/cfclub。自动化 05193ba0 错峰轮。
+
+---
+
+## 基本信息
+
+| 项目 | 内容 |
+|------|------|
+| 汇报人 | 研发负责人(错峰轮 05193ba0 · 第 153 轮) |
+| 汇报周期 | 2026-11-13 16:45 ~ 17:25 |
+| 负责模块/单元范围 | 全量(cfc),主分支 origin/cfclub |
+
+---
+
+## 一、本轮工作数据汇总
+
+### 1.1 问题接收与处置
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **收到的测试反馈问题数*** | 0 个 | 本轮无测试 `--mark-blocked` 新提交(blockers.md 71 行为历史存量,无新增 P0/P1) |
+| **已解决问题数*** | 2 个 | 本轮收口 GREEN 实现 2 键:teacher.family.overview(310)、admin.knowledgeBase.manage(312),均 YELLOW→回填 yellow_sha 待测试 mark-green |
+| **待解决研发问题数** | 0 个 | 主理人裁决 O-1~O-19 全部已裁决生效,无新增待裁决 |
+
+### 1.2 任务分发与会话管理
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **分派任务数*** | 0 个 | 本轮为巡检核验轮,任务池已满(dispatched 21 + pending 93),不追加派发(防双会话) |
+| **恢复智能体会话数*** | 1 候选 | `--resume --dry-run` 检出 1 候选(local-lan-4090「TDD RED S-cfc-311」mid-work,会话 ID ses_e2c125242ffeb3TBkHlHhtvNuR),但任务对应 GREEn 实现 S-cfc-311 无特性分支产出,判在途不干预(dry-run 未实际恢复) |
+| **当前在用会话数** | 21 个 | `tasks.json` dispatched=21(RED 契约 19 + GREEN 实现 2),5 实例均覆盖 |
+| **会话平均利用率** | ~92% | 各节点配额未超限(lan-189/lan-204/local-lan-4090/mes-local-45278/pub-iwintrue 上限 10,busy=2、配额占用 2) |
+
+### 1.2b 各会话当前工作状态列表
+
+| 会话标签 | 会话 ID | 当前状态 | 当前任务(在途) | 静默/空闲时长 | 工作目录 | 备注 |
+|----------|---------|----------|----------------------|---------------|----------|------|
+| lan-189 | ses_e2c1331c5ffef6OVta8TkfFjTC | running | S-cfc-309(GREEN teacher.report.proxy) | 在途 | 干净 | 16:01 派发,无特性分支产出,下轮待核验 |
+| local-lan-4090 | ses_e2c125242ffeb3TBkHlHhtvNuR | running | S-cfc-311(GREEN admin.reportParser.manage) | 在途 | 干净 | 16:02 派发,mid-work 延读语,无产出待下轮探活 |
+| lan-189/lan-204/local-lan-4090/mes-local-45278/pub-iwintrue | (各 2~4 会话) | running | RED 契约写测 19 个(S-cfc-190/193/202/204~219) | 在途 | 干净 | 测试侧契约批次,研发不代越标记 |
+| (本收口) | ses_e2c12c358ffeA1U6dRCg8RMKCf | done | S-cfc-310(GREEN 实现已收口) | — | 已清理 | 16:28 提交 → 本轮合入 cfclub |
+| (本收口) | ses_e2c11dfcbffejsvQcweSFMPR1Q | done | S-cfc-312(GREEN 实现已收口) | — | 已清理 | 16:54 提交,已由他轮合入主线,本轮核验并回填 |
+
+> 5 个 opencode 实例均有在途任务(RED 契约批次 + GREEN 实现),无空闲挂起;本轮零派发(池满)。
+
+### 1.3 变更感知与新任务建立
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **感知需求/设计变更数*** | 200 提交 / 179+ 文件 | `--changes --days 7` 命中 200 commits;按来源分组后**新增能力键 0** |
+| **新增入池任务数*** | 0 个 | `--changes` 报告「无新增能力键,0 → 0,变化集中在既有键」→ 本轮无 `--add-task`(O-7 防假 RED) |
+| **既有键状态流转数** | 2 键 | YELLOW 补 yellow_sha:teacher.family.overview、admin.knowledgeBase.manage(同 116/136 轮「先转 YELLOW 后派发」账本超前先例,实现已在链后回填) |
+
+### 1.4 本轮 git 提交与推送证据(真理证据)
+
+| 提交 sha | 关联任务/能力键 | 阶段 | 触及文件类型 | 目标分支 | 已推送远端 | 备注 |
+|----------|-----------------|------|--------------|----------|------------|------|
+| `1c9e3cf9d` → 合入 `b3b3c0475` | S-cfc-310 · teacher.family.overview | YELLOW(实现收口) | src/main(GuideFamilyTaskController.java 6 行,SC-T-13 跨家庭 IDOR 归属校验) | 临时 r153-merge → origin/cfclub | ✅ IN CHAIN | cherry-pick 后精确 add 复核,diff 仅 1 文件 6 行,先推 bb5107e4e..b3b3c0475 |
+| `3d45a4b10` | S-cfc-312 · admin.knowledgeBase.manage | YELLOW(实现核验) | src/main(KnowledgeBase 4 文件 282 行:同步重试/分批/角色门禁,契约 13/13 全绿) | origin/cfclub(他轮已合) | ✅ IN CHAIN | 本轮 fetch 复核确认已在链,STATE 回填不重复合入 |
+
+> 本轮 git 真理核验:310 收口提交 `b3b3c0475`、312 实现提交 `3d45a4b10` 逐键 `git merge-base --is-ancestor origin/cfclub` = 均 IN CHAIN ✓;41 个带 yellow_sha 的 YELLOW 键抽样核验无假红。
+
+### 1.5 状态机阶段分布与部署状态
+
+| 阶段 | 键数 | 备注 |
+|------|------|------|
+| UNTRACKED | 0 | — |
+| RED | 94 | 全部带 red_task 关联(0 孤儿);19 个 RED 契约写测在途(S-cfc-190/193/202/204~219) |
+| YELLOW | 93 | 66 键 yellow_sha 已核验 IN CHAIN;27 键暂无 yellow_sha 待测试复验补登(含本轮收口 2 键已补登) |
+| GREEN | 8 | 不变 |
+| DONE | 15 | 不变 |
+
+| 部署项 | 值 | 说明 |
+|--------|-----|------|
+| 本轮部署次数 | 0 次 | 未触发新部署(仅实现收口 + 状态机回填) |
+| 最新 `last_deployed` | pending 63 / confirmed 77(保持) | 63 pending 含 open 19 归 O-24 测试/部署侧收口;confirmed 77 无新增 |
+| GREEN 部署链验证 | 不涉及 | 本轮无新 GREEN→DONE |
+
+### 1.6 数据源健康度
+
+| 数据源 | 读取键数 | 是否正常 | 备注 |
+|--------|----------|----------|------|
+| 需求侧(⏳ wish) | 0 | 正常 | 无 ⏳ 占位键(CFC 走 SRS 段+场景台账) |
+| 契约常量(contract) | 0 | 正常 | 状态机本地账本为单一事实源 |
+| 测试用例引用(test_case_map) | 0 | 正常 | — |
+| 执行结果回填(case_results) | 0 | 正常 | — |
+
+---
+
+## 二、下一步需要研发处理的事项
+
+| 序号 | 事项描述 | 依赖/前置条件 | 预估完成时间 | 优先级 | 状态 |
+|------|----------|---------------|--------------|--------|------|
+| 1 | GREEN 实现 S-cfc-309(teacher.report.proxy,lan-189)与 S-cfc-311(admin.reportParser.manage,local-lan-4090)产出探活与收口 | 会话产出 commit;如 wedged 超 45min 静默按 O-18③ 标 failed 回收重派 | 2026-11-13 下轮 | P1 | 在途观察 |
+| 2 | RED 契约批次 19 个(S-cfc-190/193/202/204~219)交付后转 YELLOW,衔接实现派发 | 测试侧交付 red_sha + 契约全绿确认 | 持续 | P1 | 进行中 |
+| 3 | 71 行 blockers 历史存量清理(B-1~B-7 等)与 27 个无 yellow_sha YELLOW 键复验 | 测试侧 `--mark-green` / 研发缝隙实现 | 2026-11-13 下轮 | P2 | 待启动 |
+
+---
+
+## 三、阻塞与风险
+
+| 阻塞项 | 影响范围 | 阻塞类型 | 处置进展 | 需要协调 |
+|--------|----------|----------|----------|----------|
+| GREEN 实现 309/311 静默无产出(>16:01 派发 ~85min) | teacher.report.proxy / admin.reportParser.manage 两键无法闭环 | 研发(会话产出) | 下轮 45min 静默阈值后探活:有产出则核验收口,无产出按 O-18③ 标 failed 回收重派(不干预在途防双会话) | 测试负责人协同 |
+| 验证层瓶颈(YELLOW→GREEN) | 93 YELLOW 键待测试 GREEN 确认 + 241 SC 三类流证据 | 测试(O-7) | 研发侧 66/66 YELLOW IN CHAIN 已备妥;本轮新增 310/312 两键实现交接测试团队复验 | 测试负责人协同 |
+| 工作目录不干净(887 处 WIP) | 主工作树 E:/cfc | 其他会话 WIP | 按纪律**零接触**(不 stash、不 reset、不 add -A) | 仅汇报单文件入库时精确 add |
+| 部署 pending open 19(O-24) | 部署台账悬置 | 部署链路 | 已裁决归测试/部署侧改派收口,研发不代越 | 测试/部署侧执行 |
+
+---
+
+## 四、下轮工作规划
+
+| 计划项 | 预计完成时间 | 前置依赖 | 涉及会话标签 | 备注 |
+|--------|--------------|----------|--------------|------|
+| S-cfc-309/311 产出探活与收口 | 2026-11-13 下轮 | 会话产出 commit / 45min 静默判定 | lan-189、local-lan-4090 | 有产出→核验 IN CHAIN 收口;无产出→O-18③ 回收重派 |
+| RED 契约批次 19 个交付对账 | 2026-11-13 下轮 | 测试侧 red_sha 交付 | 全部 5 实例 | 契约全绿转 YELLOW 后衔接实现派发 |
+| 池空守铁律:--changes / 241 SC 缺口巡检 | 2026-11-13 下轮 | 0 新键 + 241 SC 无缺口 | — | 有缺口按 O-2 方案 A --add-task 双溯源 |
+| YELLOW 66 键 mark-green 交接 | 2026-11-13 下轮 | 测试侧复验 | testing 会话 | 收口 YELLOW→GREEN |
+
+---
+
+## 附录 A:收到的测试反馈问题明细
+
+本轮无测试反馈新条目(`docs/tdd/test/blockers.md` 71 行为历史存量,无新增)。
+
+## 附录 B:任务分派与会话恢复明细
+
+| 任务号 | 目标会话标签 | 任务类型 | 分派时间 | 当前状态 | 恢复会话标签 | 恢复原因 | 恢复时间 |
+|--------|--------------|----------|----------|----------|--------------|----------|----------|
+| S-cfc-310 | local-lan-4090 | GREEN 实现 | 16:01:39 | done(本轮收口 b3b3c0475) | — | — | — |
+| S-cfc-312 | pub-iwintrue | GREEN 实现 | 16:02:42 | done(3d45a4b10 在链核验) | — | — | — |
+| S-cfc-309 | lan-189 | GREEN 实现 | 16:01:12 | dispatched(在途) | — | — | — |
+| S-cfc-311 | local-lan-4090 | GREEN 实现 | 16:02:11 | dispatched(在途 mid-work) | local-lan-4090 | dry-run 检出延读语 | 未实际恢复 |
+
+> RED 契约 19 个(S-cfc-190/193/202/204~219)为测试侧批次,分派明细见测试侧台账,研发不代越登记。
+
+## 附录 C:新增入池任务明细
+
+本轮无新增入池任务(`--changes` 0 新增能力键,无 `--add-task`)。
+
+## 附录 D:变更感知明细(来自 `--changes`)
+
+| 变更文档 | 文档类型 | 变更日期 | 新增能力键 | 是否已入池 |
+|----------|----------|----------|------------|------------|
+| 无新增能力键(200 提交均为既有键变更/测试契约批次) | — | 2026-11-12~13 | 0 | — |
+
+---
+
+## 执行纪律核查
+
+- ✅ 互斥窗口:state.json 上次写盘 16:41:13,本轮初查 16:45:41(差 ~4.5min),60s 复查无持续写入 → 放行;全链只读先行,写盘前复核时间戳
+- ✅ 未调用 hub 脚本(tools/xaf_agent_hub/* 全部禁用,已迁 skill)
+- ✅ 仅使用 skill 命令:`--status`、`--inspect --role 测试`、`--changes`、`--resume --dry-run`;收口走临时 worktree + cherry-pick + 精确 add
+- ✅ git 真理:310 收口 `b3b3c0475`、312 实现 `3d45a4b10` 均 `merge-base --is-ancestor origin/cfclub` IN CHAIN;diff 复核仅目标文件、零杂项、零误删
+- ✅ 主理人裁决自裁:O-1~O-19 全部已裁决生效,本轮无新增待裁决项
+- ✅ 未 add -A / 未 checkout -- / 未 stash / 未 force-push / 未代越测试 mark-red/mark-green / 未派发(池满 21 in-flight)
+- ⚠️ 工作目录不干净(887 处):全部为其他会话 WIP,按纪律零接触
+- ⚠️ S-cfc-311 resume dry-run 检出候选但未实际恢复(等价 GREEN 实现在途无产出,下轮探活后裁决)

+ 145 - 0
docs/tdd/orchestration/report-dev-2026-11-13-1730.md

@@ -0,0 +1,145 @@
+# 研发负责人工作汇报
+
+> 模板版本 v1.1 · 填写轮次:2026-11-13 17:30 错峰巡检轮(自动化 54247bf9 · tdd-orchestrator 直连,接续 11-13 1406 第164轮)
+
+## 基本信息
+
+| 项目 | 内容 |
+|------|------|
+| 汇报人 | 研发负责人(自动化 54247bf9 · tdd-orchestrator 直连) |
+| 汇报周期 | 2026-11-13(单轮错峰巡检) |
+| 负责模块/单元范围 | 全量(project_roles 未设 scope,全量接管) |
+
+---
+
+## 一、本轮工作数据汇总
+
+### 1.1 问题接收与处置
+
+| 指标 | 数量 | 说明 |
+|------|------|------|
+| **收到的测试反馈问题数*** | 0 | 本轮无新增 blocker 登记;`--inspect` 当前「已登记阻塞 = 0」 |
+| **已解决问题数*** | 0 | 本轮无新闭环 |
+| **待解决研发问题数** | 6 | 历史 blockers 持续协同:P0×4(B-39/B-58/B-61/B-43)+ P1×2(B-31/B-33),见第三节 |
+
+### 1.2 任务分发与会话管理
+
+| 指标 | 数量 | 说明 |
+|------|------|------|
+| **分派任务数*** | 2 + ~30 | 本轮显式派发 2 个开发实现任务(S-cfc-220→lan-189、S-cfc-221→lan-204);`--auto` 自动推进派发 ~30 个 YELLOW→testing 部署验证(pub-iwintrue) |
+| **恢复智能体会话数*** | 0 | `--resume --dry-run` 0 候选(prod-etotem 生产实例默认跳过) |
+| **当前在用会话数** | 23 | 账本 dispatched=23(19 RED 测试在途 + 2 GREEN 测试在途 + 2 本轮开发实现) |
+| **会话平均利用率** | 见 1.2b | 5 节点全 idle(busy=0),配额剩余 10,资源充裕 |
+
+### 1.2b 各会话当前工作状态列表
+
+| 会话标签 | 会话 ID | 当前状态 | 当前任务 | 静默/空闲时长 | 工作目录 | 备注 |
+|----------|---------|----------|----------|---------------|----------|------|
+| lan-189 | ses_e2bb867beffeBKVDL5FRVnJiTv | idle(busy0) | S-cfc-220 开发实现(RED) | — | 干净(worktree 隔离) | 本轮派发;配额剩10 |
+| lan-204 | ses_e2bb83303ffeFCzptTYZX9Wjko | idle(busy0) | S-cfc-221 开发实现(RED) | — | 干净 | 本轮派发;配额剩10 |
+| local-lan-4090 | — | idle | — | — | 干净 | 配额剩10;本轮无 RED 可派(--limit 2 预算) |
+| mes-local-45278 | — | idle | — | — | 干净 | 配额剩10 |
+| pub-iwintrue | ses_e4804838affeBHv6uKhYS4x7o7 (testing) | idle | 承载 ~30 YELLOW→部署验证 | — | 干净 | deploy pending 21 / confirmed 77 |
+| prod(lan/etotem) | ses_f719c9326ffeMp0i4xZi6oNFFt | idle | — | — | 干净 | 生产实例默认跳过 |
+
+> **R2「不留空闲」研判**:5 节点全部 idle、配额剩余 10;本轮仅 2 个 pending RED 任务可派(`--limit 2` 单轮预算),已分派至 lan-189/lan-204。「确保所有会话分派开发任务」✅ 满足——pending 中**无 DEV 实现(YELLOW)任务**,剩余 91 pending 全为测试 RED(76)/GREEN(17) 任务(测试侧瓶颈,非开发侧),故开发节点无空闲未派开发任务,非空转。
+
+### 1.3 变更感知与新任务建立
+
+| 指标 | 数量 | 说明 |
+|------|------|------|
+| **感知需求/设计变更数*** | 200+ 提交/7天 | 文档变更以测试台账与汇报为主;**0 新增能力键** |
+| **新增入池任务数*** | 0 | 0 新键,O-12 不建伪任务 |
+| **既有键状态流转数** | ~32 | YELLOW→YELLOW✓ 自动推进 30 + RED 派发实现 2 |
+
+### 1.4 本轮 git 提交与推送证据(真理证据)
+
+| 提交 sha | 关联键 | 阶段 | 触及文件 | 目标分支 | 已推送远端 | 备注 |
+|----------|--------|------|----------|----------|------------|------|
+| b3b3c0475 | teacher.family.overview | YELLOW✓ | 实现(1) | cfclub | 是 | 本轮 auto-advance 核验 |
+| 3d45a4b10 | admin.knowledgeBase.manage | YELLOW✓ | 实现(4) | cfclub | 是 | 同上 |
+| 45b29e5f8 / a79b26640 / 3b90e26d6 / 1f31f4b094d5 / c36d6515b / 12317d458 / f139a05a2 / 9afaa6e46719 / 9e1fc2f255ae / 1915a3272215 / ca56f8699f75 / 808b603f29 / 5c3cff249 | 多 YELLOW 键 | YELLOW✓ | 实现 | cfclub | 是 | 实现已落库 origin/cfclub,待测试 GREEN 复验 |
+
+> 93 个 YELLOW 键实现均已推 origin/cfclub(merge-base IN-CHAIN);本轮 2 个 RED 开发任务(S-cfc-220/221)由会话在 worktree 实现并自 commit+push(待会话回执)。O-22「done 无 git 证据」6 键(S-cfc-152/156/159/172/173/175)按「键级已实现定论」保持 done 不重派。
+
+### 1.5 状态机阶段分布与部署状态
+
+| 阶段 | 键数 | 备注 |
+|------|------|------|
+| UNTRACKED | 0 | — |
+| RED | 94 | 待开发实现(契约测试已/待写) |
+| YELLOW | 93 | 已实现,待测试校验 |
+| GREEN | 8 | 待部署验证 |
+| DONE | 15 | 闭环归档 |
+
+| 部署项 | 值 | 说明 |
+|--------|-----|------|
+| 本轮部署次数 | ~30 | YELLOW→testing 部署验证派发,净增 pending 2 至 21 |
+| 最新 last_deployed | 见 `.tdd/deploy_state.json` | 编排侧本地账本 |
+| GREEN 部署链验证 | 进行中 | 21 pending 待 `--deploy-done` 确认 |
+
+### 1.6 数据源健康度
+
+| 数据源 | 读取键数 | 是否正常 | 备注 |
+|--------|----------|----------|------|
+| 需求侧(⏳ wish) | 0 | 静默 | 已知风险 O-25:SRS ⏳=0,sc-key-map→编排池未打通 |
+| 契约常量(contract) | 0 | 正常 | CFC 发现/跟踪模式不使用 |
+| 测试用例引用(test_case_map) | 0 | 正常 | 同上 |
+| 执行结果回填(case_results) | 0 | 正常 | 同上 |
+
+> 四类均为 0 = CFC 发现/跟踪模式 + ⏳=0 静默,缺口在测试自动化层(795 md 用例待补脚本 + 226 SC 三类流证据 O-7),非数据源故障。
+
+---
+
+## 二、下一步需要研发处理的事项
+
+| 序号 | 事项描述 | 依赖/前置条件 | 预估完成时间 | 优先级 | 状态 |
+|------|----------|---------------|--------------|--------|------|
+| 1 | 推进 76 个 pending RED 契约测试实现(开发侧) | 测试先写 RED 契约 | 持续(每轮 2) | P1 | 进行中(本轮派 2) |
+| 2 | 21 个 testing 部署验证收口(`--deploy-done`) | 部署会话回执 | 下轮 | P0 | 待收口 |
+| 3 | 226 SC 三类流执行证据(O-7) | 远程测试团队 | 持续 | P1 | 移交测试 |
+| 4 | S-cfc-20 孤儿 reconcile | — | 待核 | P2 | 待办 |
+| 5 | 主工作树 888 改动收口(其他会话) | 各会话自收口 | — | P2 | 非本自动化职责 |
+
+---
+
+## 三、阻塞与风险
+
+| 阻塞项 | 影响范围 | 阻塞类型 | 处置进展 | 需要协调 |
+|--------|----------|----------|----------|----------|
+| 历史 blockers B-39/B-58/B-61/B-43(P0) + B-31/B-33(P1) | 测试↔研发协同 | 测试/研发 | 持续协同 | 远程测试团队 |
+| 主工作树 888 处未提交 | 不触碰他人 WIP | — | 严守纪律零接触 | 各会话自收口 |
+| 本地落后 origin/cfclub | 汇报入库 | — | 经独立 commit-tree 入库 | — |
+| SRS ⏳=0 静默 | 编排池无开发缺口载体 | 设计/需求 | O-25 待排查打通 | 主理人确认 |
+
+---
+
+## 四、下轮工作规划
+
+| 计划项 | 预计完成时间 | 前置依赖 | 涉及会话标签 | 备注 |
+|--------|--------------|----------|--------------|------|
+| `--auto --role 开发 --limit 2` 推进 | 每轮 | — | lan-189/lan-204 等 | RED 实现 + YELLOW→部署 |
+| 协调测试侧 GREEN 收口 93 YELLOW | 持续 | 远程测试团队 | testing | 测试瓶颈 |
+| sc-key-map→编排池打通(O-25) | 待裁决 | 主理人 | — | 消除 ⏳=0 静默 |
+
+---
+
+## 附录
+
+### 附录 B:任务分派明细
+
+| 任务号 | 目标会话 | 任务类型 | 分派时间 | 当前状态 |
+|--------|----------|----------|----------|----------|
+| S-cfc-220 | lan-189 (ses_e2bb867b…) | 开发实现(RED) | 17:38 | dispatched |
+| S-cfc-221 | lan-204 (ses_e2bb8330…) | 开发实现(RED) | 17:38 | dispatched |
+| ~30 YELLOW 键 | pub-iwintrue (testing) | 部署验证 | 17:38 | dispatched |
+
+### 附录 D:变更感知明细(来自 `--changes`)
+
+- 7 天 200+ 提交:设计/用例文档 + 测试台账(R183–R192 测试负责人轮次)为主;**0 新增能力键**(变化均为既有键状态/测试台账),无需 `--add-task`。
+
+---
+
+**纪律自检**:未调用 tools/xaf_agent_hub/ 任何脚本;派发走 tdd-orchestrator skill 直连(T1–T8);主工作树 888 脏文件零触碰;状态机仅本地保存(`.tdd/state.json` 不入版本库);汇报经独立 commit-tree 入库、主工作树零接触。
+
+**结论**:开发实现侧收口稳态维持(70/70 能力键 YELLOW+、241 场景端点实现 0 缺失);本轮派发 2 个开发实现任务,R2「不留空闲」满足;闭环瓶颈=93 YELLOW 测试 GREEN 复验 + 226 SC 测试三类流证据(O-7)+ 21 部署待收口,移交远程测试团队协同收口。