SpringBoot文件上传下载及删除这套功能,说实话,几乎每个后台管理系统里都绕不开。不管你是做管理后台、门户网站还是内部OA,总得有个地方让人传头像、传附件、传Excel模板,传完之后还得能下、能删。我前前后后在职维护过好几个类似模块,每次以为“这还不简单,不就是MultipartFile接一下嘛”,结果一上线就被各种打脸:文件名乱码、上传几分钟后文件丢了、下载半天没响应、删不掉还说被占用……这篇就把我踩过的坑和最终验证可行的完整做法整理出来,给准备动手做或者正在调这套功能的同学一个参考。
1. 项目整体设计与技术选型
动手写代码之前,先把两个问题想清楚,后面会省掉很多返工。第一个问题是:文件到底存哪里?第二个问题是:前端怎么把文件给到后端,后端又怎么把文件还给前端?
1.1 存储方案选型:本机磁盘、数据库、还是对象存储
很多初学者会习惯性地想“把文件存到数据库里”,用BLOB字段或者Base64字符串。说实话,小文件、数量少、自己玩一玩没问题,但一旦并发上来、文件体积到几MB甚至几十MB,数据库的存储和查询压力会非常明显,而且备份恢复的时候你会想哭。所以常规做法是:文件本身存磁盘(或者云对象存储),数据库里只存文件路径、文件名、大小、上传时间这些元数据。
那本地磁盘方案行不行?分场景。如果是单机部署、内部系统、文件量不大,把文件存在项目所在机器的某个目录下,完全够用,这也是我今天重点分享的方案,因为它最通用、不依赖外部服务,你本地调试也很方便。如果你们公司已经有对象存储(比如私有化部署的MinIO,或者云上的OSS/COS),那就走对象存储,道理一样,只是把本地文件流交给SDK上传而已。不管哪种,核心的设计思路都一样:
- 上传接口接收文件,把文件内容写到存储目标,同时生成一条记录,保存文件的访问路径或存储键。
- 下载接口根据记录定位到文件实体,把流写给前端。
- 删除接口先删记录,再删文件实体。
我见过不少团队把“下载”做成“先查接口返回Base64,前端自己拼”,这种方式在移动端H5里偶尔能看到,但在后台管理系统里体验很差,而且大文件会把内存直接撑爆,后面我会讲为什么。
1.2 存本地磁盘时的目录规划
如果你决定用本地磁盘,目录规划比大部分人想的更重要。我见过有人直接把文件放到项目运行目录下,比如./upload/,看起来没问题,但部署的时候很容易出两种情况:目录在服务重启后被清掉,或者因为当前工作目录不对导致路径找不到。我的建议是,把存储路径做成配置项,强制要求填写绝对路径,并且在启动的时候自动创建目录。
spring: servlet: multipart: max-file-size: 20MB max-request-size: 100MB custom: file: upload-path: /data/file-store/ # 访问前缀,用于拼接可访问的URL access-prefix: /files这段配置里,upload-path就是文件要落盘的根目录,access-prefix是后面做静态资源映射的URL前缀。用绝对路径的好处很多:不受启动目录影响,备份方便,日志排查路径也清晰。我在实际项目里还会在这个根目录下按月份建二级目录,比如/data/file-store/202406/,避免单个目录下文件太多太散,这也算是一个后知后觉的优化。
2. 文件上传:从接入到落盘的完整实现
上传是这套功能里最核心的一环,也是代码量最多的地方。我这里按单文件上传、参数校验、多文件上传三个层次来讲。
2.1 单文件上传接口设计
先看一个完整可用的单文件上传接口。我习惯用一个统一定义的Result对象包返回值,这里为了减少干扰,只展示核心逻辑。
@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("上传文件不能为空"); } // 生成存储文件名:避免直接用原始文件名,防重名也安全 String originalFilename = file.getOriginalFilename(); String ext = StringUtils.getFilenameExtension(originalFilename); String storeName = UUID.randomUUID().toString().replace("-", "") + "." + ext; String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")); String absoluteDir = uploadPath + dateDir; File dir = new File(absoluteDir); if (!dir.exists()) { dir.mkdirs(); // 这一步别省 } File dest = new File(dir, storeName); try { file.transferTo(dest); } catch (IOException e) { log.error("文件保存失败", e); return Result.error("文件上传失败"); } String accessUrl = accessPrefix + "/" + dateDir + "/" + storeName; return Result.success(accessUrl); }这段代码有四个细节值得留意。
第一,file.transferTo(dest)是Spring封装的方法,它会根据目标文件的父目录是否存在做判断,但如果你不先mkdirs(),父目录不存在时依然会报错。所以提前确保目录存在是非常必要的,别指望框架帮你建。
第二,存储文件名我用了UUID加扩展名的形式,而不是直接用originalFilename。原文件名里可能带着中文、特殊字符、路径分隔符,直接拼到文件系统路径里非常危险,详情我在后面安全那块专门讲。
第三,StringUtils.getFilenameExtension这个方法,是从org.springframework.util.StringUtils里来的,它帮你把扩展名摘出来,比你自己substring(indexOf("."))靠谱得多,因为文件可能叫a.b.txt,自己写很容易把扩展名取错。
第四,如果原始文件是空文件,file.isEmpty()在Spring Boot里判断的是文件大小是否为0,这个和getSize() == 0等价,所以别真的只判断file == null。
接口写完之后,前端用form-data格式提交即可,name必须是file,因为@RequestParam("file")指定的就是这个名字。
2.2 多文件上传与批量上传
多文件上传和单文件其实是同一个思路,只是参数类型从MultipartFile换成MultipartFile[],或者用List<MultipartFile>。
@PostMapping("/upload/batch") public Result<List<String>> uploadMultiple(@RequestParam("files") MultipartFile[] files) { List<String> urls = new ArrayList<>(); for (MultipartFile file : files) { if (file.isEmpty()) { continue; } // 复用单文件保存逻辑 urls.add(saveFile(file)); } return Result.success(urls); }前端如果用axios并发上传多个文件,实际上每个文件都是一个独立的请求,那你可以直接并发调单文件接口,不一定需要这个批量接口。批量接口更适用于“一次请求携带多个文件”的场景,比如网页表单里一次选择多张图片然后统一提交。这两种方式我都会留着,反正实现成本很低,但前端习惯因人而异,你有哪个就调哪个。
这里有一个容易被忽略的点:批量上传时,如果其中一个文件保存失败,怎么办?我建议的做法是“能存几个存几个,返回时把成功列表和失败原因都带上”,而不是某个失败就把前面已经成功的文件全部回滚删除。因为回滚逻辑本身很复杂,而且用户那边其实更关心哪些传成功了。你可以这样设计返回结构:
public class UploadResult { private List<String> successUrls; private List<String> failedFiles; }2.3 上传校验:类型、大小、拦截时机
上传时的校验是很多人以为做了但实际没做对的地方。
Spring Boot在spring.servlet.multipart.max-file-size配置的约束下,上传文件超过指定大小会直接抛MaxUploadSizeExceededException,这个类有两个来源:如果使用Tomcat容器,是org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException;如果使用Undertow等其他容器,异常的包路径又不同。所以建议你捕获的时候直接捕获通用的MaxUploadSizeExceededException,别纠结具体子类,否则换容器就凉。
文件类型校验则要分两层。第一层是后缀名校验,比较原始文件名后几位,这种校验一眼就能被绕过,比如把exe改成png照样能传上来。第二层是内容校验,也就是读取文件头几个字节,比对魔数(Magic Number)判断真实类型。比如JPEG文件的文件头是FF D8 FF,PNG是89 50 4E 47。虽然花一点时间,但做文件上传功能,强烈建议至少做魔数校验,特别是允许用户传图片的场合。
byte[] header = new byte[4]; try (InputStream in = file.getInputStream()) { in.read(header, 0, 4); } String hex = bytesToHex(header); if (!allowedImageMagicNumbers.contains(hex)) { return Result.error("仅支持图片类型"); }这个魔数校验在“传上来的是图片伪装成txt”的场景中特别有用,但要注意:它只能校验文件真实类型,不能保证文件内容没有恶意代码(比如藏了脚本的图片),所以对上传的文件,后续如果有解析动作(比如转码、预览),必须在隔离环境处理。
3. 文件下载:三种方式与中文乱码实战
上传搞定了,文件也落到磁盘了,接下来就是怎么让用户把它下载下来。
3.1 ResponseEntity流式下载
我推荐的方式是用ResponseEntity配合InputStreamResource,代码简洁,而且能很好地控制响应头。
@GetMapping("/download/{fileId}") public ResponseEntity<InputStreamResource> download(@PathVariable String fileId) { // 根据fileId从数据库查到存储路径和原文件名 FileRecord record = fileRecordService.getById(fileId); if (record == null) { return ResponseEntity.notFound().build(); } File file = new File(record.getStorePath()); if (!file.exists()) { return ResponseEntity.notFound().build(); } HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); headers.setContentDisposition( ContentDisposition.attachment() .filename(record.getOriginalName(), StandardCharsets.UTF_8) .build()); InputStreamResource resource = new InputStreamResource(new FileInputStream(file)); return ResponseEntity.ok() .headers(headers) .contentLength(file.length()) .body(resource); }这里特别要讲的是ContentDisposition。大部分人遇到下载文件名中文乱码,是因为自己手动拼了Content-Disposition: attachment; filename=xxx.txt,这种方式对ASCII文件名没问题,但中文文件名会被浏览器直接忽略或者显示成乱码。ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8)会生成符合RFC 5987规范的文件名编码,把中文转成filename*=UTF-8''%E4%B8%AD%E6%96%87.txt这种形式,大部分现代浏览器都能正确处理。
不要用URLEncoder.encode对文件名整体编码,因为+号在某些浏览器里会被解析成空格,导致文件名不完整。
3.2 使用HttpServletResponse的写法
如果你在项目里用的是继承HttpServlet的老式Servlet风格接口,或者需要边写边控制输出,也可以用HttpServletResponse。
@GetMapping("/downloadOld/{fileId}") public void downloadOld(@PathVariable String fileId, HttpServletResponse response) throws IOException { FileRecord record = fileRecordService.getById(fileId); File file = new File(record.getStorePath()); response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + URLEncoder.encode(record.getOriginalName(), StandardCharsets.UTF_8).replace("+", "%20")); Files.copy(file.toPath(), response.getOutputStream()); }注意一个关键点:filename*=后面指定的编码规则里,空格应该被编码成%20,而不是+,所以上面代码里我做了replace("+", "%20")。这一步很多人漏,导致下载的中文文件名里出现+号。我自己在早期写这个逻辑时也踩过这个坑,后来发现网上很多文章只是教URLEncoder.encode,却没提醒替换,那时候真是被坑得不轻。
3.3 文件下载时的路径定位
下载接口入参是fileId,后端根据fileId拿到数据库里存好的storePath再去读文件。这里有一个设计原则:绝不要直接让前端传入“文件路径”来下载。如果下载接口长这样/download?path=/data/xxx/aaa.txt,那攻击者就可能尝试/download?path=/etc/passwd,这是很典型的任意文件读取漏洞。
所以正确设计是:文件记录表里保存唯一的业务ID(可以用自增主键,也可以用UUID),下载接口只认ID,由后端去数据库里查真实路径。这样既能隔离前端对路径的感知,又能避免路径注入风险。
我在某个内部系统里接手的时候就遇到过后端接口接收String path直接下载文件的情况,发现已经被人扫过好几次了。改造成ID索引后,这个问题彻底消除。如果你已经有用路径下载的接口,赶紧收口。
4. 文件删除与静态资源访问映射
删除看起来最简单,不就是file.delete()吗?但实际里牵涉的记录删除、关联清理、资源映射,都值得单独说说。
4.1 删除接口:先删库记录还是先删文件
删除接口的逻辑通常是这样:根据文件ID查到文件记录,拿到存储路径,然后先删数据库记录,再删磁盘文件。
@DeleteMapping("/delete/{fileId}") public Result<Void> delete(@PathVariable String fileId) { FileRecord record = fileRecordService.getById(fileId); if (record == null) { return Result.error("文件不存在"); } // 1. 删库记录 fileRecordService.removeById(fileId); // 2. 删物理文件,删除失败也要保证接口返回成功,避免前端卡死 File file = new File(record.getStorePath()); if (file.exists()) { boolean deleted = file.delete(); if (!deleted) { log.warn("物理文件删除失败,id={},path={}", fileId, record.getStorePath()); } } return Result.success(null); }为什么先删数据库记录、再删物理文件?因为数据库记录是唯一的入口。如果先删物理文件,再删数据库记录,万一删除数据库记录这一步失败(比如数据库连接超时),那么就会出现库里还有记录、磁盘上文件已经没了的情况,前端再去下载就会得到404。反过来,即使物理文件删除失败,因为数据库记录已经没了,用户感知上就是“删除成功”,残留在磁盘上的文件最多是占用一点空间,之后可以靠清理任务扫掉。
这里我强烈建议:物理文件删除失败时不要返回失败给前端。你只要记录下来,后台告警即可。因为磁盘上残留一个无法通过业务入口访问的文件,并不会干扰业务,但如果你因此告诉用户删除失败,用户多试几次,反而可能引发更多问题。
4.2 静态资源映射:让上传后的文件能直接访问
前面上传接口返回的accessUrl形如/files/202406/xxx.jpg,如果后端不做任何配置,前端访问这个URL会得到404。有两种方式让它可访问。
方式一:配置addResourceHandlers。
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${custom.file.upload-path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadPath); } }addResourceLocations一定要以file:开头,并且末尾要带上目录分隔符。如果直接写file:/data/file-store,Spring匹配资源时会把它和请求路径拼接,容易出现/data/file-store202406这种少了斜杠的问题。我见过好几个同事在这个配置上前前后后折腾很久,就是为了加一个结尾斜杠。
方式二:如果你不希望通过SpringMVC来管,也可以用WebServerFactoryCustomizer给Tomcat加映射,但Spring Boot场景下addResourceHandlers是最自然的,也没必要引入容器API。
有了静态资源映射之后,上传的图片就能直接用<img src="/files/202406/uuid.jpg">展示,下载接口就可以只服务于“触发浏览器下载”的场景。这里有个细节:静态资源映射是直接暴露存储目录的,所以前面说的路径安全就更加重要,如果被人猜到URL,而目录下又没有做权限拦截,就会形成越权访问。建议把文件记录表设计成包含“所属业务”字段,访问时校验归属,或者干脆不开放静态访问,所有读取都走下载接口,由业务层控制权限。
4.3 记录元数据的表结构设计
顺带把我常用的文件记录表结构分享出来,字段不多但都比较关键。
CREATE TABLE file_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', store_path VARCHAR(512) NOT NULL COMMENT '存储绝对路径', access_url VARCHAR(512) NULL COMMENT '访问URL', file_size BIGINT NOT NULL COMMENT '字节大小', file_ext VARCHAR(32) NULL COMMENT '扩展名', biz_type VARCHAR(64) NULL COMMENT '业务类型', biz_id VARCHAR(64) NULL COMMENT '业务ID', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, delete_flag TINYINT DEFAULT 0, delete_time DATETIME NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;biz_type和biz_id用来标识这个文件属于哪条业务数据。比如用户头像,biz_type=user_avatar、biz_id=1001;合同附件,biz_type=contract、biz_id=C20240601001。这样删除用户的时候就能通过biz_type + biz_id把关联文件全部找到并清理。很多系统做到后面产生一堆“孤儿文件”,就是当初没设计这两个字段。
5. 常见问题与排查技巧实录
功能做完了,不代表能安稳上线,因为文件上传下载这套东西,在真实环境里的坑是最多的。我把踩过的问题按高频顺序列出来,附带排查方向,希望能帮你少走弯路。
5.1 大文件上传超时或者内存溢出
出现这个问题,先检查配置是否生效。Spring Boot的默认单文件上传上限是1MB,如果没改配置,超过1MB直接就报错了。配置要放在spring.servlet.multipart下而不是spring.http.multipart,后者在老版本可以,新版本已经废弃。
即使配置了max-file-size: 20MB,如果使用的是file.transferTo(),Spring会把MultipartFile先写入临时文件再转存,这个过程对内存占用其实很小。如果你在代码里习惯先file.getBytes()或者file.getInputStream().readAllBytes(),那就要小心,文件体积一大,JVM堆就被撑大了。正确做法是直接用transferTo或者流式拷贝,不要先把整个文件load到内存。
另外,Nginx层如果设置了client_max_body_size,默认是1MB,大文件上传时会在Nginx就返回413。遇到明明后端配置改了但前端返回413或504,多半是Nginx的配置没动。
5.2 下载文件名中文乱码
这个问题前面已经部分提到,这里总结一个方案速查:
| 场景 | 推荐做法 |
|---|---|
| 浏览器直接下载 | 使用ContentDisposition.attachment().filename(name, UTF_8) |
| 移动端或低版本浏览 | 同时设置filename和filename*,后者用RFC 5987格式 |
使用URLEncoder.encode手工拼头 | 记得把+替换成%20 |
如果你对前端兼容性要求高,最稳妥的办法是直接给一个ASCII的默认文件名,比如download.txt,然后把真实文件名放响应头X-Download-Filename里,前端拿到之后自己改名。这个方案丑但极为稳定,几乎所有环境都兼容。
5.3 上传后目录里能看到文件,但下载时404
出现这种情况,首选排查store_path是否真的是绝对路径。常见原因是开发机上用的相对路径,比如./upload/,idea运行时当前目录可能是项目根目录,部署到服务器后当前目录变成/opt/app,路径就错位了。解决办法:配置里只允许绝对路径,加个启动校验。
@Value("${custom.file.upload-path}") private String uploadPath; @PostConstruct public void checkUploadPath() { Path path = Paths.get(uploadPath); if (!path.isAbsolute()) { throw new IllegalStateException("上传路径必须配置为绝对路径: " + uploadPath); } Files.createDirectories(path); }这段校验代码建议保留,它能在服务启动的时候就把配置问题暴露出来,而不是等用户上传之后才发现文件没落盘。
5.4 文件被占用,删除失败
Windows环境开发时,常见的问题是文件刚用InputStream读取完,没有关闭流,导致删除时file.delete()返回false。Linux上很少见,但Windows上很常见。
遇到这种问题,先检查代码里有没有未关闭的输入输出流。下载接口中如果使用Files.copy(file.toPath(), response.getOutputStream()),你不需要手动关闭输出流;但如果用了FileInputStream包装成InputStreamResource,有一些写法需要确保流随响应关闭。最容易排查的方式是:把删除失败的日志打印出来,看一眼是不是在Windows上,并且下载动作刚执行完就立刻删除。遇到这种场景,建议用file.deleteOnExit()兜底,或者延迟几秒再物理删除。
5.5 路径穿越漏洞与文件名安全
这是上传功能最容易被攻击的点。攻击者可以把文件名改成../../evil.sh,如果你的代码里把文件名直接拼到路径上,文件就会被写到站外目录。解决方式很简单,就是前面代码里做的:不要用原始文件名作为存储文件名,用UUID或者时间戳重新生成,扩展名用StringUtils.getFilenameExtension提取。
如果你确实需要在下载时展示原始文件名,也是从数据库记录里取出original_name写入响应头,而不是从文件名倒推。
另外,原始文件名里可能含有/、\、控制字符,这类字符在任何文件系统下都应该被当成非法字符处理。展示给用户时无所谓,保存文件名时一定要过滤。
5.6 多实例部署时本地存储的尴尬
如果你们系统部署了多个节点,用本地磁盘存储会遇到一个经典问题:请求打到B节点,但文件存在A节点上,导致下载404。这个问题的终极解法是走对象存储,但如果确实只能用本地磁盘,则至少要做到:上传和下载尽量路由到同一节点,或者用共享存储(NFS、CIFS)挂载同一目录。共享存储是一个折中方案,但需要考虑网络存储的IO性能,大文件频繁读写时会有明显影响。
如果项目初期就预期会多实例部署,我建议直接上对象存储,别在本地方案上投入太多。
6. 与前端联调时的几个约定
最后再说几个和前端联调时经常反复扯皮的点,提前约好能省很多沟通成本。
上传请求的Content-Type格式必须用multipart/form-data,前端如果用了JSON.stringify去传文件,后端是接不到的。很多前端刚接触上传会选择把文件转成Base64后放在JSON里,后端需要专门处理Base64解码,没必要,强烈不推荐。
下载接口统一约定为返回application/octet-stream,前端通过responseType: 'blob'接收,然后用URL.createObjectURL生成临时链接触发下载。这里有一个前端的坑:如果下载失败,后端返回的是JSON错误体,但前端用blob接回来,错误体也会被封装成Blob,导致前端无法正常弹错误提示。所以建议下载接口要么返回200和文件流,要么返回标准错误结构且前端在联调时约定好辨别方式。可以在接口里约定:失败时返回一个Content-Type: application/json的响应,前端判断响应头再决定是下载还是弹错误。
轮询上传进度这种功能,如果是用原生的XMLHttpRequest,监听xhr.upload.onprogress就可以;如果用axios,需要传入onUploadProgress回调。这些都属于前端范畴,但后端联调时知道这些,能帮你判断是不是前端把上传搞成了普通请求导致进度一直为零。
我个人在实际维护这套功能时的体会是:文件上传下载删除这套代码本身并不复杂,复杂的是对边界的处理——文件名、文件大小、文件路径、并发访问、跨平台文件系统行为差异。你把这些边界先想清楚,写出来的代码自然能稳稳跑上很久。如果之后公司上了对象存储,迁移时核心逻辑完全可以复用,只需要把“存文件”“读文件”“删文件”这三个动作替换成SDK调用就可以了。
最后再分享一个小经验:在给上传接口做测试时,别只测正常的图片和文档,一定用curl带一个奇怪文件名的文件测一次,比如curl -F "file=@/tmp/中文 文件 (1).pdf"。很多上线后才暴露的问题,在本地这样随手一测就能发现。