fix(knowledge): 修 KB 绑 ≥2 作品停用/删除必 500 真后端 bug + 补真 PG IT(studio e2e 真连揪出)

studio e2e knowledge-disable-restore 真连揪出 mock 单测掩盖的生产 bug:任何用户把知识库绑到 ≥2 作品后无法停用/删除(disable POST HTTP 200 但 body code:500)。根因:updateStatusByKbId 给该 KB 所有未删 binding 写同一停用 commandId→违反 uk_muse_knowledge_binding_command(tenant_id,command_id)唯一索引→DuplicateKeyException。

command_id 语义=「创建/最后改写该 binding 的命令」行级幂等键(单行写路径 create/delete/updateInstalled 各写一行;对称 source projection 无 command_id、用 last_event_id 印证 KB 级 fan-out 不占行级幂等槽);KB 级停用是 KB 维度单命令(commandId 已记 muse_knowledge_base+source_event)、非 binding 创建命令。修(候选①传播不覆盖 command_id):updateStatusByKbId(Long,String,String)→(Long,String)删 setCommandId 只翻 binding_status;propagateKbBlocked 去 commandId 参传 sourceEventId;disableGlobalKnowledgeBase(:190)/changeUserStatus(:452 user disable+delete)2 调用点同步。

影响面=全局/用户 KB disable+用户 KB delete 三传播路径,行为变化仅不再改写 binding.command_id(binding_status/affectedBindings/source projection 不变);对外契约不变、纯修 500;回滚 git checkout。

mock 单测掩盖根因(mock updateStatusByKbId 返行数、不触发真唯一索引)。补真 PG IT P1rKnowledgeBaseDisableMultiBindingIT(真 PG+Flyway V14+真 service/mapper):KB 绑 2 作品 disable/delete 传播成功+两 binding 各保留 command_id;反假绿用例直写同 command_id 仍被唯一约束拒(SQLState 23505)。修后 3/3 绿;临时重引入 bug→2/3 DuplicateKeyException 证真回归门禁。独立核实:e2e 连跑 3 次 2 passed(修前红);psql 复核 KB1 2 binding(work1+work2)全 disabled、command_id 各异无冲突;global-setup 补 9b 多 binding 锚点(KB1+work2)令 e2e 真走 ≥2 binding 传播路径。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-25 18:20:50 -07:00
parent d8badcde11
commit 0b152d9c6d
5 changed files with 721 additions and 12 deletions

View File

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

View File

@ -35,10 +35,15 @@ public interface MuseKnowledgeBindingMapper extends BaseMapperX<MuseKnowledgeBin
.groupBy(MuseKnowledgeBindingDO::getWorkId)).size();
}
default int updateStatusByKbId(Long kbId, String bindingStatus, String commandId) {
default int updateStatusByKbId(Long kbId, String bindingStatus) {
MuseKnowledgeBindingDO update = new MuseKnowledgeBindingDO();
update.setBindingStatus(bindingStatus);
update.setCommandId(commandId);
// WHY 不写 command_idcommand_id 创建/最后改写该 binding 的命令这一行级幂等键
// uk_muse_knowledge_binding_command (tenant_id, command_id) 唯一约束保护要求一个命令最多落一行 binding
// KB 级停用/删除是 KB 维度的单条命令 commandId 已记在 muse_knowledge_base 行与 muse_knowledge_source_event
// 不是任何 binding 的创建命令若把同一 commandId 覆盖到该 KB 的全部 binding
// KB 2 作品时多行会写入相同 (tenant_id, command_id) 触发 DuplicateKeyException 致停用/删除 500
// 因此传播只翻转 binding_status保留各 binding 自身的创建命令幂等键不变
// 绑定状态传播只更新当前 KB 的未删除绑定避免触碰其他 KB 或历史删除事实
return update(update, new LambdaUpdateWrapper<MuseKnowledgeBindingDO>()
.eq(MuseKnowledgeBindingDO::getKbId, kbId)

View File

@ -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());
}

View File

@ -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
*
* <p>这是 studio e2e 真连揪出生产可复现的 500 bug 的后端纵深守卫:用户把一个知识库绑定到 2 个作品后,
* 停用(disable)或删除(delete) KB 必返 HTTP 200 body {@code code:500 系统异常}根因是旧
* {@code MuseKnowledgeBindingMapper.updateStatusByKbId} 把同一停用命令的 {@code commandId} 覆盖写到该 KB
* <b>全部</b>未删 binding , {@code uk_muse_knowledge_binding_command (tenant_id, command_id) WHERE command_id IS NOT NULL}
* 这一行级幂等唯一约束要求一个 commandId 最多落一行 binding,2 行写同值即 DuplicateKeyException</p>
*
* <p><b>反假绿要点</b>(此前 {@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} 保持<b>创建命令</b>原值不被覆盖
* (这正是修复的语义不变式传播只翻状态不动行级幂等键);
* 反向自检:直接对两行 binding <b>同一</b> command_id 仍会被唯一约束拒绝(证明索引真实存在修复确有必要,
* 而非测试库恰好没建索引导致的假绿)</p>
*
* <p>只连显式传入的 {@code _test} (assertTestDatabaseUrl 守卫 + Flyway clean DROP 全库),
* 密码只从环境变量读(assertNoPasswordSystemProperties);失败摘要对 JDBC URL / 用户名脱敏</p>
*/
@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<String> 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 '<user-redacted>'");
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(), "<user-redacted>");
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<String> 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 ? "" : "?<query-redacted>";
}
private static String maskJdbcHost(String urlPart) {
return urlPart.replaceAll("//([^:/?#]+)", "//<host>");
}
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() ? "<user-redacted>" : "<user-redacted>");
}
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;
}
}
}

View File

@ -261,6 +261,20 @@ async function globalSetup(): Promise<void> {
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(