Spring AI + MinIO:多模态文件处理完整方案
2026/4/30大约 8 分钟
Spring AI + MinIO:多模态文件处理完整方案
适读人群:有1-5年Java开发经验,想向AI工程师方向转型的开发者 阅读时长:约18分钟 文章价值:① 掌握MinIO + Spring AI处理图片/PDF/音频的完整流程 ② 学会构建多模态AI文件处理管道 ③ 获得一套生产级的文件上传-AI处理-结果存储全链路代码
小周是我们星球里的一个同学,做了五年Java,最近在做一个医疗影像AI分析平台。
"老张,用户上传医学影像(DICOM/PNG),需要AI分析给出初步报告,结果要存下来可以随时查。但我完全不知道怎么把文件、AI、存储串起来。"
这是一个非常典型的多模态AI处理需求。文件上传、存储、AI分析、结果持久化,每一个单独做都不难,但串起来需要想清楚架构。
今天把这套方案讲清楚。
多模态文件处理的核心挑战
不同文件类型需要不同的预处理:
| 文件类型 | AI处理方式 | 关键挑战 |
|---|---|---|
| PNG/JPG | Base64编码直接发给Vision模型 | 图片压缩/大小限制 |
| 文本提取 + 图片提取 | 扫描版PDF需OCR | |
| Word/Excel | Apache POI提取文本 | 格式复杂 |
| 音频MP3/WAV | Whisper API转写文字 | 时长/格式限制 |
| 视频 | 提取关键帧 + 音频 | 处理量大,需异步 |
系统架构
代码实现
第一步:MinIO配置
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- MinIO客户端 -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
<!-- PDF文本提取 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.1</version>
</dependency>
<!-- 图片处理 -->
<dependency>
<groupId>org.imgscalr</groupId>
<artifactId>imgscalr-lib</artifactId>
<version>4.2</version>
</dependency>
</dependencies>@Configuration
@ConfigurationProperties(prefix = "minio")
@Data
public class MinioConfig {
private String endpoint;
private String accessKey;
private String secretKey;
private String bucket = "ai-files";
@Bean
public MinioClient minioClient() {
MinioClient client = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
// 确保bucket存在
try {
boolean exists = client.bucketExists(
BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
} catch (Exception e) {
throw new RuntimeException("MinIO初始化失败", e);
}
return client;
}
}第二步:文件上传和存储服务
@Service
@RequiredArgsConstructor
@Slf4j
public class FileStorageService {
private final MinioClient minioClient;
private final KafkaTemplate<String, FileProcessEvent> kafkaTemplate;
private final FileMetadataRepository metadataRepository;
@Value("${minio.bucket}")
private String bucket;
// 支持的文件类型
private static final Set<String> ALLOWED_TYPES = Set.of(
"image/png", "image/jpeg", "image/gif", "image/webp",
"application/pdf",
"audio/mpeg", "audio/wav", "audio/mp4",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
/**
* 上传文件并触发AI处理
*/
@Transactional
public FileUploadResponse uploadAndProcess(MultipartFile file, String userId,
String analysisType) throws Exception {
// 1. 文件校验
validateFile(file);
// 2. 生成存储路径
String fileId = UUID.randomUUID().toString();
String originalFilename = StringUtils.cleanPath(file.getOriginalFilename());
String extension = getExtension(originalFilename);
String storagePath = userId + "/" + fileId + "." + extension;
// 3. 上传到MinIO
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucket)
.object(storagePath)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.userMetadata(Map.of(
"original-filename", originalFilename,
"user-id", userId,
"upload-time", LocalDateTime.now().toString()
))
.build());
// 4. 保存文件元数据
FileMetadata metadata = FileMetadata.builder()
.fileId(fileId)
.userId(userId)
.originalFilename(originalFilename)
.storagePath(storagePath)
.contentType(file.getContentType())
.fileSize(file.getSize())
.analysisType(analysisType)
.status(ProcessStatus.PENDING)
.uploadedAt(LocalDateTime.now())
.build();
metadataRepository.save(metadata);
// 5. 发送处理事件到Kafka
FileProcessEvent event = FileProcessEvent.builder()
.fileId(fileId)
.userId(userId)
.storagePath(storagePath)
.contentType(file.getContentType())
.analysisType(analysisType)
.build();
kafkaTemplate.send("file-process-topic", userId, event);
log.info("文件上传成功,fileId={}, path={}", fileId, storagePath);
return FileUploadResponse.builder()
.fileId(fileId)
.status("PENDING")
.message("文件已上传,AI分析进行中,请稍候")
.build();
}
private void validateFile(MultipartFile file) {
if (file.isEmpty()) throw new InvalidFileException("文件为空");
if (file.getSize() > MAX_FILE_SIZE) {
throw new FileTooLargeException("文件超过50MB限制,当前大小:" + file.getSize() / 1024 / 1024 + "MB");
}
if (!ALLOWED_TYPES.contains(file.getContentType())) {
throw new UnsupportedFileTypeException("不支持的文件类型:" + file.getContentType());
}
}
public byte[] downloadFile(String storagePath) throws Exception {
return minioClient.getObject(GetObjectArgs.builder()
.bucket(bucket)
.object(storagePath)
.build()).readAllBytes();
}
}第三步:多模态文件处理器
这是核心,不同文件类型走不同的处理路径:
@Service
@RequiredArgsConstructor
@Slf4j
public class MultimodalFileProcessor {
private final ChatClient chatClient;
private final FileStorageService storageService;
private final AnalysisResultRepository resultRepository;
private final FileMetadataRepository metadataRepository;
/**
* 图片分析:使用Vision模型
* 支持PNG/JPG/GIF/WebP
*/
public AnalysisResult analyzeImage(String fileId, String storagePath,
String analysisType) throws Exception {
log.info("开始图片分析,fileId={}", fileId);
byte[] imageBytes = storageService.downloadFile(storagePath);
// 如果图片过大,先压缩(OpenAI Vision有大小限制)
if (imageBytes.length > 4 * 1024 * 1024) {
imageBytes = compressImage(imageBytes, 0.7f);
}
String base64Image = Base64.getEncoder().encodeToString(imageBytes);
String mimeType = detectMimeType(imageBytes);
// 根据分析类型选择不同的提示词
String systemPrompt = getImageAnalysisPrompt(analysisType);
// Spring AI 1.0 多模态调用
String result = chatClient.prompt()
.system(systemPrompt)
.user(userSpec -> userSpec
.text("请分析这张图片")
.media(MimeTypeUtils.parseMimeType(mimeType),
new ByteArrayResource(imageBytes))
)
.call()
.content();
return saveResult(fileId, result, "IMAGE_ANALYSIS");
}
/**
* PDF分析:提取文本后用Text模型分析
*/
public AnalysisResult analyzePdf(String fileId, String storagePath,
String analysisType) throws Exception {
log.info("开始PDF分析,fileId={}", fileId);
byte[] pdfBytes = storageService.downloadFile(storagePath);
// 提取PDF文本(Apache PDFBox)
String extractedText = extractPdfText(pdfBytes);
if (extractedText.isBlank()) {
// 扫描版PDF,文本提取失败,改用Vision模型处理首页
return analyzeScannedPdf(fileId, pdfBytes, analysisType);
}
// 文本太长时截断(防止超context window)
if (extractedText.length() > 10000) {
log.warn("PDF文本过长({}字),截取前10000字分析", extractedText.length());
extractedText = extractedText.substring(0, 10000) + "\n\n[内容已截断,仅分析前部分]";
}
String systemPrompt = getPdfAnalysisPrompt(analysisType);
String result = chatClient.prompt()
.system(systemPrompt)
.user("请分析以下文档内容:\n\n" + extractedText)
.call()
.content();
return saveResult(fileId, result, "PDF_ANALYSIS");
}
/**
* 音频转写+分析:先用Whisper转写,再分析文本
*/
public AnalysisResult analyzeAudio(String fileId, String storagePath,
String analysisType) throws Exception {
log.info("开始音频分析,fileId={}", fileId);
byte[] audioBytes = storageService.downloadFile(storagePath);
// Step 1: Whisper转写
// Spring AI 1.0提供AudioTranscriptionModel
String transcription = transcribeAudio(audioBytes, storagePath);
log.info("音频转写完成,fileId={}, 文字长度={}", fileId, transcription.length());
// Step 2: 分析转写文本
String systemPrompt = getAudioAnalysisPrompt(analysisType);
String result = chatClient.prompt()
.system(systemPrompt)
.user("以下是音频转写内容,请进行分析:\n\n" + transcription)
.call()
.content();
return saveResult(fileId, result + "\n\n【原始转写】\n" + transcription, "AUDIO_ANALYSIS");
}
private String extractPdfText(byte[] pdfBytes) {
try (PDDocument document = PDDocument.load(pdfBytes)) {
PDFTextStripper stripper = new PDFTextStripper();
return stripper.getText(document);
} catch (IOException e) {
log.error("PDF文本提取失败", e);
return "";
}
}
private AnalysisResult analyzeScannedPdf(String fileId, byte[] pdfBytes,
String analysisType) throws Exception {
// 扫描版PDF:取第一页转为图片,用Vision分析
try (PDDocument document = PDDocument.load(pdfBytes)) {
PDFRenderer renderer = new PDFRenderer(document);
BufferedImage pageImage = renderer.renderImageWithDPI(0, 150);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(pageImage, "PNG", baos);
byte[] imageBytes = baos.toByteArray();
String result = chatClient.prompt()
.system("这是一个扫描版PDF的第一页图片,请提取关键信息并分析。")
.user(u -> u.text("请分析此扫描文档")
.media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(imageBytes)))
.call()
.content();
return saveResult(fileId, "[扫描版PDF,仅分析首页]\n" + result, "SCANNED_PDF_ANALYSIS");
}
}
private byte[] compressImage(byte[] imageBytes, float quality) throws IOException {
BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes));
// 按比例缩小到最大1920px宽
int maxWidth = 1920;
if (original.getWidth() > maxWidth) {
double scale = (double) maxWidth / original.getWidth();
int newHeight = (int) (original.getHeight() * scale);
original = Scalr.resize(original, maxWidth, newHeight);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(original, "jpg", baos);
return baos.toByteArray();
}
@Transactional
private AnalysisResult saveResult(String fileId, String content, String analysisType) {
AnalysisResult result = AnalysisResult.builder()
.fileId(fileId)
.analysisType(analysisType)
.content(content)
.analyzedAt(LocalDateTime.now())
.build();
resultRepository.save(result);
// 更新文件状态
metadataRepository.findByFileId(fileId).ifPresent(meta -> {
meta.setStatus(ProcessStatus.COMPLETED);
metadataRepository.save(meta);
});
return result;
}
private String getImageAnalysisPrompt(String analysisType) {
return switch (analysisType) {
case "MEDICAL" -> "你是一名专业的医学影像分析助手。请仔细分析图像,描述可见的解剖结构和异常表现,注意不要做最终诊断。";
case "INVOICE" -> "你是一名财务文档识别专家。请提取图片中的所有财务信息:发票号、日期、金额、商品明细等,输出结构化JSON。";
case "GENERAL" -> "请详细描述图片的内容,包括主体、背景、文字信息等。";
default -> "请分析并描述这张图片。";
};
}
}第四步:Kafka消费者(异步处理调度)
@Component
@RequiredArgsConstructor
@Slf4j
public class FileProcessConsumer {
private final MultimodalFileProcessor fileProcessor;
private final WebSocketNotifier notifier;
@KafkaListener(topics = "file-process-topic", groupId = "file-processor-group")
public void processFile(@Payload FileProcessEvent event, Acknowledgment ack) {
log.info("开始处理文件,fileId={}, type={}", event.getFileId(), event.getContentType());
try {
AnalysisResult result = dispatchByFileType(event);
// 通过WebSocket通知用户
notifier.notifyFileAnalyzed(event.getUserId(), event.getFileId(), result);
ack.acknowledge();
log.info("文件处理完成,fileId={}", event.getFileId());
} catch (Exception e) {
log.error("文件处理失败,fileId={}", event.getFileId(), e);
notifier.notifyFileError(event.getUserId(), event.getFileId(), e.getMessage());
ack.acknowledge(); // 失败也acknowledge,避免无限重试
}
}
private AnalysisResult dispatchByFileType(FileProcessEvent event) throws Exception {
String contentType = event.getContentType();
if (contentType.startsWith("image/")) {
return fileProcessor.analyzeImage(event.getFileId(),
event.getStoragePath(), event.getAnalysisType());
} else if ("application/pdf".equals(contentType)) {
return fileProcessor.analyzePdf(event.getFileId(),
event.getStoragePath(), event.getAnalysisType());
} else if (contentType.startsWith("audio/")) {
return fileProcessor.analyzeAudio(event.getFileId(),
event.getStoragePath(), event.getAnalysisType());
} else {
throw new UnsupportedFileTypeException("暂不支持处理该类型文件: " + contentType);
}
}
}生产注意事项
小周按这套方案搭起来之后,问了几个好问题:
文件安全:上传的文件需要病毒扫描吗?
对于医疗/金融等场景,建议集成ClamAV做扫描。MinIO的server-side encryption也要开启。
大文件处理:50MB的PDF怎么办?
分页处理,每次只取N页,多次调用AI,最后汇总。不要一次全塞进context。
处理失败重试:
用Kafka的死信队列(DLT),失败3次后进DLT,由运维人工处理或补偿任务重试。
费用控制:
图片分析比文本贵得多。可以先提取缩略图(降低分辨率),用于快速初判,只有需要精细分析时才用原图。
这套方案,从文件上传到AI分析到结果推送,全链路打通了。你只需要根据自己的业务场景,调整不同文件类型的分析Prompt,就能快速交付多模态AI功能。
