diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/DataAgentApplication.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/DataAgentApplication.java index 05fe43c60..f88fbcf2d 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/DataAgentApplication.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/DataAgentApplication.java @@ -15,11 +15,14 @@ */ package com.alibaba.cloud.ai.dataagent; +import com.alibaba.cloud.ai.dataagent.config.EncryptionProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling +@EnableConfigurationProperties(EncryptionProperties.class) @SpringBootApplication public class DataAgentApplication { diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/EncryptionProperties.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/EncryptionProperties.java new file mode 100644 index 000000000..9a7a03954 --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/EncryptionProperties.java @@ -0,0 +1,65 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed 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 + * + * https://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 com.alibaba.cloud.ai.dataagent.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Encryption configuration properties for sensitive data storage. + * + *

+ * The encryption key must be a 32-byte AES key encoded as Base64. Set via environment + * variable {@code DATA_AGENT_ENCRYPT_KEY} or configuration property + * {@code spring.ai.alibaba.data-agent.encrypt-key}. + *

+ * + *

+ * Backward Compatibility: When {@code encrypt-key} is not configured + * (empty/null), encryption is completely disabled and the system behaves identically to + * the pre-encryption version. This allows: + *

+ * + * + *

+ * To generate a new key: {@code openssl rand -base64 32} + *

+ */ +@Data +@ConfigurationProperties(prefix = "spring.ai.alibaba.data-agent") +public class EncryptionProperties { + + /** + * Base64-encoded 32-byte AES key for encrypting sensitive data. If not set, + * encryption is disabled (NOT recommended for production). + */ + private String encryptKey; + + /** + * Check if encryption is enabled (key is configured). + * @return true if encryptKey is set and non-empty + */ + public boolean isEncryptionEnabled() { + return encryptKey != null && !encryptKey.isEmpty(); + } + +} diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java index bac38186e..02e4bd404 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java @@ -15,11 +15,13 @@ */ package com.alibaba.cloud.ai.dataagent.service.aimodelconfig; +import com.alibaba.cloud.ai.dataagent.config.EncryptionProperties; import com.alibaba.cloud.ai.dataagent.enums.ModelType; import com.alibaba.cloud.ai.dataagent.converter.ModelConfigConverter; import com.alibaba.cloud.ai.dataagent.dto.ModelConfigDTO; import com.alibaba.cloud.ai.dataagent.entity.ModelConfig; import com.alibaba.cloud.ai.dataagent.mapper.ModelConfigMapper; +import com.alibaba.cloud.ai.dataagent.util.CryptoUtil; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -39,9 +41,15 @@ public class ModelConfigDataServiceImpl implements ModelConfigDataService { private final ModelConfigMapper modelConfigMapper; + private final EncryptionProperties encryptionProperties; + @Override public ModelConfig findById(Integer id) { - return modelConfigMapper.findById(id); + ModelConfig config = modelConfigMapper.findById(id); + if (config != null) { + decryptModelConfig(config); + } + return config; } @Transactional(rollbackFor = Exception.class) @@ -61,14 +69,21 @@ public void switchActiveStatus(Integer id, ModelType type) { @Override public List listConfigs() { - return modelConfigMapper.findAll().stream().map(ModelConfigConverter::toDTO).collect(Collectors.toList()); + return modelConfigMapper.findAll() + .stream() + .peek(this::decryptModelConfig) + .map(ModelConfigConverter::toDTO) + .collect(Collectors.toList()); } @Override public void addConfig(ModelConfigDTO dto) { clean(dto); + // Encrypt sensitive fields before saving + ModelConfig entity = toEntity(dto); + encryptModelConfig(entity); // 只存库,不切换 - modelConfigMapper.insert(toEntity(dto)); + modelConfigMapper.insert(entity); } private void clean(ModelConfigDTO dto) { @@ -104,7 +119,10 @@ public ModelConfig updateConfigInDb(ModelConfigDTO dto) { mergeDtoToEntity(dto, entity); entity.setUpdatedTime(LocalDateTime.now()); - // 3. 更新数据库 + // 3. Encrypt sensitive fields before updating + encryptModelConfig(entity); + + // 4. 更新数据库 modelConfigMapper.updateById(entity); return entity; @@ -160,7 +178,67 @@ public ModelConfigDTO getActiveConfigByType(ModelType modelType) { log.warn("Activation model configuration of type [{}] not found, attempting to downgrade...", modelType); return null; } + decryptModelConfig(entity); return toDTO(entity); } + /** + * Encrypt sensitive fields of a model config before persisting. + * + *

+ * This method is called during add/update operations. When encryption is enabled, + * apiKey and proxyPassword are encrypted before being saved to database. + *

+ * @param config the model config to encrypt + * @see CryptoUtil#encrypt(String, String) + */ + private void encryptModelConfig(ModelConfig config) { + if (!encryptionProperties.isEncryptionEnabled()) { + // Encryption not configured, skip (backward compatible) + return; + } + String key = encryptionProperties.getEncryptKey(); + if (config.getApiKey() != null && !config.getApiKey().isEmpty()) { + config.setApiKey(CryptoUtil.encrypt(config.getApiKey(), key)); + } + if (config.getProxyPassword() != null && !config.getProxyPassword().isEmpty()) { + config.setProxyPassword(CryptoUtil.encrypt(config.getProxyPassword(), key)); + } + } + + /** + * Decrypt sensitive fields of a model config after loading from database. + * + *

+ * Backward Compatibility: Uses + * {@link CryptoUtil#decryptOrReturn(String, String)} to handle both encrypted and + * plaintext data gracefully: + *

+ * + * + *

+ * This ensures a smooth upgrade path: after enabling encryption, existing plaintext + * data remains readable, and new data will be encrypted on write. + *

+ * @param config the model config to decrypt + * @see CryptoUtil#decryptOrReturn(String, String) + */ + private void decryptModelConfig(ModelConfig config) { + if (!encryptionProperties.isEncryptionEnabled()) { + // Encryption not configured, data is plaintext, skip + return; + } + String key = encryptionProperties.getEncryptKey(); + if (config.getApiKey() != null && !config.getApiKey().isEmpty()) { + config.setApiKey(CryptoUtil.decryptOrReturn(config.getApiKey(), key)); + } + if (config.getProxyPassword() != null && !config.getProxyPassword().isEmpty()) { + config.setProxyPassword(CryptoUtil.decryptOrReturn(config.getProxyPassword(), key)); + } + } + } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImpl.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImpl.java index 7f7c4820a..7385cd6c0 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImpl.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImpl.java @@ -24,6 +24,7 @@ import com.alibaba.cloud.ai.dataagent.connector.pool.DBConnectionPool; import com.alibaba.cloud.ai.dataagent.connector.pool.DBConnectionPoolFactory; import com.alibaba.cloud.ai.dataagent.entity.AgentDatasource; +import com.alibaba.cloud.ai.dataagent.config.EncryptionProperties; import com.alibaba.cloud.ai.dataagent.entity.Datasource; import com.alibaba.cloud.ai.dataagent.entity.LogicalRelation; import com.alibaba.cloud.ai.dataagent.enums.ErrorCodeEnum; @@ -33,6 +34,7 @@ import com.alibaba.cloud.ai.dataagent.service.datasource.DatasourceService; import com.alibaba.cloud.ai.dataagent.service.datasource.handler.DatasourceTypeHandler; import com.alibaba.cloud.ai.dataagent.service.datasource.handler.registry.DatasourceTypeHandlerRegistry; +import com.alibaba.cloud.ai.dataagent.util.CryptoUtil; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -64,24 +66,36 @@ public class DatasourceServiceImpl implements DatasourceService { private final DatasourceTypeHandlerRegistry datasourceTypeHandlerRegistry; + private final EncryptionProperties encryptionProperties; + @Override public List getAllDatasource() { - return datasourceMapper.selectAll(); + List list = datasourceMapper.selectAll(); + list.forEach(this::decryptDatasourcePassword); + return list; } @Override public List getDatasourceByStatus(String status) { - return datasourceMapper.selectByStatus(status); + List list = datasourceMapper.selectByStatus(status); + list.forEach(this::decryptDatasourcePassword); + return list; } @Override public List getDatasourceByType(String type) { - return datasourceMapper.selectByType(type); + List list = datasourceMapper.selectByType(type); + list.forEach(this::decryptDatasourcePassword); + return list; } @Override public Datasource getDatasourceById(Integer id) { - return datasourceMapper.selectById(id); + Datasource datasource = datasourceMapper.selectById(id); + if (datasource != null) { + decryptDatasourcePassword(datasource); + } + return datasource; } @Override @@ -109,6 +123,9 @@ public Datasource createDatasource(Datasource datasource) { datasource.setUsername(""); } + // Encrypt sensitive fields before saving + encryptDatasource(datasource); + datasourceMapper.insert(datasource); return datasource; } @@ -139,6 +156,9 @@ public Datasource updateDatasource(Integer id, Datasource datasource) { datasource.setUsername(""); } + // Encrypt sensitive fields before updating + encryptDatasource(datasource); + datasourceMapper.updateById(datasource); return datasource; } @@ -463,4 +483,63 @@ public List saveLogicalRelations(Integer datasourceId, List + * This method is called during create/update operations. When encryption is enabled, + * password and connectionUrl are encrypted before being saved to database. + *

+ * @param datasource the datasource to encrypt + * @see CryptoUtil#encrypt(String, String) + */ + private void encryptDatasource(Datasource datasource) { + if (!encryptionProperties.isEncryptionEnabled()) { + // Encryption not configured, skip (backward compatible) + return; + } + String key = encryptionProperties.getEncryptKey(); + if (datasource.getPassword() != null && !datasource.getPassword().isEmpty()) { + datasource.setPassword(CryptoUtil.encrypt(datasource.getPassword(), key)); + } + if (datasource.getConnectionUrl() != null && !datasource.getConnectionUrl().isEmpty()) { + datasource.setConnectionUrl(CryptoUtil.encrypt(datasource.getConnectionUrl(), key)); + } + } + + /** + * Decrypt sensitive fields of a datasource after loading from database. + * + *

+ * Backward Compatibility: Uses + * {@link CryptoUtil#decryptOrReturn(String, String)} to handle both encrypted and + * plaintext data gracefully: + *

+ *
    + *
  • If data is encrypted (Base64 with valid GCM structure) → decrypt it
  • + *
  • If data is plaintext (legacy unencrypted) → return as-is
  • + *
  • If encryption is not configured → skip decryption entirely
  • + *
+ * + *

+ * This ensures a smooth upgrade path: after enabling encryption, existing plaintext + * data remains readable, and new data will be encrypted on write. + *

+ * @param datasource the datasource to decrypt + * @see CryptoUtil#decryptOrReturn(String, String) + */ + private void decryptDatasourcePassword(Datasource datasource) { + if (!encryptionProperties.isEncryptionEnabled()) { + // Encryption not configured, data is plaintext, skip + return; + } + String key = encryptionProperties.getEncryptKey(); + if (datasource.getPassword() != null && !datasource.getPassword().isEmpty()) { + datasource.setPassword(CryptoUtil.decryptOrReturn(datasource.getPassword(), key)); + } + if (datasource.getConnectionUrl() != null && !datasource.getConnectionUrl().isEmpty()) { + datasource.setConnectionUrl(CryptoUtil.decryptOrReturn(datasource.getConnectionUrl(), key)); + } + } + } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtil.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtil.java new file mode 100644 index 000000000..9b15a0e39 --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtil.java @@ -0,0 +1,225 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed 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 + * + * https://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 com.alibaba.cloud.ai.dataagent.util; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * AES-256-GCM encryption/decryption utility for sensitive data storage. + * + *

+ * Encryption key must be 32 bytes, provided as Base64-encoded string via environment + * variable {@code DATA_AGENT_ENCRYPT_KEY}. + *

+ * + *

+ * Ciphertext format: {@code Base64(iv + encryptedData + authTag)}, where iv is 12 bytes, + * authTag is 16 bytes. + *

+ */ +public final class CryptoUtil { + + private static final String ALGORITHM = "AES"; + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + + private static final int GCM_IV_LENGTH = 12; + + private static final int GCM_TAG_LENGTH = 128; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private CryptoUtil() { + } + + /** + * Encrypt plaintext using AES-256-GCM. + * @param plaintext the text to encrypt + * @param keyBase64 Base64-encoded 32-byte AES key + * @return Base64-encoded ciphertext, or null/empty if input is null/empty + */ + public static String encrypt(String plaintext, String keyBase64) { + if (plaintext == null || plaintext.isEmpty()) { + return plaintext; + } + if (keyBase64 == null || keyBase64.isEmpty()) { + throw new IllegalArgumentException("Encryption key must not be null or empty"); + } + try { + byte[] keyBytes = Base64.getDecoder().decode(keyBase64); + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); + + byte[] iv = new byte[GCM_IV_LENGTH]; + SECURE_RANDOM.nextBytes(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); + + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + // Pack iv + ciphertext into single byte array + ByteBuffer buffer = ByteBuffer.allocate(iv.length + encrypted.length); + buffer.put(iv); + buffer.put(encrypted); + + return Base64.getEncoder().encodeToString(buffer.array()); + } + catch (Exception e) { + throw new RuntimeException("Failed to encrypt data", e); + } + } + + /** + * Decrypt ciphertext using AES-256-GCM. + * @param ciphertext Base64-encoded ciphertext from {@link #encrypt(String, String)} + * @param keyBase64 Base64-encoded 32-byte AES key + * @return decrypted plaintext, or null/empty if input is null/empty + */ + public static String decrypt(String ciphertext, String keyBase64) { + if (ciphertext == null || ciphertext.isEmpty()) { + return ciphertext; + } + if (keyBase64 == null || keyBase64.isEmpty()) { + throw new IllegalArgumentException("Encryption key must not be null or empty"); + } + try { + byte[] keyBytes = Base64.getDecoder().decode(keyBase64); + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); + + byte[] decoded = Base64.getDecoder().decode(ciphertext); + ByteBuffer buffer = ByteBuffer.wrap(decoded); + + byte[] iv = new byte[GCM_IV_LENGTH]; + buffer.get(iv); + + byte[] encrypted = new byte[buffer.remaining()]; + buffer.get(encrypted); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); + + byte[] decrypted = cipher.doFinal(encrypted); + return new String(decrypted, StandardCharsets.UTF_8); + } + catch (Exception e) { + throw new RuntimeException("Failed to decrypt data", e); + } + } + + /** + * Generate a random 32-byte AES key, returned as Base64 string. Useful for initial + * setup. + * @return Base64-encoded 32-byte key + */ + public static String generateKey() { + byte[] key = new byte[32]; + SECURE_RANDOM.nextBytes(key); + return Base64.getEncoder().encodeToString(key); + } + + /** + * Check if a ciphertext string is already encrypted (Base64-encoded with valid + * length). This is a heuristic check, not cryptographic verification. + * @param value the string to check + * @return true if the value appears to be encrypted + */ + public static boolean isEncrypted(String value) { + if (value == null || value.isEmpty()) { + return false; + } + try { + byte[] decoded = Base64.getDecoder().decode(value); + // Minimum: 12 (iv) + 16 (tag) + 1 (data) = 29 bytes + return decoded.length >= GCM_IV_LENGTH + GCM_TAG_LENGTH / 8 + 1; + } + catch (IllegalArgumentException e) { + return false; + } + } + + /** + * Decrypt ciphertext if it's encrypted, otherwise return as-is. This method provides + * backward compatibility for data that was stored before encryption was enabled. + * + *

+ * Backward Compatibility Strategy: + *

+ *
    + *
  • When encryption is first enabled, existing plaintext data in database remains + * readable
  • + *
  • On read: encrypted data is decrypted, plaintext data passes through + * unchanged
  • + *
  • On write: all new data is encrypted (if key is configured)
  • + *
  • Over time, as data is updated, plaintext data is gradually replaced with + * encrypted data
  • + *
+ * + *

+ * Detection Logic: Uses {@link #isEncrypted(String)} to check if the + * value is valid Base64-encoded ciphertext with minimum GCM structure (12-byte IV + + * 16-byte auth tag + at least 1 byte data = 29 bytes minimum). + *

+ * @param value the value to decrypt (may be encrypted or plaintext), null-safe + * @param keyBase64 Base64-encoded 32-byte AES key + * @return decrypted value if encrypted, or original value if plaintext/null/empty + */ + public static String decryptOrReturn(String value, String keyBase64) { + if (value == null || value.isEmpty()) { + return value; + } + // Check if the value looks like encrypted data before attempting decryption + // This prevents decryption failures on legacy plaintext data + if (isEncrypted(value)) { + try { + return decrypt(value, keyBase64); + } + catch (RuntimeException e) { + // If decryption fails (e.g., wrong key), return original value + // This handles edge cases where plaintext data happens to pass + // isEncrypted check + return value; + } + } + // Not encrypted, return as-is (backward compatible with historical plaintext + // data) + return value; + } + + /** + * Main method for generating encryption key from command line. + * @param args unused + */ + public static void main(String[] args) { + System.out.println("=== DataAgent Encryption Key Generator ==="); + System.out.println(); + String key = generateKey(); + System.out.println("Generated AES-256 Key (Base64): " + key); + System.out.println(); + System.out.println("Set this as environment variable:"); + System.out.println(" Linux/Mac: export DATA_AGENT_ENCRYPT_KEY=" + key); + System.out.println(" Windows: set DATA_AGENT_ENCRYPT_KEY=" + key); + System.out.println(" PowerShell: $env:DATA_AGENT_ENCRYPT_KEY=\"" + key + "\""); + } + +} diff --git a/data-agent-management/src/main/resources/application-h2.yml b/data-agent-management/src/main/resources/application-h2.yml index ac6c78d46..ad5916038 100644 --- a/data-agent-management/src/main/resources/application-h2.yml +++ b/data-agent-management/src/main/resources/application-h2.yml @@ -55,6 +55,9 @@ spring: endpoint: ${OSS_ENDPOINT:} bucket-name: ${OSS_BUCKET_NAME:} custom-domain: ${OSS_CUSTOM_DOMAIN:} + # 敏感数据加密密钥(AES-256-GCM,Base64编码的32字节密钥) + # 开发环境使用默认测试密钥,生产环境必须通过环境变量设置 + encrypt-key: ${DATA_AGENT_ENCRYPT_KEY:dGVzdEVuY3J5cHRpb25LZXlGb3JEZXYxMjM0NTY3ODkw} max-sql-retry-count: 10 # Langfuse 可观测性配置 langfuse: diff --git a/data-agent-management/src/main/resources/application.yml b/data-agent-management/src/main/resources/application.yml index 30f606553..775baa55b 100644 --- a/data-agent-management/src/main/resources/application.yml +++ b/data-agent-management/src/main/resources/application.yml @@ -52,6 +52,9 @@ spring: endpoint: ${OSS_ENDPOINT:} bucket-name: ${OSS_BUCKET_NAME:} custom-domain: ${OSS_CUSTOM_DOMAIN:} + # 敏感数据加密密钥(AES-256-GCM,Base64编码的32字节密钥) + # 生产环境必须通过环境变量 DATA_AGENT_ENCRYPT_KEY 设置 + encrypt-key: ${DATA_AGENT_ENCRYPT_KEY:} max-sql-retry-count: 10 # Langfuse 可观测性配置 langfuse: diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImplTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImplTest.java index ddfccfd08..55a298b7d 100644 --- a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImplTest.java +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImplTest.java @@ -15,6 +15,7 @@ */ package com.alibaba.cloud.ai.dataagent.service.aimodelconfig; +import com.alibaba.cloud.ai.dataagent.config.EncryptionProperties; import com.alibaba.cloud.ai.dataagent.dto.ModelConfigDTO; import com.alibaba.cloud.ai.dataagent.entity.ModelConfig; import com.alibaba.cloud.ai.dataagent.enums.ModelType; @@ -30,6 +31,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) class ModelConfigDataServiceImplTest { @@ -39,9 +41,14 @@ class ModelConfigDataServiceImplTest { @Mock private ModelConfigMapper modelConfigMapper; + @Mock + private EncryptionProperties encryptionProperties; + @BeforeEach void setUp() { - service = new ModelConfigDataServiceImpl(modelConfigMapper); + // Disable encryption by default in tests + lenient().when(encryptionProperties.isEncryptionEnabled()).thenReturn(false); + service = new ModelConfigDataServiceImpl(modelConfigMapper, encryptionProperties); } @Test diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImplTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImplTest.java index 9e46534ab..67b4dd545 100644 --- a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImplTest.java +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImplTest.java @@ -15,6 +15,7 @@ */ package com.alibaba.cloud.ai.dataagent.service.datasource.impl; +import com.alibaba.cloud.ai.dataagent.config.EncryptionProperties; import com.alibaba.cloud.ai.dataagent.connector.accessor.AccessorFactory; import com.alibaba.cloud.ai.dataagent.connector.pool.DBConnectionPool; import com.alibaba.cloud.ai.dataagent.connector.pool.DBConnectionPoolFactory; @@ -38,6 +39,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) class DatasourceServiceImplTest { @@ -68,10 +70,15 @@ class DatasourceServiceImplTest { @Mock private DBConnectionPool dbConnectionPool; + @Mock + private EncryptionProperties encryptionProperties; + @BeforeEach void setUp() { + // Disable encryption by default in tests + lenient().when(encryptionProperties.isEncryptionEnabled()).thenReturn(false); datasourceService = new DatasourceServiceImpl(datasourceMapper, agentDatasourceMapper, logicalRelationMapper, - poolFactory, accessorFactory, handlerRegistry); + poolFactory, accessorFactory, handlerRegistry, encryptionProperties); } @Test diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtilTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtilTest.java new file mode 100644 index 000000000..ed86f1434 --- /dev/null +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtilTest.java @@ -0,0 +1,233 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed 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 + * + * https://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 com.alibaba.cloud.ai.dataagent.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link CryptoUtil}. + */ +class CryptoUtilTest { + + private String testKey; + + @BeforeEach + void setUp() { + // Generate a fresh key for each test + testKey = CryptoUtil.generateKey(); + } + + @Test + void testEncryptDecryptRoundTrip() { + String plaintext = "mySecretPassword123!"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + + assertNotEquals(plaintext, encrypted, "Ciphertext should differ from plaintext"); + assertEquals(plaintext, decrypted, "Decrypted text should match original"); + } + + @Test + void testEncryptNullReturnsNull() { + assertNull(CryptoUtil.encrypt(null, testKey)); + } + + @Test + void testEncryptEmptyReturnsEmpty() { + assertEquals("", CryptoUtil.encrypt("", testKey)); + } + + @Test + void testDecryptNullReturnsNull() { + assertNull(CryptoUtil.decrypt(null, testKey)); + } + + @Test + void testDecryptEmptyReturnsEmpty() { + assertEquals("", CryptoUtil.decrypt("", testKey)); + } + + @Test + void testEncryptProducesDifferentCiphertextEachTime() { + String plaintext = "samePassword"; + String encrypted1 = CryptoUtil.encrypt(plaintext, testKey); + String encrypted2 = CryptoUtil.encrypt(plaintext, testKey); + + // IV is random, so ciphertexts should differ + assertNotEquals(encrypted1, encrypted2, "Same plaintext should produce different ciphertexts due to random IV"); + } + + @Test + void testDecryptWithWrongKeyFails() { + String plaintext = "myPassword"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + String wrongKey = CryptoUtil.generateKey(); + + assertThrows(RuntimeException.class, () -> CryptoUtil.decrypt(encrypted, wrongKey)); + } + + @Test + void testDecryptTamperedCiphertextFails() { + String plaintext = "myPassword"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + // Tamper with the ciphertext + String tampered = encrypted.substring(0, encrypted.length() - 2) + "XX"; + + assertThrows(RuntimeException.class, () -> CryptoUtil.decrypt(tampered, testKey)); + } + + @Test + void testEncryptWithNullKeyThrows() { + assertThrows(IllegalArgumentException.class, () -> CryptoUtil.encrypt("test", null)); + } + + @Test + void testEncryptWithEmptyKeyThrows() { + assertThrows(IllegalArgumentException.class, () -> CryptoUtil.encrypt("test", "")); + } + + @Test + void testDecryptWithNullKeyThrows() { + assertThrows(IllegalArgumentException.class, () -> CryptoUtil.decrypt("test", null)); + } + + @Test + void testGenerateKeyProducesValidBase64() { + String key = CryptoUtil.generateKey(); + // Base64 encoded 32 bytes should be 44 characters + assertEquals(44, key.length()); + // Should not throw when decoding + java.util.Base64.getDecoder().decode(key); + } + + @Test + void testIsEncryptedWithValidCiphertext() { + String encrypted = CryptoUtil.encrypt("test", testKey); + assertTrue(CryptoUtil.isEncrypted(encrypted)); + } + + @Test + void testIsEncryptedWithPlaintext() { + assertFalse(CryptoUtil.isEncrypted("plaintext")); + assertFalse(CryptoUtil.isEncrypted("password123")); + } + + @Test + void testIsEncryptedWithNull() { + assertFalse(CryptoUtil.isEncrypted(null)); + } + + @Test + void testIsEncryptedWithEmpty() { + assertFalse(CryptoUtil.isEncrypted("")); + } + + @Test + void testEncryptDecryptSpecialCharacters() { + String plaintext = "p@$$w0rd!#$%^&*()_+-=[]{}|;':\",./<>?"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + assertEquals(plaintext, decrypted); + } + + @Test + void testEncryptDecryptUnicode() { + String plaintext = "密码测试🔐🔑"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + assertEquals(plaintext, decrypted); + } + + @Test + void testEncryptDecryptLongString() { + String plaintext = "A".repeat(10000); + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + assertEquals(plaintext, decrypted); + } + + @Test + void testEncryptDecryptConnectionString() { + String url = "jdbc:mysql://192.168.1.100:3306/mydb?useSSL=true&password=secret123"; + String encrypted = CryptoUtil.encrypt(url, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + assertEquals(url, decrypted); + } + + @Test + void testEncryptDecryptApiKey() { + String apiKey = "sk-abcdefghijklmnopqrstuvwxyz123456"; + String encrypted = CryptoUtil.encrypt(apiKey, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + assertEquals(apiKey, decrypted); + } + + // Tests for decryptOrReturn (backward compatibility) + + @Test + void testDecryptOrReturn_withEncryptedData_decrypts() { + String plaintext = "mySecretPassword"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + + String result = CryptoUtil.decryptOrReturn(encrypted, testKey); + assertEquals(plaintext, result); + } + + @Test + void testDecryptOrReturn_withPlaintext_returnsAsIs() { + String plaintext = "oldPlainTextPassword"; + + String result = CryptoUtil.decryptOrReturn(plaintext, testKey); + assertEquals(plaintext, result); + } + + @Test + void testDecryptOrReturn_withNull_returnsNull() { + assertNull(CryptoUtil.decryptOrReturn(null, testKey)); + } + + @Test + void testDecryptOrReturn_withEmpty_returnsEmpty() { + assertEquals("", CryptoUtil.decryptOrReturn("", testKey)); + } + + @Test + void testDecryptOrReturn_withConnectionString() { + String url = "jdbc:mysql://localhost:3306/test?user=root&password=123456"; + + // Plaintext URL should pass through + String result = CryptoUtil.decryptOrReturn(url, testKey); + assertEquals(url, result); + } + + @Test + void testDecryptOrReturn_withEncryptedConnectionString() { + String url = "jdbc:mysql://localhost:3306/test?user=root&password=123456"; + String encrypted = CryptoUtil.encrypt(url, testKey); + + String result = CryptoUtil.decryptOrReturn(encrypted, testKey); + assertEquals(url, result); + } + +} diff --git a/docs/DEVELOPER_GUIDE-en.md b/docs/DEVELOPER_GUIDE-en.md index 3cb13f4ff..d26b94a2c 100644 --- a/docs/DEVELOPER_GUIDE-en.md +++ b/docs/DEVELOPER_GUIDE-en.md @@ -379,6 +379,80 @@ Environment variables: `LANGFUSE_ENABLED`, `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY > For detailed usage, refer to [Advanced Features - Langfuse Observability](ADVANCED_FEATURES-en.md#langfuse-observability). +### 12. Data Encryption Configuration + +Configuration prefix: `spring.ai.alibaba.data-agent` + +The system supports AES-256-GCM encryption for sensitive information (datasource passwords, API keys, etc.). + +| Configuration Item | Description | Default Value | +|-------------------|-------------|---------------| +| `encrypt-key` | AES-256 encryption key (Base64-encoded 32-byte key) | Empty (no encryption) | + +Environment variable: `DATA_AGENT_ENCRYPT_KEY` + +#### Protected Fields + +| Database Table | Field | Description | +|----------------|-------|-------------| +| `datasource` | `password` | Database connection password | +| `datasource` | `connection_url` | Database connection URL (may contain password) | +| `model_config` | `api_key` | LLM API key | +| `model_config` | `proxy_password` | Proxy server password | + +#### Usage + +1. **Generate encryption key** + ```bash + # Option 1: Use built-in utility (requires Java 17+) + java -cp data-agent-management/target/spring-ai-alibaba-data-agent-management-${revision}.jar.original com.alibaba.cloud.ai.dataagent.util.CryptoUtil + + # Option 2: Use OpenSSL (Recommended) + openssl rand -base64 32 + ``` + + > `${revision}` is the project version, currently `1.0.0-SNAPSHOT`. See `` property in `pom.xml`. + +2. **Set environment variable** + ```bash + # Linux/Mac + export DATA_AGENT_ENCRYPT_KEY= + + # Windows PowerShell + $env:DATA_AGENT_ENCRYPT_KEY="" + ``` + +3. **Docker deployment** + ```yaml + # docker-compose.yml + services: + data-agent: + environment: + - DATA_AGENT_ENCRYPT_KEY= + ``` + +#### Backward Compatibility + +The system uses a **transparent compatibility** strategy, supporting coexistence of historical unencrypted data and newly encrypted data: + +| Scenario | Behavior | +|----------|----------| +| Read encrypted data | Decrypt normally | +| Read unencrypted data (historical) | Return as-is, no decryption | +| Write new data | Auto-encrypt (requires key configured) | +| No key configured | All data remains plaintext (backward compatible) | + +> 💡 **No data migration needed**: After configuring the encryption key, the system automatically encrypts new data. Historical data remains usable as-is. As data gets updated, historical data will be gradually encrypted. + +#### Notes + +- ⚠️ **Production environments MUST configure encryption key**, otherwise sensitive data will be stored in plaintext +- 🔑 **Do not change the key lightly** once configured, otherwise encrypted data cannot be decrypted +- 🔄 **Data cannot be recovered if key is lost**, please backup the key properly +- 📝 **Development environment** (H2) uses a built-in test key, no additional configuration needed +- 🛡️ Encryption algorithm: AES-256-GCM (with integrity check), random IV generated for each encryption +- 🔄 **Smooth upgrade**: Historical unencrypted data can be read normally, no downtime migration required + ## Learning Resources ### Official Documentation diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 8369fd36d..24853d4ae 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -432,6 +432,80 @@ public class AgentVectorStoreService { > 详细使用说明请参考 [高级功能 - Langfuse 可观测性](ADVANCED_FEATURES.md#-langfuse-可观测性)。 +### 12. 敏感数据加密配置 (Data Encryption) + +配置前缀: `spring.ai.alibaba.data-agent` + +系统支持对敏感信息(数据源密码、API密钥等)进行 AES-256-GCM 加密存储。 + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| `encrypt-key` | AES-256 加密密钥(Base64编码的32字节密钥) | 空(不加密) | + +对应环境变量: `DATA_AGENT_ENCRYPT_KEY` + +#### 受保护字段 + +| 数据库表 | 字段 | 说明 | +|----------|------|------| +| `datasource` | `password` | 数据库连接密码 | +| `datasource` | `connection_url` | 数据库连接URL(可能包含密码) | +| `model_config` | `api_key` | LLM API密钥 | +| `model_config` | `proxy_password` | 代理服务器密码 | + +#### 使用方式 + +1. **生成加密密钥** + ```bash + # 方式一:使用系统内置工具生成(需要 Java 17+) + java -cp data-agent-management/target/spring-ai-alibaba-data-agent-management-${revision}.jar.original com.alibaba.cloud.ai.dataagent.util.CryptoUtil + + # 方式二:使用 OpenSSL 生成(推荐) + openssl rand -base64 32 + ``` + + > 其中 `${revision}` 为项目版本号,当前为 `1.0.0-SNAPSHOT`,详见 `pom.xml` 中的 `` 属性。 + +2. **配置环境变量** + ```bash + # Linux/Mac + export DATA_AGENT_ENCRYPT_KEY=<生成的Base64密钥> + + # Windows PowerShell + $env:DATA_AGENT_ENCRYPT_KEY="<生成的Base64密钥>" + ``` + +3. **Docker 部署** + ```yaml + # docker-compose.yml + services: + data-agent: + environment: + - DATA_AGENT_ENCRYPT_KEY=<生成的Base64密钥> + ``` + +#### 历史数据兼容性 + +系统采用**透明兼容**策略,支持历史未加密数据与新加密数据共存: + +| 场景 | 行为 | +|------|------| +| 读取已加密数据 | 正常解密 | +| 读取未加密数据(历史) | 原样返回,不解密 | +| 写入新数据 | 自动加密(需配置密钥) | +| 未配置密钥 | 所有数据保持明文(向后兼容) | + +> 💡 **无需数据迁移**:配置加密密钥后,系统会自动处理新数据加密,历史数据保持原样可正常使用。随着数据更新,历史数据会逐步被加密覆盖。 + +#### 注意事项 + +- ⚠️ **生产环境必须配置加密密钥**,否则敏感信息将以明文存储 +- 🔑 **密钥一旦配置不要轻易更换**,否则已加密的数据将无法解密 +- 🔄 **密钥丢失后无法恢复数据**,请妥善备份密钥 +- 📝 **开发环境**(H2)使用内置测试密钥,无需额外配置 +- 🛡️ 加密算法: AES-256-GCM(带完整性校验),每次加密生成随机IV +- 🔄 **平滑升级**:历史未加密数据可正常读取,无需停机迁移 + ## 📚 学习资源 ### 官方文档 diff --git a/docs/QUICK_START-en.md b/docs/QUICK_START-en.md index bf6019a3a..d4ee0fa90 100644 --- a/docs/QUICK_START-en.md +++ b/docs/QUICK_START-en.md @@ -56,7 +56,23 @@ Auto initialization is enabled by default (`spring.sql.init.mode: always`). > For information on how to disable auto initialization, please refer to [Developer Guide - Database Initialization Configuration](DEVELOPER_GUIDE-en.md#8-database-initialization). -### 2.3 Configure Model +### 2.3 Configure Sensitive Data Encryption (Recommended) + +> ⚠️ **Production environments are strongly recommended to enable encryption** + +The system supports encrypted storage for sensitive information like database passwords and API keys. Configure the encryption key: + +```bash +# Generate encryption key +openssl rand -base64 32 + +# Set environment variable +export DATA_AGENT_ENCRYPT_KEY= +``` + +> For detailed configuration, refer to [Developer Guide - Data Encryption Configuration](DEVELOPER_GUIDE-en.md#12-data-encryption-configuration). + +### 2.4 Configure Model > If you need to manually manage model dependencies (not using default Starter), please refer to [Developer Guide - Dependency Extension Configuration](DEVELOPER_GUIDE-en.md#9-dependency-extension). @@ -75,15 +91,15 @@ Start the project, click on Model Configuration, add a new model and fill in you 3. Troubleshooting: If the configuration doesn't work after setup, we recommend first using Postman to test your interface address to confirm network connectivity and parameter format are correct. -### 2.4 Embedding Model Batch Processing Strategy Configuration +### 2.5 Embedding Model Batch Processing Strategy Configuration > For detailed configuration parameters, please refer to [Developer Guide - Development Configuration Manual](DEVELOPER_GUIDE-en.md#development-configuration-manual). -### 2.5 Vector Store Configuration +### 2.6 Vector Store Configuration The system uses an in-memory vector store by default, and also provides hybrid search support for Elasticsearch. -#### 2.5.1 Vector Store Dependency Import +#### 2.6.1 Vector Store Dependency Import You can import your preferred persistent vector store. You just need to provide a bean of type org.springframework.ai.vectorstore.VectorStore to the IoC container. For example, directly import the PGvector starter: @@ -96,7 +112,7 @@ You can import your preferred persistent vector store. You just need to provide For detailed vector store documentation, refer to: https://springdoc.cn/spring-ai/api/vectordbs.html -#### 2.5.2 Vector Store Schema Setup +#### 2.6.2 Vector Store Schema Setup Below is the ES schema structure. For other vector stores like Milvus, PG, etc., you can create your own schema based on this ES structure. Pay special attention to the data type of each field in metadata. @@ -175,15 +191,15 @@ Below is the ES schema structure. For other vector stores like Milvus, PG, etc., } ``` -#### 2.5.3 Vector Store Configuration Parameters +#### 2.6.3 Vector Store Configuration Parameters > For detailed configuration parameters, please refer to [Developer Guide - Development Configuration Manual](DEVELOPER_GUIDE-en.md#development-configuration-manual). -### 2.6 Retrieval Fusion Strategy +### 2.7 Retrieval Fusion Strategy > For detailed configuration parameters, please refer to [Developer Guide - Development Configuration Manual](DEVELOPER_GUIDE-en.md#development-configuration-manual). -### 2.7 Replace Vector Store Implementation +### 2.8 Replace Vector Store Implementation > For information on how to replace the default in-memory vector store (e.g., using PGVector, Milvus, etc.), please refer to [Developer Guide - Dependency Extension Configuration](DEVELOPER_GUIDE-en.md#9-dependency-extension). diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index dfce88b54..b0beab805 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -56,7 +56,23 @@ spring: > 关于如何关闭自动初始化,请参考 [开发者指南 - 数据库初始化配置](DEVELOPER_GUIDE.md#8-数据库初始化配置-database-initialization)。 -### 2.3 配置模型 +### 2.3 配置敏感数据加密(推荐) + +> ⚠️ **生产环境强烈建议启用加密功能** + +系统支持对数据源密码、API密钥等敏感信息进行加密存储。配置加密密钥: + +```bash +# 生成加密密钥 +openssl rand -base64 32 + +# 设置环境变量 +export DATA_AGENT_ENCRYPT_KEY=<生成的密钥> +``` + +> 详细配置请参考 [开发者指南 - 敏感数据加密配置](DEVELOPER_GUIDE.md#12-敏感数据加密配置-data-encryption)。 + +### 2.4 配置模型 > 如果涉及手动管理模型依赖(非默认 Starter),请参考 [开发者指南 - 扩展依赖配置](DEVELOPER_GUIDE.md#9-扩展依赖配置-dependency-extension)。 @@ -75,15 +91,15 @@ spring: 3. 故障排查 如发现配置后无法调用,建议优先使用 Postman 对接您的接口地址进行测试,确认网络连通性及参数格式无误。 -### 2.4 嵌入模型批处理策略配置 +### 2.5 嵌入模型批处理策略配置 > 详细配置参数请参考 [开发者指南 - 开发配置手册](DEVELOPER_GUIDE.md#⚙️-开发配置手册)。 -### 2.5 向量库配置 +### 2.6 向量库配置 系统默认使用内存向量库,同时系统提供了对es的混合检索支持。 -#### 2.5.1 向量库依赖引入 +#### 2.6.1 向量库依赖引入 您可以自行引入你想要的持久化向量库,只需要往ioc容器提供一个org.springframework.ai.vectorstore.VectorStore类型的bean即可。例如直接引入PGvector的starter @@ -96,7 +112,7 @@ spring: 详细对应的向量库参考文档:https://springdoc.cn/spring-ai/api/vectordbs.html -#### 2.5.2 向量库schema设置 +#### 2.6.2 向量库schema设置 以下为es的schema结构,其他向量库如milvus,pg等自行可根据如下的es的结构建立自己的schema。尤其要注意metadata中的每个字段的数据类型。 @@ -175,15 +191,15 @@ spring: } ``` -#### 2.5.3 向量库配置参数 +#### 2.6.3 向量库配置参数 > 详细配置参数请参考 [开发者指南 - 开发配置手册](DEVELOPER_GUIDE.md#⚙️-开发配置手册)。 -### 2.6 检索融合策略 +### 2.7 检索融合策略 > 详细配置参数请参考 [开发者指南 - 开发配置手册](DEVELOPER_GUIDE.md#⚙️-开发配置手册)。 -### 2.7 替换vector-store的实现类 +### 2.8 替换vector-store的实现类 > 关于如何替换默认的内存向量库(如使用 PGVector, Milvus 等),请参考 [开发者指南 - 扩展依赖配置](DEVELOPER_GUIDE.md#9-扩展依赖配置-dependency-extension)。