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

Merge remote-tracking branch 'origin/cfclub' into task/S-cfc-218

Agnes Agent 2 часов назад
Родитель
Сommit
7685f020b6

+ 57 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseController.java

@@ -1,5 +1,6 @@
 package com.etotem.cfc.controller.admin;
 package com.etotem.cfc.controller.admin;
 
 
+import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.DanKnowledgeBase;
 import com.etotem.cfc.entity.DanKnowledgeBase;
 import com.etotem.cfc.service.DanKnowledgeBaseService;
 import com.etotem.cfc.service.DanKnowledgeBaseService;
@@ -17,20 +18,29 @@ import org.springframework.web.client.RestTemplate;
 import org.springframework.web.multipart.MultipartFile;
 import org.springframework.web.multipart.MultipartFile;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
 import java.io.IOException;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
+import java.util.Set;
 import java.util.UUID;
 import java.util.UUID;
 
 
 @RestController
 @RestController
 @RequestMapping("/api/admin/knowledge-base")
 @RequestMapping("/api/admin/knowledge-base")
 public class KnowledgeBaseController {
 public class KnowledgeBaseController {
 
 
+    /** 允许维护知识库的角色(knowledge_admin 门户中知识库归其管理;admin/operator 平台角色放行) */
+    private static final Set<String> ALLOWED_ROLES = new HashSet<>(Arrays.asList(
+            "admin", "operator", "finance", "knowledge_admin", "butler"));
+
     @Resource
     @Resource
     private DanKnowledgeBaseService danKnowledgeBaseService;
     private DanKnowledgeBaseService danKnowledgeBaseService;
 
 
@@ -39,6 +49,32 @@ public class KnowledgeBaseController {
 
 
     private final RestTemplate restTemplate = new RestTemplate();
     private final RestTemplate restTemplate = new RestTemplate();
 
 
+    @SuppressWarnings("unchecked")
+    private List<String> roles(HttpServletRequest request) {
+        Object rolesAttr = request.getAttribute("roles");
+        if (rolesAttr instanceof List) {
+            return (List<String>) rolesAttr;
+        }
+        Object roleAttr = request.getAttribute("role");
+        if (roleAttr != null) {
+            return Collections.singletonList(roleAttr.toString());
+        }
+        return Collections.emptyList();
+    }
+
+    private boolean allowed(HttpServletRequest request) {
+        List<String> r = roles(request);
+        if (r == null || r.isEmpty()) {
+            return false;
+        }
+        for (String raw : r) {
+            if (ALLOWED_ROLES.contains(AdminRoles.normalize(raw))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     @PostMapping("/list")
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
         String keyword = (String) params.get("keyword");
         String keyword = (String) params.get("keyword");
@@ -67,7 +103,10 @@ public class KnowledgeBaseController {
     }
     }
 
 
     @PostMapping("/backfill-tags")
     @PostMapping("/backfill-tags")
-    public Result<Map<String, Object>> backfillTags() {
+    public Result<Map<String, Object>> backfillTags(HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         return Result.success(danKnowledgeBaseService.backfillTags());
         return Result.success(danKnowledgeBaseService.backfillTags());
     }
     }
 
 
@@ -86,7 +125,10 @@ public class KnowledgeBaseController {
 
 
     @SuppressWarnings("unchecked")
     @SuppressWarnings("unchecked")
     @PostMapping("/save")
     @PostMapping("/save")
-    public Result<DanKnowledgeBase> save(@RequestBody Map<String, Object> params) {
+    public Result<DanKnowledgeBase> save(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         DanKnowledgeBase knowledge = new DanKnowledgeBase();
         DanKnowledgeBase knowledge = new DanKnowledgeBase();
         if (params.get("id") != null) {
         if (params.get("id") != null) {
             knowledge.setId(((Number) params.get("id")).longValue());
             knowledge.setId(((Number) params.get("id")).longValue());
@@ -119,7 +161,11 @@ public class KnowledgeBaseController {
 
 
     @PostMapping("/delete")
     @PostMapping("/delete")
     public Result<Void> delete(@RequestBody(required = false) Map<String, Object> body,
     public Result<Void> delete(@RequestBody(required = false) Map<String, Object> body,
-                               @RequestParam(required = false) Long id) {
+                               @RequestParam(required = false) Long id,
+                               HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         if (body != null && body.get("id") != null) {
         if (body != null && body.get("id") != null) {
             id = com.etotem.cfc.util.ParamUtils.getLong(body.get("id"));
             id = com.etotem.cfc.util.ParamUtils.getLong(body.get("id"));
         }
         }
@@ -129,7 +175,10 @@ public class KnowledgeBaseController {
     }
     }
 
 
     @PostMapping("/bind-dimensions")
     @PostMapping("/bind-dimensions")
-    public Result<Void> bindDimensions(@RequestBody Map<String, Object> params) {
+    public Result<Void> bindDimensions(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         Long knowledgeId = ((Number) params.get("knowledgeId")).longValue();
         Long knowledgeId = ((Number) params.get("knowledgeId")).longValue();
         List<Long> dimensionIds = toLongList(params.get("dimensionIds"));
         List<Long> dimensionIds = toLongList(params.get("dimensionIds"));
         danKnowledgeBaseService.bindDimensions(knowledgeId, dimensionIds);
         danKnowledgeBaseService.bindDimensions(knowledgeId, dimensionIds);
@@ -137,7 +186,10 @@ public class KnowledgeBaseController {
     }
     }
 
 
     @PostMapping("/bind-tags")
     @PostMapping("/bind-tags")
-    public Result<Void> bindTags(@RequestBody Map<String, Object> params) {
+    public Result<Void> bindTags(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         Long knowledgeId = ((Number) params.get("knowledgeId")).longValue();
         Long knowledgeId = ((Number) params.get("knowledgeId")).longValue();
         List<Long> tagIds = toLongList(params.get("tagIds"));
         List<Long> tagIds = toLongList(params.get("tagIds"));
         danKnowledgeBaseService.bindTags(knowledgeId, tagIds);
         danKnowledgeBaseService.bindTags(knowledgeId, tagIds);

+ 91 - 9
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseVectorController.java

@@ -1,5 +1,6 @@
 package com.etotem.cfc.controller.admin;
 package com.etotem.cfc.controller.admin;
 
 
+import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
@@ -10,8 +11,14 @@ import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.client.RestTemplate;
 import org.springframework.web.client.RestTemplate;
 
 
+import javax.servlet.http.HttpServletRequest;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Map;
+import java.util.Set;
 
 
 /**
 /**
  * 向量知识库管理 Controller
  * 向量知识库管理 Controller
@@ -23,26 +30,80 @@ public class KnowledgeBaseVectorController {
 
 
     private static final Logger log = LoggerFactory.getLogger(KnowledgeBaseVectorController.class);
     private static final Logger log = LoggerFactory.getLogger(KnowledgeBaseVectorController.class);
 
 
+    /** 允许维护向量知识库的角色(与统一知识库一致:平台角色 + 知识管理员 + 管家) */
+    private static final Set<String> ALLOWED_ROLES = new HashSet<>(Arrays.asList(
+            "admin", "operator", "finance", "knowledge_admin", "butler"));
+
     @Value("${python.base-url:http://localhost:9000}")
     @Value("${python.base-url:http://localhost:9000}")
     private String langgraphBaseUrl;
     private String langgraphBaseUrl;
 
 
     private final RestTemplate restTemplate = new RestTemplate();
     private final RestTemplate restTemplate = new RestTemplate();
 
 
+    @SuppressWarnings("unchecked")
+    private List<String> roles(HttpServletRequest request) {
+        Object rolesAttr = request.getAttribute("roles");
+        if (rolesAttr instanceof List) {
+            return (List<String>) rolesAttr;
+        }
+        Object roleAttr = request.getAttribute("role");
+        if (roleAttr != null) {
+            return Collections.singletonList(roleAttr.toString());
+        }
+        return Collections.emptyList();
+    }
+
+    private boolean allowed(HttpServletRequest request) {
+        List<String> r = roles(request);
+        if (r == null || r.isEmpty()) {
+            return false;
+        }
+        for (String raw : r) {
+            if (ALLOWED_ROLES.contains(AdminRoles.normalize(raw))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private HttpHeaders createJsonHeaders() {
     private HttpHeaders createJsonHeaders() {
         HttpHeaders headers = new HttpHeaders();
         HttpHeaders headers = new HttpHeaders();
         headers.setContentType(MediaType.APPLICATION_JSON);
         headers.setContentType(MediaType.APPLICATION_JSON);
         return headers;
         return headers;
     }
     }
 
 
+    /** 同步失败重试(契约 SC-A-08 边界):HTTP 调用失败时按 maxRetries 重试 */
+    private Map<String, Object> postWithRetry(String url, HttpEntity<?> entity, int maxRetries) {
+        Exception last = null;
+        for (int attempt = 1; attempt <= Math.max(1, maxRetries); attempt++) {
+            try {
+                return restTemplate.postForEntity(url, entity, Map.class).getBody();
+            } catch (Exception e) {
+                last = e;
+                if (attempt < Math.max(1, maxRetries)) {
+                    log.warn("调用[{}]第 {}/{} 次失败,将重试: {}", url, attempt, Math.max(1, maxRetries), e.getMessage());
+                    try {
+                        Thread.sleep((long) (300L * attempt));
+                    } catch (InterruptedException ignored) {
+                        Thread.currentThread().interrupt();
+                    }
+                }
+            }
+        }
+        throw new RuntimeException("调用[" + url + "]重试 " + Math.max(1, maxRetries) + " 次后仍失败", last);
+    }
+
     /**
     /**
      * 获取向量库统计信息
      * 获取向量库统计信息
      */
      */
     @PostMapping("/stats")
     @PostMapping("/stats")
-    public Result<Map<String, Object>> getStats() {
+    public Result<Map<String, Object>> getStats(HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         try {
         try {
             String url = langgraphBaseUrl + "/api/v1/knowledge-base/stats";
             String url = langgraphBaseUrl + "/api/v1/knowledge-base/stats";
             HttpEntity<String> entity = new HttpEntity<>(createJsonHeaders());
             HttpEntity<String> entity = new HttpEntity<>(createJsonHeaders());
-            Map<String, Object> response = restTemplate.postForEntity(url, entity, Map.class).getBody();
+            Map<String, Object> response = postWithRetry(url, entity, 1);
             return Result.success(response);
             return Result.success(response);
         } catch (Exception e) {
         } catch (Exception e) {
             log.warn("获取向量库统计失败: {}", e.getMessage());
             log.warn("获取向量库统计失败: {}", e.getMessage());
@@ -54,11 +115,14 @@ public class KnowledgeBaseVectorController {
      * 分页列出向量库文档
      * 分页列出向量库文档
      */
      */
     @PostMapping("/documents")
     @PostMapping("/documents")
-    public Result<Map<String, Object>> listDocuments(@RequestBody Map<String, Object> params) {
+    public Result<Map<String, Object>> listDocuments(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         try {
         try {
             String url = langgraphBaseUrl + "/api/v1/knowledge-base/documents";
             String url = langgraphBaseUrl + "/api/v1/knowledge-base/documents";
             HttpEntity<Map<String, Object>> entity = new HttpEntity<>(params, createJsonHeaders());
             HttpEntity<Map<String, Object>> entity = new HttpEntity<>(params, createJsonHeaders());
-            Map<String, Object> response = restTemplate.postForEntity(url, entity, Map.class).getBody();
+            Map<String, Object> response = postWithRetry(url, entity, 1);
             return Result.success(response);
             return Result.success(response);
         } catch (Exception e) {
         } catch (Exception e) {
             log.warn("列出文档失败: {}", e.getMessage());
             log.warn("列出文档失败: {}", e.getMessage());
@@ -70,7 +134,10 @@ public class KnowledgeBaseVectorController {
      * 删除指定 source 的所有文档
      * 删除指定 source 的所有文档
      */
      */
     @PostMapping("/delete-source")
     @PostMapping("/delete-source")
-    public Result<Map<String, Object>> deleteBySource(@RequestBody Map<String, String> params) {
+    public Result<Map<String, Object>> deleteBySource(@RequestBody Map<String, String> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         String source = params.get("source");
         String source = params.get("source");
         if (source == null || source.isEmpty()) {
         if (source == null || source.isEmpty()) {
             return Result.error("source 不能为空");
             return Result.error("source 不能为空");
@@ -87,14 +154,26 @@ public class KnowledgeBaseVectorController {
     }
     }
 
 
     /**
     /**
-     * 触发完整同步
+     * 触发完整同步(接受重试配置参数 maxRetries,契约 SC-A-08 边界:同步失败重试)
      */
      */
     @PostMapping("/sync")
     @PostMapping("/sync")
-    public Result<Map<String, Object>> triggerSync() {
+    public Result<Map<String, Object>> triggerSync(@RequestBody(required = false) Map<String, Object> params,
+                                                   HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
+        int maxRetries = 3;
+        if (params != null && params.get("maxRetries") != null) {
+            try {
+                maxRetries = ((Number) params.get("maxRetries")).intValue();
+            } catch (Exception e) {
+                maxRetries = 3;
+            }
+        }
         try {
         try {
             String url = langgraphBaseUrl + "/api/v1/knowledge-base/sync";
             String url = langgraphBaseUrl + "/api/v1/knowledge-base/sync";
             HttpEntity<String> entity = new HttpEntity<>(createJsonHeaders());
             HttpEntity<String> entity = new HttpEntity<>(createJsonHeaders());
-            Map<String, Object> response = restTemplate.postForEntity(url, entity, Map.class).getBody();
+            Map<String, Object> response = postWithRetry(url, entity, maxRetries);
             return Result.success(response);
             return Result.success(response);
         } catch (Exception e) {
         } catch (Exception e) {
             log.warn("触发同步失败: {}", e.getMessage());
             log.warn("触发同步失败: {}", e.getMessage());
@@ -106,7 +185,10 @@ public class KnowledgeBaseVectorController {
      * 更新单个文档
      * 更新单个文档
      */
      */
     @PostMapping("/update-document")
     @PostMapping("/update-document")
-    public Result<Map<String, Object>> updateDocument(@RequestBody Map<String, Object> params) {
+    public Result<Map<String, Object>> updateDocument(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         String docId = (String) params.get("docId");
         String docId = (String) params.get("docId");
         if (docId == null || docId.isEmpty()) {
         if (docId == null || docId.isEmpty()) {
             return Result.error("docId 不能为空");
             return Result.error("docId 不能为空");

+ 79 - 16
cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java

@@ -385,6 +385,9 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
 
 
     private static final int MAX_TAGS_PER_KB = 12;
     private static final int MAX_TAGS_PER_KB = 12;
 
 
+    /** 大批量分批处理阈值(契约 SC-A-08 边界:大批量分批)——回填/同步按该粒度分批处理 */
+    private static final int BATCH_SIZE = 100;
+
     private static final Map<String, List<String>> DIMENSION_KEYWORDS;
     private static final Map<String, List<String>> DIMENSION_KEYWORDS;
 
 
     static {
     static {
@@ -466,7 +469,7 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
         return hit;
         return hit;
     }
     }
 
 
-    /** 为全部启用知识回填标签与维度绑定,返回统计 */
+    /** 为全部启用知识回填标签与维度绑定,返回统计(大批量按 BATCH_SIZE 分批处理) */
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
     public Map<String, Object> backfillTags() {
     public Map<String, Object> backfillTags() {
         List<DanKnowledgeTag> tags = danKnowledgeTagMapper.selectList(null);
         List<DanKnowledgeTag> tags = danKnowledgeTagMapper.selectList(null);
@@ -474,30 +477,90 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
             .eq(DanKnowledgeBase::getStatus, 1));
             .eq(DanKnowledgeBase::getStatus, 1));
         int tagBound = 0;
         int tagBound = 0;
         int dimBound = 0;
         int dimBound = 0;
-        for (DanKnowledgeBase kb : list) {
-            Set<Long> tagIds = matchTagIds(kb.getTitle(), kb.getContent(), tags);
-            if (!tagIds.isEmpty()) {
-                bindTags(kb.getId(), new ArrayList<>(tagIds));
-                tagBound++;
-            }
-            Set<String> codes = matchDimensionCodes(kb.getTitle(), kb.getContent());
-            List<Long> dimIds = codes.stream()
-                .map(this::dimensionIdByCode)
-                .filter(Objects::nonNull)
-                .distinct()
-                .collect(Collectors.toList());
-            if (!dimIds.isEmpty()) {
-                bindDimensions(kb.getId(), dimIds);
-                dimBound++;
+        int batchCount = 0;
+        for (int from = 0; from < list.size(); from += BATCH_SIZE) {
+            int to = Math.min(from + BATCH_SIZE, list.size());
+            batchCount++;
+            for (DanKnowledgeBase kb : list.subList(from, to)) {
+                Set<Long> tagIds = matchTagIds(kb.getTitle(), kb.getContent(), tags);
+                if (!tagIds.isEmpty()) {
+                    bindTags(kb.getId(), new ArrayList<>(tagIds));
+                    tagBound++;
+                }
+                Set<String> codes = matchDimensionCodes(kb.getTitle(), kb.getContent());
+                List<Long> dimIds = codes.stream()
+                    .map(this::dimensionIdByCode)
+                    .filter(Objects::nonNull)
+                    .distinct()
+                    .collect(Collectors.toList());
+                if (!dimIds.isEmpty()) {
+                    bindDimensions(kb.getId(), dimIds);
+                    dimBound++;
+                }
             }
             }
         }
         }
         Map<String, Object> r = new HashMap<>();
         Map<String, Object> r = new HashMap<>();
         r.put("knowledgeCount", list.size());
         r.put("knowledgeCount", list.size());
         r.put("tagBoundCount", tagBound);
         r.put("tagBoundCount", tagBound);
         r.put("dimensionBoundCount", dimBound);
         r.put("dimensionBoundCount", dimBound);
+        r.put("batchSize", BATCH_SIZE);
+        r.put("batchCount", batchCount);
         return r;
         return r;
     }
     }
 
 
+    /**
+     * 大批量分批同步:将指定知识条目按 BATCH_SIZE 分批,逐批触发向量同步
+     * (契约 SC-A-08 边界:大批量分批)。
+     *
+     * @param ids 知识条目 id 列表(null/空表全量启用条目)
+     * @return 批次统计(total / processed / batchSize / batchCount)
+     */
+    public Map<String, Object> syncBatch(List<Long> ids) {
+        List<DanKnowledgeBase> targets;
+        if (ids == null || ids.isEmpty()) {
+            targets = this.list(new LambdaQueryWrapper<DanKnowledgeBase>()
+                .eq(DanKnowledgeBase::getStatus, 1));
+        } else {
+            targets = new ArrayList<>();
+            for (Long id : ids) {
+                DanKnowledgeBase kb = this.getById(id);
+                if (kb != null) {
+                    targets.add(kb);
+                }
+            }
+        }
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("total", targets.size());
+        stats.put("processed", 0);
+        stats.put("batchSize", BATCH_SIZE);
+        List<Map<String, Object>> batches = new ArrayList<>();
+        for (int from = 0; from < targets.size(); from += BATCH_SIZE) {
+            int to = Math.min(from + BATCH_SIZE, targets.size());
+            List<DanKnowledgeBase> batch = targets.subList(from, to);
+            int batchOk = 0;
+            for (DanKnowledgeBase kb : batch) {
+                try {
+                    List<String> codes = getDimensionCodesForKnowledge(kb.getId());
+                    langgraphSyncService.syncWithRetry(kb, codes);
+                    batchOk++;
+                } catch (Exception e) {
+                    log.warn("分批同步知识[{}]失败: {}", kb.getId(), e.getMessage());
+                }
+            }
+            int processed = ((Number) stats.get("processed")).intValue() + batchOk;
+            stats.put("processed", processed);
+            Map<String, Object> batchStat = new HashMap<>();
+            batchStat.put("from", from);
+            batchStat.put("to", to);
+            batchStat.put("size", batch.size());
+            batchStat.put("ok", batchOk);
+            batches.add(batchStat);
+        }
+        stats.put("batchCount", batches.size());
+        stats.put("batches", batches);
+        return stats;
+    }
+
     private Long dimensionIdByCode(String code) {
     private Long dimensionIdByCode(String code) {
         return dimensionRegistryService.getDimensionIdByCode(code);
         return dimensionRegistryService.getDimensionIdByCode(code);
     }
     }

+ 55 - 20
cfc-backend/src/main/java/com/etotem/cfc/service/LanggraphSyncService.java

@@ -34,39 +34,74 @@ public class LanggraphSyncService {
     @Value("${dify.api-key:}")
     @Value("${dify.api-key:}")
     private String difyApiKey;
     private String difyApiKey;
 
 
+    /** 同步失败默认最大重试次数(契约 SC-A-08/SC-A-22 边界:同步失败重试) */
+    @Value("${dify.sync.max-retries:3}")
+    private int maxRetries;
+
     @Resource
     @Resource
     private DimensionRegistryService dimensionRegistryService;
     private DimensionRegistryService dimensionRegistryService;
 
 
     private final RestTemplate restTemplate = new RestTemplate();
     private final RestTemplate restTemplate = new RestTemplate();
 
 
+    /**
+     * 带失败重试的同步(契约 SC-A-08/SC-A-22 边界:同步失败重试)。
+     * 每个维度的 Dify dataset 推送失败时按指数退避重试(上限 maxRetries 次)。
+     */
     @Async
     @Async
-    public void syncToLanggraph(DanKnowledgeBase knowledge, List<String> dimensionCodes) {
+    public void syncWithRetry(DanKnowledgeBase knowledge, List<String> dimensionCodes) {
         if (dimensionCodes == null || dimensionCodes.isEmpty()) return;
         if (dimensionCodes == null || dimensionCodes.isEmpty()) return;
         for (String code : dimensionCodes) {
         for (String code : dimensionCodes) {
-            try {
-                String datasetId = dimensionRegistryService.getDifyDatasetIdByCode(code);
-                if (datasetId == null || datasetId.isEmpty()) {
-                    log.warn("维度[{}]未配置 Dify Dataset,跳过同步", code);
-                    continue;
+            int retryCount = 0;
+            boolean success = false;
+            while (!success && retryCount < Math.max(1, maxRetries)) {
+                retryCount++;
+                try {
+                    syncOneDataset(knowledge, code);
+                    success = true;
+                } catch (Exception e) {
+                    if (retryCount >= Math.max(1, maxRetries)) {
+                        log.error("同步知识点[{}]到维度[{}]重试 {} 次后仍失败,放弃",
+                                knowledge.getTitle(), code, retryCount, e);
+                    } else {
+                        log.warn("同步知识点[{}]到维度[{}]第 {}/{} 次尝试失败,将重试: {}",
+                                knowledge.getTitle(), code, retryCount, Math.max(1, maxRetries), e.getMessage());
+                        try {
+                            Thread.sleep((long) (500L * retryCount));
+                        } catch (InterruptedException ignored) {
+                            Thread.currentThread().interrupt();
+                        }
+                    }
                 }
                 }
-                String url = difyBaseUrl + "/v1/datasets/" + datasetId + "/documents";
-                HttpHeaders headers = new HttpHeaders();
-                headers.setContentType(MediaType.APPLICATION_JSON);
-                headers.set("Authorization", "Bearer " + difyApiKey);
-                Map<String, Object> body = new HashMap<>();
-                body.put("name", knowledge.getTitle());
-                Map<String, Object> textContent = new HashMap<>();
-                textContent.put("content", knowledge.getContent());
-                body.put("text", textContent);
-                HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
-                restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
-                log.info("同步知识点[{}]到维度[{}]的 LangGraph dataset 成功", knowledge.getTitle(), code);
-            } catch (Exception e) {
-                log.error("同步知识点[{}]到维度[{}]的 LangGraph dataset 失败", knowledge.getTitle(), code, e);
             }
             }
         }
         }
     }
     }
 
 
+    @Async
+    public void syncToLanggraph(DanKnowledgeBase knowledge, List<String> dimensionCodes) {
+        syncWithRetry(knowledge, dimensionCodes);
+    }
+
+    /** 单维度推送一次(成功返回,失败抛异常交由调用方重试) */
+    private void syncOneDataset(DanKnowledgeBase knowledge, String code) {
+        String datasetId = dimensionRegistryService.getDifyDatasetIdByCode(code);
+        if (datasetId == null || datasetId.isEmpty()) {
+            log.warn("维度[{}]未配置 Dify Dataset,跳过同步", code);
+            return;
+        }
+        String url = difyBaseUrl + "/v1/datasets/" + datasetId + "/documents";
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.set("Authorization", "Bearer " + difyApiKey);
+        Map<String, Object> body = new HashMap<>();
+        body.put("name", knowledge.getTitle());
+        Map<String, Object> textContent = new HashMap<>();
+        textContent.put("content", knowledge.getContent());
+        body.put("text", textContent);
+        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
+        restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
+        log.info("同步知识点[{}]到维度[{}]的 LangGraph dataset 成功", knowledge.getTitle(), code);
+    }
+
     @Async
     @Async
     public void removeFromLanggraph(Long knowledgeId, String langgraphDatasetId) {
     public void removeFromLanggraph(Long knowledgeId, String langgraphDatasetId) {
         try {
         try {

+ 205 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/growth/ChildGrowthArchiveContractTest.java

@@ -0,0 +1,205 @@
+package com.etotem.cfc.controller.growth;
+
+import com.etotem.cfc.entity.GrowthRecord;
+import com.etotem.cfc.service.GrowthRecordService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * S-cfc-217 / 能力键 {@code child.growth.archive} 契约级单测(TDD RED 阶段:契约新增、实现未落地,预期红)。
+ *
+ * <p>溯源·需求:`tests/user-scenarios/应用场景总览.md` SC-C-11(成长记录查看-孩子视角)、SC-C-20(成长档案查看/家长代导出)。
+ * <br>溯源·设计:`docs/superpowers/api/API_REFERENCE.md` §4.6 §4.61.9 G-11;
+ * `docs/superpowers/specs/2026-11-07-child-zone-contract.md` §5 G-11(跨切面:无归属校验)。
+ *
+ * <p>契约锚点(G-11):成长档案读端点 {@code POST /api/growth/record/child/{memberId}}、
+ * {@code POST /api/growth/record/child-records} 与 {@code POST /api/growth/record/{id}} 必须做
+ * 家庭归属校验(参考 {@code HealthPlanController /tasks} 的 familyId 校验口径)——任一登录用户不得
+ * 按 id 读取他人(跨家庭)的成长档案;越权访问应以业务码 <b>403</b> 拒绝,而不是返回他人数据。
+ *
+ * <p>RED 预期:{@link GrowthRecordController#getChildRecords(Long)} / {@code getRecordsByChild} /
+ * {@code getRecordDetail} 当前均无归属校验(方法签名未读取 familyId 请求属性),跨家庭读取会
+ * 直接返回 200+他人数据 → 本类「跨家庭拒绝」用例失败(红);同家庭正常路径用例为守住不误伤的
+ * 绿锚(当前无校验时可通过,GREEN 实现后仍须通过)。
+ *
+ * <p>测试隔离:MockMvc standalone + 仅 mock {@link GrowthRecordService},无 Spring 上下文 / 无 DB / 无外部服务。
+ */
+class ChildGrowthArchiveContractTest {
+
+    private static final Long PARENT_USER_ID = 1001L;
+    private static final Long MY_FAMILY_ID = 5001L;
+    private static final Long MY_MEMBER_ID = 2001L;
+    /** 跨家庭(他人家族)成员 id:不属于当前登录用户所在家庭 */
+    private static final Long FOREIGN_MEMBER_ID = 9001L;
+    private static final Long FOREIGN_RECORD_ID = 90001L;
+
+    private static final String MSG_MEMBER_FORBIDDEN = "无权访问该成员成长档案";
+    private static final String MSG_RECORD_FORBIDDEN = "无权访问该成长档案";
+
+    private GrowthRecordController controller;
+    private MockMvc mockMvc;
+    private GrowthRecordService growthRecordService;
+
+    @BeforeEach
+    void setUp() {
+        controller = new GrowthRecordController();
+        growthRecordService = mock(GrowthRecordService.class);
+        ReflectionTestUtils.setField(controller, "growthRecordService", growthRecordService);
+        // 归属校验实现(GREEN)需要注入的家族成员归属查询依赖不在本契约测试范围内,由 GREEN 阶段接线
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ================================================================
+    // G-11 契约一:POST /api/growth/record/child/{memberId} —— 成员归属校验
+    // ================================================================
+
+    @Test
+    @DisplayName("getChildRecords:跨家庭成员 - 必须 403 拒绝,不得返回他人档案(当前无校验 → 红)")
+    void childRecords_跨家庭成员_应拒绝读取_403() throws Exception {
+        // Given:他人家族成员即使存在档案,本用户也无权读取
+        List<GrowthRecord> foreignRecords = Collections.singletonList(
+                buildGrowthRecord(1L, FOREIGN_MEMBER_ID, "2026-06-15", "他人档案")
+        );
+        when(growthRecordService.getRecordsByChild(eq(FOREIGN_MEMBER_ID))).thenReturn(foreignRecords);
+
+        // When:携带本家庭 familyId/currentMemberId 请求属性(模拟 JwtInterceptor 注入)读取跨家庭成员档案
+        mockMvc.perform(post("/api/growth/record/child/" + FOREIGN_MEMBER_ID)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID)
+                        .requestAttr("familyId", MY_FAMILY_ID)
+                        .requestAttr("currentMemberId", MY_MEMBER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(403))
+                .andExpect(jsonPath("$.message").value(MSG_MEMBER_FORBIDDEN));
+
+        // Then:归属门禁应先于 Service 查询生效,绝不允许按他人 memberId 放行
+        verify(growthRecordService, never()).getRecordsByChild(eq(FOREIGN_MEMBER_ID));
+    }
+
+    @Test
+    @DisplayName("getChildRecords:同家庭成员 - 正常返回档案列表(绿锚,防归属校验误伤)")
+    void childRecords_同家庭成员_正常返回_200() throws Exception {
+        List<GrowthRecord> myRecords = Arrays.asList(
+                buildGrowthRecord(1L, MY_MEMBER_ID, "2026-06-15", "我的档案1"),
+                buildGrowthRecord(2L, MY_MEMBER_ID, "2026-03-01", "我的档案2")
+        );
+        when(growthRecordService.getRecordsByChild(eq(MY_MEMBER_ID))).thenReturn(myRecords);
+
+        mockMvc.perform(post("/api/growth/record/child/" + MY_MEMBER_ID)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID)
+                        .requestAttr("familyId", MY_FAMILY_ID)
+                        .requestAttr("currentMemberId", MY_MEMBER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.length()").value(2))
+                .andExpect(jsonPath("$.data[0].childId").value(MY_MEMBER_ID));
+
+        verify(growthRecordService).getRecordsByChild(eq(MY_MEMBER_ID));
+    }
+
+    // ================================================================
+    // G-11 契约二:POST /api/growth/record/child-records —— 裸 memberId body 同受归属校验
+    // ================================================================
+
+    @Test
+    @DisplayName("getRecordsByChild(body):跨家庭成员 - 必须 403 拒绝(body 路径同受归属校验 → 红)")
+    void childRecordsByBody_跨家庭成员_应拒绝读取_403() throws Exception {
+        // Given:body 传裸 Long memberId(API 口径),目标成员属于其他家庭
+        List<GrowthRecord> foreignRecords = Collections.singletonList(
+                buildGrowthRecord(3L, FOREIGN_MEMBER_ID, "2026-06-15", "他人档案")
+        );
+        when(growthRecordService.getRecordsByChild(eq(FOREIGN_MEMBER_ID))).thenReturn(foreignRecords);
+
+        mockMvc.perform(post("/api/growth/record/child-records")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(String.valueOf(FOREIGN_MEMBER_ID))
+                        .requestAttr("userId", PARENT_USER_ID)
+                        .requestAttr("familyId", MY_FAMILY_ID)
+                        .requestAttr("currentMemberId", MY_MEMBER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(403))
+                .andExpect(jsonPath("$.message").value(MSG_MEMBER_FORBIDDEN));
+
+        // Then:归属门禁应拦截,不允许跨家庭按 memberId 放行
+        verify(growthRecordService, never()).getRecordsByChild(eq(FOREIGN_MEMBER_ID));
+    }
+
+    // ================================================================
+    // G-11 契约三:POST /api/growth/record/{id} —— 档案明细归属校验
+    // ================================================================
+
+    @Test
+    @DisplayName("getRecordDetail:跨家庭档案 - 必须 403 拒绝,不得按 id 读他人档案(当前无校验 → 红)")
+    void recordDetail_跨家庭档案_应拒绝读取_403() throws Exception {
+        // Given:档案存在但属于他人家族成员(memberId=FOREIGN_MEMBER_ID)
+        GrowthRecord foreignRecord = buildGrowthRecord(FOREIGN_RECORD_ID, FOREIGN_MEMBER_ID, "2026-06-15", "他人档案详情");
+        when(growthRecordService.getRecordById(eq(FOREIGN_RECORD_ID))).thenReturn(foreignRecord);
+
+        mockMvc.perform(post("/api/growth/record/" + FOREIGN_RECORD_ID)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID)
+                        .requestAttr("familyId", MY_FAMILY_ID)
+                        .requestAttr("currentMemberId", MY_MEMBER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(403))
+                .andExpect(jsonPath("$.message").value(MSG_RECORD_FORBIDDEN));
+
+        // Then:归属门禁应先于 Service 查询生效,绝不允许按他人档案 id 放行
+        verify(growthRecordService, never()).getRecordById(eq(FOREIGN_RECORD_ID));
+    }
+
+    @Test
+    @DisplayName("getRecordDetail:同家庭档案 - 正常返回明细(绿锚,防归属校验误伤)")
+    void recordDetail_同家庭档案_正常返回_200() throws Exception {
+        GrowthRecord myRecord = buildGrowthRecord(4L, MY_MEMBER_ID, "2026-06-15", "我的档案详情");
+        when(growthRecordService.getRecordById(eq(4L))).thenReturn(myRecord);
+
+        mockMvc.perform(post("/api/growth/record/4")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", PARENT_USER_ID)
+                        .requestAttr("familyId", MY_FAMILY_ID)
+                        .requestAttr("currentMemberId", MY_MEMBER_ID))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.childId").value(MY_MEMBER_ID))
+                .andExpect(jsonPath("$.data.recordTitle").value("我的档案详情"));
+
+        verify(growthRecordService).getRecordById(eq(4L));
+    }
+
+    // ================================================================
+    // Helpers
+    // ================================================================
+
+    private GrowthRecord buildGrowthRecord(Long id, Long memberId, String dateStr, String title) {
+        GrowthRecord r = new GrowthRecord();
+        r.setId(id);
+        r.setMemberId(memberId);
+        r.setRecordTitle(title);
+        try {
+            r.setCreatedAt(new java.text.SimpleDateFormat("yyyy-MM-dd").parse(dateStr));
+        } catch (Exception ignored) {
+        }
+        return r;
+    }
+}

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

+ 126 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildGrowthArchiveExportContractTest.java

@@ -0,0 +1,126 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.AssessmentOrderMapper;
+import com.etotem.cfc.mapper.ChatMessageMapper;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.PaymentOrderMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AccountCleanupService;
+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.Collections;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * S-cfc-217 / 能力键 {@code child.growth.archive} 家长代导出子契约(TDD RED 阶段:预期红)。
+ *
+ * <p>溯源·需求:`tests/user-scenarios/应用场景总览.md` SC-C-20(成长档案查看/家长代导出);
+ * 规则:「未成年人导出受限,需家长代办」。
+ * <br>溯源·设计:`docs/superpowers/api/API_REFERENCE.md` §4.61.9 G-05;
+ * `docs/superpowers/specs/2026-11-07-child-zone-contract.md` §3.4 §5 G-05。
+ *
+ * <p>契约锚点(G-05):{@code AccountCleanupService.exportUserData} 生成的个人数据导出包,
+ * 必须覆盖家庭内「不能登录用户」(原「孩子」)成员的<b>成长档案</b>——即导出包应包含一个
+ * {@code growthRecords} 区块,承载家庭各成员的成长记录({@code growth_records}),以支持家长
+ * 代导出指定成员的成长档案。当前实现仅导出 userInfo / children / assessmentResults / healthReports /
+ * tasks,<b>完全缺失成长档案区块</b> → 本类断言失败(红)。
+ *
+ * <p>测试隔离:纯 Mockito(@Mock 各 Mapper + @InjectMocks {@link AccountCleanupService}),
+ * 无 Spring 上下文 / 无 DB / 无外部服务。字符串/集合断言驱动,不依赖真实外部服务。
+ */
+class ChildGrowthArchiveExportContractTest {
+
+    private static final Long PARENT_USER_ID = 1001L;
+    private static final Long CHILD_MEMBER_ID = 2001L;
+    private static final Long FAMILY_ID = 5001L;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Mock
+    private HealthReportMapper healthReportMapper;
+
+    @Mock
+    private ChatMessageMapper chatMessageMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private AssessmentOrderMapper assessmentOrderMapper;
+
+    @Mock
+    private PaymentOrderMapper paymentOrderMapper;
+
+    @Mock
+    private TaskMapper taskMapper;
+
+    @InjectMocks
+    private AccountCleanupService service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    @Test
+    @DisplayName("exportUserData:家长导出包应包含家庭成员的成长档案区块(当前缺失 → 红)")
+    void exportUserData_家长导出_应包含成员成长档案() {
+        // Given:家长账号,家庭内有一个不能登录用户(原「孩子」)成员,且无其它外部依赖干扰
+        User parent = new User();
+        parent.setId(PARENT_USER_ID);
+        parent.setFamilyId(FAMILY_ID);
+        when(userMapper.selectById(PARENT_USER_ID)).thenReturn(parent);
+
+        FamilyMember child = new FamilyMember();
+        child.setId(CHILD_MEMBER_ID);
+        child.setFamilyId(FAMILY_ID);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(child));
+
+        // When:家长导出个人数据(成长档案代导出载体)
+        Map<String, Object> data = service.exportUserData(PARENT_USER_ID);
+
+        // Then:导出包必须包含 growthRecords 区块以承载成员成长档案(SC-C-20 / G-05)
+        assertTrue(data.containsKey("growthRecords"),
+                "导出包应包含 growthRecords 区块(成长档案),当前实现完全缺失(G-05)");
+        assertNotNull(data.get("growthRecords"), "growthRecords 区块不应为 null");
+    }
+
+    @Test
+    @DisplayName("exportUserData:无成长记录时 growthRecords 区块为稳定空集合(契约完整性 → 红)")
+    void exportUserData_无成长记录_档案区块为空集合() {
+        // Given:家长账号 + 家庭内一个成员,成长档案为空
+        User parent = new User();
+        parent.setId(PARENT_USER_ID);
+        parent.setFamilyId(FAMILY_ID);
+        when(userMapper.selectById(PARENT_USER_ID)).thenReturn(parent);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        // When:家长导出个人数据
+        Map<String, Object> data = service.exportUserData(PARENT_USER_ID);
+
+        // Then:growthRecords 区块始终存在且为空集合(不是缺失、不是 null)
+        assertTrue(data.get("growthRecords") instanceof java.util.Collection,
+                "导出包应包含 growthRecords 区块,且无记录时为空集合(当前缺失,G-05)");
+        assertTrue(((java.util.Collection<?>) data.get("growthRecords")).isEmpty(),
+                "无成长记录时应为空集合");
+    }
+}

+ 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 @@
-3fa4eb7dbd527a89ef023374061be66f51a20edd
+dca0505a2e2d6147d48f0880c18f62b6bbf4e1a1

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,22 @@
 
 
 ---
 ---
 
 
+## v1.0.2443 (2026-11-13)
+
+### 测试
+- 契约RED单测 child.game.reportScore 小游戏成绩上报
+- mind.model.manage 契约测试 RED - 模型版本/开关/上下线/回退方案契约未落地,反射结构契约 10 红 1 绿(权限流说明)
+
+### 其他
+- - 契约锚点:需求 SC-C-03(异常分数拦截)/SC-C-02(成绩上报幂等)、设计 API_REFERENCE §4.61.3 + 缺口 G-09
+- - 纯 Mockito 契约测试 MiniGameService.completeGame:3 正常流/边界合法端点绿 + 4 契约缺口红
+- - RED 实测:Tests run 7, Failures 4(score 超上限/负分未拦截、score=999999 积分膨胀 124997>10、重复上报未幂等)
+- 
+
+### 文档
+- 研发负责人错峰巡检汇报 2026-11-13 1622(298dcf47 第139轮)
+
+
 ## v1.0.2442 (2026-11-13)
 ## v1.0.2442 (2026-11-13)
 
 
 ### 文档
 ### 文档

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

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.2442
+> 当前版本: v1.0.2443
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,22 @@
 
 
 ---
 ---
 
 
+## v1.0.2443 (2026-11-13)
+
+### 测试
+- 契约RED单测 child.game.reportScore 小游戏成绩上报
+- mind.model.manage 契约测试 RED - 模型版本/开关/上下线/回退方案契约未落地,反射结构契约 10 红 1 绿(权限流说明)
+
+### 其他
+- - 契约锚点:需求 SC-C-03(异常分数拦截)/SC-C-02(成绩上报幂等)、设计 API_REFERENCE §4.61.3 + 缺口 G-09
+- - 纯 Mockito 契约测试 MiniGameService.completeGame:3 正常流/边界合法端点绿 + 4 契约缺口红
+- - RED 实测:Tests run 7, Failures 4(score 超上限/负分未拦截、score=999999 积分膨胀 124997>10、重复上报未幂等)
+- 
+
+### 文档
+- 研发负责人错峰巡检汇报 2026-11-13 1622(298dcf47 第139轮)
+
+
 ## v1.0.2442 (2026-11-13)
 ## v1.0.2442 (2026-11-13)
 
 
 ### 文档
 ### 文档