diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java index bc4bd650..5e232cc6 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java @@ -61,7 +61,7 @@ public class DatasourceTool { """) public String query_datasource( @ToolParam(description = "动作:list_datasources / list_tables / describe_table") String action, - @ToolParam(description = "数据源 ID(list_tables 和 describe_table 时必填)", required = false) Long datasourceId, + @ToolParam(description = "数据源 ID(list_tables 和 describe_table 时必填)。必须作为字符串传入,避免大整数精度丢失", required = false) String datasourceId, @ToolParam(description = "表名(describe_table 时必填)", required = false) String tableName) { try { @@ -208,4 +208,24 @@ public class DatasourceTool { private String error(String message) { return JSONUtil.toJsonStr(new JSONObject().set("error", message)); } + + private Long parseDatasourceId(String datasourceId, String action) { + String trimmed = datasourceId != null ? datasourceId.trim() : ""; + if (trimmed.isEmpty()) { + throw new IllegalArgumentException(action + " 需要 datasourceId 参数"); + } + try { + return Long.parseLong(trimmed); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("datasourceId 必须是数字字符串"); + } + } + + private String listTables(String datasourceId) throws SQLException { + return listTables(parseDatasourceId(datasourceId, "list_tables")); + } + + private String describeTable(String datasourceId, String tableName) throws SQLException { + return describeTable(parseDatasourceId(datasourceId, "describe_table"), tableName); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SqlQueryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SqlQueryTool.java index c04acdba..c2e6c107 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SqlQueryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SqlQueryTool.java @@ -47,16 +47,17 @@ public class SqlQueryTool { 如果数据适合可视化,会自动附带一个 echarts 图表配置代码块,前端会自动渲染为交互式图表。 """) public String execute_sql( - @ToolParam(description = "目标数据源 ID") Long datasourceId, + @ToolParam(description = "目标数据源 ID。必须作为字符串传入,避免大整数精度丢失") String datasourceId, @ToolParam(description = "要执行的 SQL 查询(仅允许 SELECT)") String sql) { try { + Long parsedDatasourceId = parseDatasourceId(datasourceId); // 1. 验证并规范化 SQL String safeSql = sqlValidationService.validateAndNormalize(sql); - log.info("执行 SQL 查询 [数据源 {}]: {}", datasourceId, safeSql); + log.info("执行 SQL 查询 [数据源 {}]: {}", parsedDatasourceId, safeSql); // 2. 获取数据源连接 - DatasourceEntity entity = datasourceService.getDecrypted(datasourceId); + DatasourceEntity entity = datasourceService.getDecrypted(parsedDatasourceId); // 3. 执行查询 long startTime = System.currentTimeMillis(); @@ -79,6 +80,18 @@ public class SqlQueryTool { } } + private Long parseDatasourceId(String datasourceId) { + String trimmed = datasourceId != null ? datasourceId.trim() : ""; + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("datasourceId is required"); + } + try { + return Long.parseLong(trimmed); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("datasourceId must be a numeric string"); + } + } + private String formatResult(ResultSet rs, String sql, long elapsedMs) throws SQLException { ResultSetMetaData meta = rs.getMetaData(); int colCount = meta.getColumnCount(); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolIdPrecisionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolIdPrecisionTest.java index ca20f78a..f2dc5706 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolIdPrecisionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolIdPrecisionTest.java @@ -1,14 +1,18 @@ package vip.mate.tool.builtin; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import com.fasterxml.jackson.databind.module.SimpleModule; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; import vip.mate.datasource.model.DatasourceEntity; import vip.mate.datasource.service.DatasourceConnectionManager; import vip.mate.datasource.service.DatasourceService; +import vip.mate.datasource.service.SqlValidationService; import java.util.List; @@ -56,4 +60,36 @@ class DatasourceToolIdPrecisionTest { assertFalse(out.contains(": " + bigId) || out.contains(":" + bigId), "id must NOT appear as a bare JSON number (precision-lossy across double/JS Number)"); } + + @Test + @DisplayName("datasource tools publish datasourceId as a string parameter so LLM tool calls preserve precision") + void datasourceIdSchemasAreString() throws Exception { + DatasourceTool datasourceTool = new DatasourceTool( + mock(DatasourceService.class), + mock(DatasourceConnectionManager.class), + idSafeMapper()); + SqlQueryTool sqlQueryTool = new SqlQueryTool( + mock(DatasourceService.class), + mock(DatasourceConnectionManager.class), + mock(SqlValidationService.class)); + + assertDatasourceIdIsString(datasourceTool, "query_datasource"); + assertDatasourceIdIsString(sqlQueryTool, "execute_sql"); + } + + private static void assertDatasourceIdIsString(Object tool, String name) throws Exception { + JsonNode root = idSafeMapper().readTree(callback(tool, name).getToolDefinition().inputSchema()); + + assertTrue("string".equals(root.at("/properties/datasourceId/type").asText()), + name + " datasourceId must be a string schema"); + } + + private static ToolCallback callback(Object tool, String name) { + for (ToolCallback callback : ToolCallbacks.from(tool)) { + if (name.equals(callback.getToolDefinition().name())) { + return callback; + } + } + throw new AssertionError("Missing tool callback: " + name); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolPostgresqlViewDiscoveryTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolPostgresqlViewDiscoveryTest.java index ba2b48a0..1984c991 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolPostgresqlViewDiscoveryTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DatasourceToolPostgresqlViewDiscoveryTest.java @@ -52,7 +52,7 @@ class DatasourceToolPostgresqlViewDiscoveryTest { DatasourceTool tool = new DatasourceTool(service, connectionManager, JsonMapper.builder().build()); // When the agent asks MateClaw to discover available relations. - String output = tool.query_datasource("list_tables", 1L, null); + String output = tool.query_datasource("list_tables", "1", null); // Then the metadata query must include views and return the discovered view. ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);