diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/api/MuseKnowledgeRetrievalApiImplTest.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/api/MuseKnowledgeRetrievalApiImplTest.java index a4251c1f..29b7b3f2 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/api/MuseKnowledgeRetrievalApiImplTest.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/api/MuseKnowledgeRetrievalApiImplTest.java @@ -85,6 +85,15 @@ class MuseKnowledgeRetrievalApiImplTest extends BaseMockitoUnitTest { return d; } + /** + * installed_ref binding 范式(D0-fork / U-materialize 写出形态):kb_id 已去污染为【本地 installed_ref kbId】、 + * authorizationSnapshotId 承载【字符串 envelope】(ADR-020 V14 VARCHAR),bindingScope 含 search 检索用途。 + * 对应 U-materialize 的 binding.setKbId(本地kbId)/binding.setAuthorizationSnapshotId(字符串)。 + */ + private MuseKnowledgeBindingDO installedRefBinding(Long localKbId, String authSnapshotEnvelope) { + return binding(localKbId, "search,generate", authSnapshotEnvelope); + } + private RuntimeResult retrievalSuccess(String responseJson) { Map summary = new LinkedHashMap<>(); summary.put("responseBody", JsonUtils.parseTree(responseJson)); @@ -286,4 +295,95 @@ class MuseKnowledgeRetrievalApiImplTest extends BaseMockitoUnitTest { assertNull(sent.ragflowDocumentIds(), "document_ids 为 null → RAGFlow 对该 dataset 整库返回,无文档级隔离"); assertNull(sent.metadataFilter(), "metadata_filter 为 null → 无元数据级 chunk 过滤"); } + + // ======================================================================================== + // U-retrieve(临时-04 §U-retrieve :270-290 / D0-fork 第三单元):检索打通。 + // 验证 RetrievalApiImpl 主路径【无需改动】——靠 U-materialize 把 binding.kb_id 去污染为本地 installed_ref + // kbId、并建好指向公开副本 dataset 的 ragflow_binding 后,第四门 selectActiveDatasetByKbId(本地kbId) 自然 + // 返非空 → no_dataset 静默省略消除 → 进 RAGFlow 检索公开副本。授权快照沿用 ADR-020 VARCHAR 字符串 envelope。 + // 这是数据形态测试(mock 出 U-materialize 的写出形态),不改任何检索/绑定主路径代码。 + // ======================================================================================== + + /** + * U-retrieve 核心:no_dataset 消除 + 命中。 + *

输入:U-materialize 物化后的 installed_ref——binding.kb_id=本地 kbId(K2=9002,已去污染、非 assetId), + * 该本地 kbId 的 active dataset binding 指向公开副本 D_pub;work 绑定含 search 用途 + 授权快照。 + *

预期:结果 {@code empty("no_dataset")},进入 RAGFlow 检索(第四门拿到副本 dataset), + * {@code RetrievalResult.ok(chunks)},chunk 带齐 §5.3 合同字段。 + *

对照基线:{@link #should_returnNoDataset_whenAuthorizedButNoActiveDataset()} 钉死了"无 dataset binding + * 时必返 no_dataset"——本 case 证明 U-materialize 建好指向副本的 dataset binding 后,同一检索链 no_dataset 消失。 + */ + @Test + void uRetrieve_should_hitPublicForkDataset_eliminatingNoDataset_whenInstalledRefMaterialized() { + Long localInstalledRefKbId = 9002L; // U-materialize 新建的本地 installed_ref kbId(K2),非市场 assetId + String publicForkDatasetId = "ds-public-fork-Dpub"; // U-materialize 指向的公开副本 dataset + // installed_ref binding:kb_id=本地 kbId(去污染后),不再是 assetId + when(bindingMapper.selectActiveByWorkId(4001L)) + .thenReturn(List.of(installedRefBinding(localInstalledRefKbId, "auth-envelope-installed"))); + when(projectionMapper.selectActiveByWorkId(4001L)) + .thenReturn(List.of(projection(localInstalledRefKbId, "active"))); + // 第四门:本地 kbId → 命中指向公开副本的 active dataset binding(U-materialize ensureForkDatasetBinding 写出) + when(ragflowBindingMapper.selectActiveDatasetByKbId(localInstalledRefKbId)) + .thenReturn(dataset(publicForkDatasetId)); + // 公开副本整库返回的本就只有公开内容(发布者私有物理不在副本内,无需运行时隔离) + String json = "{\"code\":0,\"data\":{\"chunks\":[{" + + "\"content\":\"公开副本里的写作技巧\",\"document_id\":\"doc-pub-1\",\"id\":\"c-1\"," + + "\"kb_id\":\"" + publicForkDatasetId + "\",\"similarity\":0.9}],\"total\":1}}"; + when(ragFlowClient.retrieveChunks(any(RetrieveChunksCommand.class))).thenReturn(retrievalSuccess(json)); + + RetrievalResult result = retrievalApi.retrieveForWork(req()); + + // no_dataset 消除:命中后 omittedReason 为 null(对照基线 should_returnNoDataset 的 omittedReason="no_dataset") + assertNull(result.omittedReason(), "no_dataset 应已消除(U-materialize 建好指向副本的 dataset binding,第四门命中)"); + // 进 RAGFlow 检索且命中 → ok + assertTrue(result.hasChunks(), "应进入 RAGFlow 检索并命中公开副本"); + assertEquals("ok", result.status()); + assertEquals(1, result.chunks().size()); + // 检索发往 RAGFlow 的确是公开副本 dataset,且授权来源归因到本地 installed_ref kbId + ArgumentCaptor captor = ArgumentCaptor.forClass(RetrieveChunksCommand.class); + verify(ragFlowClient).retrieveChunks(captor.capture()); + assertEquals(List.of(publicForkDatasetId), captor.getValue().ragflowDatasetIds(), + "检索命中的是公开副本 dataset(纯公开内容,无需运行时隔离)"); + MuseKnowledgeRetrievalApi.RetrievedChunk chunk = result.chunks().get(0); + assertEquals(localInstalledRefKbId, chunk.sourceKbId(), "chunk 归因到本地 installed_ref kbId(去污染后)"); + assertEquals(publicForkDatasetId, chunk.datasetId()); + assertEquals("公开副本里的写作技巧", chunk.contentSummary()); + } + + /** + * U-retrieve:字符串授权快照(ADR-020 V14 VARCHAR envelope)。 + *

输入:installed_ref binding 的 authorization_snapshot_id 为字符串 envelope 形态 + * (非纯数字,如 {@code "snap:installed-ref:v1:abc-123"})。 + *

预期:§5.3 授权快照门正常放行(hasText 判定,不 parseLong);chunk 原样透传该字符串到 + * {@code authorizationSnapshotId} 合同字段;全程无 NumberFormatException、该字段不落 null。 + *

这钉死 ADR-020 的核心约束:检索门读 binding.getAuthorizationSnapshotId() 当字符串用, + * 绝不回退 BIGINT/parseLong(否则字符串 envelope 会 NumberFormatException 或被丢成 null)。 + */ + @Test + void uRetrieve_should_passThroughStringAuthorizationSnapshot_withoutNumberFormatException() { + Long localInstalledRefKbId = 9003L; + // 字符串 envelope 形态的授权快照(绝非可 parseLong 的纯数字) + String stringEnvelopeSnapshot = "snap:installed-ref:v1:abc-123"; + when(bindingMapper.selectActiveByWorkId(4001L)) + .thenReturn(List.of(installedRefBinding(localInstalledRefKbId, stringEnvelopeSnapshot))); + when(projectionMapper.selectActiveByWorkId(4001L)) + .thenReturn(List.of(projection(localInstalledRefKbId, "active"))); + when(ragflowBindingMapper.selectActiveDatasetByKbId(localInstalledRefKbId)) + .thenReturn(dataset("ds-public-fork-Dpub")); + when(ragFlowClient.retrieveChunks(any(RetrieveChunksCommand.class))) + .thenReturn(retrievalSuccess("{\"code\":0,\"data\":{\"chunks\":[{" + + "\"content\":\"片段\",\"document_id\":\"doc-1\",\"id\":\"c-1\"," + + "\"kb_id\":\"ds-public-fork-Dpub\",\"similarity\":0.7}]}}")); + + // 全程不应抛 NumberFormatException(若检索门误 parseLong 字符串 envelope 即会抛 → 被 catch 降级成 retrieval_error) + RetrievalResult result = retrievalApi.retrieveForWork(req()); + + // 放行且命中(未因字符串 envelope 被门拒、也未异常降级) + assertTrue(result.hasChunks(), "字符串授权快照应放行检索门并命中,不应被当作无授权/异常降级"); + assertEquals("ok", result.status()); + // chunk 原样透传字符串 envelope,不落 null、不被 parseLong 截断 + MuseKnowledgeRetrievalApi.RetrievedChunk chunk = result.chunks().get(0); + assertEquals(stringEnvelopeSnapshot, chunk.authorizationSnapshotId(), + "授权快照字符串 envelope 原样透传到 chunk 合同字段,未 parseLong、未落 null"); + } }