文件上传看起来只是接收一个 MultipartFile,再调用 transferTo()。但线上真正容易出问题的地方,是上传大小失控、恶意文件名、覆盖已有文件、数据库记录与磁盘文件不一致,以及下载接口越权。本文从一个"业务附件"场景出发,构建可以审计、可以清理、可以扩展到对象存储的处理流程。
技术栈:Java 17、Spring Boot 3.5、Spring MVC、Spring JDBC、PostgreSQL;本文代码为可复用设计示例,未在独立工程执行。
一、先定义上传接口的边界
业务场景:用户给自己有权限的工单上传 PDF 附件,最大 10 MiB,允许下载和删除。
与"直接把文件放进 static 目录"相比,推荐流程是:
- 认证并验证用户有权操作目标工单。
- Servlet 容器限制整个 multipart 请求的大小。
- 服务端验证文件内容、实际读取字节数和允许的类型。
- 文件先写入受控临时目录,计算 SHA-256。
- 将文件移动到不对外暴露的持久化目录。
- 数据库保存文件元数据和业务关联。
- 下载必须重新校验权限,绝不直接暴露服务器真实路径。
这里的"允许 PDF"不等于文件安全。PDF 仍可能携带恶意内容;面向公网或允许预览的系统,还需要病毒扫描、隔离和安全的内容处理策略。
二、配置 Multipart 大小限制
Spring Boot 的 multipart 配置来自 spring.servlet.multipart。官方文档说明默认单文件限制为 1MB、请求限制为 10MB;业务需要明确覆盖。
yaml
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 12MB
file-size-threshold: 0B
location: /var/app/upload-tmp
max-request-size 应考虑表单字段和协议开销,不建议恰好等于单文件大小。location 是容器接收 multipart 时使用的临时目录,需确保目录存在且进程有权限。
反向代理还可能有独立限制。例如 Nginx 的 client_max_body_size 可能在请求到达 Spring Boot 前返回 413;生产排查要检查整条链路。
不要通过设置 -1 取消限制。如果确实需要超大文件,优先采用分片上传、对象存储直传、上传会话和独立配额。
三、为什么不能信任原始文件名?
下面这种写法不安全:
java
Path target = uploadRoot.resolve(file.getOriginalFilename());
file.transferTo(target);
客户端提供的文件名可能包含路径分隔符、特殊字符、重名或伪造后缀。即使清理了文件名,也不应把它当作存储键。
推荐策略:
- 原始文件名仅作为展示字段,限制长度并去掉控制字符。
- 存储键由服务端生成随机 UUID,不保留用户提供的路径。
- 所有文件写在固定、非公开、专用的存储根目录。
- 存储目录权限遵循最小权限原则,不允许应用执行上传内容。
- 下载时使用数据库里的存储键,不允许客户端传任意文件系统路径。
java
String objectKey = UUID.randomUUID().toString() + ".bin";
Path target = storageRoot.resolve(objectKey);
这里的 .bin 是存储内部名称;下载时通过响应头提供安全处理后的展示文件名。
四、元数据表:文件状态必须可追踪
以 PostgreSQL 为例:
sql
CREATE TABLE attachment (
id UUID PRIMARY KEY,
ticket_id BIGINT NOT NULL,
owner_user_id BIGINT NOT NULL,
original_name VARCHAR(255) NOT NULL,
object_key VARCHAR(80) NOT NULL UNIQUE,
content_type VARCHAR(100) NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_attachment_ticket
ON attachment(ticket_id, status);
状态可以设计为 PENDING、READY、DELETING、FAILED。如果对象存储与数据库分别提交,状态机有助于处理部分成功。
不要把"数据库事务提交"误认为磁盘文件也自动回滚。文件系统和关系数据库之间没有天然的单个 ACID 事务。
五、核心实现:流式写入、限额和摘要
下面示例使用 Java NIO 写入私有暂存文件,不使用 getBytes() 将整个文件读入堆内存。
java
public record StoredFile(
String objectKey,
long size,
String sha256
) {}
@Service
public class LocalAttachmentStorage {
private static final long MAX_BYTES = 10L * 1024 * 1024;
private final Path root = Path.of("/var/app/attachments")
.toAbsolutePath().normalize();
public StoredFile store(MultipartFile file) throws Exception {
Files.createDirectories(root);
Path temp = Files.createTempFile(root, ".upload-", ".part");
String key = UUID.randomUUID() + ".bin";
Path target = root.resolve(key);
long count = 0;
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try {
try (InputStream in = file.getInputStream();
OutputStream out = Files.newOutputStream(temp)) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
count += read;
if (count > MAX_BYTES) {
throw new IllegalArgumentException("文件超出大小限制");
}
digest.update(buffer, 0, read);
out.write(buffer, 0, read);
}
}
if (count == 0) {
throw new IllegalArgumentException("不允许空文件");
}
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
return new StoredFile(
key, count, HexFormat.of().formatHex(digest.digest()));
} finally {
Files.deleteIfExists(temp);
}
}
}
这段代码有意保持最小化:调用方仍需检查权限、文件类型和数据库状态;生产环境要处理文件系统不支持原子移动的情况。由于暂存与目标都在同一根目录,通常更容易获得同一文件系统内的原子移动,但不能无条件保证。
如果 Files.move 已成功而数据库保存失败,目标文件会留下孤儿文件。这个问题不能只靠 finally 删除暂存文件解决。
为什么还要在读取时计数?
Servlet multipart 限制是第一道防线;业务读取时再次计数是防御性检查,避免后续更换接入方式时绕过业务约束。
MultipartFile.getSize() 可以用于提前拒绝,但不能替代实际读取时的大小控制。
六、文件类型校验:扩展名与 Content-Type 都不够
客户端提交的 Content-Type: application/pdf 可以伪造,report.pdf 的后缀同样可以伪造。
PDF 的常见文件签名是 %PDF-。可以在受控暂存文件上读取头部做基础识别:
java
static boolean looksLikePdf(Path path) throws IOException {
try (InputStream in = Files.newInputStream(path)) {
return Arrays.equals(
in.readNBytes(5),
new byte[]{'%', 'P', 'D', 'F', '-'});
}
}
文件签名只是初筛,不是安全扫描。对不可信文件,仍需考虑反病毒扫描、PDF 解析器漏洞、嵌入脚本、压缩炸弹和恶意文档。
建议将"可上传"与"可预览"分开:上传完成后先处于待扫描状态,通过安全处理后才允许在线预览。
如果业务只是提供下载,可以优先使用 Content-Disposition: attachment,降低浏览器直接执行或解析上传内容的风险。
七、数据库与文件系统不一致怎么办?
常见失败窗口:
| 阶段 | 失败 | 后果 | 补偿 |
|---|---|---|---|
| 暂存写入 | 超限或断线 | 临时文件残留 | finally 清理 |
| 文件移动 | 目标磁盘满 | 没有正式文件 | 返回失败 |
| 文件移动成功 | DB 插入失败 | 孤儿文件 | 记录并清理 |
| DB 已提交 | 服务进程宕机 | 可能未响应客户端 | 幂等查询 |
| 删除 DB 成功 | 删除文件失败 | 垃圾文件 | 异步重试 |
小型系统可以先采用"文件写入成功 → DB 写入 → DB 失败时尝试删除文件"的补偿方案,但仍存在进程突然崩溃的窗口。
更稳健的方式是:
- 先创建
PENDING元数据,分配上传 ID。 - 文件写入与安全检查完成后更新为
READY。 - 定时扫描长时间停留在
PENDING的记录与孤儿文件。 - 删除时先改为
DELETING,异步删除对象并完成最终状态变更。
对于对象存储,还可利用生命周期规则清理未完成的临时对象。
重要原则:不要因为补偿失败而静默返回"成功"。 应记录操作 ID,并让后台能够定位和重试。
八、下载接口:权限校验必须重新执行
不要设计成 GET /download?path=/var/app/attachments/xxx。
更合理的接口是:
text
GET /api/tickets/{ticketId}/attachments/{attachmentId}
服务端先根据认证用户、工单 ID、附件 ID 查询元数据并校验授权,再从私有存储读取内容。
java
@GetMapping("/api/tickets/{ticketId}/attachments/{id}")
public ResponseEntity<Resource> download(
@PathVariable long ticketId,
@PathVariable UUID id,
Authentication authentication) {
Attachment meta = attachmentService.requireReadable(
ticketId, id, authentication.getName());
Resource resource = storage.load(meta.objectKey());
ContentDisposition disposition = ContentDisposition.attachment()
.filename(meta.originalName(), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
disposition.toString())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
Attachment、attachmentService、storage 是业务抽象,示例不是独立可运行的完整项目。load 必须限制在受控根目录,禁止通过用户输入构造路径。
对敏感附件,应设置合适的 Cache-Control,并记录必要的下载审计信息。
九、上传 API 的错误码与幂等设计
建议明确区分:
- 400:空文件、非法格式、缺少必要字段。
- 401/403:未登录或无业务权限。
- 413:请求超过大小限制。
- 409:上传状态冲突或幂等键重复且内容不一致。
- 500/503:磁盘、数据库、对象存储等内部故障。
上传超时后,客户端可能不知道服务端是否成功。可以为上传会话分配 uploadId,并提供状态查询接口,避免盲目重复创建附件。
若业务需要"同一个请求只能产生一条附件",可以使用经过身份绑定的幂等键和数据库唯一约束。仅依赖 SHA-256 去重并不等于幂等:同一文件可能被合法上传到不同工单。
十、测试要覆盖哪些真正危险的边界?
至少覆盖:
- 空文件和恰好等于上限的文件。
- 比上限多 1 字节的文件。
- 伪造
Content-Type和错误 PDF 签名。 - 文件名包含
../、反斜杠和控制字符。 - 两个并发上传使用相同原始文件名。
- 文件移动成功、数据库写入失败。
- 进程中断后残留
PENDING的补偿。 - 其他用户尝试下载不属于自己的附件。
- 下载时附件已经被标记为删除。
- 文件不存在但数据库记录仍是
READY。
Spring MVC 层可用 MockMvc 测试请求限制与响应,但文件系统补偿、数据库事务和并发问题仍需要集成测试。
示例断言:
java
mockMvc.perform(multipart("/api/tickets/1/attachments")
.file(new MockMultipartFile(
"file", "empty.pdf", "application/pdf", new byte[0])))
.andExpect(status().isBadRequest());
这个断言依赖你的 Controller 和异常映射已实现空文件返回 400;本文未执行该测试,属于预期行为示例。
十一、常见问题
上传文件要不要存数据库 BLOB?
小文件且强事务一致性要求的场景可以评估 BLOB;大量附件通常更适合对象存储加数据库元数据。应综合备份、恢复、访问模式与成本选择。
文件名使用 UUID 就绝对安全吗?
不是。UUID 解决命名冲突和部分路径输入风险,不解决恶意内容、越权、目录权限与存储资源耗尽。
能否直接把 MultipartFile 上传到对象存储?
可以,但仍要做权限、大小、类型、状态管理和失败补偿。大文件可以考虑由服务端签发短期上传凭证进行直传,并在上传完成后验证对象属性。
上传成功但客户端超时怎么办?
不要立即重传并生成第二份业务附件。优先查询上传会话状态;必要时使用幂等键重试。
总结
安全文件上传不是一个 transferTo() 方法,而是从请求限制、可信存储键、内容验证、权限控制、失败补偿到下载审计的一条完整链路。
先明确存储和权限边界,再补齐状态机与故障恢复。真正上线前,要用异常注入和集成测试验证"部分成功"场景,而不是只测试正常上传。