Browse Source

Merge branch 'task/S-cfc-198' into cfclub (S-cfc-198 admin.knowledgeBase.manage RED contract test)

E2E Test Bot 2 hours ago
parent
commit
309fa02205

+ 386 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminKnowledgeBaseManageContractTest.java

@@ -0,0 +1,386 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:管理端知识库管理(能力键 {@code admin.knowledgeBase.manage})。
+ *
+ * <p><b>契约来源(设计/需求溯源)</b>:
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} <b>SC-A-08 知识库管理</b>(知识条目 CRUD → 向量同步;
+ *       边界:<b>同步失败重试;大批量分批</b>)与 <b>SC-A-22 精准营养知识库管理</b>(CRUD → 同步;边界:<b>同步失败重试</b>)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.60.8 —— 载体为
+ *       {@code KnowledgeBaseController} 系(非开工包猜测的 {@code AdminKnowledgeController}):
+ *       {@code /api/admin/knowledge-base}(list/get/save/delete/upload-file/fetch-url/bind-tags/bind-dimensions/backfill-tags)
+ *       + {@code KnowledgeBaseVectorController /api/admin/knowledge-base/vector}(stats/documents/sync/delete-source/update-document)
+ *       + {@code KnowledgeTagController /api/admin/knowledge-tag}(list/save/delete)
+ *       + {@code AdminUnifiedKnowledgeController /api/admin/knowledge-base/unified}(overview/section/update/reset)。</li>
+ *   <li>契约核对:{@code docs/superpowers/specs/2026-11-07-admin-ops-contract.md} §1.8/§1.22 —— SC-A-08 载体确认存在,
+ *       写入端点权限粒度「留待测试侧补权限流核验」。</li>
+ * </ul></p>
+ *
+ * <p><b>实现现状(src/main 实测)</b>:
+ * <ul>
+ *   <li>✅ <b>已落地</b>:四个 Controller(CRUD/向量/标签/统一知识库)与 {@code DanKnowledgeBaseService} /
+ *       {@code UnifiedKnowledgeBaseService} 均存在,schema.sql 含 {@code dan_knowledge_base} / {@code dan_knowledge_tag} /
+ *       {@code dan_knowledge_base_tag} / {@code dan_knowledge_base_dimension} / {@code knowledge_base_section} 建表。</li>
+ *   <li>❌ <b>未落地(契约缺口,RED)</b>:① 同步失败重试 —— {@code LanggraphSyncService}(已标 deprecated)与
+ *       {@code KnowledgeBaseVectorController} 同步路径无任何 retry/重试能力(无 {@code maxRetries}、无
+ *       {@code syncWithRetry}、无 {@code @Retryable});② 大批量分批 —— 同步/回填路径无任何 batch 分批能力
+ *       (无 {@code syncBatch}、无 {@code BATCH_SIZE});③ 知识库写入端点角色门禁 —— {@code KnowledgeBaseController} /
+ *       {@code KnowledgeBaseVectorController} 无控制器内平台角色校验({@code AdminUnifiedKnowledgeController} 已有
+ *       {@code allowed} 门禁,故该项仅对 base/vector 缺口红)。</li>
+ * </ul></p>
+ *
+ * <p><b>测试范围</b>:仅用反射 + schema.sql 类路径探测(无 Spring / 无 DB / 无外部服务),
+ * 与同批已合入主链的 {@code TeamProfitHookContractTest} 风格一致。</p>
+ *
+ * <p><b>RED 预期</b>:契约边界行为(同步失败重试 / 大批量分批)与 base/vector 角色门禁未落地,
+ * 对应用例在实现补齐前应失败(红);已落地的契约表面(CRUD/向量/标签/统一端点 + 统一知识库角色门禁)用例为绿,
+ * 作为「已存在」的登记证据。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者在同步路径补「失败重试」({@code maxRetries} 配置 / {@code syncWithRetry} /
+ * {@code @Retryable})、补「大批量分批」({@code syncBatch} / {@code BATCH_SIZE})、并为
+ * {@code KnowledgeBaseController} / {@code KnowledgeBaseVectorController} 补平台角色门禁后,
+ * 对应 RED 用例自动转绿。</p>
+ */
+class AdminKnowledgeBaseManageContractTest {
+
+    /** SC-A-08 载体 Controller 类(§4.60.8) */
+    private static final String KB_CONTROLLER = "com.etotem.cfc.controller.admin.KnowledgeBaseController";
+    private static final String KB_VECTOR_CONTROLLER = "com.etotem.cfc.controller.admin.KnowledgeBaseVectorController";
+    private static final String KB_TAG_CONTROLLER = "com.etotem.cfc.controller.admin.KnowledgeTagController";
+    private static final String KB_UNIFIED_CONTROLLER = "com.etotem.cfc.controller.admin.AdminUnifiedKnowledgeController";
+
+    /** SC-A-08/SC-A-22 Service 载体 */
+    private static final String KB_SERVICE = "com.etotem.cfc.service.DanKnowledgeBaseService";
+    private static final String UNIFIED_KB_SERVICE = "com.etotem.cfc.service.UnifiedKnowledgeBaseService";
+    private static final String SYNC_SERVICE = "com.etotem.cfc.service.LanggraphSyncService";
+
+    /** §4.60.8 端点子路径(Controller 内 {@code @PostMapping} 值) */
+    private static final String[] KB_CRUD_ENDPOINTS = {
+            "/list", "/get", "/save", "/delete", "/upload-file", "/fetch-url",
+            "/bind-tags", "/bind-dimensions", "/backfill-tags"
+    };
+    private static final String[] KB_VECTOR_ENDPOINTS = {
+            "/stats", "/documents", "/sync", "/delete-source", "/update-document"
+    };
+    private static final String[] KB_TAG_ENDPOINTS = {"/list", "/save", "/delete"};
+    private static final String[] KB_UNIFIED_ENDPOINTS = {"/overview", "/section", "/update", "/reset"};
+
+    /** schema.sql 应包含的知识库相关表 */
+    private static final String[] KB_TABLES = {
+            "dan_knowledge_base", "dan_knowledge_tag", "dan_knowledge_base_tag",
+            "dan_knowledge_base_dimension", "knowledge_base_section"
+    };
+
+    // ------------------------------------------------------------------
+    // 正常流:契约表面已落地(登记证据,GREEN)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:KnowledgeBaseController 存在并承载知识条目 CRUD 端点(§4.60.8)")
+    void knowledgeBaseController_surfaceExists() {
+        Class<?> c = loadClass(KB_CONTROLLER);
+        assertNotNull(c, "[admin.knowledgeBase.manage] KnowledgeBaseController 类不存在(§4.60.8)。");
+        assertEndpoints(c, KB_CRUD_ENDPOINTS, "KnowledgeBaseController", "/api/admin/knowledge-base");
+    }
+
+    @Test
+    @DisplayName("正常流-2:KnowledgeBaseVectorController 存在并承载向量库端点(§4.60.8)")
+    void knowledgeBaseVectorController_surfaceExists() {
+        Class<?> c = loadClass(KB_VECTOR_CONTROLLER);
+        assertNotNull(c, "[admin.knowledgeBase.manage] KnowledgeBaseVectorController 类不存在(§4.60.8)。");
+        assertEndpoints(c, KB_VECTOR_ENDPOINTS, "KnowledgeBaseVectorController", "/api/admin/knowledge-base/vector");
+    }
+
+    @Test
+    @DisplayName("正常流-3:KnowledgeTagController 存在并承载知识标签字典端点(§4.60.8)")
+    void knowledgeTagController_surfaceExists() {
+        Class<?> c = loadClass(KB_TAG_CONTROLLER);
+        assertNotNull(c, "[admin.knowledgeBase.manage] KnowledgeTagController 类不存在(§4.60.8)。");
+        assertEndpoints(c, KB_TAG_ENDPOINTS, "KnowledgeTagController", "/api/admin/knowledge-tag");
+    }
+
+    @Test
+    @DisplayName("正常流-4:AdminUnifiedKnowledgeController 存在并承载统一知识库端点(§4.60.8)")
+    void adminUnifiedKnowledgeController_surfaceExists() {
+        Class<?> c = loadClass(KB_UNIFIED_CONTROLLER);
+        assertNotNull(c, "[admin.knowledgeBase.manage] AdminUnifiedKnowledgeController 类不存在(§4.60.8)。");
+        assertEndpoints(c, KB_UNIFIED_ENDPOINTS, "AdminUnifiedKnowledgeController", "/api/admin/knowledge-base/unified");
+    }
+
+    @Test
+    @DisplayName("正常流-5:知识库 Service 载体存在(DanKnowledgeBaseService / UnifiedKnowledgeBaseService)")
+    void knowledgeServices_surfaceExists() {
+        Class<?> svc = loadClass(KB_SERVICE);
+        assertNotNull(svc, "[admin.knowledgeBase.manage] DanKnowledgeBaseService 类不存在。");
+        assertTrue(hasMethod(svc, "saveWithAssociations"), "[admin.knowledgeBase.manage] "
+                + "DanKnowledgeBaseService 应提供 saveWithAssociations(保存+关联标签/维度)。");
+        assertTrue(hasMethod(svc, "getWithAssociations"), "[admin.knowledgeBase.manage] "
+                + "DanKnowledgeBaseService 应提供 getWithAssociations(详情+关联)。");
+        assertTrue(hasMethod(svc, "backfillTags"), "[admin.knowledgeBase.manage] "
+                + "DanKnowledgeBaseService 应提供 backfillTags(标签/维度回填)。");
+
+        Class<?> uni = loadClass(UNIFIED_KB_SERVICE);
+        assertNotNull(uni, "[admin.knowledgeBase.manage] UnifiedKnowledgeBaseService 类不存在。");
+        assertTrue(hasMethod(uni, "getModuleOverviewDetailed"), "[admin.knowledgeBase.manage] "
+                + "UnifiedKnowledgeBaseService 应提供 getModuleOverviewDetailed(模块概览)。");
+        assertTrue(hasMethod(uni, "updateModule"), "[admin.knowledgeBase.manage] "
+                + "UnifiedKnowledgeBaseService 应提供 updateModule(写库+热重载)。");
+    }
+
+    @Test
+    @DisplayName("正常流-6:知识库相关表落地 schema.sql(§2.2~2.5 设计)")
+    void schemaSql_hasKnowledgeTables() {
+        String schema = readSchemaSql();
+        for (String table : KB_TABLES) {
+            assertTrue(schema.contains(table),
+                    "[admin.knowledgeBase.manage] schema.sql 缺少 " + table + " 建表。");
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:同步失败重试(契约 SC-A-08/A-22 要求,当前未实现 → RED)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:知识库同步路径应支持失败重试(SC-A-08/A-22 边界,未实现 → 红)")
+    void syncFailureRetry_syncPathShouldSupportRetry() {
+        // 契约 AC:知识条目 CRUD → 向量同步,边界「同步失败重试」。
+        // 探测同步路径(LanggraphSyncService / KnowledgeBaseVectorController / DanKnowledgeBaseService)
+        // 是否暴露重试能力:syncWithRetry 方法 / maxRetries、retryCount 字段 / @Retryable 注解。
+        assertTrue(hasRetryCapability(SYNC_SERVICE, KB_VECTOR_CONTROLLER, KB_SERVICE),
+                "[admin.knowledgeBase.manage] 同步路径无任何失败重试能力(syncWithRetry / maxRetries / @Retryable):"
+                        + "契约 SC-A-08/A-22 边界要求「同步失败重试」,当前未实现。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:向量同步端点应接受重试配置参数(SC-A-08 边界,未实现 → 红)")
+    void syncFailureRetry_vectorSyncShouldAcceptRetryParam() {
+        Class<?> c = loadClass(KB_VECTOR_CONTROLLER);
+        if (c == null) {
+            fail("[admin.knowledgeBase.manage] KnowledgeBaseVectorController 类不存在,无法验证重试参数。");
+        }
+        Method sync = findMethod(c, "triggerSync");
+        if (sync == null) {
+            fail("[admin.knowledgeBase.manage] KnowledgeBaseVectorController 缺少 triggerSync(/sync)端点。");
+        }
+        // 契约要求同步可传重试/批量参数;现无参(triggerSync())→ 红
+        boolean hasRetryParam = sync.getParameterCount() > 0
+                && (sync.getParameterTypes()[0] == java.util.Map.class);
+        assertTrue(hasRetryParam,
+                "[admin.knowledgeBase.manage] /api/admin/knowledge-base/vector/sync 应接受重试配置参数"
+                        + "(如 maxRetries),支持同步失败重试(SC-A-08 边界);当前为无参 triggerSync()。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:大批量分批(契约 SC-A-08 要求,当前未实现 → RED)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-3:知识库同步/回填应支持大批量分批(SC-A-08 边界,未实现 → 红)")
+    void largeBatch_syncAndBackfillShouldSupportBatching() {
+        // 契约 AC:边界「大批量分批」。探测 DanKnowledgeBaseService / LanggraphSyncService 是否暴露
+        // 分批能力:syncBatch / batchSync 方法 或 BATCH_SIZE 常量(默认分批阈值)。
+        assertTrue(hasBatchCapability(KB_SERVICE, SYNC_SERVICE),
+                "[admin.knowledgeBase.manage] 同步/回填路径无任何分批能力(syncBatch / BATCH_SIZE):"
+                        + "契约 SC-A-08 边界要求「大批量分批」,当前未实现。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:backfill-tags 应分批回填并返回批处理统计(SC-A-08 边界,未实现 → 红)")
+    void largeBatch_backfillShouldReturnBatchStats() {
+        Class<?> svc = loadClass(KB_SERVICE);
+        if (svc == null) {
+            fail("[admin.knowledgeBase.manage] DanKnowledgeBaseService 类不存在,无法验证分批回填。");
+        }
+        Method backfill = findMethod(svc, "backfillTags");
+        if (backfill == null) {
+            fail("[admin.knowledgeBase.manage] DanKnowledgeBaseService 缺少 backfillTags。");
+        }
+        // 契约要求回填按批处理并返回批统计(如 total / processed / batchSize);
+        // 现返回 Map 但无 batchSize 语义探测点(返回类型为 Map<String,Object>,无法静态断言)——
+        // 以是否存在 BATCH_SIZE 常量 / batchSize 字段作为分批哨兵。
+        boolean hasBatchSentinel = false;
+        for (Field f : svc.getDeclaredFields()) {
+            if (f.getName().equalsIgnoreCase("BATCH_SIZE") || f.getName().toLowerCase().contains("batch")) {
+                hasBatchSentinel = true;
+                break;
+            }
+        }
+        assertTrue(hasBatchSentinel,
+                "[admin.knowledgeBase.manage] DanKnowledgeBaseService 无 BATCH_SIZE/batch 分批哨兵:"
+                        + "契约 SC-A-08 边界要求「大批量分批」回填,当前未实现。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:知识库管理应受平台角色门禁(SC-A-08 权限粒度)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流-1:统一知识库端点已实现平台角色门禁(allowed,已落地 → 绿)")
+    void permissionFlow_unifiedEnforcesRole() {
+        Class<?> c = loadClass(KB_UNIFIED_CONTROLLER);
+        if (c == null) {
+            fail("[admin.knowledgeBase.manage] AdminUnifiedKnowledgeController 类不存在。");
+        }
+        // 已落地:allowed(request) 门禁 + ALLOWED_ROLES 集合(admin/operator/finance/knowledge_admin/butler)
+        assertTrue(hasMethod(c, "allowed"),
+                "[admin.knowledgeBase.manage] AdminUnifiedKnowledgeController 应含 allowed(request) 角色门禁。");
+    }
+
+    @Test
+    @DisplayName("权限流-2:知识库 CRUD 写入端点应实现平台角色门禁(SC-A-08 权限粒度,未实现 → 红)")
+    void permissionFlow_kbWriteEndpointsShouldEnforceRole() {
+        Class<?> c = loadClass(KB_CONTROLLER);
+        if (c == null) {
+            fail("[admin.knowledgeBase.manage] KnowledgeBaseController 类不存在。");
+        }
+        // 契约(§4.60.8 + 2026-11-07-admin-ops-contract §3#5「写入端点权限粒度留待测试侧核验」):
+        // 知识库写入(save/delete/bind-tags/bind-dimensions/backfill-tags)应受平台/knowledge_admin 角色门禁,
+        // 实现方式为控制器内 @RequestAttribute("role") 校验或 allowed/roles 门禁。
+        boolean hasRoleGate = hasMethod(c, "allowed") || hasMethod(c, "roles")
+                || hasParameterAnnotation(c, "javax.servlet.http.HttpServletRequest");
+        assertTrue(hasRoleGate,
+                "[admin.knowledgeBase.manage] KnowledgeBaseController 写入端点无平台角色门禁"
+                        + "(无 allowed/roles 门禁、无 @RequestAttribute(\"role\")):"
+                        + "知识库写入权限粒度应核验(2026-11-07-admin-ops-contract §3#5)。");
+    }
+
+    @Test
+    @DisplayName("权限流-3:向量库端点应实现平台角色门禁(SC-A-08 权限粒度,未实现 → 红)")
+    void permissionFlow_vectorEndpointsShouldEnforceRole() {
+        Class<?> c = loadClass(KB_VECTOR_CONTROLLER);
+        if (c == null) {
+            fail("[admin.knowledgeBase.manage] KnowledgeBaseVectorController 类不存在。");
+        }
+        boolean hasRoleGate = hasMethod(c, "allowed") || hasMethod(c, "roles")
+                || hasParameterAnnotation(c, "javax.servlet.http.HttpServletRequest");
+        assertTrue(hasRoleGate,
+                "[admin.knowledgeBase.manage] KnowledgeBaseVectorController 无平台角色门禁"
+                        + "(向量库管理应受平台角色保护,与统一知识库一致)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private void assertEndpoints(Class<?> c, String[] endpoints, String label, String mappingPrefix) {
+        for (String ep : endpoints) {
+            boolean found = false;
+            for (Method m : c.getDeclaredMethods()) {
+                if (m.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)) {
+                    org.springframework.web.bind.annotation.PostMapping pm =
+                            m.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+                    String[] vals = pm.value();
+                    for (String v : vals) {
+                        if (v.equals(ep)) {
+                            found = true;
+                            break;
+                        }
+                    }
+                }
+                if (found) break;
+            }
+            assertTrue(found,
+                    "[admin.knowledgeBase.manage] " + label + " 缺少端点 " + mappingPrefix + ep
+                            + "(§4.60.8)。");
+        }
+    }
+
+    private boolean hasRetryCapability(String... classNames) {
+        for (String cn : classNames) {
+            Class<?> c = loadClass(cn);
+            if (c == null) continue;
+            for (Method m : c.getDeclaredMethods()) {
+                if (m.getName().toLowerCase().contains("retry")) return true;
+            }
+            for (Field f : c.getDeclaredFields()) {
+                if (f.getName().toLowerCase().contains("retry")) return true;
+            }
+            if (hasAnnotationByName(c, "org.springframework.retry.annotation.Retryable")) return true;
+        }
+        return false;
+    }
+
+    private boolean hasBatchCapability(String... classNames) {
+        for (String cn : classNames) {
+            Class<?> c = loadClass(cn);
+            if (c == null) continue;
+            for (Method m : c.getDeclaredMethods()) {
+                if (m.getName().toLowerCase().contains("batch")) return true;
+            }
+            for (Field f : c.getDeclaredFields()) {
+                if (f.getName().equalsIgnoreCase("BATCH_SIZE")
+                        || f.getName().toLowerCase().contains("batch")) return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean hasMethod(Class<?> c, String name) {
+        for (Method m : c.getDeclaredMethods()) {
+            if (m.getName().equals(name)) return true;
+        }
+        return false;
+    }
+
+    private Method findMethod(Class<?> c, String name) {
+        for (Method m : c.getDeclaredMethods()) {
+            if (m.getName().equals(name)) return m;
+        }
+        return null;
+    }
+
+    private boolean hasParameterAnnotation(Class<?> c, String annotationName) {
+        for (Method m : c.getDeclaredMethods()) {
+            for (java.lang.annotation.Annotation[] paramAnns : m.getParameterAnnotations()) {
+                for (java.lang.annotation.Annotation a : paramAnns) {
+                    if (a.annotationType().getName().equals(annotationName)) return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private boolean hasAnnotationByName(Class<?> c, String annotationName) {
+        for (java.lang.annotation.Annotation a : c.getDeclaredAnnotations()) {
+            if (a.annotationType().getName().equals(annotationName)) return true;
+        }
+        return false;
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}