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

Merge origin/cfclub into cfclub (S-cfc-211 child.auth.login RED + 同批 child.*/mind RED)

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

+ 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;
+    }
+}

+ 187 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildEnergyViewContractTest.java

@@ -0,0 +1,187 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.DanAssessmentController;
+import com.etotem.cfc.controller.EnergyController;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.entity.EnergyLog;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.AssessmentService;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.ReportMemberPermissionService;
+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.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 能力键 {@code child.energy.view}(任务 S-cfc-215,TDD-RED 阶段)契约级单测。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-06(查看能量与报告,只读)+
+ *       SC-C-07(查看 DAN 测评报告)——{@code docs/tdd/orchestration/sc-key-map.md} 中
+ *       S-cfc-215 为 1:N,同时覆盖 SC-C-06 与 SC-C-07;能力键 {@code child.energy.view}。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.10(测评)+ 能力键映射
+ *       {@code docs/superpowers/specs/2026-11-07-child-zone-contract.md} §4(SC-C-06 标注
+ *       「五维能量只读(需补成员级可见性)」)。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(TDD-RED 锚点)</b></p>
+ * <ul>
+ *   <li><b>SC-C-06</b>:{@link EnergyController} {@code /api/energy/overview} 与
+ *       {@code /api/energy/logs} 必须<b>只读</b>且<b>越权拦截</b>——请求查看<b>非本家庭</b>
+ *       成员的成员级能量/流水时,必须拒绝(越权 403,看不到其他成员隐私),
+ *       而非直接返回目标成员数据。</li>
+ *   <li><b>SC-C-07</b>:{@link DanAssessmentController} {@code /api/dan-assessment/result/{id}}
+ *       对<b>结果未录入</b>({@code status=draft},上传后待确认/未完成)的报告必须
+ *       <b>不展示</b>(返回 {@code data=null}),而非把半成品记录返回给前端。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(本分支 src/main 核实,2026-11-13)</b></p>
+ * <ul>
+ *   <li>{@code EnergyController.getEnergyOverview / getEnergyLogs}:仅按请求参数
+ *       {@code memberId} 直接调 {@code energyService.getOverview/getLogs},<b>无任何成员级
+ *       可见性/越权校验</b>——跨家庭成员访问不会被拦截(越权拦截缺失)。</li>
+ *   <li>{@code DanAssessmentController.getResult}:{@code result != null} 即
+ *       {@code return Result.success(result)},<b>未按 {@code status} 过滤未录入(draft)</b>
+ *       报告(结果未录入不展示缺失)。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,仅 Mockito(无 Spring 上下文 / 无 DB / 无真实微信,
+ * 无外部服务依赖)。直接实例化 Controller 并注入 mock 依赖,断言可观察的契约行为。</p>
+ *
+ * <p><b>RED 预期</b>:上述两处契约缺口未落地——跨家庭越权用例实际返回 200(成功),
+ * 未录入(draft)用例实际返回半成品 data,全部断言失败(红),失败原文为
+ * 「RED[child.energy.view] …」。GREEN 阶段实现越权拦截与 draft 过滤后自动转绿。</p>
+ *
+ * <p><b>权限流</b>:本能力权限流即核心契约——SC-C-06 的「越权拦截」正是权限维度,
+ * 无「权限流不适用」情况。</p>
+ */
+class ChildEnergyViewContractTest {
+
+    // ==================== 契约 fixture ====================
+
+    /** 调用方所属家庭。 */
+    private static final Long CALLER_FAMILY_ID = 100L;
+    /** 调用方当前成员(自身)。 */
+    private static final Long CALLER_MEMBER_ID = 5L;
+    /** 登录账号 userId(JWT 身份)。 */
+    private static final Long USER_ID = 88L;
+    /** 越权目标:不属于调用方家庭的另一成员。 */
+    private static final Long FOREIGN_MEMBER_ID = 999L;
+    /** DAN 报告结果 id。 */
+    private static final Long RESULT_ID = 101L;
+
+    @Mock
+    private EnergyService energyService;
+
+    @Mock
+    private AssessmentService assessmentService;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private ReportMemberPermissionService reportMemberPermissionService;
+
+    @InjectMocks
+    private EnergyController energyController;
+
+    @InjectMocks
+    private DanAssessmentController danAssessmentController;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================== SC-C-06:EnergyController /overview /logs 只读·越权拦截 ====================
+
+    @Test
+    @DisplayName("SC-C-06:/api/energy/overview 对跨家庭成员访问应越权拦截(403),不返回他人能量")
+    void energyOverview_跨家庭查看他人能量_应越权拦截403() {
+        // 契约现场:调用方 familyId=100 请求 memberId=999(不在本家庭)——必须看不到其他成员隐私
+        Map<String, Object> foreignData = new HashMap<>();
+        foreignData.put("memberId", FOREIGN_MEMBER_ID);
+        foreignData.put("overallScore", 95);
+        when(energyService.getOverview(FOREIGN_MEMBER_ID)).thenReturn(foreignData);
+
+        Result<Map<String, Object>> r = energyController.getEnergyOverview(
+                params(FOREIGN_MEMBER_ID), CALLER_FAMILY_ID, CALLER_MEMBER_ID);
+
+        // 契约:越权拦截(403 forbidden),而非成功返回他人能量数据
+        assertEquals(Integer.valueOf(403), r.getCode(),
+                "RED[child.energy.view] SC-C-06:/api/energy/overview 对跨家庭成员访问应越权拦截(403),"
+                        + "实际 code=" + r.getCode() + " message=" + r.getMessage());
+        // 只读:查看过程不得触发能量发放/扣除等写副作用
+        verify(energyService, never()).grantEnergy(anyLong(), anyLong(), anyLong(), any());
+        verify(energyService, never()).deductForWish(anyLong(), anyLong(), anyLong());
+    }
+
+    @Test
+    @DisplayName("SC-C-06:/api/energy/logs 对跨家庭成员访问应越权拦截(403),不返回他人流水")
+    void energyLogs_跨家庭查看他人流水_应越权拦截403() {
+        // 契约现场:调用方 familyId=100 请求 memberId=999(不在本家庭)——必须看不到其他成员隐私
+        Page<EnergyLog> foreignLogs = new Page<>(1, 10);
+        when(energyService.getLogs(FOREIGN_MEMBER_ID, null, 1, 10)).thenReturn(foreignLogs);
+
+        Result<Page<EnergyLog>> r = energyController.getEnergyLogs(
+                params(FOREIGN_MEMBER_ID), CALLER_FAMILY_ID, CALLER_MEMBER_ID);
+
+        // 契约:越权拦截(403 forbidden),而非成功返回他人能量流水
+        assertEquals(Integer.valueOf(403), r.getCode(),
+                "RED[child.energy.view] SC-C-06:/api/energy/logs 对跨家庭成员访问应越权拦截(403),"
+                        + "实际 code=" + r.getCode() + " message=" + r.getMessage());
+        // 只读:查看过程不得触发能量发放/扣除等写副作用
+        verify(energyService, never()).grantEnergy(anyLong(), anyLong(), anyLong(), any());
+        verify(energyService, never()).deductForWish(anyLong(), anyLong(), anyLong());
+    }
+
+    // ==================== SC-C-07:DanAssessmentController /result/{id} 结果未录入不展示 ====================
+
+    @Test
+    @DisplayName("SC-C-07:/api/dan-assessment/result/{id} 对 status=draft(结果未录入)应不展示(data=null)")
+    void danResult_未录入draft_不应展示() {
+        // 契约现场:报告已上传但结果未录入(status=draft,待确认半成品)——必须不展示
+        DanAssessmentResult draft = new DanAssessmentResult();
+        draft.setId(RESULT_ID);
+        draft.setMemberId(CALLER_MEMBER_ID);
+        draft.setStatus("draft"); // 未录入/待确认
+        when(assessmentService.getByAppointmentId(RESULT_ID)).thenReturn(draft);
+        // 权限分支:本人可见(避免因权限拒绝而非「不展示」失败)
+        when(reportMemberPermissionService.canViewMemberReports(
+                USER_ID, CALLER_MEMBER_ID, CALLER_FAMILY_ID)).thenReturn(true);
+
+        Result<DanAssessmentResult> r = danAssessmentController.getResult(
+                RESULT_ID, USER_ID, CALLER_FAMILY_ID);
+
+        // 契约:结果未录入 → 不展示(data=null),而非把 draft 半成品返回给前端
+        assertNull(r.getData(),
+                "RED[child.energy.view] SC-C-07:/api/dan-assessment/result/{id} 对 status=draft(结果未录入)"
+                        + "的报告应不展示(data=null),实际 code=" + r.getCode()
+                        + " data.status=" + (r.getData() == null ? null : r.getData().getStatus()));
+    }
+
+    // ==================== 辅助 ====================
+
+    private Map<String, Object> params(Long memberId) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("memberId", memberId);
+        return m;
+    }
+}

+ 215 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildGameReportScoreContractTest.java

@@ -0,0 +1,215 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.MiniGame;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MiniGameMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.service.ContentDimensionThresholdService;
+import com.etotem.cfc.service.FiveDimensionScoreService;
+import com.etotem.cfc.service.GameRecordService;
+import com.etotem.cfc.service.MiniGameService;
+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.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:小游戏成绩上报(能力键 {@code child.game.reportScore})。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-03「小游戏成绩上报」——
+ *       流程:完游戏 → 上报分数 → 关联能量/任务;<b>边界:异常分数拦截</b>;SC-C-02「执行任务/小游戏得积分」——
+ *       <b>边界:成绩上报幂等</b>。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.61.3——{@code POST /api/mini-game/complete}
+ *       ({@code CompleteGameDTO{memberId, gameCode, completionTime, score}} → {@code {pointsEarned, ...}})
+ *       成绩上报:加积分 + 写流水 + 写 {@code game_record} + 能量;G-09 缺口:
+ *       <b>{@code /api/mini-game/complete} 无幂等、{@code score} 无上限({@code score=999999}
+ *       可换巨额积分并污染 Redis 排行榜)</b>。</li>
+ * </ul>
+ *
+ * <p><b>被校验契约行为(GREEN 目标)</b></p>
+ * <ul>
+ *   <li><b>正常流</b>:合法 {@code score}(0–100)→ 加积分 + 写积分流水 + 写 {@code game_record}
+ *       + 返回 {@code pointsEarned}。</li>
+ *   <li><b>边界流-异常分数拦截(SC-C-03)</b>:{@code score} 越界({@code >100} 如 {@code 999999}、
+ *       {@code <0} 如 {@code -5})→ 拒绝发分(拦截),防止巨额积分与 Redis 排行榜污染(G-09)。</li>
+ *   <li><b>边界流-积分不膨胀(G-09)</b>:{@code score=999999} 时 {@code pointsEarned} 不得超过
+ *       游戏合理上限(基础分 ×2,满分 100 分线性折算上界)。</li>
+ *   <li><b>边界流-成绩上报幂等(SC-C-02)</b>:同一局重复上报 → 不重复发分 / 不重复写流水(幂等)。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:{@code MiniGameService.completeGame} 当前已落地正常流(加积分/写流水/写记录),
+ * 但 <b>异常分数拦截(score 越界)与上报幂等均未实现</b>(G-09 缺口),故边界流用例在实现补齐前
+ * 应失败(红);正常流为已实现契约锚点(绿),用于区分「实现存在但契约缺口行为未落地」而非全绿。</p>
+ *
+ * <p><b>测试范围</b>:{@link MiniGameService#completeGame(Long, String, Integer, Integer)} 契约行为。
+ * 纯 Mockito(无 Spring / 无 DB / 无 Redis / 无外部服务),断言不依赖真实基础设施。</p>
+ */
+class ChildGameReportScoreContractTest {
+
+    /** 契约合法分数区间上界(文档:score 0-100) */
+    private static final int SCORE_MAX = 100;
+    /** 契约合法分数区间下界 */
+    private static final int SCORE_MIN = 0;
+    /** 游戏基础分(game.defaultPoints) */
+    private static final int BASE_POINTS = 5;
+    /** 契约:满分 100 分线性折算上界 = 基础分 ×2 */
+    private static final int REASONABLE_POINTS_CAP = BASE_POINTS * 2;
+
+    private static final Long MEMBER_ID = 100L;
+    private static final String GAME_CODE = "contract-test";
+    private static final String GAME_NAME = "契约测试游戏";
+
+    @Mock
+    private MiniGameMapper miniGameMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private PointsLogMapper pointsLogMapper;
+
+    @Mock
+    private GameRecordService gameRecordService;
+
+    @Mock
+    private FiveDimensionScoreService fiveDimensionScoreService;
+
+    @Mock
+    private ContentDimensionThresholdService contentDimensionThresholdService;
+
+    @InjectMocks
+    private MiniGameService service;
+
+    private MiniGame game;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        game = new MiniGame();
+        game.setId(1L);
+        game.setGameCode(GAME_CODE);
+        game.setGameName(GAME_NAME);
+        game.setDefaultPoints(BASE_POINTS);
+        game.setStatus(1);
+        when(miniGameMapper.selectOne(any())).thenReturn(game);
+    }
+
+    private FamilyMember child(int totalPoints) {
+        FamilyMember child = new FamilyMember();
+        child.setId(MEMBER_ID);
+        child.setTotalPoints(totalPoints);
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(child);
+        return child;
+    }
+
+    // ==================== 正常流(SC-C-03 已实现契约锚点) ====================
+
+    @Test
+    @DisplayName("正常流-1:合法分数上报 → 加积分 + 写流水 + 写记录 + 返回积分")
+    void 合法分数_加积分写流水写记录返回积分() {
+        child(100);
+        // score=80 → 5 + (80-60)/40*5 = 7(默认分支无时间加成)
+        Map<String, Object> result = service.completeGame(MEMBER_ID, GAME_CODE, null, 80);
+
+        assertNotNull(result, "[child.game.reportScore] 成绩上报应返回结果 Map(含 pointsEarned)。");
+        assertEquals(7, result.get("pointsEarned"),
+                "[child.game.reportScore] score=80 应折算积分 7(基础分 5 + 线性 2)。");
+        assertEquals(107, result.get("newBalance"),
+                "[child.game.reportScore] 上报后成员总积分应累加(100 → 107)。");
+        verify(familyMemberMapper).updateById(any(FamilyMember.class));
+        verify(pointsLogMapper).insert(any(PointsLog.class));
+        verify(gameRecordService).saveRecord(eq(MEMBER_ID), eq(GAME_CODE), isNull(), eq(80), eq("medium"), eq(7));
+    }
+
+    // ==================== 边界流-合法区间端点(契约允许,应接受) ====================
+
+    @Test
+    @DisplayName("边界-1:score=100(合法上界)→ 接受且积分不超上限")
+    void 分数恰好100_接受且积分不超上限() {
+        child(0);
+        Map<String, Object> result = service.completeGame(MEMBER_ID, GAME_CODE, null, SCORE_MAX);
+
+        assertNotNull(result);
+        int earned = ((Number) result.get("pointsEarned")).intValue();
+        assertTrue(earned <= REASONABLE_POINTS_CAP,
+                "[child.game.reportScore] score=100(满分)积分不应超过基础分×2(" + REASONABLE_POINTS_CAP + ")。");
+    }
+
+    @Test
+    @DisplayName("边界-2:score=0(合法下界)→ 接受(保底 1 分)")
+    void 分数恰好0_接受() {
+        child(0);
+        Map<String, Object> result = service.completeGame(MEMBER_ID, GAME_CODE, null, SCORE_MIN);
+        assertNotNull(result);
+    }
+
+    // ==================== 边界流-异常分数拦截(SC-C-03,G-09 未落地 → 红) ====================
+
+    @Test
+    @DisplayName("边界流-3:score 超过 100 → 应拒绝发分(异常分数拦截)")
+    void 分数超上限_应拒绝发分() {
+        child(0);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.completeGame(MEMBER_ID, GAME_CODE, null, 999999),
+                "[child.game.reportScore] score=999999 超过合法上限,契约要求拦截(异常分数,SC-C-03 / G-09)。");
+        assertTrue(ex.getMessage() != null && ex.getMessage().contains("分数"),
+                "[child.game.reportScore] 拒绝消息应指明分数不合法(G-09:score 无上限可换巨额积分)。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:score 为负 → 应拒绝发分(异常分数拦截)")
+    void 分数为负_应拒绝发分() {
+        child(0);
+        assertThrows(RuntimeException.class,
+                () -> service.completeGame(MEMBER_ID, GAME_CODE, null, -5),
+                "[child.game.reportScore] score=-5 为负数,契约要求拦截(异常分数,SC-C-03)。");
+    }
+
+    // ==================== 边界流-积分不膨胀(G-09 → 红) ====================
+
+    @Test
+    @DisplayName("边界流-5:score=999999 积分不得膨胀污染排行榜(G-09)")
+    void 巨额分数_积分不膨胀() {
+        child(0);
+        Map<String, Object> result = service.completeGame(MEMBER_ID, GAME_CODE, null, 999999);
+
+        int earned = ((Number) result.get("pointsEarned")).intValue();
+        assertTrue(earned <= REASONABLE_POINTS_CAP,
+                "[child.game.reportScore] score=999999 折算积分(实际 " + earned
+                        + ")不得超过游戏合理上限(基础分×2=" + REASONABLE_POINTS_CAP
+                        + "),否则巨额积分污染 Redis 排行榜(G-09)。");
+    }
+
+    // ==================== 边界流-成绩上报幂等(SC-C-02,G-09 未落地 → 红) ====================
+
+    @Test
+    @DisplayName("边界流-6:同一局重复上报 → 幂等,不重复发分/不重复写流水")
+    void 重复上报同一局_幂等不重复发分() {
+        child(100);
+        service.completeGame(MEMBER_ID, GAME_CODE, null, 80);
+        service.completeGame(MEMBER_ID, GAME_CODE, null, 80);
+
+        verify(pointsLogMapper, times(1)).insert(any(PointsLog.class));
+        verify(gameRecordService, times(1)).saveRecord(
+                eq(MEMBER_ID), eq(GAME_CODE), isNull(), eq(80), eq("medium"), eq(7));
+    }
+}

+ 319 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/MindModelManageContractTest.java

@@ -0,0 +1,319 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:舌诊/情绪模型管理(能力键 {@code mind.model.manage},单元 mind)。
+ *
+ * <p><b>需求/设计锚点(SC-A-21)</b></p>
+ * <blockquote>
+ *   SC-A-21 · 舌诊/情绪模型管理 —— 目标:模型版本/开关。流程:版控/上下线。
+ *   边界:下线前回退方案。模块:模型管理。
+ * </blockquote>
+ * <p>用户故事:作为运营,我想管理舌诊/情绪模型版本,以便保障识别质量。</p>
+ *
+ * <p><b>契约 AC 条款(从 SC-A-21 提取,TDD-RED 锚点)</b></p>
+ * <ul>
+ *   <li><b>AC-1 模型版本/开关</b>:模型管理应提供<b>版本登记</b> + <b>上下线开关</b>的数据载体
+ *       —— 承载 {@code modelType}(emotion|tongue)、{@code version}、{@code enabled} 的模型实体/表。</li>
+ *   <li><b>AC-2 版控/上下线</b>:应提供<b>上线/下线</b>服务入口({@code online}/{@code offline})
+ *       与<b>版本列表</b>查询({@code listAll}),支撑运营版控。</li>
+ *   <li><b>AC-3 下线前回退方案(边界)</b>:下线必须<b>指定回退版本</b>({@code fallbackVersion}),
+ *       未指定回退方案时不得下线。</li>
+ *   <li><b>AC-4 非法版本 400(边界)</b>:对<b>非法/未知版本</b>的操作应返回 {@code Result} code 400。</li>
+ * </ul>
+ *
+ * <p><b>设计载体(API_REFERENCE §4.37/§4.14/§4.13 锚点)</b>:现有三个模型载体均已存在,但均
+ * <b>无模型版本/上下线管理</b>:
+ * <ul>
+ *   <li>{@code EmotionRecognitionController}(/api/mind/emotion:analyze/analyze-url)—— 情绪模型,无版本/开关</li>
+ *   <li>{@code TraditionalMirrorController}(/api/mind/traditional:mirror/family-dashboard/compatibility)—— 舌诊(传统)模型,无版本/开关</li>
+ *   <li>{@code InnatePortraitConfigController}(/api/admin/innate-portrait-config:list/update/invalidate)—— 先天画像来源权重,仅 weight/enabled/aiEnabled,无版本/上下线</li>
+ * </ul></p>
+ *
+ * <p><b>实现现状</b>:本分支 src/main 中 {@code MindModel} 实体、{@code MindModelManageService}
+ * 服务、{@code MindModelManageController} 控制器、{@code mind_model} 表均<b>未落地</b>
+ * (全库 0 引用,schema.sql 无对应建表)。因此本测试以「反射结构契约」探测实现缺失:
+ * 能编译通过、能运行、断言失败(红),且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者创建 {@code MindModel} 实体(含 modelType/version/enabled/fallbackVersion)、
+ * {@code MindModelManageService}(含 listAll/online/offline/isEnabled,offline 三参含 fallbackVersion、
+ * 非法版本返回 Result 400)、建 {@code mind_model} 表与 {@code MindModelManageController}
+ * (/api/admin/mind-model:list/online/offline)后,本测试自动转绿。</p>
+ *
+ * <p><b>权限流说明</b>:本能力为平台运营内部模型管理(版本/开关/回退),无用户权限维度,
+ * 「权限(适用时)」三流中的权限流不适用,以说明性用例标注。</p>
+ */
+class MindModelManageContractTest {
+
+    /** 契约宿主服务类(未实现,GREEN 后落地)。 */
+    private static final String HOST_SERVICE = "com.etotem.cfc.service.MindModelManageService";
+
+    /** 契约模型实体类(未实现,GREEN 后落地)。 */
+    private static final String HOST_ENTITY = "com.etotem.cfc.entity.MindModel";
+
+    /** 契约管理控制器类(未实现,GREEN 后落地)。 */
+    private static final String HOST_CONTROLLER = "com.etotem.cfc.controller.admin.MindModelManageController";
+
+    /** 契约承载表(AC-1/AC-3 数据载体)。 */
+    private static final String MODEL_TABLE = "mind_model";
+
+    /** AC-1 模型类型字段(emotion|tongue)。 */
+    private static final String FIELD_MODEL_TYPE = "modelType";
+
+    /** AC-1 模型版本字段。 */
+    private static final String FIELD_VERSION = "version";
+
+    /** AC-1 上下线开关字段。 */
+    private static final String FIELD_ENABLED = "enabled";
+
+    /** AC-3 下线前回退版本字段。 */
+    private static final String FIELD_FALLBACK = "fallbackVersion";
+
+    // ------------------------------------------------------------------
+    // 正常流-AC1:模型版本/开关的数据载体(MindModel 实体 + mind_model 表)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-AC1-1:MindModel 实体应存在(模型版本/开关数据载体)")
+    void mindModel_entityExists() {
+        assertNotNull(loadClass(HOST_ENTITY),
+                "[mind.model.manage] MindModel 实体不存在:契约 AC-1 要求模型版本/开关的数据载体"
+                        + "(modelType/version/enabled),实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-AC1-2:MindModel 实体应含 modelType/version/enabled 字段")
+    void mindModel_entityCarriesVersionAndSwitch() {
+        Class<?> clazz = loadClass(HOST_ENTITY);
+        if (clazz == null) {
+            fail("[mind.model.manage] MindModel 实体不存在,无法验证字段(AC-1)。");
+        }
+        assertField(clazz, FIELD_MODEL_TYPE, "MindModel 应含 modelType(emotion|tongue)字段(AC-1)。");
+        assertField(clazz, FIELD_VERSION, "MindModel 应含 version(模型版本)字段(AC-1)。");
+        assertField(clazz, FIELD_ENABLED, "MindModel 应含 enabled(上下线开关)字段(AC-1)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流-AC2:版控/上下线服务入口(listAll/online/offline)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-AC2-1:MindModelManageService 服务类应存在")
+    void modelManageService_classExists() {
+        assertNotNull(loadClass(HOST_SERVICE),
+                "[mind.model.manage] MindModelManageService 类不存在:契约 AC-2 要求版控/上下线服务入口"
+                        + "(listAll/online/offline),实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-AC2-2:MindModelManageService 应提供 listAll/online/offline/isEnabled 方法")
+    void modelManageService_hasVersionControlMethods() {
+        Class<?> clazz = loadClass(HOST_SERVICE);
+        if (clazz == null) {
+            fail("[mind.model.manage] MindModelManageService 类不存在,无法验证版控方法(AC-2)。");
+        }
+        assertMethod(clazz, "listAll", "MindModelManageService 应提供 listAll() 版本列表(版控,AC-2)。");
+        assertMethod(clazz, "online", "MindModelManageService 应提供 online(...) 上线入口(AC-2)。");
+        assertMethod(clazz, "offline", "MindModelManageService 应提供 offline(...) 下线入口(AC-2)。");
+        assertMethod(clazz, "isEnabled", "MindModelManageService 应提供 isEnabled(...) 开关查询(AC-1/AC-2)。");
+    }
+
+    @Test
+    @DisplayName("正常流-AC2-3:MindModelManageController 应存在(模型管理 admin 端点)")
+    void modelManageController_exists() {
+        Class<?> clazz = loadClass(HOST_CONTROLLER);
+        if (clazz == null) {
+            fail("[mind.model.manage] MindModelManageController 不存在:契约 AC-2 要求模型管理 admin 端点"
+                    + "(/api/admin/mind-model:list/online/offline),实现未落地。");
+        }
+        assertMethod(clazz, "list", "MindModelManageController 应提供 list()(版控列表,AC-2)。");
+        assertMethod(clazz, "online", "MindModelManageController 应提供 online(...)(上线,AC-2)。");
+        assertMethod(clazz, "offline", "MindModelManageController 应提供 offline(...)(下线,AC-2)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流-AC3:下线前需回退方案(fallbackVersion 必填)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-AC3-1:offline 应接受回退版本参数(三参签名)")
+    void offline_requiresFallbackVersionParam() {
+        Class<?> clazz = loadClass(HOST_SERVICE);
+        if (clazz == null) {
+            fail("[mind.model.manage] MindModelManageService 类不存在,无法验证 offline 签名(AC-3)。");
+        }
+        Method m = findMethod(clazz, "offline");
+        assertNotNull(m, "[mind.model.manage] MindModelManageService 应提供 offline(...)(AC-3)。");
+        assertEquals(3, m.getParameterCount(),
+                "[mind.model.manage] offline(modelType, version, fallbackVersion) 应为三参签名:"
+                        + "下线必须接受回退版本 fallbackVersion(AC-3 下线前回退方案)。");
+    }
+
+    @Test
+    @DisplayName("边界流-AC3-2:MindModel 实体应含 fallbackVersion(承载回退方案)")
+    void mindModel_entityCarriesFallbackVersion() {
+        Class<?> clazz = loadClass(HOST_ENTITY);
+        if (clazz == null) {
+            fail("[mind.model.manage] MindModel 实体不存在,无法验证 fallbackVersion(AC-3)。");
+        }
+        assertField(clazz, FIELD_FALLBACK, "MindModel 应含 fallbackVersion(下线前回退版本)字段(AC-3)。");
+    }
+
+    @Test
+    @DisplayName("边界流-AC3-3:未指定回退方案时下线应被拒(Result 400)")
+    void offline_withoutFallback_isRejected() {
+        Object host = contractHost();
+        Object rejected = invoke(host, "offline",
+                new Class<?>[]{String.class, String.class, String.class},
+                "emotion", "v1", null);
+        Integer code = readResultCode(rejected);
+        assertEquals(400, code,
+                "[mind.model.manage] offline 未指定 fallbackVersion 应被拒(Result code 400):"
+                        + "下线前需回退方案(AC-3 边界)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流-AC4:非法版本 400
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-AC4-1:对非法/未知版本操作应返回 Result 400")
+    void illegalVersion_returns400() {
+        Object host = contractHost();
+        Object result = invoke(host, "online",
+                new Class<?>[]{String.class, String.class},
+                "emotion", "v999999");
+        Integer code = readResultCode(result);
+        assertEquals(400, code,
+                "[mind.model.manage] 对非法/未知版本(v999999)执行上线应返回 Result code 400(AC-4 边界)。");
+    }
+
+    @Test
+    @DisplayName("边界流-AC4-2:mind_model 表应落地 schema.sql(承载模型版本/开关)")
+    void schemaSql_containsMindModelTable() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(MODEL_TABLE),
+                "[mind.model.manage] schema.sql 缺少 " + MODEL_TABLE + " 建表:契约 AC-1 要求模型版本/开关"
+                        + "数据载体(modelType/version/enabled/fallbackVersion),实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):平台运营内部模型管理,无用户权限维度
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:本能力为平台运营内部模型管理,无用户权限维度,权限流不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:契约 AC 为「运营版控/上下线/回退方案」内部管理链路,无用户权限校验维度。
+        // 不产生断言失败;仅为三流覆盖标注「不适用」。
+        assertTrue(true, "权限流不适用:mind.model.manage 为平台运营内部模型管理,无用户权限维度。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    /** 获取契约宿主服务实例(GREEN 后由 Spring 容器/直接实例化提供;RED 期类缺失即失败)。 */
+    private Object contractHost() {
+        Class<?> clazz = loadClass(HOST_SERVICE);
+        if (clazz == null) {
+            fail("[mind.model.manage] 契约宿主 MindModelManageService 未实现,无法验证行为级契约(AC-2/AC-3/AC-4)。");
+            return null;
+        }
+        try {
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (Exception e) {
+            // 宿主存在但无法无参实例化(如构造注入),退化为结构探测,行为用例标记跳过语义。
+            fail("[mind.model.manage] 契约宿主 MindModelManageService 无法实例化:" + e.getMessage()
+                    + "(GREEN 期应提供无参构造或以 mock 方式装配)。");
+            return null;
+        }
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private void assertField(Class<?> clazz, String fieldName, String message) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(fieldName)) {
+                return;
+            }
+        }
+        fail("[mind.model.manage] " + message);
+    }
+
+    private void assertMethod(Class<?> clazz, String methodName, String message) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(methodName)) {
+                return;
+            }
+        }
+        fail("[mind.model.manage] " + message);
+    }
+
+    private Method findMethod(Class<?> clazz, String methodName) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(methodName)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private Object invoke(Object target, String methodName, Class<?>[] paramTypes, Object... args) {
+        try {
+            Method m = target.getClass().getMethod(methodName, paramTypes);
+            return m.invoke(target, args);
+        } catch (Exception e) {
+            String cause = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
+            fail("[mind.model.manage] 反射调用 " + methodName + " 失败:" + cause);
+            return null;
+        }
+    }
+
+    /** 反射读取 Result 的 code(getCode() 或 code 字段),契约宿主返回 Result 对象。 */
+    private Integer readResultCode(Object result) {
+        if (result == null) {
+            return null;
+        }
+        try {
+            Method getCode = result.getClass().getMethod("getCode");
+            Object code = getCode.invoke(result);
+            return code instanceof Integer ? (Integer) code : code instanceof Number ? ((Number) code).intValue() : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    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 "";
+        }
+    }
+}

+ 326 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskCheckinChildContractTest.java

@@ -0,0 +1,326 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.MiniGame;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MiniGameMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.GameRecordService;
+import com.etotem.cfc.service.MiniGameService;
+import com.etotem.cfc.service.TaskService;
+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 java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+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.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:孩子端任务打卡(能力键 {@code child.task.checkin},TDD-RED 阶段)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} § 四——SC-C-02(执行任务/小游戏得积分)、
+ *       SC-C-05(任务打卡⭐)、SC-C-08(方案打卡进度)、SC-C-14(任务驳回重做);</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.6(成长档案 {@code /api/growth/*});
+ *       {@code TaskController}(完成/审核/驳回)+ {@code GrowthTaskController}(打卡提交/进度查询/驳回重做)
+ *       + {@code GrowthPlanController} {@code /api/growth/plan/child/{id}/active};</li>
+ *   <li>契约核对:{@code docs/superpowers/specs/2026-11-07-child-zone-contract.md}
+ *       §2(SC-C-05/C-08/C-14 端点核验)+ §5 G-09(小游戏无幂等/无分数上限);</li>
+ *   <li>风格:与已合入主链的同批 RED 契约测试({@code TeamProfitHookContractTest} /
+ *       {@code FamilyJoinValidationRulesContractTest})一致——纯 Mockito + 反射,无 Spring / 无 DB / 无外部服务。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(本分支 src/main 实测)</b>:</p>
+ * <ul>
+ *   <li><b>打卡提交(SC-C-05)</b>:{@code TaskController} {@code POST /api/tasks/{id}/complete} 已存在(含 T24-A 幂等);
+ *       但设计锚点 {@code GrowthTaskController} 无任何 {@code checkin} 提交端点(仅 {@code /list /claim /accept-dynamic})——<b>缺口</b>;</li>
+ *   <li><b>方案打卡进度(SC-C-08)</b>:{@code GrowthPlanController} 无 {@code progress} 查询端点(仅 create/child/active/detail/update/review/list)——<b>缺口</b>;</li>
+ *   <li><b>驳回重做(SC-C-14)</b>:{@code TaskService.reviewTask(approved=false)} 已把 pending_review 回 pending、清 completedAt,可重做(已落地);</li>
+ *   <li><b>小游戏分数上限(SC-C-02,G-09)</b>:{@code MiniGameService.calculatePoints} 对 {@code score>100} 无上限,
+ *       {@code score=999999} 可换巨额积分——<b>缺口</b>。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:缺口项(打卡提交端点 / 方案进度端点 / 进度契约 VO / 小游戏分数上限)在实现补齐前应失败(红);
+ * 已落地项(重复打卡幂等 / 驳回重做 / 活跃方案锚点)实测为绿,作「已落地」证据上报,不私改实现。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者补齐 ①{@code GrowthTaskController} 增加 {@code @PostMapping("/checkin")} 提交端点;
+ * ②{@code GrowthPlanController} 增加 {@code @PostMapping("/child/{memberId}/progress")} 进度端点(返回含
+ * {@code completedTasks/totalTasks/progressPercent} 的 VO);③小游戏 {@code calculatePoints} 对 {@code score}
+ * 限 {@code 0~100}(或按游戏配置),且 {@code completeGame} 增加 {@code (memberId,gameCode,date)} 判重幂等;
+ * 本契约测试自动转绿。</p>
+ *
+ * <p><b>权限流说明</b>:打卡归属以「成员匹配」维度在 {@code completeTask} 内体现({@code familyMemberId == memberId});
+ * 跨家庭归属校验(契约核对 G-10/G-11)为已知跨切面缺口,由编排侧登记,不在本测试内新造断言。</p>
+ */
+class TaskCheckinChildContractTest {
+
+    /** 设计锚点:打卡提交/进度查询承载类 */
+    private static final String GROWTH_TASK_CONTROLLER = "com.etotem.cfc.controller.GrowthTaskController";
+    private static final String GROWTH_PLAN_CONTROLLER = "com.etotem.cfc.controller.growth.GrowthPlanController";
+
+    /** 契约要求的打卡提交 DTO(SC-C-05 打卡提交入参) */
+    private static final String TASK_CHECKIN_DTO = "com.etotem.cfc.dto.TaskCheckinDTO";
+
+    /** 契约要求的方案进度 VO(SC-C-08 进度查询出参) */
+    private static final String PLAN_PROGRESS_VO = "com.etotem.cfc.dto.GrowthPlanProgressVO";
+
+    // ==================== 正常流 · 打卡提交(SC-C-05) ====================
+
+    @Test
+    @DisplayName("SC-C-05 正常流-1:GrowthTaskController 应暴露「打卡提交」端点")
+    void c05_打卡提交端点应存在() {
+        Class<?> controller = loadClass(GROWTH_TASK_CONTROLLER);
+        assertNotNull(controller,
+                "[child.task.checkin] GrowthTaskController 类不存在,无法承载方案任务打卡提交(API_REFERENCE §4.6)。");
+        boolean hasCheckinEndpoint = false;
+        for (Method m : controller.getDeclaredMethods()) {
+            if (postMappingPathContains(m, "checkin")) {
+                hasCheckinEndpoint = true;
+                break;
+            }
+        }
+        assertTrue(hasCheckinEndpoint,
+                "[child.task.checkin] GrowthTaskController 应暴露 @PostMapping 打卡提交端点(path 含 \"checkin\")。"
+                        + " 当前仅 /list /claim /accept-dynamic,无打卡提交入口(SC-C-05 任务打卡⭐,设计锚点未落地)。");
+    }
+
+    @Test
+    @DisplayName("SC-C-05 正常流-2:应存在打卡提交契约 DTO(memberId/taskId/photoUrl/content)")
+    void c05_打卡提交契约DTO应存在() {
+        Class<?> dto = loadClass(TASK_CHECKIN_DTO);
+        if (dto == null) {
+            fail("[child.task.checkin] 契约 DTO " + TASK_CHECKIN_DTO + " 不存在:"
+                    + "打卡提交入参未契约化(SC-C-05)。");
+        }
+        assertTrue(hasField(dto, "memberId"),
+                "[child.task.checkin] TaskCheckinDTO 应含 memberId(打卡归属成员,SC-C-05)。");
+        assertTrue(hasField(dto, "taskId"),
+                "[child.task.checkin] TaskCheckinDTO 应含 taskId(被打卡任务,SC-C-05)。");
+        assertTrue(hasField(dto, "photoUrl") || hasField(dto, "content"),
+                "[child.task.checkin] TaskCheckinDTO 应含提交凭证 photoUrl/content(SC-C-05 打卡提交证据)。");
+    }
+
+    // ==================== 正常流 · 方案打卡进度(SC-C-08) ====================
+
+    @Test
+    @DisplayName("SC-C-08 正常流-3:GrowthPlanController 应暴露「方案进度」查询端点")
+    void c08_方案进度端点应存在() {
+        Class<?> controller = loadClass(GROWTH_PLAN_CONTROLLER);
+        assertNotNull(controller,
+                "[child.task.checkin] GrowthPlanController 类不存在,无法承载方案打卡进度(SC-C-08)。");
+        boolean hasProgressEndpoint = false;
+        for (Method m : controller.getDeclaredMethods()) {
+            if (postMappingPathContains(m, "progress")) {
+                hasProgressEndpoint = true;
+                break;
+            }
+        }
+        assertTrue(hasProgressEndpoint,
+                "[child.task.checkin] GrowthPlanController 应暴露 @PostMapping 方案进度端点(path 含 \"progress\")。"
+                        + " 当前无进度查询入口(SC-C-08 方案打卡进度,进度计算未落地)。");
+    }
+
+    @Test
+    @DisplayName("SC-C-08 正常流-4:应存在方案进度契约 VO(completedTasks/totalTasks/progressPercent)")
+    void c08_方案进度契约VO应含完成率() {
+        Class<?> vo = loadClass(PLAN_PROGRESS_VO);
+        if (vo == null) {
+            fail("[child.task.checkin] 契约 VO " + PLAN_PROGRESS_VO + " 不存在:"
+                    + "方案打卡进度出参未契约化(SC-C-08)。");
+        }
+        assertTrue(hasField(vo, "completedTasks"),
+                "[child.task.checkin] GrowthPlanProgressVO 应含 completedTasks(已完成任务数,SC-C-08)。");
+        assertTrue(hasField(vo, "totalTasks"),
+                "[child.task.checkin] GrowthPlanProgressVO 应含 totalTasks(方案任务总数,SC-C-08)。");
+        assertTrue(hasField(vo, "progressPercent"),
+                "[child.task.checkin] GrowthPlanProgressVO 应含 progressPercent(完成率 0-100,SC-C-08 进度计算正确)。");
+    }
+
+    // ==================== 正常流 · 活跃方案锚点(harness 自检,已落地) ====================
+
+    @Test
+    @DisplayName("SC-C-05 锚点:活跃方案入口 /api/growth/plan/child/{id}/active 应存在(已落地)")
+    void c05_活跃方案入口锚点() {
+        Class<?> controller = loadClass(GROWTH_PLAN_CONTROLLER);
+        assertNotNull(controller);
+        boolean hasActiveEndpoint = false;
+        for (Method m : controller.getDeclaredMethods()) {
+            if (postMappingPathContains(m, "active")) {
+                hasActiveEndpoint = true;
+                break;
+            }
+        }
+        assertTrue(hasActiveEndpoint,
+                "[child.task.checkin] 活跃方案锚点 /api/growth/plan/child/{id}/active 应存在(设计锚点,已落地)。");
+    }
+
+    // ==================== 边界流 · 重复打卡幂等(SC-C-02/C-05) ====================
+
+    @Test
+    @DisplayName("SC-C-05 边界流-1:重复打卡(任务已 completed)应幂等、不重复发奖")
+    void c05_重复打卡_幂等不重复发奖() {
+        Task t = new Task();
+        t.setId(10L);
+        t.setStatus("completed");
+        when(taskMapper.selectById(10L)).thenReturn(t);
+        when(familyMemberMapper.selectById(100L)).thenReturn(member(100L, 88));
+
+        Map<String, Object> result = taskService.completeTask(10L, 100L, null, "已完成", "text");
+
+        assertNotNull(result, "重复打卡不应返回 null(T24-A 幂等)。");
+        assertEquals(0, ((Number) result.get("pointsEarned")).intValue(), "重复打卡不重复发奖。");
+        assertEquals(Boolean.TRUE, result.get("alreadyCompleted"), "应返回已完成的幂等信号。");
+    }
+
+    // ==================== 边界流 · 驳回重做(SC-C-14) ====================
+
+    @Test
+    @DisplayName("SC-C-14 边界流-2:驳回 pending_review 后状态回 pending、清 completedAt,可重做")
+    void c14_驳回后_状态回pending_可重做() {
+        Task t = new Task();
+        t.setId(50L);
+        t.setFamilyMemberId(100L);
+        t.setStatus("pending_review");
+        t.setCompletedAt(new java.util.Date());
+        when(taskMapper.selectById(50L)).thenReturn(t);
+
+        boolean ok = taskService.reviewTask(50L, reviewDto(false));
+        assertTrue(ok, "驳回应成功");
+        assertEquals("pending", t.getStatus(), "驳回后应回 pending(可重做再提交)。");
+        assertNull(t.getCompletedAt(), "驳回后应清空 completedAt。");
+        verify(taskMapper).updateById(any(Task.class));
+    }
+
+    // ==================== 边界流 · 小游戏分数上限(SC-C-02 / G-09) ====================
+
+    @Test
+    @DisplayName("SC-C-02 边界流-3:小游戏越界分数(score>100)应被拦截,不得换取超额积分")
+    void c02_小游戏_越界分数应拦截() {
+        MiniGame game = new MiniGame();
+        game.setGameCode("1a2b");
+        game.setGameName("猜数字");
+        game.setDefaultPoints(5);
+        game.setStatus(1);
+        when(miniGameMapper.selectOne(any(Wrapper.class))).thenReturn(game);
+        when(familyMemberMapper.selectById(100L)).thenReturn(member(100L, 50));
+        when(pointsLogMapper.insert(any(PointsLog.class))).thenReturn(1);
+
+        // 越界分数:score=999999(契约:score 应限 0~100 或按游戏配置,G-09)
+        Map<String, Object> result = miniGameService.completeGame(100L, "1a2b", 10, 999999);
+
+        int basePoints = game.getDefaultPoints();
+        int pointsEarned = ((Number) result.get("pointsEarned")).intValue();
+        // 契约边界:60-100 分档最多 2×基础分(+ 游戏特定加成);越界分数不得突破合理上限
+        int contractCap = basePoints * 2 + 3; // 2×base + 1a2b 用时加成上限
+        assertTrue(pointsEarned <= contractCap,
+                "[child.task.checkin] 小游戏越界分数应拦截:score=999999 实得 " + pointsEarned
+                        + " 分,超出契约上限 " + contractCap + "(G-09:score 应限 0~100,无上限为缺口)。");
+    }
+
+    // ==================== 权限流(说明性) ====================
+
+    @Test
+    @DisplayName("权限流:打卡归属以成员匹配维度体现;跨家庭归属校验为已知跨切面缺口(G-10/G-11)")
+    void permissionFlow_memberMatch() {
+        // 说明性用例:打卡归属由 completeTask 内 familyMemberId==memberId 匹配体现;
+        // 跨家庭归属校验(G-10 能量/G-11 成长档案)为契约核对已登记的跨切面缺口,由编排侧转实现,不在此新造断言。
+        assertTrue(true, "打卡归属=成员匹配(completeTask);跨家庭归属校验为 G-10/G-11 已知缺口。");
+    }
+
+    // ==================== 依赖注入与辅助 ====================
+
+    private TaskService taskService;
+    private TaskMapper taskMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private MiniGameService miniGameService;
+    private MiniGameMapper miniGameMapper;
+    private PointsLogMapper pointsLogMapper;
+    private GameRecordService gameRecordService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        taskService = new TaskService();
+        taskMapper = mock(TaskMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        inject(taskService, "taskMapper", taskMapper);
+        inject(taskService, "familyMemberMapper", familyMemberMapper);
+
+        miniGameService = new MiniGameService();
+        miniGameMapper = mock(MiniGameMapper.class);
+        pointsLogMapper = mock(PointsLogMapper.class);
+        gameRecordService = mock(GameRecordService.class);
+        inject(miniGameService, "miniGameMapper", miniGameMapper);
+        inject(miniGameService, "familyMemberMapper", familyMemberMapper);
+        inject(miniGameService, "pointsLogMapper", pointsLogMapper);
+        inject(miniGameService, "gameRecordService", gameRecordService);
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private static FamilyMember member(Long id, int totalPoints) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setTotalPoints(totalPoints);
+        return m;
+    }
+
+    private static com.etotem.cfc.dto.TaskReviewDTO reviewDto(boolean approved) {
+        com.etotem.cfc.dto.TaskReviewDTO dto = new com.etotem.cfc.dto.TaskReviewDTO();
+        dto.setApproved(approved);
+        return dto;
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private boolean hasField(Class<?> clazz, String name) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 反射提取方法上 @PostMapping 的 path,判断是否含指定子串 */
+    private boolean postMappingPathContains(Method m, String fragment) {
+        for (Annotation a : m.getDeclaredAnnotations()) {
+            if (a.annotationType().equals(PostMapping.class)) {
+                PostMapping pm = (PostMapping) a;
+                for (String path : pm.value()) {
+                    if (path != null && path.contains(fragment)) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-07518bfd9763ce1de35b340509eb98571f5d57da
+3fa4eb7dbd527a89ef023374061be66f51a20edd

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

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.2440",
+  "version": "1.0.2441",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.2440",
+      "version": "1.0.2441",
       "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.2441",
+  "version": "1.0.2442",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

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

@@ -4,6 +4,60 @@
 
 ---
 
+## v1.0.2442 (2026-11-13)
+
+### 文档
+- 研发负责人错峰轮汇报 2026-11-13 1607(T8 死锁解锁 8 键 RED→YELLOW + 派发 6 新会话 + 续派观察 3 项)
+
+### 测试
+- 契约级单测 admin.system.monitor 告警管理与限流配置端点缺失 5红 TDD-RED
+- admin.content.security 契约测试 - 实测实现已存在全绿,交编排侧转测试登记
+- admin.badge.manage RED 契约测试(S-cfc-206)
+- S-cfc-207 admin.user.govern 契约测试 9例7红(/users-search/审计留痕/导出脱敏/注销冷静期/GDPR工单未落地)
+- 契约测试 admin.audit.log(S-cfc-204,SC-A-15 操作审计日志)
+
+### 其他
+- S-cfc-205: admin.data.migration contract test (TDD-RED) - 8 red assertions across normal/error/idempotent/permission flows
+- - 新增 AdminContentSecurityContractTest(15 例,纯 Mockito/反射,无 Spring/DB/外部服务)
+- - 覆盖:audit/submit-review/withdraw/re-draft/upload image|video/comments/list 端点家族、
+-   敏感词命中拦截(checkTextSafe 标题/正文命中拒绝发布 + 空白 fail-open)、
+-   ReviewCenter summary 13 类待审聚合(含空队列全 0)、ContentFilter by-tags 空标签边界
+- 实测结论:origin/cfclub 该契约端点与行为已全部落地(契约文档 admin-ops-contract
+- §1.13/§1.18 标记 ✅ 端点存在;B-142-1 /api/admin/ 登录门已收口)。
+- TDD-RED 任务测试意外全绿(15 通过/0 失败)——按任务执行要求第 2 条汇报注明,
+- 交编排侧将 sc-key-map SC-A-13/A-18 由 RED/未跑通转测试登记为已实现+契约测试锁定;
+- 未改动任何 src/main 实现。
+- 
+- - 正常流:BadgeController(11端点)/AdminChallengeScoreConfigController(list/update)/
+-   AdminLeaderboardController(top/search) 端点面反射校验(现状已落地,转绿)
+- - 边界流(RED):已发放勋章不可删(停用代替)——当前 deleteBadge 无条件物理删除
+- - 权限流(RED):/api/badges/create|update|delete 与 challenge-score-config/update
+-   无 role=admin 守卫,非 admin 可越权
+- 纯 Mockito+反射+MockMvc standalone,无 Spring/DB。结果:11 run / 5 fail / 0 error(RED 达标)。
+- 
+- - OperationLogController /api/admin/logs operations+recent
+- - AuditLogService 只增不删改(日志不可篡改,SC-A-15 边界)
+- - AuditLog 实体溯源字段 + AuditLogAspect 自动落审计
+- - audit_log / user_operation_logs 建表落地 schema.sql + DatabaseInitializer 迁移
+- 注:实测 origin/cfclub 已含实现宿主类,测试全绿(10/10),
+- 按编排指令第2条注明实现已存在、转测试登记,未改动 src/main。
+- 
+-   背景图,遵循 mp-weixin 不支持模板内联 <svg> 的既有约定)
+- - 四步闭环改浅底圆钉图标+中性文字(去彩虹/去生硬箭头)
+- - 五维标签带改统一描边容器+发丝分割+深档色记号(非等权重填充 pills)
+- - 品牌区三级层级 + 60-30-10 暖白底 + 单品牌橙强调
+- - 八态按钮(default/hover/active/disabled/loading/error/success)
+- - script 以基线为准逐字节保留(含 $emit('open')/$emit('close')),
+-   仅 data() 追加 errorState/successState 两个八态视觉位(template 已绑定)
+- 
+
+### Bug 修复
+- overlay z-index 回退 99999 恢复全库唯一置顶不变量(P1)
+
+### 新功能
+- 底部滑出登录面板视觉升级
+
+
 ## v1.0.2441 (2026-11-13)
 
 ### 测试

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.2441
+> 当前版本: v1.0.2442
 
 ## 历史版本
 
@@ -8,6 +8,60 @@
 
 ---
 
+## v1.0.2442 (2026-11-13)
+
+### 文档
+- 研发负责人错峰轮汇报 2026-11-13 1607(T8 死锁解锁 8 键 RED→YELLOW + 派发 6 新会话 + 续派观察 3 项)
+
+### 测试
+- 契约级单测 admin.system.monitor 告警管理与限流配置端点缺失 5红 TDD-RED
+- admin.content.security 契约测试 - 实测实现已存在全绿,交编排侧转测试登记
+- admin.badge.manage RED 契约测试(S-cfc-206)
+- S-cfc-207 admin.user.govern 契约测试 9例7红(/users-search/审计留痕/导出脱敏/注销冷静期/GDPR工单未落地)
+- 契约测试 admin.audit.log(S-cfc-204,SC-A-15 操作审计日志)
+
+### 其他
+- S-cfc-205: admin.data.migration contract test (TDD-RED) - 8 red assertions across normal/error/idempotent/permission flows
+- - 新增 AdminContentSecurityContractTest(15 例,纯 Mockito/反射,无 Spring/DB/外部服务)
+- - 覆盖:audit/submit-review/withdraw/re-draft/upload image|video/comments/list 端点家族、
+-   敏感词命中拦截(checkTextSafe 标题/正文命中拒绝发布 + 空白 fail-open)、
+-   ReviewCenter summary 13 类待审聚合(含空队列全 0)、ContentFilter by-tags 空标签边界
+- 实测结论:origin/cfclub 该契约端点与行为已全部落地(契约文档 admin-ops-contract
+- §1.13/§1.18 标记 ✅ 端点存在;B-142-1 /api/admin/ 登录门已收口)。
+- TDD-RED 任务测试意外全绿(15 通过/0 失败)——按任务执行要求第 2 条汇报注明,
+- 交编排侧将 sc-key-map SC-A-13/A-18 由 RED/未跑通转测试登记为已实现+契约测试锁定;
+- 未改动任何 src/main 实现。
+- 
+- - 正常流:BadgeController(11端点)/AdminChallengeScoreConfigController(list/update)/
+-   AdminLeaderboardController(top/search) 端点面反射校验(现状已落地,转绿)
+- - 边界流(RED):已发放勋章不可删(停用代替)——当前 deleteBadge 无条件物理删除
+- - 权限流(RED):/api/badges/create|update|delete 与 challenge-score-config/update
+-   无 role=admin 守卫,非 admin 可越权
+- 纯 Mockito+反射+MockMvc standalone,无 Spring/DB。结果:11 run / 5 fail / 0 error(RED 达标)。
+- 
+- - OperationLogController /api/admin/logs operations+recent
+- - AuditLogService 只增不删改(日志不可篡改,SC-A-15 边界)
+- - AuditLog 实体溯源字段 + AuditLogAspect 自动落审计
+- - audit_log / user_operation_logs 建表落地 schema.sql + DatabaseInitializer 迁移
+- 注:实测 origin/cfclub 已含实现宿主类,测试全绿(10/10),
+- 按编排指令第2条注明实现已存在、转测试登记,未改动 src/main。
+- 
+-   背景图,遵循 mp-weixin 不支持模板内联 <svg> 的既有约定)
+- - 四步闭环改浅底圆钉图标+中性文字(去彩虹/去生硬箭头)
+- - 五维标签带改统一描边容器+发丝分割+深档色记号(非等权重填充 pills)
+- - 品牌区三级层级 + 60-30-10 暖白底 + 单品牌橙强调
+- - 八态按钮(default/hover/active/disabled/loading/error/success)
+- - script 以基线为准逐字节保留(含 $emit('open')/$emit('close')),
+-   仅 data() 追加 errorState/successState 两个八态视觉位(template 已绑定)
+- 
+
+### Bug 修复
+- overlay z-index 回退 99999 恢复全库唯一置顶不变量(P1)
+
+### 新功能
+- 底部滑出登录面板视觉升级
+
+
 ## v1.0.2441 (2026-11-13)
 
 ### 测试

+ 166 - 0
docs/tdd/orchestration/report-dev-2026-11-13-1622.md

@@ -0,0 +1,166 @@
+# 研发负责人工作汇报 · 2026-11-13 1622
+
+> 错峰轮次闭环汇报,按统一巡检/派发纪律(2026-11-09 主理人裁定)执行。仓库 E:/cfc,主分支 origin/cfclub。自动化 298dcf47 错峰轮。
+
+---
+
+## 基本信息
+
+| 项目 | 内容 |
+|------|------|
+| 汇报人 | 研发负责人(错峰轮 298dcf47 · 第 139 轮) |
+| 汇报周期 | 2026-11-13 16:22 ~ 16:39 |
+| 负责模块/单元范围 | 全量(cfc),主分支 origin/cfclub |
+
+---
+
+## 一、本轮工作数据汇总
+
+### 1.1 问题接收与处置
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **收到的测试反馈问题数*** | 0 个 | 本轮无测试 `--mark-blocked` 新提交(blockers.md 无新增 P0/P1 项) |
+| **已解决问题数*** | 0 个 | 本轮为常规派发轮,无 T8 requeue 死锁键待解(上一轮 1607 已解 8 键) |
+| **待解决研发问题数** | 0 个 | 主理人裁决 O-1~O-19 全部已裁决生效,无新增待裁决;child.* 弃用域按 O-19 裁决「维持现状、不阻塞派发」 |
+
+### 1.2 任务分发与会话管理
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **分派任务数*** | 8 个 | `--auto --role 开发 --limit 2` 分 4 批派发:S-cfc-210/211/214/215/216/217/218/219(均为 TDD-RED 写契约单测) |
+| **恢复智能体会话数*** | 0 个 | `--resume --dry-run` 0 候选(prod-etotem 生产跳过;其余节点无中断会话) |
+| **当前在用会话数** | 23 个 | `tasks.json` dispatched=23(旧在途 15 + 本轮新派 8),5 实例均覆盖 |
+| **会话平均利用率** | ~88% | 各节点配额未超限(lan-189/lan-204/local-lan-4090/mes-local-45278/pub-iwintrue 上限 10);mes-local-45278 本轮首获派发 |
+
+### 1.2b 各会话当前工作状态列表
+
+| 会话标签 | 会话 ID | 当前状态 | 当前任务(本轮新派) | 静默/空闲时长 | 工作目录 | 备注 |
+|----------|---------|----------|----------------------|---------------|----------|------|
+| lan-189 | ses_e2bf7e076ffeWK6hGkR5isik3a | running | S-cfc-210(RED mind.model.manage) | <15min | 干净 | 另在途 S-cfc-215/219、S-cfc-309(GREEN) |
+| lan-204 | ses_e2bf7bc72ffebspOzKPCZtkzKV | running | S-cfc-211(RED child.auth.login) | <15min | 干净 | 另在途 S-cfc-216、S-cfc-193/202 等 |
+| local-lan-4090 | ses_e2bf09433ffeJEglVLXr5V0gFC | running | S-cfc-217(RED child.growth.archive) | <15min | 干净 | 另在途 S-cfc-310/311(GREEN) |
+| mes-local-45278 | ses_e2beebcf2ffeR1HwflvBvQAzx5 | running | S-cfc-218(RED child.diet.viewMenu) | <15min | 干净 | 本轮首派(探活 HTTP 401→已连通) |
+| pub-iwintrue | ses_e2bf30719ffeC2k3Yp2xfkEbsL | running | S-cfc-214(RED child.wish.manage) | <15min | 干净 | 另在途 S-cfc-312(GREEN) |
+
+> 5 个 opencode 实例本轮全部拿到新派发任务,满足「所有会话都已分派任务」。
+
+### 1.3 变更感知与新任务建立
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| **感知需求/设计变更数*** | 200 提交 / 247 文件 | `--changes --days 7` 命中 200 commits;按来源分组后**新增能力键 0** |
+| **新增入池任务数*** | 0 个 | `--changes` 报告「无新增能力键,0 → 0,变化集中在既有键」→ 本轮无 `--add-task` |
+| **既有键状态流转数** | 多键 | 大量 YELLOW 键经 `--auto` 自动派发 testing 部署验证(如 dan.assessment.*/task.task.*/membership.* 等),待编排侧 `--deploy-done` 收口 |
+
+### 1.4 本轮 git 提交与推送证据(真理证据)
+
+| 提交 sha | 关联任务/能力键 | 阶段 | 触及文件类型 | 目标分支 | 已推送远端 | 备注 |
+|----------|-----------------|------|--------------|----------|------------|------|
+| (无) | 本轮仅派发,未产生研发提交 | — | — | — | — | 会话自提交由 opencode 侧完成;汇报文档走单文件入库 |
+
+> 本轮 git 真理核验聚焦 YELLOW 实现真实性门禁:**66 个带 yellow_sha 的 YELLOW 键逐键 `git merge-base --is-ancestor <sha> origin/cfclub` = IN_CHAIN 66 / NOT_IN_CHAIN 0**(实现全部已推远端,无假红)。
+
+### 1.5 状态机阶段分布与部署状态
+
+| 阶段 | 键数 | 备注 |
+|------|------|------|
+| UNTRACKED | 0 | — |
+| RED | 94 | 全部带 red_task 关联(0 孤儿,O-19 风险已消解);含 14 个 child.* 弃用域(O-19 裁决:维持现状不阻塞派发) |
+| YELLOW | 93 | 66 键 yellow_sha 已核验 IN CHAIN;27 键暂无 yellow_sha 待测试复验补登 |
+| GREEN | 8 | 不变 |
+| DONE | 15 | 不变 |
+
+| 部署项 | 值 | 说明 |
+|--------|-----|------|
+| 本轮部署次数 | 0 次 | 未触发新部署(仅派发 RED 写测任务 + YELLOW 自动待部署验证) |
+| 最新 `last_deployed` | pending 19 / confirmed 77(保持) | 部署链未动;19 待 `--deploy-done` 台账收口(归 O-24 测试/部署侧) |
+| GREEN 部署链验证 | 不涉及 | 本轮无新 GREEN→DONE |
+
+### 1.6 数据源健康度
+
+| 数据源 | 读取键数 | 是否正常 | 备注 |
+|--------|----------|----------|------|
+| 需求侧(⏳ wish) | 0 | 正常 | 无 ⏳ 占位键(CFC 走 SRS 段+场景台账) |
+| 契约常量(contract) | 0 | 正常 | 状态机本地账本为单一事实源 |
+| 测试用例引用(test_case_map) | 0 | 正常 | — |
+| 执行结果回填(case_results) | 0 | 正常 | — |
+
+---
+
+## 二、下一步需要研发处理的事项
+
+| 序号 | 事项描述 | 依赖/前置条件 | 预估完成时间 | 优先级 | 状态 |
+|------|----------|---------------|--------------|--------|------|
+| 1 | 17 个 GREEN-stage 实现任务(S-cfc-226~242)续派,使契约单测变绿 | 下轮 `--auto` 按 tid 序派发 | 2026-11-13 下轮 | P1 | 待启动 |
+| 2 | 86 个 RED-stage 写测任务持续推进(含 child.* 14 键,按 O-19 不阻塞) | 测试/开发会话容量 | 持续 | P1 | 进行中 |
+| 3 | 27 个无 yellow_sha 的 YELLOW 键待测试复验补登 evidence | 测试侧 `--mark-green` 复验 | 2026-11-13 下轮 | P2 | 待启动 |
+
+---
+
+## 三、阻塞与风险
+
+| 阻塞项 | 影响范围 | 阻塞类型 | 处置进展 | 需要协调 |
+|--------|----------|----------|----------|----------|
+| child.* 弃用域 14 键(children 表已删,禁止新引入孩子概念) | S-cfc-211/212/213/214/215/216/217/218/219/220~224 等写测任务 | 研发(架构约束 vs 状态机噪声) | **O-19 已裁决**:维持现状、不阻塞派发、`--auto` 只派发合法 pending;清理留待编排侧正规通道 | 无 |
+| 验证层瓶颈(YELLOW→GREEN) | 93 YELLOW 键待测试 GREEN 确认 + 241 SC 三类流证据 | 测试(O-7) | 研发侧 66/66 YELLOW IN CHAIN 已备妥;交接测试团队闭环 | 测试负责人协同 |
+| 工作目录不干净(864 处 WIP) | 主工作树 E:/cfc | 其他会话 WIP | 按纪律**零接触**(不 stash、不 reset、不 add -A) | 仅汇报单文件入库时精确 add |
+| 5 失败用例(P-2 MapContract 预期数据偏差) | 测试侧非研发回归 | 测试 | 已知测试侧问题,研发无需介入 | 测试负责人处理 |
+
+---
+
+## 四、下轮工作规划
+
+| 计划项 | 预计完成时间 | 前置依赖 | 涉及会话标签 | 备注 |
+|--------|--------------|----------|--------------|------|
+| 续派 17 个 GREEN 实现任务(S-cfc-226~242) | 2026-11-13 下轮 | state.json 键级 GREEN 状态确认 | 待选路 | `--auto` 按 tid 序派发 |
+| 复核本轮 8 个新派 RED 会话进度 | 2026-11-13 下轮 | 会话产出 commit | 全部 5 实例 | 目标 RED→YELLOW 推进 |
+| 27 个无 yellow_sha 的 YELLOW 键复验 | 2026-11-13 下轮 | 测试侧 `--mark-green` | testing 会话 | 收口 YELLOW→GREEN |
+
+---
+
+## 附录 A:收到的测试反馈问题明细
+
+本轮无测试反馈新条目(`docs/tdd/test/blockers.md` 无新增 P0/P1)。
+
+---
+
+## 附录 B:任务分派与会话恢复明细
+
+| 任务号 | 目标会话标签 | 任务类型 | 分派时间 | 当前状态 | 恢复会话标签 | 恢复原因 | 恢复时间 |
+|--------|--------------|----------|----------|----------|--------------|----------|----------|
+| S-cfc-210 | lan-189 | RED | 16:23 | dispatched | — | — | — |
+| S-cfc-211 | lan-204 | RED | 16:23 | dispatched | — | — | — |
+| S-cfc-214 | pub-iwintrue | RED | 16:30 | dispatched | — | — | — |
+| S-cfc-215 | lan-189 | RED | 16:30 | dispatched | — | — | — |
+| S-cfc-216 | lan-204 | RED | 16:33 | dispatched | — | — | — |
+| S-cfc-217 | local-lan-4090 | RED | 16:33 | dispatched | — | — | — |
+| S-cfc-218 | mes-local-45278 | RED | 16:36 | dispatched | — | — | — |
+| S-cfc-219 | lan-189 | RED | 16:36 | dispatched | — | — | — |
+
+---
+
+## 附录 C:新增入池任务明细
+
+本轮无新增入池任务(`--changes` 0 新增能力键,无 `--add-task`)。
+
+---
+
+## 附录 D:变更感知明细(来自 `--changes`)
+
+| 变更文档 | 文档类型 | 变更日期 | 新增能力键 | 是否已入池 |
+|----------|----------|----------|------------|------------|
+| 无新增能力键(200 提交均为既有键变更) | — | 2026-11-11~13 | 0 | — |
+
+---
+
+## 执行纪律核查
+
+- ✅ 互斥窗口:state.json 上次写盘 15:51:44,本轮启动 16:22:09(差 ~30 分钟),>5min 通过
+- ✅ 未调用 hub 脚本(tools/xaf_agent_hub/* 全部禁用,已迁 skill)
+- ✅ 仅使用 skill 命令:`--status`、`--inspect --role 测试`、`--changes`、`--clean-check`、`--resume --dry-run`、`--auto --role 开发 --limit 2`
+- ✅ git 真理:66 个 YELLOW 键 yellow_sha 核验 `merge-base --is-ancestor origin/cfclub` 全部 IN CHAIN
+- ✅ 主理人裁决自裁:O-1~O-19 全部已裁决生效,本轮无新增待裁决项(O-19 child.* 维持不阻塞派发)
+- ✅ 5 个 opencode 实例本轮全部拿到派发任务(含此前空闲的 mes-local-45278)
+- ⚠️ 工作目录不干净(864 处):全部为其他会话 WIP,按纪律零接触
+- ⚠️ 本轮无研发实现提交(仅派发 + 状态机本地推进);汇报文档走单文件入库