1. 现实问题:文件上传成功后,系统仍可能不安全
上传接口除了接收字节,还要处理文件名、内容类型、大小、存储位置、权限和失败清理。把这些逻辑塞进 Controller,会让每个接口重复。请求耗时日志、traceId 和登录检查又横跨多个请求,更适合过滤器或拦截器。异常如果由每个方法自己拼 JSON,客户端就无法稳定处理。
我们只做边界:接收 MultipartFile,生成服务端文件名,返回资源 id;不信任原始文件名,不把文件直接放进公开目录,不把异常堆栈返回给用户。
2. 最小可运行示例:接收并转译结果
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@RestController
class CoverController {
@PostMapping(path = "/api/articles/42/cover", consumes = "multipart/form-data")
UploadResult upload(@RequestPart("file") MultipartFile file) {
if (file.isEmpty() || file.getSize() > 5 * 1024 * 1024) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "文件为空或超过大小限制");
}
String storedKey = "covers/" + java.util.UUID.randomUUID();
// storage.put(storedKey, file.getInputStream(), file.getContentType());
return new UploadResult(storedKey, file.getSize());
}
}
record UploadResult(String key, long size) {}
真实项目应读取内容签名、限制扩展名和解压行为,使用 getOriginalFilename() 只作展示,不能直接拼路径。Controller 示例用 ResponseStatusException 说明结果,统一异常类会更适合大型项目。
3. 调用链与对象变化
Servlet multipart 解析器先把请求边界拆成文件头和临时/内存内容,Controller 参数绑定成 MultipartFile。文件名、大小和 content type 是不可信元数据;Service 校验后生成随机 key,Storage adapter 读取流并写到受控位置,最后返回不包含本地路径的 UploadResult。
拦截器的 preHandle 可读取请求上下文并提前拒绝,postHandle 在 Controller 返回后但视图渲染前执行,afterCompletion 适合清理和记录最终异常。统一异常解析器接住绑定、校验、领域和未知异常,生成稳定的 code/message/traceId JSON。
4. 为什么这样设计
文件字节属于外部资源,存储实现可能是本地磁盘、对象存储或测试内存;Controller 不应依赖具体路径。拦截器适合与 Handler 相关的横向规则,Filter 更早、能覆盖所有 Servlet 请求;认证通常由 Spring Security 处理,不要用一个自写拦截器替代完整安全链。
统一异常让前端只处理一份契约,也能防止泄露堆栈和 SQL。REST 资源响应应使用明确状态码:上传成功 201,参数错误 400,权限不足 403,资源不存在 404,冲突 409。动作接口可以返回资源表示或 operation id,不要所有结果都 200。
5. 项目落点:上传和文章状态分开提交
先上传得到 media key,再在文章 Service 中保存引用;若文章更新失败,要清理孤儿文件或用异步补偿任务。数据库事务无法自动回滚对象存储,所以必须记录状态和重试。文件下载同样做权限检查,公开 URL 和私有签名 URL 分开。
练习:增加 HandlerInterceptor 记录耗时,增加 @RestControllerAdvice 处理 ArticleNotFoundException、MethodArgumentNotValidException 和未知异常;写 MockMvc 测试确认上传过大、空文件、未认证和存储失败各返回稳定响应。
6. 易错排查
- 上传 415:确认 consumes、multipart boundary、字段名和客户端请求方式。
- 文件名路径穿越:不要直接 resolve 原始文件名,使用随机 key 并固定根目录。
- 异常返回 HTML:检查异常处理器顺序、Accept、容器错误页和自定义响应体。
- 拦截器没有覆盖静态或异步请求:确认注册路径和 Filter/Security 的职责边界。
7. 一页复习
上传链是 multipart 解析 → 元数据校验 → 生成存储 key → 写入适配器 → 保存引用;横向请求规则选择 Filter、Interceptor 或 Security;异常统一翻译成稳定 REST 响应。跨文件系统和数据库的成功需要补偿策略,不要假设一个事务能回滚所有外部副作用。
上传对象的状态应包含原始文件名、content type、大小、临时位置、最终 key 和扫描状态,但只有经过校验的 key 才能进入数据库。请求成功返回的是资源标识和大小,不应返回服务器绝对路径;存储失败时临时流和文件要清理,文章引用不能先写成可公开状态。数据库提交和对象存储完成不是一个天然事务。
项目可以把 CoverController 放 web,UploadService 放 application,Storage 端口放 domain/application,LocalStorage 或 S3 实现在 infrastructure;Interceptor 只记录请求上下文,Security 负责身份,Advice 负责错误响应。这样文件大小规则、权限规则、存储路径和响应格式各有拥有者,换存储供应商不需要改 Controller。
排错时 415 查 multipart boundary/字段名/consumes,空文件查解析器和客户端,路径穿越查是否使用原始文件名和最终 normalize 路径,公开了未扫描文件查状态迁移和下载授权;异常变成 HTML 时查 Advice 顺序与 Accept。上传慢要分别记录接收、扫描、写存储和数据库耗时,不要只把接口 timeout 调大。
练习是增加上传文件 hash、大小上限和允许类型白名单,使用临时目录测试成功和失败清理;再模拟数据库写入失败,验证文件进入待清理队列。用 MockMvc 验证错误 code、traceId 和状态码,使用一次真实下载请求验证只有授权用户能读私有对象。
上传的输入状态至少包括 multipart boundary、字段名、原始文件名、媒体类型、长度和字节流;经过校验后才得到内部 storage key;存储成功后得到对象地址和 hash;数据库引用保存后才可以被文章读取。任意一步失败,都要决定临时资源是否删除、文章状态是否回滚以及客户端是否可以安全重试。
拦截器和过滤器的调用位置不同:Filter 可以在 Servlet 参数解析前添加 traceId,multipart 解析后 Controller 才能拿到 MultipartFile,Interceptor 可以围绕 HandlerMethod 记录耗时,Security 过滤器负责身份。把上传权限写在一个普通 Interceptor 里可能绕过其他请求入口,项目应先确定哪条规则属于全局安全链。
文件落点建议是 web/media/CoverController、application/media/UploadService、application/port/Storage、infrastructure/storage/LocalStorage。Service 接收受限的输入和 actor,Storage 只处理字节和 key,数据库 Repository 保存 media record。公开下载再经过一个授权查询,不允许拿到 key 就直接拼本地路径。
排错时 415 查 Content-Type 和 boundary,上传空查客户端字段与解析器,超过大小查网关/Boot/业务三层限制,路径穿越查原始文件名和 normalize 后路径,存储成功但文章无图查数据库引用事务,错误变 HTML 查 Advice/Accept。上传慢要把接收、hash、扫描、写对象和写库分段计时。
练习是加上 hash 去重和 pending 状态:同一个文件重复上传返回已有 media id,扫描失败保持不可公开,数据库保存失败进入清理队列。用 MockMvc 测输入,用临时目录测存储,用真实下载测权限,分别保存响应、文件状态和数据库行,证明外部对象不能靠数据库事务自动回滚。
验证清单:准备合法 multipart、空文件、超限文件、错误类型、路径穿越文件名和未授权下载六组输入,记录 boundary/字段名、校验结果、storage key、文件状态、数据库引用和响应 code。正常上传必须先得到受限 key,再写入对象并保存引用;任意中途失败都要检查临时文件是否删除、文章是否仍为 pending、客户端是否可以安全重试。故意让数据库 insert 失败,再让对象存储写失败,比较补偿队列和清理结果;如果响应返回了服务器绝对路径或原始文件名进入 key,说明边界仍不安全。
复盘调用链时把 Filter、multipart resolver、Interceptor、Controller、Service、Storage 和 Advice 排成顺序:Filter 先建立 trace,解析器把字节变成 MultipartFile,Service 验证元数据并生成内部命令,Storage 返回对象结果,Repository 保存引用,Advice 只翻译错误。下载路径再反向验证 actor、资源状态和读取流,不能因为上传时已经鉴权就跳过私有对象检查。
进阶附录:断点续传与病毒扫描
大文件可用分片和临时 upload session,完成后再合并并校验 hash。生产上传常接入异步病毒扫描和内容审核,文章状态先保持 pending,扫描通过后才公开。所有临时文件都要有过期清理任务和容量指标。
本课按「Spring Boot 4.1 MVC 上传、拦截器与 REST 异常处理」的学习范围组织,正文与示例均为本站原创整理。