10 - 文件管理服务
一、模块组成
文件管理涉及 3 个核心类:
| 类 | 职责 |
|---|
FileManageService | 文件管理门面(上传、查询、删除、向量化编排) |
FileParserService | 多格式文件解析(PDF/Word/TXT) |
MinioService | MinIO 对象存储封装 |
FileContentService | 对外暴露的文件加载 + RAG 工具 |
二、上传流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| HTTP POST /file/upload (MultipartFile) ↓ FileController.uploadFile() ↓ FileManageService.uploadFile(MultipartFile) ↓ ┌──────────────────────────────────────────────┐ │ 1. 生成 fileId (UUID) + 提取 fileType │ │ 2. 创建 FileInfo (status=PROCESSING) │ │ 3. fileInfoService.saveFileInfo() → MySQL │ │ 4. minioService.uploadFile() → MinIO │ │ 5. fileInfoService.updateFileInfo() 更新 │ │ (status=SUCCESS, minioPath) │ │ 6. 根据 fileType 分支处理 │ │ ┌──────────────────────────────────┐ │ │ │ isTextFile? │ │ │ │ - 解析 (PDF/Word/TXT) │ │ │ │ - extractedText 存入数据库 │ │ │ │ - 大文件? 向量化 │ │ │ ├──────────────────────────────────┤ │ │ │ isImageFile? │ │ │ │ - Qwen-VL 识别 │ │ │ │ - 识别结果存入 extractedText │ │ │ ├──────────────────────────────────┤ │ │ │ 其他: 仅记录元数据 │ │ │ └──────────────────────────────────┘ │ │ 7. 失败时 status=FAILED │ └──────────────────────────────────────────────┘ ↓ 返回 BaseResult<FileInfo>
|
三、文件解析(FileParserService)
3.1 支持的格式
| 格式 | 解析器 | 说明 |
|---|
| PDF | Apache PDFBox 2.0.30 | 提取所有页面文本 |
| DOCX | Apache POI 5.2.5 | 提取段落文本 |
| DOC | ❌ | 不支持(提示转 DOCX) |
| TXT | NIO | 按 UTF-8 读取 |
| PNG / JPG | Qwen-VL | 多模态识别 |
3.2 核心代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| public ParseRes parseFile(MultipartFile file) { String fullText = parseFileInternal(file); String truncatedText = truncateIfNeeded(fullText); return new ParseRes(fullText, truncatedText); }
private String parseFileInternal(MultipartFile file) { String fileType = getFileType(file.getOriginalFilename());
switch (fileType.toLowerCase()) { case "pdf": return parsePdf(file); case "docx": return parseDocx(file); case "doc": throw new IllegalArgumentException("暂不支持 .doc 格式"); case "txt": return parseTxt(file); default: throw new IllegalArgumentException("不支持的文件类型: " + fileType); } }
private String parsePdf(MultipartFile file) throws Exception { try (InputStream is = file.getInputStream(); PDDocument document = PDDocument.load(is)) { PDFTextStripper stripper = new PDFTextStripper(); stripper.setSortByPosition(true); return stripper.getText(document).trim(); } }
private String parseDocx(MultipartFile file) throws Exception { try (InputStream is = file.getInputStream(); XWPFDocument document = new XWPFDocument(is)) { StringBuilder text = new StringBuilder(); for (XWPFParagraph paragraph : document.getParagraphs()) { String paraText = paragraph.getText(); if (paraText != null && !paraText.trim().isEmpty()) { text.append(paraText).append("\n"); } } return text.toString().trim(); } }
|
3.3 文本截断
1 2 3 4 5 6 7 8 9
| private static final int MAX_TEXT_LENGTH = 20000;
private String truncateIfNeeded(String content) { if (content.length() > MAX_TEXT_LENGTH) { log.warn("文件内容过长,将截断至 {} 字符", MAX_TEXT_LENGTH); return content.substring(0, MAX_TEXT_LENGTH) + "\n\n... (内容已截断,文件过长)"; } return content; }
|
截断与向量化:
truncatedText(≤ 20000)→ 存入数据库 → 用于直接加载fullText(原始长度)→ 用于向量化(如果是大文件)
四、图片识别
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| @PostConstruct public void init() { OpenAiChatOptions options = OpenAiChatOptions.builder() .temperature(0.2d) .model("qwen3-vl-plus") .build(); multimodalChatModel = OpenAiChatModel.builder() .openAiApi(OpenAiApi.builder() .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/") .apiKey(new SimpleApiKey(apiKey)) .build()) .defaultOptions(options) .build(); }
private String image2Text(MultipartFile file) { try (InputStream inputStream = file.getInputStream()) { byte[] imageBytes = IOUtils.toByteArray(inputStream); ByteArrayResource imageResource = new ByteArrayResource(imageBytes);
var userMessage = UserMessage.builder() .text("请描述这张图片的内容,包括场景、对象、布局、颜色、文字信息," + "直接输出纯文本描述,不要多余说明,不要增加任何特殊符号," + "特别是换行符") .media(List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageResource))) .build();
var response = multimodalChatModel.call(new Prompt(List.of(userMessage))); return response.getResult().getOutput().getText().trim(); } catch (Exception e) { throw new RuntimeException("图片识别失败: " + e.getMessage(), e); } }
|
模型:qwen3-vl-plus(通义千问 VL Plus)
五、MinIO 操作
service/MinioService.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| @Service public class MinioService {
@Autowired private MinioClient minioClient;
@Value("${minio.bucketName}") private String bucketName; @Value("${minio.endpoint}") private String endpoint;
private void createBucketIfNotExists(boolean publicRead) throws Exception { if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build())) { minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()); if (publicRead) { String policy = "{...}"; minioClient.setBucketPolicy(SetBucketPolicyArgs.builder() .bucket(bucketName) .config(policy) .build()); } } }
public String uploadFile(MultipartFile file, String objectName) throws Exception { createBucketIfNotExists(true); minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return String.format("%s/%s/%s", endpoint, bucketName, objectName); }
public InputStream downloadFile(String objectName) throws Exception { return minioClient.getObject(GetObjectArgs.builder() .bucket(bucketName) .object(objectName) .build()); }
public void deleteFile(String objectName) throws Exception { minioClient.removeObject(RemoveObjectArgs.builder() .bucket(bucketName) .object(objectName) .build()); } }
|
对象命名(FileManageService.generateObjectName):
1 2 3
| public static String generateObjectName(String fileId, String fileType) { return "file-" + fileId.replace("-", "") + "." + fileType.toLowerCase(); }
|
示例:fileId="abc-123-def" + fileType="pdf" → file-abc123def.pdf
六、文件信息模型
entity/record/FileInfo.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Data @Builder public class FileInfo { private String fileId; private String fileName; private String fileType; private Long fileSize; private String minioPath; private String extractedText; private LocalDateTime createdAt; private String conversationId; private FileStatus status; private Integer embed;
public enum FileStatus { PENDING, PROCESSING, SUCCESS, FAILED } }
|
七、文件加载工具
tool/FileContentService.java 是 Agent 调用的工具:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Tool(description = "根据文件ID加载文件内容或进行RAG语义检索...") public String loadContent( @ToolParam(description = "文件ID") String fileId, @ToolParam(description = "用户的问题,用于语义检索(可选)") String question) {
var fileInfo = fileManageService.getFileInfo(fileId); if (fileInfo.getStatus() != FileInfo.FileStatus.SUCCESS) { return "文件处理中或处理失败..."; }
Integer embed = fileInfo.getEmbed(); if (embed != null && embed == 1) { return retrieveWithRAG(fileId, fileInfo, question); } else { return loadDirectly(fileId, fileInfo); } }
|
响应格式:
1 2 3 4 5 6
| === 文件信息 === 文件名: xxx.pdf 文件类型: pdf
=== 文件内容 === [内容或检索结果]
|
八、REST 接口
controller/FileController.java:
| 方法 | 路径 | 说明 |
|---|
POST | /file/upload | 上传文件 |
GET | /file/info/{fileId} | 获取文件元信息 |
GET | /file/content/{fileId} | 获取文件文本内容 |
DELETE | /file/{fileId} | 删除文件 |
GET | /file/list | 获取所有文件列表 |
GET | /file/exists/{fileId} | 检查文件是否存在 |
九、配置项
1 2 3 4 5 6 7 8 9 10 11 12 13
| spring: servlet: multipart: enabled: true max-file-size: 50MB max-request-size: 50MB
minio: url: http://localhost:19000/ accessKey: minioadmin secretKey: minioadmin bucketName: rag-test2 endpoint: http://localhost:19000/
|
十、异常处理
| 场景 | 处理 |
|---|
| 文件为空 | 返回 BaseResult.newError("文件不能为空") |
| 解析失败 | fileInfo.setStatus(FAILED) + 抛出 RuntimeException |
| 上传失败 | 事务回滚 + 数据库状态置 FAILED |
| 类型不支持 | IllegalArgumentException("不支持的文件类型") |
| MinIO 不可用 | 异常向上抛出 |
十一、扩展方向
- OSS 兼容:支持阿里云 OSS、AWS S3、腾讯云 COS
- 分布式存储:MinIO 集群模式
- 分块上传:支持大文件分块上传 + 秒传
- 病毒扫描:上传后调用云端病毒扫描
- OCR 增强:对扫描版 PDF 增加 OCR 识别
- 表格识别:解析 PDF/Excel 中的表格