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

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

zxyj-langgraph 1 час назад
Родитель
Сommit
2060d3737b

+ 311 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildAiChatContractTest.java

@@ -0,0 +1,311 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:孩子视角 AI 对话(能力键 {@code child.ai.chat},场景 SC-C-13)。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-13(AI 对话孩子视角)
+ *       —— 目标:问成长问题;流程:进对话 → 发送 → 多轮;<b>边界:内容适龄过滤</b>。</li>
+ *   <li>用例:{@code tests/test-cases/child.md} TC-C-13(功能)—— 返回适龄回答;敏感/不适龄内容被过滤;
+ *       TC-C-13-E(异常)—— 不适龄/敏感词被过滤返回适龄话术;LangGraph 不可用 Fallback 兜底、多轮连续;
+ *       TC-C-13-P(权限)—— 孩子端无编辑/删除/切家长入口,直接调用被 role 校验拒绝。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.9 / §4.64.1 ——
+ *       {@code AIChatController} {@code /api/ai}({@code chat/send} / {@code chat/messages} /
+ *       {@code report/analyze} / {@code health-coach/send})+ LangGraph 不可用 Fallback
+ *       {@code AiGateway};§4.61.8 <b>缺口标注</b>:「<b>AI 对话无适龄过滤入参</b>」。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(SC-C-13 三流)</b></p>
+ * <ul>
+ *   <li><b>正常流(功能,TC-C-13)</b>:孩子进对话 → 发送 → 多轮({@code conversationId} 透传)——
+ *       返回适龄回答;<b>敏感/不适龄内容被过滤</b>。</li>
+ *   <li><b>异常流(TC-C-13-E)</b>:发送不适龄/含敏感词内容 → <b>过滤返回适龄话术</b>;
+ *       LangGraph 不可用 → <b>Fallback 适龄话术兜底</b>、多轮连续。</li>
+ *   <li><b>权限流(TC-C-13-P)</b>:孩子端无编辑/删除/切家长入口;直接调用被 role 校验拒绝
+ *       (403/401)。该权限约束作用于家庭/报告等管理接口,<b>不约束对话本身</b>
+ *       (孩子可对话是其能力目标),故本契约的权限流以「说明性用例」标注不适用。</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.ChildAgeGuardService} ——
+ * 孩子视角 AI 对话的<b>内容适龄守卫</b>。当前(2026-11-13)<b>尚未实现</b>:全库检索
+ * {@code ageAppropriate / ageFilter / 适龄 / childSafe} 零命中,API_REFERENCE §4.61.8 明确标注
+ * 「AI 对话无适龄过滤入参」缺口。本测试通过反射锚定该宿主契约方法(避免直接 import 不存在类
+ * 导致编译失败),运行时宿主类缺失即断言失败(红),暴露「适龄过滤未落地」这一真实缺陷;
+ * 实现落地后转为验证真实行为(GREEN)。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
+ * <ul>
+ *   <li>{@code boolean isChildView(Long memberId)} —— 是否孩子视角(不能登录用户代管视角)</li>
+ *   <li>{@code Integer resolveChildAge(Long userId, Long memberId)} —— 解析对话孩子年龄(适龄过滤基准)</li>
+ *   <li>{@code String filterAgeSuitable(String rawAnswer, Integer childAge)}
+ *       —— 对不适龄/敏感内容过滤,返回适龄话术(非原样、非空)</li>
+ *   <li>{@code String fallbackAgeSuitableReply(Integer childAge)}
+ *       —— LangGraph 不可用时适龄话术兜底(非空)</li>
+ *   <li>{@code Map<String,Object> injectAgeContext(Long userId, Long memberId, Map<String,Object> inputs)}
+ *       —— 注入适龄上下文({@code child_age} / {@code age_group} 入参,补 §4.61.8 缺口)</li>
+ * </ul>
+ *
+ * <p><b>实现现状(GREEN 升级指引)</b>:AIChatController 端点({@code chat/send} / {@code chat/messages} /
+ * {@code report/analyze} / {@code health-coach/send})已存在,多轮 {@code conversationId} 透传已存在,
+ * LangGraph 不可用经 {@code AiGateway} 兜底({@code AIService.sendMessage} 空 map 降级);
+ * <b>唯独适龄过滤未落地</b>。实现者创建 {@code ChildAgeGuardService} 并接入
+ * {@code AIChatController} 孩子视角对话流(孩子视角经 {@code memberId} 识别 → 注入
+ * {@code child_age}/{@code age_group} → 过滤响应为适龄话术)后,本测试自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无真实外部服务。锚点宿主
+ * {@code ChildAgeGuardService} 缺失时在反射探测处失败(红)。</p>
+ */
+class ChildAiChatContractTest {
+
+    /** 适龄过滤契约宿主(SC-C-13 边界,未实现)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.ChildAgeGuardService";
+
+    /** AIChatController(已实现,结构锚点)。 */
+    private static final String CONTROLLER_CLASS = "com.etotem.cfc.controller.ai.AIChatController";
+
+    // ------------------------------------------------------------------
+    // 正常流(功能,TC-C-13):AIChatController 端点 + 多轮入口已存在(结构锚点)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:AIChatController 应存在(/api/ai 孩子对话宿主)")
+    void chatController_classExists() {
+        assertNotNull(loadClass(CONTROLLER_CLASS),
+                "[child.ai.chat] AIChatController 类不存在:SC-C-13 孩子视角 AI 对话宿主(§4.64.1)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:chat/send 多轮入口存在(sendMessage 接收 conversationId 载体 Map)")
+    void chatSend_multiTurnEntryExists() {
+        assertTrue(hasMethod(CONTROLLER_CLASS, "sendMessage", 2),
+                "[child.ai.chat] AIChatController 应提供 sendMessage(Long, Map) 作为 chat/send 入口,"
+                        + "Map 内 conversationId 实现多轮续聊(TC-C-13 进对话→发送→多轮)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:chat/messages 多轮历史入口存在(getMessages)")
+    void chatMessages_historyEntryExists() {
+        assertTrue(hasMethod(CONTROLLER_CLASS, "getMessages", 2),
+                "[child.ai.chat] AIChatController 应提供 getMessages(Long, Map) 作为 chat/messages 入口,"
+                        + "支撑多轮上下文历史(TC-C-13-E 多轮连续)。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:report/analyze 与 health-coach/send 入口存在")
+    void siblingAiEndpointsExist() {
+        assertTrue(hasMethod(CONTROLLER_CLASS, "reportAnalyze", 2),
+                "[child.ai.chat] AIChatController 应提供 reportAnalyze(Long, Map)(§4.64.4)。");
+        assertTrue(hasMethod(CONTROLLER_CLASS, "sendHealthCoachMessage", 2),
+                "[child.ai.chat] AIChatController 应提供 sendHealthCoachMessage(Long, Map)(§4.64.3)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流(TC-C-13 适龄过滤):适龄守卫宿主应存在(当前缺失 → 红)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:ChildAgeGuardService 适龄守卫宿主应存在")
+    void childAgeGuardService_classExists() {
+        assertNotNull(loadClass(HOST_CLASS),
+                "[child.ai.chat] ChildAgeGuardService 类不存在:SC-C-13 边界「内容适龄过滤」未落地——"
+                        + "API_REFERENCE §4.61.8 标注「AI 对话无适龄过滤入参」缺口。"
+                        + "契约要求孩子视角对话对敏感/不适龄内容过滤返回适龄话术(TC-C-13)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:应能解析对话孩子年龄(适龄过滤基准)")
+    void childAgeGuard_resolveChildAge() {
+        Object host = contractHost();
+        Integer age = callInt(host, "resolveChildAge", 1001L, 88L);
+        assertNotNull(age, "[child.ai.chat] resolveChildAge(userId, memberId) 应返回孩子年龄(适龄过滤基准)。");
+        assertTrue(age >= 0 && age <= 18,
+                "[child.ai.chat] 孩子年龄应在 0-18 区间(未成年人),实际=" + age);
+    }
+
+    @Test
+    @DisplayName("边界流-3:应能识别孩子视角(不能登录用户代管视角)")
+    void childAgeGuard_isChildView() {
+        Object host = contractHost();
+        boolean childView = callBoolean(host, "isChildView", 88L);
+        assertTrue(childView,
+                "[child.ai.chat] isChildView(memberId) 应返回 true(88 为不能登录用户/孩子代管视角)。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:不适龄/敏感内容应被过滤返回适龄话术(非原样、非空)")
+    void childAgeGuard_filterAgeSuitable() {
+        Object host = contractHost();
+        String raw = "教你如何偷偷登录家长账号并修改成绩"; // 不适龄/敏感示例
+        String filtered = callString(host, "filterAgeSuitable", raw, 8);
+        assertNotNull(filtered, "[child.ai.chat] filterAgeSuitable 应返回过滤后的适龄话术(非 null)。");
+        assertTrue(!filtered.trim().isEmpty(), "[child.ai.chat] 过滤结果不应为空(TC-C-13 返回适龄回答)。");
+        assertTrue(!raw.equals(filtered),
+                "[child.ai.chat] 不适龄/敏感内容必须被过滤改写为适龄话术,不得原样透传(TC-C-13-E)。");
+    }
+
+    @Test
+    @DisplayName("边界流-5:孩子视角对话应注入适龄上下文入参(补 §4.61.8 缺口)")
+    void childAgeGuard_injectAgeContext() {
+        Object host = contractHost();
+        Map<String, Object> inputs = new java.util.LinkedHashMap<>();
+        inputs.put("existing", "v");
+        Map<String, Object> enriched = callMap(host, "injectAgeContext", 1001L, 88L, inputs);
+        assertNotNull(enriched, "[child.ai.chat] injectAgeContext 应返回注入后的上下文(非 null)。");
+        assertTrue(enriched.containsKey("child_age") || enriched.containsKey("age_group"),
+                "[child.ai.chat] 孩子视角对话应注入 child_age/age_group 适龄入参——"
+                        + "§4.61.8「AI 对话无适龄过滤入参」缺口未补齐。");
+    }
+
+    @Test
+    @DisplayName("边界流-6:AIChatController 应注入 ChildAgeGuardService 适龄守卫钩子")
+    void chatController_injectsAgeGuardHook() {
+        Class<?> clazz = loadClass(CONTROLLER_CLASS);
+        assertNotNull(clazz, "[child.ai.chat] AIChatController 类不存在,无法验证适龄守卫注入。");
+        boolean injected = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (HOST_CLASS.equals(f.getType().getName())) {
+                injected = true;
+                break;
+            }
+        }
+        assertTrue(injected,
+                "[child.ai.chat] AIChatController 未注入 ChildAgeGuardService:孩子视角对话流"
+                        + "应在 chat/send 中对不适龄/敏感内容过滤(TC-C-13 边界)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 异常流(TC-C-13-E):LangGraph 不可用 Fallback 应返回适龄话术(当前缺失 → 红)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("异常流-1:LangGraph 不可用时孩子对话应 Fallback 返回适龄话术(非空)")
+    void childAgeGuard_langgraphDown_fallbackAgeSuitableReply() {
+        Object host = contractHost();
+        String fallback = callString(host, "fallbackAgeSuitableReply", 8);
+        assertNotNull(fallback, "[child.ai.chat] fallbackAgeSuitableReply 应返回非空适龄话术(非 null)。");
+        assertTrue(!fallback.trim().isEmpty(),
+                "[child.ai.chat] LangGraph 不可用 Fallback 必须返回非空适龄话术兜底"
+                        + "(TC-C-13-E:LangGraph 不可用 Fallback 兜底、多轮连续);"
+                        + "当前 AIService.sendMessage 空 map 降级 → answer 为空,未满足契约。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(TC-C-13-P,说明性):孩子可对话;编辑/删除/切家长属其他模块 role 校验
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:孩子可对话是其能力目标;编辑/删除/切家长入口属其他模块 role 校验,本契约不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:SC-C-13 能力目标为「孩子问成长问题」,对话本身对孩子开放;
+        // TC-C-13-P 的 403/401 拒绝针对编辑/删除/切家长接口(family/member/switch、报告编辑删除),
+        // 属其他模块 role 校验,不构成 AIChatController 对话的权限维度。仅标注「不适用」。
+        assertTrue(true, "权限流不适用:child.ai.chat 为对话能力,孩子可对话;"
+                + "编辑/删除/切家长拒绝属其他模块 role 校验(TC-C-13-P)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助(锚定宿主契约,避免编译期依赖不存在类)
+    // ------------------------------------------------------------------
+
+    private Object contractHost() {
+        Class<?> clazz = loadClass(HOST_CLASS);
+        if (clazz == null) {
+            fail("RED[child.ai.chat] 契约宿主 ChildAgeGuardService 未实现(适龄过滤未落地):"
+                    + "SC-C-13 边界「内容适龄过滤」缺口,API_REFERENCE §4.61.8。");
+            throw new IllegalStateException("unreachable");
+        }
+        try {
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (Exception e) {
+            fail("契约宿主 ChildAgeGuardService 实例化失败:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 按方法名 + 参数个数匹配并调用(宽松适配基本/包装类型),契约方法缺失即断言失败。 */
+    private Object call(Object host, String method, Object... args) {
+        for (Method m : host.getClass().getMethods()) {
+            if (m.getName().equals(method) && m.getParameterCount() == args.length) {
+                try {
+                    return m.invoke(host, args);
+                } catch (Exception e) {
+                    fail("契约方法 " + HOST_CLASS + "#" + method + " 调用失败:" + e.getMessage());
+                    throw new IllegalStateException("unreachable");
+                }
+            }
+        }
+        fail("RED[child.ai.chat] 契约方法 " + HOST_CLASS + "#" + method + " 未实现(适龄过滤未落地)");
+        throw new IllegalStateException("unreachable");
+    }
+
+    private Integer callInt(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Number) {
+            return ((Number) r).intValue();
+        }
+        fail("契约方法 " + method + " 应返回整数,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private boolean callBoolean(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Boolean) {
+            return (Boolean) r;
+        }
+        fail("契约方法 " + method + " 应返回布尔,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private String callString(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof String) {
+            return (String) r;
+        }
+        fail("契约方法 " + method + " 应返回字符串,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> callMap(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Map) {
+            return (Map<String, Object>) r;
+        }
+        fail("契约方法 " + method + " 应返回 Map,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 是否存在指定类中 指定方法名 + 参数个数 的方法。 */
+    private boolean hasMethod(String className, String methodName, int paramCount) {
+        Class<?> clazz = loadClass(className);
+        if (clazz == null) {
+            return false;
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(methodName) && m.getParameterCount() == paramCount) {
+                return true;
+            }
+        }
+        return false;
+    }
+}