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

Merge origin/cfclub into cfclub (rebase S-cfc-211 onto S-cfc-312 / version bump)

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

+ 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 {

+ 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)
 
 
 ### 文档
 ### 文档