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

test(S-cfc-244): ai.coach.consult TDD-RED 契约单测(SC-B-01 成长咨询对话 + SC-B-11 报告细节追问)

能力键 ai.coach.consult 契约级单测(纯 Mockito/反射,无 Spring/DB/外部服务)。
AICoachConsultContractTest:16 用例(三流覆盖),5 红为 GREEN 阶段需补齐的契约缺口:

- SC-B-01 异常:空消息应 400(现 500「消息不能为空」)—— TC-B-01-E
- SC-B-01 异常:超长文本(>8000字)应 400 长度上限(现无校验)—— TC-B-01-E
- SC-B-11 异常:chat/messages 缺 conversationId 应 400(现 500)—— TC-B-11-E
- SC-B-11 异常:report/analyze 缺 reportId/draftId 应 400(现 500)—— TC-B-11-E
- SC-B-11 权限:越权查他人报告应拒答且不泄漏到 LangGraph(现无家庭归属校验,上下文缺失 NPE)—— TC-B-11-P

其余 11 项(chat/send 多轮 200/[TASK] 解析/报告上下文注入/会话列表消息历史删除回退/
行为摘要接线/非会员 46003/会员过期 46004)当前实现已具备,保持绿。

任务号:S-cfc-244(SC-B-01/SC-B-11 → ai.coach.consult)
溯源:tests/user-scenarios/08-butler.md SC-B-01/B-11 + API_REFERENCE §4.64.1/§4.64.4
参考:docs/superpowers/specs/2026-11-07-ai-butler-contract.md(防假 RED 端点核对)
Sisyphus 2 часов назад
Родитель
Сommit
058b029b0b

+ 714 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AICoachConsultContractTest.java

@@ -0,0 +1,714 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.ai.AIBehaviorController;
+import com.etotem.cfc.controller.ai.AIChatController;
+import com.etotem.cfc.dto.AiGateResult;
+import com.etotem.cfc.enums.AiGateStatus;
+import com.etotem.cfc.service.AIService;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.FamilyContextService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.PortraitService;
+import com.etotem.cfc.service.SelfCheckAnalysisService;
+import com.etotem.cfc.service.TaskParseService;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.GrowthTaskService;
+import com.etotem.cfc.service.ChatMirrorService;
+import com.etotem.cfc.service.HealthAnalysisService;
+import com.etotem.cfc.service.RecommendationService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.*;
+
+/**
+ * 契约级单测:AI 成长教练咨询对话与报告追问(能力键 {@code ai.coach.consult})。
+ *
+ * <p>契约溯源(v0.7,O-8 归一后 241 SC):
+ * <ul>
+ *   <li>SC-B-01:成长咨询对话(问题解答·咨询)—— AIChatController /api/ai/chat/send 多轮上下文、
+ *       /api/ai/chat/conversations、/api/ai/chat/messages、/api/ai/chat/conversations/{id}/delete</li>
+ *   <li>SC-B-11:报告细节追问(问题解答·追问)—— 同 chat/send 追问上下文、report/analyze 注入报告上下文、
+ *       conversations/messages 会话历史</li>
+ *   <li>AIBehaviorController /api/ai/behavior/summary 行为摘要</li>
+ *   <li>LangGraph 问答生成:AIService.sendMessage → AiGateway.chat</li>
+ * </ul>
+ * </p>
+ *
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB / 无外部服务):
+ * <ul>
+ *   <li>正常流:咨询对话/报告追问 200、多轮 conversationId、会话历史、行为摘要</li>
+ *   <li>异常流:空消息、超长文本、空 conversationId、报告上下文缺失、越权报告</li>
+ *   <li>权限流:非会员 46003、会员过期 46004、孩子端 403、越权家庭 403</li>
+ * </ul>
+ * </p>
+ *
+ * <p><b>RED 预期</b>:以下契约行为在当前实现中缺失/默认值未实现,断言应失败(红):
+ * <ul>
+ *   <li>空消息应返回 400(当前实现返回 500「消息不能为空」)</li>
+ *   <li>超长文本(&gt;8000 字)应返回 400 长度上限(当前实现无长度校验)</li>
+ *   <li>chat/messages 缺 conversationId 应返回 400(当前实现返回 500)</li>
+ *   <li>report/analyze 缺 reportId/draftId 应返回 400(当前实现返回 500)</li>
+ *   <li>越权查询他人报告应拒绝且不泄漏到 LangGraph(当前实现无家庭归属校验,上下文缺失时 NPE)</li>
+ * </ul>
+ * 以上 5 处为 GREEN 阶段需补齐的契约行为;其余(正常流 200/多轮/会话历史/行为摘要接线、
+ * 非会员 46003/会员过期 46004)当前实现已具备,应保持绿(意外全绿仅限这些已落地项)。
+ * </p>
+ */
+class AICoachConsultContractTest {
+
+    // ============================================================
+    // Test constants
+    // ============================================================
+    private static final Long TEST_USER_ID = 1L;
+    private static final Long TEST_MEMBER_ID = 10L;
+    private static final String TEST_CONVERSATION_ID = "conv-123";
+    private static final Long TEST_REPORT_ID = 999L;
+
+    // ============================================================
+    // Helper: build a fully mocked AIChatController
+    // ============================================================
+    private AIChatController buildChatController(
+            MembershipService membershipSvc,
+            AIService aiSvc,
+            FamilyContextService familyCtxSvc,
+            PortraitService portraitSvc,
+            SelfCheckAnalysisService selfCheckSvc,
+            TaskParseService taskParseSvc,
+            GrowthTaskService growthTaskSvc,
+            UserService userSvc,
+            ChatMirrorService chatMirrorSvc,
+            AiGateway aiGateway,
+            HealthAnalysisService healthAnalysisSvc,
+            RecommendationService recommendationSvc) throws Exception {
+
+        AIChatController c = new AIChatController();
+        inject(c, "membershipService", membershipSvc);
+        inject(c, "aiService", aiSvc);
+        inject(c, "familyContextService", familyCtxSvc);
+        inject(c, "portraitService", portraitSvc);
+        inject(c, "selfCheckAnalysisService", selfCheckSvc);
+        inject(c, "taskParseService", taskParseSvc);
+        inject(c, "growthTaskService", growthTaskSvc);
+        inject(c, "userService", userSvc);
+        inject(c, "chatMirrorService", chatMirrorSvc);
+        inject(c, "aiGateway", aiGateway);
+        inject(c, "healthAnalysisService", healthAnalysisSvc);
+        inject(c, "recommendationService", recommendationSvc);
+        return c;
+    }
+
+    // ============================================================
+    // 正常流:SC-B-01 成长咨询对话(chat/send 多轮)
+    // ============================================================
+
+    @Test
+    @DisplayName("SC-B-01 正常:会员发送咨询消息 → 200 返回 answer + conversationId")
+    void scB01_正常_咨询对话_返回答案与会话ID() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIService aiSvc = mock(AIService.class);
+        TaskParseService taskParseSvc = mock(TaskParseService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, aiSvc,
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), taskParseSvc,
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        // 门禁放行
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setEnabled(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+
+        // LangGraph 返回回答
+        Map<String, Object> aiResp = new LinkedHashMap<>();
+        aiResp.put("answer", "孩子便秘建议多吃膳食纤维、适量运动、规律作息。");
+        aiResp.put("conversationId", TEST_CONVERSATION_ID);
+        // 首轮无 conversationId(null)→ 第 3 参用 nullable 匹配 null 与非 null
+        when(aiSvc.sendMessage(anyString(), anyString(), nullable(String.class), anyMap()))
+                .thenReturn(aiResp);
+        // TaskParse 无 [TASK] 标记 → 原样返回清洗后文本
+        when(taskParseSvc.stripTaskMarkers(anyString()))
+                .thenAnswer(inv -> inv.getArgument(0));
+
+        Map<String, String> params = new HashMap<>();
+        params.put("query", "孩子便秘吃什么好?");
+        Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData().get("answer"));
+        assertEquals(TEST_CONVERSATION_ID, r.getData().get("conversationId"));
+        verify(aiSvc).sendMessage(eq("孩子便秘吃什么好?"), eq("1"), eq(null), anyMap());
+    }
+
+    @Test
+    @DisplayName("SC-B-01 正常:多轮对话传入 conversationId → 透传给 AIService")
+    void scB01_正常_多轮对话_透传ConversationId() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIService aiSvc = mock(AIService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, aiSvc,
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+
+        Map<String, Object> aiResp = new LinkedHashMap<>();
+        aiResp.put("answer", "3 岁也可以,注意调整剂量。");
+        aiResp.put("conversationId", TEST_CONVERSATION_ID);
+        when(aiSvc.sendMessage(anyString(), anyString(), anyString(), anyMap()))
+                .thenReturn(aiResp);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("query", "那 3 岁可以吗?");
+        params.put("conversationId", TEST_CONVERSATION_ID);
+        Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
+
+        assertEquals(200, r.getCode());
+        verify(aiSvc).sendMessage(eq("那 3 岁可以吗?"), eq("1"), eq(TEST_CONVERSATION_ID), anyMap());
+    }
+
+    @Test
+    @DisplayName("SC-B-01 正常:chat/send 解析 [TASK] 标记返回任务列表")
+    void scB01_正常_解析Task标记() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIService aiSvc = mock(AIService.class);
+        TaskParseService taskParseSvc = mock(TaskParseService.class);
+        GrowthTaskService growthTaskSvc = mock(GrowthTaskService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, aiSvc,
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), taskParseSvc,
+                growthTaskSvc, mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+
+        Map<String, Object> aiResp = new LinkedHashMap<>();
+        aiResp.put("answer", "建议每天喝水 [TASK: {\"title\":\"每日饮水\",\"dimension\":\"身\",\"rewardPoints\":5}] 8 杯");
+        aiResp.put("conversationId", TEST_CONVERSATION_ID);
+        when(aiSvc.sendMessage(anyString(), anyString(), anyString(), anyMap()))
+                .thenReturn(aiResp);
+
+        // TaskParseService 返回解析结果
+        com.etotem.cfc.dto.TaskParseResult parsed = mock(com.etotem.cfc.dto.TaskParseResult.class);
+        when(parsed.getTitle()).thenReturn("每日饮水");
+        when(parsed.getDescription()).thenReturn("每天喝水 8 杯");
+        when(parsed.getDimension()).thenReturn("身");
+        when(parsed.getRewardPoints()).thenReturn(5);
+        when(taskParseSvc.parseTasks(anyString())).thenReturn(Collections.singletonList(parsed));
+        when(taskParseSvc.stripTaskMarkers(anyString())).thenReturn("建议每天喝水 8 杯");
+
+        com.etotem.cfc.entity.GrowthTask created = new com.etotem.cfc.entity.GrowthTask();
+        created.setId(888L);
+        created.setTitle("每日饮水");
+        created.setDescription("每天喝水 8 杯");
+        created.setDimension("身");
+        created.setRewardPoints(5);
+        when(growthTaskSvc.createDynamicTask(anyLong(), anyString(), anyString(), anyString(), anyInt(), anyString()))
+                .thenReturn(created);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("query", "给个喝水任务");
+        Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
+
+        assertEquals(200, r.getCode());
+        assertTrue(r.getData().containsKey("tasks"));
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> tasks = (List<Map<String, Object>>) r.getData().get("tasks");
+        assertEquals(1, tasks.size());
+        assertEquals("每日饮水", tasks.get(0).get("title"));
+    }
+
+    // ============================================================
+    // 正常流:SC-B-11 报告细节追问(report/analyze + chat/send 追问上下文)
+    // ============================================================
+
+    @Test
+    @DisplayName("SC-B-11 正常:report/analyze 注入报告上下文 → 200 定向回答")
+    void scB11_正常_报告解读_注入上下文() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIService aiSvc = mock(AIService.class);
+        FamilyContextService familyCtxSvc = mock(FamilyContextService.class);
+        UserService userSvc = mock(UserService.class);
+        TaskParseService taskParseSvc = mock(TaskParseService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, aiSvc,
+                familyCtxSvc, mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), taskParseSvc,
+                mock(GrowthTaskService.class), userSvc,
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        // 付费会员(非 FREE)→ 不走免费版单轮限制路径(付费版多轮,见 @Operation 摘要)
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+
+        com.etotem.cfc.entity.User paidUser = new com.etotem.cfc.entity.User();
+        paidUser.setId(TEST_USER_ID);
+        paidUser.setMemberLevel("VIP");
+        when(userSvc.getUserInfo(TEST_USER_ID)).thenReturn(paidUser);
+        // TaskParse 无 [TASK] 标记 → 原样返回清洗后文本
+        when(taskParseSvc.stripTaskMarkers(anyString()))
+                .thenAnswer(inv -> inv.getArgument(0));
+
+        // buildChatInputs 注入报告上下文
+        Map<String, Object> reportContext = new LinkedHashMap<>();
+        reportContext.put("reportId", TEST_REPORT_ID);
+        reportContext.put("reportType", "growth");
+        reportContext.put("dimensionScores", mapOf("身", 70, "心", 65));
+        when(familyCtxSvc.buildContext(eq(TEST_USER_ID), eq(TEST_REPORT_ID)))
+                .thenReturn(reportContext);
+
+        Map<String, Object> aiResp = new LinkedHashMap<>();
+        aiResp.put("answer", "报告显示身维 70 分,心维 65 分,建议重点加强心维活动。");
+        aiResp.put("conversationId", TEST_CONVERSATION_ID);
+        when(aiSvc.sendMessage(anyString(), anyString(), anyString(), anyMap()))
+                .thenReturn(aiResp);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("query", "报告里身维和心维什么意思?");
+        params.put("reportId", String.valueOf(TEST_REPORT_ID));
+        params.put("conversationId", TEST_CONVERSATION_ID);
+        Result<Map<String, Object>> r = c.reportAnalyze(TEST_USER_ID, params);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData().get("answer"));
+        verify(familyCtxSvc).buildContext(eq(TEST_USER_ID), eq(TEST_REPORT_ID));
+    }
+
+    @Test
+    @DisplayName("SC-B-11 正常:chat/send 追问传入 reportId → 基于报告上下文回答")
+    void scB11_正常_追问传报告上下文() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIService aiSvc = mock(AIService.class);
+        FamilyContextService familyCtxSvc = mock(FamilyContextService.class);
+        TaskParseService taskParseSvc = mock(TaskParseService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, aiSvc,
+                familyCtxSvc, mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), taskParseSvc,
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        // TaskParse 无 [TASK] 标记 → 原样返回清洗后文本
+        when(taskParseSvc.stripTaskMarkers(anyString()))
+                .thenAnswer(inv -> inv.getArgument(0));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+
+        Map<String, Object> reportContext = new LinkedHashMap<>();
+        reportContext.put("reportId", TEST_REPORT_ID);
+        reportContext.put("riskItems", Collections.singletonList("菌种A偏低"));
+        when(familyCtxSvc.buildContext(eq(TEST_USER_ID), eq(TEST_REPORT_ID)))
+                .thenReturn(reportContext);
+
+        Map<String, Object> aiResp = new LinkedHashMap<>();
+        aiResp.put("answer", "菌种A偏低可能影响肠道屏障,建议补充益生元。");
+        aiResp.put("conversationId", TEST_CONVERSATION_ID);
+        when(aiSvc.sendMessage(anyString(), anyString(), anyString(), anyMap()))
+                .thenReturn(aiResp);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("query", "菌种A偏低有什么影响?");
+        params.put("reportId", String.valueOf(TEST_REPORT_ID));
+        params.put("conversationId", TEST_CONVERSATION_ID);
+        Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData().get("answer"));
+        verify(familyCtxSvc).buildContext(eq(TEST_USER_ID), eq(TEST_REPORT_ID));
+    }
+
+    @Test
+    @DisplayName("SC-B-11 正常:chat/conversations 返回会话列表(回退本地镜像)")
+    void scB11_正常_会话列表_回退本地() throws Exception {
+        AIService aiSvc = mock(AIService.class);
+        ChatMirrorService chatMirrorSvc = mock(ChatMirrorService.class);
+        AIChatController c = buildChatController(
+                mock(MembershipService.class), aiSvc,
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
+                mock(GrowthTaskService.class), mock(UserService.class),
+                chatMirrorSvc, mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        // LangGraph 抛异常 → 回退本地镜像
+        when(aiSvc.getConversations(anyString())).thenThrow(new RuntimeException("LangGraph 不可用"));
+
+        com.etotem.cfc.entity.ChatConversation localConv = new com.etotem.cfc.entity.ChatConversation();
+        localConv.setId(100L);
+        localConv.setDifyConversationId(TEST_CONVERSATION_ID);
+        localConv.setTitle("成长咨询");
+        localConv.setLastMessageAt(new Date());
+        when(chatMirrorSvc.getConversations(TEST_USER_ID)).thenReturn(Collections.singletonList(localConv));
+
+        Result<List<Map<String, Object>>> r = c.getConversations(TEST_USER_ID);
+
+        assertEquals(200, r.getCode());
+        assertEquals(1, r.getData().size());
+        assertEquals(TEST_CONVERSATION_ID, r.getData().get(0).get("id"));
+    }
+
+    @Test
+    @DisplayName("SC-B-11 正常:chat/messages 返回指定会话消息历史")
+    void scB11_正常_消息历史() throws Exception {
+        AIService aiSvc = mock(AIService.class);
+        ChatMirrorService chatMirrorSvc = mock(ChatMirrorService.class);
+        AIChatController c = buildChatController(
+                mock(MembershipService.class), aiSvc,
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
+                mock(GrowthTaskService.class), mock(UserService.class),
+                chatMirrorSvc, mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        when(aiSvc.getMessages(anyString(), anyString())).thenThrow(new RuntimeException("LangGraph 不可用"));
+
+        com.etotem.cfc.entity.ChatConversation localConv = new com.etotem.cfc.entity.ChatConversation();
+        localConv.setId(200L);
+        localConv.setDifyConversationId(TEST_CONVERSATION_ID);
+        when(chatMirrorSvc.getConversations(TEST_USER_ID)).thenReturn(Collections.singletonList(localConv));
+
+        com.etotem.cfc.entity.ChatMessage msg1 = new com.etotem.cfc.entity.ChatMessage();
+        msg1.setRole("user");
+        msg1.setContent("你好");
+        msg1.setCreatedAt(new Date());
+        com.etotem.cfc.entity.ChatMessage msg2 = new com.etotem.cfc.entity.ChatMessage();
+        msg2.setRole("assistant");
+        msg2.setContent("您好,有什么可以帮您?");
+        msg2.setCreatedAt(new Date());
+        when(chatMirrorSvc.getMessages(200L)).thenReturn(Arrays.asList(msg1, msg2));
+
+        Map<String, String> params = new HashMap<>();
+        params.put("conversationId", TEST_CONVERSATION_ID);
+        Result<List<Map<String, Object>>> r = c.getMessages(TEST_USER_ID, params);
+
+        assertEquals(200, r.getCode());
+        assertEquals(2, r.getData().size());
+        assertEquals("user", r.getData().get(0).get("role"));
+    }
+
+    @Test
+    @DisplayName("SC-B-11 正常:删除会话 → 200")
+    void scB11_正常_删除会话() throws Exception {
+        AIService aiSvc = mock(AIService.class);
+        AIChatController c = buildChatController(
+                mock(MembershipService.class), aiSvc,
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        doNothing().when(aiSvc).deleteConversation(eq(TEST_CONVERSATION_ID), anyString());
+
+        Result<Void> r = c.deleteConversation(TEST_USER_ID, TEST_CONVERSATION_ID);
+
+        assertEquals(200, r.getCode());
+        verify(aiSvc).deleteConversation(eq(TEST_CONVERSATION_ID), eq("1"));
+    }
+
+    // ============================================================
+    // 正常流:AIBehaviorController /api/ai/behavior/summary
+    // ============================================================
+
+    @Test
+    @DisplayName("SC-B-01/B-11 正常:/api/ai/behavior/summary 返回行为摘要")
+    void scB01_B11_正常_行为摘要() throws Exception {
+        FamilyContextService familyCtxSvc = mock(FamilyContextService.class);
+        AIBehaviorController c = new AIBehaviorController();
+        inject(c, "familyContextService", familyCtxSvc);
+
+        // 契约(设计面):/api/ai/behavior/summary 为「行为摘要」端点(TDD 设计溯源列为
+        // ai.coach.consult 载体之一)。当前实现 = 透传 familyContextService.buildContext(userId)
+        // 通用家庭上下文;专用行为摘要聚合(五维能量趋势/任务完成率/异常指标)属 GREEN 阶段
+        // 增强项(若契约要求),本用例仅钉住现有接线:200 + member_id 注入 context。
+        Map<String, Object> genericContext = new LinkedHashMap<>();
+        genericContext.put("familyId", 7L);
+        genericContext.put("memberId", null);
+        when(familyCtxSvc.buildContext(TEST_USER_ID)).thenReturn(genericContext);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("member_id", TEST_MEMBER_ID);
+        Result<Map<String, Object>> r = c.getBehaviorSummary(TEST_USER_ID, params);
+
+        assertEquals(200, r.getCode());
+        // 现有接线断言:member_id 覆盖注入(行为摘要按指定成员返回)
+        assertEquals(TEST_MEMBER_ID, r.getData().get("memberId"),
+                "传入 member_id 时应注入到行为摘要上下文中");
+    }
+
+    // ============================================================
+    // 异常流
+    // ============================================================
+
+    @Test
+    @DisplayName("SC-B-01 异常:空消息 → 400「内容不能为空」(契约要求 400,非 500)")
+    void scB01_异常_空消息_返回400() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, mock(AIService.class),
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        // 门禁放行(空消息校验在门禁前)
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+
+        Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, new HashMap<>());
+
+        // 契约要求:空消息返回 400(当前实现返回 500 "消息不能为空")
+        assertEquals(400, r.getCode(), "空消息应返回 400 Bad Request,而非 500");
+        assertTrue(r.getMessage().contains("内容不能为空") || r.getMessage().contains("消息不能为空"));
+    }
+
+    @Test
+    @DisplayName("SC-B-01 异常:超长文本(>8000 字) → 400 长度上限")
+    void scB01_异常_超长文本_返回400() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, mock(AIService.class),
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+
+        String longQuery = repeat("x", 8001);
+        Map<String, String> params = new HashMap<>();
+        params.put("query", longQuery);
+
+        Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
+
+        // 契约要求:超长文本返回 400 明确提示长度上限(当前实现无长度校验)
+        assertEquals(400, r.getCode(), "超长文本(>8000 字)应返回 400 长度上限");
+        assertTrue(r.getMessage().contains("长度") || r.getMessage().contains("超长") || r.getMessage().contains("上限"));
+    }
+
+    @Test
+    @DisplayName("SC-B-11 异常:chat/messages 缺少 conversationId → 400")
+    void scB11_异常_缺少ConversationId_返回400() throws Exception {
+        AIChatController c = buildChatController(
+                mock(MembershipService.class), mock(AIService.class),
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        Result<List<Map<String, Object>>> r = c.getMessages(TEST_USER_ID, new HashMap<>());
+
+        // 当前实现返回 500 "conversationId 不能为空",契约要求 400
+        assertEquals(400, r.getCode(), "缺少 conversationId 应返回 400");
+        assertTrue(r.getMessage().contains("conversationId"));
+    }
+
+    @Test
+    @DisplayName("SC-B-11 异常:report/analyze 缺少 reportId/draftId → 400")
+    void scB11_异常_缺少报告Id_返回400() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, mock(AIService.class),
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("query", "解读一下");
+        Result<Map<String, Object>> r = c.reportAnalyze(TEST_USER_ID, params);
+
+        // 当前实现返回 500 "报告ID或草稿ID不能为空",契约要求 400
+        assertEquals(400, r.getCode(), "缺少报告 ID 应返回 400");
+        assertTrue(r.getMessage().contains("报告"));
+    }
+
+    // ============================================================
+    // 权限流
+    // ============================================================
+
+    @Test
+    @DisplayName("SC-B-01 权限:非会员(FREE)调用 chat/send → 46003 会员专享")
+    void scB01_权限_非会员_返回46003() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, mock(AIService.class),
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(false);
+        gate.setEnabled(true);
+        gate.setStatus(AiGateStatus.UPGRADE_REQUIRED);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("query", "测试");
+        Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
+
+        assertEquals(46003, r.getCode());
+        assertTrue(r.getMessage().contains("会员"));
+    }
+
+    @Test
+    @DisplayName("SC-B-01 权限:会员已过期 → 46004 续费引导")
+    void scB01_权限_会员过期_返回46004() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, mock(AIService.class),
+                mock(FamilyContextService.class), mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(false);
+        gate.setStatus(AiGateStatus.MEMBER_EXPIRED);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("query", "测试");
+        Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
+
+        assertEquals(46004, r.getCode());
+        assertTrue(r.getMessage().contains("过期") || r.getMessage().contains("续费"));
+    }
+
+    @Test
+    @DisplayName("SC-B-11 权限:chat/send 越权查询他人报告 → 拒绝且不泄漏到 LangGraph")
+    void scB11_权限_越权报告_拒绝不泄漏() throws Exception {
+        MembershipService membershipSvc = mock(MembershipService.class);
+        AIService aiSvc = mock(AIService.class);
+        FamilyContextService familyCtxSvc = mock(FamilyContextService.class);
+        TaskParseService taskParseSvc = mock(TaskParseService.class);
+        AIChatController c = buildChatController(
+                membershipSvc, aiSvc,
+                familyCtxSvc, mock(PortraitService.class),
+                mock(SelfCheckAnalysisService.class), taskParseSvc,
+                mock(GrowthTaskService.class), mock(UserService.class),
+                mock(ChatMirrorService.class), mock(AiGateway.class),
+                mock(HealthAnalysisService.class), mock(RecommendationService.class));
+
+        AiGateResult gate = new AiGateResult();
+        gate.setCanUse(true);
+        gate.setStatus(AiGateStatus.ALLOW);
+        when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
+        when(taskParseSvc.stripTaskMarkers(anyString()))
+                .thenAnswer(inv -> inv.getArgument(0));
+
+        // 越权:当前用户查询其他家庭的报告(8888),上下文服务对越权报告只应返回
+        // 「无法构建/拒绝」语义(null)——契约要求控制器在此边界拒答,不组装他人
+        // 报告上下文、不调用 LangGraph、不返回 200+泄露内容。
+        Long otherReportId = 8888L;
+        when(familyCtxSvc.buildContext(eq(TEST_USER_ID), eq(otherReportId)))
+                .thenReturn(null);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("query", "看看这个报告");
+        params.put("reportId", String.valueOf(otherReportId));
+
+        Result<Map<String, Object>> r;
+        try {
+            r = c.sendMessage(TEST_USER_ID, params);
+        } catch (Exception e) {
+            fail("[ai.coach.consult] 越权报告上下文缺失时控制器异常(" + e.getClass().getSimpleName()
+                    + ": " + e.getMessage() + "),契约要求优雅拒答(403/错误码),不 NPE 不 500");
+            return;
+        }
+        // 契约要求:越权报告不得返回 200 + 泄露回答;GREEN 侧应返回 403 或业务错误码
+        assertNotEquals(200, r.getCode(),
+                "越权查询他人报告应被拒绝(返回 403/业务错误码),不得返回 200 泄露数据");
+        verify(aiSvc, never()).sendMessage(anyString(), anyString(), anyString(), anyMap());
+    }
+
+    // ============================================================
+    // Reflection inject helper (from AssessmentPlanContractTestBase)
+    // ============================================================
+
+    private 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);
+    }
+
+    /** Java 8 兼容:构造小 Map(避免 List.of/Map.of 的 Java 9+ API) */
+    private static Map<String, Object> mapOf(Object... kv) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        for (int i = 0; i + 1 < kv.length; i += 2) {
+            m.put(String.valueOf(kv[i]), kv[i + 1]);
+        }
+        return m;
+    }
+
+    /** Java 8 兼容:拼接 n 次字符串(String.repeat 为 Java 11+ API) */
+    private static String repeat(String s, int n) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < n; i++) {
+            sb.append(s);
+        }
+        return sb.toString();
+    }
+}