diff --git a/easyflow-api/easyflow-api-admin/pom.xml b/easyflow-api/easyflow-api-admin/pom.xml index 1739df30..56d4edc9 100644 --- a/easyflow-api/easyflow-api-admin/pom.xml +++ b/easyflow-api/easyflow-api-admin/pom.xml @@ -40,6 +40,10 @@ tech.easyflow easyflow-module-job + + tech.easyflow + easyflow-module-dataspace + tech.easyflow easyflow-common-captcha diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dataspace/DataspaceConnectionController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dataspace/DataspaceConnectionController.java new file mode 100644 index 00000000..9b6ec6a0 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dataspace/DataspaceConnectionController.java @@ -0,0 +1,161 @@ +package tech.easyflow.admin.controller.dataspace; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import java.math.BigInteger; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.dataspace.model.ConnectionDefinition; +import tech.easyflow.dataspace.model.ConnectionView; +import tech.easyflow.dataspace.model.ObjectView; +import tech.easyflow.dataspace.provider.DataspaceProbe; +import tech.easyflow.dataspace.service.DataspaceConnectionService; + +/** + * 数据空间物理连接管理端 API。 + */ +@RestController +@RequestMapping("/api/v1/dataspaceConnection") +public class DataspaceConnectionController { + + private final DataspaceConnectionService connectionService; + + /** + * 创建连接控制器。 + * + * @param connectionService 连接服务 + */ + public DataspaceConnectionController(DataspaceConnectionService connectionService) { + this.connectionService = connectionService; + } + + /** + * 查询当前租户连接列表。 + * + * @param keyword 搜索关键词 + * @return 连接列表 + */ + @GetMapping("/list") + @SaCheckPermission("/api/v1/dataspaceConnection/query") + public Result> list(String keyword) { + return Result.ok(connectionService.list(keyword)); + } + + /** + * 获取连接详情。 + * + * @param id 连接 ID + * @return 连接详情 + */ + @GetMapping("/detail") + @SaCheckPermission("/api/v1/dataspaceConnection/query") + public Result detail(BigInteger id) { + return Result.ok(connectionService.detail(id)); + } + + /** + * 测试候选或已保存连接。 + * + * @param definition 连接定义 + * @return 测试结果 + */ + @PostMapping("/test") + @SaCheckPermission("/api/v1/dataspaceConnection/test") + public Result test( + @JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) { + return Result.ok(connectionService.test(definition)); + } + + /** + * 创建或更新连接。 + * + * @param definition 连接定义 + * @return 保存后的连接 + */ + @PostMapping("/save") + @SaCheckPermission("/api/v1/dataspaceConnection/save") + public Result save( + @JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) { + return Result.ok(connectionService.save(definition)); + } + + /** + * 启用或禁用连接。 + * + * @param request 状态变更请求 + * @return 变更后的连接 + */ + @PostMapping("/status") + @SaCheckPermission("/api/v1/dataspaceConnection/save") + public Result status( + @JsonBody(required = true, skipConvertError = false) StatusRequest request) { + if (request == null || request.enabled() == null) { + throw new BusinessException("连接状态不能为空"); + } + return Result.ok(connectionService.setEnabled(request.id(), request.enabled())); + } + + /** + * 查询当前连接的对象树数据。 + * + * @param connectionId 连接 ID + * @param keyword Schema 或表名关键词 + * @return 对象列表 + */ + @GetMapping("/objects") + @SaCheckPermission("/api/v1/dataspaceConnection/query") + public Result> objects(BigInteger connectionId, String keyword) { + return Result.ok(connectionService.objects(connectionId, keyword)); + } + + /** + * 刷新连接元数据。 + * + * @param request 刷新请求 + * @return 新 revision 对象列表 + */ + @PostMapping("/refreshMetadata") + @SaCheckPermission("/api/v1/dataspaceConnection/metadata") + public Result> refreshMetadata( + @JsonBody(required = true, skipConvertError = false) RefreshRequest request) { + return Result.ok(connectionService.refreshMetadata( + request.connectionId(), request.expectedRevision())); + } + + /** + * 删除未被引用的连接。 + * + * @param id 连接 ID + * @return 成功结果 + */ + @PostMapping("/remove") + @SaCheckPermission("/api/v1/dataspaceConnection/remove") + public Result remove( + @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) { + connectionService.remove(id); + return Result.ok(); + } + + /** + * 元数据刷新请求。 + * + * @param connectionId 连接 ID + * @param expectedRevision 期望 revision + */ + public record RefreshRequest(BigInteger connectionId, long expectedRevision) { + } + + /** + * 连接状态变更请求。 + * + * @param id 连接 ID + * @param enabled 是否启用 + */ + public record StatusRequest(BigInteger id, Boolean enabled) { + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dataspace/DataspaceController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dataspace/DataspaceController.java new file mode 100644 index 00000000..ebc35f04 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dataspace/DataspaceController.java @@ -0,0 +1,113 @@ +package tech.easyflow.admin.controller.dataspace; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import java.math.BigInteger; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.dataspace.model.DataspaceDefinition; +import tech.easyflow.dataspace.model.DataspaceSummary; +import tech.easyflow.dataspace.model.DataspaceView; +import tech.easyflow.dataspace.service.DataspaceService; + +/** + * 虚拟数据空间管理端 API。 + */ +@RestController +@RequestMapping("/api/v1/dataspace") +public class DataspaceController { + + private final DataspaceService dataspaceService; + + /** + * 创建数据空间控制器。 + * + * @param dataspaceService 数据空间服务 + */ + public DataspaceController(DataspaceService dataspaceService) { + this.dataspaceService = dataspaceService; + } + + /** + * 查询数据空间列表。 + * + * @param keyword 搜索关键词 + * @return 数据空间摘要 + */ + @GetMapping("/list") + @SaCheckPermission("/api/v1/dataspace/query") + public Result> list(String keyword) { + return Result.ok(dataspaceService.list(keyword)); + } + + /** + * 获取数据空间当前 revision 详情。 + * + * @param id 数据空间 ID + * @return 数据空间详情 + */ + @GetMapping("/detail") + @SaCheckPermission("/api/v1/dataspace/detail") + public Result detail(BigInteger id) { + return Result.ok(dataspaceService.detail(id)); + } + + /** + * 保存数据空间并生成新 revision。 + * + * @param definition 数据空间定义 + * @return 保存后的详情 + */ + @PostMapping("/save") + @SaCheckPermission("/api/v1/dataspace/save") + public Result save( + @RequestBody DataspaceDefinition definition) { + return Result.ok(dataspaceService.save(definition)); + } + + /** + * 启用或禁用数据空间。 + * + * @param request 状态变更请求 + * @return 成功结果 + */ + @PostMapping("/status") + @SaCheckPermission("/api/v1/dataspace/save") + public Result status( + @JsonBody(required = true, skipConvertError = false) StatusRequest request) { + if (request == null || request.enabled() == null) { + throw new BusinessException("数据空间状态不能为空"); + } + dataspaceService.setEnabled(request.id(), request.enabled()); + return Result.ok(); + } + + /** + * 逻辑删除数据空间。 + * + * @param id 数据空间 ID + * @return 成功结果 + */ + @PostMapping("/remove") + @SaCheckPermission("/api/v1/dataspace/remove") + public Result remove( + @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) { + dataspaceService.remove(id); + return Result.ok(); + } + + /** + * 数据空间状态变更请求。 + * + * @param id 数据空间 ID + * @param enabled 是否启用 + */ + public record StatusRequest(BigInteger id, Boolean enabled) { + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dataspace/DataspaceSqlController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dataspace/DataspaceSqlController.java new file mode 100644 index 00000000..e8917eac --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/dataspace/DataspaceSqlController.java @@ -0,0 +1,86 @@ +package tech.easyflow.admin.controller.dataspace; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.dataspace.model.DataspaceExplainResult; +import tech.easyflow.dataspace.model.DataspaceQueryRequest; +import tech.easyflow.dataspace.model.DataspaceQueryResult; +import tech.easyflow.dataspace.model.DataspaceSqlCompletionRequest; +import tech.easyflow.dataspace.model.DataspaceSqlCompletionResult; +import tech.easyflow.dataspace.service.DataspaceQueryService; + +/** + * 数据空间 SQL 工作台 Query、Explain、Complete 与 Cancel API。 + */ +@RestController +@RequestMapping("/api/v1/dataspaceSql") +public class DataspaceSqlController { + + private final DataspaceQueryService queryService; + + /** + * 创建 SQL 控制器。 + * + * @param queryService 查询服务 + */ + public DataspaceSqlController(DataspaceQueryService queryService) { + this.queryService = queryService; + } + + /** + * 执行只读 SQL。 + * + * @param request 查询请求 + * @return 查询结果与指标 + */ + @PostMapping("/query") + @SaCheckPermission("/api/v1/dataspaceSql/query") + public Result query( + @JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) { + return Result.ok(queryService.query(request)); + } + + /** + * 显式执行非 ANALYZE Explain。 + * + * @param request Explain 请求 + * @return Explain 与索引信息 + */ + @PostMapping("/explain") + @SaCheckPermission("/api/v1/dataspaceSql/explain") + public Result explain( + @JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) { + return Result.ok(queryService.explain(request)); + } + + /** + * 返回当前数据空间内的 Calcite SQL 补全候选。 + * + * @param request 补全请求 + * @return 补全替换区间与候选 + */ + @PostMapping("/complete") + @SaCheckPermission("/api/v1/dataspaceSql/query") + public Result complete( + @JsonBody(required = true, skipConvertError = false) + DataspaceSqlCompletionRequest request) { + return Result.ok(queryService.complete(request)); + } + + /** + * 尝试取消当前节点查询。 + * + * @param queryId 查询 ID + * @return 是否找到并发起取消 + */ + @PostMapping("/cancel") + @SaCheckPermission("/api/v1/dataspaceSql/query") + public Result cancel( + @JsonBody(value = "queryId", required = true, skipConvertError = false) String queryId) { + return Result.ok(queryService.cancel(queryId)); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/dataspace/DataspaceControllerContractTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/dataspace/DataspaceControllerContractTest.java new file mode 100644 index 00000000..e5e7f419 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/dataspace/DataspaceControllerContractTest.java @@ -0,0 +1,77 @@ +package tech.easyflow.admin.controller.dataspace; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import org.springframework.web.bind.annotation.RequestBody; +import org.testng.annotations.Test; +import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.dataspace.model.DataspaceDefinition; + +/** + * 数据空间管理接口请求绑定契约测试。 + */ +public class DataspaceControllerContractTest { + + /** + * 验证保存接口使用 Jackson 请求体绑定,避免嵌套定义残留为 JSONObject。 + * + * @throws Exception 反射或 JSON 转换失败时抛出 + */ + @Test + public void shouldBindNestedDataspaceDefinitionWithJackson() throws Exception { + Method method = DataspaceController.class.getMethod( + "save", DataspaceDefinition.class); + Parameter parameter = method.getParameters()[0]; + assertNotNull(parameter.getAnnotation(RequestBody.class)); + assertNull(parameter.getAnnotation(JsonBody.class)); + + String request = """ + { + "name": "网点经营分析", + "tables": [ + { + "clientKey": "table:outlet", + "objectId": "1001", + "sourceAlias": "MYSQL_1", + "schemaAlias": "MAIN", + "tableAlias": "outlet", + "positionX": 80, + "positionY": 120 + }, + { + "clientKey": "table:region", + "objectId": "1002", + "sourceAlias": "PG_1", + "schemaAlias": "PUBLIC", + "tableAlias": "outlet_region", + "positionX": 420, + "positionY": 120 + } + ], + "relations": [ + { + "leftClientKey": "table:outlet", + "rightClientKey": "table:region", + "joinType": "INNER", + "leftColumn": "institution_id", + "rightColumn": "institution_id" + } + ] + } + """; + + DataspaceDefinition definition = new ObjectMapper().readValue( + request, DataspaceDefinition.class); + + assertEquals(2, definition.tables().size()); + assertEquals("outlet", definition.tables().get(0).tableAlias()); + assertEquals("outlet_region", definition.tables().get(1).tableAlias()); + assertEquals(1, definition.relations().size()); + assertEquals("institution_id", definition.relations().get(0).leftColumn()); + } +} diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/easyflow-modules/easyflow-module-autoconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 864731f5..828e7e77 100644 --- a/easyflow-modules/easyflow-module-autoconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/easyflow-modules/easyflow-module-autoconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -5,6 +5,7 @@ tech.easyflow.approval.config.ApprovalModuleConfig tech.easyflow.auth.config.AuthModuleConfig tech.easyflow.chatlog.config.ChatlogModuleConfig tech.easyflow.datacenter.config.DatacenterModuleConfig +tech.easyflow.dataspace.config.DataspaceModuleConfig tech.easyflow.job.config.JobModuleConfig tech.easyflow.log.config.LogModuleConfig tech.easyflow.skill.config.SkillModuleConfig diff --git a/easyflow-modules/easyflow-module-dataspace/pom.xml b/easyflow-modules/easyflow-module-dataspace/pom.xml new file mode 100644 index 00000000..42e61446 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + tech.easyflow + easyflow-modules + ${revision} + + + easyflow-module-dataspace + + + + com.mybatis-flex + mybatis-flex-spring-boot3-starter + + + com.zaxxer + HikariCP + + + com.easyagents + easy-agents-federation-sql-adapter-jdbc + + + org.postgresql + postgresql + 42.7.3 + + + tech.easyflow + easyflow-common-base + + + tech.easyflow + easyflow-common-cache + + + tech.easyflow + easyflow-common-satoken + + + tech.easyflow + easyflow-common-web + + + junit + junit + ${junit.version} + test + + + org.mockito + mockito-core + 5.12.0 + test + + + com.mysql + mysql-connector-j + test + + + diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/config/DataspaceModuleConfig.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/config/DataspaceModuleConfig.java new file mode 100644 index 00000000..7a348f77 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/config/DataspaceModuleConfig.java @@ -0,0 +1,14 @@ +package tech.easyflow.dataspace.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.ComponentScan; + +/** + * 数据空间模块自动配置。 + */ +@MapperScan("tech.easyflow.dataspace.mapper") +@ComponentScan("tech.easyflow.dataspace") +@AutoConfiguration +public class DataspaceModuleConfig { +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/Dataspace.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/Dataspace.java new file mode 100644 index 00000000..19980b9a --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/Dataspace.java @@ -0,0 +1,24 @@ +package tech.easyflow.dataspace.entity; + +import com.mybatisflex.annotation.Table; + +/** + * 面向 Agent 和 SQL 工作台的虚拟数据空间。 + */ +@Table("tb_dataspace") +public class Dataspace extends DataspaceRecordBase { + + private String name; + private String description; + private Long currentRevision; + private String status; + + /** @return 名称 */ public String getName() { return name; } + /** @param name 名称 */ public void setName(String name) { this.name = name; } + /** @return 说明 */ public String getDescription() { return description; } + /** @param description 说明 */ public void setDescription(String description) { this.description = description; } + /** @return 当前 revision */ public Long getCurrentRevision() { return currentRevision; } + /** @param currentRevision 当前 revision */ public void setCurrentRevision(Long currentRevision) { this.currentRevision = currentRevision; } + /** @return 状态 */ public String getStatus() { return status; } + /** @param status 状态 */ public void setStatus(String status) { this.status = status; } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceConnection.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceConnection.java new file mode 100644 index 00000000..3dc2eee6 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceConnection.java @@ -0,0 +1,82 @@ +package tech.easyflow.dataspace.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.handler.FastjsonTypeHandler; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 数据空间物理数据库连接。 + */ +@Table("tb_dataspace_connection") +public class DataspaceConnection extends DataspaceRecordBase { + + private String name; + private String databaseType; + private String host; + private Integer port; + private String databaseName; + private String username; + private String credentialCipher; + private Integer sslEnabled; + private String driverClassName; + @Column(typeHandler = FastjsonTypeHandler.class) + private Map optionsJson = new LinkedHashMap<>(); + private Long definitionRevision; + private String definitionChecksum; + private String status; + private String lastTestStatus; + private String lastTestMessage; + private String databaseProduct; + private String databaseVersion; + private String driverName; + private String driverVersion; + private Date lastTestedAt; + private Date metadataRefreshedAt; + + /** @return 连接名称 */ public String getName() { return name; } + /** @param name 连接名称 */ public void setName(String name) { this.name = name; } + /** @return 数据库类型 */ public String getDatabaseType() { return databaseType; } + /** @param databaseType 数据库类型 */ public void setDatabaseType(String databaseType) { this.databaseType = databaseType; } + /** @return 主机 */ public String getHost() { return host; } + /** @param host 主机 */ public void setHost(String host) { this.host = host; } + /** @return 端口 */ public Integer getPort() { return port; } + /** @param port 端口 */ public void setPort(Integer port) { this.port = port; } + /** @return 数据库名 */ public String getDatabaseName() { return databaseName; } + /** @param databaseName 数据库名 */ public void setDatabaseName(String databaseName) { this.databaseName = databaseName; } + /** @return 用户名 */ public String getUsername() { return username; } + /** @param username 用户名 */ public void setUsername(String username) { this.username = username; } + /** @return 凭据密文 */ @JsonIgnore public String getCredentialCipher() { return credentialCipher; } + /** @param credentialCipher 凭据密文 */ public void setCredentialCipher(String credentialCipher) { this.credentialCipher = credentialCipher; } + /** @return 是否启用 SSL */ public Integer getSslEnabled() { return sslEnabled; } + /** @param sslEnabled 是否启用 SSL */ public void setSslEnabled(Integer sslEnabled) { this.sslEnabled = sslEnabled; } + /** @return Driver 类名 */ public String getDriverClassName() { return driverClassName; } + /** @param driverClassName Driver 类名 */ public void setDriverClassName(String driverClassName) { this.driverClassName = driverClassName; } + /** @return 扩展选项 */ @JsonIgnore public Map getOptionsJson() { return optionsJson; } + /** @param optionsJson 扩展选项 */ public void setOptionsJson(Map optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; } + /** @return Definition revision */ public Long getDefinitionRevision() { return definitionRevision; } + /** @param definitionRevision Definition revision */ public void setDefinitionRevision(Long definitionRevision) { this.definitionRevision = definitionRevision; } + /** @return Definition checksum */ public String getDefinitionChecksum() { return definitionChecksum; } + /** @param definitionChecksum Definition checksum */ public void setDefinitionChecksum(String definitionChecksum) { this.definitionChecksum = definitionChecksum; } + /** @return 连接状态 */ public String getStatus() { return status; } + /** @param status 连接状态 */ public void setStatus(String status) { this.status = status; } + /** @return 最近测试状态 */ public String getLastTestStatus() { return lastTestStatus; } + /** @param lastTestStatus 最近测试状态 */ public void setLastTestStatus(String lastTestStatus) { this.lastTestStatus = lastTestStatus; } + /** @return 最近测试信息 */ public String getLastTestMessage() { return lastTestMessage; } + /** @param lastTestMessage 最近测试信息 */ public void setLastTestMessage(String lastTestMessage) { this.lastTestMessage = lastTestMessage; } + /** @return 数据库产品 */ public String getDatabaseProduct() { return databaseProduct; } + /** @param databaseProduct 数据库产品 */ public void setDatabaseProduct(String databaseProduct) { this.databaseProduct = databaseProduct; } + /** @return 数据库版本 */ public String getDatabaseVersion() { return databaseVersion; } + /** @param databaseVersion 数据库版本 */ public void setDatabaseVersion(String databaseVersion) { this.databaseVersion = databaseVersion; } + /** @return 驱动名称 */ public String getDriverName() { return driverName; } + /** @param driverName 驱动名称 */ public void setDriverName(String driverName) { this.driverName = driverName; } + /** @return 驱动版本 */ public String getDriverVersion() { return driverVersion; } + /** @param driverVersion 驱动版本 */ public void setDriverVersion(String driverVersion) { this.driverVersion = driverVersion; } + /** @return 最近测试时间 */ public Date getLastTestedAt() { return lastTestedAt; } + /** @param lastTestedAt 最近测试时间 */ public void setLastTestedAt(Date lastTestedAt) { this.lastTestedAt = lastTestedAt; } + /** @return 元数据刷新时间 */ public Date getMetadataRefreshedAt() { return metadataRefreshedAt; } + /** @param metadataRefreshedAt 元数据刷新时间 */ public void setMetadataRefreshedAt(Date metadataRefreshedAt) { this.metadataRefreshedAt = metadataRefreshedAt; } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceObject.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceObject.java new file mode 100644 index 00000000..abdd9ea5 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceObject.java @@ -0,0 +1,49 @@ +package tech.easyflow.dataspace.entity; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.handler.FastjsonTypeHandler; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 物理连接同步得到的表或视图元数据。 + */ +@Table("tb_dataspace_object") +public class DataspaceObject extends DataspaceRecordBase { + + private BigInteger connectionId; + private String catalogName; + private String schemaName; + private String objectName; + private String objectType; + private String remarks; + @Column(typeHandler = FastjsonTypeHandler.class) + private List> columnsJson = new ArrayList<>(); + @Column(typeHandler = FastjsonTypeHandler.class) + private List primaryKeysJson = new ArrayList<>(); + private Long metadataRevision; + + /** @return 连接 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getConnectionId() { return connectionId; } + /** @param connectionId 连接 ID */ public void setConnectionId(BigInteger connectionId) { this.connectionId = connectionId; } + /** @return Catalog */ public String getCatalogName() { return catalogName; } + /** @param catalogName Catalog */ public void setCatalogName(String catalogName) { this.catalogName = catalogName; } + /** @return Schema */ public String getSchemaName() { return schemaName; } + /** @param schemaName Schema */ public void setSchemaName(String schemaName) { this.schemaName = schemaName; } + /** @return 对象名 */ public String getObjectName() { return objectName; } + /** @param objectName 对象名 */ public void setObjectName(String objectName) { this.objectName = objectName; } + /** @return 对象类型 */ public String getObjectType() { return objectType; } + /** @param objectType 对象类型 */ public void setObjectType(String objectType) { this.objectType = objectType; } + /** @return 说明 */ public String getRemarks() { return remarks; } + /** @param remarks 说明 */ public void setRemarks(String remarks) { this.remarks = remarks; } + /** @return 字段快照 */ public List> getColumnsJson() { return columnsJson; } + /** @param columnsJson 字段快照 */ public void setColumnsJson(List> columnsJson) { this.columnsJson = columnsJson == null ? new ArrayList<>() : columnsJson; } + /** @return 主键字段 */ public List getPrimaryKeysJson() { return primaryKeysJson; } + /** @param primaryKeysJson 主键字段 */ public void setPrimaryKeysJson(List primaryKeysJson) { this.primaryKeysJson = primaryKeysJson == null ? new ArrayList<>() : primaryKeysJson; } + /** @return 元数据 revision */ public Long getMetadataRevision() { return metadataRevision; } + /** @param metadataRevision 元数据 revision */ public void setMetadataRevision(Long metadataRevision) { this.metadataRevision = metadataRevision; } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceQueryAudit.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceQueryAudit.java new file mode 100644 index 00000000..99ef41f0 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceQueryAudit.java @@ -0,0 +1,51 @@ +package tech.easyflow.dataspace.entity; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.mybatisflex.annotation.Table; +import java.math.BigInteger; + +/** + * 数据空间查询审计摘要。 + */ +@Table("tb_dataspace_query_audit") +public class DataspaceQueryAudit extends DataspaceRecordBase { + + private BigInteger dataspaceId; + private Long revisionNo; + private String queryId; + private String queryMode; + private String sqlDigest; + private String sqlText; + private String status; + private Long returnedRows; + private Long intermediateRows; + private Long durationMillis; + private String errorCode; + private String errorMessage; + + /** @return 数据空间 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getDataspaceId() { return dataspaceId; } + /** @param dataspaceId 数据空间 ID */ public void setDataspaceId(BigInteger dataspaceId) { this.dataspaceId = dataspaceId; } + /** @return revision */ public Long getRevisionNo() { return revisionNo; } + /** @param revisionNo revision */ public void setRevisionNo(Long revisionNo) { this.revisionNo = revisionNo; } + /** @return 查询 ID */ public String getQueryId() { return queryId; } + /** @param queryId 查询 ID */ public void setQueryId(String queryId) { this.queryId = queryId; } + /** @return 查询模式 */ public String getQueryMode() { return queryMode; } + /** @param queryMode 查询模式 */ public void setQueryMode(String queryMode) { this.queryMode = queryMode; } + /** @return SQL 摘要 */ public String getSqlDigest() { return sqlDigest; } + /** @param sqlDigest SQL 摘要 */ public void setSqlDigest(String sqlDigest) { this.sqlDigest = sqlDigest; } + /** @return SQL 文本 */ public String getSqlText() { return sqlText; } + /** @param sqlText SQL 文本 */ public void setSqlText(String sqlText) { this.sqlText = sqlText; } + /** @return 终态 */ public String getStatus() { return status; } + /** @param status 终态 */ public void setStatus(String status) { this.status = status; } + /** @return 返回行数 */ public Long getReturnedRows() { return returnedRows; } + /** @param returnedRows 返回行数 */ public void setReturnedRows(Long returnedRows) { this.returnedRows = returnedRows; } + /** @return 中间行数 */ public Long getIntermediateRows() { return intermediateRows; } + /** @param intermediateRows 中间行数 */ public void setIntermediateRows(Long intermediateRows) { this.intermediateRows = intermediateRows; } + /** @return 耗时毫秒 */ public Long getDurationMillis() { return durationMillis; } + /** @param durationMillis 耗时毫秒 */ public void setDurationMillis(Long durationMillis) { this.durationMillis = durationMillis; } + /** @return 错误码 */ public String getErrorCode() { return errorCode; } + /** @param errorCode 错误码 */ public void setErrorCode(String errorCode) { this.errorCode = errorCode; } + /** @return 错误信息 */ public String getErrorMessage() { return errorMessage; } + /** @param errorMessage 错误信息 */ public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceRecordBase.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceRecordBase.java new file mode 100644 index 00000000..41acc8a4 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceRecordBase.java @@ -0,0 +1,63 @@ +package tech.easyflow.dataspace.entity; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; +import tech.easyflow.common.entity.DateEntity; + +/** + * 数据空间持久化记录公共字段。 + */ +public abstract class DataspaceRecordBase extends DateEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + @Column(tenantId = true) + private BigInteger tenantId; + private Date created; + private BigInteger createdBy; + private Date modified; + private BigInteger modifiedBy; + @Column(value = "is_deleted", isLogicDelete = true) + private Integer deleted; + + /** @return 主键 */ + @JsonSerialize(using = ToStringSerializer.class) + public BigInteger getId() { return id; } + /** @param id 主键 */ + public void setId(BigInteger id) { this.id = id; } + /** @return 租户 ID */ + @JsonSerialize(using = ToStringSerializer.class) + public BigInteger getTenantId() { return tenantId; } + /** @param tenantId 租户 ID */ + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + /** @return 创建时间 */ + @Override public Date getCreated() { return created; } + /** @param created 创建时间 */ + @Override public void setCreated(Date created) { this.created = created; } + /** @return 创建人 */ + @JsonSerialize(using = ToStringSerializer.class) + public BigInteger getCreatedBy() { return createdBy; } + /** @param createdBy 创建人 */ + public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; } + /** @return 修改时间 */ + @Override public Date getModified() { return modified; } + /** @param modified 修改时间 */ + @Override public void setModified(Date modified) { this.modified = modified; } + /** @return 修改人 */ + @JsonSerialize(using = ToStringSerializer.class) + public BigInteger getModifiedBy() { return modifiedBy; } + /** @param modifiedBy 修改人 */ + public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; } + /** @return 删除标记 */ + public Integer getDeleted() { return deleted; } + /** @param deleted 删除标记 */ + public void setDeleted(Integer deleted) { this.deleted = deleted; } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceRelation.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceRelation.java new file mode 100644 index 00000000..20377820 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceRelation.java @@ -0,0 +1,36 @@ +package tech.easyflow.dataspace.entity; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.mybatisflex.annotation.Table; +import java.math.BigInteger; + +/** + * revision 中两张表的字段关联配置。 + */ +@Table("tb_dataspace_relation") +public class DataspaceRelation extends DataspaceRecordBase { + + private BigInteger dataspaceId; + private BigInteger revisionId; + private BigInteger leftBindingId; + private BigInteger rightBindingId; + private String joinType; + private String leftColumn; + private String rightColumn; + + /** @return 数据空间 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getDataspaceId() { return dataspaceId; } + /** @param dataspaceId 数据空间 ID */ public void setDataspaceId(BigInteger dataspaceId) { this.dataspaceId = dataspaceId; } + /** @return revision ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getRevisionId() { return revisionId; } + /** @param revisionId revision ID */ public void setRevisionId(BigInteger revisionId) { this.revisionId = revisionId; } + /** @return 左表绑定 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getLeftBindingId() { return leftBindingId; } + /** @param leftBindingId 左表绑定 ID */ public void setLeftBindingId(BigInteger leftBindingId) { this.leftBindingId = leftBindingId; } + /** @return 右表绑定 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getRightBindingId() { return rightBindingId; } + /** @param rightBindingId 右表绑定 ID */ public void setRightBindingId(BigInteger rightBindingId) { this.rightBindingId = rightBindingId; } + /** @return Join 类型 */ public String getJoinType() { return joinType; } + /** @param joinType Join 类型 */ public void setJoinType(String joinType) { this.joinType = joinType; } + /** @return 左字段 */ public String getLeftColumn() { return leftColumn; } + /** @param leftColumn 左字段 */ public void setLeftColumn(String leftColumn) { this.leftColumn = leftColumn; } + /** @return 右字段 */ public String getRightColumn() { return rightColumn; } + /** @param rightColumn 右字段 */ public void setRightColumn(String rightColumn) { this.rightColumn = rightColumn; } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceRevision.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceRevision.java new file mode 100644 index 00000000..682a05e7 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceRevision.java @@ -0,0 +1,24 @@ +package tech.easyflow.dataspace.entity; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.mybatisflex.annotation.Table; +import java.math.BigInteger; + +/** + * 数据空间不可变 revision 记录。 + */ +@Table("tb_dataspace_revision") +public class DataspaceRevision extends DataspaceRecordBase { + + private BigInteger dataspaceId; + private Long revisionNo; + private String snapshotChecksum; + + /** @return 数据空间 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getDataspaceId() { return dataspaceId; } + /** @param dataspaceId 数据空间 ID */ public void setDataspaceId(BigInteger dataspaceId) { this.dataspaceId = dataspaceId; } + /** @return revision */ public Long getRevisionNo() { return revisionNo; } + /** @param revisionNo revision */ public void setRevisionNo(Long revisionNo) { this.revisionNo = revisionNo; } + /** @return 快照校验和 */ public String getSnapshotChecksum() { return snapshotChecksum; } + /** @param snapshotChecksum 快照校验和 */ public void setSnapshotChecksum(String snapshotChecksum) { this.snapshotChecksum = snapshotChecksum; } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceTableBinding.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceTableBinding.java new file mode 100644 index 00000000..26bb6aae --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/entity/DataspaceTableBinding.java @@ -0,0 +1,45 @@ +package tech.easyflow.dataspace.entity; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.mybatisflex.annotation.Table; +import java.math.BigInteger; + +/** + * revision 中一张可查询物理表的绑定。 + */ +@Table("tb_dataspace_table_binding") +public class DataspaceTableBinding extends DataspaceRecordBase { + + private BigInteger dataspaceId; + private BigInteger revisionId; + private BigInteger connectionId; + private Long sourceRevision; + private BigInteger objectId; + private String sourceAlias; + private String schemaAlias; + private String tableAlias; + private Integer positionX; + private Integer positionY; + + /** @return 数据空间 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getDataspaceId() { return dataspaceId; } + /** @param dataspaceId 数据空间 ID */ public void setDataspaceId(BigInteger dataspaceId) { this.dataspaceId = dataspaceId; } + /** @return revision ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getRevisionId() { return revisionId; } + /** @param revisionId revision ID */ public void setRevisionId(BigInteger revisionId) { this.revisionId = revisionId; } + /** @return 连接 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getConnectionId() { return connectionId; } + /** @param connectionId 连接 ID */ public void setConnectionId(BigInteger connectionId) { this.connectionId = connectionId; } + /** @return 绑定时的物理源 revision */ public Long getSourceRevision() { return sourceRevision; } + /** @param sourceRevision 绑定时的物理源 revision */ public void setSourceRevision(Long sourceRevision) { this.sourceRevision = sourceRevision; } + /** @return 元数据对象 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getObjectId() { return objectId; } + /** @param objectId 元数据对象 ID */ public void setObjectId(BigInteger objectId) { this.objectId = objectId; } + /** @return SQL 数据源别名 */ public String getSourceAlias() { return sourceAlias; } + /** @param sourceAlias SQL 数据源别名 */ public void setSourceAlias(String sourceAlias) { this.sourceAlias = sourceAlias; } + /** @return SQL Schema 别名 */ public String getSchemaAlias() { return schemaAlias; } + /** @param schemaAlias SQL Schema 别名 */ public void setSchemaAlias(String schemaAlias) { this.schemaAlias = schemaAlias; } + /** @return SQL 表名 */ public String getTableAlias() { return tableAlias; } + /** @param tableAlias SQL 表名 */ public void setTableAlias(String tableAlias) { this.tableAlias = tableAlias; } + /** @return 画布横坐标 */ public Integer getPositionX() { return positionX; } + /** @param positionX 画布横坐标 */ public void setPositionX(Integer positionX) { this.positionX = positionX; } + /** @return 画布纵坐标 */ public Integer getPositionY() { return positionY; } + /** @param positionY 画布纵坐标 */ public void setPositionY(Integer positionY) { this.positionY = positionY; } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceDataSourceFactory.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceDataSourceFactory.java new file mode 100644 index 00000000..5dc5cc7f --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceDataSourceFactory.java @@ -0,0 +1,106 @@ +package tech.easyflow.dataspace.federation; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import tech.easyflow.dataspace.entity.DataspaceConnection; +import tech.easyflow.dataspace.provider.DataspaceConnectionConfig; +import tech.easyflow.dataspace.provider.DataspaceDatabaseProvider; +import tech.easyflow.dataspace.provider.DataspaceDatabaseProviderRegistry; +import tech.easyflow.dataspace.provider.DataspaceDatabaseType; +import tech.easyflow.dataspace.security.DataspaceCredentialCipher; + +/** + * 创建数据空间测试池和 Federation 独占只读池。 + */ +@Component +public class DataspaceDataSourceFactory { + + private final DataspaceDatabaseProviderRegistry providerRegistry; + private final DataspaceCredentialCipher credentialCipher; + private final int maximumPoolSize; + + /** + * 创建连接池工厂。 + * + * @param providerRegistry 数据库 Provider 注册表 + * @param credentialCipher 凭据解密器 + * @param maximumPoolSize 单物理源最大池大小 + */ + public DataspaceDataSourceFactory( + DataspaceDatabaseProviderRegistry providerRegistry, + DataspaceCredentialCipher credentialCipher, + @Value("${easyflow.dataspace.connection-pool.maximum-size:10}") + int maximumPoolSize) { + if (maximumPoolSize <= 0) { + throw new IllegalArgumentException("数据空间连接池大小必须大于零"); + } + this.providerRegistry = providerRegistry; + this.credentialCipher = credentialCipher; + this.maximumPoolSize = maximumPoolSize; + } + + /** + * 为临时测试或元数据探测创建单连接池。 + * + * @param config 连接配置 + * @return 调用方负责关闭的连接池 + */ + public HikariDataSource temporary(DataspaceConnectionConfig config) { + return create("dataspace-probe", config, 1); + } + + /** + * 为已持久化连接创建 Federation 独占池。 + * + * @param source 连接快照 + * @return Federation Runtime 负责关闭的连接池 + */ + public HikariDataSource runtime(DataspaceConnection source) { + return create("dataspace-" + source.getId() + "-r" + source.getDefinitionRevision(), + config(source), maximumPoolSize); + } + + /** + * 将持久化连接转换为节点内短生命周期明文配置。 + * + * @param source 连接快照 + * @return Provider 连接配置 + */ + DataspaceConnectionConfig config(DataspaceConnection source) { + return new DataspaceConnectionConfig( + DataspaceDatabaseType.parse(source.getDatabaseType()), + source.getHost(), source.getPort(), source.getDatabaseName(), source.getUsername(), + credentialCipher.decrypt(source.getCredentialCipher()), + Integer.valueOf(1).equals(source.getSslEnabled()), source.getOptionsJson()); + } + + /** + * 创建带安全边界的只读 HikariCP 连接池。 + * + * @param poolName 连接池名称 + * @param config 数据库连接参数 + * @param poolSize 最大连接数 + * @return 初始化完成的连接池 + */ + private HikariDataSource create(String poolName, DataspaceConnectionConfig config, int poolSize) { + DataspaceDatabaseProvider provider = providerRegistry.require(config.type()); + HikariConfig hikari = new HikariConfig(); + hikari.setPoolName(poolName); + hikari.setJdbcUrl(provider.jdbcUrl(config)); + hikari.setDriverClassName(config.type().driverClassName()); + hikari.setUsername(config.username()); + hikari.setPassword(config.password()); + hikari.setReadOnly(true); + hikari.setAutoCommit(true); + hikari.setMaximumPoolSize(poolSize); + hikari.setMinimumIdle(0); + hikari.setConnectionTimeout(5_000L); + hikari.setValidationTimeout(3_000L); + hikari.setIdleTimeout(600_000L); + hikari.setMaxLifetime(1_800_000L); + hikari.setInitializationFailTimeout(5_000L); + return new HikariDataSource(hikari); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceDataSourceResolver.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceDataSourceResolver.java new file mode 100644 index 00000000..b9ee7e08 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceDataSourceResolver.java @@ -0,0 +1,76 @@ +package tech.easyflow.dataspace.federation; + +import com.easyagents.federation.sql.source.FederationDataSourceHandle; +import com.easyagents.federation.sql.source.FederationDataSourceHandles; +import com.easyagents.federation.sql.source.FederationDataSourceResolver; +import com.easyagents.federation.sql.source.FederationSourceDefinition; +import com.easyagents.federation.sql.source.RuntimeFingerprint; +import com.zaxxer.hikari.HikariDataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import org.springframework.stereotype.Component; +import tech.easyflow.dataspace.entity.DataspaceConnection; +import tech.easyflow.dataspace.mapper.DataspaceConnectionMapper; + +/** + * 从 EasyFlow 权威连接记录解析节点本地 Federation DataSource。 + */ +@Component +public class DataspaceDataSourceResolver implements FederationDataSourceResolver { + + private final DataspaceConnectionMapper connectionMapper; + private final DataspaceDefinitionFactory definitionFactory; + private final DataspaceDataSourceFactory dataSourceFactory; + + /** + * 创建 DataSource Resolver。 + * + * @param connectionMapper 连接 Mapper + * @param definitionFactory Definition 工厂 + * @param dataSourceFactory 连接池工厂 + */ + public DataspaceDataSourceResolver( + DataspaceConnectionMapper connectionMapper, + DataspaceDefinitionFactory definitionFactory, + DataspaceDataSourceFactory dataSourceFactory) { + this.connectionMapper = connectionMapper; + this.definitionFactory = definitionFactory; + this.dataSourceFactory = dataSourceFactory; + } + + /** + * 创建 revision 独占的只读连接池句柄。 + * + * @param definition 无凭据 Definition + * @return 独占句柄 + */ + @Override + public FederationDataSourceHandle resolve(FederationSourceDefinition definition) { + DataspaceConnection source = connectionMapper.selectOneById( + definitionFactory.connectionId(definition)); + if (source == null || !"ENABLED".equals(source.getStatus())) { + throw new IllegalStateException("数据空间连接不存在或已禁用"); + } + if (!definitionFactory.sourceId(source).equals(definition.sourceId()) + || source.getDefinitionRevision() == null + || source.getDefinitionRevision() != definition.revision()) { + throw new IllegalStateException("数据空间连接 revision 已变化"); + } + FederationSourceDefinition current = definitionFactory.create(source); + if (!current.checksum().equals(definition.checksum()) + || !current.checksum().equals(source.getDefinitionChecksum())) { + throw new IllegalStateException("数据空间连接 Definition 校验失败"); + } + HikariDataSource pool = dataSourceFactory.runtime(source); + try (Connection connection = pool.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + RuntimeFingerprint fingerprint = new RuntimeFingerprint( + metadata.getDatabaseProductName(), metadata.getDatabaseProductVersion(), + metadata.getDriverName(), metadata.getDriverVersion(), "easyflow-xl16"); + return FederationDataSourceHandles.owned(pool, fingerprint, pool::close); + } catch (Exception exception) { + pool.close(); + throw new IllegalStateException("初始化数据空间连接池失败", exception); + } + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceDefinitionFactory.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceDefinitionFactory.java new file mode 100644 index 00000000..4572fe33 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceDefinitionFactory.java @@ -0,0 +1,125 @@ +package tech.easyflow.dataspace.federation; + +import com.easyagents.federation.sql.adapter.jdbc.JdbcFederationSqlAdapterProvider; +import com.easyagents.federation.sql.adapter.jdbc.JdbcSchemaDefinition; +import com.easyagents.federation.sql.source.FederationSourceDefinition; +import com.easyagents.federation.sql.source.SourceId; +import com.mybatisflex.core.query.QueryWrapper; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; +import tech.easyflow.dataspace.entity.DataspaceConnection; +import tech.easyflow.dataspace.entity.DataspaceObject; +import tech.easyflow.dataspace.mapper.DataspaceObjectMapper; +import tech.easyflow.dataspace.provider.DataspaceDatabaseProvider; +import tech.easyflow.dataspace.provider.DataspaceDatabaseProviderRegistry; +import tech.easyflow.dataspace.provider.DataspaceDatabaseType; + +/** + * 将权威连接和元数据快照转换为无凭据 Federation Definition。 + */ +@Component +public class DataspaceDefinitionFactory { + + /** Definition 中的业务连接主键选项。 */ + public static final String CONNECTION_ID_OPTION = "dataspaceConnectionId"; + + private final DataspaceObjectMapper objectMapper; + private final DataspaceDatabaseProviderRegistry providerRegistry; + + /** + * 创建 Definition 工厂。 + * + * @param objectMapper 元数据对象 Mapper + * @param providerRegistry 数据库 Provider 注册表 + */ + public DataspaceDefinitionFactory( + DataspaceObjectMapper objectMapper, + DataspaceDatabaseProviderRegistry providerRegistry) { + this.objectMapper = objectMapper; + this.providerRegistry = providerRegistry; + } + + /** + * 从数据库加载当前元数据并创建 Definition。 + * + * @param source 连接记录 + * @return Federation Definition + */ + public FederationSourceDefinition create(DataspaceConnection source) { + List objects = objectMapper.selectListByQuery(QueryWrapper.create() + .eq(DataspaceObject::getConnectionId, source.getId()) + .eq(DataspaceObject::getMetadataRevision, source.getDefinitionRevision()) + .orderBy("schema_name asc, object_name asc")); + return create(source, objects); + } + + /** + * 使用给定元数据快照创建 Definition。 + * + * @param source 连接记录 + * @param objects 元数据对象 + * @return Federation Definition + */ + public FederationSourceDefinition create( + DataspaceConnection source, + List objects) { + DataspaceDatabaseType type = DataspaceDatabaseType.parse(source.getDatabaseType()); + DataspaceDatabaseProvider provider = providerRegistry.require(type); + Set seen = new LinkedHashSet<>(); + List schemas = new ArrayList<>(); + for (DataspaceObject object : objects == null ? List.of() : objects) { + String logical = provider.logicalSchema(object.getSchemaName()); + if (!seen.add(logical)) { + continue; + } + schemas.add(type == DataspaceDatabaseType.MYSQL + ? new JdbcSchemaDefinition(logical, source.getDatabaseName(), null) + : new JdbcSchemaDefinition(logical, null, object.getSchemaName())); + } + if (schemas.isEmpty()) { + schemas.add(type == DataspaceDatabaseType.MYSQL + ? new JdbcSchemaDefinition("MAIN", source.getDatabaseName(), null) + : new JdbcSchemaDefinition("PUBLIC", null, "public")); + } + Map options = new LinkedHashMap<>(); + options.put(CONNECTION_ID_OPTION, source.getId().toString()); + return new FederationSourceDefinition( + sourceId(source), + Math.max(1L, source.getDefinitionRevision() == null + ? 1L : source.getDefinitionRevision()), + JdbcFederationSqlAdapterProvider.ADAPTER_ID, + new ArrayList<>(schemas), + options); + } + + /** + * 返回租户隔离且稳定的 Federation SourceId。 + * + * @param source 连接记录 + * @return SourceId + */ + public SourceId sourceId(DataspaceConnection source) { + BigInteger tenant = source.getTenantId() == null ? BigInteger.ZERO : source.getTenantId(); + return new SourceId("tenant-" + tenant + "-dataspace-source-" + source.getId()); + } + + /** + * 从 Definition 提取业务连接 ID。 + * + * @param definition Federation Definition + * @return 连接 ID + */ + public BigInteger connectionId(FederationSourceDefinition definition) { + String value = definition.adapterOptions().get(CONNECTION_ID_OPTION); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Federation Definition 缺少连接 ID"); + } + return new BigInteger(value); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceFederationRuntime.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceFederationRuntime.java new file mode 100644 index 00000000..549bba60 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceFederationRuntime.java @@ -0,0 +1,136 @@ +package tech.easyflow.dataspace.federation; + +import com.easyagents.federation.sql.adapter.jdbc.JdbcFederationSqlAdapterProvider; +import com.easyagents.federation.sql.api.FederationSqlEngine; +import com.easyagents.federation.sql.api.FederationSqlEngines; +import com.easyagents.federation.sql.api.FederationSqlErrorCode; +import com.easyagents.federation.sql.api.FederationSqlException; +import com.easyagents.federation.sql.source.FederationSourceDefinition; +import com.easyagents.federation.sql.source.FederationSourceView; +import com.easyagents.federation.sql.source.SourceApplyOptions; +import com.easyagents.federation.sql.source.SourceId; +import com.easyagents.federation.sql.source.SourceRuntimeStatus; +import jakarta.annotation.PreDestroy; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.springframework.stereotype.Component; +import tech.easyflow.dataspace.entity.DataspaceConnection; + +/** + * 数据空间共享 Federation SQL Engine 生命周期。 + */ +@Component +public class DataspaceFederationRuntime { + + private final DataspaceDefinitionFactory definitionFactory; + private final FederationSqlEngine engine; + private final ConcurrentMap definitions = + new ConcurrentHashMap<>(); + + /** + * 创建数据空间 Federation Runtime。 + * + * @param resolver DataSource Resolver + * @param definitionFactory Definition 工厂 + * @param sqlPolicy 数据空间表白名单策略 + */ + public DataspaceFederationRuntime( + DataspaceDataSourceResolver resolver, + DataspaceDefinitionFactory definitionFactory, + DataspaceSqlPolicy sqlPolicy) { + this.definitionFactory = definitionFactory; + this.engine = FederationSqlEngines.builder() + .dataSourceResolver(resolver) + .adapter(new JdbcFederationSqlAdapterProvider()) + .policy(sqlPolicy) + .maximumPlanCacheEntries(1024) + .crossSourceEnabled(true) + .build(); + } + + /** + * 确保连接当前 revision 已以懒加载方式应用到本节点。 + * + * @param connection 连接快照 + * @return Federation Definition + */ + public FederationSourceDefinition ensureSource(DataspaceConnection connection) { + SourceId sourceId = definitionFactory.sourceId(connection); + FederationSourceDefinition definition = definitions.compute(sourceId, + (ignored, current) -> reusable(current, connection) + ? current : loadDefinition(connection)); + FederationSourceView view = engine.sources().view(sourceId).orElse(null); + if (!applied(view, definition)) { + engine.sources().apply(definition, SourceApplyOptions.lazy()); + } + return definition; + } + + /** + * 判断缓存 Definition 是否仍对应连接的权威 revision 和 checksum。 + * + * @param definition 缓存 Definition + * @param connection 连接快照 + * @return 是否可复用 + */ + private boolean reusable( + FederationSourceDefinition definition, + DataspaceConnection connection) { + return definition != null + && connection.getDefinitionRevision() != null + && definition.revision() == connection.getDefinitionRevision() + && Objects.equals(definition.checksum(), connection.getDefinitionChecksum()); + } + + /** + * 加载并校验连接的完整物理 Schema Definition。 + * + * @param connection 连接快照 + * @return 完整 Federation Definition + * @throws FederationSqlException 权威 checksum 与元数据不一致时抛出 + */ + private FederationSourceDefinition loadDefinition(DataspaceConnection connection) { + FederationSourceDefinition definition = definitionFactory.create(connection); + if (!Objects.equals(definition.checksum(), connection.getDefinitionChecksum())) { + throw new FederationSqlException( + FederationSqlErrorCode.SOURCE_DEFINITION_CONFLICT, + "dataspace source definition checksum does not match authoritative metadata"); + } + return definition; + } + + /** + * 判断 Definition 是否已经登记到本节点 Source Manager。 + * + * @param view 节点本地 Source 视图 + * @param definition 目标 Definition + * @return 是否已经应用 + */ + private boolean applied( + FederationSourceView view, + FederationSourceDefinition definition) { + return view != null + && view.status() != SourceRuntimeStatus.REMOVED + && view.desiredRevision() == definition.revision() + && Objects.equals(view.checksum(), definition.checksum()); + } + + /** + * 返回统一查询引擎。 + * + * @return Federation SQL Engine + */ + public FederationSqlEngine engine() { + return engine; + } + + /** + * 应用停止时关闭计划缓存与连接池。 + */ + @PreDestroy + public void close() { + definitions.clear(); + engine.close(); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceSqlPolicy.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceSqlPolicy.java new file mode 100644 index 00000000..23201dae --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/federation/DataspaceSqlPolicy.java @@ -0,0 +1,308 @@ +package tech.easyflow.dataspace.federation; + +import com.easyagents.federation.sql.api.FederationSqlErrorCode; +import com.easyagents.federation.sql.api.FederationSqlException; +import com.easyagents.federation.sql.compile.FederationSqlPolicy; +import com.easyagents.federation.sql.compile.SqlPolicyContext; +import com.easyagents.federation.sql.source.SourceId; +import com.mybatisflex.core.query.QueryWrapper; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.RelVisitor; +import org.apache.calcite.rel.core.TableScan; +import org.springframework.stereotype.Component; +import tech.easyflow.dataspace.entity.Dataspace; +import tech.easyflow.dataspace.entity.DataspaceConnection; +import tech.easyflow.dataspace.entity.DataspaceObject; +import tech.easyflow.dataspace.entity.DataspaceRevision; +import tech.easyflow.dataspace.entity.DataspaceTableBinding; +import tech.easyflow.dataspace.mapper.DataspaceConnectionMapper; +import tech.easyflow.dataspace.mapper.DataspaceMapper; +import tech.easyflow.dataspace.mapper.DataspaceObjectMapper; +import tech.easyflow.dataspace.mapper.DataspaceRevisionMapper; +import tech.easyflow.dataspace.mapper.DataspaceTableBindingMapper; +import tech.easyflow.dataspace.service.DataspaceMetadataResolver; + +/** + * 将 Calcite 已解析表限制在当前数据空间不可变 revision 内。 + */ +@Component +public class DataspaceSqlPolicy implements FederationSqlPolicy { + + private final DataspaceMapper dataspaceMapper; + private final DataspaceRevisionMapper revisionMapper; + private final DataspaceTableBindingMapper bindingMapper; + private final DataspaceObjectMapper objectMapper; + private final DataspaceConnectionMapper connectionMapper; + private final DataspaceDefinitionFactory definitionFactory; + private final DataspaceMetadataResolver metadataResolver; + + /** + * 创建数据空间 SQL Policy。 + * + * @param dataspaceMapper 数据空间 Mapper + * @param revisionMapper revision Mapper + * @param bindingMapper 表绑定 Mapper + * @param objectMapper 元数据对象 Mapper + * @param connectionMapper 连接 Mapper + * @param definitionFactory Definition 工厂 + * @param metadataResolver 元数据兼容解析器 + */ + public DataspaceSqlPolicy( + DataspaceMapper dataspaceMapper, + DataspaceRevisionMapper revisionMapper, + DataspaceTableBindingMapper bindingMapper, + DataspaceObjectMapper objectMapper, + DataspaceConnectionMapper connectionMapper, + DataspaceDefinitionFactory definitionFactory, + DataspaceMetadataResolver metadataResolver) { + this.dataspaceMapper = dataspaceMapper; + this.revisionMapper = revisionMapper; + this.bindingMapper = bindingMapper; + this.objectMapper = objectMapper; + this.connectionMapper = connectionMapper; + this.definitionFactory = definitionFactory; + this.metadataResolver = metadataResolver; + } + + /** + * 返回策略版本。 + * + * @return 策略版本 + */ + @Override + public String version() { + return "xl16-v1"; + } + + /** + * 校验数据空间 revision、物理源集合和表白名单。 + * + * @param context Calcite 策略上下文 + */ + @Override + public void validate(SqlPolicyContext context) { + ScopeIdentity identity = ScopeIdentity.parse(context.request().queryScope().definitionId()); + Dataspace dataspace = dataspaceMapper.selectOneById(identity.dataspaceId()); + if (dataspace == null) { + throw rejected("数据空间不存在"); + } + // 查询开始时已经固定不可变 revision;后续保存新 revision 不应使在途查询失效。 + DataspaceRevision revision = revisionMapper.selectOneByQuery(QueryWrapper.create() + .eq(DataspaceRevision::getDataspaceId, identity.dataspaceId()) + .eq(DataspaceRevision::getRevisionNo, identity.revision())); + if (revision == null) { + throw rejected("数据空间 revision 不存在"); + } + List bindings = bindingMapper.selectListByQuery(QueryWrapper.create() + .eq(DataspaceTableBinding::getRevisionId, revision.getId())); + PolicySnapshot snapshot = snapshot(bindings); + if (!snapshot.sources().containsAll(context.referencedSources())) { + throw rejected("SQL 引用了数据空间外的物理数据源"); + } + new RelVisitor() { + /** + * 校验每个 Calcite 表扫描节点是否位于当前 revision 白名单。 + * + * @param node 当前关系节点 + * @param ordinal 输入序号 + * @param parent 父节点 + */ + @Override + public void visit(RelNode node, int ordinal, RelNode parent) { + if (node instanceof TableScan scan) { + List path = normalizeParts(scan.getTable().getQualifiedName()); + if (!isAllowed(path, snapshot)) { + throw rejected("SQL 引用了数据空间外的表: " + String.join(".", path)); + } + } + super.visit(node, ordinal, parent); + } + }.go(context.relRoot().rel); + } + + /** + * 从当前 revision 表绑定构建精确路径和物理源白名单。 + * + * @param bindings 当前 revision 表绑定 + * @return 策略快照 + */ + private PolicySnapshot snapshot(List bindings) { + if (bindings.isEmpty()) { + throw rejected("数据空间尚未配置可查询表"); + } + Set objectIds = new HashSet<>(); + Set connectionIds = new HashSet<>(); + for (DataspaceTableBinding binding : bindings) { + objectIds.add(binding.getObjectId()); + connectionIds.add(binding.getConnectionId()); + } + Map objects = new HashMap<>(); + objectMapper.selectListByQuery(QueryWrapper.create().in(DataspaceObject::getId, objectIds)) + .forEach(item -> objects.put(item.getId(), item)); + Map connections = new HashMap<>(); + connectionMapper.selectListByQuery(QueryWrapper.create().in(DataspaceConnection::getId, connectionIds)) + .forEach(item -> connections.put(item.getId(), item)); + Map resolutions = + metadataResolver.resolve(objects, connections); + List activeBindings = bindings.stream() + .filter(binding -> { + DataspaceMetadataResolver.ObjectResolution resolution = + resolutions.get(binding.getObjectId()); + return resolution != null && resolution.active(); + }) + .toList(); + if (activeBindings.isEmpty()) { + throw rejected("数据空间尚未配置可查询表"); + } + Set fullPaths = new HashSet<>(); + Set schemaPaths = new HashSet<>(); + Map tableNameCounts = activeBindings.stream() + .map(DataspaceTableBinding::getObjectId) + .map(resolutions::get) + .map(DataspaceMetadataResolver.ObjectResolution::current) + .map(DataspaceObject::getObjectName) + .map(this::normalizePart) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + Set uniqueTableNames = tableNameCounts.entrySet().stream() + .filter(entry -> entry.getValue() == 1L) + .map(Map.Entry::getKey) + .collect(Collectors.toUnmodifiableSet()); + Set sources = new HashSet<>(); + for (DataspaceTableBinding binding : activeBindings) { + DataspaceObject object = resolutions.get(binding.getObjectId()).current(); + DataspaceConnection connection = connections.get(binding.getConnectionId()); + if (object == null || connection == null) { + throw rejected("数据空间包含不可用的表绑定"); + } + // 策略阶段再次按稳定物理身份解析,仅将当前可用表加入白名单。 + fullPaths.add(normalize(List.of( + binding.getSourceAlias(), binding.getSchemaAlias(), object.getObjectName()))); + schemaPaths.add(normalize(List.of(binding.getSchemaAlias(), object.getObjectName()))); + sources.add(definitionFactory.sourceId(connection)); + } + return new PolicySnapshot( + Set.copyOf(fullPaths), Set.copyOf(schemaPaths), uniqueTableNames, Set.copyOf(sources)); + } + + /** + * 按 Calcite 解析后的限定名层级校验表,避免同名表通过后缀匹配越过物理源边界。 + * + * @param path Calcite 表限定名 + * @param snapshot 当前 revision 白名单快照 + * @return 是否允许访问 + */ + private boolean isAllowed(List path, PolicySnapshot snapshot) { + if (path.isEmpty()) { + return false; + } + if (path.size() >= 3) { + String fullPath = String.join(".", path.subList(path.size() - 3, path.size())); + return snapshot.fullPaths().contains(fullPath); + } + if (path.size() == 2) { + return snapshot.schemaPaths().contains(String.join(".", path)); + } + return snapshot.uniqueTableNames().contains(path.get(0)); + } + + /** + * 将限定名片段规范化后拼接为精确路径。 + * + * @param names 限定名片段 + * @return 规范化路径 + */ + private String normalize(List names) { + return String.join(".", normalizeParts(names)); + } + + /** + * 规范化限定名各段,便于大小写不敏感地进行精确比较。 + * + * @param names 原始限定名 + * @return 规范化后的限定名片段 + */ + private List normalizeParts(List names) { + return names.stream() + .filter(value -> value != null && !value.isBlank()) + .map(this::normalizePart) + .toList(); + } + + /** + * 规范化单个限定名片段。 + * + * @param value 原始片段 + * @return 规范化片段 + */ + private String normalizePart(String value) { + return value.toUpperCase(Locale.ROOT); + } + + /** + * 创建统一的 SQL 校验异常。 + * + * @param message 校验失败信息 + * @return Federation SQL 异常 + */ + private FederationSqlException rejected(String message) { + return new FederationSqlException(FederationSqlErrorCode.SQL_VALIDATION_FAILED, message); + } + + /** + * 当前 revision 的表路径与物理源白名单。 + * + * @param fullPaths source.schema.table 路径 + * @param schemaPaths schema.table 路径 + * @param uniqueTableNames 在当前空间唯一的非限定表名 + * @param sources 物理源集合 + */ + private record PolicySnapshot( + Set fullPaths, + Set schemaPaths, + Set uniqueTableNames, + Set sources) { + } + + /** + * 从查询作用域标识解析出的数据空间 revision。 + * + * @param dataspaceId 数据空间 ID + * @param revision revision 号 + */ + private record ScopeIdentity(BigInteger dataspaceId, long revision) { + + /** + * 解析 dataspace:{id}:{revision} 格式的作用域标识。 + * + * @param definitionId 作用域标识 + * @return 作用域身份 + * @throws FederationSqlException 格式非法时抛出 + */ + private static ScopeIdentity parse(String definitionId) { + if (definitionId == null || !definitionId.startsWith("dataspace:")) { + throw new FederationSqlException( + FederationSqlErrorCode.SQL_VALIDATION_FAILED, "数据空间范围标识无效"); + } + String[] parts = definitionId.split(":"); + if (parts.length != 3) { + throw new FederationSqlException( + FederationSqlErrorCode.SQL_VALIDATION_FAILED, "数据空间范围标识无效"); + } + try { + return new ScopeIdentity(new BigInteger(parts[1]), Long.parseLong(parts[2])); + } catch (NumberFormatException exception) { + throw new FederationSqlException( + FederationSqlErrorCode.SQL_VALIDATION_FAILED, "数据空间范围标识无效", exception); + } + } + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceConnectionMapper.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceConnectionMapper.java new file mode 100644 index 00000000..07cd02f9 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceConnectionMapper.java @@ -0,0 +1,34 @@ +package tech.easyflow.dataspace.mapper; + +import com.mybatisflex.core.BaseMapper; +import java.math.BigInteger; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; +import tech.easyflow.dataspace.entity.DataspaceConnection; + +/** + * 数据空间连接 Mapper。 + */ +public interface DataspaceConnectionMapper extends BaseMapper { + + /** + * 以 expected revision 原子推进连接 Definition。 + * + * @param id 连接 ID + * @param tenantId 租户 ID + * @param expectedRevision 期望 revision + * @param nextRevision 新 revision + * @param checksum 新校验和 + * @return 更新行数 + */ + @Update("UPDATE tb_dataspace_connection SET definition_revision = #{nextRevision}, " + + "definition_checksum = #{checksum}, modified = CURRENT_TIMESTAMP " + + "WHERE id = #{id} AND tenant_id = #{tenantId} AND is_deleted = 0 " + + "AND definition_revision = #{expectedRevision}") + int compareAndSetRevision( + @Param("id") BigInteger id, + @Param("tenantId") BigInteger tenantId, + @Param("expectedRevision") long expectedRevision, + @Param("nextRevision") long nextRevision, + @Param("checksum") String checksum); +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceMapper.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceMapper.java new file mode 100644 index 00000000..5210cbd4 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceMapper.java @@ -0,0 +1,31 @@ +package tech.easyflow.dataspace.mapper; + +import com.mybatisflex.core.BaseMapper; +import java.math.BigInteger; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; +import tech.easyflow.dataspace.entity.Dataspace; + +/** + * 数据空间 Mapper。 + */ +public interface DataspaceMapper extends BaseMapper { + + /** + * 原子切换数据空间当前 revision。 + * + * @param id 数据空间 ID + * @param tenantId 租户 ID + * @param expectedRevision 期望 revision + * @param nextRevision 新 revision + * @return 更新行数 + */ + @Update("UPDATE tb_dataspace SET current_revision = #{nextRevision}, " + + "modified = CURRENT_TIMESTAMP WHERE id = #{id} AND tenant_id = #{tenantId} " + + "AND is_deleted = 0 AND current_revision = #{expectedRevision}") + int compareAndSetCurrentRevision( + @Param("id") BigInteger id, + @Param("tenantId") BigInteger tenantId, + @Param("expectedRevision") long expectedRevision, + @Param("nextRevision") long nextRevision); +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceObjectMapper.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceObjectMapper.java new file mode 100644 index 00000000..97d4fbc8 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceObjectMapper.java @@ -0,0 +1,26 @@ +package tech.easyflow.dataspace.mapper; + +import com.mybatisflex.core.BaseMapper; +import java.math.BigInteger; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; +import tech.easyflow.dataspace.entity.DataspaceObject; + +/** + * 数据空间元数据对象 Mapper。 + */ +public interface DataspaceObjectMapper extends BaseMapper { + + /** + * 物理删除指定连接的旧元数据快照。 + * + * @param connectionId 连接 ID + * @param tenantId 租户 ID + * @return 删除行数 + */ + @Delete("DELETE FROM tb_dataspace_object WHERE connection_id = #{connectionId} " + + "AND tenant_id = #{tenantId}") + int deleteSnapshot( + @Param("connectionId") BigInteger connectionId, + @Param("tenantId") BigInteger tenantId); +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceQueryAuditMapper.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceQueryAuditMapper.java new file mode 100644 index 00000000..3f8bebbf --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceQueryAuditMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.dataspace.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.dataspace.entity.DataspaceQueryAudit; + +/** + * 数据空间查询审计 Mapper。 + */ +public interface DataspaceQueryAuditMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceRelationMapper.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceRelationMapper.java new file mode 100644 index 00000000..befa77b8 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceRelationMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.dataspace.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.dataspace.entity.DataspaceRelation; + +/** + * 数据空间关联 Mapper。 + */ +public interface DataspaceRelationMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceRevisionMapper.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceRevisionMapper.java new file mode 100644 index 00000000..98eac9ef --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceRevisionMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.dataspace.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.dataspace.entity.DataspaceRevision; + +/** + * 数据空间 revision Mapper。 + */ +public interface DataspaceRevisionMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceTableBindingMapper.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceTableBindingMapper.java new file mode 100644 index 00000000..f63321c7 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/mapper/DataspaceTableBindingMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.dataspace.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.dataspace.entity.DataspaceTableBinding; + +/** + * 数据空间表绑定 Mapper。 + */ +public interface DataspaceTableBindingMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/ConnectionDefinition.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/ConnectionDefinition.java new file mode 100644 index 00000000..008017af --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/ConnectionDefinition.java @@ -0,0 +1,41 @@ +package tech.easyflow.dataspace.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.math.BigInteger; +import java.util.Map; + +/** + * 物理数据库连接创建、更新或临时测试请求。 + * + * @param id 更新时的连接 ID + * @param expectedRevision 更新时的期望 revision + * @param name 连接名称 + * @param databaseType 数据库类型 + * @param host 主机 + * @param port 端口 + * @param databaseName 数据库名 + * @param username 用户名 + * @param password 密码;更新时为空表示保留原密码 + * @param sslEnabled 是否启用 SSL + * @param options 扩展选项 + */ +public record ConnectionDefinition( + BigInteger id, + Long expectedRevision, + String name, + String databaseType, + String host, + Integer port, + String databaseName, + String username, + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String password, + Boolean sslEnabled, + Map options) { + + /** + * 防御性复制扩展选项。 + */ + public ConnectionDefinition { + options = Map.copyOf(options == null ? Map.of() : options); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/ConnectionView.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/ConnectionView.java new file mode 100644 index 00000000..88ecb5fb --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/ConnectionView.java @@ -0,0 +1,64 @@ +package tech.easyflow.dataspace.model; + +import java.math.BigInteger; +import java.util.Date; +import tech.easyflow.dataspace.entity.DataspaceConnection; + +/** + * 不含凭据的连接视图。 + * + * @param id 连接 ID + * @param name 名称 + * @param databaseType 数据库类型 + * @param host 主机 + * @param port 端口 + * @param databaseName 数据库名 + * @param username 用户名 + * @param sslEnabled 是否启用 SSL + * @param definitionRevision Definition revision + * @param status 状态 + * @param lastTestStatus 最近测试状态 + * @param lastTestMessage 最近测试信息 + * @param databaseProduct 数据库产品 + * @param databaseVersion 数据库版本 + * @param driverName 驱动名称 + * @param driverVersion 驱动版本 + * @param lastTestedAt 最近测试时间 + * @param metadataRefreshedAt 元数据刷新时间 + */ +public record ConnectionView( + BigInteger id, + String name, + String databaseType, + String host, + Integer port, + String databaseName, + String username, + boolean sslEnabled, + Long definitionRevision, + String status, + String lastTestStatus, + String lastTestMessage, + String databaseProduct, + String databaseVersion, + String driverName, + String driverVersion, + Date lastTestedAt, + Date metadataRefreshedAt) { + + /** + * 从持久化记录创建安全视图。 + * + * @param source 连接记录 + * @return 安全视图 + */ + public static ConnectionView from(DataspaceConnection source) { + return new ConnectionView( + source.getId(), source.getName(), source.getDatabaseType(), source.getHost(), + source.getPort(), source.getDatabaseName(), source.getUsername(), + Integer.valueOf(1).equals(source.getSslEnabled()), source.getDefinitionRevision(), + source.getStatus(), source.getLastTestStatus(), source.getLastTestMessage(), + source.getDatabaseProduct(), source.getDatabaseVersion(), source.getDriverName(), + source.getDriverVersion(), source.getLastTestedAt(), source.getMetadataRefreshedAt()); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceDefinition.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceDefinition.java new file mode 100644 index 00000000..4a3a6664 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceDefinition.java @@ -0,0 +1,115 @@ +package tech.easyflow.dataspace.model; + +import com.alibaba.fastjson.JSONObject; +import java.math.BigInteger; +import java.util.List; + +/** + * 数据空间保存请求;每次保存都会生成新的不可变 revision。 + * + * @param id 更新时的数据空间 ID + * @param expectedRevision 更新时的期望 revision + * @param name 名称 + * @param description 说明 + * @param tables 纳管表 + * @param relations 表关系 + */ +public record DataspaceDefinition( + BigInteger id, + Long expectedRevision, + String name, + String description, + List tables, + List relations) { + + /** + * 防御性复制保存请求。 + */ + public DataspaceDefinition { + tables = typedCopy(tables, TableDefinition.class, "tables"); + relations = typedCopy(relations, RelationDefinition.class, "relations"); + } + + /** + * 将请求转换器保留的 JSON 对象恢复成强类型定义,并创建不可变副本。 + * + * @param values 原始列表 + * @param elementType 元素类型 + * @param fieldName 请求字段名 + * @param 元素类型 + * @return 强类型不可变列表 + * @throws IllegalArgumentException 元素无法转换时抛出 + */ + private static List typedCopy( + List values, + Class elementType, + String fieldName) { + if (values == null || values.isEmpty()) { + return List.of(); + } + return values.stream() + .map(value -> convertElement(value, elementType, fieldName)) + .toList(); + } + + /** + * 转换一个嵌套定义元素。 + * + * @param value 原始元素 + * @param elementType 目标类型 + * @param fieldName 请求字段名 + * @param 元素类型 + * @return 强类型元素 + * @throws IllegalArgumentException 元素为空或无法转换时抛出 + */ + private static T convertElement( + Object value, + Class elementType, + String fieldName) { + if (elementType.isInstance(value)) { + return elementType.cast(value); + } + if (value instanceof JSONObject jsonObject) { + return jsonObject.toJavaObject(elementType); + } + throw new IllegalArgumentException(fieldName + " 包含无效元素"); + } + + /** + * 一张纳管表的画布与 SQL 名称定义。 + * + * @param clientKey 前端稳定临时键,供关系引用 + * @param objectId 元数据对象 ID + * @param sourceAlias SQL 第一段数据源别名 + * @param schemaAlias SQL 第二段 Schema 别名 + * @param tableAlias 数据空间内全局唯一的逻辑表名 + * @param positionX 画布横坐标 + * @param positionY 画布纵坐标 + */ + public record TableDefinition( + String clientKey, + BigInteger objectId, + String sourceAlias, + String schemaAlias, + String tableAlias, + Integer positionX, + Integer positionY) { + } + + /** + * 两张纳管表之间的字段关系。 + * + * @param leftClientKey 左表临时键 + * @param rightClientKey 右表临时键 + * @param joinType Join 类型 + * @param leftColumn 左字段 + * @param rightColumn 右字段 + */ + public record RelationDefinition( + String leftClientKey, + String rightClientKey, + String joinType, + String leftColumn, + String rightColumn) { + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceErrorCode.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceErrorCode.java new file mode 100644 index 00000000..268f17c5 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceErrorCode.java @@ -0,0 +1,240 @@ +package tech.easyflow.dataspace.model; + +import com.easyagents.federation.sql.api.FederationSqlErrorCode; +import com.easyagents.federation.sql.api.FederationSqlException; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.dataspace.security.DataspaceCredentialUnavailableException; + +/** + * 数据空间稳定业务错误码及 HTTP 语义。 + */ +public enum DataspaceErrorCode { + + /** 聚合 revision 冲突。 */ + REVISION_CONFLICT(409, 40901, "数据已被其他操作更新,请刷新后重试"), + /** 连接仍被数据空间引用。 */ + CONNECTION_IN_USE(409, 40902, "连接已被数据空间引用,无法删除"), + /** 资源名称重复。 */ + NAME_CONFLICT(409, 40903, "名称已存在"), + /** 查询 ID 正在使用。 */ + QUERY_ID_CONFLICT(409, 40904, "查询 ID 已在执行中"), + /** 当前账号无权操作目标查询或资源。 */ + ACCESS_DENIED(403, 40361, "无权执行该操作"), + /** 物理连接不存在。 */ + CONNECTION_NOT_FOUND(404, 40461, "数据连接不存在"), + /** 数据空间不存在。 */ + DATASPACE_NOT_FOUND(404, 40462, "数据空间不存在"), + /** 数据空间已禁用。 */ + DATASPACE_DISABLED(409, 40961, "数据空间已禁用,请先启用后再查询"), + /** 物理连接已禁用。 */ + CONNECTION_DISABLED(409, 40962, "数据连接已禁用,请先启用并测试连接"), + /** 已绑定物理对象在最新元数据中不存在。 */ + OBJECT_MISSING(409, 40963, "已绑定的数据库对象不存在,请刷新元数据或移除该表"), + /** 关系字段在最新元数据中不存在。 */ + RELATION_FIELD_MISSING(409, 40964, "关联字段已不存在,请重新连线或删除该关系"), + /** 关系字段类型不再可比较。 */ + RELATION_TYPE_INCOMPATIBLE(409, 40965, "关联字段类型不兼容,请重新选择字段"), + /** 查询被主动取消。 */ + QUERY_CANCELLED(409, 40966, "查询已取消"), + /** SQL 解析失败。 */ + SQL_PARSE_FAILED(400, 40061, "SQL 语法解析失败,请检查语句"), + /** SQL 校验失败。 */ + SQL_VALIDATION_FAILED(400, 40062, "SQL 校验失败,请检查表名和字段名"), + /** 查询不满足只读限制。 */ + READ_ONLY_REQUIRED(400, 40063, "仅允许执行单条只读查询"), + /** SQL 引用了数据空间外的对象。 */ + OBJECT_OUT_OF_SCOPE(400, 40064, "SQL 引用了当前数据空间之外的对象"), + /** 数据空间建模定义不完整。 */ + DEFINITION_INVALID(400, 40065, "数据空间配置不完整,请检查后重试"), + /** 查询请求参数不合法。 */ + QUERY_REQUEST_INVALID(400, 40066, "查询参数不正确"), + /** 数据连接定义不合法。 */ + CONNECTION_DEFINITION_INVALID(400, 40067, "数据连接配置不完整,请检查后重试"), + /** 查询准入超时。 */ + QUERY_ADMISSION_TIMEOUT(408, 40861, "查询排队超时,请稍后重试"), + /** 查询执行超时。 */ + QUERY_TIMEOUT(408, 40862, "查询执行超时,请缩小查询范围后重试"), + /** SQL 编译超时。 */ + SQL_COMPILE_TIMEOUT(408, 40863, "SQL 编译超时,请简化语句后重试"), + /** 节点查询内存准入超时。 */ + NODE_MEMORY_ADMISSION_TIMEOUT(429, 42961, "节点查询资源繁忙,请稍后重试"), + /** 联邦资源超过平台上限。 */ + QUERY_LIMIT_EXCEEDED(422, 42261, "查询结果或中间数据超过平台限制,请缩小查询范围"), + /** 当前查询能力暂不支持。 */ + QUERY_UNSUPPORTED(422, 42262, "当前查询暂不支持,请简化 SQL 后重试"), + /** 连接测试未通过。 */ + CONNECTION_TEST_FAILED(422, 42263, "连接测试未通过,请检查连接信息"), + /** 查询依赖的数据源不可用。 */ + SOURCE_UNAVAILABLE(503, 50361, "查询使用的数据连接当前不可用,请检查连接状态"), + /** 底层数据库或联邦执行失败。 */ + FEDERATION_EXECUTION_FAILED(503, 50362, "数据库执行失败,请检查 SQL 和数据源状态"), + /** Explain 执行失败。 */ + EXPLAIN_FAILED(503, 50363, "执行计划获取失败,请检查数据源状态后重试"), + /** SQL 补全暂时不可用。 */ + SQL_COMPLETION_FAILED(503, 50364, "SQL 补全暂不可用,请稍后重试"), + /** 数据库连接池等待超时。 */ + CONNECTION_ACQUISITION_TIMEOUT(503, 50365, "数据库连接池繁忙,请稍后重试"), + /** 无法获取数据库连接。 */ + CONNECTION_ACQUISITION_FAILED(503, 50366, "无法获取数据库连接,请检查连接状态"); + + private final int httpStatus; + private final int code; + private final String defaultMessage; + + /** + * 创建错误码定义。 + * + * @param httpStatus HTTP 状态码 + * @param code 稳定业务错误码 + * @param defaultMessage 默认用户提示 + */ + DataspaceErrorCode(int httpStatus, int code, String defaultMessage) { + this.httpStatus = httpStatus; + this.code = code; + this.defaultMessage = defaultMessage; + } + + /** + * 创建使用默认提示的业务异常。 + * + * @return 业务异常 + */ + public BusinessException exception() { + return new BusinessException(httpStatus, code, defaultMessage); + } + + /** + * 创建使用指定安全提示的业务异常。 + * + * @param message 可安全展示的提示 + * @return 业务异常 + */ + public BusinessException exception(String message) { + return new BusinessException(httpStatus, code, normalizeMessage(message)); + } + + /** + * 创建保留原始根因的业务异常。 + * + * @param message 可安全展示的提示 + * @param cause 原始异常 + * @return 业务异常 + */ + public BusinessException exception(String message, Throwable cause) { + return new BusinessException(httpStatus, code, normalizeMessage(message), cause); + } + + /** + * 将 Federation SQL 错误转换为数据空间错误契约。 + * + * @param exception Federation SQL 异常 + * @param explain 是否来自 Explain API + * @return 携带稳定 HTTP 状态和业务码的异常 + */ + public static BusinessException fromFederation( + FederationSqlException exception, + boolean explain) { + Throwable cause = exception.getCause(); + while (cause != null) { + // 数据源初始化会包装调用方异常,凭据失效需要保留可恢复的 40905 提示。 + if (cause instanceof DataspaceCredentialUnavailableException credentialException) { + return credentialException; + } + cause = cause.getCause(); + } + FederationSqlErrorCode source = exception.errorCode(); + DataspaceErrorCode target = switch (source) { + case SQL_PARSE_FAILED -> SQL_PARSE_FAILED; + case SQL_VALIDATION_FAILED, INVALID_QUERY_SCOPE -> SQL_VALIDATION_FAILED; + case SQL_NOT_READ_ONLY -> READ_ONLY_REQUIRED; + case QUERY_ADMISSION_TIMEOUT -> QUERY_ADMISSION_TIMEOUT; + case QUERY_TIMEOUT -> QUERY_TIMEOUT; + case SQL_COMPILE_TIMEOUT -> SQL_COMPILE_TIMEOUT; + case NODE_MEMORY_ADMISSION_TIMEOUT -> NODE_MEMORY_ADMISSION_TIMEOUT; + case CONNECTION_ACQUISITION_TIMEOUT -> CONNECTION_ACQUISITION_TIMEOUT; + case CONNECTION_ACQUISITION_FAILED -> CONNECTION_ACQUISITION_FAILED; + case QUERY_CANCELLED -> QUERY_CANCELLED; + case FEDERATION_RESOURCE_LIMIT_EXCEEDED -> QUERY_LIMIT_EXCEEDED; + case SOURCE_NOT_FOUND, SOURCE_REMOVED, SOURCE_REVISION_NOT_READY, + SOURCE_DEFINITION_CONFLICT, SOURCE_INITIALIZATION_FAILED, + ADAPTER_NOT_FOUND, ADAPTER_UNSUPPORTED, PLAN_STALE, ENGINE_CLOSED -> + SOURCE_UNAVAILABLE; + case EXPLAIN_FAILED -> EXPLAIN_FAILED; + case SQL_COMPLETION_FAILED -> SQL_COMPLETION_FAILED; + case CROSS_SOURCE_DISABLED, CROSS_SOURCE_EXECUTION_UNSUPPORTED, + FEDERATION_OPERATOR_UNSUPPORTED, SQL_NOT_FULLY_PUSHDOWN -> QUERY_UNSUPPORTED; + case EXECUTION_FAILED, RESOURCE_CLOSE_FAILED -> explain + ? EXPLAIN_FAILED : FEDERATION_EXECUTION_FAILED; + case INVALID_ARGUMENT, SQL_COMPILE_FAILED, PARAMETER_COUNT_MISMATCH -> + SQL_VALIDATION_FAILED; + }; + String message = target == SQL_PARSE_FAILED || target == SQL_VALIDATION_FAILED + ? appendSafeDetail(target.defaultMessage, localizeSqlDetail(exception.getMessage())) + : target.defaultMessage; + return target.exception(message, exception); + } + + /** + * 将底层逻辑表定位信息转换为用户可直接处理的提示。 + * + * @param detail Federation SQL 原始详情 + * @return 本地化后的安全详情 + */ + private static String localizeSqlDetail(String detail) { + if (detail == null) { + return null; + } + String prefix = "logical table is not declared in the query scope: "; + if (detail.startsWith(prefix)) { + String table = detail.substring(prefix.length()).trim(); + return "逻辑表 “" + table + "” 不在当前数据空间中,请检查逻辑表名"; + } + return detail; + } + + /** + * 获取稳定数值错误码。 + * + * @return 数值错误码 + */ + public int code() { + return code; + } + + /** + * 获取默认用户提示。 + * + * @return 默认提示 + */ + public String defaultMessage() { + return defaultMessage; + } + + /** + * 归一化自定义提示。 + * + * @param message 原始提示 + * @return 非空提示 + */ + private String normalizeMessage(String message) { + return message == null || message.isBlank() ? defaultMessage : message; + } + + /** + * 为 SQL 解析和校验错误保留有界的底层定位信息。 + * + * @param prefix 用户提示前缀 + * @param detail 底层安全详情 + * @return 有界提示 + */ + private static String appendSafeDetail(String prefix, String detail) { + if (detail == null || detail.isBlank()) { + return prefix; + } + String normalized = detail.replaceAll("[\\r\\n\\t]+", " ").trim(); + if (normalized.length() > 180) { + normalized = normalized.substring(0, 180) + "…"; + } + return prefix + ":" + normalized; + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceExplainResult.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceExplainResult.java new file mode 100644 index 00000000..7c03cbcd --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceExplainResult.java @@ -0,0 +1,121 @@ +package tech.easyflow.dataspace.model; + +import java.util.List; + +/** + * 数据空间 SQL Explain 视图。 + * + * @param queryMode 查询模式 + * @param normalizedSql 统一规范化 SQL + * @param executionPlan 实际执行计划 + * @param executable 是否可执行 + * @param diagnostic 诊断信息 + * @param statisticsStatus 统计完整性与时效状态 + * @param estimateAvailable 聚合成本数值是否为有效估算 + * @param estimatedTransferBytes 预计跨源搬运字节数 + * @param estimatedLocalMemoryBytes 预计本地计算内存字节数 + * @param joins Join 优化选择 + * @param fragments 数据库分片计划 + */ +public record DataspaceExplainResult( + String queryMode, + String normalizedSql, + String executionPlan, + boolean executable, + String diagnostic, + String statisticsStatus, + boolean estimateAvailable, + double estimatedTransferBytes, + long estimatedLocalMemoryBytes, + List joins, + List fragments) { + + /** + * 防御性复制分片列表。 + */ + public DataspaceExplainResult { + joins = List.copyOf(joins == null ? List.of() : joins); + fragments = List.copyOf(fragments == null ? List.of() : fragments); + } + + /** + * 一个跨源 Join 的执行选择。 + * + * @param stageIndex 执行阶段序号 + * @param leftSources 左输入包含的数据源 + * @param rightSources 右输入包含的数据源 + * @param leftSource 左输入数据源 + * @param rightSource 右输入数据源 + * @param buildSource 哈希表构建侧数据源 + * @param algorithm Join 算法 + * @param reason 选择原因 + * @param estimatedBuildBytes 预计构建侧字节数 + */ + public record JoinView( + int stageIndex, + List leftSources, + List rightSources, + String leftSource, + String rightSource, + String buildSource, + String algorithm, + String reason, + double estimatedBuildBytes) { + + /** + * 防御性复制阶段输入集合。 + */ + public JoinView { + leftSources = List.copyOf(leftSources == null ? List.of() : leftSources); + rightSources = List.copyOf(rightSources == null ? List.of() : rightSources); + } + } + + /** + * 一个物理数据库分片的 Explain 信息。 + * + * @param sourceAlias 数据源别名 + * @param executableSql 目标方言 SQL + * @param scanType 扫描方式 + * @param candidateIndexes 候选索引 + * @param chosenIndex 选中索引 + * @param estimatedRows 估算行数 + * @param estimateAvailable 分片成本数值是否为有效估算 + * @param estimatedOutputRows 分片预计返回行数 + * @param estimatedRowWidthBytes 预计平均行宽 + * @param estimatedTransferBytes 预计搬运字节数 + * @param statisticsStatus 统计状态 + * @param statisticsSource 统计来源 + * @param statisticsCollectedAt 统计采集时间 + * @param pushedDownOperators 已下推算子 + * @param nativePlan 数据库原生计划 + * @param diagnostic 诊断信息 + */ + public record FragmentView( + String sourceAlias, + String executableSql, + String scanType, + List candidateIndexes, + String chosenIndex, + Long estimatedRows, + boolean estimateAvailable, + double estimatedOutputRows, + long estimatedRowWidthBytes, + double estimatedTransferBytes, + String statisticsStatus, + String statisticsSource, + String statisticsCollectedAt, + List pushedDownOperators, + String nativePlan, + String diagnostic) { + + /** + * 防御性复制候选索引。 + */ + public FragmentView { + candidateIndexes = List.copyOf(candidateIndexes == null ? List.of() : candidateIndexes); + pushedDownOperators = List.copyOf( + pushedDownOperators == null ? List.of() : pushedDownOperators); + } + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceQueryRequest.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceQueryRequest.java new file mode 100644 index 00000000..39c65498 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceQueryRequest.java @@ -0,0 +1,20 @@ +package tech.easyflow.dataspace.model; + +import java.math.BigInteger; + +/** + * 数据空间只读 SQL 请求。 + * + * @param dataspaceId 数据空间 ID + * @param queryId 调用方预生成的查询 ID,可为空 + * @param sql Calcite SQL + * @param maxRows 最大返回行数 + * @param timeoutSeconds 超时秒数 + */ +public record DataspaceQueryRequest( + BigInteger dataspaceId, + String queryId, + String sql, + Integer maxRows, + Integer timeoutSeconds) { +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceQueryResult.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceQueryResult.java new file mode 100644 index 00000000..050ba215 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceQueryResult.java @@ -0,0 +1,64 @@ +package tech.easyflow.dataspace.model; + +import java.util.List; + +/** + * 数据空间 SQL 查询结果。 + * + * @param queryId 查询 ID + * @param columns 结果列 + * @param rows 结果行 + * @param metrics 查询指标 + */ +public record DataspaceQueryResult( + String queryId, + List columns, + List> rows, + QueryMetricsView metrics) { + + /** + * 防御性复制查询结果。 + */ + public DataspaceQueryResult { + columns = List.copyOf(columns == null ? List.of() : columns); + rows = List.copyOf(rows == null ? List.of() : rows); + } + + /** + * 查询结果列。 + * + * @param name 列名 + * @param jdbcType JDBC 类型 + * @param typeName 类型名 + * @param nullable 是否可空 + */ + public record ColumnView(String name, int jdbcType, String typeName, boolean nullable) { + } + + /** + * 常用查询消耗指标。 + * + * @param queryMode 单源或联邦模式 + * @param planCacheHit 是否命中计划缓存 + * @param planningMillis 编译耗时 + * @param databaseMillis 数据库执行累计耗时 + * @param localMillis 本地算子耗时 + * @param totalMillis 总耗时 + * @param firstRowMillis 首行耗时 + * @param returnedRows 返回行数 + * @param intermediateRows 中间结果行数 + * @param truncated 是否截断 + */ + public record QueryMetricsView( + String queryMode, + boolean planCacheHit, + long planningMillis, + long databaseMillis, + long localMillis, + long totalMillis, + long firstRowMillis, + long returnedRows, + long intermediateRows, + boolean truncated) { + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceSqlCompletionRequest.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceSqlCompletionRequest.java new file mode 100644 index 00000000..d3f72599 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceSqlCompletionRequest.java @@ -0,0 +1,16 @@ +package tech.easyflow.dataspace.model; + +import java.math.BigInteger; + +/** + * 数据空间 SQL 补全请求。 + * + * @param dataspaceId 数据空间 ID + * @param sql 允许不完整的 SQL 文本 + * @param cursorOffset 光标 UTF-16 字符偏移 + */ +public record DataspaceSqlCompletionRequest( + BigInteger dataspaceId, + String sql, + Integer cursorOffset) { +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceSqlCompletionResult.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceSqlCompletionResult.java new file mode 100644 index 00000000..d3b44cc1 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceSqlCompletionResult.java @@ -0,0 +1,46 @@ +package tech.easyflow.dataspace.model; + +import java.util.List; + +/** + * 数据空间 SQL 补全结果。 + * + * @param replaceStart 建议替换区间起点,使用 UTF-16 字符偏移 + * @param replaceEnd 建议替换区间终点,使用 UTF-16 字符偏移 + * @param items 补全候选 + */ +public record DataspaceSqlCompletionResult( + int replaceStart, + int replaceEnd, + List items) { + + /** + * 防御性复制补全候选。 + */ + public DataspaceSqlCompletionResult { + items = List.copyOf(items == null ? List.of() : items); + } + + /** + * 一个 SQL 补全候选。 + * + * @param label 展示名称 + * @param insertText 插入文本 + * @param kind 候选类型 + * @param qualifiedName 完整限定名称 + */ + public record ItemView( + String label, + String insertText, + String kind, + List qualifiedName) { + + /** + * 防御性复制限定名称。 + */ + public ItemView { + qualifiedName = List.copyOf( + qualifiedName == null ? List.of() : qualifiedName); + } + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceSummary.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceSummary.java new file mode 100644 index 00000000..ae6fc102 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceSummary.java @@ -0,0 +1,27 @@ +package tech.easyflow.dataspace.model; + +import java.math.BigInteger; +import java.util.Date; + +/** + * 数据空间列表摘要。 + * + * @param id 数据空间 ID + * @param name 名称 + * @param description 说明 + * @param currentRevision 当前 revision + * @param status 状态 + * @param tableCount 表数量 + * @param sourceCount 物理源数量 + * @param modified 最近修改时间 + */ +public record DataspaceSummary( + BigInteger id, + String name, + String description, + long currentRevision, + String status, + long tableCount, + long sourceCount, + Date modified) { +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceView.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceView.java new file mode 100644 index 00000000..1bf4f888 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/DataspaceView.java @@ -0,0 +1,106 @@ +package tech.easyflow.dataspace.model; + +import java.math.BigInteger; +import java.util.List; + +/** + * 数据空间当前 revision 视图。 + * + * @param id 数据空间 ID + * @param name 名称 + * @param description 说明 + * @param currentRevision 当前 revision + * @param status 状态 + * @param tables 表绑定 + * @param relations 表关系 + */ +public record DataspaceView( + BigInteger id, + String name, + String description, + long currentRevision, + String status, + List tables, + List relations) { + + /** + * 防御性复制详情集合。 + */ + public DataspaceView { + tables = List.copyOf(tables == null ? List.of() : tables); + relations = List.copyOf(relations == null ? List.of() : relations); + } + + /** + * 数据空间表绑定视图。 + * + * @param id 绑定 ID + * @param connectionId 连接 ID + * @param connectionName 连接名称 + * @param databaseType 数据库类型 + * @param objectId 元数据对象 ID + * @param catalogName Catalog + * @param schemaName 物理 Schema + * @param objectName 物理表名 + * @param sourceAlias SQL 数据源别名 + * @param schemaAlias SQL Schema 别名 + * @param tableAlias SQL 表名 + * @param positionX 横坐标 + * @param positionY 纵坐标 + * @param columns 字段快照 + * @param status ACTIVE、MISSING 或 CONNECTION_DISABLED + * @param issueCode 稳定问题码 + * @param issueMessage 用户可读问题提示 + */ + public record TableView( + BigInteger id, + BigInteger connectionId, + String connectionName, + String databaseType, + BigInteger objectId, + String catalogName, + String schemaName, + String objectName, + String sourceAlias, + String schemaAlias, + String tableAlias, + Integer positionX, + Integer positionY, + List> columns, + String status, + Integer issueCode, + String issueMessage) { + + /** + * 防御性复制字段快照。 + */ + public TableView { + columns = List.copyOf(columns == null ? List.of() : columns); + } + } + + /** + * 数据空间表关联视图。 + * + * @param id 关系 ID + * @param leftBindingId 左表绑定 ID + * @param rightBindingId 右表绑定 ID + * @param joinType Join 类型 + * @param leftColumn 左字段 + * @param rightColumn 右字段 + * @param status ACTIVE 或 INVALID + * @param issueCode 稳定问题码 + * @param issueMessage 用户可读问题提示 + */ + public record RelationView( + BigInteger id, + BigInteger leftBindingId, + BigInteger rightBindingId, + String joinType, + String leftColumn, + String rightColumn, + String status, + Integer issueCode, + String issueMessage) { + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/ObjectView.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/ObjectView.java new file mode 100644 index 00000000..4855550e --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/model/ObjectView.java @@ -0,0 +1,52 @@ +package tech.easyflow.dataspace.model; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import tech.easyflow.dataspace.entity.DataspaceObject; + +/** + * 数据库对象与字段视图。 + * + * @param id 对象 ID + * @param connectionId 连接 ID + * @param catalogName Catalog + * @param schemaName Schema + * @param objectName 表或视图名 + * @param objectType 类型 + * @param remarks 说明 + * @param columns 字段 + * @param primaryKeys 主键字段 + */ +public record ObjectView( + BigInteger id, + BigInteger connectionId, + String catalogName, + String schemaName, + String objectName, + String objectType, + String remarks, + List> columns, + List primaryKeys) { + + /** + * 防御性复制对象视图。 + */ + public ObjectView { + columns = List.copyOf(columns == null ? List.of() : columns); + primaryKeys = List.copyOf(primaryKeys == null ? List.of() : primaryKeys); + } + + /** + * 从元数据记录创建视图。 + * + * @param source 元数据记录 + * @return 对象视图 + */ + public static ObjectView from(DataspaceObject source) { + return new ObjectView( + source.getId(), source.getConnectionId(), source.getCatalogName(), + source.getSchemaName(), source.getObjectName(), source.getObjectType(), + source.getRemarks(), source.getColumnsJson(), source.getPrimaryKeysJson()); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/AbstractJdbcDatabaseProvider.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/AbstractJdbcDatabaseProvider.java new file mode 100644 index 00000000..631dd450 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/AbstractJdbcDatabaseProvider.java @@ -0,0 +1,236 @@ +package tech.easyflow.dataspace.provider; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * 基于 JDBC DatabaseMetaData 的 Provider 公共实现。 + */ +abstract class AbstractJdbcDatabaseProvider implements DataspaceDatabaseProvider { + + /** + * 返回需要扫描的物理 Schema。 + * + * @param connection JDBC 连接 + * @param config 连接配置 + * @return Schema 名称;MySQL 使用单个空 Schema + * @throws SQLException 读取失败 + */ + protected abstract List schemas(Connection connection, DataspaceConnectionConfig config) + throws SQLException; + + /** + * 返回元数据查询使用的 Catalog。 + * + * @param connection JDBC 连接 + * @param config 连接配置 + * @return Catalog + * @throws SQLException 读取失败 + */ + protected String catalog(Connection connection, DataspaceConnectionConfig config) throws SQLException { + return connection.getCatalog(); + } + + /** + * 判断 Schema 是否属于系统空间。 + * + * @param schema Schema 名称 + * @return 系统 Schema 时为 true + */ + protected boolean systemSchema(String schema) { + return false; + } + + /** + * 读取当前连接的表、视图与字段元数据。 + * + * @param connection JDBC 连接 + * @param config 连接配置 + * @return 稳定排序的对象快照 + * @throws SQLException 读取失败 + */ + @Override + public List inspect( + Connection connection, + DataspaceConnectionConfig config) throws SQLException { + DatabaseMetaData metadata = connection.getMetaData(); + String catalog = catalog(connection, config); + List objects = new ArrayList<>(); + for (String schema : schemas(connection, config)) { + if (systemSchema(schema)) { + continue; + } + List descriptors = new ArrayList<>(); + try (ResultSet tables = metadata.getTables( + catalog, + schema, + "%", + new String[]{"TABLE", "VIEW"})) { + while (tables.next()) { + String tableCatalog = tables.getString("TABLE_CAT"); + String tableSchema = tables.getString("TABLE_SCHEM"); + String tableName = tables.getString("TABLE_NAME"); + descriptors.add(new TableDescriptor( + tableCatalog, + tableSchema, + tableName, + tables.getString("TABLE_TYPE"), + tables.getString("REMARKS"))); + } + } + if (descriptors.isEmpty()) { + continue; + } + Map> primaryKeys = primaryKeys( + connection, catalog, schema, + descriptors.stream().map(TableDescriptor::name).toList()); + Map> columns = columns( + metadata, catalog, schema, primaryKeys); + for (TableDescriptor descriptor : descriptors) { + objects.add(new DataspaceObjectMetadata( + descriptor.catalog(), descriptor.schema(), descriptor.name(), descriptor.type(), + descriptor.remarks(), columns.getOrDefault(tableKey(descriptor.name()), List.of()))); + } + } + objects.sort(Comparator + .comparing((DataspaceObjectMetadata item) -> safe(item.schema())) + .thenComparing(DataspaceObjectMetadata::name, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(objects); + } + + /** + * 批量读取当前 Schema 的主键字段;未知数据库默认逐表调用 JDBC 元数据。 + * + * @param connection JDBC 连接 + * @param catalog Catalog + * @param schema Schema + * @param tables 表名列表 + * @return 按规范化表名归组的大写主键字段 + * @throws SQLException 读取失败时抛出 + */ + protected Map> primaryKeys( + Connection connection, + String catalog, + String schema, + List tables) throws SQLException { + Map> keysByTable = new HashMap<>(); + DatabaseMetaData metadata = connection.getMetaData(); + for (String table : tables) { + Set keys = new HashSet<>(); + try (ResultSet result = metadata.getPrimaryKeys(catalog, schema, table)) { + while (result.next()) { + keys.add(result.getString("COLUMN_NAME").toUpperCase(Locale.ROOT)); + } + } + keysByTable.put(tableKey(table), Set.copyOf(keys)); + } + return Map.copyOf(keysByTable); + } + + /** + * 单次读取当前 Schema 的全部有序字段元数据。 + * + * @param metadata JDBC 元数据 + * @param catalog Catalog + * @param schema Schema + * @param primaryKeys 按表归组的大写主键字段 + * @return 按规范化表名归组的字段元数据 + * @throws SQLException 读取失败时抛出 + */ + private Map> columns( + DatabaseMetaData metadata, + String catalog, + String schema, + Map> primaryKeys) throws SQLException { + Map> columnsByTable = new LinkedHashMap<>(); + try (ResultSet result = metadata.getColumns(catalog, schema, "%", "%")) { + while (result.next()) { + String table = result.getString("TABLE_NAME"); + String name = result.getString("COLUMN_NAME"); + Set tableKeys = primaryKeys.getOrDefault(tableKey(table), Set.of()); + columnsByTable.computeIfAbsent(tableKey(table), ignored -> new ArrayList<>()).add( + new DataspaceColumnMetadata( + name, + result.getInt("DATA_TYPE"), + result.getString("TYPE_NAME"), + result.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls, + result.getInt("ORDINAL_POSITION"), + tableKeys.contains(name.toUpperCase(Locale.ROOT)), + result.getString("REMARKS"))); + } + } + columnsByTable.replaceAll((ignored, columns) -> columns.stream() + .sorted(Comparator.comparingInt(DataspaceColumnMetadata::ordinalPosition)) + .toList()); + return Map.copyOf(columnsByTable); + } + + /** + * 读取数据库暴露的业务 Schema。 + * + * @param connection JDBC 连接 + * @return Schema 名称 + * @throws SQLException 读取失败 + */ + protected List listSchemas(Connection connection) throws SQLException { + Set schemas = new LinkedHashSet<>(); + try (ResultSet result = connection.getMetaData().getSchemas()) { + while (result.next()) { + String schema = result.getString("TABLE_SCHEM"); + if (schema != null && !schema.isBlank() && !systemSchema(schema)) { + schemas.add(schema); + } + } + } + return List.copyOf(schemas); + } + + /** + * 将可空字符串转换为空串,保证排序稳定。 + * + * @param value 原始值 + * @return 非空字符串 + */ + private String safe(String value) { + return value == null ? "" : value; + } + + /** + * 规范化单个 Schema 内的表名索引键。 + * + * @param table 表名 + * @return 大写表名键 + */ + protected String tableKey(String table) { + return table.toUpperCase(Locale.ROOT); + } + + /** + * JDBC 表枚举阶段的轻量描述。 + * + * @param catalog Catalog + * @param schema Schema + * @param name 表名 + * @param type 对象类型 + * @param remarks 备注 + */ + private record TableDescriptor( + String catalog, + String schema, + String name, + String type, + String remarks) { + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceColumnMetadata.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceColumnMetadata.java new file mode 100644 index 00000000..ba34fd14 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceColumnMetadata.java @@ -0,0 +1,22 @@ +package tech.easyflow.dataspace.provider; + +/** + * 数据空间字段元数据。 + * + * @param name 字段名 + * @param jdbcType JDBC 类型 + * @param typeName 数据库类型名 + * @param nullable 是否可空 + * @param ordinalPosition 字段顺序 + * @param primaryKey 是否主键 + * @param remarks 字段说明 + */ +public record DataspaceColumnMetadata( + String name, + int jdbcType, + String typeName, + boolean nullable, + int ordinalPosition, + boolean primaryKey, + String remarks) { +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceConnectionConfig.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceConnectionConfig.java new file mode 100644 index 00000000..7e925880 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceConnectionConfig.java @@ -0,0 +1,37 @@ +package tech.easyflow.dataspace.provider; + +import java.util.Map; + +/** + * 数据库 Provider 使用的只读连接配置。 + * + * @param type 数据库类型 + * @param host 主机 + * @param port 端口 + * @param database 数据库名 + * @param username 用户名 + * @param password 明文密码,仅在节点内短暂使用 + * @param sslEnabled 是否启用 SSL + * @param options 扩展参数 + */ +public record DataspaceConnectionConfig( + DataspaceDatabaseType type, + String host, + int port, + String database, + String username, + String password, + boolean sslEnabled, + Map options) { + + /** + * 校验并复制连接配置。 + */ + public DataspaceConnectionConfig { + if (type == null || host == null || host.isBlank() || port <= 0 || port > 65535 + || database == null || database.isBlank() || username == null || username.isBlank()) { + throw new IllegalArgumentException("数据库连接参数不完整"); + } + options = Map.copyOf(options == null ? Map.of() : options); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProvider.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProvider.java new file mode 100644 index 00000000..91af6645 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProvider.java @@ -0,0 +1,45 @@ +package tech.easyflow.dataspace.provider; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; + +/** + * 数据空间数据库差异适配入口。 + */ +public interface DataspaceDatabaseProvider { + + /** + * 返回数据库类型。 + * + * @return 数据库类型 + */ + DataspaceDatabaseType type(); + + /** + * 构造 JDBC URL。 + * + * @param config 连接配置 + * @return JDBC URL + */ + String jdbcUrl(DataspaceConnectionConfig config); + + /** + * 读取当前连接中的业务表和视图。 + * + * @param connection JDBC 连接 + * @param config 连接配置 + * @return 元数据快照 + * @throws SQLException JDBC 元数据读取失败 + */ + List inspect(Connection connection, DataspaceConnectionConfig config) + throws SQLException; + + /** + * 返回 Calcite Definition 中的默认逻辑 Schema。 + * + * @param physicalSchema 物理 Schema + * @return SQL 使用的逻辑 Schema + */ + String logicalSchema(String physicalSchema); +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProviderRegistry.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProviderRegistry.java new file mode 100644 index 00000000..4716bdb2 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProviderRegistry.java @@ -0,0 +1,46 @@ +package tech.easyflow.dataspace.provider; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** + * 数据空间数据库 Provider 注册表。 + */ +@Component +public class DataspaceDatabaseProviderRegistry { + + private final Map providers; + + /** + * 创建 Provider 注册表。 + * + * @param providers Spring 发现的 Provider + */ + public DataspaceDatabaseProviderRegistry(List providers) { + EnumMap indexed = + new EnumMap<>(DataspaceDatabaseType.class); + for (DataspaceDatabaseProvider provider : providers) { + if (indexed.put(provider.type(), provider) != null) { + throw new IllegalStateException("重复的数据空间数据库 Provider: " + provider.type()); + } + } + this.providers = Map.copyOf(indexed); + } + + /** + * 获取指定类型的 Provider。 + * + * @param type 数据库类型 + * @return Provider + * @throws IllegalArgumentException 未注册时抛出 + */ + public DataspaceDatabaseProvider require(DataspaceDatabaseType type) { + DataspaceDatabaseProvider provider = providers.get(type); + if (provider == null) { + throw new IllegalArgumentException("数据库类型尚未接入: " + type); + } + return provider; + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceDatabaseType.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceDatabaseType.java new file mode 100644 index 00000000..8900a5a1 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceDatabaseType.java @@ -0,0 +1,64 @@ +package tech.easyflow.dataspace.provider; + +import com.easyagents.federation.sql.source.KnownJdbcDriver; + +/** + * 当前产品开放的数据空间数据库类型。 + */ +public enum DataspaceDatabaseType { + + /** MySQL 数据库。 */ + MYSQL(3306, KnownJdbcDriver.MYSQL.driverClassName()), + /** PostgreSQL 数据库。 */ + POSTGRESQL(5432, KnownJdbcDriver.POSTGRESQL.driverClassName()); + + private final int defaultPort; + private final String driverClassName; + + /** + * 创建数据库类型。 + * + * @param defaultPort 默认端口 + * @param driverClassName JDBC Driver 类名 + */ + DataspaceDatabaseType(int defaultPort, String driverClassName) { + this.defaultPort = defaultPort; + this.driverClassName = driverClassName; + } + + /** + * 返回默认端口。 + * + * @return 默认端口 + */ + public int defaultPort() { + return defaultPort; + } + + /** + * 返回 JDBC Driver 类名。 + * + * @return Driver 类名 + */ + public String driverClassName() { + return driverClassName; + } + + /** + * 解析数据库类型。 + * + * @param value 类型文本 + * @return 数据库类型 + * @throws IllegalArgumentException 类型不受支持时抛出 + */ + public static DataspaceDatabaseType parse(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("数据库类型不能为空"); + } + try { + return valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("暂不支持该数据库类型: " + value, exception); + } + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceObjectMetadata.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceObjectMetadata.java new file mode 100644 index 00000000..82c3baa7 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceObjectMetadata.java @@ -0,0 +1,29 @@ +package tech.easyflow.dataspace.provider; + +import java.util.List; + +/** + * 数据空间表或视图元数据。 + * + * @param catalog 物理 Catalog + * @param schema 物理 Schema + * @param name 对象名 + * @param type 对象类型 + * @param remarks 对象说明 + * @param columns 字段列表 + */ +public record DataspaceObjectMetadata( + String catalog, + String schema, + String name, + String type, + String remarks, + List columns) { + + /** + * 防御性复制字段列表。 + */ + public DataspaceObjectMetadata { + columns = List.copyOf(columns == null ? List.of() : columns); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceProbe.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceProbe.java new file mode 100644 index 00000000..11c787fc --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/DataspaceProbe.java @@ -0,0 +1,24 @@ +package tech.easyflow.dataspace.provider; + +/** + * 数据库连接测试结果。 + * + * @param success 是否成功 + * @param latencyMillis 响应耗时 + * @param databaseProduct 数据库产品 + * @param databaseVersion 数据库版本 + * @param driverName 驱动名称 + * @param driverVersion 驱动版本 + * @param errorCode 失败分类,成功时为空 + * @param message 诊断信息 + */ +public record DataspaceProbe( + boolean success, + long latencyMillis, + String databaseProduct, + String databaseVersion, + String driverName, + String driverVersion, + String errorCode, + String message) { +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/MySqlProvider.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/MySqlProvider.java new file mode 100644 index 00000000..18e744f7 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/MySqlProvider.java @@ -0,0 +1,97 @@ +package tech.easyflow.dataspace.provider; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; + +/** + * MySQL 数据空间 Provider。 + */ +@Component +public class MySqlProvider extends AbstractJdbcDatabaseProvider { + + /** + * 返回 MySQL 类型。 + * + * @return MySQL + */ + @Override + public DataspaceDatabaseType type() { + return DataspaceDatabaseType.MYSQL; + } + + /** + * 构造 MySQL JDBC URL。 + * + * @param config 连接配置 + * @return JDBC URL + */ + @Override + public String jdbcUrl(DataspaceConnectionConfig config) { + return "jdbc:mysql://" + config.host() + ":" + config.port() + "/" + config.database() + + "?useUnicode=true&characterEncoding=utf8&useSSL=" + config.sslEnabled() + + "&serverTimezone=UTC&allowMultiQueries=false"; + } + + /** + * MySQL 将数据库建模为 Catalog,Schema 参数为空。 + * + * @param connection JDBC 连接 + * @param config 连接配置 + * @return 单个空 Schema + */ + @Override + protected List schemas(Connection connection, DataspaceConnectionConfig config) { + return java.util.Collections.singletonList(null); + } + + /** + * 一次查询当前 Catalog 的全部主键,避免逐表元数据往返。 + * + * @param connection JDBC 连接 + * @param catalog Catalog + * @param schema Schema + * @param tables 表名列表 + * @return 按表归组的主键字段 + * @throws SQLException 查询失败 + */ + @Override + protected Map> primaryKeys( + Connection connection, + String catalog, + String schema, + List tables) throws SQLException { + Map> keys = new HashMap<>(); + String sql = "SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE " + + "WHERE TABLE_SCHEMA = ? AND CONSTRAINT_NAME = 'PRIMARY'"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, catalog); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + keys.computeIfAbsent(tableKey(result.getString("TABLE_NAME")), ignored -> new HashSet<>()) + .add(result.getString("COLUMN_NAME").toUpperCase(java.util.Locale.ROOT)); + } + } + } + keys.replaceAll((ignored, value) -> Set.copyOf(value)); + return Map.copyOf(keys); + } + + /** + * MySQL Definition 使用稳定的 MAIN 逻辑 Schema。 + * + * @param physicalSchema 物理 Schema + * @return MAIN + */ + @Override + public String logicalSchema(String physicalSchema) { + return "MAIN"; + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/PostgresqlProvider.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/PostgresqlProvider.java new file mode 100644 index 00000000..b9f26f49 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/provider/PostgresqlProvider.java @@ -0,0 +1,136 @@ +package tech.easyflow.dataspace.provider; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; + +/** + * PostgreSQL 数据空间 Provider。 + */ +@Component +public class PostgresqlProvider extends AbstractJdbcDatabaseProvider { + + private static final Set SYSTEM_SCHEMAS = Set.of( + "information_schema", "pg_catalog", "pg_toast"); + + /** + * 返回 PostgreSQL 类型。 + * + * @return PostgreSQL + */ + @Override + public DataspaceDatabaseType type() { + return DataspaceDatabaseType.POSTGRESQL; + } + + /** + * 构造 PostgreSQL JDBC URL。 + * + * @param config 连接配置 + * @return JDBC URL + */ + @Override + public String jdbcUrl(DataspaceConnectionConfig config) { + return "jdbc:postgresql://" + config.host() + ":" + config.port() + "/" + config.database() + + "?sslmode=" + (config.sslEnabled() ? "require" : "disable") + + "&ApplicationName=easyflow-dataspace"; + } + + /** + * 读取 PostgreSQL 业务 Schema。 + * + * @param connection JDBC 连接 + * @param config 连接配置 + * @return Schema 列表 + * @throws SQLException 读取失败 + */ + @Override + protected List schemas(Connection connection, DataspaceConnectionConfig config) + throws SQLException { + return listSchemas(connection); + } + + /** + * PostgreSQL 元数据查询不限定 Catalog,避免驱动版本差异。 + * + * @param connection JDBC 连接 + * @param config 连接配置 + * @return null + */ + @Override + protected String catalog(Connection connection, DataspaceConnectionConfig config) { + return null; + } + + /** + * 一次查询当前 Schema 的全部主键,避免逐表元数据往返。 + * + * @param connection JDBC 连接 + * @param catalog Catalog + * @param schema Schema + * @param tables 表名列表 + * @return 按表归组的主键字段 + * @throws SQLException 查询失败 + */ + @Override + protected Map> primaryKeys( + Connection connection, + String catalog, + String schema, + List tables) throws SQLException { + Map> keys = new HashMap<>(); + String sql = "SELECT c.relname AS table_name, a.attname AS column_name " + + "FROM pg_catalog.pg_index i " + + "JOIN pg_catalog.pg_class c ON c.oid = i.indrelid " + + "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + + "JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey) " + + "WHERE i.indisprimary AND n.nspname = ?"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, schema); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + keys.computeIfAbsent(tableKey(result.getString("table_name")), ignored -> new HashSet<>()) + .add(result.getString("column_name").toUpperCase(Locale.ROOT)); + } + } + } + keys.replaceAll((ignored, value) -> Set.copyOf(value)); + return Map.copyOf(keys); + } + + /** + * 过滤 PostgreSQL 系统 Schema。 + * + * @param schema Schema 名称 + * @return 系统 Schema 时为 true + */ + @Override + protected boolean systemSchema(String schema) { + if (schema == null) { + return false; + } + String normalized = schema.toLowerCase(Locale.ROOT); + return SYSTEM_SCHEMAS.contains(normalized) || normalized.startsWith("pg_temp_"); + } + + /** + * PostgreSQL 直接使用大写物理 Schema 作为 Calcite 逻辑 Schema。 + * + * @param physicalSchema 物理 Schema + * @return 逻辑 Schema + */ + @Override + public String logicalSchema(String physicalSchema) { + return physicalSchema == null || physicalSchema.isBlank() + ? "PUBLIC" : physicalSchema.toUpperCase(Locale.ROOT); + } + +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/security/DataspaceActor.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/security/DataspaceActor.java new file mode 100644 index 00000000..20477e9b --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/security/DataspaceActor.java @@ -0,0 +1,39 @@ +package tech.easyflow.dataspace.security; + +import java.math.BigInteger; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.dataspace.model.DataspaceErrorCode; + +/** + * 数据空间当前登录主体解析工具。 + */ +public final class DataspaceActor { + + private DataspaceActor() { + } + + /** + * 返回当前登录账号。 + * + * @return 登录账号 + * @throws BusinessException 登录上下文缺失时抛出 + */ + public static LoginAccount current() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw DataspaceErrorCode.ACCESS_DENIED.exception("当前登录信息无效"); + } + return account; + } + + /** + * 返回当前租户 ID。 + * + * @return 租户 ID + */ + public static BigInteger tenantId() { + return current().getTenantId(); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/security/DataspaceCredentialCipher.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/security/DataspaceCredentialCipher.java new file mode 100644 index 00000000..00dc37c2 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/security/DataspaceCredentialCipher.java @@ -0,0 +1,115 @@ +package tech.easyflow.dataspace.security; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 使用部署主密钥认证加密数据空间连接密码。 + */ +@Component +public class DataspaceCredentialCipher { + + private static final String VERSION = "v1:"; + private static final String KEY_DOMAIN = "easyflow-dataspace-credential-v1\u0000"; + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + + private final SecretKeySpec key; + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * 创建凭据加密器。 + * + * @param masterKey 部署主密钥;兼容复用现有数据中心独立密钥 + * @throws IllegalStateException 密钥长度不足时抛出 + */ + public DataspaceCredentialCipher( + @Value("${easyflow.dataspace.credential-key:${easyflow.datacenter.credential-key:}}") + String masterKey) { + if (masterKey == null || masterKey.length() < 32) { + throw new IllegalStateException( + "easyflow.dataspace.credential-key must contain at least 32 characters"); + } + this.key = new SecretKeySpec(sha256(KEY_DOMAIN + masterKey), "AES"); + } + + /** + * 加密密码。 + * + * @param plainText 明文 + * @return 版本化密文;空值返回 null + */ + public String encrypt(String plainText) { + if (plainText == null || plainText.isBlank()) { + return null; + } + byte[] iv = new byte[IV_BYTES]; + secureRandom.nextBytes(iv); + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + return VERSION + Base64.getEncoder().encodeToString( + ByteBuffer.allocate(iv.length + encrypted.length) + .put(iv).put(encrypted).array()); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("数据空间凭据加密失败", exception); + } + } + + /** + * 解密密码。 + * + * @param cipherText 版本化密文 + * @return 明文;空值返回 null + * @throws DataspaceCredentialUnavailableException 密钥不匹配、密文损坏或版本不兼容时抛出 + */ + public String decrypt(String cipherText) { + if (cipherText == null || cipherText.isBlank()) { + return null; + } + if (!cipherText.startsWith(VERSION)) { + throw new DataspaceCredentialUnavailableException( + new IllegalArgumentException("不支持的数据空间凭据格式")); + } + try { + byte[] payload = Base64.getDecoder().decode(cipherText.substring(VERSION.length())); + if (payload.length <= IV_BYTES) { + throw new DataspaceCredentialUnavailableException( + new IllegalArgumentException("数据空间凭据内容无效")); + } + byte[] iv = java.util.Arrays.copyOfRange(payload, 0, IV_BYTES); + byte[] encrypted = java.util.Arrays.copyOfRange(payload, IV_BYTES, payload.length); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } catch (GeneralSecurityException | IllegalArgumentException exception) { + throw new DataspaceCredentialUnavailableException(exception); + } + } + + /** + * 将任意长度部署密钥派生为固定长度 AES 密钥材料。 + * + * @param value 部署密钥 + * @return SHA-256 摘要 + */ + private byte[] sha256(String value) { + try { + return MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 不可用", exception); + } + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/security/DataspaceCredentialUnavailableException.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/security/DataspaceCredentialUnavailableException.java new file mode 100644 index 00000000..44760ca8 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/security/DataspaceCredentialUnavailableException.java @@ -0,0 +1,30 @@ +package tech.easyflow.dataspace.security; + +import tech.easyflow.common.web.exceptions.BusinessException; + +/** + * 表示已保存的数据空间连接凭据无法使用。 + * + *

部署密钥变更、密文损坏或密文版本不兼容时,用户需要重新输入数据库密码。 + * 该异常使用冲突状态表达当前连接定义与部署密钥不匹配。

+ */ +public final class DataspaceCredentialUnavailableException extends BusinessException { + + private static final long serialVersionUID = 1L; + + /** 凭据失效的稳定业务错误码。 */ + public static final int ERROR_CODE = 40905; + + /** 可安全返回给用户的恢复提示。 */ + public static final String USER_MESSAGE = + "连接凭据已失效,请编辑连接并重新输入数据库密码"; + + /** + * 创建凭据不可用异常。 + * + * @param cause 原始解密失败原因 + */ + public DataspaceCredentialUnavailableException(Throwable cause) { + super(409, ERROR_CODE, USER_MESSAGE, cause); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceConnectionService.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceConnectionService.java new file mode 100644 index 00000000..968954dc --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceConnectionService.java @@ -0,0 +1,694 @@ +package tech.easyflow.dataspace.service; + +import com.mybatisflex.core.query.QueryWrapper; +import com.zaxxer.hikari.HikariDataSource; +import java.math.BigInteger; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import tech.easyflow.common.cache.RedisLockExecutor; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.util.SearchKeywordUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.dataspace.entity.DataspaceConnection; +import tech.easyflow.dataspace.entity.DataspaceObject; +import tech.easyflow.dataspace.entity.DataspaceTableBinding; +import tech.easyflow.dataspace.federation.DataspaceDataSourceFactory; +import tech.easyflow.dataspace.federation.DataspaceDefinitionFactory; +import tech.easyflow.dataspace.mapper.DataspaceConnectionMapper; +import tech.easyflow.dataspace.mapper.DataspaceObjectMapper; +import tech.easyflow.dataspace.mapper.DataspaceTableBindingMapper; +import tech.easyflow.dataspace.model.ConnectionDefinition; +import tech.easyflow.dataspace.model.ConnectionView; +import tech.easyflow.dataspace.model.DataspaceErrorCode; +import tech.easyflow.dataspace.model.ObjectView; +import tech.easyflow.dataspace.provider.DataspaceColumnMetadata; +import tech.easyflow.dataspace.provider.DataspaceConnectionConfig; +import tech.easyflow.dataspace.provider.DataspaceDatabaseProvider; +import tech.easyflow.dataspace.provider.DataspaceDatabaseProviderRegistry; +import tech.easyflow.dataspace.provider.DataspaceDatabaseType; +import tech.easyflow.dataspace.provider.DataspaceObjectMetadata; +import tech.easyflow.dataspace.provider.DataspaceProbe; +import tech.easyflow.dataspace.security.DataspaceActor; +import tech.easyflow.dataspace.security.DataspaceCredentialCipher; + +/** + * 物理数据库连接、测试和版本化元数据快照服务。 + */ +@Service +public class DataspaceConnectionService { + + private static final Logger LOG = LoggerFactory.getLogger(DataspaceConnectionService.class); + private static final Duration LOCK_WAIT = Duration.ofSeconds(3); + private static final Duration LOCK_LEASE = Duration.ofSeconds(30); + private final DataspaceConnectionMapper connectionMapper; + private final DataspaceObjectMapper objectMapper; + private final DataspaceTableBindingMapper bindingMapper; + private final DataspaceDatabaseProviderRegistry providerRegistry; + private final DataspaceDataSourceFactory dataSourceFactory; + private final DataspaceDefinitionFactory definitionFactory; + private final DataspaceCredentialCipher credentialCipher; + private final RedisLockExecutor lockExecutor; + private final TransactionTemplate transactionTemplate; + + /** + * 创建连接服务。 + * + * @param connectionMapper 连接 Mapper + * @param objectMapper 元数据对象 Mapper + * @param bindingMapper 表绑定 Mapper + * @param providerRegistry Provider 注册表 + * @param dataSourceFactory 连接池工厂 + * @param definitionFactory Federation Definition 工厂 + * @param credentialCipher 凭据加密器 + * @param lockExecutor 分布式锁执行器 + * @param transactionTemplate 事务模板 + */ + public DataspaceConnectionService( + DataspaceConnectionMapper connectionMapper, + DataspaceObjectMapper objectMapper, + DataspaceTableBindingMapper bindingMapper, + DataspaceDatabaseProviderRegistry providerRegistry, + DataspaceDataSourceFactory dataSourceFactory, + DataspaceDefinitionFactory definitionFactory, + DataspaceCredentialCipher credentialCipher, + RedisLockExecutor lockExecutor, + TransactionTemplate transactionTemplate) { + this.connectionMapper = connectionMapper; + this.objectMapper = objectMapper; + this.bindingMapper = bindingMapper; + this.providerRegistry = providerRegistry; + this.dataSourceFactory = dataSourceFactory; + this.definitionFactory = definitionFactory; + this.credentialCipher = credentialCipher; + this.lockExecutor = lockExecutor; + this.transactionTemplate = transactionTemplate; + } + + /** + * 查询当前租户连接。 + * + * @param keyword 名称、主机或数据库关键词 + * @return 安全连接视图 + */ + public List list(String keyword) { + QueryWrapper query = QueryWrapper.create().orderBy("modified desc, id desc"); + if (keyword != null && !keyword.isBlank()) { + String pattern = SearchKeywordUtil.literalContainsPattern(keyword.trim()); + query.and("(name LIKE ? ESCAPE '\\\\' OR host LIKE ? ESCAPE '\\\\' " + + "OR database_name LIKE ? ESCAPE '\\\\')", pattern, pattern, pattern); + } + return connectionMapper.selectListByQuery(query).stream().map(ConnectionView::from).toList(); + } + + /** + * 获取连接详情。 + * + * @param id 连接 ID + * @return 安全连接视图 + */ + public ConnectionView detail(BigInteger id) { + return ConnectionView.from(requireConnection(id)); + } + + /** + * 测试候选连接;已保存连接会记录最近测试结果。 + * + * @param definition 候选定义 + * @return 测试结果 + */ + public DataspaceProbe test(ConnectionDefinition definition) { + CandidateInspection inspection = inspect(definition, false); + if (definition != null && definition.id() != null) { + LoginAccount account = DataspaceActor.current(); + String lockKey = connectionLockKey(account, definition.id().toString()); + lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE, () -> + transactionTemplate.executeWithoutResult(status -> { + DataspaceConnection current = requireConnection(definition.id()); + applyProbe(current, inspection.probe(), account); + connectionMapper.update(current); + })); + } + return inspection.probe(); + } + + /** + * 创建或更新连接,并在同一业务操作中保存元数据快照。 + * + * @param definition 连接定义 + * @return 保存后的安全视图 + */ + public ConnectionView save(ConnectionDefinition definition) { + if (definition == null) { + throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception("连接参数不能为空"); + } + String connectionName = requiredText(definition.name(), "连接名称"); + CandidateInspection inspection = inspect(definition, true); + if (!inspection.probe().success()) { + throw DataspaceErrorCode.CONNECTION_TEST_FAILED.exception( + inspection.probe().message()); + } + LoginAccount account = DataspaceActor.current(); + String identity = definition.id() == null + ? "name:" + connectionName.toLowerCase(Locale.ROOT) + : definition.id().toString(); + String lockKey = connectionLockKey(account, identity); + DataspaceConnection saved = lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE, + () -> transactionTemplate.execute(status -> definition.id() == null + ? insert(definition, inspection, account) + : update(definition, inspection, account))); + return ConnectionView.from(saved); + } + + /** + * 启用或禁用连接。状态切换与连接定义更新共用同一把分布式锁,避免并发覆盖。 + * + * @param id 连接 ID + * @param enabled 是否启用 + * @return 变更后的安全连接视图 + */ + public ConnectionView setEnabled(BigInteger id, boolean enabled) { + LoginAccount account = DataspaceActor.current(); + String lockKey = connectionLockKey(account, id == null ? "missing" : id.toString()); + DataspaceConnection saved = lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE, + () -> transactionTemplate.execute(status -> { + DataspaceConnection current = requireConnection(id); + String nextStatus = enabled ? "ENABLED" : "DISABLED"; + if (nextStatus.equals(current.getStatus())) { + return current; + } + current.setStatus(nextStatus); + current.setModified(new Date()); + current.setModifiedBy(account.getId()); + connectionMapper.update(current); + return current; + })); + return ConnectionView.from(saved); + } + + /** + * 刷新已保存连接的元数据并推进 Definition revision。 + * + * @param id 连接 ID + * @param expectedRevision 期望 revision + * @return 新 revision 的对象列表 + */ + public List refreshMetadata(BigInteger id, long expectedRevision) { + DataspaceConnection current = requireConnection(id); + ConnectionDefinition definition = new ConnectionDefinition( + id, expectedRevision, current.getName(), current.getDatabaseType(), current.getHost(), + current.getPort(), current.getDatabaseName(), current.getUsername(), null, + Integer.valueOf(1).equals(current.getSslEnabled()), current.getOptionsJson()); + CandidateInspection inspection = inspect(definition, true); + if (!inspection.probe().success()) { + throw DataspaceErrorCode.CONNECTION_TEST_FAILED.exception( + inspection.probe().message()); + } + LoginAccount account = DataspaceActor.current(); + String lockKey = connectionLockKey(account, id.toString()); + DataspaceConnection saved = lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE, + () -> transactionTemplate.execute(status -> update(definition, inspection, account))); + return objects(id, saved.getDefinitionRevision(), null); + } + + /** + * 查询连接当前 revision 的数据库对象,关键词同时匹配 Schema 和表名。 + * + * @param connectionId 连接 ID + * @param keyword 搜索关键词 + * @return 对象列表 + */ + public List objects(BigInteger connectionId, String keyword) { + DataspaceConnection connection = requireConnection(connectionId); + return objects(connectionId, connection.getDefinitionRevision(), keyword); + } + + /** + * 删除未被任何数据空间 revision 引用的连接。 + * + * @param id 连接 ID + */ + public void remove(BigInteger id) { + LoginAccount account = DataspaceActor.current(); + String lockKey = connectionLockKey(account, id == null ? "missing" : id.toString()); + lockExecutor.executeWithLock( + lockKey, LOCK_WAIT, LOCK_LEASE, () -> + transactionTemplate.execute(status -> { + DataspaceConnection connection = requireConnection(id); + long references = bindingMapper.selectCountByQuery(QueryWrapper.create() + .eq(DataspaceTableBinding::getConnectionId, id)); + if (references > 0) { + throw DataspaceErrorCode.CONNECTION_IN_USE.exception(); + } + connectionMapper.deleteById(connection.getId()); + return connection; + })); + } + + /** + * 新增连接及其首个不可变元数据快照。 + * + * @param definition 连接定义 + * @param inspection 探测与元数据结果 + * @param account 当前登录账号 + * @return 新增连接 + */ + private DataspaceConnection insert( + ConnectionDefinition definition, + CandidateInspection inspection, + LoginAccount account) { + ensureNameUnique(definition.name(), null); + DataspaceConnection source = toEntity(definition, null, account); + source.setDefinitionRevision(1L); + source.setStatus("ENABLED"); + applyProbe(source, inspection.probe(), account); + source.setMetadataRefreshedAt(new Date()); + connectionMapper.insert(source); + List objects = toObjects(source, 1L, inspection.objects(), account); + if (!objects.isEmpty()) { + objectMapper.insertBatch(objects); + } + source.setDefinitionChecksum(definitionFactory.create(source, objects).checksum()); + connectionMapper.update(source); + return source; + } + + /** + * 通过 revision CAS 更新连接并写入新的元数据快照。 + * + * @param definition 连接定义 + * @param inspection 探测与元数据结果 + * @param account 当前登录账号 + * @return 更新后的连接 + * @throws BusinessException revision 冲突时抛出 + */ + private DataspaceConnection update( + ConnectionDefinition definition, + CandidateInspection inspection, + LoginAccount account) { + DataspaceConnection current = requireConnection(definition.id()); + long expected = definition.expectedRevision() == null + ? -1L : definition.expectedRevision(); + if (current.getDefinitionRevision() == null || current.getDefinitionRevision() != expected) { + throw DataspaceErrorCode.REVISION_CONFLICT.exception( + "连接已被其他节点更新,请刷新后重试"); + } + ensureNameUnique(definition.name(), current.getId()); + long nextRevision = expected + 1L; + DataspaceConnection candidate = toEntity(definition, current, account); + candidate.setDefinitionRevision(nextRevision); + candidate.setStatus(current.getStatus()); + applyProbe(candidate, inspection.probe(), account); + candidate.setMetadataRefreshedAt(new Date()); + List objects = toObjects(candidate, nextRevision, inspection.objects(), account); + String checksum = definitionFactory.create(candidate, objects).checksum(); + int changed = connectionMapper.compareAndSetRevision( + candidate.getId(), account.getTenantId(), expected, nextRevision, checksum); + if (changed != 1) { + throw DataspaceErrorCode.REVISION_CONFLICT.exception( + "连接已被其他节点更新,请刷新后重试"); + } + candidate.setDefinitionChecksum(checksum); + connectionMapper.update(candidate); + if (!objects.isEmpty()) { + objectMapper.insertBatch(objects); + } + return candidate; + } + + /** + * 使用短生命周期连接池测试连接并按需读取元数据。 + * + * @param definition 候选连接定义 + * @param includeMetadata 是否读取对象元数据 + * @return 探测结果 + */ + private CandidateInspection inspect(ConnectionDefinition definition, boolean includeMetadata) { + if (definition == null) { + throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception("连接参数不能为空"); + } + DataspaceConnection existing = definition.id() == null ? null : requireConnection(definition.id()); + DataspaceConnectionConfig config = connectionConfig(definition, existing); + DataspaceDatabaseProvider provider = providerRegistry.require(config.type()); + long started = System.nanoTime(); + try (HikariDataSource pool = dataSourceFactory.temporary(config); + Connection connection = pool.getConnection()) { + connection.setReadOnly(true); + DatabaseMetaData metadata = connection.getMetaData(); + List objects = includeMetadata + ? provider.inspect(connection, config) : List.of(); + DataspaceProbe probe = new DataspaceProbe( + true, Duration.ofNanos(System.nanoTime() - started).toMillis(), + metadata.getDatabaseProductName(), metadata.getDatabaseProductVersion(), + metadata.getDriverName(), metadata.getDriverVersion(), null, "连接成功"); + return new CandidateInspection(probe, objects); + } catch (Exception exception) { + LOG.error("数据空间连接测试失败,type={}, host={}, database={}", + config.type(), config.host(), config.database(), exception); + ConnectionFailure failure = classifyFailure(exception); + return new CandidateInspection(new DataspaceProbe( + false, Duration.ofNanos(System.nanoTime() - started).toMillis(), + null, null, null, null, failure.code(), failure.message()), + List.of()); + } + } + + /** + * 合并候选定义和已有密文凭据,构造 Provider 连接参数。 + * + * @param definition 候选连接定义 + * @param existing 已有连接 + * @return Provider 连接参数 + * @throws BusinessException 必填参数或地址非法时抛出 + */ + private DataspaceConnectionConfig connectionConfig( + ConnectionDefinition definition, + DataspaceConnection existing) { + DataspaceDatabaseType type = DataspaceDatabaseType.parse(definition.databaseType()); + String password = definition.password(); + if ((password == null || password.isBlank()) && existing != null) { + password = credentialCipher.decrypt(existing.getCredentialCipher()); + } + if (password == null) { + throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception( + "数据库密码不能为空"); + } + String host = requiredText(definition.host(), "主机地址"); + String database = requiredText(definition.databaseName(), "数据库"); + if (host.matches(".*[/?#&;].*") || database.matches(".*[/?#&;].*")) { + throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception( + "主机地址或数据库名称包含非法字符"); + } + int port = definition.port() == null ? type.defaultPort() : definition.port(); + return new DataspaceConnectionConfig( + type, host, port, database, requiredText(definition.username(), "用户名"), password, + Boolean.TRUE.equals(definition.sslEnabled()), definition.options()); + } + + /** + * 将 API 连接定义转换为持久化记录并加密密码。 + * + * @param definition 连接定义 + * @param existing 已有记录 + * @param account 当前登录账号 + * @return 待持久化记录 + */ + private DataspaceConnection toEntity( + ConnectionDefinition definition, + DataspaceConnection existing, + LoginAccount account) { + DataspaceDatabaseType type = DataspaceDatabaseType.parse(definition.databaseType()); + DataspaceConnection target = new DataspaceConnection(); + Date now = new Date(); + target.setId(existing == null ? null : existing.getId()); + target.setTenantId(account.getTenantId()); + target.setName(requiredText(definition.name(), "连接名称")); + target.setDatabaseType(type.name()); + target.setHost(requiredText(definition.host(), "主机地址")); + target.setPort(definition.port() == null ? type.defaultPort() : definition.port()); + target.setDatabaseName(requiredText(definition.databaseName(), "数据库")); + target.setUsername(requiredText(definition.username(), "用户名")); + target.setCredentialCipher(definition.password() == null || definition.password().isBlank() + ? existing == null ? null : existing.getCredentialCipher() + : credentialCipher.encrypt(definition.password())); + target.setSslEnabled(Boolean.TRUE.equals(definition.sslEnabled()) ? 1 : 0); + target.setDriverClassName(type.driverClassName()); + target.setOptionsJson(new LinkedHashMap<>(definition.options())); + target.setCreated(existing == null ? now : existing.getCreated()); + target.setCreatedBy(existing == null ? account.getId() : existing.getCreatedBy()); + target.setModified(now); + target.setModifiedBy(account.getId()); + target.setDeleted(0); + return target; + } + + /** + * 将 Provider 元数据转换为指定 revision 的持久化对象。 + * + * @param connection 物理连接 + * @param metadataRevision 元数据 revision + * @param metadata Provider 元数据 + * @param account 当前登录账号 + * @return 元数据对象列表 + */ + private List toObjects( + DataspaceConnection connection, + long metadataRevision, + List metadata, + LoginAccount account) { + List objects = new ArrayList<>(metadata.size()); + Date now = new Date(); + for (DataspaceObjectMetadata item : metadata) { + DataspaceObject object = new DataspaceObject(); + object.setTenantId(account.getTenantId()); + object.setConnectionId(connection.getId()); + object.setCatalogName(item.catalog()); + object.setSchemaName(item.schema()); + object.setObjectName(item.name()); + object.setObjectType(item.type()); + object.setRemarks(item.remarks()); + List> columns = new ArrayList<>(); + List primaryKeys = new ArrayList<>(); + for (DataspaceColumnMetadata column : item.columns()) { + Map value = new LinkedHashMap<>(); + value.put("name", column.name()); + value.put("jdbcType", column.jdbcType()); + value.put("typeName", column.typeName()); + value.put("nullable", column.nullable()); + value.put("ordinalPosition", column.ordinalPosition()); + value.put("primaryKey", column.primaryKey()); + value.put("remarks", column.remarks()); + columns.add(value); + if (column.primaryKey()) { + primaryKeys.add(column.name()); + } + } + object.setColumnsJson(columns); + object.setPrimaryKeysJson(primaryKeys); + object.setMetadataRevision(metadataRevision); + object.setCreated(now); + object.setCreatedBy(account.getId()); + object.setModified(now); + object.setModifiedBy(account.getId()); + object.setDeleted(0); + objects.add(object); + } + return objects; + } + + /** + * 将连接探测信息写入连接记录。 + * + * @param target 目标连接 + * @param probe 探测结果 + * @param account 当前登录账号 + */ + private void applyProbe( + DataspaceConnection target, + DataspaceProbe probe, + LoginAccount account) { + target.setLastTestStatus(probe.success() ? "SUCCESS" : "FAILED"); + target.setLastTestMessage(probe.message()); + target.setDatabaseProduct(probe.databaseProduct()); + target.setDatabaseVersion(probe.databaseVersion()); + target.setDriverName(probe.driverName()); + target.setDriverVersion(probe.driverVersion()); + target.setLastTestedAt(new Date()); + target.setModified(new Date()); + target.setModifiedBy(account.getId()); + } + + /** + * 查询指定元数据 revision 的对象,并同时支持 Schema 与表名模糊搜索。 + * + * @param connectionId 连接 ID + * @param metadataRevision 元数据 revision + * @param keyword 搜索关键词 + * @return 对象视图列表 + */ + private List objects( + BigInteger connectionId, + long metadataRevision, + String keyword) { + QueryWrapper query = QueryWrapper.create() + .eq(DataspaceObject::getConnectionId, connectionId) + .eq(DataspaceObject::getMetadataRevision, metadataRevision) + .orderBy("schema_name asc, object_name asc"); + if (keyword != null && !keyword.isBlank()) { + String pattern = SearchKeywordUtil.literalContainsPattern(keyword.trim()); + query.and("(schema_name LIKE ? ESCAPE '\\\\' OR object_name LIKE ? ESCAPE '\\\\')", + pattern, pattern); + } + return objectMapper.selectListByQuery(query).stream().map(ObjectView::from).toList(); + } + + /** + * 校验当前租户内连接名称唯一性。 + * + * @param name 连接名称 + * @param excludedId 更新时排除的连接 ID + * @throws BusinessException 名称重复时抛出 + */ + private void ensureNameUnique(String name, BigInteger excludedId) { + QueryWrapper query = QueryWrapper.create().eq(DataspaceConnection::getName, requiredText(name, "连接名称")); + if (excludedId != null) { + query.ne(DataspaceConnection::getId, excludedId); + } + if (connectionMapper.selectCountByQuery(query) > 0) { + throw DataspaceErrorCode.NAME_CONFLICT.exception("连接名称已存在"); + } + } + + /** + * 获取当前租户可见的连接。 + * + * @param id 连接 ID + * @return 连接记录 + * @throws BusinessException ID 为空或记录不存在时抛出 + */ + private DataspaceConnection requireConnection(BigInteger id) { + if (id == null) { + throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception("连接 ID 不能为空"); + } + DataspaceConnection source = connectionMapper.selectOneById(id); + if (source == null) { + throw DataspaceErrorCode.CONNECTION_NOT_FOUND.exception(); + } + return source; + } + + /** + * 获取必填文本并去除首尾空白。 + * + * @param value 原始值 + * @param label 字段标签 + * @return 规范化文本 + * @throws BusinessException 文本为空时抛出 + */ + private String requiredText(String value, String label) { + if (value == null || value.isBlank()) { + throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception( + label + "不能为空"); + } + return value.trim(); + } + + /** + * 构造同一租户内统一的连接聚合锁键,确保测试、保存、刷新和删除互斥。 + * + * @param account 当前登录账号 + * @param identity 连接 ID 或创建时的规范化名称 + * @return 分布式锁键 + */ + private String connectionLockKey(LoginAccount account, String identity) { + return "easyflow:lock:dataspace:connection:" + + account.getTenantId() + ":" + identity; + } + + /** + * 将底层连接异常转换为有长度上限的用户可读信息。 + * + * @param exception 连接异常 + * @return 安全错误信息 + */ + private String safeMessage(Throwable exception) { + String message = exception.getMessage(); + if (message == null || message.isBlank()) { + return "连接失败,请检查地址、账密和网络"; + } + return message.length() > 300 ? message.substring(0, 300) : message; + } + + /** + * 将 JDBC、驱动与网络异常归一化为稳定的连接失败分类。 + * + * @param exception 连接失败异常 + * @return 安全失败分类 + */ + private ConnectionFailure classifyFailure(Exception exception) { + Throwable cause = exception; + while (cause != null) { + if (cause instanceof SQLException sqlException) { + String state = sqlException.getSQLState(); + if (state != null && state.startsWith("28")) { + return new ConnectionFailure("AUTH_FAILED", "数据库用户名或密码不正确"); + } + if ("3D000".equals(state) || sqlException.getErrorCode() == 1049) { + return new ConnectionFailure("DATABASE_NOT_FOUND", "目标数据库不存在"); + } + if ("42501".equals(state)) { + return new ConnectionFailure("PERMISSION_DENIED", "当前账号没有访问权限"); + } + if (state != null && state.startsWith("08")) { + return new ConnectionFailure("NETWORK_UNREACHABLE", "无法连接数据库服务"); + } + } + if (cause instanceof UnknownHostException + || cause instanceof ConnectException + || cause instanceof NoRouteToHostException + || cause instanceof SocketTimeoutException) { + return new ConnectionFailure("NETWORK_UNREACHABLE", "无法连接数据库服务"); + } + cause = cause.getCause(); + } + String message = safeMessage(exception); + String normalized = message.toLowerCase(Locale.ROOT); + if (normalized.contains("driver") && normalized.contains("not found")) { + return new ConnectionFailure("DRIVER_NOT_FOUND", "数据库驱动未安装"); + } + if (normalized.contains("access denied") + || normalized.contains("password authentication failed")) { + return new ConnectionFailure("AUTH_FAILED", "数据库用户名或密码不正确"); + } + if (normalized.contains("unknown database") + || normalized.contains("database") && normalized.contains("does not exist")) { + return new ConnectionFailure("DATABASE_NOT_FOUND", "目标数据库不存在"); + } + if (normalized.contains("permission denied") || normalized.contains("not authorized")) { + return new ConnectionFailure("PERMISSION_DENIED", "当前账号没有访问权限"); + } + return new ConnectionFailure("CONNECTION_FAILED", message); + } + + /** + * 一次候选连接探测及其可选元数据结果。 + * + * @param probe 探测结果 + * @param objects 元数据对象 + */ + private record CandidateInspection( + DataspaceProbe probe, + List objects) { + + /** + * 防御性复制元数据列表。 + */ + private CandidateInspection { + objects = List.copyOf(objects == null ? List.of() : objects); + } + } + + /** + * 稳定的连接失败分类。 + * + * @param code 失败代码 + * @param message 用户可读信息 + */ + private record ConnectionFailure(String code, String message) { + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceMetadataResolver.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceMetadataResolver.java new file mode 100644 index 00000000..73346813 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceMetadataResolver.java @@ -0,0 +1,296 @@ +package tech.easyflow.dataspace.service; + +import com.mybatisflex.core.query.QueryWrapper; +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.stereotype.Component; +import tech.easyflow.dataspace.entity.DataspaceConnection; +import tech.easyflow.dataspace.entity.DataspaceObject; +import tech.easyflow.dataspace.mapper.DataspaceObjectMapper; +import tech.easyflow.dataspace.model.DataspaceErrorCode; + +/** + * 将历史表绑定按稳定物理身份解析到连接的最新元数据快照。 + */ +@Component +public class DataspaceMetadataResolver { + + private final DataspaceObjectMapper objectMapper; + + /** + * 创建元数据解析器。 + * + * @param objectMapper 元数据对象 Mapper + */ + public DataspaceMetadataResolver(DataspaceObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 批量解析历史对象,避免按表逐条查询当前元数据。 + * + * @param historicalObjects 绑定时的历史对象,键为历史对象 ID + * @param connections 当前物理连接 + * @return 历史对象 ID 到解析结果的映射 + */ + public Map resolve( + Map historicalObjects, + Map connections) { + Map> currentByConnection = + new LinkedHashMap<>(); + for (DataspaceConnection connection : connections.values()) { + if (!"ENABLED".equals(connection.getStatus())) { + currentByConnection.put(connection.getId(), Map.of()); + continue; + } + long revision = currentMetadataRevision(connection); + Map current = new LinkedHashMap<>(); + if (revision > 0) { + List objects = objectMapper.selectListByQuery(QueryWrapper.create() + .eq(DataspaceObject::getConnectionId, connection.getId()) + .eq(DataspaceObject::getMetadataRevision, revision)); + for (DataspaceObject object : objects) { + current.put(identity(object, connection), object); + } + } + currentByConnection.put(connection.getId(), current); + } + + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : historicalObjects.entrySet()) { + DataspaceObject historical = entry.getValue(); + DataspaceConnection connection = connections.get(historical.getConnectionId()); + if (connection == null) { + result.put(entry.getKey(), new ObjectResolution( + historical, null, "MISSING", + DataspaceErrorCode.CONNECTION_NOT_FOUND.code(), + DataspaceErrorCode.CONNECTION_NOT_FOUND.defaultMessage())); + continue; + } + if (!"ENABLED".equals(connection.getStatus())) { + result.put(entry.getKey(), new ObjectResolution( + historical, null, "CONNECTION_DISABLED", + DataspaceErrorCode.CONNECTION_DISABLED.code(), + DataspaceErrorCode.CONNECTION_DISABLED.defaultMessage())); + continue; + } + DataspaceObject current = currentByConnection + .getOrDefault(connection.getId(), Map.of()) + .get(identity(historical, connection)); + result.put(entry.getKey(), current == null + ? new ObjectResolution( + historical, null, "MISSING", + DataspaceErrorCode.OBJECT_MISSING.code(), + "表或视图 “" + historical.getObjectName() + + "” 已不在最新元数据中,请刷新或移除该表") + : new ObjectResolution(historical, current, "ACTIVE", null, null)); + } + return Map.copyOf(result); + } + + /** + * 获取连接当前对应的元数据快照 revision。 + * + *

首期元数据快照随 Federation Definition 一同推进;解析逻辑集中在此处, + * 后续拆分独立 metadata revision 时无需修改调用方。

+ * + * @param connection 物理连接 + * @return 当前元数据 revision,缺失时返回 0 + */ + public long currentMetadataRevision(DataspaceConnection connection) { + return connection == null || connection.getDefinitionRevision() == null + ? 0L : connection.getDefinitionRevision(); + } + + /** + * 判断对象字段是否存在。 + * + * @param object 元数据对象 + * @param columnName 字段名 + * @param databaseType 数据库类型 + * @return 是否存在 + */ + public boolean hasColumn( + DataspaceObject object, + String columnName, + String databaseType) { + if (object == null || columnName == null || columnName.isBlank()) { + return false; + } + return columns(object).stream() + .map(column -> String.valueOf(column.get("name"))) + .anyMatch(name -> sameIdentifier(name, columnName.trim(), databaseType)); + } + + /** + * 返回指定字段的元数据。 + * + * @param object 元数据对象 + * @param columnName 字段名 + * @param databaseType 数据库类型 + * @return 字段元数据,不存在时返回空映射 + */ + public Map column( + DataspaceObject object, + String columnName, + String databaseType) { + if (object == null || columnName == null || columnName.isBlank()) { + return Map.of(); + } + return columns(object).stream() + .filter(column -> sameIdentifier( + String.valueOf(column.get("name")), + columnName.trim(), + databaseType)) + .findFirst() + .map(column -> java.util.Collections.unmodifiableMap( + new LinkedHashMap<>(column))) + .orElseGet(Map::of); + } + + /** + * 安全读取对象字段,兼容历史异常数据中的空字段集合。 + * + * @param object 元数据对象 + * @return 非空字段集合 + */ + private List> columns(DataspaceObject object) { + return object.getColumnsJson() == null ? List.of() : object.getColumnsJson(); + } + + /** + * 按数据库标识符规则比较字段名。 + * + * @param left 元数据字段名 + * @param right 保存的字段名 + * @param databaseType 数据库类型 + * @return 是否指向同一字段 + */ + private boolean sameIdentifier(String left, String right, String databaseType) { + return "POSTGRESQL".equalsIgnoreCase(databaseType) + ? left.equals(right) + : left.equalsIgnoreCase(right); + } + + /** + * 判断两个字段的 JDBC 类型是否可用于等值关联。 + * + * @param left 左字段元数据 + * @param right 右字段元数据 + * @return 是否可比较 + */ + public boolean comparable(Map left, Map right) { + if (left.isEmpty() || right.isEmpty()) { + return false; + } + String leftFamily = typeFamily(left); + String rightFamily = typeFamily(right); + return leftFamily.equals(rightFamily); + } + + /** + * 构造稳定物理身份。 + * + * @param object 元数据对象 + * @param connection 物理连接 + * @return 物理身份 + */ + private PhysicalIdentity identity( + DataspaceObject object, + DataspaceConnection connection) { + boolean caseSensitive = "POSTGRESQL".equalsIgnoreCase(connection.getDatabaseType()); + return new PhysicalIdentity( + normalize(object.getCatalogName(), caseSensitive), + normalize(object.getSchemaName(), caseSensitive), + normalize(object.getObjectName(), caseSensitive), + normalize(object.getObjectType(), false)); + } + + /** + * 规范化物理身份片段。 + * + * @param value 原始值 + * @param caseSensitive 是否保留大小写 + * @return 大小写无关的稳定值 + */ + private String normalize(String value, boolean caseSensitive) { + String normalized = value == null ? "" : value.trim(); + return caseSensitive ? normalized : normalized.toUpperCase(Locale.ROOT); + } + + /** + * 将 JDBC 类型归一化为关联比较族。 + * + * @param column 字段元数据 + * @return 类型族 + */ + private String typeFamily(Map column) { + int jdbcType; + try { + jdbcType = Integer.parseInt(String.valueOf(column.get("jdbcType"))); + } catch (NumberFormatException exception) { + return "OTHER:" + String.valueOf(column.get("typeName")).toUpperCase(Locale.ROOT); + } + return switch (jdbcType) { + case java.sql.Types.TINYINT, java.sql.Types.SMALLINT, java.sql.Types.INTEGER, + java.sql.Types.BIGINT, java.sql.Types.FLOAT, java.sql.Types.REAL, + java.sql.Types.DOUBLE, java.sql.Types.NUMERIC, java.sql.Types.DECIMAL -> "NUMERIC"; + case java.sql.Types.CHAR, java.sql.Types.VARCHAR, java.sql.Types.LONGVARCHAR, + java.sql.Types.NCHAR, java.sql.Types.NVARCHAR, java.sql.Types.LONGNVARCHAR -> "TEXT"; + case java.sql.Types.DATE, java.sql.Types.TIME, java.sql.Types.TIME_WITH_TIMEZONE, + java.sql.Types.TIMESTAMP, java.sql.Types.TIMESTAMP_WITH_TIMEZONE -> "TEMPORAL"; + case java.sql.Types.BOOLEAN, java.sql.Types.BIT -> "BOOLEAN"; + case java.sql.Types.BINARY, java.sql.Types.VARBINARY, java.sql.Types.LONGVARBINARY -> "BINARY"; + default -> "OTHER:" + String.valueOf(column.get("typeName")).toUpperCase(Locale.ROOT); + }; + } + + /** + * 历史对象到当前元数据对象的解析结果。 + * + * @param historical 历史对象 + * @param current 当前对象;不可用时为空 + * @param status ACTIVE、MISSING 或 CONNECTION_DISABLED + * @param issueCode 稳定问题码 + * @param issueMessage 用户可读提示 + */ + public record ObjectResolution( + DataspaceObject historical, + DataspaceObject current, + String status, + Integer issueCode, + String issueMessage) { + + /** + * 返回当前对象;不可用时返回历史快照供页面定位与修复。 + * + * @return 可展示对象 + */ + public DataspaceObject displayObject() { + return current == null ? historical : current; + } + + /** + * 判断对象是否可用。 + * + * @return 是否为 ACTIVE + */ + public boolean active() { + return "ACTIVE".equals(status); + } + } + + /** + * 数据库对象稳定物理身份。 + * + * @param catalog Catalog + * @param schema Schema + * @param name 对象名 + * @param type 对象类型 + */ + private record PhysicalIdentity(String catalog, String schema, String name, String type) { + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceQueryResultBudget.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceQueryResultBudget.java new file mode 100644 index 00000000..e0e53862 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceQueryResultBudget.java @@ -0,0 +1,161 @@ +package tech.easyflow.dataspace.service; + +import tech.easyflow.dataspace.model.DataspaceErrorCode; + +/** + * 数据空间查询结果的单字段与序列化数据量预算。 + */ +final class DataspaceQueryResultBudget { + + private final long maximumResultBytes; + private final long maximumCellBytes; + private long reservedBytes = 2L; + private int rows; + + /** + * 创建结果预算。 + * + * @param maximumResultBytes 结果行数据的最大 JSON 字节数 + * @param maximumCellBytes 单个文本或二进制字段的最大原始字节数 + * @throws IllegalArgumentException 上限不为正时抛出 + */ + DataspaceQueryResultBudget(long maximumResultBytes, long maximumCellBytes) { + if (maximumResultBytes <= 0L || maximumCellBytes <= 0L) { + throw new IllegalArgumentException("查询结果预算必须为正数"); + } + this.maximumResultBytes = maximumResultBytes; + this.maximumCellBytes = maximumCellBytes; + } + + /** + * 为一行结果预留数组和分隔符开销。 + * + * @param columnCount 当前行列数 + * @throws IllegalArgumentException 列数为负数时抛出 + */ + void beginRow(int columnCount) { + if (columnCount < 0) { + throw new IllegalArgumentException("列数不能为负数"); + } + reserve(2L + Math.max(0, columnCount - 1) + (rows == 0 ? 0L : 1L)); + rows++; + } + + /** + * 校验单个文本字段的 UTF-8 字节数。 + * + * @param value 文本值 + * @return 原文本值 + * @throws tech.easyflow.common.web.exceptions.BusinessException 超过单字段上限时抛出 + */ + String validateText(String value) { + if (utf8Bytes(value) > maximumCellBytes) { + throw DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.exception( + "单个文本字段超过 1 MiB 限制"); + } + return value; + } + + /** + * 校验单个二进制字段的原始字节数。 + * + * @param value 二进制值 + * @return 原二进制值 + * @throws tech.easyflow.common.web.exceptions.BusinessException 超过单字段上限时抛出 + */ + byte[] validateBinary(byte[] value) { + if (value.length > maximumCellBytes) { + throw DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.exception( + "单个二进制字段超过 1 MiB 限制"); + } + return value; + } + + /** + * 将一个已规范化单元格计入结果序列化预算。 + * + * @param value JSON 安全的单元格值 + * @throws tech.easyflow.common.web.exceptions.BusinessException 超过结果上限时抛出 + */ + void reserveCell(Object value) { + long bytes; + if (value == null) { + bytes = 4L; + } else if (value instanceof String text) { + bytes = jsonStringBytes(text); + } else { + bytes = utf8Bytes(String.valueOf(value)); + } + reserve(bytes); + } + + /** + * 原子检查并增加已使用预算。 + * + * @param bytes 新增字节数 + * @throws tech.easyflow.common.web.exceptions.BusinessException 超过结果上限时抛出 + */ + private void reserve(long bytes) { + if (bytes < 0L || bytes > maximumResultBytes - reservedBytes) { + throw DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.exception( + "查询结果数据超过 16 MiB 限制,请减少返回行数或字段"); + } + reservedBytes += bytes; + } + + /** + * 计算字符串的 UTF-8 字节数,避免为预算检查额外复制大字符串。 + * + * @param value 字符串 + * @return UTF-8 字节数 + */ + private static long utf8Bytes(String value) { + long bytes = 0L; + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + if (current <= 0x7F) { + bytes++; + } else if (current <= 0x7FF) { + bytes += 2L; + } else if (Character.isHighSurrogate(current) + && index + 1 < value.length() + && Character.isLowSurrogate(value.charAt(index + 1))) { + bytes += 4L; + index++; + } else { + bytes += 3L; + } + } + return bytes; + } + + /** + * 计算 JSON 字符串编码后的字节数,包括引号与转义字符。 + * + * @param value 字符串 + * @return JSON 字符串字节数 + */ + private static long jsonStringBytes(String value) { + long bytes = 2L; + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + if (current == '"' || current == '\\') { + bytes += 2L; + } else if (current <= 0x1F) { + bytes += 6L; + } else if (current <= 0x7F) { + bytes++; + } else if (current <= 0x7FF) { + bytes += 2L; + } else if (Character.isHighSurrogate(current) + && index + 1 < value.length() + && Character.isLowSurrogate(value.charAt(index + 1))) { + bytes += 4L; + index++; + } else { + bytes += 3L; + } + } + return bytes; + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceQueryService.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceQueryService.java new file mode 100644 index 00000000..129d5db8 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceQueryService.java @@ -0,0 +1,701 @@ +package tech.easyflow.dataspace.service; + +import com.easyagents.federation.sql.api.FederationSqlException; +import com.easyagents.federation.sql.api.SqlCompletionRequest; +import com.easyagents.federation.sql.api.SqlCompletionResult; +import com.easyagents.federation.sql.api.SqlQueryCommand; +import com.easyagents.federation.sql.compile.FederationFragmentExplain; +import com.easyagents.federation.sql.compile.SqlCompileRequest; +import com.easyagents.federation.sql.compile.SqlExplainLevel; +import com.easyagents.federation.sql.compile.SqlExplainRequest; +import com.easyagents.federation.sql.compile.SqlExplainResult; +import com.easyagents.federation.sql.execute.FederationColumn; +import com.easyagents.federation.sql.execute.FederationPhysicalExplain; +import com.easyagents.federation.sql.execute.FederationQueryMetricsSnapshot; +import com.easyagents.federation.sql.execute.FederationResultCursor; +import com.easyagents.federation.sql.execute.QueryId; +import com.easyagents.federation.sql.execute.SqlExecutionOptions; +import com.easyagents.federation.sql.federation.FederationExecutionPolicy; +import com.easyagents.federation.sql.federation.FederationLogicalTableDefinition; +import com.easyagents.federation.sql.federation.FederationQueryScopeDefinition; +import com.easyagents.federation.sql.federation.FederationSourceBindingDefinition; +import com.easyagents.federation.sql.source.FederationSourceDefinition; +import com.easyagents.federation.sql.source.SourceId; +import java.io.InputStream; +import java.io.Reader; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.SQLXML; +import java.time.Duration; +import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Date; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.dataspace.entity.DataspaceConnection; +import tech.easyflow.dataspace.entity.DataspaceObject; +import tech.easyflow.dataspace.entity.DataspaceQueryAudit; +import tech.easyflow.dataspace.entity.DataspaceTableBinding; +import tech.easyflow.dataspace.federation.DataspaceDefinitionFactory; +import tech.easyflow.dataspace.federation.DataspaceFederationRuntime; +import tech.easyflow.dataspace.mapper.DataspaceQueryAuditMapper; +import tech.easyflow.dataspace.model.DataspaceExplainResult; +import tech.easyflow.dataspace.model.DataspaceErrorCode; +import tech.easyflow.dataspace.model.DataspaceQueryRequest; +import tech.easyflow.dataspace.model.DataspaceQueryResult; +import tech.easyflow.dataspace.model.DataspaceSqlCompletionRequest; +import tech.easyflow.dataspace.model.DataspaceSqlCompletionResult; +import tech.easyflow.dataspace.security.DataspaceActor; + +/** + * 数据空间单源与联邦只读 Query、Explain、Complete 和 Cancel 服务。 + */ +@Service +public class DataspaceQueryService { + + private static final Logger LOG = LoggerFactory.getLogger(DataspaceQueryService.class); + private static final int DEFAULT_MAX_ROWS = 500; + private static final int MAX_ROWS = 2_000; + private static final int DEFAULT_TIMEOUT_SECONDS = 30; + private static final int MAX_TIMEOUT_SECONDS = 60; + private static final long MAX_RESULT_BYTES = 16L * 1024L * 1024L; + private static final long MAX_CELL_BYTES = 1024L * 1024L; + private final DataspaceService dataspaceService; + private final DataspaceFederationRuntime runtime; + private final DataspaceDefinitionFactory definitionFactory; + private final DataspaceQueryAuditMapper auditMapper; + private final ConcurrentMap activeQueries = new ConcurrentHashMap<>(); + + /** + * 创建查询服务。 + * + * @param dataspaceService 数据空间服务 + * @param runtime Federation Runtime + * @param definitionFactory Definition 工厂 + * @param auditMapper 查询审计 Mapper + */ + public DataspaceQueryService( + DataspaceService dataspaceService, + DataspaceFederationRuntime runtime, + DataspaceDefinitionFactory definitionFactory, + DataspaceQueryAuditMapper auditMapper) { + this.dataspaceService = dataspaceService; + this.runtime = runtime; + this.definitionFactory = definitionFactory; + this.auditMapper = auditMapper; + } + + /** + * 执行一条 Calcite 只读 SQL,并返回数据和常用消耗指标。 + * + * @param request 查询请求 + * @return 查询结果 + */ + public DataspaceQueryResult query(DataspaceQueryRequest request) { + validateRequest(request); + DataspaceService.RuntimeSnapshot snapshot = dataspaceService.loadSnapshot(request.dataspaceId()); + String executableSql = normalizeSql(request.sql()); + FederationQueryScopeDefinition scope = scopeSafely(snapshot, false); + int maxRows = normalize(request.maxRows(), DEFAULT_MAX_ROWS, MAX_ROWS); + int timeout = normalize(request.timeoutSeconds(), DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS); + QueryId queryId = request.queryId() == null || request.queryId().isBlank() + ? QueryId.create() : new QueryId(request.queryId().trim()); + LoginAccount account = DataspaceActor.current(); + ActiveQueryOwner owner = new ActiveQueryOwner(account.getTenantId(), account.getId()); + if (activeQueries.putIfAbsent(queryId.value(), owner) != null) { + throw DataspaceErrorCode.QUERY_ID_CONFLICT.exception(); + } + SqlQueryCommand command = new SqlQueryCommand( + queryId, executableSql, scope, List.of(), + new SqlExecutionOptions(500, maxRows, timeout, true), + Duration.ofSeconds(5).toMillis(), policyVersion(snapshot)); + FederationQueryMetricsSnapshot metrics = null; + String failureCode = null; + String failureMessage = null; + try (FederationResultCursor cursor = runtime.engine().query(command)) { + List columns = cursor.columns().stream() + .map(this::columnView).toList(); + List> rows = new ArrayList<>(); + DataspaceQueryResultBudget resultBudget = new DataspaceQueryResultBudget( + MAX_RESULT_BYTES, MAX_CELL_BYTES); + while (cursor.next()) { + resultBudget.beginRow(columns.size()); + List row = new ArrayList<>(columns.size()); + for (int index = 1; index <= columns.size(); index++) { + Object value = normalizeCell(cursor.getObject(index), resultBudget); + resultBudget.reserveCell(value); + row.add(value); + } + // JDBC 结果允许 NULL;List.copyOf 会对合法的空值抛出 NullPointerException。 + rows.add(java.util.Collections.unmodifiableList(row)); + } + metrics = cursor.metrics(); + return new DataspaceQueryResult(queryId.value(), columns, rows, metricsView(metrics)); + } catch (FederationSqlException exception) { + failureCode = exception.errorCode().name(); + failureMessage = exception.getMessage(); + throw DataspaceErrorCode.fromFederation(exception, false); + } catch (BusinessException exception) { + failureCode = String.valueOf(exception.getErrorCode()); + failureMessage = exception.getMessage(); + throw exception; + } catch (RuntimeException exception) { + failureCode = "UNEXPECTED"; + failureMessage = exception.getMessage(); + LOG.error("数据空间查询异常,dataspaceId={}, queryId={}", + request.dataspaceId(), queryId.value(), exception); + throw exception; + } finally { + activeQueries.remove(queryId.value(), owner); + writeAudit(snapshot, request.sql(), queryId, metrics, null, null, + failureCode, failureMessage); + } + } + + /** + * 显式执行逻辑与数据库物理 Explain,不执行 ANALYZE。 + * + * @param request Explain 请求 + * @return Explain 结果 + */ + public DataspaceExplainResult explain(DataspaceQueryRequest request) { + validateRequest(request); + DataspaceService.RuntimeSnapshot snapshot = dataspaceService.loadSnapshot(request.dataspaceId()); + String executableSql = normalizeSql(request.sql()); + FederationQueryScopeDefinition scope = scopeSafely(snapshot, true); + QueryId queryId = request.queryId() == null || request.queryId().isBlank() + ? QueryId.create() : new QueryId(request.queryId().trim()); + long startedNanos = System.nanoTime(); + String queryMode = null; + String failureCode = null; + String failureMessage = null; + try { + SqlExplainResult explain = runtime.engine().explain(new SqlExplainRequest( + new SqlCompileRequest(executableSql, scope, List.of(), policyVersion(snapshot)), + SqlExplainLevel.PHYSICAL)); + queryMode = explain.queryMode().name(); + return explainView(explain); + } catch (FederationSqlException exception) { + failureCode = exception.errorCode().name(); + failureMessage = exception.getMessage(); + throw DataspaceErrorCode.fromFederation(exception, true); + } catch (RuntimeException exception) { + failureCode = "UNEXPECTED"; + failureMessage = exception.getMessage(); + LOG.error("数据空间 Explain 异常,dataspaceId={}, queryId={}", + request.dataspaceId(), queryId.value(), exception); + throw exception; + } finally { + writeAudit(snapshot, request.sql(), queryId, null, queryMode, + Duration.ofNanos(System.nanoTime() - startedNanos).toMillis(), + failureCode, failureMessage); + } + } + + /** + * 根据当前数据空间逻辑目录返回 Calcite SQL 补全候选。 + * + * @param request 补全请求 + * @return 补全替换区间与候选 + */ + public DataspaceSqlCompletionResult complete(DataspaceSqlCompletionRequest request) { + validateCompletionRequest(request); + DataspaceService.RuntimeSnapshot snapshot = + dataspaceService.loadSnapshot(request.dataspaceId()); + FederationQueryScopeDefinition scope = scopeSafely(snapshot, false); + try { + SqlCompletionResult result = runtime.engine().complete(new SqlCompletionRequest( + scope, request.sql(), request.cursorOffset())); + return new DataspaceSqlCompletionResult( + result.replaceStart(), + result.replaceEnd(), + result.items().stream() + .map(item -> new DataspaceSqlCompletionResult.ItemView( + item.label(), item.insertText(), item.kind().name(), + item.qualifiedName())) + .toList()); + } catch (FederationSqlException exception) { + throw DataspaceErrorCode.fromFederation(exception, false); + } catch (RuntimeException exception) { + LOG.error("数据空间 SQL 补全异常,dataspaceId={}", + request.dataspaceId(), exception); + throw exception; + } + } + + /** + * 尝试取消当前节点上的查询。 + * + * @param queryId 查询 ID + * @return 是否找到并发起取消 + */ + public boolean cancel(String queryId) { + if (queryId == null || queryId.isBlank()) { + throw DataspaceErrorCode.QUERY_REQUEST_INVALID.exception("查询 ID 不能为空"); + } + String normalized = queryId.trim(); + ActiveQueryOwner owner = activeQueries.get(normalized); + if (owner == null) { + return false; + } + LoginAccount account = DataspaceActor.current(); + if (!Objects.equals(owner.tenantId(), account.getTenantId()) + || !Objects.equals(owner.accountId(), account.getId())) { + throw DataspaceErrorCode.ACCESS_DENIED.exception("无权取消其他用户的查询"); + } + return runtime.engine().cancel(new QueryId(normalized)); + } + + /** + * 校验 SQL 补全请求。 + * + * @param request 补全请求 + * @throws BusinessException 请求参数不合法时抛出 + */ + private void validateCompletionRequest(DataspaceSqlCompletionRequest request) { + if (request == null || request.dataspaceId() == null || request.sql() == null + || request.cursorOffset() == null + || request.cursorOffset() < 0 + || request.cursorOffset() > request.sql().length()) { + throw DataspaceErrorCode.QUERY_REQUEST_INVALID.exception( + "SQL 补全参数不正确"); + } + } + + /** + * 将数据库权威 revision 转换为本次查询的联邦作用域。 + * + * @param snapshot 数据空间运行快照 + * @return Federation 查询作用域 + * @throws BusinessException 数据空间未配置表时抛出 + */ + private FederationQueryScopeDefinition scope(DataspaceService.RuntimeSnapshot snapshot) { + Map> byConnection = new LinkedHashMap<>(); + for (DataspaceTableBinding binding : activeBindings(snapshot)) { + byConnection.computeIfAbsent(binding.getConnectionId(), ignored -> new ArrayList<>()) + .add(binding); + } + if (byConnection.isEmpty()) { + DataspaceService.BindingHealth issue = snapshot.bindingHealth().values().stream() + .filter(health -> !health.active()) + .findFirst() + .orElse(null); + if (issue != null && "CONNECTION_DISABLED".equals(issue.status())) { + throw DataspaceErrorCode.CONNECTION_DISABLED.exception(issue.issueMessage()); + } + if (issue != null) { + throw DataspaceErrorCode.OBJECT_MISSING.exception(issue.issueMessage()); + } + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + "数据空间尚未配置可查询表"); + } + Map definitions = new LinkedHashMap<>(); + List logicalTables = new ArrayList<>(); + for (Map.Entry> entry : byConnection.entrySet()) { + DataspaceConnection connection = snapshot.connections().get(entry.getKey()); + FederationSourceDefinition source = runtime.ensureSource(connection); + String sourceAlias = entry.getValue().get(0).getSourceAlias(); + Map schemaMappings = new LinkedHashMap<>(); + for (DataspaceTableBinding binding : entry.getValue()) { + schemaMappings.put(binding.getSchemaAlias(), binding.getSchemaAlias()); + } + definitions.put(sourceAlias, FederationSourceBindingDefinition.of( + source.sourceId(), source.revision(), schemaMappings)); + for (DataspaceTableBinding binding : entry.getValue()) { + DataspaceObject object = snapshot.objects().get(binding.getObjectId()); + if (object != null) { + logicalTables.add(FederationLogicalTableDefinition.of( + binding.getTableAlias(), sourceAlias, + binding.getSchemaAlias(), object.getObjectName())); + } + } + } + String defaultBinding = definitions.keySet().iterator().next(); + return FederationQueryScopeDefinition.virtual( + scopeId(snapshot), snapshot.revision().getRevisionNo(), definitions, + defaultBinding, logicalTables, FederationExecutionPolicy.basic()); + } + + /** + * 返回当前元数据快照中仍可查询的表绑定。 + * + * @param snapshot 数据空间运行快照 + * @return 活跃表绑定 + */ + private List activeBindings( + DataspaceService.RuntimeSnapshot snapshot) { + return snapshot.bindings().stream() + .filter(binding -> { + DataspaceService.BindingHealth health = + snapshot.bindingHealth().get(binding.getId()); + return health != null && health.active(); + }) + .toList(); + } + + /** + * 装配查询作用域并统一映射可能发生的 Federation 错误。 + * + * @param snapshot 数据空间运行快照 + * @param explain 是否用于 Explain + * @return Federation 查询作用域 + */ + private FederationQueryScopeDefinition scopeSafely( + DataspaceService.RuntimeSnapshot snapshot, + boolean explain) { + try { + return scope(snapshot); + } catch (FederationSqlException exception) { + throw DataspaceErrorCode.fromFederation(exception, explain); + } catch (IllegalArgumentException exception) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + "数据空间逻辑表配置无效:" + exception.getMessage(), exception); + } + } + + /** + * 转换完整的 Federation Explain 信息。 + * + * @param explain Federation Explain 结果 + * @return API Explain 视图 + */ + static DataspaceExplainResult explainView(SqlExplainResult explain) { + return new DataspaceExplainResult( + explain.queryMode().name(), explain.normalizedSql(), explain.executionPlan(), + explain.executable(), explain.diagnostic(), + explain.statisticsStatus().name(), explain.estimateAvailable(), + explain.estimatedTransferBytes(), + explain.estimatedLocalMemoryBytes(), + explain.joinOptimizations().stream() + .map(join -> new DataspaceExplainResult.JoinView( + join.stageIndex(), join.leftBindings(), join.rightBindings(), + join.leftBinding(), join.rightBinding(), join.buildBinding(), + join.algorithm().name(), join.reason().name(), + join.estimatedBuildBytes())) + .toList(), + explain.fragments().stream().map(DataspaceQueryService::fragmentView).toList()); + } + + /** + * 转换单个物理分片的 Explain 信息。 + * + * @param fragment Federation 分片 Explain + * @return API 分片视图 + */ + static DataspaceExplainResult.FragmentView fragmentView(FederationFragmentExplain fragment) { + FederationPhysicalExplain physical = fragment.physicalExplain(); + var cost = fragment.costEstimate(); + return new DataspaceExplainResult.FragmentView( + fragment.bindingName(), fragment.executableSql(), + physical == null ? null : physical.scanType(), + physical == null ? List.of() : physical.candidateIndexes(), + physical == null ? null : physical.chosenIndex(), + physical == null ? null : physical.estimatedRows(), + cost.estimateAvailable(), cost.estimatedRows(), cost.estimatedRowWidthBytes(), + cost.estimatedTransferBytes(), cost.statisticsStatus().name(), + cost.statisticsSource(), + java.time.Instant.EPOCH.equals(cost.statisticsCollectedAt()) + ? null : cost.statisticsCollectedAt().toString(), + fragment.pushedDownOperators(), + physical == null ? "" : physical.nativePlan(), + physical == null ? "" : physical.diagnostic()); + } + + /** + * 转换查询结果列元数据。 + * + * @param column Federation 结果列 + * @return API 列视图 + */ + private DataspaceQueryResult.ColumnView columnView(FederationColumn column) { + return new DataspaceQueryResult.ColumnView( + column.label(), column.jdbcType(), column.typeName(), column.nullable()); + } + + /** + * 将底层纳秒指标转换为面向调用方的毫秒指标。 + * + * @param metrics Federation 查询指标 + * @return API 查询指标 + */ + private DataspaceQueryResult.QueryMetricsView metricsView(FederationQueryMetricsSnapshot metrics) { + return new DataspaceQueryResult.QueryMetricsView( + metrics.queryMode().name(), metrics.planCacheHit(), millis(metrics.planningNanos()), + millis(metrics.databaseExecutionNanos()), millis(metrics.localExecutionNanos()), + millis(metrics.executionNanos()), millis(metrics.firstRowNanos()), + metrics.returnedRows(), metrics.intermediateRows(), metrics.truncated()); + } + + /** + * 将 JDBC 值转换为可稳定 JSON 序列化的结果值。 + * + * @param value JDBC 原始值 + * @param budget 查询结果预算 + * @return JSON 安全值 + */ + private Object normalizeCell(Object value, DataspaceQueryResultBudget budget) { + if (value == null || value instanceof Boolean || value instanceof Integer + || value instanceof Short || value instanceof Byte || value instanceof Float + || value instanceof Double) { + return value; + } + if (value instanceof String text) { + return budget.validateText(text); + } + if (value instanceof BigInteger bigInteger) { + return budget.validateText(bigInteger.toString()); + } + if (value instanceof Long longValue) { + return Math.abs(longValue) > 9_007_199_254_740_991L + ? budget.validateText(longValue.toString()) : longValue; + } + if (value instanceof BigDecimal decimal) { + return budget.validateText(decimal.toPlainString()); + } + if (value instanceof byte[] bytes) { + return Base64.getEncoder().encodeToString(budget.validateBinary(bytes)); + } + if (value instanceof java.sql.Date || value instanceof java.sql.Time + || value instanceof java.sql.Timestamp || value instanceof TemporalAccessor) { + return budget.validateText(value.toString()); + } + try { + if (value instanceof Clob clob) { + return budget.validateText(readText(clob.getCharacterStream())); + } + if (value instanceof Blob blob) { + byte[] bytes = budget.validateBinary(readBytes(blob.getBinaryStream())); + return Base64.getEncoder().encodeToString(bytes); + } + if (value instanceof SQLXML xml) { + return budget.validateText(readText(xml.getCharacterStream())); + } + } catch (BusinessException exception) { + throw exception; + } catch (Exception exception) { + throw new IllegalStateException("读取查询结果大字段失败", exception); + } + return budget.validateText(String.valueOf(value)); + } + + /** + * 读取受大小限制的字符流。 + * + * @param reader 字符流 + * @return 文本内容 + * @throws Exception 读取失败或超过大小限制时抛出 + */ + private String readText(Reader reader) throws Exception { + try (reader) { + char[] buffer = new char[8_192]; + StringBuilder value = new StringBuilder(); + int count; + while ((count = reader.read(buffer)) >= 0) { + value.append(buffer, 0, count); + if (value.length() > MAX_CELL_BYTES) { + throw DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.exception( + "单个文本字段超过 1 MiB 限制"); + } + } + return value.toString(); + } + } + + /** + * 读取受大小限制的二进制流。 + * + * @param stream 二进制流 + * @return 字节内容 + * @throws Exception 读取失败或超过大小限制时抛出 + */ + private byte[] readBytes(InputStream stream) throws Exception { + try (stream) { + byte[] bytes = stream.readNBytes((int) MAX_CELL_BYTES + 1); + if (bytes.length > MAX_CELL_BYTES) { + throw DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.exception( + "单个二进制字段超过 1 MiB 限制"); + } + return bytes; + } + } + + /** + * 持久化查询审计;审计失败会记录完整异常,避免覆盖原始查询异常。 + * + * @param snapshot 数据空间运行快照 + * @param sql 原始 SQL + * @param queryId 查询 ID + * @param metrics 成功执行后的指标 + * @param queryMode 无查询指标时的查询模式 + * @param durationMillis 无查询指标时的总耗时毫秒 + * @param failureCode 失败代码 + * @param failureMessage 失败信息 + */ + private void writeAudit( + DataspaceService.RuntimeSnapshot snapshot, + String sql, + QueryId queryId, + FederationQueryMetricsSnapshot metrics, + String queryMode, + Long durationMillis, + String failureCode, + String failureMessage) { + try { + LoginAccount account = DataspaceActor.current(); + DataspaceQueryAudit audit = new DataspaceQueryAudit(); + audit.setTenantId(account.getTenantId()); + audit.setDataspaceId(snapshot.dataspace().getId()); + audit.setRevisionNo(snapshot.revision().getRevisionNo()); + audit.setQueryId(queryId.value()); + audit.setQueryMode(metrics == null ? queryMode : metrics.queryMode().name()); + audit.setSqlDigest(sha256(sql)); + audit.setSqlText(sql.length() > 16_000 ? sql.substring(0, 16_000) : sql); + audit.setStatus(failureCode == null ? "SUCCESS" : "FAILED"); + audit.setReturnedRows(metrics == null ? 0L : metrics.returnedRows()); + audit.setIntermediateRows(metrics == null ? 0L : metrics.intermediateRows()); + audit.setDurationMillis(metrics == null + ? durationMillis == null ? 0L : durationMillis + : millis(metrics.executionNanos())); + audit.setErrorCode(failureCode); + audit.setErrorMessage(truncate(failureMessage, 500)); + Date now = new Date(); + audit.setCreated(now); + audit.setCreatedBy(account.getId()); + audit.setModified(now); + audit.setModifiedBy(account.getId()); + audit.setDeleted(0); + auditMapper.insert(audit); + } catch (RuntimeException exception) { + LOG.error("写入数据空间查询审计失败,queryId={}", queryId.value(), exception); + } + } + + /** + * 校验查询必填参数。 + * + * @param request 查询请求 + * @throws BusinessException 数据空间或 SQL 为空时抛出 + */ + private void validateRequest(DataspaceQueryRequest request) { + if (request == null || request.dataspaceId() == null + || request.sql() == null || request.sql().isBlank()) { + throw DataspaceErrorCode.QUERY_REQUEST_INVALID.exception( + "数据空间和 SQL 不能为空"); + } + } + + /** + * 去除工作台常见的单个结尾分号,保持 Calcite 单语句解析兼容性。 + * + * @param sql 调用方原始 SQL + * @return 可交给 Calcite 编译的 SQL + */ + private String normalizeSql(String sql) { + String normalized = sql.trim(); + if (normalized.endsWith(";")) { + return normalized.substring(0, normalized.length() - 1).stripTrailing(); + } + return normalized; + } + + /** + * 生成包含权威 revision 的作用域 ID。 + * + * @param snapshot 数据空间运行快照 + * @return 作用域 ID + */ + private String scopeId(DataspaceService.RuntimeSnapshot snapshot) { + return "dataspace:" + snapshot.dataspace().getId() + ":" + snapshot.revision().getRevisionNo(); + } + + /** + * 生成随数据空间 revision 变化的策略版本。 + * + * @param snapshot 数据空间运行快照 + * @return 策略版本 + */ + private String policyVersion(DataspaceService.RuntimeSnapshot snapshot) { + return "dataspace-" + snapshot.dataspace().getId() + "-r" + snapshot.revision().getRevisionNo(); + } + + /** + * 规范化正整数限制并应用服务端上限。 + * + * @param value 调用方值 + * @param defaultValue 默认值 + * @param maximum 服务端上限 + * @return 规范化结果 + * @throws BusinessException 值不为正数时抛出 + */ + private int normalize(Integer value, int defaultValue, int maximum) { + int normalized = value == null ? defaultValue : value; + if (normalized <= 0) { + throw DataspaceErrorCode.QUERY_REQUEST_INVALID.exception("查询限制必须大于零"); + } + return Math.min(normalized, maximum); + } + + /** + * 将纳秒转换为毫秒并保留未知值。 + * + * @param nanos 纳秒值 + * @return 毫秒值 + */ + private long millis(long nanos) { + return nanos < 0 ? -1L : Duration.ofNanos(nanos).toMillis(); + } + + /** + * 计算 SQL 文本摘要。 + * + * @param value SQL 文本 + * @return SHA-256 十六进制摘要 + */ + private String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 不可用", exception); + } + } + + /** + * 截断可空文本。 + * + * @param value 原始文本 + * @param maximum 最大长度 + * @return 截断后的文本 + */ + private String truncate(String value, int maximum) { + if (value == null || value.length() <= maximum) { + return value; + } + return value.substring(0, maximum); + } + + /** + * 当前节点在途查询的租户与调用人归属。 + * + * @param tenantId 租户 ID + * @param accountId 调用账号 ID + */ + private record ActiveQueryOwner(BigInteger tenantId, BigInteger accountId) { + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceService.java b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceService.java new file mode 100644 index 00000000..672bcf2f --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/main/java/tech/easyflow/dataspace/service/DataspaceService.java @@ -0,0 +1,911 @@ +package tech.easyflow.dataspace.service; + +import com.mybatisflex.core.query.QueryWrapper; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import tech.easyflow.common.cache.RedisLockExecutor; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.util.SearchKeywordUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.dataspace.entity.Dataspace; +import tech.easyflow.dataspace.entity.DataspaceConnection; +import tech.easyflow.dataspace.entity.DataspaceObject; +import tech.easyflow.dataspace.entity.DataspaceRelation; +import tech.easyflow.dataspace.entity.DataspaceRevision; +import tech.easyflow.dataspace.entity.DataspaceTableBinding; +import tech.easyflow.dataspace.mapper.DataspaceConnectionMapper; +import tech.easyflow.dataspace.mapper.DataspaceMapper; +import tech.easyflow.dataspace.mapper.DataspaceObjectMapper; +import tech.easyflow.dataspace.mapper.DataspaceRelationMapper; +import tech.easyflow.dataspace.mapper.DataspaceRevisionMapper; +import tech.easyflow.dataspace.mapper.DataspaceTableBindingMapper; +import tech.easyflow.dataspace.model.DataspaceDefinition; +import tech.easyflow.dataspace.model.DataspaceErrorCode; +import tech.easyflow.dataspace.model.DataspaceSummary; +import tech.easyflow.dataspace.model.DataspaceView; +import tech.easyflow.dataspace.provider.DataspaceDatabaseProviderRegistry; +import tech.easyflow.dataspace.provider.DataspaceDatabaseType; +import tech.easyflow.dataspace.security.DataspaceActor; + +/** + * 数据空间版本化建模和一致性读取服务。 + */ +@Service +public class DataspaceService { + + private static final Duration LOCK_WAIT = Duration.ofSeconds(3); + private static final Duration LOCK_LEASE = Duration.ofSeconds(30); + private static final int MAX_TABLES = 100; + private static final int MAX_SOURCES = 2; + private final DataspaceMapper dataspaceMapper; + private final DataspaceRevisionMapper revisionMapper; + private final DataspaceTableBindingMapper bindingMapper; + private final DataspaceRelationMapper relationMapper; + private final DataspaceObjectMapper objectMapper; + private final DataspaceConnectionMapper connectionMapper; + private final DataspaceDatabaseProviderRegistry providerRegistry; + private final DataspaceMetadataResolver metadataResolver; + private final RedisLockExecutor lockExecutor; + private final TransactionTemplate transactionTemplate; + + /** + * 创建数据空间服务。 + * + * @param dataspaceMapper 数据空间 Mapper + * @param revisionMapper revision Mapper + * @param bindingMapper 表绑定 Mapper + * @param relationMapper 关系 Mapper + * @param objectMapper 元数据对象 Mapper + * @param connectionMapper 连接 Mapper + * @param providerRegistry Provider 注册表 + * @param metadataResolver 元数据兼容解析器 + * @param lockExecutor 分布式锁执行器 + * @param transactionTemplate 事务模板 + */ + public DataspaceService( + DataspaceMapper dataspaceMapper, + DataspaceRevisionMapper revisionMapper, + DataspaceTableBindingMapper bindingMapper, + DataspaceRelationMapper relationMapper, + DataspaceObjectMapper objectMapper, + DataspaceConnectionMapper connectionMapper, + DataspaceDatabaseProviderRegistry providerRegistry, + DataspaceMetadataResolver metadataResolver, + RedisLockExecutor lockExecutor, + TransactionTemplate transactionTemplate) { + this.dataspaceMapper = dataspaceMapper; + this.revisionMapper = revisionMapper; + this.bindingMapper = bindingMapper; + this.relationMapper = relationMapper; + this.objectMapper = objectMapper; + this.connectionMapper = connectionMapper; + this.providerRegistry = providerRegistry; + this.metadataResolver = metadataResolver; + this.lockExecutor = lockExecutor; + this.transactionTemplate = transactionTemplate; + } + + /** + * 查询当前租户数据空间列表。 + * + * @param keyword 名称或说明关键词 + * @return 列表摘要 + */ + public List list(String keyword) { + QueryWrapper query = QueryWrapper.create().orderBy("modified desc, id desc"); + if (keyword != null && !keyword.isBlank()) { + String pattern = SearchKeywordUtil.literalContainsPattern(keyword.trim()); + query.and("(name LIKE ? ESCAPE '\\\\' OR description LIKE ? ESCAPE '\\\\')", + pattern, pattern); + } + List spaces = dataspaceMapper.selectListByQuery(query); + if (spaces.isEmpty()) { + return List.of(); + } + List ids = spaces.stream().map(Dataspace::getId).toList(); + Map spacesById = new HashMap<>(); + spaces.forEach(space -> spacesById.put(space.getId(), space)); + Map currentRevisions = new HashMap<>(); + for (DataspaceRevision revision : revisionMapper.selectListByQuery( + QueryWrapper.create().in(DataspaceRevision::getDataspaceId, ids))) { + currentRevisions.putIfAbsent(revision.getDataspaceId(), revision); + Dataspace target = spacesById.get(revision.getDataspaceId()); + if (target != null && target.getCurrentRevision() != null + && target.getCurrentRevision().equals(revision.getRevisionNo())) { + currentRevisions.put(revision.getDataspaceId(), revision); + } + } + List revisionIds = currentRevisions.values().stream() + .map(DataspaceRevision::getId).toList(); + Map> bindingsByRevision = new HashMap<>(); + if (!revisionIds.isEmpty()) { + for (DataspaceTableBinding binding : bindingMapper.selectListByQuery( + QueryWrapper.create().in(DataspaceTableBinding::getRevisionId, revisionIds))) { + bindingsByRevision.computeIfAbsent(binding.getRevisionId(), ignored -> new ArrayList<>()) + .add(binding); + } + } + return spaces.stream().map(space -> { + DataspaceRevision revision = currentRevisions.get(space.getId()); + List bindings = revision == null + ? List.of() : bindingsByRevision.getOrDefault(revision.getId(), List.of()); + long sources = bindings.stream().map(DataspaceTableBinding::getConnectionId).distinct().count(); + return new DataspaceSummary( + space.getId(), space.getName(), space.getDescription(), + space.getCurrentRevision() == null ? 0L : space.getCurrentRevision(), + space.getStatus(), bindings.size(), sources, space.getModified()); + }).toList(); + } + + /** + * 获取当前 revision 完整详情。 + * + * @param id 数据空间 ID + * @return 数据空间详情 + */ + public DataspaceView detail(BigInteger id) { + RuntimeSnapshot snapshot = readSnapshot(id, false); + List tables = snapshot.bindings().stream().map(binding -> { + DataspaceObject object = snapshot.objects().get(binding.getObjectId()); + DataspaceConnection connection = snapshot.connections().get(binding.getConnectionId()); + BindingHealth health = snapshot.bindingHealth().get(binding.getId()); + return new DataspaceView.TableView( + binding.getId(), binding.getConnectionId(), connection.getName(), + connection.getDatabaseType(), object.getId(), + object.getCatalogName(), object.getSchemaName(), object.getObjectName(), + binding.getSourceAlias(), binding.getSchemaAlias(), binding.getTableAlias(), + binding.getPositionX(), binding.getPositionY(), object.getColumnsJson(), + health.status(), health.issueCode(), health.issueMessage()); + }).toList(); + List relations = snapshot.relations().stream() + .map(relation -> { + RelationHealth health = snapshot.relationHealth().get(relation.getId()); + return new DataspaceView.RelationView( + relation.getId(), relation.getLeftBindingId(), relation.getRightBindingId(), + relation.getJoinType(), relation.getLeftColumn(), relation.getRightColumn(), + health.status(), health.issueCode(), health.issueMessage()); + }) + .toList(); + Dataspace space = snapshot.dataspace(); + return new DataspaceView( + space.getId(), space.getName(), space.getDescription(), + snapshot.revision().getRevisionNo(), space.getStatus(), tables, relations); + } + + /** + * 创建或更新数据空间并生成新的不可变 revision。 + * + * @param definition 建模定义 + * @return 保存后的当前详情 + */ + public DataspaceView save(DataspaceDefinition definition) { + if (definition == null) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception("数据空间参数不能为空"); + } + LoginAccount account = DataspaceActor.current(); + String identity = definition.id() == null + ? "name:" + required(definition.name(), "数据空间名称").toLowerCase(Locale.ROOT) + : definition.id().toString(); + String lockKey = "easyflow:lock:dataspace:space:" + account.getTenantId() + ":" + identity; + BigInteger id = lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE, + () -> transactionTemplate.execute(status -> writeRevision(definition, account))); + return detail(id); + } + + /** + * 启用或禁用数据空间。新查询始终从数据库读取状态,状态提交后立即生效。 + * + * @param id 数据空间 ID + * @param enabled 是否启用 + */ + public void setEnabled(BigInteger id, boolean enabled) { + LoginAccount account = DataspaceActor.current(); + String lockKey = "easyflow:lock:dataspace:space:" + + account.getTenantId() + ":" + (id == null ? "missing" : id); + lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE, () -> + transactionTemplate.executeWithoutResult(status -> { + Dataspace space = requireDataspace(id); + String nextStatus = enabled ? "ENABLED" : "DISABLED"; + if (nextStatus.equals(space.getStatus())) { + return; + } + space.setStatus(nextStatus); + space.setModified(new Date()); + space.setModifiedBy(account.getId()); + dataspaceMapper.update(space); + })); + } + + /** + * 逻辑删除数据空间入口,历史 revision 仍保留用于审计。 + * + * @param id 数据空间 ID + */ + public void remove(BigInteger id) { + LoginAccount account = DataspaceActor.current(); + String lockKey = "easyflow:lock:dataspace:space:" + + account.getTenantId() + ":" + (id == null ? "missing" : id); + lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE, () -> + transactionTemplate.executeWithoutResult(status -> { + Dataspace space = requireDataspace(id); + dataspaceMapper.deleteById(space.getId()); + })); + } + + /** + * 在查询开始时从数据库读取权威 current revision 快照。 + * + * @param id 数据空间 ID + * @return 不可变运行快照 + */ + public RuntimeSnapshot loadSnapshot(BigInteger id) { + return readSnapshot(id, true); + } + + /** + * 从数据库装配指定数据空间的当前不可变 revision。 + * + * @param id 数据空间 ID + * @param requireQueryable 是否要求数据空间处于可查询状态 + * @return 当前 revision 快照 + */ + private RuntimeSnapshot readSnapshot(BigInteger id, boolean requireQueryable) { + Dataspace space = requireDataspace(id); + if ((requireQueryable && !"ENABLED".equals(space.getStatus())) + || space.getCurrentRevision() == null) { + throw DataspaceErrorCode.DATASPACE_DISABLED.exception(); + } + DataspaceRevision revision = revisionMapper.selectOneByQuery(QueryWrapper.create() + .eq(DataspaceRevision::getDataspaceId, id) + .eq(DataspaceRevision::getRevisionNo, space.getCurrentRevision())); + if (revision == null) { + throw new IllegalStateException("数据空间 current revision 记录缺失"); + } + List bindings = bindingMapper.selectListByQuery(QueryWrapper.create() + .eq(DataspaceTableBinding::getRevisionId, revision.getId()) + .orderBy("id asc")); + List relations = relationMapper.selectListByQuery(QueryWrapper.create() + .eq(DataspaceRelation::getRevisionId, revision.getId()) + .orderBy("id asc")); + Set objectIds = new LinkedHashSet<>(); + Set connectionIds = new LinkedHashSet<>(); + bindings.forEach(binding -> { + objectIds.add(binding.getObjectId()); + connectionIds.add(binding.getConnectionId()); + }); + Map objects = new LinkedHashMap<>(); + if (!objectIds.isEmpty()) { + objectMapper.selectListByQuery(QueryWrapper.create().in(DataspaceObject::getId, objectIds)) + .forEach(item -> objects.put(item.getId(), item)); + } + Map connections = new LinkedHashMap<>(); + if (!connectionIds.isEmpty()) { + connectionMapper.selectListByQuery(QueryWrapper.create().in(DataspaceConnection::getId, connectionIds)) + .forEach(item -> connections.put(item.getId(), item)); + } + if (objects.size() != objectIds.size() || connections.size() != connectionIds.size()) { + throw new IllegalStateException("数据空间 revision 引用记录不完整"); + } + Map resolutions = + metadataResolver.resolve(objects, connections); + Map resolvedObjects = new LinkedHashMap<>(); + Map bindingHealth = new LinkedHashMap<>(); + for (DataspaceTableBinding binding : bindings) { + DataspaceMetadataResolver.ObjectResolution resolution = + resolutions.get(binding.getObjectId()); + if (resolution == null) { + throw new IllegalStateException("数据空间元数据解析结果不完整"); + } + resolvedObjects.put(binding.getObjectId(), resolution.displayObject()); + bindingHealth.put(binding.getId(), new BindingHealth( + resolution.status(), resolution.issueCode(), resolution.issueMessage())); + } + Map relationHealth = relationHealth( + bindings, relations, resolvedObjects, connections, bindingHealth); + return new RuntimeSnapshot( + space, revision, bindings, relations, resolvedObjects, connections, + bindingHealth, relationHealth); + } + + /** + * 在同一事务中写入新 revision,并通过 CAS 切换当前 revision。 + * + * @param definition 数据空间定义 + * @param account 当前登录账号 + * @return 数据空间 ID + * @throws BusinessException revision 冲突或定义校验失败时抛出 + */ + private BigInteger writeRevision(DataspaceDefinition definition, LoginAccount account) { + Dataspace space; + long expected; + if (definition.id() == null) { + ensureNameUnique(definition.name(), null); + space = new Dataspace(); + space.setTenantId(account.getTenantId()); + space.setName(required(definition.name(), "数据空间名称")); + space.setDescription(trim(definition.description())); + space.setCurrentRevision(0L); + space.setStatus("ENABLED"); + fillCreate(space, account); + dataspaceMapper.insert(space); + expected = 0L; + } else { + space = requireDataspace(definition.id()); + expected = definition.expectedRevision() == null ? -1L : definition.expectedRevision(); + if (space.getCurrentRevision() == null || space.getCurrentRevision() != expected) { + throw DataspaceErrorCode.REVISION_CONFLICT.exception( + "数据空间已被其他节点更新,请刷新后重试"); + } + ensureNameUnique(definition.name(), space.getId()); + } + ValidatedDefinition validated = validateDefinition(definition); + long nextRevision = expected + 1L; + DataspaceRevision revision = new DataspaceRevision(); + revision.setTenantId(account.getTenantId()); + revision.setDataspaceId(space.getId()); + revision.setRevisionNo(nextRevision); + revision.setSnapshotChecksum(checksum(definition, validated)); + fillCreate(revision, account); + revisionMapper.insert(revision); + + Map bindingIds = new HashMap<>(); + for (ValidatedTable item : validated.tables()) { + DataspaceTableBinding binding = new DataspaceTableBinding(); + binding.setTenantId(account.getTenantId()); + binding.setDataspaceId(space.getId()); + binding.setRevisionId(revision.getId()); + binding.setConnectionId(item.connection().getId()); + binding.setSourceRevision(item.connection().getDefinitionRevision()); + binding.setObjectId(item.object().getId()); + binding.setSourceAlias(item.sourceAlias()); + binding.setSchemaAlias(item.schemaAlias()); + binding.setTableAlias(item.logicalTableName()); + binding.setPositionX(item.definition().positionX()); + binding.setPositionY(item.definition().positionY()); + fillCreate(binding, account); + bindingMapper.insert(binding); + bindingIds.put(item.definition().clientKey(), binding.getId()); + } + for (DataspaceDefinition.RelationDefinition item : definition.relations()) { + DataspaceRelation relation = new DataspaceRelation(); + relation.setTenantId(account.getTenantId()); + relation.setDataspaceId(space.getId()); + relation.setRevisionId(revision.getId()); + relation.setLeftBindingId(bindingIds.get(item.leftClientKey())); + relation.setRightBindingId(bindingIds.get(item.rightClientKey())); + relation.setJoinType(normalizeJoin(item.joinType())); + relation.setLeftColumn(item.leftColumn().trim()); + relation.setRightColumn(item.rightColumn().trim()); + fillCreate(relation, account); + relationMapper.insert(relation); + } + int changed = dataspaceMapper.compareAndSetCurrentRevision( + space.getId(), account.getTenantId(), expected, nextRevision); + if (changed != 1) { + throw DataspaceErrorCode.REVISION_CONFLICT.exception( + "数据空间已被其他节点更新,请刷新后重试"); + } + space.setName(required(definition.name(), "数据空间名称")); + space.setDescription(trim(definition.description())); + space.setCurrentRevision(nextRevision); + space.setModified(new Date()); + space.setModifiedBy(account.getId()); + dataspaceMapper.update(space); + return space.getId(); + } + + /** + * 校验纳管表、数据源版本、别名与关联关系。 + * + * @param definition 数据空间定义 + * @return 解析完成的定义 + * @throws BusinessException 定义不完整或元数据过期时抛出 + */ + private ValidatedDefinition validateDefinition(DataspaceDefinition definition) { + if (definition.tables().isEmpty()) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception("数据空间至少需要一张表"); + } + if (definition.tables().size() > MAX_TABLES) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + "单个数据空间最多支持 " + MAX_TABLES + " 张表"); + } + Set clientKeys = new HashSet<>(); + Set objectIds = new LinkedHashSet<>(); + for (DataspaceDefinition.TableDefinition table : definition.tables()) { + if (table.clientKey() == null || table.clientKey().isBlank() + || !clientKeys.add(table.clientKey())) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + "表节点标识不能为空且不能重复"); + } + if (table.objectId() == null || !objectIds.add(table.objectId())) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + "同一物理表不能重复加入数据空间"); + } + } + Map objects = new HashMap<>(); + objectMapper.selectListByQuery(QueryWrapper.create().in(DataspaceObject::getId, objectIds)) + .forEach(item -> objects.put(item.getId(), item)); + if (objects.size() != objectIds.size()) { + throw DataspaceErrorCode.OBJECT_MISSING.exception( + "部分数据库对象不存在,请刷新元数据后重试"); + } + Set connectionIds = objects.values().stream() + .map(DataspaceObject::getConnectionId).collect(java.util.stream.Collectors.toSet()); + if (connectionIds.size() > MAX_SOURCES) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + "当前阶段单个数据空间最多支持两个物理数据源"); + } + Map connections = new HashMap<>(); + connectionMapper.selectListByQuery(QueryWrapper.create().in(DataspaceConnection::getId, connectionIds)) + .forEach(item -> connections.put(item.getId(), item)); + Map resolutions = + metadataResolver.resolve(objects, connections); + Map aliasesByConnection = new LinkedHashMap<>(); + Set usedAliases = new HashSet<>(); + Set logicalTableAliases = new HashSet<>(); + Set currentObjectIds = new HashSet<>(); + List tables = new ArrayList<>(); + int index = 1; + for (DataspaceDefinition.TableDefinition table : definition.tables()) { + DataspaceObject historical = objects.get(table.objectId()); + DataspaceConnection connection = connections.get(historical.getConnectionId()); + if (connection == null) { + throw DataspaceErrorCode.CONNECTION_NOT_FOUND.exception(); + } + if (!"ENABLED".equals(connection.getStatus())) { + throw DataspaceErrorCode.CONNECTION_DISABLED.exception( + "数据连接 “" + connection.getName() + "” 已禁用,请先启用并测试连接"); + } + DataspaceMetadataResolver.ObjectResolution resolution = resolutions.get(table.objectId()); + DataspaceObject object = resolution == null ? null : resolution.current(); + if (object == null) { + throw DataspaceErrorCode.OBJECT_MISSING.exception( + "表或视图 “" + historical.getObjectName() + + "” 已不在最新元数据中,请刷新或移除该表"); + } + if (!currentObjectIds.add(object.getId())) { + throw DataspaceErrorCode.OBJECT_MISSING.exception("同一物理表不能重复加入数据空间"); + } + String logicalTableName = required(table.tableAlias(), "逻辑表名").trim(); + requireIdentifier(logicalTableName, "逻辑表名"); + if (!logicalTableAliases.add(logicalTableName.toUpperCase(Locale.ROOT))) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + "数据空间内的逻辑表名必须唯一,名称 “" + logicalTableName + "” 重复"); + } + String requested = table.sourceAlias() == null || table.sourceAlias().isBlank() + ? defaultAlias(connection.getName(), index) : table.sourceAlias().trim().toUpperCase(Locale.ROOT); + requireIdentifier(requested, "数据源别名"); + String existingAlias = aliasesByConnection.putIfAbsent(connection.getId(), requested); + if (existingAlias != null && !existingAlias.equals(requested)) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + "同一物理连接必须使用相同的数据源别名"); + } + if (existingAlias == null && !usedAliases.add(requested)) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + "不同物理连接的数据源别名不能重复"); + } + String schemaAlias = providerRegistry + .require(DataspaceDatabaseType.parse(connection.getDatabaseType())) + .logicalSchema(object.getSchemaName()); + tables.add(new ValidatedTable( + table, object, connection, requested, schemaAlias, logicalTableName)); + index++; + } + Map byKey = new HashMap<>(); + tables.forEach(item -> byKey.put(item.definition().clientKey(), item)); + for (DataspaceDefinition.RelationDefinition relation : definition.relations()) { + ValidatedTable left = byKey.get(relation.leftClientKey()); + ValidatedTable right = byKey.get(relation.rightClientKey()); + if (left == null || right == null || left == right) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception("表关系引用无效"); + } + normalizeJoin(relation.joinType()); + requireColumn(left.object(), left.connection(), relation.leftColumn()); + requireColumn(right.object(), right.connection(), relation.rightColumn()); + requireComparable( + left.object(), left.connection(), relation.leftColumn(), + right.object(), right.connection(), relation.rightColumn()); + } + return new ValidatedDefinition(List.copyOf(tables)); + } + + /** + * 确认元数据对象包含指定字段。 + * + * @param object 元数据对象 + * @param connection 物理连接 + * @param columnName 字段名 + * @throws BusinessException 字段不存在时抛出 + */ + private void requireColumn( + DataspaceObject object, + DataspaceConnection connection, + String columnName) { + String required = required(columnName, "关联字段"); + if (!metadataResolver.hasColumn(object, required, connection.getDatabaseType())) { + throw DataspaceErrorCode.RELATION_FIELD_MISSING.exception( + "表 “" + object.getObjectName() + "” 的关联字段 “" + required + + "” 已不存在,请重新连线或删除该关系"); + } + } + + /** + * 校验等值关系两端字段类型可比较。 + * + * @param leftObject 左表对象 + * @param leftConnection 左表连接 + * @param leftColumn 左字段 + * @param rightObject 右表对象 + * @param rightConnection 右表连接 + * @param rightColumn 右字段 + * @throws BusinessException 字段类型不兼容时抛出 + */ + private void requireComparable( + DataspaceObject leftObject, + DataspaceConnection leftConnection, + String leftColumn, + DataspaceObject rightObject, + DataspaceConnection rightConnection, + String rightColumn) { + if (!metadataResolver.comparable( + metadataResolver.column( + leftObject, leftColumn, leftConnection.getDatabaseType()), + metadataResolver.column( + rightObject, rightColumn, rightConnection.getDatabaseType()))) { + throw DataspaceErrorCode.RELATION_TYPE_INCOMPATIBLE.exception( + "关联字段 “" + leftObject.getObjectName() + "." + leftColumn + "” 与 “" + + rightObject.getObjectName() + "." + rightColumn + + "” 类型不兼容,请重新选择字段"); + } + } + + /** + * 计算当前元数据下每条关系的局部健康状态。 + * + * @param bindings 当前表绑定 + * @param relations 当前关系 + * @param objects 已解析到最新快照的对象 + * @param connections 当前物理连接 + * @param bindingHealth 表绑定健康状态 + * @return 关系 ID 到健康状态的映射 + */ + private Map relationHealth( + List bindings, + List relations, + Map objects, + Map connections, + Map bindingHealth) { + Map bindingsById = new HashMap<>(); + bindings.forEach(binding -> bindingsById.put(binding.getId(), binding)); + Map result = new LinkedHashMap<>(); + for (DataspaceRelation relation : relations) { + DataspaceTableBinding leftBinding = bindingsById.get(relation.getLeftBindingId()); + DataspaceTableBinding rightBinding = bindingsById.get(relation.getRightBindingId()); + if (leftBinding == null || rightBinding == null) { + throw new IllegalStateException("数据空间关系引用的表绑定不存在"); + } + BindingHealth leftHealth = bindingHealth.get(leftBinding.getId()); + BindingHealth rightHealth = bindingHealth.get(rightBinding.getId()); + if (leftHealth == null || rightHealth == null + || !leftHealth.active() || !rightHealth.active()) { + BindingHealth issue = leftHealth != null && !leftHealth.active() + ? leftHealth : rightHealth; + result.put(relation.getId(), new RelationHealth( + "INVALID", + issue == null || issue.issueCode() == null + ? DataspaceErrorCode.OBJECT_MISSING.code() : issue.issueCode(), + issue == null || issue.issueMessage() == null + ? "关系引用的表当前不可用,请移除失效表或重新添加后连线" + : issue.issueMessage())); + continue; + } + DataspaceObject left = objects.get(leftBinding.getObjectId()); + DataspaceObject right = objects.get(rightBinding.getObjectId()); + DataspaceConnection leftConnection = connections.get(leftBinding.getConnectionId()); + DataspaceConnection rightConnection = connections.get(rightBinding.getConnectionId()); + if (!metadataResolver.hasColumn( + left, relation.getLeftColumn(), leftConnection.getDatabaseType()) + || !metadataResolver.hasColumn( + right, relation.getRightColumn(), rightConnection.getDatabaseType())) { + result.put(relation.getId(), new RelationHealth( + "INVALID", DataspaceErrorCode.RELATION_FIELD_MISSING.code(), + "关联字段已不存在,请重新连线或删除该关系")); + continue; + } + if (!metadataResolver.comparable( + metadataResolver.column( + left, relation.getLeftColumn(), leftConnection.getDatabaseType()), + metadataResolver.column( + right, relation.getRightColumn(), rightConnection.getDatabaseType()))) { + result.put(relation.getId(), new RelationHealth( + "INVALID", DataspaceErrorCode.RELATION_TYPE_INCOMPATIBLE.code(), + "关联字段类型已不兼容,请重新选择字段")); + continue; + } + result.put(relation.getId(), RelationHealth.active()); + } + return Map.copyOf(result); + } + + /** + * 计算数据空间 revision 的稳定快照摘要。 + * + * @param definition 原始定义 + * @param validated 已校验定义 + * @return SHA-256 十六进制摘要 + */ + private String checksum(DataspaceDefinition definition, ValidatedDefinition validated) { + StringBuilder canonical = new StringBuilder("dataspace-v1\n") + .append(required(definition.name(), "数据空间名称")).append('\n') + .append(trim(definition.description())).append('\n'); + validated.tables().stream() + .sorted(Comparator.comparing(item -> item.definition().clientKey())) + .forEach(item -> canonical.append(item.definition().clientKey()).append('|') + .append(item.object().getId()).append('|').append(item.sourceAlias()).append('|') + .append(item.schemaAlias()).append('|').append(item.object().getObjectName()).append('|') + .append(item.definition().positionX()).append('|').append(item.definition().positionY()) + .append('\n')); + definition.relations().stream() + .sorted(Comparator.comparing(item -> item.leftClientKey() + ":" + item.rightClientKey())) + .forEach(item -> canonical.append(item.leftClientKey()).append('|') + .append(item.rightClientKey()).append('|').append(normalizeJoin(item.joinType())).append('|') + .append(item.leftColumn()).append('|').append(item.rightColumn()).append('\n')); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(canonical.toString().getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 不可用", exception); + } + } + + /** + * 校验当前租户内的数据空间名称唯一性。 + * + * @param name 数据空间名称 + * @param excludedId 更新时排除的数据空间 ID + * @throws BusinessException 名称重复时抛出 + */ + private void ensureNameUnique(String name, BigInteger excludedId) { + QueryWrapper query = QueryWrapper.create().eq(Dataspace::getName, required(name, "数据空间名称")); + if (excludedId != null) { + query.ne(Dataspace::getId, excludedId); + } + if (dataspaceMapper.selectCountByQuery(query) > 0) { + throw DataspaceErrorCode.NAME_CONFLICT.exception("数据空间名称已存在"); + } + } + + /** + * 获取当前租户可见的数据空间。 + * + * @param id 数据空间 ID + * @return 数据空间记录 + * @throws BusinessException ID 为空或记录不存在时抛出 + */ + private Dataspace requireDataspace(BigInteger id) { + if (id == null) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception("数据空间 ID 不能为空"); + } + Dataspace space = dataspaceMapper.selectOneById(id); + if (space == null) { + throw DataspaceErrorCode.DATASPACE_NOT_FOUND.exception(); + } + return space; + } + + /** + * 填充新增记录的公共审计字段。 + * + * @param target 目标记录 + * @param account 当前登录账号 + */ + private void fillCreate(tech.easyflow.dataspace.entity.DataspaceRecordBase target, LoginAccount account) { + Date now = new Date(); + target.setCreated(now); + target.setCreatedBy(account.getId()); + target.setModified(now); + target.setModifiedBy(account.getId()); + target.setDeleted(0); + } + + /** + * 根据连接名称生成合法且稳定的默认数据源别名。 + * + * @param name 连接名称 + * @param index 连接序号 + * @return SQL 数据源别名 + */ + private String defaultAlias(String name, int index) { + String normalized = name == null ? "SOURCE_" + index + : name.trim().toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9_]", "_"); + if (normalized.isBlank()) { + normalized = "SOURCE_" + index; + } + if (Character.isDigit(normalized.charAt(0))) { + normalized = "S_" + normalized; + } + return normalized.length() > 48 ? normalized.substring(0, 48) : normalized; + } + + /** + * 校验 Calcite 标识符格式。 + * + * @param value 标识符 + * @param label 字段标签 + * @throws BusinessException 格式非法时抛出 + */ + private void requireIdentifier(String value, String label) { + if (!isIdentifier(value)) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + label + "仅支持字母、数字和下划线,且不能以数字开头"); + } + } + + /** + * 判断名称是否为 Calcite 可直接引用的非引号标识符。 + * + * @param value 待校验名称 + * @return 名称是否合法 + */ + static boolean isIdentifier(String value) { + return value != null && value.matches("[A-Za-z_][A-Za-z0-9_]{0,63}"); + } + + /** + * 规范化并校验 Join 类型。 + * + * @param value Join 类型 + * @return 大写 Join 类型 + * @throws BusinessException 类型不受支持时抛出 + */ + private String normalizeJoin(String value) { + String normalized = required(value, "Join 类型").toUpperCase(Locale.ROOT); + if (!Set.of("INNER", "LEFT", "RIGHT", "FULL").contains(normalized)) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception( + "暂不支持该 Join 类型: " + value); + } + return normalized; + } + + /** + * 获取必填文本并去除首尾空白。 + * + * @param value 原始值 + * @param label 字段标签 + * @return 规范化文本 + * @throws BusinessException 文本为空时抛出 + */ + private String required(String value, String label) { + if (value == null || value.isBlank()) { + throw DataspaceErrorCode.DEFINITION_INVALID.exception(label + "不能为空"); + } + return value.trim(); + } + + /** + * 将可空文本规范化为空串或去除首尾空白的文本。 + * + * @param value 原始值 + * @return 规范化文本 + */ + private String trim(String value) { + return value == null ? "" : value.trim(); + } + + /** + * 查询使用的数据库权威 revision 快照。 + * + * @param dataspace 数据空间 + * @param revision 当前 revision + * @param bindings 表绑定 + * @param relations 表关系 + * @param objects 元数据对象映射 + * @param connections 连接映射 + * @param bindingHealth 表绑定健康状态 + * @param relationHealth 关系健康状态 + */ + public record RuntimeSnapshot( + Dataspace dataspace, + DataspaceRevision revision, + List bindings, + List relations, + Map objects, + Map connections, + Map bindingHealth, + Map relationHealth) { + + /** + * 防御性复制运行快照。 + */ + public RuntimeSnapshot { + bindings = List.copyOf(bindings); + relations = List.copyOf(relations); + objects = Map.copyOf(objects); + connections = Map.copyOf(connections); + bindingHealth = Map.copyOf(bindingHealth); + relationHealth = Map.copyOf(relationHealth); + } + } + + /** + * 表绑定当前健康状态。 + * + * @param status ACTIVE、MISSING 或 CONNECTION_DISABLED + * @param issueCode 稳定问题码 + * @param issueMessage 用户提示 + */ + public record BindingHealth(String status, Integer issueCode, String issueMessage) { + + /** + * 判断表绑定是否可用。 + * + * @return 是否为 ACTIVE + */ + public boolean active() { + return "ACTIVE".equals(status); + } + } + + /** + * 表关系当前健康状态。 + * + * @param status ACTIVE 或 INVALID + * @param issueCode 稳定问题码 + * @param issueMessage 用户提示 + */ + public record RelationHealth(String status, Integer issueCode, String issueMessage) { + + /** + * 创建健康关系状态。 + * + * @return ACTIVE 状态 + */ + public static RelationHealth active() { + return new RelationHealth("ACTIVE", null, null); + } + } + + /** + * 通过校验并补全运行信息的数据空间定义。 + * + * @param tables 纳管表 + */ + private record ValidatedDefinition(List tables) { + } + + /** + * 已解析物理对象、连接和逻辑别名的纳管表。 + * + * @param definition 表定义 + * @param object 元数据对象 + * @param connection 物理连接 + * @param sourceAlias 数据源别名 + * @param schemaAlias Schema 别名 + * @param logicalTableName 数据空间内全局唯一的逻辑表名 + */ + private record ValidatedTable( + DataspaceDefinition.TableDefinition definition, + DataspaceObject object, + DataspaceConnection connection, + String sourceAlias, + String schemaAlias, + String logicalTableName) { + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/model/DataspaceDefinitionTest.java b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/model/DataspaceDefinitionTest.java new file mode 100644 index 00000000..4d22f096 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/model/DataspaceDefinitionTest.java @@ -0,0 +1,50 @@ +package tech.easyflow.dataspace.model; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.alibaba.fastjson.JSON; +import org.junit.Test; + +/** + * 数据空间保存请求反序列化测试。 + */ +public class DataspaceDefinitionTest { + + /** + * 验证 Fastjson 反序列化 record 时遗留的嵌套 JSON 对象会恢复为强类型定义。 + */ + @Test + public void shouldNormalizeNestedDefinitionsAfterFastjsonDeserialization() { + String json = """ + { + "id": "448492737717747712", + "expectedRevision": 1, + "name": "网点经营分析", + "description": "", + "tables": [{ + "clientKey": "binding:1", + "objectId": "448679746354769926", + "sourceAlias": "MYSQL_1", + "schemaAlias": "MAIN", + "tableAlias": "outlet_main", + "positionX": 80, + "positionY": 80 + }], + "relations": [{ + "leftClientKey": "binding:1", + "rightClientKey": "binding:2", + "joinType": "INNER", + "leftColumn": "source_row", + "rightColumn": "source_row" + }] + } + """; + + DataspaceDefinition definition = JSON.parseObject(json, DataspaceDefinition.class); + + assertEquals("outlet_main", definition.tables().get(0).tableAlias()); + assertEquals("source_row", definition.relations().get(0).leftColumn()); + assertTrue(definition.tables().get(0) instanceof DataspaceDefinition.TableDefinition); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/model/DataspaceErrorCodeTest.java b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/model/DataspaceErrorCodeTest.java new file mode 100644 index 00000000..b633adbb --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/model/DataspaceErrorCodeTest.java @@ -0,0 +1,152 @@ +package tech.easyflow.dataspace.model; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +import com.easyagents.federation.sql.api.FederationSqlErrorCode; +import com.easyagents.federation.sql.api.FederationSqlException; +import org.junit.Test; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.dataspace.security.DataspaceCredentialUnavailableException; + +/** + * 数据空间错误码映射契约测试。 + */ +public class DataspaceErrorCodeTest { + + /** + * 验证 SQL 语法错误保留 HTTP 400 和稳定业务码。 + */ + @Test + public void shouldMapSqlParseFailure() { + BusinessException exception = DataspaceErrorCode.fromFederation( + new FederationSqlException( + FederationSqlErrorCode.SQL_PARSE_FAILED, + "Encountered token at line 1"), + false); + + assertEquals(400, exception.getHttpStatus()); + assertEquals(40061, exception.getErrorCode()); + assertEquals(true, exception.getMessage().contains("SQL 语法解析失败")); + } + + /** + * 验证未知逻辑表返回可直接处理的中文提示。 + */ + @Test + public void shouldLocalizeUnknownLogicalTable() { + BusinessException exception = DataspaceErrorCode.fromFederation( + new FederationSqlException( + FederationSqlErrorCode.SQL_VALIDATION_FAILED, + "logical table is not declared in the query scope: missing_table"), + false); + + assertEquals(400, exception.getHttpStatus()); + assertEquals(40062, exception.getErrorCode()); + assertEquals(true, exception.getMessage().contains("逻辑表 “missing_table” 不在当前数据空间中")); + } + + /** + * 验证数据源初始化失败映射为可恢复的 503 错误。 + */ + @Test + public void shouldMapSourceInitializationFailure() { + BusinessException exception = DataspaceErrorCode.fromFederation( + new FederationSqlException( + FederationSqlErrorCode.SOURCE_INITIALIZATION_FAILED, + "driver failure"), + false); + + assertEquals(503, exception.getHttpStatus()); + assertEquals(50361, exception.getErrorCode()); + assertEquals(DataspaceErrorCode.SOURCE_UNAVAILABLE.defaultMessage(), exception.getMessage()); + } + + /** + * 验证数据源初始化包装后仍保留凭据失效的精确恢复提示。 + */ + @Test + public void shouldPreserveCredentialUnavailableFailure() { + DataspaceCredentialUnavailableException credentialException = + new DataspaceCredentialUnavailableException( + new IllegalArgumentException("credential key mismatch")); + BusinessException exception = DataspaceErrorCode.fromFederation( + new FederationSqlException( + FederationSqlErrorCode.SOURCE_INITIALIZATION_FAILED, + "failed to initialize source", + credentialException), + false); + + assertSame(credentialException, exception); + assertEquals(409, exception.getHttpStatus()); + assertEquals(40905, exception.getErrorCode()); + assertEquals(DataspaceCredentialUnavailableException.USER_MESSAGE, + exception.getMessage()); + } + + /** + * 验证联邦资源限制映射为 422,便于页面提示缩小查询范围。 + */ + @Test + public void shouldMapFederationResourceLimit() { + BusinessException exception = DataspaceErrorCode.fromFederation( + new FederationSqlException( + FederationSqlErrorCode.FEDERATION_RESOURCE_LIMIT_EXCEEDED, + "too many rows"), + false); + + assertEquals(422, exception.getHttpStatus()); + assertEquals(42261, exception.getErrorCode()); + } + + /** + * 验证补全内核异常使用独立错误码,避免伪装成数据源不可用。 + */ + @Test + public void shouldMapSqlCompletionFailure() { + BusinessException exception = DataspaceErrorCode.fromFederation( + new FederationSqlException( + FederationSqlErrorCode.SQL_COMPLETION_FAILED, + "advisor failed"), + false); + + assertEquals(503, exception.getHttpStatus()); + assertEquals(50364, exception.getErrorCode()); + assertEquals(DataspaceErrorCode.SQL_COMPLETION_FAILED.defaultMessage(), + exception.getMessage()); + } + + /** + * 验证 SQL 编译超时使用独立错误码和可恢复提示。 + */ + @Test + public void shouldMapSqlCompileTimeout() { + BusinessException exception = DataspaceErrorCode.fromFederation( + new FederationSqlException( + FederationSqlErrorCode.SQL_COMPILE_TIMEOUT, + "plan cache wait expired"), + false); + + assertEquals(408, exception.getHttpStatus()); + assertEquals(40863, exception.getErrorCode()); + assertEquals(DataspaceErrorCode.SQL_COMPILE_TIMEOUT.defaultMessage(), + exception.getMessage()); + } + + /** + * 验证连接池等待超时不会被泛化为数据源不可用。 + */ + @Test + public void shouldMapConnectionAcquisitionTimeout() { + BusinessException exception = DataspaceErrorCode.fromFederation( + new FederationSqlException( + FederationSqlErrorCode.CONNECTION_ACQUISITION_TIMEOUT, + "pool acquisition timeout"), + false); + + assertEquals(503, exception.getHttpStatus()); + assertEquals(50365, exception.getErrorCode()); + assertEquals(DataspaceErrorCode.CONNECTION_ACQUISITION_TIMEOUT.defaultMessage(), + exception.getMessage()); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProviderIntegrationTest.java b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProviderIntegrationTest.java new file mode 100644 index 00000000..5f0a9b30 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProviderIntegrationTest.java @@ -0,0 +1,71 @@ +package tech.easyflow.dataspace.provider; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.List; +import java.util.Map; +import org.junit.Assume; +import org.junit.Test; + +/** + * 本机 Docker MySQL 与 PostgreSQL 元数据接入验证。 + */ +public class DataspaceDatabaseProviderIntegrationTest { + + /** + * 验证 MySQL data-sheet 元数据可被读取。 + * + * @throws Exception JDBC 连接或元数据读取失败 + */ + @Test + public void shouldInspectMysqlDataSheet() throws Exception { + Assume.assumeTrue(Boolean.getBoolean("dataspace.integration.enabled")); + MySqlProvider provider = new MySqlProvider(); + DataspaceConnectionConfig config = new DataspaceConnectionConfig( + DataspaceDatabaseType.MYSQL, + System.getProperty("dataspace.mysql.host", "127.0.0.1"), + Integer.getInteger("dataspace.mysql.port", 33306), + System.getProperty("dataspace.mysql.database", "data-sheet"), + System.getProperty("dataspace.mysql.username", "root"), + System.getProperty("dataspace.mysql.password", "root"), + false, + Map.of()); + + try (Connection connection = DriverManager.getConnection( + provider.jdbcUrl(config), config.username(), config.password())) { + List objects = provider.inspect(connection, config); + assertFalse(objects.isEmpty()); + assertTrue(objects.stream().anyMatch(object -> "outlet".equalsIgnoreCase(object.name()))); + } + } + + /** + * 验证 PostgreSQL public Schema 元数据可被读取。 + * + * @throws Exception JDBC 连接或元数据读取失败 + */ + @Test + public void shouldInspectPostgresql() throws Exception { + Assume.assumeTrue(Boolean.getBoolean("dataspace.integration.enabled")); + PostgresqlProvider provider = new PostgresqlProvider(); + DataspaceConnectionConfig config = new DataspaceConnectionConfig( + DataspaceDatabaseType.POSTGRESQL, + System.getProperty("dataspace.pg.host", "127.0.0.1"), + Integer.getInteger("dataspace.pg.port", 54329), + System.getProperty("dataspace.pg.database", "harmony_adapter"), + System.getProperty("dataspace.pg.username", "harmony"), + System.getProperty("dataspace.pg.password", "harmony"), + false, + Map.of()); + + try (Connection connection = DriverManager.getConnection( + provider.jdbcUrl(config), config.username(), config.password())) { + List objects = provider.inspect(connection, config); + assertFalse(objects.isEmpty()); + assertTrue(objects.stream().allMatch(object -> !object.schema().startsWith("pg_"))); + } + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProviderTest.java b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProviderTest.java new file mode 100644 index 00000000..bec11453 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/provider/DataspaceDatabaseProviderTest.java @@ -0,0 +1,73 @@ +package tech.easyflow.dataspace.provider; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import java.util.Map; +import org.junit.Test; + +/** + * MySQL 与 PostgreSQL Provider 契约测试。 + */ +public class DataspaceDatabaseProviderTest { + + /** + * 验证 MySQL URL 与固定逻辑 Schema。 + */ + @Test + public void shouldBuildMysqlDefinition() { + MySqlProvider provider = new MySqlProvider(); + DataspaceConnectionConfig config = new DataspaceConnectionConfig( + DataspaceDatabaseType.MYSQL, + "127.0.0.1", + 33306, + "data-sheet", + "root", + "root", + false, + Map.of()); + + assertEquals("MAIN", provider.logicalSchema(null)); + assertEquals( + "jdbc:mysql://127.0.0.1:33306/data-sheet" + + "?useUnicode=true&characterEncoding=utf8&useSSL=false" + + "&serverTimezone=UTC&allowMultiQueries=false", + provider.jdbcUrl(config)); + assertFalse(provider.jdbcUrl(config).contains("allowMultiQueries=true")); + } + + /** + * 验证 PostgreSQL URL、SSL 策略与 Schema 规范化。 + */ + @Test + public void shouldBuildPostgresqlDefinition() { + PostgresqlProvider provider = new PostgresqlProvider(); + DataspaceConnectionConfig config = new DataspaceConnectionConfig( + DataspaceDatabaseType.POSTGRESQL, + "127.0.0.1", + 54329, + "harmony_adapter", + "harmony", + "harmony", + false, + Map.of()); + + assertEquals("PUBLIC", provider.logicalSchema(null)); + assertEquals("SALES", provider.logicalSchema("sales")); + assertEquals( + "jdbc:postgresql://127.0.0.1:54329/harmony_adapter" + + "?sslmode=disable&ApplicationName=easyflow-dataspace", + provider.jdbcUrl(config)); + } + + /** + * 验证未知数据库类型被显式拒绝。 + */ + @Test + public void shouldRejectUnknownDatabaseType() { + assertThrows( + IllegalArgumentException.class, + () -> DataspaceDatabaseType.parse("oracle")); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/security/DataspaceCredentialCipherTest.java b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/security/DataspaceCredentialCipherTest.java new file mode 100644 index 00000000..7bbbc1d9 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/security/DataspaceCredentialCipherTest.java @@ -0,0 +1,77 @@ +package tech.easyflow.dataspace.security; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +/** + * 数据空间凭据认证加密测试。 + */ +public class DataspaceCredentialCipherTest { + + private static final String MASTER_KEY = "dataspace-test-master-key-32-characters"; + + /** + * 验证同一明文使用随机 IV,并能正确解密。 + */ + @Test + public void shouldRoundTripWithRandomIv() { + DataspaceCredentialCipher cipher = new DataspaceCredentialCipher(MASTER_KEY); + + String first = cipher.encrypt("root"); + String second = cipher.encrypt("root"); + + assertNotEquals(first, second); + assertEquals("root", cipher.decrypt(first)); + assertEquals("root", cipher.decrypt(second)); + } + + /** + * 验证认证标签能够拒绝被篡改的密文。 + */ + @Test + public void shouldRejectTamperedCipherText() { + DataspaceCredentialCipher cipher = new DataspaceCredentialCipher(MASTER_KEY); + String encrypted = cipher.encrypt("root"); + char replacement = encrypted.endsWith("A") ? 'B' : 'A'; + String tampered = encrypted.substring(0, encrypted.length() - 1) + replacement; + + DataspaceCredentialUnavailableException exception = assertThrows( + DataspaceCredentialUnavailableException.class, + () -> cipher.decrypt(tampered)); + + assertEquals(409, exception.getHttpStatus()); + assertEquals(DataspaceCredentialUnavailableException.ERROR_CODE, exception.getErrorCode()); + assertEquals(DataspaceCredentialUnavailableException.USER_MESSAGE, exception.getMessage()); + } + + /** + * 验证部署密钥变更后返回可恢复的稳定业务错误。 + */ + @Test + public void shouldDescribeCredentialRecoveryWhenMasterKeyChanges() { + DataspaceCredentialCipher original = new DataspaceCredentialCipher(MASTER_KEY); + DataspaceCredentialCipher changed = new DataspaceCredentialCipher( + "changed-dataspace-master-key-32-characters"); + + DataspaceCredentialUnavailableException exception = assertThrows( + DataspaceCredentialUnavailableException.class, + () -> changed.decrypt(original.encrypt("root"))); + + assertEquals(409, exception.getHttpStatus()); + assertEquals(DataspaceCredentialUnavailableException.ERROR_CODE, exception.getErrorCode()); + assertEquals(DataspaceCredentialUnavailableException.USER_MESSAGE, exception.getMessage()); + } + + /** + * 验证部署主密钥长度下限。 + */ + @Test + public void shouldRejectShortMasterKey() { + assertThrows( + IllegalStateException.class, + () -> new DataspaceCredentialCipher("too-short")); + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/service/DataspaceMetadataCompatibilityTest.java b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/service/DataspaceMetadataCompatibilityTest.java new file mode 100644 index 00000000..bfb2fc0f --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/service/DataspaceMetadataCompatibilityTest.java @@ -0,0 +1,326 @@ +package tech.easyflow.dataspace.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.mybatisflex.core.query.QueryWrapper; +import java.math.BigInteger; +import java.sql.Types; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import tech.easyflow.common.cache.RedisLockExecutor; +import tech.easyflow.dataspace.entity.Dataspace; +import tech.easyflow.dataspace.entity.DataspaceConnection; +import tech.easyflow.dataspace.entity.DataspaceObject; +import tech.easyflow.dataspace.entity.DataspaceRelation; +import tech.easyflow.dataspace.entity.DataspaceRevision; +import tech.easyflow.dataspace.entity.DataspaceTableBinding; +import tech.easyflow.dataspace.mapper.DataspaceConnectionMapper; +import tech.easyflow.dataspace.mapper.DataspaceMapper; +import tech.easyflow.dataspace.mapper.DataspaceObjectMapper; +import tech.easyflow.dataspace.mapper.DataspaceRelationMapper; +import tech.easyflow.dataspace.mapper.DataspaceRevisionMapper; +import tech.easyflow.dataspace.mapper.DataspaceTableBindingMapper; +import tech.easyflow.dataspace.model.DataspaceErrorCode; +import tech.easyflow.dataspace.model.DataspaceView; +import tech.easyflow.dataspace.provider.DataspaceDatabaseProviderRegistry; + +/** + * 数据空间历史绑定与最新元数据兼容解析测试。 + */ +public class DataspaceMetadataCompatibilityTest { + + private static final BigInteger SPACE_ID = BigInteger.valueOf(1L); + private static final BigInteger REVISION_ID = BigInteger.valueOf(2L); + private static final BigInteger BINDING_ID = BigInteger.valueOf(3L); + private static final BigInteger CONNECTION_ID = BigInteger.valueOf(4L); + private static final BigInteger HISTORICAL_OBJECT_ID = BigInteger.valueOf(5L); + private static final BigInteger CURRENT_OBJECT_ID = BigInteger.valueOf(6L); + private static final BigInteger SECOND_BINDING_ID = BigInteger.valueOf(7L); + private static final BigInteger SECOND_HISTORICAL_OBJECT_ID = BigInteger.valueOf(8L); + private static final BigInteger SECOND_CURRENT_OBJECT_ID = BigInteger.valueOf(9L); + private static final BigInteger RELATION_ID = BigInteger.valueOf(10L); + + /** + * 验证逻辑表名兼容产品默认使用的小写名称和手动下划线命名。 + */ + @Test + public void shouldAcceptLowercaseLogicalTableNameWithUnderscore() { + assertTrue(DataspaceService.isIdentifier("outlet_main")); + assertTrue(DataspaceService.isIdentifier("OUTLET_2")); + assertFalse(DataspaceService.isIdentifier("2_outlet")); + assertFalse(DataspaceService.isIdentifier("outlet-name")); + } + + /** + * 验证兼容元数据刷新后详情自动使用最新对象和字段。 + */ + @Test + public void shouldResolveHistoricalBindingToCurrentMetadata() { + Fixture fixture = fixture(List.of(currentObject())); + + DataspaceView detail = fixture.service().detail(SPACE_ID); + + assertEquals(1, detail.tables().size()); + assertEquals(CURRENT_OBJECT_ID, detail.tables().get(0).objectId()); + assertEquals("MYSQL", detail.tables().get(0).databaseType()); + assertEquals("ACTIVE", detail.tables().get(0).status()); + assertEquals(2, detail.tables().get(0).columns().size()); + assertNull(detail.tables().get(0).issueCode()); + } + + /** + * 验证表从最新快照消失时仅返回局部失效状态,不阻断详情加载。 + */ + @Test + public void shouldExposeMissingBindingWithoutRejectingWholeDataspace() { + Fixture fixture = fixture(List.of()); + + DataspaceView detail = fixture.service().detail(SPACE_ID); + + assertEquals(1, detail.tables().size()); + assertEquals(HISTORICAL_OBJECT_ID, detail.tables().get(0).objectId()); + assertEquals("MISSING", detail.tables().get(0).status()); + assertEquals( + Integer.valueOf(DataspaceErrorCode.OBJECT_MISSING.code()), + detail.tables().get(0).issueCode()); + } + + /** + * 验证关联字段被删除时仅标记该关系失效,其他表仍可继续查询。 + */ + @Test + public void shouldInvalidateOnlyRelationWhenJoinColumnDisappears() { + DataspaceObject historicalRegion = object( + SECOND_HISTORICAL_OBJECT_ID, + 1L, + "outlet_region", + List.of(column("outlet_id", Types.BIGINT))); + DataspaceObject currentRegion = object( + SECOND_CURRENT_OBJECT_ID, + 2L, + "outlet_region", + List.of(column("region_name", Types.VARCHAR))); + Fixture fixture = fixture( + List.of(historicalObject(), historicalRegion), + List.of(binding(), secondBinding()), + List.of(relation()), + List.of(currentObject(), currentRegion)); + + DataspaceView detail = fixture.service().detail(SPACE_ID); + + assertEquals("ACTIVE", detail.tables().get(0).status()); + assertEquals("ACTIVE", detail.tables().get(1).status()); + assertEquals("INVALID", detail.relations().get(0).status()); + assertEquals( + Integer.valueOf(DataspaceErrorCode.RELATION_FIELD_MISSING.code()), + detail.relations().get(0).issueCode()); + } + + /** + * 创建只覆盖详情读取路径的测试夹具。 + * + * @param currentObjects 最新元数据对象 + * @return 测试夹具 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private Fixture fixture(List currentObjects) { + return fixture( + List.of(historicalObject()), + List.of(binding()), + List.of(), + currentObjects); + } + + /** + * 创建指定历史与当前元数据的测试夹具。 + * + * @param historicalObjects revision 保存的历史对象 + * @param bindings revision 表绑定 + * @param relations revision 表关系 + * @param currentObjects 最新元数据对象 + * @return 测试夹具 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private Fixture fixture( + List historicalObjects, + List bindings, + List relations, + List currentObjects) { + DataspaceMapper dataspaceMapper = mock(DataspaceMapper.class); + DataspaceRevisionMapper revisionMapper = mock(DataspaceRevisionMapper.class); + DataspaceTableBindingMapper bindingMapper = mock(DataspaceTableBindingMapper.class); + DataspaceRelationMapper relationMapper = mock(DataspaceRelationMapper.class); + DataspaceObjectMapper objectMapper = mock(DataspaceObjectMapper.class); + DataspaceConnectionMapper connectionMapper = mock(DataspaceConnectionMapper.class); + + when(dataspaceMapper.selectOneById(SPACE_ID)).thenReturn(dataspace()); + when(revisionMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(revision()); + when(bindingMapper.selectListByQuery(any(QueryWrapper.class))) + .thenReturn(bindings); + when(relationMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(relations); + // 首次读取不可变历史对象,随后由解析器读取连接当前快照。 + when(objectMapper.selectListByQuery(any(QueryWrapper.class))) + .thenReturn(historicalObjects, currentObjects); + when(connectionMapper.selectListByQuery(any(QueryWrapper.class))) + .thenReturn(List.of(connection())); + + DataspaceMetadataResolver resolver = new DataspaceMetadataResolver(objectMapper); + DataspaceService service = new DataspaceService( + dataspaceMapper, + revisionMapper, + bindingMapper, + relationMapper, + objectMapper, + connectionMapper, + mock(DataspaceDatabaseProviderRegistry.class), + resolver, + mock(RedisLockExecutor.class), + mock(org.springframework.transaction.support.TransactionTemplate.class)); + return new Fixture(service); + } + + /** @return 数据空间记录 */ + private Dataspace dataspace() { + Dataspace value = new Dataspace(); + value.setId(SPACE_ID); + value.setName("网点经营分析"); + value.setDescription(""); + value.setCurrentRevision(1L); + value.setStatus("ENABLED"); + return value; + } + + /** @return 当前 revision */ + private DataspaceRevision revision() { + DataspaceRevision value = new DataspaceRevision(); + value.setId(REVISION_ID); + value.setDataspaceId(SPACE_ID); + value.setRevisionNo(1L); + return value; + } + + /** @return 历史表绑定 */ + private DataspaceTableBinding binding() { + DataspaceTableBinding value = new DataspaceTableBinding(); + value.setId(BINDING_ID); + value.setDataspaceId(SPACE_ID); + value.setRevisionId(REVISION_ID); + value.setConnectionId(CONNECTION_ID); + value.setSourceRevision(1L); + value.setObjectId(HISTORICAL_OBJECT_ID); + value.setSourceAlias("MYSQL_1"); + value.setSchemaAlias("MAIN"); + value.setTableAlias("outlet"); + return value; + } + + /** @return 第二张历史表绑定 */ + private DataspaceTableBinding secondBinding() { + DataspaceTableBinding value = binding(); + value.setId(SECOND_BINDING_ID); + value.setObjectId(SECOND_HISTORICAL_OBJECT_ID); + value.setTableAlias("outlet_region"); + return value; + } + + /** @return 依赖已删除字段的表关系 */ + private DataspaceRelation relation() { + DataspaceRelation value = new DataspaceRelation(); + value.setId(RELATION_ID); + value.setDataspaceId(SPACE_ID); + value.setRevisionId(REVISION_ID); + value.setLeftBindingId(BINDING_ID); + value.setRightBindingId(SECOND_BINDING_ID); + value.setJoinType("INNER"); + value.setLeftColumn("id"); + value.setRightColumn("outlet_id"); + return value; + } + + /** @return 当前物理连接 */ + private DataspaceConnection connection() { + DataspaceConnection value = new DataspaceConnection(); + value.setId(CONNECTION_ID); + value.setName("销售主库"); + value.setDatabaseType("MYSQL"); + value.setDefinitionRevision(2L); + value.setStatus("ENABLED"); + return value; + } + + /** @return 历史元数据对象 */ + private DataspaceObject historicalObject() { + return object( + HISTORICAL_OBJECT_ID, + 1L, + "outlet", + List.of(column("id", Types.BIGINT))); + } + + /** @return 最新元数据对象 */ + private DataspaceObject currentObject() { + return object( + CURRENT_OBJECT_ID, + 2L, + "outlet", + List.of(column("id", Types.BIGINT), column("name", Types.VARCHAR))); + } + + /** + * 创建元数据对象。 + * + * @param id 对象 ID + * @param revision 元数据 revision + * @param objectName 对象名 + * @param columns 字段 + * @return 元数据对象 + */ + private DataspaceObject object( + BigInteger id, + long revision, + String objectName, + List> columns) { + DataspaceObject value = new DataspaceObject(); + value.setId(id); + value.setConnectionId(CONNECTION_ID); + value.setCatalogName("data-sheet"); + value.setSchemaName(null); + value.setObjectName(objectName); + value.setObjectType("TABLE"); + value.setColumnsJson(columns); + value.setMetadataRevision(revision); + return value; + } + + /** + * 创建字段元数据,并保留空备注验证兼容复制。 + * + * @param name 字段名 + * @param jdbcType JDBC 类型 + * @return 字段元数据 + */ + private Map column(String name, int jdbcType) { + Map value = new LinkedHashMap<>(); + value.put("name", name); + value.put("jdbcType", jdbcType); + value.put("typeName", jdbcType == Types.BIGINT ? "BIGINT" : "VARCHAR"); + value.put("remarks", null); + return value; + } + + /** + * 测试夹具。 + * + * @param service 数据空间服务 + */ + private record Fixture(DataspaceService service) { + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/service/DataspaceQueryResultBudgetTest.java b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/service/DataspaceQueryResultBudgetTest.java new file mode 100644 index 00000000..ae9975b4 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/service/DataspaceQueryResultBudgetTest.java @@ -0,0 +1,83 @@ +package tech.easyflow.dataspace.service; + +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.dataspace.model.DataspaceErrorCode; + +/** + * 数据空间查询结果字节预算测试。 + */ +public class DataspaceQueryResultBudgetTest { + + /** + * 验证多个合法单元格累计超过结果上限时返回稳定业务错误。 + */ + @Test + public void shouldRejectAccumulatedResultBytes() { + DataspaceQueryResultBudget budget = new DataspaceQueryResultBudget(24L, 16L); + budget.beginRow(2); + budget.reserveCell(budget.validateText("12345678")); + + BusinessException exception = expectLimitExceeded(() -> + budget.reserveCell(budget.validateText("abcdefgh"))); + + Assert.assertTrue(exception.getMessage().contains("查询结果数据")); + } + + /** + * 验证文本按 UTF-8 字节数校验,避免多字节字符绕过单字段上限。 + */ + @Test + public void shouldRejectUtf8TextCellOverLimit() { + DataspaceQueryResultBudget budget = new DataspaceQueryResultBudget(128L, 4L); + + BusinessException exception = expectLimitExceeded(() -> + budget.validateText("中文")); + + Assert.assertTrue(exception.getMessage().contains("文本字段")); + } + + /** + * 验证二进制字段在 Base64 编码前按原始大小拦截。 + */ + @Test + public void shouldRejectBinaryCellOverLimit() { + DataspaceQueryResultBudget budget = new DataspaceQueryResultBudget(128L, 4L); + + BusinessException exception = expectLimitExceeded(() -> + budget.validateBinary(new byte[5])); + + Assert.assertTrue(exception.getMessage().contains("二进制字段")); + } + + /** + * 验证 JSON 转义字符计入总结果字节预算。 + */ + @Test + public void shouldAccountForJsonEscaping() { + DataspaceQueryResultBudget budget = new DataspaceQueryResultBudget(9L, 8L); + budget.beginRow(1); + + expectLimitExceeded(() -> budget.reserveCell(budget.validateText("\"\n"))); + } + + /** + * 执行操作并断言返回查询上限业务错误。 + * + * @param action 待执行操作 + * @return 捕获的业务异常 + */ + private BusinessException expectLimitExceeded(Runnable action) { + try { + action.run(); + Assert.fail("expected query result limit exception"); + return null; + } catch (BusinessException exception) { + Assert.assertEquals( + DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.code(), + exception.getErrorCode()); + return exception; + } + } +} diff --git a/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/service/DataspaceQueryServiceExplainTest.java b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/service/DataspaceQueryServiceExplainTest.java new file mode 100644 index 00000000..8ae34b49 --- /dev/null +++ b/easyflow-modules/easyflow-module-dataspace/src/test/java/tech/easyflow/dataspace/service/DataspaceQueryServiceExplainTest.java @@ -0,0 +1,95 @@ +package tech.easyflow.dataspace.service; + +import com.easyagents.federation.sql.compile.FederationFragmentExplain; +import com.easyagents.federation.sql.compile.SqlExplainLevel; +import com.easyagents.federation.sql.compile.SqlExplainResult; +import com.easyagents.federation.sql.execute.FederationPhysicalExplain; +import com.easyagents.federation.sql.federation.FederationCostEstimate; +import com.easyagents.federation.sql.federation.FederationJoinAlgorithm; +import com.easyagents.federation.sql.federation.FederationJoinOptimization; +import com.easyagents.federation.sql.federation.FederationJoinSelectionReason; +import com.easyagents.federation.sql.federation.FederationQueryMode; +import com.easyagents.federation.sql.federation.FederationStatisticsStatus; +import com.easyagents.federation.sql.source.SourceId; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.dataspace.model.DataspaceExplainResult; + +/** + * 数据空间 Explain API 视图映射测试。 + */ +public class DataspaceQueryServiceExplainTest { + + /** + * 验证聚合成本、Join 决策和分片统计完整映射到 API 视图。 + */ + @Test + public void shouldMapFederationExplainDetails() { + Instant collectedAt = Instant.parse("2026-08-23T08:00:00Z"); + FederationCostEstimate cost = new FederationCostEstimate( + 120D, 64L, 7_680D, "catalog", "stats-7", collectedAt, + false, FederationStatisticsStatus.COMPLETE); + FederationPhysicalExplain physical = new FederationPhysicalExplain( + true, "Index Scan", "INDEX_SCAN", "INDEX", List.of("idx_region"), + "idx_region", 120L, null, ""); + FederationFragmentExplain fragment = new FederationFragmentExplain( + "fragment-1", "REGION", new SourceId("region-source"), "jdbc", + "SELECT * FROM region", List.of(), List.of(), cost, + List.of("TableScan", "Filter"), physical); + FederationJoinOptimization join = new FederationJoinOptimization( + 1, List.of("SALES"), List.of("REGION"), "REGION", + FederationJoinAlgorithm.HASH_JOIN, + FederationJoinSelectionReason.SMALLER_BUILD_SIDE, 7_680D); + SqlExplainResult explain = new SqlExplainResult( + SqlExplainLevel.PHYSICAL, FederationQueryMode.FEDERATED, + FederationStatisticsStatus.COMPLETE, true, 12_400D, 3_100L, + List.of(join), "SELECT * FROM sales JOIN region", "", + "logical", "execution", List.of(fragment), + Set.of(new SourceId("region-source")), null, true, false, ""); + + DataspaceExplainResult view = DataspaceQueryService.explainView(explain); + + Assert.assertEquals("FEDERATED", view.queryMode()); + Assert.assertEquals("COMPLETE", view.statisticsStatus()); + Assert.assertTrue(view.estimateAvailable()); + Assert.assertEquals(12_400D, view.estimatedTransferBytes(), 0D); + Assert.assertEquals(3_100L, view.estimatedLocalMemoryBytes()); + Assert.assertEquals(1, view.joins().get(0).stageIndex()); + Assert.assertEquals(List.of("SALES"), view.joins().get(0).leftSources()); + Assert.assertEquals(List.of("REGION"), view.joins().get(0).rightSources()); + Assert.assertEquals("REGION", view.joins().get(0).buildSource()); + Assert.assertEquals("INDEX", view.fragments().get(0).scanType()); + Assert.assertEquals("idx_region", view.fragments().get(0).chosenIndex()); + Assert.assertEquals(collectedAt.toString(), + view.fragments().get(0).statisticsCollectedAt()); + Assert.assertEquals(List.of("TableScan", "Filter"), + view.fragments().get(0).pushedDownOperators()); + } + + /** + * 验证未知估算保持显式不可用,避免页面把零值展示成精确结果。 + */ + @Test + public void shouldPreserveUnavailableEstimate() { + FederationCostEstimate unavailable = new FederationCostEstimate( + 0D, 0L, 0D, "default", "none", Instant.EPOCH, + true, FederationStatisticsStatus.MISSING, false); + FederationFragmentExplain fragment = new FederationFragmentExplain( + "fragment-1", "SALES", new SourceId("sales-source"), "jdbc", + "SELECT 1", List.of(), List.of(), unavailable, List.of(), null); + SqlExplainResult explain = new SqlExplainResult( + SqlExplainLevel.LOGICAL, FederationQueryMode.SINGLE_SOURCE, + FederationStatisticsStatus.MISSING, false, 0D, 0L, List.of(), + "SELECT 1", "SELECT 1", "logical", "execution", List.of(fragment), + Set.of(new SourceId("sales-source")), null, true, false, ""); + + DataspaceExplainResult view = DataspaceQueryService.explainView(explain); + + Assert.assertFalse(view.estimateAvailable()); + Assert.assertFalse(view.fragments().get(0).estimateAvailable()); + Assert.assertNull(view.fragments().get(0).statisticsCollectedAt()); + } +} diff --git a/easyflow-modules/pom.xml b/easyflow-modules/pom.xml index 8d170a39..c1738151 100644 --- a/easyflow-modules/pom.xml +++ b/easyflow-modules/pom.xml @@ -22,6 +22,7 @@ easyflow-module-skill easyflow-module-job easyflow-module-datacenter + easyflow-module-dataspace diff --git a/easyflow-starter/easyflow-starter-all/pom.xml b/easyflow-starter/easyflow-starter-all/pom.xml index 598bbc44..ce0d9b4f 100644 --- a/easyflow-starter/easyflow-starter-all/pom.xml +++ b/easyflow-starter/easyflow-starter-all/pom.xml @@ -60,6 +60,10 @@ tech.easyflow easyflow-module-skill + + tech.easyflow + easyflow-module-dataspace + tech.easyflow easyflow-module-auth diff --git a/easyflow-starter/easyflow-starter-all/src/main/java/tech/easyflow/starter/MybatisConfig.java b/easyflow-starter/easyflow-starter-all/src/main/java/tech/easyflow/starter/MybatisConfig.java index 25df6336..95794fd3 100644 --- a/easyflow-starter/easyflow-starter-all/src/main/java/tech/easyflow/starter/MybatisConfig.java +++ b/easyflow-starter/easyflow-starter-all/src/main/java/tech/easyflow/starter/MybatisConfig.java @@ -4,8 +4,16 @@ import com.mybatisflex.core.FlexGlobalConfig; import com.mybatisflex.core.audit.AuditManager; import com.mybatisflex.core.audit.ConsoleMessageCollector; import com.mybatisflex.core.audit.MessageCollector; +import com.mybatisflex.core.tenant.TenantManager; import com.mybatisflex.spring.boot.MyBatisFlexCustomizer; +import cn.dev33.satoken.stp.StpUtil; +import cn.dev33.satoken.exception.NotWebContextException; +import cn.dev33.satoken.exception.SaTokenContextException; +import cn.dev33.satoken.context.SaHolder; +import java.math.BigInteger; import org.springframework.context.annotation.Configuration; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; @Configuration public class MybatisConfig implements MyBatisFlexCustomizer { @@ -20,6 +28,9 @@ public class MybatisConfig implements MyBatisFlexCustomizer { flexGlobalConfig.setNormalValueOfLogicDelete(0); flexGlobalConfig.setDeletedValueOfLogicDelete(1); + // 登录请求自动追加 tenant_id 条件;系统任务没有登录上下文时保持显式全局语义。 + TenantManager.setTenantFactory(this::currentTenantIds); + //取消控制台的 Banner 打印 flexGlobalConfig.setPrintBanner(false); @@ -27,4 +38,24 @@ public class MybatisConfig implements MyBatisFlexCustomizer { MessageCollector collector = new ConsoleMessageCollector(); AuditManager.setMessageCollector(collector); } + + /** + * 返回当前请求的租户条件;启动与调度线程没有 Sa-Token 上下文时保留系统级查询语义。 + * + * @return 当前租户 ID 数组;没有请求登录上下文时返回 {@code null} + */ + private Object[] currentTenantIds() { + try { + if (!SaHolder.getContext().isValid() || !StpUtil.isLogin()) { + return null; + } + LoginAccount account = SaTokenUtil.getLoginAccount(); + BigInteger tenantId = account == null || account.getTenantId() == null + ? BigInteger.ZERO : account.getTenantId(); + return new Object[]{tenantId}; + } catch (NotWebContextException | SaTokenContextException ignored) { + // Bean 初始化与调度线程没有 Web 上下文,必须允许显式系统任务读取全局状态。 + return null; + } + } } diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V61__mysql_dataspace_schema_and_menu.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V61__mysql_dataspace_schema_and_menu.sql new file mode 100644 index 00000000..759f8283 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V61__mysql_dataspace_schema_and_menu.sql @@ -0,0 +1,197 @@ +SET NAMES utf8mb4; + +CREATE TABLE `tb_dataspace_connection` ( + `id` BIGINT UNSIGNED NOT NULL COMMENT '主键', + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + `name` VARCHAR(100) NOT NULL COMMENT '连接名称', + `database_type` VARCHAR(32) NOT NULL COMMENT '数据库类型', + `host` VARCHAR(255) NOT NULL COMMENT '主机地址', + `port` INT NOT NULL COMMENT '端口', + `database_name` VARCHAR(128) NOT NULL COMMENT '数据库名', + `username` VARCHAR(128) NOT NULL COMMENT '用户名', + `credential_cipher` TEXT NOT NULL COMMENT '凭据密文', + `ssl_enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用SSL', + `driver_class_name` VARCHAR(255) NOT NULL COMMENT 'JDBC Driver类名', + `options_json` JSON NULL COMMENT '连接扩展选项', + `definition_revision` BIGINT NOT NULL COMMENT 'Federation Definition revision', + `definition_checksum` CHAR(64) NULL COMMENT 'Definition SHA-256', + `status` VARCHAR(24) NOT NULL DEFAULT 'ENABLED' COMMENT '连接状态', + `last_test_status` VARCHAR(24) NULL COMMENT '最近测试状态', + `last_test_message` VARCHAR(500) NULL COMMENT '最近测试信息', + `database_product` VARCHAR(128) NULL COMMENT '数据库产品', + `database_version` VARCHAR(128) NULL COMMENT '数据库版本', + `driver_name` VARCHAR(255) NULL COMMENT '驱动名称', + `driver_version` VARCHAR(128) NULL COMMENT '驱动版本', + `last_tested_at` DATETIME NULL COMMENT '最近测试时间', + `metadata_refreshed_at` DATETIME NULL COMMENT '元数据刷新时间', + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + KEY `idx_dataspace_connection_tenant_name` (`tenant_id`, `name`, `is_deleted`), + KEY `idx_dataspace_connection_tenant_status` (`tenant_id`, `status`, `is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间物理数据库连接'; + +CREATE TABLE `tb_dataspace_object` ( + `id` BIGINT UNSIGNED NOT NULL COMMENT '主键', + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + `connection_id` BIGINT UNSIGNED NOT NULL COMMENT '连接ID', + `catalog_name` VARCHAR(128) NULL COMMENT '物理Catalog', + `schema_name` VARCHAR(128) NULL COMMENT '物理Schema', + `object_name` VARCHAR(128) NOT NULL COMMENT '表或视图名称', + `object_type` VARCHAR(32) NOT NULL COMMENT '对象类型', + `remarks` VARCHAR(500) NULL COMMENT '对象说明', + `columns_json` JSON NOT NULL COMMENT '字段元数据快照', + `primary_keys_json` JSON NOT NULL COMMENT '主键字段快照', + `metadata_revision` BIGINT NOT NULL COMMENT '所属连接Definition revision', + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + KEY `idx_dataspace_object_snapshot` (`tenant_id`, `connection_id`, `metadata_revision`, `schema_name`, `object_name`), + KEY `idx_dataspace_object_search` (`tenant_id`, `connection_id`, `object_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间版本化数据库对象元数据'; + +CREATE TABLE `tb_dataspace` ( + `id` BIGINT UNSIGNED NOT NULL COMMENT '主键', + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + `name` VARCHAR(100) NOT NULL COMMENT '数据空间名称', + `description` VARCHAR(500) NULL COMMENT '说明', + `current_revision` BIGINT NOT NULL DEFAULT 0 COMMENT '当前生效revision', + `status` VARCHAR(24) NOT NULL DEFAULT 'ENABLED' COMMENT '状态', + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + KEY `idx_dataspace_tenant_name` (`tenant_id`, `name`, `is_deleted`), + KEY `idx_dataspace_tenant_status` (`tenant_id`, `status`, `is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Agent隔离查询使用的虚拟数据空间'; + +CREATE TABLE `tb_dataspace_revision` ( + `id` BIGINT UNSIGNED NOT NULL COMMENT '主键', + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + `dataspace_id` BIGINT UNSIGNED NOT NULL COMMENT '数据空间ID', + `revision_no` BIGINT NOT NULL COMMENT '不可变revision', + `snapshot_checksum` CHAR(64) NOT NULL COMMENT '快照SHA-256', + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dataspace_revision` (`tenant_id`, `dataspace_id`, `revision_no`), + KEY `idx_dataspace_revision_lookup` (`tenant_id`, `dataspace_id`, `revision_no`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间不可变revision'; + +CREATE TABLE `tb_dataspace_table_binding` ( + `id` BIGINT UNSIGNED NOT NULL COMMENT '主键', + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + `dataspace_id` BIGINT UNSIGNED NOT NULL COMMENT '数据空间ID', + `revision_id` BIGINT UNSIGNED NOT NULL COMMENT 'revision ID', + `connection_id` BIGINT UNSIGNED NOT NULL COMMENT '物理连接ID', + `source_revision` BIGINT NOT NULL COMMENT '绑定时物理源revision', + `object_id` BIGINT UNSIGNED NOT NULL COMMENT '版本化元数据对象ID', + `source_alias` VARCHAR(64) NOT NULL COMMENT 'Calcite数据源别名', + `schema_alias` VARCHAR(64) NOT NULL COMMENT 'Calcite Schema别名', + `table_alias` VARCHAR(128) NOT NULL COMMENT 'Calcite表名', + `position_x` INT NULL COMMENT '画布横坐标', + `position_y` INT NULL COMMENT '画布纵坐标', + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dataspace_binding_object` (`tenant_id`, `revision_id`, `object_id`), + KEY `idx_dataspace_binding_revision` (`tenant_id`, `revision_id`, `id`), + KEY `idx_dataspace_binding_connection` (`tenant_id`, `connection_id`, `revision_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间revision表绑定'; + +CREATE TABLE `tb_dataspace_relation` ( + `id` BIGINT UNSIGNED NOT NULL COMMENT '主键', + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + `dataspace_id` BIGINT UNSIGNED NOT NULL COMMENT '数据空间ID', + `revision_id` BIGINT UNSIGNED NOT NULL COMMENT 'revision ID', + `left_binding_id` BIGINT UNSIGNED NOT NULL COMMENT '左表绑定ID', + `right_binding_id` BIGINT UNSIGNED NOT NULL COMMENT '右表绑定ID', + `join_type` VARCHAR(16) NOT NULL COMMENT 'Join类型', + `left_column` VARCHAR(128) NOT NULL COMMENT '左字段', + `right_column` VARCHAR(128) NOT NULL COMMENT '右字段', + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + KEY `idx_dataspace_relation_revision` (`tenant_id`, `revision_id`, `id`), + KEY `idx_dataspace_relation_left` (`tenant_id`, `left_binding_id`), + KEY `idx_dataspace_relation_right` (`tenant_id`, `right_binding_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间revision表关联'; + +CREATE TABLE `tb_dataspace_query_audit` ( + `id` BIGINT UNSIGNED NOT NULL COMMENT '主键', + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + `dataspace_id` BIGINT UNSIGNED NOT NULL COMMENT '数据空间ID', + `revision_no` BIGINT NOT NULL COMMENT '查询使用的revision', + `query_id` VARCHAR(64) NOT NULL COMMENT '查询ID', + `query_mode` VARCHAR(32) NULL COMMENT '单源或联邦模式', + `sql_digest` CHAR(64) NOT NULL COMMENT 'SQL SHA-256', + `sql_text` MEDIUMTEXT NULL COMMENT '有界SQL文本', + `status` VARCHAR(24) NOT NULL COMMENT '查询终态', + `returned_rows` BIGINT NOT NULL DEFAULT 0 COMMENT '返回行数', + `intermediate_rows` BIGINT NOT NULL DEFAULT 0 COMMENT '中间结果行数', + `duration_millis` BIGINT NOT NULL DEFAULT 0 COMMENT '总耗时毫秒', + `error_code` VARCHAR(64) NULL COMMENT '错误码', + `error_message` VARCHAR(500) NULL COMMENT '脱敏错误信息', + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dataspace_query_id` (`tenant_id`, `query_id`), + KEY `idx_dataspace_query_audit` (`tenant_id`, `dataspace_id`, `created`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间查询审计'; + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +SELECT + 368100000000000001, 0, 0, 'menus.ai.dataspace', '/dataspace', '/dataspace/DataspaceWorkspace', 'lucide:database-zap', + 1, '', 54, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '统一数据空间与联邦SQL工作台' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 368100000000000001); + +INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) VALUES + (368100000000000011, 368100000000000001, 1, '连接查询', '', '', '', 0, '/api/v1/dataspaceConnection/query', 1, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间连接查询'), + (368100000000000012, 368100000000000001, 1, '连接保存', '', '', '', 0, '/api/v1/dataspaceConnection/save', 2, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间连接保存'), + (368100000000000013, 368100000000000001, 1, '连接删除', '', '', '', 0, '/api/v1/dataspaceConnection/remove', 3, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间连接删除'), + (368100000000000014, 368100000000000001, 1, '连接测试', '', '', '', 0, '/api/v1/dataspaceConnection/test', 4, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间连接测试'), + (368100000000000015, 368100000000000001, 1, '元数据刷新', '', '', '', 0, '/api/v1/dataspaceConnection/metadata', 5, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间元数据刷新'), + (368100000000000021, 368100000000000001, 1, '空间查询', '', '', '', 0, '/api/v1/dataspace/query', 11, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间列表查询'), + (368100000000000022, 368100000000000001, 1, '空间详情', '', '', '', 0, '/api/v1/dataspace/detail', 12, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间详情'), + (368100000000000023, 368100000000000001, 1, '空间保存', '', '', '', 0, '/api/v1/dataspace/save', 13, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间保存'), + (368100000000000024, 368100000000000001, 1, '空间删除', '', '', '', 0, '/api/v1/dataspace/remove', 14, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间删除'), + (368100000000000031, 368100000000000001, 1, 'SQL查询', '', '', '', 0, '/api/v1/dataspaceSql/query', 21, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间SQL查询'), + (368100000000000032, 368100000000000001, 1, 'SQL Explain', '', '', '', 0, '/api/v1/dataspaceSql/explain', 22, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间SQL Explain'); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES + (368100000000000101, 1, 368100000000000001), + (368100000000000111, 1, 368100000000000011), + (368100000000000112, 1, 368100000000000012), + (368100000000000113, 1, 368100000000000013), + (368100000000000114, 1, 368100000000000014), + (368100000000000115, 1, 368100000000000015), + (368100000000000121, 1, 368100000000000021), + (368100000000000122, 1, 368100000000000022), + (368100000000000123, 1, 368100000000000023), + (368100000000000124, 1, 368100000000000024), + (368100000000000131, 1, 368100000000000031), + (368100000000000132, 1, 368100000000000032); diff --git a/pom.xml b/pom.xml index 1a67b506..e6cf25d8 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,7 @@ 1.2.0-RC - 1.1.0 + 1.2.0-RC 17 17 17 @@ -224,6 +224,11 @@ easy-agents-skill ${easy-agents.version} + + com.easyagents + easy-agents-federation-sql-adapter-jdbc + ${easy-agents.version} + com.squareup.okhttp3 @@ -520,6 +525,11 @@ easyflow-module-datacenter ${revision} + + tech.easyflow + easyflow-module-dataspace + ${revision} +