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:
parent
d8badcde11
commit
0b152d9c6d
@ -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;
|
||||
}
|
||||
|
||||
@ -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_id:command_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)
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user