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:
parent
bc63a35e88
commit
8aad7a4b1b
@ -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 默认未生效。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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\"")));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
Loading…
x
Reference in New Issue
Block a user