diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBaseService.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBaseService.java index 15868c35..b0192097 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBaseService.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBaseService.java @@ -187,7 +187,7 @@ public class MuseKnowledgeBaseService { knowledgeBaseMapper.updateById(kb); String sourceEventId = recordSourceEvent("global_kb", kbId, GLOBAL_OWNER_USER_ID, reqVO.getCommandId(), "blocked", "blocked"); - int affectedBindings = propagateKbBlocked(kbId, STATUS_DISABLED, reqVO.getCommandId(), sourceEventId); + int affectedBindings = propagateKbBlocked(kbId, STATUS_DISABLED, sourceEventId); AdminKnowledgeBaseVO.StatusRespVO respVO = new AdminKnowledgeBaseVO.StatusRespVO(); respVO.setStatus(STATUS_DISABLED); respVO.setSourceEventId(sourceEventId); @@ -449,7 +449,7 @@ public class MuseKnowledgeBaseService { respVO.setStatus(status); String sourceEventId = recordSourceEvent(KB_TYPE_USER + "_kb", kbId, loginUserId, commandId, "blocked", "blocked"); - int affectedBindings = propagateKbBlocked(kbId, status, commandId, sourceEventId); + int affectedBindings = propagateKbBlocked(kbId, status, sourceEventId); respVO.setAffectedBindings(affectedBindings); respVO.setCancellableUntil(withCancelWindow ? MuseKnowledgeDateTimes.toOpenApiDateTime(LocalDateTime.now().plusMinutes(30)) : null); @@ -474,8 +474,11 @@ public class MuseKnowledgeBaseService { } } - private int propagateKbBlocked(Long kbId, String bindingStatus, String commandId, String sourceEventId) { - int affectedBindings = knowledgeBindingMapper.updateStatusByKbId(kbId, bindingStatus, commandId); + private int propagateKbBlocked(Long kbId, String bindingStatus, String sourceEventId) { + // 传播只翻转 binding 的状态:command_id 是 binding 行级创建命令的幂等键(受唯一约束保护), + // KB 级停用/删除不得覆盖各 binding 的 command_id,否则 KB 绑 ≥2 作品时会撞唯一约束 500(见 mapper 注释)。 + int affectedBindings = knowledgeBindingMapper.updateStatusByKbId(kbId, bindingStatus); + // 来源绑定读模型按 last_event_id 传播(无 command_id 唯一约束),用 KB 级 sourceEventId 标记本次阻断,天然支持批量。 sourceBindingProjectionMapper.updateStatusByKbId(kbId, "blocked", "blocked", sourceEventId); return affectedBindings; } diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeBindingMapper.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeBindingMapper.java index 51ec8f11..efc02862 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeBindingMapper.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/dal/mysql/muse/MuseKnowledgeBindingMapper.java @@ -35,10 +35,15 @@ public interface MuseKnowledgeBindingMapper extends BaseMapperX() .eq(MuseKnowledgeBindingDO::getKbId, kbId) diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBaseServiceTest.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBaseServiceTest.java index aafedfe2..d16478f0 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBaseServiceTest.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBaseServiceTest.java @@ -160,7 +160,7 @@ class MuseKnowledgeBaseServiceTest extends BaseMockitoUnitTest { when(knowledgeBaseMapper.selectById(4001L)).thenReturn(kb); when(commandService.buildRequestHash(any())).thenReturn("hash-disable-user"); when(commandService.reserveCommand(any(MuseKnowledgeCommandService.CommandEnvelope.class))).thenReturn(null); - when(knowledgeBindingMapper.updateStatusByKbId(4001L, "disabled", "cmd-disable-user-kb")).thenReturn(3); + when(knowledgeBindingMapper.updateStatusByKbId(4001L, "disabled")).thenReturn(3); when(sourceBindingProjectionMapper.updateStatusByKbId(any(), any(), any(), any())).thenReturn(2); AppKnowledgeBaseVO.DisableReqVO reqVO = new AppKnowledgeBaseVO.DisableReqVO(); @@ -181,7 +181,7 @@ class MuseKnowledgeBaseServiceTest extends BaseMockitoUnitTest { Long.valueOf(4001L).equals(updated.getId()) && "disabled".equals(updated.getStatus()) && Integer.valueOf(4).equals(updated.getRevision()))); - verify(knowledgeBindingMapper).updateStatusByKbId(4001L, "disabled", "cmd-disable-user-kb"); + verify(knowledgeBindingMapper).updateStatusByKbId(4001L, "disabled"); verify(sourceBindingProjectionMapper).updateStatusByKbId(org.mockito.ArgumentMatchers.eq(4001L), org.mockito.ArgumentMatchers.eq("blocked"), org.mockito.ArgumentMatchers.eq("blocked"), org.mockito.ArgumentMatchers.startsWith("source-event-user_kb-4001-")); @@ -201,7 +201,7 @@ class MuseKnowledgeBaseServiceTest extends BaseMockitoUnitTest { when(knowledgeBaseMapper.selectById(4001L)).thenReturn(kb); when(commandService.buildRequestHash(any())).thenReturn("hash-delete-user"); when(commandService.reserveCommand(any(MuseKnowledgeCommandService.CommandEnvelope.class))).thenReturn(null); - when(knowledgeBindingMapper.updateStatusByKbId(4001L, "deleting", "cmd-delete-user-kb")).thenReturn(2); + when(knowledgeBindingMapper.updateStatusByKbId(4001L, "deleting")).thenReturn(2); when(sourceBindingProjectionMapper.updateStatusByKbId(any(), any(), any(), any())).thenReturn(2); AppKnowledgeBaseVO.DeleteReqVO reqVO = new AppKnowledgeBaseVO.DeleteReqVO(); @@ -214,7 +214,7 @@ class MuseKnowledgeBaseServiceTest extends BaseMockitoUnitTest { assertEquals("deleting", respVO.getStatus()); assertEquals(2, respVO.getAffectedBindings()); - verify(knowledgeBindingMapper).updateStatusByKbId(4001L, "deleting", "cmd-delete-user-kb"); + verify(knowledgeBindingMapper).updateStatusByKbId(4001L, "deleting"); verify(sourceBindingProjectionMapper).updateStatusByKbId(org.mockito.ArgumentMatchers.eq(4001L), org.mockito.ArgumentMatchers.eq("blocked"), org.mockito.ArgumentMatchers.eq("blocked"), org.mockito.ArgumentMatchers.startsWith("source-event-user_kb-4001-")); @@ -674,7 +674,7 @@ class MuseKnowledgeBaseServiceTest extends BaseMockitoUnitTest { when(knowledgeBaseMapper.selectById(4001L)).thenReturn(globalKb()); when(commandService.buildRequestHash(any())).thenReturn("hash-disable-global"); when(commandService.reserveCommand(any(MuseKnowledgeCommandService.CommandEnvelope.class))).thenReturn(null); - when(knowledgeBindingMapper.updateStatusByKbId(4001L, "disabled", "cmd-disable-global-kb")).thenReturn(4); + when(knowledgeBindingMapper.updateStatusByKbId(4001L, "disabled")).thenReturn(4); when(sourceBindingProjectionMapper.updateStatusByKbId(any(), any(), any(), any())).thenReturn(4); AdminKnowledgeBaseVO.CommandReqVO reqVO = new AdminKnowledgeBaseVO.CommandReqVO(); @@ -686,7 +686,7 @@ class MuseKnowledgeBaseServiceTest extends BaseMockitoUnitTest { assertEquals("disabled", respVO.getStatus()); assertEquals(4, respVO.getAffectedBindings()); - verify(knowledgeBindingMapper).updateStatusByKbId(4001L, "disabled", "cmd-disable-global-kb"); + verify(knowledgeBindingMapper).updateStatusByKbId(4001L, "disabled"); verify(sourceBindingProjectionMapper).updateStatusByKbId(4001L, "blocked", "blocked", respVO.getSourceEventId()); } diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeBaseDisableMultiBindingIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeBaseDisableMultiBindingIT.java new file mode 100644 index 00000000..38bd286c --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeBaseDisableMultiBindingIT.java @@ -0,0 +1,687 @@ +package cn.iocoder.muse.server.framework.api; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.muse.framework.common.enums.UserTypeEnum; +import cn.iocoder.muse.framework.datasource.config.MuseDataSourceAutoConfiguration; +import cn.iocoder.muse.framework.mybatis.config.MuseMybatisAutoConfiguration; +import cn.iocoder.muse.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.muse.framework.security.core.LoginUser; +import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.muse.framework.tenant.config.TenantProperties; +import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.muse.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeBaseService; +import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeCommandService; +import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeBaseVO; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration; +import org.apache.ibatis.annotations.Mapper; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.output.MigrateResult; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.mybatis.spring.annotation.MapperScan; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import javax.sql.DataSource; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * P1R Knowledge «KB 绑 ≥2 作品后停用/删除» 真实 PostgreSQL 回归 IT。 + * + *

这是 studio e2e 真连揪出、生产可复现的 500 bug 的后端纵深守卫:用户把一个知识库绑定到 ≥2 个作品后, + * 停用(disable)或删除(delete)该 KB 必返 HTTP 200 但 body {@code code:500 系统异常}。根因是旧 + * {@code MuseKnowledgeBindingMapper.updateStatusByKbId} 把同一停用命令的 {@code commandId} 覆盖写到该 KB 的 + * 全部未删 binding 上,而 {@code uk_muse_knowledge_binding_command (tenant_id, command_id) WHERE command_id IS NOT NULL} + * 这一行级幂等唯一约束要求「一个 commandId 最多落一行 binding」,≥2 行写同值即 DuplicateKeyException。

+ * + *

反假绿要点(此前 {@code MuseKnowledgeBaseServiceTest} 用 Mockito 桩 {@code updateStatusByKbId} 返 3, + * 永远不触发真实唯一索引,故掩盖了本 bug):本 IT 连真实 PostgreSQL、跑真实 Flyway V14 schema(含该唯一索引)、 + * 经真实 {@link MuseKnowledgeBaseService} 与真实 mapper 写库,证明: + * ① KB 有 2 条 binding 时 disable / delete 成功返回(status 翻转、affectedBindings=2),不再抛 DuplicateKeyException; + * ② 传播后两条 binding 的 {@code binding_status} 都被阻断,且各自的 {@code command_id} 保持创建命令原值不被覆盖 + * (这正是修复的语义不变式——传播只翻状态、不动行级幂等键); + * ③ 反向自检:直接对两行 binding 写同一 command_id 仍会被唯一约束拒绝(证明索引真实存在、修复确有必要, + * 而非测试库恰好没建索引导致的假绿)。

+ * + *

只连显式传入的 {@code _test} 库(assertTestDatabaseUrl 守卫 + Flyway clean 会 DROP 全库), + * 密码只从环境变量读(assertNoPasswordSystemProperties);失败摘要对 JDBC URL / 用户名脱敏。

+ */ +@SpringBootTest( + classes = P1rKnowledgeBaseDisableMultiBindingIT.KnowledgeDisableConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class P1rKnowledgeBaseDisableMultiBindingIT { + + /** Knowledge schema 当前最高迁移版本(V5 建 binding 表与 uk_muse_knowledge_binding_command,V14 扩展)。 */ + private static final String TARGET_VERSION = "14"; + private static final Long TENANT_ID = 100L; + private static final Long OWNER_USER_ID = 2001L; + private static final String API_VERSION = "1"; + /** 绑定该 KB 的两个不同作品(binding 表对 work_id 无外键,直接用占位 id 即可触发多 binding 传播)。 */ + private static final Long WORK_ID_A = 9101L; + private static final Long WORK_ID_B = 9102L; + /** 各 binding 的创建命令 id(互不相同,模拟真实「每个 binding 由各自创建命令落库」),修复后传播不得覆盖它们。 */ + private static final String BINDING_A_COMMAND_ID = "kb-bind-create-work-a"; + private static final String BINDING_B_COMMAND_ID = "kb-bind-create-work-b"; + private static final Set CREDENTIAL_QUERY_KEYS = Set.of( + "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password", + "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token"); + + private static volatile KnowledgeDisableSettings cachedSettings; + private static volatile boolean originalFlywayPropertiesCaptured; + private static volatile String originalFlywayUrlSystemProperty; + private static volatile String originalFlywayUserSystemProperty; + + @Autowired + private DataSource dataSource; + @Autowired + private MuseKnowledgeBaseService knowledgeBaseService; + + /** 每个用例新建的 KB id(列 GENERATED ALWAYS,不能显式插入,故用 RETURNING 取真实 id)。 */ + private Long kbId; + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + KnowledgeDisableSettings settings = settings(); + redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser()); + registry.add("spring.application.name", () -> "p1r-knowledge-disable-multi-binding-it"); + registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module.knowledge"); + registry.add("spring.datasource.url", settings::jdbcUrl); + registry.add("spring.datasource.username", settings::jdbcUser); + registry.add("spring.datasource.password", settings::jdbcPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.main.banner-mode", () -> "off"); + registry.add("spring.main.lazy-initialization", () -> "true"); + registry.add("mybatis-plus.global-config.db-config.id-type", () -> "AUTO"); + } + + @BeforeAll + void migrateSchema() { + KnowledgeDisableSettings settings = settings(); + silenceFlywayInfoLogs(); + ensureTestDatabaseExists(settings); + Flyway flyway = Flyway.configure() + .dataSource(settings.jdbcUrl(), settings.jdbcUser(), settings.jdbcPassword()) + .locations(resolveMuseSqlLocation(settings.flywayLocations())) + .schemas("public") + .defaultSchema("public") + .target(TARGET_VERSION) + .cleanDisabled(false) + .load(); + cleanSchema(flyway, settings); + MigrateResult result = migrateSchema(flyway, settings); + assertTrue(result.migrationsExecuted >= 1, + "Knowledge disable IT 必须在隔离库执行 Flyway 迁移,实际: " + result.migrationsExecuted); + assertEquals(TARGET_VERSION, Objects.requireNonNull(result.targetSchemaVersion, + "迁移必须有目标版本"), + "Knowledge disable IT 必须迁移到 V14 schema(含 V5 唯一索引 uk_muse_knowledge_binding_command)"); + assertUniqueIndexPresent(); + } + + @BeforeEach + void setUp() throws SQLException { + resetTables(); + setRuntimeContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @AfterAll + void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } + + @Test + void should_disableUserKnowledgeBaseBoundToTwoWorks_onRealPostgresql_withoutDuplicateCommandIdConflict() + throws SQLException { + seedUserKnowledgeBase(); + Long bindingA = seedBinding(WORK_ID_A, BINDING_A_COMMAND_ID); + Long bindingB = seedBinding(WORK_ID_B, BINDING_B_COMMAND_ID); + assertEquals(2, undeletedBindingCount(), "前置:KB 必须先有 2 条未删 binding 才能复现 ≥2 绑定路径"); + + AppKnowledgeBaseVO.DisableReqVO reqVO = new AppKnowledgeBaseVO.DisableReqVO(); + reqVO.setCommandId("kb-disable-multi-binding"); + reqVO.setImpactConfirmed(true); + + // 旧实现在这一步会因两行 binding 被写入同一 commandId 而抛 DuplicateKeyException → 上层返 code:500。 + AppKnowledgeBaseVO.StatusRespVO respVO = + knowledgeBaseService.disableAppKnowledgeBase(OWNER_USER_ID, API_VERSION, kbId, reqVO); + + assertEquals("disabled", respVO.getStatus(), "disable 必须把 KB 状态翻转为 disabled"); + assertEquals(2, respVO.getAffectedBindings(), "disable 必须传播到该 KB 的全部 2 条 binding"); + // KB 自身状态落库。 + assertEquals("disabled", kbStatus(), "KB 行状态必须持久化为 disabled"); + // 两条 binding 状态都被阻断为 disabled。 + assertEquals("disabled", bindingStatus(bindingA), "binding A 必须被传播阻断为 disabled"); + assertEquals("disabled", bindingStatus(bindingB), "binding B 必须被传播阻断为 disabled"); + // 语义不变式:传播只翻 binding_status,不得覆盖各 binding 的创建命令幂等键。 + assertEquals(BINDING_A_COMMAND_ID, bindingCommandId(bindingA), + "传播不得覆盖 binding A 的创建命令 command_id(行级幂等键)"); + assertEquals(BINDING_B_COMMAND_ID, bindingCommandId(bindingB), + "传播不得覆盖 binding B 的创建命令 command_id(行级幂等键)"); + // disable 命令本身落一条 completed 幂等命令事实。 + assertEquals(1, commandCount("kb-disable-multi-binding"), "disable 成功必须写一条 completed 命令事实"); + } + + @Test + void should_deleteUserKnowledgeBaseBoundToTwoWorks_onRealPostgresql_withoutDuplicateCommandIdConflict() + throws SQLException { + seedUserKnowledgeBase(); + Long bindingA = seedBinding(WORK_ID_A, BINDING_A_COMMAND_ID); + Long bindingB = seedBinding(WORK_ID_B, BINDING_B_COMMAND_ID); + assertEquals(2, undeletedBindingCount(), "前置:KB 必须先有 2 条未删 binding"); + + AppKnowledgeBaseVO.DeleteReqVO reqVO = new AppKnowledgeBaseVO.DeleteReqVO(); + reqVO.setCommandId("kb-delete-multi-binding"); + reqVO.setImpactConfirmed(true); + reqVO.setRetentionConfirmed(true); + + AppKnowledgeBaseVO.StatusRespVO respVO = + knowledgeBaseService.deleteAppKnowledgeBase(OWNER_USER_ID, API_VERSION, kbId, reqVO); + + assertEquals("deleting", respVO.getStatus(), "delete 必须把 KB 状态翻转为 deleting"); + assertEquals(2, respVO.getAffectedBindings(), "delete 必须传播到该 KB 的全部 2 条 binding"); + assertEquals("deleting", kbStatus(), "KB 行状态必须持久化为 deleting"); + assertEquals("deleting", bindingStatus(bindingA), "binding A 必须被传播为 deleting"); + assertEquals("deleting", bindingStatus(bindingB), "binding B 必须被传播为 deleting"); + assertEquals(BINDING_A_COMMAND_ID, bindingCommandId(bindingA), + "传播不得覆盖 binding A 的创建命令 command_id"); + assertEquals(BINDING_B_COMMAND_ID, bindingCommandId(bindingB), + "传播不得覆盖 binding B 的创建命令 command_id"); + assertEquals(1, commandCount("kb-delete-multi-binding"), "delete 成功必须写一条 completed 命令事实"); + } + + @Test + void should_stillRejectWritingSameCommandIdToTwoBindings_provingUniqueIndexIsReal() throws SQLException { + // 反假绿:直接对两行 binding 写同一非空 command_id 必被 uk_muse_knowledge_binding_command 拒绝。 + // 这证明唯一索引真实存在于测试库——若不存在,本断言会失败,提示「测试库缺索引导致前两条用例假绿」。 + seedUserKnowledgeBase(); + Long bindingA = seedBinding(WORK_ID_A, BINDING_A_COMMAND_ID); + seedBinding(WORK_ID_B, BINDING_B_COMMAND_ID); + + SQLException conflict = assertThrows(SQLException.class, () -> { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement( + "UPDATE muse_knowledge_binding SET command_id = ? WHERE tenant_id = ? AND id <> ?")) { + // 把除 binding A 外的所有 binding 都改成 binding A 的 command_id → 与 binding A 撞唯一约束。 + statement.setString(1, BINDING_A_COMMAND_ID); + statement.setLong(2, TENANT_ID); + statement.setLong(3, bindingA); + statement.executeUpdate(); + } + }); + assertEquals("23505", conflict.getSQLState(), + "对两行 binding 写同一 command_id 必须触发唯一约束冲突(SQLState 23505),证明索引真实存在"); + } + + // —— 运行期上下文 —— // + + private void setRuntimeContext() { + TenantContextHolder.setTenantId(TENANT_ID); + LoginUser loginUser = new LoginUser(); + loginUser.setId(OWNER_USER_ID); + loginUser.setUserType(UserTypeEnum.MEMBER.getValue()); + loginUser.setTenantId(TENANT_ID); + loginUser.setVisitTenantId(TENANT_ID); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + + // —— 种子与重置 —— // + + private void resetTables() throws SQLException { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(""" + TRUNCATE TABLE + muse_knowledge_binding, + muse_knowledge_source_binding_projection, + muse_knowledge_source_event, + muse_knowledge_command, + muse_knowledge_base + RESTART IDENTITY CASCADE + """); + } + } + + private void seedUserKnowledgeBase() throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_knowledge_base + (name, description, kb_type, owner_user_id, status, active_version, + visibility_policy, command_id, revision, tenant_id) + VALUES ('多绑定回归 KB', '绑定到 2 个作品的用户知识库', 'user', ?, 'searchable', 1, + '{}'::jsonb, 'kb-create-multi-binding', 1, ?) + RETURNING id + """)) { + statement.setLong(1, OWNER_USER_ID); + statement.setLong(2, TENANT_ID); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "seed 用户 KB 必须返回 id"); + this.kbId = resultSet.getLong(1); + } + } + } + + private Long seedBinding(Long workId, String commandId) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_knowledge_binding + (work_id, kb_id, binding_type, binding_scope, binding_status, + source_snapshot_id, command_id, revision, tenant_id) + VALUES (?, ?, 'user_kb', 'search', 'active', ?, ?, 1, ?) + RETURNING id + """)) { + statement.setLong(1, workId); + statement.setLong(2, kbId); + statement.setString(3, "source-user_kb-" + kbId + "-v1"); + statement.setString(4, commandId); + statement.setLong(5, TENANT_ID); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "seed binding 必须返回 id"); + return resultSet.getLong(1); + } + } + } + + // —— 事实查询 —— // + + private String kbStatus() throws SQLException { + return queryString("SELECT status FROM muse_knowledge_base WHERE tenant_id = ? AND id = ?", TENANT_ID, kbId); + } + + private String bindingStatus(Long bindingId) throws SQLException { + return queryString("SELECT binding_status FROM muse_knowledge_binding WHERE tenant_id = ? AND id = ?", + TENANT_ID, bindingId); + } + + private String bindingCommandId(Long bindingId) throws SQLException { + return queryString("SELECT command_id FROM muse_knowledge_binding WHERE tenant_id = ? AND id = ?", + TENANT_ID, bindingId); + } + + private int undeletedBindingCount() throws SQLException { + return queryInt(""" + SELECT COUNT(*) FROM muse_knowledge_binding + WHERE tenant_id = ? AND kb_id = ? AND deleted = FALSE + """, TENANT_ID, kbId); + } + + private int commandCount(String commandId) throws SQLException { + return queryInt(""" + SELECT COUNT(*) FROM muse_knowledge_command + WHERE tenant_id = ? AND command_id = ? AND status = 'completed' + """, TENANT_ID, commandId); + } + + private int queryInt(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "计数查询必须返回一行"); + return resultSet.getInt(1); + } + } + } + + private String queryString(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "事实查询必须返回一行"); + return resultSet.getString(1); + } + } + } + + private void bind(PreparedStatement statement, Object... args) throws SQLException { + for (int i = 0; i < args.length; i++) { + Object value = args[i]; + if (value instanceof Long longValue) { + statement.setLong(i + 1, longValue); + } else if (value instanceof Integer intValue) { + statement.setInt(i + 1, intValue); + } else { + statement.setString(i + 1, String.valueOf(value)); + } + } + } + + private void assertUniqueIndexPresent() { + KnowledgeDisableSettings settings = settings(); + try (Connection connection = DriverManager.getConnection(settings.jdbcUrl(), settings.jdbcUser(), + settings.jdbcPassword()); + PreparedStatement statement = connection.prepareStatement(""" + SELECT COUNT(*) FROM pg_indexes + WHERE tablename = 'muse_knowledge_binding' + AND indexname = 'uk_muse_knowledge_binding_command' + """); + ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next() && resultSet.getInt(1) == 1, + "测试库必须存在 uk_muse_knowledge_binding_command 唯一索引,否则本回归无意义(假绿)"); + } catch (SQLException exception) { + throw sanitizedSqlFailure("校验唯一索引存在失败", settings, exception); + } + } + + // —— 连接设置与脱敏守卫(与既有 P1r IT 一致)—— // + + private static KnowledgeDisableSettings settings() { + if (cachedSettings == null) { + cachedSettings = KnowledgeDisableSettings.fromPropertiesAndEnvironment(); + } + return cachedSettings; + } + + private static void cleanSchema(Flyway flyway, KnowledgeDisableSettings settings) { + try { + flyway.clean(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception); + } + } + + private static MigrateResult migrateSchema(Flyway flyway, KnowledgeDisableSettings settings) { + try { + return flyway.migrate(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception); + } + } + + private static void ensureTestDatabaseExists(KnowledgeDisableSettings settings) { + String databaseName = jdbcDatabaseName(settings.jdbcUrl()); + try (Connection connection = DriverManager.getConnection( + maintenanceJdbcUrl(settings.jdbcUrl()), settings.jdbcUser(), settings.jdbcPassword()); + Statement statement = connection.createStatement()) { + connection.setAutoCommit(true); + statement.execute("CREATE DATABASE " + quotedIdentifier(databaseName)); + } catch (SQLException exception) { + if ("42P04".equals(exception.getSQLState())) { + return; + } + throw sanitizedSqlFailure("创建 PostgreSQL _test 数据库失败", settings, exception); + } + } + + private static AssertionError sanitizedFlywayFailure(String action, KnowledgeDisableSettings settings, + RuntimeException exception) { + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl())) + .replace("for user '" + settings.jdbcUser() + "'", "for user ''"); + return new AssertionError(action + ": " + sanitizedMessage); + } + + private static AssertionError sanitizedSqlFailure(String action, KnowledgeDisableSettings settings, + SQLException exception) { + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl())) + .replace(settings.jdbcUser(), ""); + return new AssertionError(action + ": " + sanitizedMessage); + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name); + return value; + } + + private static String requiredPasswordEnvironment() { + String password = firstNonBlankEnvironment("P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, "缺少必需环境变量: P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD"); + return password; + } + + private static String firstNonBlankEnvironment(String... names) { + for (String name : names) { + String value = System.getenv(name); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static void assertNoPasswordSystemProperties() { + Properties properties = System.getProperties(); + List passwordProperties = properties.stringPropertyNames().stream() + .filter(P1rKnowledgeBaseDisableMultiBindingIT::isForbiddenPasswordSystemProperty) + .sorted() + .toList(); + assertTrue(passwordProperties.isEmpty(), + "数据库密码不能通过 JVM system property 传入: " + passwordProperties); + } + + private static boolean isForbiddenPasswordSystemProperty(String name) { + String normalized = name.toLowerCase(Locale.ROOT); + return normalized.contains("password") + && (normalized.startsWith("p1r.") + || normalized.startsWith("p1r_") + || normalized.contains(".flyway.") + || normalized.contains(".knowledge.") + || normalized.contains(".datasource.")); + } + + private static void assertNoCredentialQuery(String url) { + int queryStart = url.indexOf('?'); + if (queryStart < 0) { + return; + } + String query = url.substring(queryStart + 1); + for (String parameter : query.split("&")) { + String key = parameter; + int equalsStart = key.indexOf('='); + if (equalsStart >= 0) { + key = key.substring(0, equalsStart); + } + assertTrue(!isCredentialQueryKey(key), + "p1r.flyway.url 不能携带凭据 query 参数;请通过用户名属性和密码环境变量传入"); + } + } + + private static boolean isCredentialQueryKey(String rawKey) { + String key = rawKey.trim().toLowerCase(Locale.ROOT).replace('-', '_'); + return CREDENTIAL_QUERY_KEYS.contains(key) + || key.endsWith("_token") + || key.endsWith("_secret") + || key.endsWith("_password"); + } + + private static void assertTestDatabaseUrl(String url) { + String databaseName = jdbcDatabaseName(url); + assertTrue(databaseName.endsWith("_test"), + "p1r.flyway.url 必须指向 _test 后缀隔离库,避免清理非测试库: " + maskedUrl(url)); + } + + private static String resolveMuseSqlLocation(String requestedLocations) { + assertEquals("filesystem:sql/muse", requestedLocations, + "Knowledge disable IT 要求显式使用 filesystem:sql/muse"); + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + String relativeLocation = requestedLocations.substring("filesystem:".length()); + for (Path cursor = current; cursor != null; cursor = cursor.getParent()) { + Path candidate = cursor.resolve(relativeLocation); + if (Files.isDirectory(candidate)) { + return "filesystem:" + candidate; + } + } + throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current); + } + + private static String jdbcDatabaseName(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1, + "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url)); + return urlWithoutQuery.substring(databaseStart + 1); + } + + private static String jdbcUrlWithoutQuery(String url) { + int queryStart = url.indexOf('?'); + return queryStart < 0 ? url : url.substring(0, queryStart); + } + + private static String maintenanceJdbcUrl(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1, + "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url)); + String querySuffix = url.indexOf('?') < 0 ? "" : url.substring(url.indexOf('?')); + return urlWithoutQuery.substring(0, databaseStart + 1) + "postgres" + querySuffix; + } + + private static String quotedIdentifier(String identifier) { + assertTrue(identifier.matches("[A-Za-z0-9_]+"), + "测试数据库名只能包含字母、数字和下划线: " + identifier); + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + private static String maskedUrl(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + if (databaseStart < 0) { + return maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url); + } + String prefix = urlWithoutQuery.substring(0, databaseStart + 1); + String database = urlWithoutQuery.substring(databaseStart + 1); + return maskJdbcHost(prefix) + database + maskedQuerySuffix(url); + } + + private static String maskedQuerySuffix(String url) { + return url.indexOf('?') < 0 ? "" : "?"; + } + + private static String maskJdbcHost(String urlPart) { + return urlPart.replaceAll("//([^:/?#]+)", "//"); + } + + private static void redactFlywaySystemProperties(String url, String user) { + captureOriginalFlywaySystemProperties(); + System.setProperty("p1r.flyway.url", maskedUrl(url)); + System.setProperty("p1r.flyway.user", user == null || user.isBlank() ? "" : ""); + } + + private static void captureOriginalFlywaySystemProperties() { + if (originalFlywayPropertiesCaptured) { + return; + } + originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url"); + originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user"); + originalFlywayPropertiesCaptured = true; + } + + private static void restoreOriginalFlywaySystemProperties() { + if (!originalFlywayPropertiesCaptured) { + return; + } + restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty); + restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty); + } + + private static void restoreSystemProperty(String name, String value) { + if (value == null) { + System.clearProperty(name); + return; + } + System.setProperty(name, value); + } + + private static void silenceFlywayInfoLogs() { + try { + Object flywayLogger = LoggerFactory.getLogger("org.flywaydb"); + Class levelClass = Class.forName("ch.qos.logback.classic.Level"); + Object warnLevel = levelClass.getField("WARN").get(null); + flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel); + } catch (ReflectiveOperationException | LinkageError ignored) { + // 日志实现不是 logback 时不影响迁移;测试自身仍只输出脱敏 URL。 + } + } + + private record KnowledgeDisableSettings(String jdbcUrl, String jdbcUser, String jdbcPassword, + String flywayLocations) { + + static KnowledgeDisableSettings fromPropertiesAndEnvironment() { + assertNoPasswordSystemProperties(); + String url = requiredProperty("p1r.flyway.url"); + String user = requiredProperty("p1r.flyway.user"); + String password = requiredPasswordEnvironment(); + String locations = requiredProperty("p1r.flyway.locations"); + assertNoCredentialQuery(url); + assertTestDatabaseUrl(url); + return new KnowledgeDisableSettings(url, user, password, locations); + } + } + + @SpringBootConfiguration + @MapperScan(basePackages = "cn.iocoder.muse.module.knowledge.dal.mysql.muse", annotationClass = Mapper.class) + @ImportAutoConfiguration({ + JacksonAutoConfiguration.class, + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + TransactionAutoConfiguration.class, + MuseDataSourceAutoConfiguration.class, + MuseMybatisAutoConfiguration.class, + MybatisPlusAutoConfiguration.class, + MybatisPlusJoinAutoConfiguration.class + }) + @Import({ + MuseKnowledgeBaseService.class, + MuseKnowledgeCommandService.class, + SpringUtil.class + }) + static class KnowledgeDisableConfiguration { + + @Bean + TenantLineInnerInterceptor tenantLineInnerInterceptor(MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor( + new TenantDatabaseInterceptor(new TenantProperties())); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return inner; + } + } +} diff --git a/muse-studio/e2e/global-setup.ts b/muse-studio/e2e/global-setup.ts index 1f2be661..820eb688 100644 --- a/muse-studio/e2e/global-setup.ts +++ b/muse-studio/e2e/global-setup.ts @@ -261,6 +261,20 @@ async function globalSetup(): Promise { console.warn('[e2e globalSetup] ⚠️ 未找到 KB id=1(knowledge 停用/恢复用例需基础 KB 种子)'); } + // 9b) knowledge 停用 ≥2 绑定回归锚点:确保 KB id=1 至少有 2 条未删 binding(binding id=1=work1 + 本条 work2), + // 使 F disable/restore e2e 真正走「KB 绑 ≥2 作品→传播停用」路径——这正是历史 500 bug 的触发条件 + // (旧 propagateKbBlocked 给同 KB 全部 binding 写同一 commandId,撞 uk_muse_knowledge_binding_command 唯一约束)。 + // 后端修复后传播只翻 binding_status、不再覆盖 command_id,本锚点用于守住该回归不复发。 + // 选 work2(owner=1、未占 (work,kb) uk):work1+kb1 已被 installed binding(§10,id=1)占,work4+kb1 由 handoff-knowledge + // spec 运行时占用,故必须避开二者,选 work2。幂等:先按 (work2,kb1) 删再插(释放 uk_muse_knowledge_binding_work_kb), + // command_id 用本条自身创建命令(与 binding id=1 不同值),模拟真实「各 binding 各自创建命令」语义。 + await client.query('DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND work_id=2 AND kb_id=1'); + await client.query( + `INSERT INTO muse_knowledge_binding + (work_id, kb_id, binding_type, binding_scope, binding_status, source_snapshot_id, command_id, revision, tenant_id) + VALUES (2,1,'user_kb','search','active','source-user_kb-1-v1','e2e-seed-kb1-binding-work2',1,1)` + ); + // 10) 已安装知识库停用/恢复:复位 installed binding(id=1)binding_status='active'——供 G installed disable/restore e2e // 幂等重跑(开局 active→先显示停用;e2e disable→'disabled'、restore→'active' 均归位)。 const installedReset = await client.query(