10 - 文件管理服务

一、模块组成

文件管理涉及 3 个核心类:

职责
FileManageService文件管理门面(上传、查询、删除、向量化编排)
FileParserService多格式文件解析(PDF/Word/TXT)
MinioServiceMinIO 对象存储封装
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 支持的格式

格式解析器说明
PDFApache PDFBox 2.0.30提取所有页面文本
DOCXApache POI 5.2.5提取段落文本
DOC不支持(提示转 DOCX)
TXTNIO按 UTF-8 读取
PNG / JPGQwen-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;

// 自动创建 bucket(首次上传时)
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; // MinIO 路径
private String extractedText; // 解析后的文本
private LocalDateTime createdAt;
private String conversationId; // 关联会话(可选)
private FileStatus status; // 状态
private Integer embed; // 0=未向量化, 1=已向量化

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); // RAG
} 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 中的表格