fix(knowledge): 绑定确认回填来源投影 + 唯一键补 work_id

绑定确认为 source binding projection 读模型的权威回填点:bind 成功后写投影行;unbind 按 bindingId 作用域撤销,不误伤同源绑定到其它作品的投影。

唯一键补 work_id(V24):同源 KB 可被同一用户绑定到多个作品,旧唯一键缺 work_id 会把合法跨作品复用误判为冲突;改为含 work_id 的 partial unique index(WHERE deleted=FALSE)。

验证:MuseKnowledgeBindingServiceTest + MuseKnowledgeSourceBindingProjectionMapperTest 11/11,knowledge-server 整套件 214/214 绿(clean 重编译,反假绿)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-19 09:31:56 -07:00
parent bc63a35e88
commit 8aad7a4b1b
6 changed files with 129 additions and 1 deletions

View File

@ -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 默认未生效。

View File

@ -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<String, Object> summary,
List<String> 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<String, Object> projectionSummary(Long workId, Map<String, Object> summary, List<String> purposes,
MuseKnowledgeBindPrecheckDO precheck) {
Map<String, Object> 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,

View File

@ -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<MuseKnowledgeSourceBindingProjectionDO>()
.eq(MuseKnowledgeSourceBindingProjectionDO::getBindingId, bindingId)
.eq(MuseKnowledgeSourceBindingProjectionDO::getDeleted, false));
}
default long countByKbIdAndStatus(Long kbId, String status) {
return selectCount(new LambdaQueryWrapperX<MuseKnowledgeSourceBindingProjectionDO>()
.eq(MuseKnowledgeSourceBindingProjectionDO::getKbId, kbId)

View File

@ -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.<MuseKnowledgeSourceBindingProjectionDO>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.<MuseKnowledgeBindPrecheckDO>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\"")));
}

View File

@ -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.<MuseKnowledgeSourceBindingProjectionDO>any(),
org.mockito.ArgumentMatchers.<Wrapper<MuseKnowledgeSourceBindingProjectionDO>>any());
int affected = mapper.markDeletedByBindingId(6001L, "blocked", "blocked");
assertEquals(1, affected);
ArgumentCaptor<MuseKnowledgeSourceBindingProjectionDO> updateCaptor =
ArgumentCaptor.forClass(MuseKnowledgeSourceBindingProjectionDO.class);
ArgumentCaptor<Wrapper<MuseKnowledgeSourceBindingProjectionDO>> 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);

View File

@ -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;