diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentSkillBinding.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentSkillBinding.java index 6170ebb4..08ec9447 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentSkillBinding.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentSkillBinding.java @@ -17,6 +17,5 @@ public class AgentSkillBinding { private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentToolBinding.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentToolBinding.java index 055cf8e7..c9e1a342 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentToolBinding.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentToolBinding.java @@ -16,6 +16,5 @@ public class AgentToolBinding { private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic private Integer deleted; } diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index cd189fd0..badfbc51 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -78,11 +78,6 @@ mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl - global-config: - db-config: - logic-delete-field: deleted - logic-delete-value: 1 - logic-not-delete-value: 0 # SpringDoc OpenAPI springdoc: diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V17__purge_binding_soft_deleted_rows.sql b/mateclaw-server/src/main/resources/db/migration/h2/V17__purge_binding_soft_deleted_rows.sql new file mode 100644 index 00000000..0607dbc9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V17__purge_binding_soft_deleted_rows.sql @@ -0,0 +1,10 @@ +-- V17: Purge soft-deleted rows from binding tables. +-- mate_agent_tool / mate_agent_skill previously had @TableLogic on their `deleted` +-- column, but the unique indexes uk_agent_tool(agent_id, tool_name) / +-- uk_agent_skill(agent_id, skill_id) did not include `deleted`. Any rebind after +-- an unbind therefore hit a duplicate-key error because the soft-deleted row +-- still occupied the unique slot. Soft-delete has been removed for these tables +-- (see AgentToolBinding / AgentSkillBinding) — clear residual deleted=1 rows so +-- pre-existing deployments can rebind the affected pairs. +DELETE FROM mate_agent_tool WHERE deleted = 1; +DELETE FROM mate_agent_skill WHERE deleted = 1; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V17__purge_binding_soft_deleted_rows.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V17__purge_binding_soft_deleted_rows.sql new file mode 100644 index 00000000..0607dbc9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V17__purge_binding_soft_deleted_rows.sql @@ -0,0 +1,10 @@ +-- V17: Purge soft-deleted rows from binding tables. +-- mate_agent_tool / mate_agent_skill previously had @TableLogic on their `deleted` +-- column, but the unique indexes uk_agent_tool(agent_id, tool_name) / +-- uk_agent_skill(agent_id, skill_id) did not include `deleted`. Any rebind after +-- an unbind therefore hit a duplicate-key error because the soft-deleted row +-- still occupied the unique slot. Soft-delete has been removed for these tables +-- (see AgentToolBinding / AgentSkillBinding) — clear residual deleted=1 rows so +-- pre-existing deployments can rebind the affected pairs. +DELETE FROM mate_agent_tool WHERE deleted = 1; +DELETE FROM mate_agent_skill WHERE deleted = 1; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java new file mode 100644 index 00000000..c94a951b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java @@ -0,0 +1,150 @@ +package vip.mate.agent.binding; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.agent.binding.model.AgentToolBinding; +import vip.mate.agent.binding.service.AgentBindingService; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 覆盖 issue #8 的重绑定 bug 回归:在去掉 @TableLogic 之前, + * bind → unbind → rebind 会因为 uk_agent_tool / uk_agent_skill 唯一索引 + * 与软删除并存而抛 DuplicateKeyException。本测试断言修复后各条路径都成功, + * 同时断言合法的唯一约束仍被保留(不能让修 bug 顺带破坏唯一性)。 + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:binding_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none" +}) +class AgentBindingServiceTest { + + private static final AtomicLong AGENT_ID_SEQ = new AtomicLong(9_000_000L); + + @Autowired + private AgentBindingService bindingService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private long agentId; + + @BeforeEach + void setUp() { + // 每个用例用独立 agent_id,互不干扰 + agentId = AGENT_ID_SEQ.getAndIncrement(); + } + + @Test + @DisplayName("bindTool → unbindTool → bindTool 同一 (agent, tool) 不抛异常") + void rebindToolAfterUnbind() { + bindingService.bindTool(agentId, "echo"); + bindingService.unbindTool(agentId, "echo"); + assertDoesNotThrow(() -> bindingService.bindTool(agentId, "echo")); + + Set names = bindingService.getBoundToolNames(agentId); + assertNotNull(names); + assertTrue(names.contains("echo")); + } + + @Test + @DisplayName("bindSkill → unbindSkill → bindSkill 同一 (agent, skill) 不抛异常") + void rebindSkillAfterUnbind() { + long skillId = 7_777_001L; + bindingService.bindSkill(agentId, skillId); + bindingService.unbindSkill(agentId, skillId); + assertDoesNotThrow(() -> bindingService.bindSkill(agentId, skillId)); + + Set ids = bindingService.getBoundSkillIds(agentId); + assertNotNull(ids); + assertTrue(ids.contains(skillId)); + } + + @Test + @DisplayName("setToolBindings 连续调用两次相同列表不抛异常,状态收敛") + void setToolBindingsIsIdempotent() { + List desired = List.of("tool_a", "tool_b"); + bindingService.setToolBindings(agentId, desired); + assertDoesNotThrow(() -> bindingService.setToolBindings(agentId, desired)); + + Set names = bindingService.getBoundToolNames(agentId); + assertNotNull(names); + assertEquals(2, names.size()); + assertTrue(names.containsAll(desired)); + } + + @Test + @DisplayName("setSkillBindings 连续调用两次相同列表不抛异常,状态收敛") + void setSkillBindingsIsIdempotent() { + List desired = List.of(7_777_101L, 7_777_102L); + bindingService.setSkillBindings(agentId, desired); + assertDoesNotThrow(() -> bindingService.setSkillBindings(agentId, desired)); + + Set ids = bindingService.getBoundSkillIds(agentId); + assertNotNull(ids); + assertEquals(2, ids.size()); + assertTrue(ids.containsAll(desired)); + } + + @Test + @DisplayName("唯一性回归:同一 (agent, tool) 直接 INSERT 第二行仍被唯一索引拦截") + void uniqueIndexStillEnforcedForTool() { + bindingService.bindTool(agentId, "unique_probe"); + + // 绕过 service,直接 INSERT 第二行,断言 DB 层唯一约束仍生效 + assertThrows(DuplicateKeyException.class, () -> + jdbcTemplate.update( + "INSERT INTO mate_agent_tool " + + "(id, agent_id, tool_name, enabled, create_time, update_time, deleted) " + + "VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + System.nanoTime(), agentId, "unique_probe" + ) + ); + } + + @Test + @DisplayName("唯一性回归:同一 (agent, skill) 直接 INSERT 第二行仍被唯一索引拦截") + void uniqueIndexStillEnforcedForSkill() { + long skillId = 7_777_201L; + bindingService.bindSkill(agentId, skillId); + + assertThrows(DuplicateKeyException.class, () -> + jdbcTemplate.update( + "INSERT INTO mate_agent_skill " + + "(id, agent_id, skill_id, enabled, create_time, update_time, deleted) " + + "VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + System.nanoTime(), agentId, skillId + ) + ); + } + + @Test + @DisplayName("unbindTool 后 DB 里真的没行(物理 delete,不是软删留 deleted=1)") + void unbindPhysicallyRemovesRow() { + bindingService.bindTool(agentId, "physical_check"); + bindingService.unbindTool(agentId, "physical_check"); + + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mate_agent_tool WHERE agent_id = ? AND tool_name = ?", + Integer.class, agentId, "physical_check" + ); + assertNotNull(count); + assertEquals(0, count, "unbind 应该物理删除,而不是软删(软删会留 deleted=1 行,占用唯一索引槽位导致 rebind 失败)"); + } +}