17 文档上传
文档上传
文档上传的核心不是把文件收到就完事,而是完成文件校验、安全存储、元数据入库和后续解析任务的入口。
[TOC]
1. 学完本节要达到什么程度
前面已经准备了数据库。
现在开始进入 RAG 入库流程的第一步:
上传文档
上传文档不是简单的:
MultipartFile file
Files.copy(...)
还要考虑:
文件是否为空
文件类型是否允许
文件大小是否超限
文件名是否安全
是否重复上传
保存路径如何生成
元数据如何入库
后续解析任务如何触发
这一节先做一个稳定的上传入口。
2. 上传流程
建议流程:
前端选择文件
-> Java 接收 multipart/form-data -> 校验文件
-> 生成 documentId -> 保存原始文件
-> 计算 sha256 -> 写入 rag_document -> 返回 documentId
上传流程图:
graph TD;
A["前端选择文件"] --> B["POST multipart/form-data"];
B --> C["Java Controller 接收文件"];
C --> D{"基础校验通过吗?"};
D -- "否" --> D1["返回错误<br/>空文件 / 类型不支持 / 大小超限"];
D -- "是" --> E["生成 documentId 和安全文件名"];
E --> F["保存原始文件"];
F --> G["计算 sha256"]; G --> H["写入 rag_document"]; H --> I["创建解析任务或等待手动触发"];
I --> J["返回 documentId"];
后续再解析。
不要在上传接口里一口气做完所有事。
上传接口先保证文件安全落地。
3. 支持的文件类型
第一版建议只支持:
pdf
docx
md
txt
不要一开始就支持:
xls
ppt
图片
扫描件
压缩包
网页链接
类型越多,解析难度越高。
先把最常见的四类跑通。
4. Spring Boot 上传配置
可以在 application.yaml 里设置上传限制:
spring:
servlet: multipart: max-file-size: 20MB max-request-size: 20MB
含义:
max-file-size:单个文件最大大小
max-request-size:整个请求最大大小
学习阶段 20MB 足够。
正式项目要按业务场景调整。
5. Controller
Java 接口可以这样设计:
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/17/documents")
public class DocumentUploadController {
private final DocumentUploadService documentUploadService;
public DocumentUploadController(DocumentUploadService documentUploadService) { this.documentUploadService = documentUploadService; }
@PostMapping( value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE ) public DocumentUploadResponse upload(@RequestPart("file") MultipartFile file) { return documentUploadService.upload(file); }}
接口:
POST /api/17/documents/upload
Content-Type: multipart/form-data
字段:
file:上传文件
6. 响应对象
import java.time.LocalDateTime;
import java.util.UUID;
public record DocumentUploadResponse(
UUID documentId, String originalName, long fileSize, String contentType, String status, LocalDateTime uploadedAt) {
}
返回示例:
{
"documentId": "3d24e89e-0c22-4b4b-9e0a-b4fd0f83c739",
"originalName": "产品手册.pdf",
"fileSize": 102400,
"contentType": "application/pdf",
"status": "UPLOADED",
"uploadedAt": "2026-06-07T23:30:00"
}
前端拿到 documentId 后,后面可以查询解析状态。
7. 文件校验
最少校验:
文件不能为空
原始文件名不能为空
文件大小不能超过限制
扩展名必须允许
Content-Type 尽量匹配
扩展名校验:
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"pdf", "docx", "md", "txt");
private String getExtension(String filename) {
int index = filename.lastIndexOf('.'); if (index < 0 || index == filename.length() - 1) { throw new IllegalArgumentException("文件缺少扩展名");
}
return filename.substring(index + 1).toLowerCase(Locale.ROOT);}
校验:
String extension = getExtension(originalName);
if (!ALLOWED_EXTENSIONS.contains(extension)) {
throw new IllegalArgumentException("不支持的文件类型:" + extension);
}
不要只相信浏览器传来的 Content-Type。
它可以作为参考,但不能作为唯一依据。
8. 安全文件名
不要直接用原始文件名当服务器保存路径。
危险示例:
../../application.yaml
应该生成自己的文件名:
documentId + "." + extension
比如:
3d24e89e-0c22-4b4b-9e0a-b4fd0f83c739.pdf
原始文件名只作为 metadata 保存。
9. 保存文件
Service 示例:
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
public class DocumentUploadService {
private final Path uploadRoot = Path.of("data", "uploads");
public DocumentUploadResponse upload(MultipartFile file) { if (file.isEmpty()) { throw new IllegalArgumentException("上传文件不能为空");
}
String originalName = file.getOriginalFilename(); if (originalName == null || originalName.isBlank()) { throw new IllegalArgumentException("文件名不能为空");
}
String extension = getExtension(originalName); UUID documentId = UUID.randomUUID(); String storedName = documentId + "." + extension; Path target = uploadRoot.resolve(storedName).normalize().toAbsolutePath();
try { Files.createDirectories(uploadRoot);
try (InputStream inputStream = file.getInputStream()) { Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING); } } catch (Exception e) { throw new IllegalStateException("保存上传文件失败", e);
}
return new DocumentUploadResponse( documentId, originalName, file.getSize(), file.getContentType(), "UPLOADED", LocalDateTime.now() ); }}
这里还没有入库。
下一步要把 metadata 写到 rag_document。
10. 计算 sha256
sha256 可以用于判断重复文件。
示例:
import java.io.InputStream;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
private String sha256(MultipartFile file) {
try { MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream inputStream = file.getInputStream(); DigestInputStream digestInputStream = new DigestInputStream(inputStream, digest)) { digestInputStream.transferTo(OutputStream.nullOutputStream()); }
return HexFormat.of().formatHex(digest.digest()); } catch (Exception e) { throw new IllegalStateException("计算文件 hash 失败", e);
}}
需要导入:
import java.io.OutputStream;
注意:
如果先计算 hash,再保存文件,会读取两次 input stream
学习阶段可以接受。
正式项目可以边保存边计算,或者保存后对文件计算 hash。
11. 元数据入库
上传成功后写入 rag_document。
伪代码:
RagDocument document = new RagDocument(
documentId, originalName, target.toString(), file.getContentType(), file.getSize(), sha256, "UPLOADED");
ragDocumentRepository.save(document);
数据库记录要包含:
id
original_name
storage_path
content_type
file_size
sha256
status
created_at
updated_at
保存文件和写数据库要考虑一致性。
第一版可以:
先保存文件
再写数据库
数据库失败时删除文件
或者:
先写 UPLOADING保存成功后改 UPLOADED
学习阶段先实现简单版本。
12. 触发解析任务
上传完成后可以选择两种模式。
同步模式:
上传成功
-> 立即解析
-> 返回解析结果
异步模式:
上传成功
-> 创建解析任务
-> 返回 documentId -> 后台解析
建议第一版:
上传接口只返回 documentId解析接口单独触发
比如:
POST /api/18/documents/{documentId}/parse
这样更容易调试。
13. curl 测试
上传文件:
curl -X POST http://127.0.0.1:8080/api/17/documents/upload \ -F "file=@./docs/demo.pdf"```
上传 Markdown:
```bash
curl -X POST http://127.0.0.1:8080/api/17/documents/upload \ -F "file=@./README.md"```
测试不支持类型:
```bash
curl -X POST http://127.0.0.1:8080/api/17/documents/upload \ -F "file=@./demo.exe"```
期望返回明确错误。
## 14. 上传链路的工程化验收
上传成功不能只看接口返回 `200`。一个可交付的上传链路,还要同时保证文件、数据库记录和后续任务之间能够对应起来。
### 14.1 一致性边界
建议把上传过程拆成三个明确阶段:接收临时文件、持久化原文件、提交文档记录。任一阶段失败,都要知道前面已经留下了什么。
- 文件已保存但数据库写入失败:记录补偿日志,并由定时任务清理孤儿文件。
- 数据库已写入但任务创建失败:文档保留 `UPLOADED` 状态,允许重新触发解析。
- 客户端超时后重复提交:使用文件摘要、业务幂等键或上传请求 ID 判断是否重复。
不要把“删除文件”当作唯一补偿方式。网络抖动、进程退出和对象存储短暂不可用,都可能让同步回滚失败,因此还需要可重试的后台清理机制。
### 14.2 可观测性和审计
每次上传至少应记录 `requestId`、`documentId`、原始文件名、安全文件名、文件大小、SHA-256、耗时和最终状态。日志中不要输出文件正文,也不要记录对象存储的永久访问凭证。
建议关注以下指标:
| 指标 | 用途 |
| --- | --- |
| 上传成功率 | 判断入口是否稳定 |
| P95 上传耗时 | 发现磁盘、网络或对象存储变慢 |
| 校验失败次数 | 观察大小、类型和空文件问题 |
| 重复文件命中率 | 评估去重策略是否有效 |
| 孤儿文件数量 | 检查文件与数据库的一致性 |
### 14.3 验收标准
上线前至少验证正常文件、空文件、超限文件、伪造扩展名、重复上传、中文文件名、同名文件和存储失败等场景。每种失败都应返回稳定的错误码,并能通过 `requestId` 定位完整日志。
验收结果不应依赖人工查看目录。应该能通过数据库状态、接口响应和监控指标判断上传是否真正完成。
## 15. 常见问题
### 15.1 为什么不直接解析文件
上传和解析是两个职责。
上传关注:
```text
接收
校验
保存
入库
解析关注:
读取文件
抽取文本
保留元数据
处理失败
拆开后更容易定位问题。
15.2 文件名要不要保留中文
原始文件名可以保留在数据库。
服务器保存文件名建议使用 UUID。
这样可以避免编码、重名和路径安全问题。
15.3 如何处理重复文件
可以根据 sha256 判断。
第一版可以只记录 hash。
后面再决定:
禁止重复上传
允许重复但复用解析结果
同用户去重
同知识库去重
15.4 上传到本地磁盘够不够
学习阶段够。
正式项目建议对象存储:
MinIO
阿里云 OSSAWS S3
数据库只存对象路径。
16. 练习清单
完成几件事情:
配置 multipart 最大文件大小
实现 /api/17/documents/upload校验空文件
校验扩展名
生成 UUID 文件名
保存文件到 data/uploads计算 sha256写入 rag_document返回 documentId用 curl 上传 pdf/docx/md/txt用 curl 测试非法类型
建议目录:
ai-agent-study
├── java
│ └── rag-api
│ ├── DocumentUploadController.java
│ ├── DocumentUploadService.java
│ ├── DocumentUploadResponse.java
│ └── RagDocumentRepository.java
├── data
│ └── uploads
└── docs
└── document-upload.md
17. 小结
本节的结论:
文档上传要先做好校验、安全存储和元数据入库,解析和向量化不要急着塞进同一个接口。
最小链路:
multipart file
-> 校验
-> UUID 保存
-> sha256 -> rag_document -> documentId
有了稳定的 documentId,后面的解析、清洗、切片、Embedding 才能一步步接上。