diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java index 0a6f6445..0f7e4dd0 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java @@ -47,7 +47,8 @@ public interface FileApi { */ default FileDescriptor createFile(FileContent content) { String reference = createFile(content.bytes(), content.name(), content.directory(), content.contentType()); - return new FileDescriptor(reference, content.name(), content.contentType(), content.bytes().length); + return new FileDescriptor(reference, content.name(), content.contentType(), content.bytes().length, + cn.hutool.crypto.digest.DigestUtil.sha256Hex(content.bytes())); } /** diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java index f5d8da82..d49a30ba 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java @@ -26,6 +26,11 @@ public class FileApiImpl implements FileApi { return fileService.createFile(content, name, directory, type); } + @Override + public FileScanStatus scan(FileDescriptor file) { + return fileService.scan(file); + } + @Override public String presignGetUrl(String url, Integer expirationSeconds) { return fileService.presignGetUrl(url, expirationSeconds); diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileDescriptor.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileDescriptor.java index 0eb575df..d86c7895 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileDescriptor.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileDescriptor.java @@ -1,7 +1,11 @@ package cn.iocoder.yudao.module.infra.api.file; /** Public, storage-agnostic description of an accepted file. */ -public record FileDescriptor(String reference, String name, String contentType, long size) { +public record FileDescriptor(String reference, String name, String contentType, long size, String checksumSha256) { + + public FileDescriptor(String reference, String name, String contentType, long size) { + this(reference, name, contentType, size, null); + } public FileDescriptor { if (reference == null || reference.isBlank()) { @@ -10,5 +14,8 @@ public record FileDescriptor(String reference, String name, String contentType, if (size < 0 || size > FileContent.MAX_CONTENT_BYTES) { throw new IllegalArgumentException("file size is outside public contract bounds"); } + if (checksumSha256 != null && !checksumSha256.matches("(?i)[0-9a-f]{64}")) { + throw new IllegalArgumentException("file checksum must be SHA-256 hex"); + } } } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileScanStatus.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileScanStatus.java index 5538130f..9e0838b3 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileScanStatus.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileScanStatus.java @@ -4,5 +4,6 @@ package cn.iocoder.yudao.module.infra.api.file; public enum FileScanStatus { CLEAN, INFECTED, + ERROR, UNAVAILABLE } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java index b64df52e..2a1bbb67 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java @@ -23,6 +23,10 @@ public interface FileMapper extends BaseMapperX { .orderByDesc(FileDO::getId)); } + default FileDO selectLatestByUrl(String url) { + return selectLastOne(new LambdaQueryWrapperX().eq(FileDO::getUrl, url).orderByAsc(FileDO::getId)); + } + default FileDO selectLatestByConfigIdAndPath(Long configId, String path) { return selectLastOne(new LambdaQueryWrapperX() .eq(FileDO::getConfigId, configId) diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileScanProperties.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileScanProperties.java new file mode 100644 index 00000000..628eeab0 --- /dev/null +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileScanProperties.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.infra.framework.file.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "yudao.infra.file-scan") +public class FileScanProperties { + private boolean enabled; + private String host = "127.0.0.1"; + private int port = 3310; + private int connectTimeoutMillis = 2000; + private int readTimeoutMillis = 10000; + private int chunkBytes = 8192; + private int maxResponseBytes = 4096; +} diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/YudaoFileAutoConfiguration.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/YudaoFileAutoConfiguration.java index 8e3f506f..681c4bd2 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/YudaoFileAutoConfiguration.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/YudaoFileAutoConfiguration.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.infra.framework.file.config; import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientFactory; import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClientFactoryImpl; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,6 +12,7 @@ import org.springframework.context.annotation.Configuration; * @author 恭学教育 */ @Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(FileScanProperties.class) public class YudaoFileAutoConfiguration { @Bean diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/scan/ClamAvFileScanner.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/scan/ClamAvFileScanner.java new file mode 100644 index 00000000..d810f411 --- /dev/null +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/scan/ClamAvFileScanner.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.scan; + +import cn.iocoder.yudao.module.infra.api.file.FileScanStatus; +import cn.iocoder.yudao.module.infra.framework.file.config.FileScanProperties; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; + +@Component +public class ClamAvFileScanner { + private final FileScanProperties properties; + + public ClamAvFileScanner(FileScanProperties properties) { this.properties = properties; } + + public FileScanStatus scan(byte[] content) { + if (!properties.isEnabled()) return FileScanStatus.UNAVAILABLE; + if (properties.getHost() == null || properties.getHost().isBlank() + || properties.getPort() <= 0 || properties.getPort() > 65535 + || properties.getConnectTimeoutMillis() <= 0 || properties.getReadTimeoutMillis() <= 0 + || properties.getChunkBytes() <= 0 || properties.getMaxResponseBytes() <= 0) { + return FileScanStatus.ERROR; + } + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(properties.getHost(), properties.getPort()), properties.getConnectTimeoutMillis()); + socket.setSoTimeout(properties.getReadTimeoutMillis()); + DataOutputStream output = new DataOutputStream(socket.getOutputStream()); + output.write("zINSTREAM\0".getBytes(StandardCharsets.US_ASCII)); + for (int offset = 0; offset < content.length; offset += properties.getChunkBytes()) { + int length = Math.min(properties.getChunkBytes(), content.length - offset); + output.writeInt(length); + output.write(content, offset, length); + } + output.writeInt(0); output.flush(); + String response = readResponse(socket.getInputStream()); + if (response.endsWith(" OK")) return FileScanStatus.CLEAN; + if (response.endsWith(" FOUND")) return FileScanStatus.INFECTED; + return FileScanStatus.ERROR; + } catch (Exception ex) { + return FileScanStatus.UNAVAILABLE; + } + } + + private String readResponse(InputStream input) throws Exception { + ByteArrayOutputStream response = new ByteArrayOutputStream(); + while (response.size() < properties.getMaxResponseBytes()) { + int value = input.read(); + if (value < 0 || value == 0 || value == '\n') break; + response.write(value); + } + if (response.size() == properties.getMaxResponseBytes()) return ""; + return response.toString(StandardCharsets.US_ASCII); + } +} diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java index 23603d35..53cfc8ea 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java @@ -1,6 +1,8 @@ package cn.iocoder.yudao.module.infra.service.file; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.infra.api.file.FileDescriptor; +import cn.iocoder.yudao.module.infra.api.file.FileScanStatus; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; @@ -36,6 +38,8 @@ public interface FileService { String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, String name, String directory, String type); + FileScanStatus scan(FileDescriptor file); + /** * 生成文件预签名地址信息,用于上传 * diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java index 3ff0777a..e519a42b 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java @@ -9,12 +9,15 @@ import cn.hutool.crypto.digest.DigestUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.infra.api.file.FileDescriptor; +import cn.iocoder.yudao.module.infra.api.file.FileScanStatus; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.scan.ClamAvFileScanner; import cn.iocoder.yudao.module.infra.framework.file.core.utils.FilePathUtils; import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; import com.google.common.annotations.VisibleForTesting; @@ -63,6 +66,9 @@ public class FileServiceImpl implements FileService { @Resource private FileMapper fileMapper; + @Resource + private ClamAvFileScanner fileScanner; + @Override public PageResult getFilePage(FilePageReqVO pageReqVO) { return fileMapper.selectPage(pageReqVO); @@ -104,6 +110,23 @@ public class FileServiceImpl implements FileService { return url; } + @Override + public FileScanStatus scan(FileDescriptor descriptor) { + try { + FileDO file = fileMapper.selectLatestByUrl(descriptor.reference()); + if (file == null || file.getSize() == null || file.getSize() != descriptor.size()) return FileScanStatus.ERROR; + FileClient client = fileConfigService.getFileClient(file.getConfigId()); + if (client == null) return FileScanStatus.UNAVAILABLE; + byte[] content = client.getContent(file.getPath()); + if (content == null || content.length != file.getSize() || content.length != descriptor.size()) return FileScanStatus.ERROR; + if (descriptor.checksumSha256() != null + && !descriptor.checksumSha256().equalsIgnoreCase(DigestUtil.sha256Hex(content))) return FileScanStatus.ERROR; + return fileScanner.scan(content); + } catch (Exception ex) { + return FileScanStatus.UNAVAILABLE; + } + } + @VisibleForTesting String generateUploadPath(String name, String directory) { // 1.1 处理 name 和 directory 的合法性 diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/api/file/FileApiImplTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/api/file/FileApiImplTest.java index 753054de..298ad839 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/api/file/FileApiImplTest.java +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/api/file/FileApiImplTest.java @@ -25,15 +25,19 @@ class FileApiImplTest { assertEquals("questions.csv", descriptor.name()); assertEquals("text/csv", descriptor.contentType()); assertEquals(3, descriptor.size()); + assertEquals("039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81", descriptor.checksumSha256()); verify(fileService).createFile(new byte[]{1, 2, 3}, "questions.csv", "education/imports", "text/csv"); } @Test - void scannerUnavailableNeverReportsClean() { - FileApiImpl api = new FileApiImpl(mock(FileService.class)); + void scanDelegatesToService() { + FileService service = mock(FileService.class); + FileDescriptor descriptor = new FileDescriptor("opaque", "questions.csv", "text/csv", 3); + when(service.scan(descriptor)).thenReturn(FileScanStatus.INFECTED); + FileApiImpl api = new FileApiImpl(service); - assertEquals(FileScanStatus.UNAVAILABLE, - api.scan(new FileDescriptor("opaque", "questions.csv", "text/csv", 3))); + assertEquals(FileScanStatus.INFECTED, api.scan(descriptor)); + verify(service).scan(descriptor); } @Test diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/scan/ClamAvFileScannerTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/scan/ClamAvFileScannerTest.java new file mode 100644 index 00000000..39aea6bd --- /dev/null +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/scan/ClamAvFileScannerTest.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.scan; + +import cn.iocoder.yudao.module.infra.api.file.FileScanStatus; +import cn.iocoder.yudao.module.infra.framework.file.config.FileScanProperties; +import org.junit.jupiter.api.Test; + +import java.io.DataInputStream; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ClamAvFileScannerTest { + @Test + void sendsBoundedInstreamFramesAndParsesClean() throws Exception { + byte[] content = "abcdef".getBytes(StandardCharsets.US_ASCII); + try (ServerSocket server = new ServerSocket(0)) { + CompletableFuture received = CompletableFuture.supplyAsync(() -> { + try (var socket = server.accept()) { + DataInputStream input = new DataInputStream(socket.getInputStream()); + input.readNBytes(10); + var bytes = new java.io.ByteArrayOutputStream(); + for (int length; (length = input.readInt()) != 0;) bytes.write(input.readNBytes(length)); + socket.getOutputStream().write("stream: OK\0".getBytes(StandardCharsets.US_ASCII)); + return bytes.toByteArray(); + } catch (Exception ex) { throw new RuntimeException(ex); } + }); + FileScanProperties properties = new FileScanProperties(); + properties.setEnabled(true); properties.setPort(server.getLocalPort()); properties.setChunkBytes(2); + assertEquals(FileScanStatus.CLEAN, new ClamAvFileScanner(properties).scan(content)); + assertArrayEquals(content, received.get()); + } + } + + @Test + void disabledAndProtocolErrorsFailClosed() throws Exception { + FileScanProperties disabled = new FileScanProperties(); + assertEquals(FileScanStatus.UNAVAILABLE, new ClamAvFileScanner(disabled).scan(new byte[]{1})); + try (ServerSocket server = new ServerSocket(0)) { + CompletableFuture.runAsync(() -> { try (var socket = server.accept()) { + socket.getInputStream().readNBytes(10); socket.getOutputStream().write("stream: ERROR\0".getBytes(StandardCharsets.US_ASCII)); + } catch (Exception ex) { throw new RuntimeException(ex); }}); + FileScanProperties properties = new FileScanProperties(); properties.setEnabled(true); properties.setPort(server.getLocalPort()); + assertEquals(FileScanStatus.ERROR, new ClamAvFileScanner(properties).scan(new byte[]{1})); + } + } + + @Test + void invalidConnectionConfigurationFailsClosed() { + FileScanProperties properties = new FileScanProperties(); + properties.setEnabled(true); + + properties.setHost(" "); + assertEquals(FileScanStatus.ERROR, new ClamAvFileScanner(properties).scan(new byte[]{1})); + properties.setHost("127.0.0.1"); + properties.setPort(0); + assertEquals(FileScanStatus.ERROR, new ClamAvFileScanner(properties).scan(new byte[]{1})); + properties.setPort(3310); + properties.setConnectTimeoutMillis(0); + assertEquals(FileScanStatus.ERROR, new ClamAvFileScanner(properties).scan(new byte[]{1})); + properties.setConnectTimeoutMillis(2000); + properties.setReadTimeoutMillis(0); + assertEquals(FileScanStatus.ERROR, new ClamAvFileScanner(properties).scan(new byte[]{1})); + } +} diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java index 4b14aec0..0b97b50c 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java @@ -5,11 +5,15 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.framework.test.core.util.AssertUtils; +import cn.iocoder.yudao.module.infra.api.file.FileDescriptor; +import cn.iocoder.yudao.module.infra.api.file.FileScanStatus; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.scan.ClamAvFileScanner; +import cn.hutool.crypto.digest.DigestUtil; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -40,6 +44,9 @@ public class FileServiceImplTest extends BaseDbUnitTest { @MockitoBean private FileConfigService fileConfigService; + @MockitoBean + private ClamAvFileScanner fileScanner; + @BeforeEach public void setUp() { FileServiceImpl.PATH_PREFIX_DATE_ENABLE = true; @@ -146,6 +153,37 @@ public class FileServiceImplTest extends BaseDbUnitTest { assertEquals(content.length, file.getSize()); } + + @Test + void scanUsesRegisteredPathAndVerifiesIntegrity() throws Exception { + byte[] content = {1, 2, 3}; + FileDO file = new FileDO().setConfigId(10L).setName("questions.csv").setPath("stored/questions.csv") + .setUrl("opaque-ref").setType("text/csv").setSize(3L); + fileMapper.insert(file); + FileClient client = mock(FileClient.class); + when(fileConfigService.getFileClient(10L)).thenReturn(client); + when(client.getContent("stored/questions.csv")).thenReturn(content); + when(fileScanner.scan(content)).thenReturn(FileScanStatus.CLEAN); + + assertEquals(FileScanStatus.CLEAN, fileService.scan(new FileDescriptor("opaque-ref", "questions.csv", + "text/csv", 3, DigestUtil.sha256Hex(content)))); + verify(client).getContent("stored/questions.csv"); + } + + @Test + void scanRejectsUnknownReferenceAndIntegrityMismatch() throws Exception { + FileClient client = mock(FileClient.class); + assertEquals(FileScanStatus.ERROR, fileService.scan(new FileDescriptor("http://attacker/internal", "x", null, 1))); + verifyNoInteractions(fileConfigService, client, fileScanner); + + FileDO file = new FileDO().setConfigId(10L).setName("x").setPath("stored/x").setUrl("opaque-ref").setSize(3L); + fileMapper.insert(file); + when(fileConfigService.getFileClient(10L)).thenReturn(client); + when(client.getContent("stored/x")).thenReturn(new byte[]{1, 2}); + assertEquals(FileScanStatus.ERROR, fileService.scan(new FileDescriptor("opaque-ref", "x", null, 3))); + verifyNoInteractions(fileScanner); + } + @Test public void testDeleteFile_success() throws Exception { // mock 数据 diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml index 364badbc..200019b2 100644 --- a/yudao-server/src/main/resources/application.yaml +++ b/yudao-server/src/main/resources/application.yaml @@ -198,6 +198,15 @@ spring: enabled: false # TODO @管理员:有 bug https://github.com/spring-projects/spring-ai/issues/4917 需要官方修复 yudao: + infra: + file-scan: + enabled: ${CLAMAV_ENABLED:false} + host: ${CLAMAV_HOST:127.0.0.1} + port: ${CLAMAV_PORT:3310} + connect-timeout-millis: ${CLAMAV_CONNECT_TIMEOUT_MILLIS:2000} + read-timeout-millis: ${CLAMAV_READ_TIMEOUT_MILLIS:10000} + chunk-bytes: ${CLAMAV_CHUNK_BYTES:8192} + max-response-bytes: ${CLAMAV_MAX_RESPONSE_BYTES:4096} ai: doubao: # 字节豆包 enable: true