diff --git a/core/src/main/java/org/apache/seata/core/constants/DBType.java b/common/src/main/java/org/apache/seata/core/constants/DBType.java
similarity index 100%
rename from core/src/main/java/org/apache/seata/core/constants/DBType.java
rename to common/src/main/java/org/apache/seata/core/constants/DBType.java
diff --git a/console/pom.xml b/console/pom.xml
index 594de337913..b849a858a97 100644
--- a/console/pom.xml
+++ b/console/pom.xml
@@ -32,6 +32,7 @@
console for Seata built with Maven
+ 25
3.5.2
6.2.8
2.0
@@ -168,6 +169,22 @@
spring-ai-starter-mcp-server-webmvc
${spring-ai.version}
+
+ com.alibaba
+ druid
+
+
+ org.apache.commons
+ commons-dbcp2
+
+
+ com.zaxxer
+ HikariCP
+
+
+ com.mysql
+ mysql-connector-j
+
diff --git a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java
new file mode 100644
index 00000000000..b3dd5048bf5
--- /dev/null
+++ b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.console.controller;
+
+import org.apache.seata.common.result.SingleResult;
+import org.apache.seata.common.util.StringUtils;
+import org.apache.seata.console.security.DataSourcePasswordCipher;
+import org.apache.seata.console.security.DataSourcePasswordTransportCipher;
+import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest;
+import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo;
+import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult;
+import org.apache.seata.mcp.service.BusinessDataSourceService;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/businessDataSources")
+public class BusinessDataSourceController {
+
+ private final BusinessDataSourceService dataSourceService;
+
+ private final DataSourcePasswordCipher passwordCipher;
+
+ private final DataSourcePasswordTransportCipher passwordTransportCipher;
+
+ public BusinessDataSourceController(
+ BusinessDataSourceService dataSourceService,
+ DataSourcePasswordCipher passwordCipher,
+ DataSourcePasswordTransportCipher passwordTransportCipher) {
+ this.dataSourceService = dataSourceService;
+ this.passwordCipher = passwordCipher;
+ this.passwordTransportCipher = passwordTransportCipher;
+ }
+
+ @GetMapping
+ public SingleResult> listDataSources() {
+ return SingleResult.success(dataSourceService.getMysqlDataSources());
+ }
+
+ @GetMapping("/password/publicKey")
+ public SingleResult getPasswordPublicKey() {
+ return SingleResult.success(passwordTransportCipher.getPublicKey());
+ }
+
+ @PostMapping
+ public SingleResult registerDataSource(@RequestBody MysqlDataSourceRegisterRequest request) {
+ try {
+ preparePassword(request);
+ return SingleResult.success(dataSourceService.registerMysqlDataSource(request));
+ } catch (Exception e) {
+ return SingleResult.failure(e.getMessage());
+ }
+ }
+
+ @PostMapping("/test")
+ public SingleResult testDataSource(@RequestBody MysqlDataSourceRegisterRequest request) {
+ try {
+ preparePassword(request);
+ return SingleResult.success(dataSourceService.testMysqlDataSource(request));
+ } catch (Exception e) {
+ MysqlDataSourceTestResult result = new MysqlDataSourceTestResult();
+ result.setSuccess(false);
+ result.setMessage(e.getMessage());
+ return SingleResult.success(result);
+ }
+ }
+
+ @DeleteMapping("/{name}")
+ public SingleResult unregisterDataSource(@PathVariable String name) {
+ try {
+ return SingleResult.success(dataSourceService.unregisterMysqlDataSource(name));
+ } catch (Exception e) {
+ return SingleResult.failure(e.getMessage());
+ }
+ }
+
+ private void preparePassword(MysqlDataSourceRegisterRequest request) {
+ if (request == null) {
+ return;
+ }
+ if (StringUtils.isBlank(request.getPassword())) {
+ return;
+ }
+ if (passwordTransportCipher.isEncrypted(request.getPassword())) {
+ request.setPassword(passwordTransportCipher.decrypt(request.getPassword()));
+ return;
+ }
+ if (!passwordCipher.isEnabled()) {
+ return;
+ }
+ request.setPassword(passwordCipher.decrypt(request.getPassword()));
+ }
+}
diff --git a/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java
new file mode 100644
index 00000000000..af8c459a88c
--- /dev/null
+++ b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.console.security;
+
+import org.apache.seata.common.util.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
+
+@Component
+public class DataSourcePasswordCipher {
+
+ private static final String AES_ALGORITHM = "AES";
+
+ private static final String AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding";
+
+ private static final int GCM_IV_LENGTH = 12;
+
+ private static final int GCM_TAG_LENGTH_BITS = 128;
+
+ private final SecretKeySpec secretKeySpec;
+
+ private final boolean enabled;
+
+ private final SecureRandom secureRandom = new SecureRandom();
+
+ public DataSourcePasswordCipher(
+ @Value("${seata.security.secretKey}") String secretKey,
+ @Value("${seata.businessDataSources.encryption.enabled:true}") boolean enabled) {
+ if (StringUtils.isBlank(secretKey)) {
+ throw new IllegalArgumentException("seata.security.secretKey cannot be empty");
+ }
+ this.secretKeySpec = new SecretKeySpec(sha256(secretKey), AES_ALGORITHM);
+ this.enabled = enabled;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public String decrypt(String encryptedPassword) {
+ if (StringUtils.isBlank(encryptedPassword)) {
+ return "";
+ }
+ try {
+ byte[] payload = Base64.getDecoder().decode(encryptedPassword);
+ if (payload.length <= GCM_IV_LENGTH) {
+ throw new IllegalArgumentException("Invalid datasource password ciphertext");
+ }
+ byte[] iv = Arrays.copyOfRange(payload, 0, GCM_IV_LENGTH);
+ byte[] ciphertext = Arrays.copyOfRange(payload, GCM_IV_LENGTH, payload.length);
+ Cipher cipher = Cipher.getInstance(AES_GCM_TRANSFORMATION);
+ cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
+ return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unable to decrypt datasource password");
+ }
+ }
+
+ public String encrypt(String password) {
+ if (StringUtils.isBlank(password)) {
+ return "";
+ }
+ try {
+ byte[] iv = new byte[GCM_IV_LENGTH];
+ secureRandom.nextBytes(iv);
+ Cipher cipher = Cipher.getInstance(AES_GCM_TRANSFORMATION);
+ cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
+ byte[] ciphertext = cipher.doFinal(password.getBytes(StandardCharsets.UTF_8));
+ byte[] payload = new byte[iv.length + ciphertext.length];
+ System.arraycopy(iv, 0, payload, 0, iv.length);
+ System.arraycopy(ciphertext, 0, payload, iv.length, ciphertext.length);
+ return Base64.getEncoder().encodeToString(payload);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unable to encrypt datasource password");
+ }
+ }
+
+ private byte[] sha256(String secretKey) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(secretKey.getBytes(StandardCharsets.UTF_8));
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unable to initialize datasource password cipher");
+ }
+ }
+}
diff --git a/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordTransportCipher.java b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordTransportCipher.java
new file mode 100644
index 00000000000..48dfacf322c
--- /dev/null
+++ b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordTransportCipher.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.console.security;
+
+import org.apache.seata.common.util.StringUtils;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.OAEPParameterSpec;
+import javax.crypto.spec.PSource;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.spec.MGF1ParameterSpec;
+import java.util.Base64;
+
+@Component
+public class DataSourcePasswordTransportCipher {
+
+ private static final String RSA_ALGORITHM = "RSA";
+
+ private static final String RSA_OAEP_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
+
+ private static final int RSA_KEY_SIZE = 2048;
+
+ private static final String TRANSPORT_CIPHER_PREFIX = "rsa:";
+
+ private final KeyPair keyPair;
+
+ public DataSourcePasswordTransportCipher() {
+ try {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance(RSA_ALGORITHM);
+ generator.initialize(RSA_KEY_SIZE);
+ this.keyPair = generator.generateKeyPair();
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unable to initialize datasource password transport cipher");
+ }
+ }
+
+ public String getPublicKey() {
+ return Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
+ }
+
+ public boolean isEncrypted(String password) {
+ return StringUtils.isNotBlank(password) && password.startsWith(TRANSPORT_CIPHER_PREFIX);
+ }
+
+ public String encrypt(String password) {
+ if (StringUtils.isBlank(password)) {
+ return "";
+ }
+ try {
+ Cipher cipher = newCipher(Cipher.ENCRYPT_MODE);
+ byte[] ciphertext = cipher.doFinal(password.getBytes(StandardCharsets.UTF_8));
+ return TRANSPORT_CIPHER_PREFIX + Base64.getEncoder().encodeToString(ciphertext);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unable to encrypt datasource password for transport");
+ }
+ }
+
+ public String decrypt(String encryptedPassword) {
+ if (StringUtils.isBlank(encryptedPassword)) {
+ return "";
+ }
+ if (!isEncrypted(encryptedPassword)) {
+ throw new IllegalArgumentException("Invalid datasource password transport ciphertext");
+ }
+ try {
+ String ciphertext = encryptedPassword.substring(TRANSPORT_CIPHER_PREFIX.length());
+ byte[] payload = Base64.getDecoder().decode(ciphertext);
+ Cipher cipher = newCipher(Cipher.DECRYPT_MODE);
+ return new String(cipher.doFinal(payload), StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unable to decrypt datasource password for transport");
+ }
+ }
+
+ private Cipher newCipher(int mode) throws Exception {
+ Cipher cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION);
+ OAEPParameterSpec spec =
+ new OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT);
+ cipher.init(mode, mode == Cipher.ENCRYPT_MODE ? keyPair.getPublic() : keyPair.getPrivate(), spec);
+ return cipher;
+ }
+}
diff --git a/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java b/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java
new file mode 100644
index 00000000000..785f365befa
--- /dev/null
+++ b/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.mcp.core.constant;
+
+public class SqlConstant {
+
+ public static final String GET_TABLE_NAME_SQL =
+ "SELECT TABLE_NAME, TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? "
+ + "ORDER BY TABLE_NAME";
+
+ public static final String GET_SCHEMA_SQL =
+ "SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS "
+ + "WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION";
+
+ public static final String MYSQL_VALIDATION_SQL = "SELECT 1";
+
+ public static final String MYSQL_EXPLAIN_PREFIX = "EXPLAIN ";
+}
diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java
new file mode 100644
index 00000000000..b5a1ddaf535
--- /dev/null
+++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java
@@ -0,0 +1,640 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.mcp.core.props;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.seata.common.util.StringUtils;
+import org.apache.seata.mcp.core.secret.SecretResolver;
+import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest;
+import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.EnumerablePropertySource;
+import org.springframework.core.env.Environment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MAX_CONN;
+import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MIN_CONN;
+
+@Component
+public class BusinessDataSourcesProperties implements InitializingBean {
+
+ private final Environment env;
+
+ @SuppressWarnings("unused")
+ private final ObjectMapper objectMapper;
+
+ private final SecretResolver secretResolver;
+
+ private final int maxDynamicDataSources;
+
+ private final boolean dynamicRegistrationEnabled;
+
+ private final Map allowedHosts;
+
+ private static final Map DATASOURCES = new ConcurrentHashMap<>();
+
+ private static final Map DATA_SOURCES_NAMES_AND_RESOURCE_IDS = new ConcurrentHashMap<>();
+
+ private static final Set DYNAMIC_RESOURCE_IDS = ConcurrentHashMap.newKeySet();
+
+ private static final String BASE_PREFIX = "seata.businessDataSources.";
+
+ private static final String MYSQL_DB_TYPE = "mysql";
+
+ private static final String MYSQL_DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver";
+
+ private static final String RESOURCE_ID_PREFIX = "business-ds://";
+
+ private static final int DEFAULT_MAX_DYNAMIC_DATA_SOURCES = 100;
+
+ private static final Pattern DATASOURCE_NAME_PATTERN = Pattern.compile("[A-Za-z0-9_.-]+");
+
+ private static final Set SYSTEM_DATABASES =
+ new HashSet<>(Arrays.asList("information_schema", "mysql", "performance_schema", "sys"));
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourcesProperties.class);
+
+ public BusinessDataSourcesProperties(Environment env, ObjectMapper objectMapper, SecretResolver secretResolver) {
+ this.env = env;
+ this.objectMapper = objectMapper;
+ this.secretResolver = secretResolver;
+ this.maxDynamicDataSources = env.getProperty(
+ "seata.businessDataSources.max-dynamic-size", Integer.class, DEFAULT_MAX_DYNAMIC_DATA_SOURCES);
+ this.dynamicRegistrationEnabled =
+ env.getProperty("seata.businessDataSources.dynamic-registration.enabled", Boolean.class, false);
+ this.allowedHosts =
+ parseAllowedHosts(env.getProperty("seata.businessDataSources.dynamic-registration.allowed-hosts", ""));
+ }
+
+ @Override
+ public void afterPropertiesSet() {
+ Set dataSourceNames = getDataSourceNames();
+ for (String name : dataSourceNames) {
+ DataSourceProperties props = new DataSourceProperties();
+ String prefix = BASE_PREFIX + name + ".";
+ props.setName(name);
+ props.setResourceId(buildResourceId(name));
+ props.setEnabled(env.getProperty(prefix + "enabled", Boolean.class, true));
+ props.setDynamic(false);
+ props.setDbType(env.getProperty(prefix + "dbType", MYSQL_DB_TYPE));
+ props.setDriverClassName(env.getProperty(prefix + "driverClassName", MYSQL_DRIVER_CLASS_NAME));
+ props.setUrl(env.getProperty(prefix + "url"));
+ props.setUsername(env.getProperty(prefix + "username"));
+ props.setPassword(env.getProperty(prefix + "password"));
+ props.setPasswordSecretRef(env.getProperty(prefix + "passwordSecretRef"));
+ if (!StringUtils.hasText(props.getPassword()) && StringUtils.hasText(props.getPasswordSecretRef())) {
+ props.setPassword(secretResolver.resolve(props.getPasswordSecretRef()));
+ }
+ props.setDatasource(env.getProperty(prefix + "datasource", "druid"));
+ props.setMinConn(env.getProperty(prefix + "minConn", Integer.class, DEFAULT_DB_MIN_CONN));
+ props.setMaxConn(env.getProperty(prefix + "maxConn", Integer.class, DEFAULT_DB_MAX_CONN));
+ props.setMaxWait(env.getProperty(prefix + "maxWait", Long.class, 5000L));
+ if (!validateDataSourceProperties(props, name)) {
+ continue;
+ }
+ if (props.enabled) {
+ DATASOURCES.put(props.getResourceId(), props);
+ DATA_SOURCES_NAMES_AND_RESOURCE_IDS.put(name, props.getResourceId());
+ }
+ }
+ }
+
+ public synchronized String registerMysqlDataSource(MysqlDataSourceRegisterRequest request) {
+ if (!dynamicRegistrationEnabled) {
+ throw new IllegalArgumentException("Dynamic business data source registration is disabled");
+ }
+ DataSourceProperties props = buildDynamicMysqlProperties(request);
+ String name = props.getName();
+ String resourceId = props.getResourceId();
+ if (DATA_SOURCES_NAMES_AND_RESOURCE_IDS.containsKey(name) || DATASOURCES.containsKey(resourceId)) {
+ throw new IllegalArgumentException("The data source name has already been registered: " + name);
+ }
+ if (DYNAMIC_RESOURCE_IDS.size() >= maxDynamicDataSources) {
+ throw new IllegalArgumentException(
+ "The number of dynamic business data sources exceeds the limit: " + maxDynamicDataSources);
+ }
+ DATASOURCES.put(resourceId, props);
+ DATA_SOURCES_NAMES_AND_RESOURCE_IDS.put(name, resourceId);
+ DYNAMIC_RESOURCE_IDS.add(resourceId);
+ return resourceId;
+ }
+
+ public synchronized DataSourceProperties buildDynamicMysqlProperties(MysqlDataSourceRegisterRequest request) {
+ if (request == null) {
+ throw new IllegalArgumentException("Data source registration request cannot be null");
+ }
+ DataSourceProperties props = new DataSourceProperties();
+ props.setName(request.getName());
+ props.setResourceId(buildResourceId(request.getName()));
+ props.setEnabled(true);
+ props.setDynamic(true);
+ props.setDbType(MYSQL_DB_TYPE);
+ props.setDriverClassName(MYSQL_DRIVER_CLASS_NAME);
+ props.setUsername(request.getUsername());
+ props.setPasswordSecretRef(request.getPasswordSecretRef());
+ props.setPassword(resolvePassword(request));
+ props.setDatasource(StringUtils.hasText(request.getDatasource()) ? request.getDatasource() : "druid");
+ props.setMinConn(request.getMinConn() <= 0 ? DEFAULT_DB_MIN_CONN : request.getMinConn());
+ props.setMaxConn(request.getMaxConn() <= 0 ? DEFAULT_DB_MAX_CONN : request.getMaxConn());
+ props.setMaxWait(request.getMaxWait() == null ? 5000L : request.getMaxWait());
+ validateDynamicMysqlProperties(props, request.getUrl());
+ if (!validateDataSourceProperties(props, props.getName())) {
+ throw new IllegalArgumentException("Business DataSource Properties has failure");
+ }
+ return props;
+ }
+
+ public synchronized String unregisterMysqlDataSource(String name) {
+ if (!dynamicRegistrationEnabled) {
+ throw new IllegalArgumentException("Dynamic business data source registration is disabled");
+ }
+ if (!StringUtils.hasText(name)) {
+ throw new IllegalArgumentException("The data source name cannot be empty");
+ }
+ String resourceId = DATA_SOURCES_NAMES_AND_RESOURCE_IDS.get(name);
+ if (!StringUtils.hasText(resourceId) || !DYNAMIC_RESOURCE_IDS.contains(resourceId)) {
+ throw new IllegalArgumentException("Dynamic data source is not registered: " + name);
+ }
+ DATASOURCES.remove(resourceId);
+ DATA_SOURCES_NAMES_AND_RESOURCE_IDS.remove(name);
+ DYNAMIC_RESOURCE_IDS.remove(resourceId);
+ return resourceId;
+ }
+
+ public List getMysqlDataSourceInfos() {
+ return DATA_SOURCES_NAMES_AND_RESOURCE_IDS.entrySet().stream()
+ .map(entry -> toInfo(entry.getKey(), DATASOURCES.get(entry.getValue())))
+ .filter(info -> info != null)
+ .collect(Collectors.toList());
+ }
+
+ public String getDatabaseName(String resourceId) {
+ DataSourceProperties props = DATASOURCES.get(resourceId);
+ if (props == null) {
+ throw new IllegalArgumentException("Cannot find datasource properties: " + resourceId);
+ }
+ return props.getDatabaseName();
+ }
+
+ private MysqlDataSourceInfo toInfo(String name, DataSourceProperties props) {
+ if (props == null) {
+ return null;
+ }
+ MysqlDataSourceInfo info = new MysqlDataSourceInfo();
+ info.setName(name);
+ info.setResourceId(props.getResourceId());
+ info.setDatabaseName(props.getDatabaseName());
+ info.setDatasource(props.getDatasource());
+ info.setDynamic(props.isDynamic());
+ info.setEnabled(props.isEnabled());
+ return info;
+ }
+
+ private String resolvePassword(MysqlDataSourceRegisterRequest request) {
+ if (StringUtils.hasText(request.getPassword())) {
+ return request.getPassword();
+ }
+ return secretResolver.resolve(request.getPasswordSecretRef());
+ }
+
+ private boolean validateDataSourceProperties(DataSourceProperties props, String dataSourceName) {
+ if (props == null) {
+ LOGGER.error("DataSource configuration cannot be null for: {}", dataSourceName);
+ return false;
+ }
+ if (!StringUtils.hasText(props.getName())) {
+ LOGGER.error("DataSource name cannot be empty");
+ return false;
+ }
+ if (!DATASOURCE_NAME_PATTERN.matcher(props.getName()).matches()) {
+ LOGGER.error("DataSource name contains unsupported characters");
+ return false;
+ }
+ if (!StringUtils.hasText(props.getUrl())) {
+ LOGGER.error("Database URL cannot be empty for datasource: {}", dataSourceName);
+ return false;
+ }
+ if (!StringUtils.hasText(props.getUsername())) {
+ LOGGER.error("Database username cannot be empty for datasource: {}", dataSourceName);
+ return false;
+ }
+ if (!StringUtils.hasText(props.getPassword())) {
+ LOGGER.error("Database password cannot be empty for datasource: {}", dataSourceName);
+ return false;
+ }
+ if (!MYSQL_DB_TYPE.equalsIgnoreCase(props.getDbType())) {
+ LOGGER.error("Only MySQL business data source is supported: {}", dataSourceName);
+ return false;
+ }
+ if (!MYSQL_DRIVER_CLASS_NAME.equals(props.getDriverClassName())) {
+ LOGGER.error("Only MySQL 8 driver is supported for datasource: {}", dataSourceName);
+ return false;
+ }
+ try {
+ if (!StringUtils.hasText(props.getDatabaseName())) {
+ props.setDatabaseName(parseMysqlDatabaseName(props.getUrl()));
+ }
+ } catch (IllegalArgumentException e) {
+ LOGGER.error("Invalid MySQL JDBC URL for datasource: {}", dataSourceName);
+ return false;
+ }
+ if (props.getMinConn() < 0) {
+ LOGGER.error("Minimum connection count cannot be negative for datasource: {}", dataSourceName);
+ return false;
+ }
+ if (props.getMaxConn() <= 0) {
+ LOGGER.error("Maximum connection count must be positive for datasource: {}", dataSourceName);
+ return false;
+ }
+ if (props.getMinConn() > props.getMaxConn()) {
+ LOGGER.error(
+ "Minimum connection count cannot be greater than maximum connection count for datasource: {}",
+ dataSourceName);
+ return false;
+ }
+ if (props.getMaxWait() != null && props.getMaxWait() < 0) {
+ LOGGER.error("Maximum wait time cannot be negative for datasource: {}", dataSourceName);
+ return false;
+ }
+ return true;
+ }
+
+ private void validateDynamicMysqlProperties(DataSourceProperties props, String url) {
+ if (StringUtils.isBlank(url) || !url.toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) {
+ throw new IllegalArgumentException("Only jdbc:mysql:// URL is supported");
+ }
+ if (allowedHosts.isEmpty()) {
+ throw new IllegalArgumentException("MySQL host allowlist cannot be empty for dynamic registration");
+ }
+ MysqlJdbcUrl mysqlJdbcUrl = parseMysqlJdbcUrl(url);
+ MysqlAllowedHost allowedHost = allowedHosts.get(mysqlJdbcUrl.getNormalizedAddress());
+ if (allowedHost == null) {
+ throw new IllegalArgumentException("MySQL host is not allowed: " + mysqlJdbcUrl.getHost());
+ }
+ props.setDatabaseName(mysqlJdbcUrl.getDatabaseName());
+ props.setUrl(allowedHost.toJdbcBaseUrl());
+ }
+
+ private String parseMysqlDatabaseName(String url) {
+ return parseMysqlJdbcUrl(url).getDatabaseName();
+ }
+
+ private MysqlJdbcUrl parseMysqlJdbcUrl(String url) {
+ if (!StringUtils.hasText(url) || !url.toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) {
+ throw new IllegalArgumentException("Only jdbc:mysql:// URL is supported");
+ }
+ try {
+ URI uri = URI.create(url.substring("jdbc:".length()));
+ if (StringUtils.hasText(uri.getUserInfo())) {
+ throw new IllegalArgumentException("MySQL JDBC URL cannot include user info");
+ }
+ if (StringUtils.hasText(uri.getFragment())) {
+ throw new IllegalArgumentException("MySQL JDBC URL cannot include fragment");
+ }
+ if (!StringUtils.hasText(uri.getHost())) {
+ throw new IllegalArgumentException("MySQL host cannot be empty");
+ }
+ String path = uri.getPath();
+ if (!StringUtils.hasText(path) || "/".equals(path)) {
+ throw new IllegalArgumentException("MySQL JDBC URL must include a database name");
+ }
+ String databaseName = path.startsWith("/") ? path.substring(1) : path;
+ int slashIndex = databaseName.indexOf('/');
+ if (slashIndex >= 0) {
+ databaseName = databaseName.substring(0, slashIndex);
+ }
+ if (!StringUtils.hasText(databaseName)) {
+ throw new IllegalArgumentException("MySQL JDBC URL must include a database name");
+ }
+ String normalized = databaseName.toLowerCase(Locale.ROOT);
+ if (SYSTEM_DATABASES.contains(normalized)) {
+ throw new IllegalArgumentException("MySQL JDBC URL database is not allowed: " + databaseName);
+ }
+ int port = uri.getPort() < 0 ? 3306 : uri.getPort();
+ if (port <= 0 || port > 65535) {
+ throw new IllegalArgumentException("MySQL port is invalid");
+ }
+ return new MysqlJdbcUrl(uri.getHost(), port, databaseName);
+ } catch (IllegalArgumentException e) {
+ throw e;
+ }
+ }
+
+ private Map parseAllowedHosts(String hosts) {
+ if (!StringUtils.hasText(hosts)) {
+ return Collections.emptyMap();
+ }
+ return Arrays.stream(hosts.split(","))
+ .map(String::trim)
+ .filter(StringUtils::isNotBlank)
+ .map(this::parseAllowedHost)
+ .collect(Collectors.toMap(MysqlAllowedHost::getNormalizedAddress, host -> host, (left, right) -> left));
+ }
+
+ private MysqlAllowedHost parseAllowedHost(String hostConfig) {
+ String config = hostConfig;
+ if (config.toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) {
+ MysqlJdbcUrl jdbcUrl = parseMysqlJdbcUrl(config);
+ return new MysqlAllowedHost(jdbcUrl.getHost(), jdbcUrl.getPort());
+ }
+ String host = config;
+ int port = 3306;
+ int portSeparator = config.lastIndexOf(':');
+ if (portSeparator > 0 && portSeparator < config.length() - 1 && config.indexOf(']') < portSeparator) {
+ host = config.substring(0, portSeparator);
+ port = Integer.parseInt(config.substring(portSeparator + 1));
+ }
+ if (host.startsWith("[") && host.endsWith("]")) {
+ host = host.substring(1, host.length() - 1);
+ }
+ if (!StringUtils.hasText(host) || port <= 0 || port > 65535) {
+ throw new IllegalArgumentException("Invalid MySQL host allowlist entry");
+ }
+ return new MysqlAllowedHost(host, port);
+ }
+
+ private String buildResourceId(String name) {
+ if (!StringUtils.hasText(name)) {
+ throw new IllegalArgumentException("The data source name cannot be empty");
+ }
+ return RESOURCE_ID_PREFIX + name.trim();
+ }
+
+ private Set getDataSourceNames() {
+ Set names = new HashSet<>();
+ if (env instanceof ConfigurableEnvironment) {
+ ConfigurableEnvironment configEnv = (ConfigurableEnvironment) env;
+ Set processedNames = new HashSet<>();
+ for (PropertySource> propertySource : configEnv.getPropertySources()) {
+ if (propertySource instanceof EnumerablePropertySource) {
+ EnumerablePropertySource> enumSource = (EnumerablePropertySource>) propertySource;
+ for (String propertyName : enumSource.getPropertyNames()) {
+ if (propertyName.startsWith(BASE_PREFIX)) {
+ String[] parts = propertyName.split("\\.");
+ if (parts.length > 3) {
+ String dsName = parts[2];
+ if (!processedNames.contains(dsName)
+ && env.containsProperty(BASE_PREFIX + dsName + ".url")) {
+ names.add(dsName);
+ processedNames.add(dsName);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ return names;
+ }
+
+ public static Map getDatasources() {
+ return DATASOURCES;
+ }
+
+ public static Map getDataSourcesNamesAndResourceIds() {
+ return DATA_SOURCES_NAMES_AND_RESOURCE_IDS;
+ }
+
+ public static Set getResourceIds() {
+ return DATASOURCES.keySet();
+ }
+
+ public static Set getDynamicResourceIds() {
+ return DYNAMIC_RESOURCE_IDS;
+ }
+
+ static void clear() {
+ DATASOURCES.clear();
+ DATA_SOURCES_NAMES_AND_RESOURCE_IDS.clear();
+ DYNAMIC_RESOURCE_IDS.clear();
+ }
+
+ private static class MysqlJdbcUrl {
+ private final String host;
+ private final int port;
+ private final String databaseName;
+
+ MysqlJdbcUrl(String host, int port, String databaseName) {
+ this.host = host;
+ this.port = port;
+ this.databaseName = databaseName;
+ }
+
+ String getHost() {
+ return host;
+ }
+
+ int getPort() {
+ return port;
+ }
+
+ String getNormalizedAddress() {
+ return normalizeAddress(host, port);
+ }
+
+ String getDatabaseName() {
+ return databaseName;
+ }
+ }
+
+ private static class MysqlAllowedHost {
+ private final String host;
+ private final int port;
+
+ MysqlAllowedHost(String host, int port) {
+ this.host = host;
+ this.port = port;
+ }
+
+ String getNormalizedAddress() {
+ return normalizeAddress(host, port);
+ }
+
+ String toJdbcBaseUrl() {
+ StringBuilder builder = new StringBuilder("jdbc:mysql://")
+ .append(formatHost(host))
+ .append(":")
+ .append(port);
+ return builder.toString();
+ }
+
+ private String formatHost(String host) {
+ if (host.indexOf(':') >= 0 && !host.startsWith("[") && !host.endsWith("]")) {
+ return "[" + host + "]";
+ }
+ return host;
+ }
+ }
+
+ private static String normalizeAddress(String host, int port) {
+ return host.toLowerCase(Locale.ROOT) + ":" + port;
+ }
+
+ public static class DataSourceProperties {
+ private boolean enabled = true;
+ private boolean dynamic;
+ private String name;
+ private String resourceId;
+ private String databaseName;
+ private String dbType = MYSQL_DB_TYPE;
+ private String driverClassName = MYSQL_DRIVER_CLASS_NAME;
+ private String url = "";
+ private String username = "";
+ private String password = "";
+ private String passwordSecretRef = "";
+ private String datasource = "druid";
+ private int minConn = DEFAULT_DB_MIN_CONN;
+ private int maxConn = DEFAULT_DB_MAX_CONN;
+ private Long maxWait = 5000L;
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public boolean isDynamic() {
+ return dynamic;
+ }
+
+ public void setDynamic(boolean dynamic) {
+ this.dynamic = dynamic;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getResourceId() {
+ return resourceId;
+ }
+
+ public void setResourceId(String resourceId) {
+ this.resourceId = resourceId;
+ }
+
+ public String getDatabaseName() {
+ return databaseName;
+ }
+
+ public void setDatabaseName(String databaseName) {
+ this.databaseName = databaseName;
+ }
+
+ public Long getMaxWait() {
+ return maxWait;
+ }
+
+ public void setMaxWait(Long maxWait) {
+ this.maxWait = maxWait;
+ }
+
+ public String getDbType() {
+ return dbType;
+ }
+
+ public void setDbType(String dbType) {
+ this.dbType = dbType;
+ }
+
+ public String getDriverClassName() {
+ return driverClassName;
+ }
+
+ public void setDriverClassName(String driverClassName) {
+ this.driverClassName = driverClassName;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public String getPasswordSecretRef() {
+ return passwordSecretRef;
+ }
+
+ public void setPasswordSecretRef(String passwordSecretRef) {
+ this.passwordSecretRef = passwordSecretRef;
+ }
+
+ public String getDatasource() {
+ return datasource;
+ }
+
+ public void setDatasource(String datasource) {
+ this.datasource = datasource;
+ }
+
+ public int getMinConn() {
+ return minConn;
+ }
+
+ public void setMinConn(int minConn) {
+ this.minConn = minConn;
+ }
+
+ public int getMaxConn() {
+ return maxConn;
+ }
+
+ public void setMaxConn(int maxConn) {
+ this.maxConn = maxConn;
+ }
+ }
+}
diff --git a/console/src/main/java/org/apache/seata/mcp/core/secret/EnvSecretResolver.java b/console/src/main/java/org/apache/seata/mcp/core/secret/EnvSecretResolver.java
new file mode 100644
index 00000000000..64e374b1362
--- /dev/null
+++ b/console/src/main/java/org/apache/seata/mcp/core/secret/EnvSecretResolver.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.mcp.core.secret;
+
+import org.apache.seata.common.util.StringUtils;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Component;
+
+@Component
+public class EnvSecretResolver implements SecretResolver {
+
+ private final Environment environment;
+
+ public EnvSecretResolver(Environment environment) {
+ this.environment = environment;
+ }
+
+ @Override
+ public String resolve(String secretRef) {
+ if (!StringUtils.hasText(secretRef)) {
+ throw new IllegalArgumentException("passwordSecretRef cannot be empty");
+ }
+ String secret = environment.getProperty(secretRef);
+ if (!StringUtils.hasText(secret)) {
+ secret = System.getenv(secretRef);
+ }
+ if (!StringUtils.hasText(secret)) {
+ throw new IllegalArgumentException("Unable to resolve password secret: " + secretRef);
+ }
+ return secret;
+ }
+}
diff --git a/console/src/main/java/org/apache/seata/mcp/core/secret/SecretResolver.java b/console/src/main/java/org/apache/seata/mcp/core/secret/SecretResolver.java
new file mode 100644
index 00000000000..e4b9fa38bfc
--- /dev/null
+++ b/console/src/main/java/org/apache/seata/mcp/core/secret/SecretResolver.java
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.mcp.core.secret;
+
+public interface SecretResolver {
+
+ String resolve(String secretRef);
+}
diff --git a/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java b/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java
index 237ec494e8f..f19e5471f53 100644
--- a/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java
+++ b/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java
@@ -70,7 +70,9 @@ public static String convertToDateTimeFromTimestamp(Long timestamp) {
}
public static boolean judgeExceedTimeDuration(Long startTime, Long endTime, Long maxDuration) {
- if (endTime < startTime) throw new IllegalArgumentException("endTime must not be earlier than startTime");
+ if (endTime < startTime) {
+ throw new IllegalArgumentException("endTime must not be earlier than startTime");
+ }
return endTime - startTime > maxDuration;
}
diff --git a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java
new file mode 100644
index 00000000000..901daf7116a
--- /dev/null
+++ b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.mcp.entity.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class MysqlDataSourceRegisterRequest {
+
+ private String name;
+ private String url;
+ private String username;
+
+ @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
+ private String password;
+
+ private String passwordSecretRef;
+ private String datasource = "druid";
+ private int minConn = 10;
+ private int maxConn = 100;
+ private Long maxWait = 5000L;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public String getPasswordSecretRef() {
+ return passwordSecretRef;
+ }
+
+ public void setPasswordSecretRef(String passwordSecretRef) {
+ this.passwordSecretRef = passwordSecretRef;
+ }
+
+ public String getDatasource() {
+ return datasource;
+ }
+
+ public void setDatasource(String datasource) {
+ this.datasource = datasource;
+ }
+
+ public int getMinConn() {
+ return minConn;
+ }
+
+ public void setMinConn(int minConn) {
+ this.minConn = minConn;
+ }
+
+ public int getMaxConn() {
+ return maxConn;
+ }
+
+ public void setMaxConn(int maxConn) {
+ this.maxConn = maxConn;
+ }
+
+ public Long getMaxWait() {
+ return maxWait;
+ }
+
+ public void setMaxWait(Long maxWait) {
+ this.maxWait = maxWait;
+ }
+}
diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/BusinessQueryResult.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/BusinessQueryResult.java
new file mode 100644
index 00000000000..d18484e69e7
--- /dev/null
+++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/BusinessQueryResult.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.mcp.entity.vo;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class BusinessQueryResult {
+
+ private List columns = new ArrayList<>();
+ private List