diff --git a/muse-cloud/muse-module-knowledge/.agent b/muse-cloud/muse-module-knowledge/.agent index cc316469..797b7277 100644 --- a/muse-cloud/muse-module-knowledge/.agent +++ b/muse-cloud/muse-module-knowledge/.agent @@ -5,5 +5,5 @@ - **目标(owner 职责)**:Local KB / User KB / 全局知识处理 / Knowledge Draft / Knowledge Source Binding / 投影与索引状态。 - **边界(不可违反)**:**进 Local KB 唯一入口=用户显式确认草稿或手动修正**;全书解析章节确认只产/推 Draft,不写正式知识;来源优先级 Local KB > 用户绑定 > 市场/全局;依赖 RAGFlow GraphRAG(不可用时 Unavailable 优雅降级);守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。 - **out-of-scope**:正文写入(归 content)。 -- **现状**:只读评估 72%;后端最完整模块之一(59 端点对齐契约),RAGFlow 真 HTTP + SSRF 防护;installed-KB 删除记录回显 bug 已修(2026-06-19:`MuseKnowledgeBindingMapperTest` + `MuseInstalledKnowledgeBaseServiceTest` 10/10,删除时写 `deleted=true` 且列表排除 `binding_status=deleted`);impact preview 已按 `muse_knowledge_source_binding_projection.owner_user_id` 输出 installed/source-binding owner 审计计数(2026-06-19:`MuseKnowledgeBaseServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 30/30)。 +- **现状**:只读评估 72%;后端最完整模块之一(59 端点对齐契约),RAGFlow 真 HTTP + SSRF 防护;installed-KB 删除记录回显 bug 已修(2026-06-19:`MuseKnowledgeBindingMapperTest` + `MuseInstalledKnowledgeBaseServiceTest` 10/10,删除时写 `deleted=true` 且列表排除 `binding_status=deleted`);impact preview 已按 `muse_knowledge_source_binding_projection.owner_user_id` 输出 installed/source-binding owner 审计计数(2026-06-19:`MuseKnowledgeBaseServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 30/30);source binding projection 回填闭环已修(2026-06-19:绑定确认为权威回填点写读模型行、unbind 按 bindingId 作用域撤销不误伤同源跨作品投影、唯一键补 work_id 修跨作品复用误判冲突〔V24〕;`MuseKnowledgeBindingServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 11/11、整套件 214/214 绿)。 - **关键风险 / TODO**:前端仅约 1/3 面(草稿确认/绑定/发布/图谱缺 hook);Task4 剩余 owner 缺口已收窄为 **document owner count / export_task_owner** 仍不能伪造,需后续 owner 事实或外部服务接入;recheck/worker 默认未生效。 diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBindingService.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBindingService.java index 23b5b9ac..3b2391df 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBindingService.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBindingService.java @@ -8,8 +8,10 @@ import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeBindi import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBindPrecheckDO; import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBindingDO; import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeCommandDO; +import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceBindingProjectionDO; import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeBindPrecheckMapper; import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeBindingMapper; +import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeSourceBindingProjectionMapper; import cn.iocoder.muse.module.knowledge.domain.muse.MuseKnowledgeApiVersionGuard; import com.fasterxml.jackson.core.type.TypeReference; import jakarta.annotation.Resource; @@ -54,6 +56,8 @@ public class MuseKnowledgeBindingService { @Resource private MuseKnowledgeBindingMapper bindingMapper; @Resource + private MuseKnowledgeSourceBindingProjectionMapper sourceBindingProjectionMapper; + @Resource private MuseKnowledgeCommandService commandService; @Resource private MuseKnowledgeWorkOwnerFacade workOwnerFacade; @@ -142,6 +146,7 @@ public class MuseKnowledgeBindingService { binding.setRevision(1); binding.setTenantId(TenantContextHolder.getRequiredTenantId()); bindingMapper.insert(binding); + writeSourceBindingProjection(loginUserId, workId, binding, precheck, summary, purposes); precheck.setStatus("consumed"); precheck.setExpectedWorkRevision(reqVO.getExpectedWorkRevision()); @@ -185,6 +190,7 @@ public class MuseKnowledgeBindingService { binding.setCommandId(reqVO.getCommandId()); binding.setRevision(binding.getRevision() == null ? 1 : binding.getRevision() + 1); bindingMapper.updateById(binding); + sourceBindingProjectionMapper.markDeletedByBindingId(bindingId, "blocked", "blocked"); AppKnowledgeBindingVO.UnbindRespVO respVO = new AppKnowledgeBindingVO.UnbindRespVO(); respVO.setBindingId(String.valueOf(bindingId)); respVO.setStatus("unbound"); @@ -192,6 +198,31 @@ public class MuseKnowledgeBindingService { return respVO; } + private void writeSourceBindingProjection(Long ownerUserId, Long workId, MuseKnowledgeBindingDO binding, + MuseKnowledgeBindPrecheckDO precheck, Map summary, + List purposes) { + MuseKnowledgeSourceBindingProjectionDO projection = new MuseKnowledgeSourceBindingProjectionDO(); + projection.setProjectionId("kb-source-projection-" + binding.getId()); + projection.setSourceOwner(toSourceOwner(binding.getBindingType())); + projection.setSourceType(binding.getBindingType()); + projection.setSourceId(stringValue(summary, "sourceId")); + projection.setSourceRevision(binding.getSourceVersion() == null ? null : String.valueOf(binding.getSourceVersion())); + projection.setOwnerUserId(ownerUserId); + projection.setWorkId(workId); + projection.setKbId(binding.getKbId()); + projection.setBindingId(binding.getId()); + projection.setStatus("active"); + projection.setActionPolicy("allowed"); + projection.setSourceSnapshotId(precheck.getSourceSnapshotId()); + projection.setAuthorizationSnapshotId(precheck.getAuthorizationSnapshotId()); + projection.setSourceHash(hashNullable(precheck.getSourceSnapshotId())); + projection.setProjectionSummary(JsonUtils.toJsonString(projectionSummary(workId, summary, purposes, precheck))); + projection.setCheckedAt(LocalDateTime.now()); + projection.setTenantId(TenantContextHolder.getRequiredTenantId()); + // 绑定确认动作是 source binding projection 的权威回填点;来源变化事件只负责后续状态传播。 + sourceBindingProjectionMapper.insert(projection); + } + private void validatePrecheckReq(AppKnowledgeBindingVO.PrecheckReqVO reqVO) { if (!SOURCE_TYPES.contains(reqVO.getSourceType())) { throw new ServiceException(KNOWLEDGE_INVALID_PAGE_PARAM); @@ -288,6 +319,25 @@ public class MuseKnowledgeBindingService { return respVO; } + private Map projectionSummary(Long workId, Map summary, List purposes, + MuseKnowledgeBindPrecheckDO precheck) { + Map projection = new LinkedHashMap<>(); + projection.put("targetOwner", "knowledge"); + projection.put("targetId", String.valueOf(workId)); + projection.put("sourceName", stringValue(summary, "sourceId")); + projection.put("sourceStatus", stringValue(summary, "sourceStatus")); + projection.put("purposes", purposes); + projection.put("authorizationSummaryId", stringValue(summary, "authorizationSummaryId")); + projection.put("authorizationSnapshotId", precheck.getAuthorizationSnapshotId()); + projection.put("sourceSnapshotId", precheck.getSourceSnapshotId()); + projection.put("handoffTokenPersisted", false); + return projection; + } + + private String toSourceOwner(String sourceType) { + return "market_kb".equals(sourceType) ? "market" : "knowledge"; + } + private MuseKnowledgeCommandService.CommandEnvelope commandEnvelope(String commandId, String operationId, String apiVersion, Long actorUserId, Long ownerUserId, String targetType, diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeSourceBindingProjectionMapper.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeSourceBindingProjectionMapper.java index cd0de73f..a93b4677 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeSourceBindingProjectionMapper.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeSourceBindingProjectionMapper.java @@ -134,6 +134,18 @@ public interface MuseKnowledgeSourceBindingProjectionMapper extends BaseMapperX< .eq(MuseKnowledgeSourceBindingProjectionDO::getDeleted, false)); } + default int markDeletedByBindingId(Long bindingId, String status, String actionPolicy) { + MuseKnowledgeSourceBindingProjectionDO update = new MuseKnowledgeSourceBindingProjectionDO(); + update.setStatus(status); + update.setActionPolicy(actionPolicy); + update.setCheckedAt(LocalDateTime.now()); + update.setDeleted(true); + // 解绑只撤销当前 binding 对应的读模型行,避免误伤同一来源绑定到其它作品的投影。 + return update(update, new LambdaUpdateWrapper() + .eq(MuseKnowledgeSourceBindingProjectionDO::getBindingId, bindingId) + .eq(MuseKnowledgeSourceBindingProjectionDO::getDeleted, false)); + } + default long countByKbIdAndStatus(Long kbId, String status) { return selectCount(new LambdaQueryWrapperX() .eq(MuseKnowledgeSourceBindingProjectionDO::getKbId, kbId) diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBindingServiceTest.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBindingServiceTest.java index a5e49097..5c70216d 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBindingServiceTest.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBindingServiceTest.java @@ -8,8 +8,10 @@ import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeBindi import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBindPrecheckDO; import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBindingDO; import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeCommandDO; +import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceBindingProjectionDO; import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeBindPrecheckMapper; import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeBindingMapper; +import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeSourceBindingProjectionMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -52,6 +54,8 @@ class MuseKnowledgeBindingServiceTest extends BaseMockitoUnitTest { @Mock private MuseKnowledgeBindingMapper bindingMapper; @Mock + private MuseKnowledgeSourceBindingProjectionMapper sourceBindingProjectionMapper; + @Mock private MuseKnowledgeCommandService commandService; @Mock private MuseKnowledgeWorkOwnerFacade workOwnerFacade; @@ -197,6 +201,27 @@ class MuseKnowledgeBindingServiceTest extends BaseMockitoUnitTest { && "auth-snap-B".equals(binding.getAuthorizationSnapshotId()) && Integer.valueOf(3).equals(binding.getSourceVersion()) && "cmd-bind".equals(binding.getCommandId()))); + verify(sourceBindingProjectionMapper).insert(org.mockito.ArgumentMatchers.argThat(projection -> + "kb-source-projection-6001".equals(projection.getProjectionId()) + && "market".equals(projection.getSourceOwner()) + && "market_kb".equals(projection.getSourceType()) + && "4001".equals(projection.getSourceId()) + && "3".equals(projection.getSourceRevision()) + && Long.valueOf(2001L).equals(projection.getOwnerUserId()) + && Long.valueOf(9001L).equals(projection.getWorkId()) + && Long.valueOf(4001L).equals(projection.getKbId()) + && Long.valueOf(6001L).equals(projection.getBindingId()) + && "active".equals(projection.getStatus()) + && "allowed".equals(projection.getActionPolicy()) + && "source-snap-A".equals(projection.getSourceSnapshotId()) + && "auth-snap-B".equals(projection.getAuthorizationSnapshotId()) + && projection.getSourceHash() != null + && projection.getProjectionSummary().contains("\"targetOwner\":\"knowledge\"") + && projection.getProjectionSummary().contains("\"targetId\":\"9001\"") + && projection.getProjectionSummary().contains("\"purposes\":[\"search\",\"generate\"]") + && projection.getProjectionSummary().contains("\"handoffTokenPersisted\":false") + && !projection.getProjectionSummary().contains("market-secret-token") + && Long.valueOf(100L).equals(projection.getTenantId()))); verify(precheckMapper).updateById(org.mockito.ArgumentMatchers.argThat(updated -> "consumed".equals(updated.getStatus()) && updated.getConsumedAt() != null)); } @@ -246,6 +271,7 @@ class MuseKnowledgeBindingServiceTest extends BaseMockitoUnitTest { assertEquals(List.of("search", "generate"), replayResp.getPurposes()); verify(precheckMapper, times(1)).selectByPrecheckId("kb-bind-precheck-1"); verify(bindingMapper, times(1)).insert(any(MuseKnowledgeBindingDO.class)); + verify(sourceBindingProjectionMapper, times(1)).insert(any(MuseKnowledgeSourceBindingProjectionDO.class)); verify(precheckMapper, times(1)).updateById(any(MuseKnowledgeBindPrecheckDO.class)); } @@ -291,6 +317,7 @@ class MuseKnowledgeBindingServiceTest extends BaseMockitoUnitTest { Long.valueOf(6001L).equals(updated.getId()) && "unbound".equals(updated.getBindingStatus()) && "cmd-unbind".equals(updated.getCommandId()))); + verify(sourceBindingProjectionMapper).markDeletedByBindingId(6001L, "blocked", "blocked"); verify(commandService).recordCompleted(any(MuseKnowledgeCommandService.CommandEnvelope.class), argThat(snapshot -> snapshot.contains("\"status\":\"unbound\""))); } diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeSourceBindingProjectionMapperTest.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeSourceBindingProjectionMapperTest.java index 2b853099..0e920cdd 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeSourceBindingProjectionMapperTest.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeSourceBindingProjectionMapperTest.java @@ -4,6 +4,7 @@ import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceB import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; @@ -60,6 +61,34 @@ class MuseKnowledgeSourceBindingProjectionMapperTest { assertTrue(((AbstractWrapper) captor.getValue()).getParamNameValuePairs().containsValue(false)); } + @Test + void should_markProjectionDeletedByBindingIdOnlyForActiveRows() { + MuseKnowledgeSourceBindingProjectionMapper mapper = + mock(MuseKnowledgeSourceBindingProjectionMapper.class, CALLS_REAL_METHODS); + doReturn(1).when(mapper).update( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.>any()); + + int affected = mapper.markDeletedByBindingId(6001L, "blocked", "blocked"); + + assertEquals(1, affected); + ArgumentCaptor updateCaptor = + ArgumentCaptor.forClass(MuseKnowledgeSourceBindingProjectionDO.class); + ArgumentCaptor> wrapperCaptor = + ArgumentCaptor.forClass(Wrapper.class); + verify(mapper).update(updateCaptor.capture(), wrapperCaptor.capture()); + MuseKnowledgeSourceBindingProjectionDO update = updateCaptor.getValue(); + assertEquals("blocked", update.getStatus()); + assertEquals("blocked", update.getActionPolicy()); + assertEquals(Boolean.TRUE, update.getDeleted()); + assertTrue(update.getCheckedAt() != null); + String sqlSegment = normalizeSql(wrapperCaptor.getValue()); + assertTrue(sqlSegment.contains("binding_id =")); + assertTrue(sqlSegment.contains("deleted =")); + assertTrue(((AbstractWrapper) wrapperCaptor.getValue()).getParamNameValuePairs().containsValue(6001L)); + assertTrue(((AbstractWrapper) wrapperCaptor.getValue()).getParamNameValuePairs().containsValue(false)); + } + private static MuseKnowledgeSourceBindingProjectionDO projection(Long ownerUserId) { MuseKnowledgeSourceBindingProjectionDO projection = new MuseKnowledgeSourceBindingProjectionDO(); projection.setOwnerUserId(ownerUserId); diff --git a/muse-cloud/sql/muse/V24__fix_knowledge_source_projection_scope.sql b/muse-cloud/sql/muse/V24__fix_knowledge_source_projection_scope.sql new file mode 100644 index 00000000..2806f390 --- /dev/null +++ b/muse-cloud/sql/muse/V24__fix_knowledge_source_projection_scope.sql @@ -0,0 +1,10 @@ +-- Knowledge source binding projection 是按作品读回的读模型;同一来源 KB 可被同一用户绑定到多个作品。 +-- 旧唯一键缺少 work_id,会把合法的跨作品复用误判为冲突。 +ALTER TABLE muse_knowledge_source_binding_projection + DROP CONSTRAINT IF EXISTS uk_muse_knowledge_source_projection_scope; + +DROP INDEX IF EXISTS uk_muse_knowledge_source_projection_scope; + +CREATE UNIQUE INDEX uk_muse_knowledge_source_projection_scope + ON muse_knowledge_source_binding_projection(tenant_id, owner_user_id, work_id, source_owner, source_type, source_id, kb_id) + WHERE deleted = FALSE;