fix(binding): drop @TableLogic on agent-tool/skill to fix rebind error

This commit is contained in:
matevip 2026-04-18 23:15:56 +08:00
parent cf8a6b9fcb
commit 5aed6f176d
6 changed files with 170 additions and 7 deletions

View File

@ -17,6 +17,5 @@ public class AgentSkillBinding {
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -16,6 +16,5 @@ public class AgentToolBinding {
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -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:

View File

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

View File

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

View File

@ -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<String> 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<Long> ids = bindingService.getBoundSkillIds(agentId);
assertNotNull(ids);
assertTrue(ids.contains(skillId));
}
@Test
@DisplayName("setToolBindings 连续调用两次相同列表不抛异常,状态收敛")
void setToolBindingsIsIdempotent() {
List<String> desired = List.of("tool_a", "tool_b");
bindingService.setToolBindings(agentId, desired);
assertDoesNotThrow(() -> bindingService.setToolBindings(agentId, desired));
Set<String> names = bindingService.getBoundToolNames(agentId);
assertNotNull(names);
assertEquals(2, names.size());
assertTrue(names.containsAll(desired));
}
@Test
@DisplayName("setSkillBindings 连续调用两次相同列表不抛异常,状态收敛")
void setSkillBindingsIsIdempotent() {
List<Long> desired = List.of(7_777_101L, 7_777_102L);
bindingService.setSkillBindings(agentId, desired);
assertDoesNotThrow(() -> bindingService.setSkillBindings(agentId, desired));
Set<Long> 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 失败)");
}
}