在Spring Boot项目里塞个文件上传下载功能,听起来确实是“最简单”的那类需求,随便搜一下就能找到几十篇教程。但真到自己动手做,尤其是要放到生产环境给用户用的时候,问题就全冒出来了:文件名带中文怎么处理、用户传了个伪装成图片的脚本怎么办、下载接口被人拿路径穿越攻击怎么防、大文件传一半断了怎么续。这篇就把我实际项目中用过的一套完整方案整理出来,从环境搭建到安全加固,再到集成MinIO做对象存储,全部走一遍。
1. 功能拆解:文件上传下载到底包含哪些核心问题
1.1 你以为的上传下载 vs 实际要处理的边界场景
很多新手第一次接触文件上传,脑子里想的就是“前端一个input框,后端一个MultipartFile参数,完事”。但等你真正去看生产环境的文件服务,会发现要处理的事情多得多。
先说说文件上传。单文件上传只是基础,实际业务里更常见的是多文件批量上传,这时候前后端怎么约定数据结构就成了第一个问题。文件传上来以后存哪里?存本地磁盘要考虑磁盘空间、备份、扩容,存对象存储要考虑桶(Bucket)管理、访问权限、CDN加速。文件名怎么处理?用户上传的原始文件名千奇百怪,有带空格的、有带中文的、还有带../这种特殊字符的,直接拿来当存储文件名,轻则乱码,重则出安全漏洞。文件类型怎么校验?光看扩展名完全不可靠,一个.jpg文件内容可能是PHP脚本,这种文件一旦被服务器解析执行,服务器就沦陷了。
再说下载。下载接口要考虑的东西更多:是走普通下载还是支持断点续传?要不要支持浏览器在线预览?文件在对象存储里,是走代理转发还是直接302重定向到临时签名URL?用户下载文件的时候要不要记录操作日志?下载权限怎么控制,是不是只有登录用户才能下载?还有那个最经典的坑:响应头里的Content-Disposition处理不好,中文文件名直接变成一串乱码。
1.2 技术选型:为什么用Spring Boot做这件事
这个标题可能有点废话,但我想说的是,文件上传下载这个功能,用它做和用别的技术栈做,差别到底在哪。
Spring Boot做这件事的核心优势有三个。第一是生态成熟,MultipartFile这个抽象把Servlet标准的上传解析封装得非常好,你不需要去和HttpServletRequest里那一堆Part对象打交道,也不需要手动解析multipart/form-data格式的报文。第二是配置灵活,上传大小限制、临时目录、文件大小阈值,全都可以通过application.yml里的配置项调整,改完立刻生效。第三是安全体系完善,Spring Security可以很自然地和下载接口做权限集成,你不需要另外想一套鉴权方案。
当然,它不是没有缺点。Spring Boot默认的存储方案是本地磁盘,这在单机部署时完全够用,但一旦上了多节点部署,文件存在节点A上,请求打到节点B上就找不到了,这时候你就得上对象存储或者分布式文件系统。这个我们后面专门用一章来聊。
2. 环境准备与工程搭建
2.1 基础依赖与配置文件
先建立一个最简单的Spring Boot工程。我用的是Spring Boot 2.7.x,JDK 8还是11都行,但建议至少用11,因为后面有些代码写法会更方便。核心依赖只需要两个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>spring-boot-starter-web里已经包含了对multipart/form-data的原生支持,MultipartFile就是它提供的。spring-boot-starter-validation不是必须的,但强烈建议加上,后面做参数校验的时候会用到。
接着在application.yml里配置上传相关参数:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 100MB file-size-threshold: 2KB location: /data/tmp这几个参数的含义我展开说一下。
max-file-size是单个文件的最大大小,max-request-size是单次请求中所有文件加起来的最大大小。比如你允许用户一次传10张图片,每张最大5MB,那么max-file-size设5MB没问题,但max-request-size必须至少50MB,不然前端一次传10张图,后端直接给你拒了。
file-size-threshold是个很多人不知道的参数,意思是当文件小于这个阈值时,文件内容会直接保存在内存里,超过这个阈值才写入临时文件。调这个参数可以在上传小文件时提高吞吐量,但设得太大会导致内存压力增大,实测下来2KB到10KB之间是个合理区间。
location是临时目录,Spring会在这个目录下创建临时文件来协助上传解析。这个目录最好独立设置,并且确保磁盘空间充足,尤其是做大文件上传的时候。
2.2 存储策略:本地磁盘 vs 对象存储
在动手写接口之前,先把存储策略定了,否则后面返工成本很高。
如果项目规模不大、单机部署、文件量在几百GB以内,本地磁盘完全够用。实现上就是指定一个绝对路径,比如/data/files,然后按业务类型建子目录,比如/data/files/avatar/、/data/files/contract/,这样后面做定时清理或者迁移都比较方便。
如果项目一开始就打算多节点部署,或者文件量会快速增长,建议直接上对象存储。对象存储的接口标准是S3协议,开源的可以用MinIO,云厂商的可以用阿里云OSS、腾讯云COS、AWS S3。选对象存储的好处是:容量按需扩展、数据多副本冗余、自带CDN加速能力,而且应用和文件存储彻底解耦,应用节点挂了文件不会丢。
我这里给出一个很实际的建议:不管最终用不用对象存储,业务代码里的文件存储逻辑一定要做一层抽象。定义一个FileStorageService接口,接口方法就三四个:store(存)、load(取)、delete(删)、getUrl(获取访问URL)。本地磁盘实现和MinIO实现各写一套,用条件装配或者@Profile切换。这个抽象多花不了多少时间,但能让你后面切换存储方案的时候不伤筋动骨。
3. 文件上传核心实现
3.1 单文件上传:一个接口走通全流程
先写一个标准的单文件上传接口:
@RestController @RequestMapping("/api/file") public class FileController { private final FileStorageService fileStorageService; public FileController(FileStorageService fileStorageService) { this.fileStorageService = fileStorageService; } @PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { throw new BizException("上传文件不能为空"); } String fileId = fileStorageService.store(file); return Result.success(fileId); } }代码本身不复杂,但有几个细节值得说道说道。
第一,@RequestParam("file")里的名字必须和前端表单里的字段名一致,否则Spring会直接报Required request part 'file' is not present。如果前端用的是Vue,FormData里的append用的什么名字,后端就得写什么名字。
第二,接口返回值不要返回文件存储路径,要返回一个fileId。这个fileId可以是UUID,也可以是数据库里的主键或者业务主键。这样做的好处是:不把物理存储结构暴露给前端,后面就算调整存储目录结构也不会影响已上线的前端。
第三,store方法里会做一系列处理,包括生成存储名、校验类型、保存文件。这些逻辑抽到一个单独的FileStorageService实现类里,Controller保持轻薄。
后面我会给出完整的FileStorageService实现代码。
3.2 多文件上传:前端Vue与后端的配合细节
多文件上传,前端最常用的方案是把多个文件循环追加到同一个FormData对象里,然后一次性提交。核心代码如下:
// Vue 3 + Element Plus 场景 const uploadFiles = async (fileList) => { const formData = new FormData(); fileList.forEach((file) => { formData.append('files', file.raw); }); const response = await axios.post('/api/file/upload/batch', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); return response.data; };后端接收多文件,用MultipartFile[]数组接:
@PostMapping("/upload/batch") public Result<List<String>> uploadBatch(@RequestParam("files") MultipartFile[] files) { List<String> fileIds = new ArrayList<>(); for (MultipartFile file : files) { fileIds.add(fileStorageService.store(file)); } return Result.success(fileIds); }这里有个坑:前端循环append('files', ...),传到后端就是同名多值,Spring用MultipartFile[]数组接收没有任何问题。但如果前端某次只传了一个文件,也用append('files', file),Web容器把这个请求解析成单值,后端如果用数组接收反而会报错。稳妥的做法有两个:第一种,后端不区分单文件和多文件,统一接收数组,前端永远用数组结构提交;第二种,前端判断只有单个文件时,用另一个接口。我一般推荐第一种,简单省事,少一个分支就少一个bug。
还有一个前端配合的细节:axios默认的请求头是application/json;charset=utf-8,如果手动设置Content-Type为multipart/form-data,会导致浏览器无法自动生成正确的boundary边界标识,后端解析直接失败。正确的做法是:设置headers: { 'Content-Type': 'multipart/form-data' },让浏览器自动补全boundary,或者干脆不手写这个header,用axios默认行为。经验之谈,90%的前后端联调上传失败都是这个原因。
3.3 文件校验:类型、大小、内容嗅探
文件校验这块,我认为是上传功能里最不能跳过的一环,也是网上教程讲得最浅的一环。
先说大小校验。如果依赖application.yml里的max-file-size,那是在请求解析阶段由Web容器帮你拒绝大文件的,但这是最终防线。业务层也要做一个显式校验,因为配置文件可能被调整,而且有些场景下你希望给用户更友好的提示,而不是Web容器默认返回的413错误。
类型校验要分两层。第一层是扩展名校验,用StringUtils.getFilenameExtension(originalFilename)拿后缀,和允许列表比对。但这一步只能防“手滑”,防不了“恶意”。第二层是内容校验,通过读取文件头魔数(Magic Number)判断真实文件类型。比如JPEG文件开头固定是FF D8 FF,PNG是89 50 4E 47,PDF是25 50 44 46。用户把一个PHP脚本改名为avatar.jpg上传,扩展名校验完全看不出来,但内容嗅探一读文件头,发现不是合法图片格式,直接拦截。
public static void validateFile(MultipartFile file) { long maxSize = 20 * 1024 * 1024; if (file.getSize() > maxSize) { throw new BizException("文件大小超过20MB限制"); } String originalFilename = file.getOriginalFilename(); String extension = StringUtils.getFilenameExtension(originalFilename); if (!allowedExtensions.contains(extension.toLowerCase())) { throw new BizException("不支持的文件类型"); } byte[] bytes = file.getBytes(); if (bytes.length < 4) { throw new BizException("文件内容无效"); } String magic = String.format("%02X %02X %02X %02X", bytes[0], bytes[1], bytes[2], bytes[3]); if (!ALLOWED_MAGIC_NUMBERS.containsKey(extension.toLowerCase()) || !ALLOWED_MAGIC_NUMBERS.get(extension.toLowerCase()).contains(magic)) { throw new BizException("文件内容与扩展名不匹配"); } }ALLOWED_MAGIC_NUMBERS是一个Map<String, List<String>>,键是扩展名,值是该类型文件可能出现的魔数列表。判断逻辑用“内容里的魔数是否在允许列表”来算,而不是用“扩展名对应的魔数是否匹配内容”,这样更安全。
魔数校验的局限性我也说清楚:它只能识别文件头,图省事的话,一个攻击者在文件开头塞了合法图片的头部字节,后面接恶意代码,这种文件构造得好的话,魔数校验拦不住。所以魔数校验只是提高了攻击门槛,真正的兜底方案是第5章要讲的:把上传目录和Web容器的代码执行能力彻底隔离。
4. 文件下载核心实现
4.1 普通文件下载与Content-Disposition
下载接口的经典实现方式,是先把文件从存储系统中读取出来,然后通过ResponseEntity返回字节流:
@GetMapping("/download/{fileId}") public ResponseEntity<Resource> download(@PathVariable String fileId, HttpServletRequest request) throws IOException { StoredFile storedFile = fileStorageService.load(fileId); Resource resource = new FileSystemResource(storedFile.getFilePath()); String contentType = storedFile.getContentType(); String filename = storedFile.getOriginalFilename(); String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8) .replace("+", "%20"); return ResponseEntity.ok() .contentType(MediaType.parseMediaType(contentType)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename) .body(resource); }Content-Disposition这行是重点。attachment表示“附件方式下载”,也就是浏览器会弹下载,而不是直接在页面上打开。filename="..."是给老浏览器用的,filename*=UTF-8''是RFC 5987的标准写法,给现代浏览器用的。因为HTTP头只支持ASCII字符,中文文件名必须进行URL编码,用URLEncoder.encode处理。注意URLEncoder会把空格编码成+,这在URL的查询参数里是对的,但在Content-Disposition里是错的,所以要手动把+替换成%20。这个小坑,我见过太多人踩了后下载下来的文件名里带个加号。
如果只想让用户在线预览,把attachment换inline即可。浏览器会自动根据Content-Type决定是展示还是下载。
4.2 断点续传下载与Range请求
普通下载接口在文件比较大的时候会遇到一个问题:用户下载到一半网络断了,重新下载又得从头开始。HTTP协议本身已经提供了断点续传的机制,那就是Range请求头。
实现Range请求的核心逻辑是:解析Range头,比如bytes=0-1023,截取文件对应区间的内容作为响应体,同时设置响应头Content-Range: bytes 0-1023/2048和状态码206 Partial Content。如果请求头里没有Range,就返回完整文件,状态码200。
Spring提供了一种简化的写法,使用Resource配合HttpRange:
@GetMapping("/download/{fileId}") public ResponseEntity<Resource> download(@PathVariable String fileId, @RequestHeader(value = "Range", required = false) String rangeHeader) { StoredFile storedFile = fileStorageService.load(fileId); Resource resource = new FileSystemResource(storedFile.getFilePath()); long fileLength = resource.contentLength(); if (rangeHeader != null) { List<HttpRange> ranges = HttpRange.parseRanges(rangeHeader); if (!ranges.isEmpty()) { HttpRange range = ranges.get(0); long start = range.getRangeStart(fileLength); long end = range.getRangeEnd(fileLength); InputStream inputStream = resource.getInputStream(); inputStream.skip(start); byte[] bytes = IOUtils.toByteArray(inputStream, end - start + 1); String contentRange = "bytes " + start + "-" + end + "/" + fileLength; return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.CONTENT_RANGE, contentRange) .contentType(MediaType.APPLICATION_OCTET_STREAM) .contentLength(end - start + 1) .body(new ByteArrayResource(bytes)); } } // 无Range,返回完整文件 return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .contentLength(fileLength) .body(resource); }这里还忽略了一个重要环节:If-Range头和ETag配合处理。如果只实现Range而不实现ETag,当文件内容变更后客户端还拿着旧Range来请求,会出现数据错乱。所以生产环境如果真要做断点续传,建议连同Last-Modified、ETag一起加。如果不想自己也这么繁琐,可以直接用HttpMessageConverter的那一套,或者交给Nginx处理静态文件的Range。
我的个人建议是:如果文件是存在本地磁盘的,可以直接把下载接口指向Nginx的静态文件服务,让Nginx处理Range和性能优化;如果文件存在对象存储里,对象存储一般原生支持Range,为接口返回302跳转到对象的签名URL就行。业务代码里手动实现Range,其实是最折腾的一种方式,适用于中间件不支持、且必须由应用层转发字节流的场景。
4.3 下载场景的权限校验与日志记录
文件下载往往关联到具体业务资源,比如合同、订单附件、用户头像,所以接口必须在业务层做权限校验,不能裸奔。最基本的做法是:根据fileId查到这条文件记录,然后校验当前登录用户是否有权限访问它关联的业务对象。
登录用户的获取方式,在Spring Boot里通常是从SecurityContextHolder或者自定义的Interceptor里取。如果项目用的是Spring Security,可以这样写:
public StoredFile loadWithPermission(String fileId, Long userId) { FileRecord record = fileRecordMapper.selectByFileId(fileId); if (record == null) { throw new BizException("文件不存在"); } if (!record.getOwnerId().equals(userId) && !isPublic(record)) { throw new ForbiddenException("无权访问该文件"); } return convert(record); }日志记录这块也很重要。文件下载涉及敏感数据泄露风险,至少要把下载人、下载时间、文件ID、IP地址记录下来。高安全要求的场景,还要记录下载次数、是否下载成功、耗时多少,方便事后审计。
关于文件下载用的fileId,我强烈建议不要把原始文件路径传给前端。数据和物理路径分离,即使fileId泄露,也只能通过后端接口拿到真实路径,避免存储结构暴露。另外,fileId用UUID或者雪花ID都行,但不要用自增ID,因为自增ID很容易被遍历抓取,配合权限校验一旦有疏漏就会被批量下载。
5. 安全加固:文件上传漏洞与防护实践
5.1 文件上传漏洞是真实存在的攻击方式
写文件上传安全,是因为这个问题太容易被忽略了。很多开发者在本地测试上传功能时,随手传了个文本文件、图片,感觉流程通了就上线了。但攻击者盯上的恰恰是文件上传接口。
最常见的攻击方式有三种。
第一种是上传可执行脚本,比如shell.php、shell.jsp、shell.asp。如果服务器把上传文件保存在Web可达的目录,且Web容器配置了解析该脚本,攻击者直接访问上传文件的URL,脚本就在服务器上执行了。这种攻击通常被用来做后续的反弹Shell、内网渗透。
第二种是伪装文件类型,把恶意脚本扩展名改成.jpg、.png,希望绕过扩展名白名单校验。如果Web容器或中间件存在解析漏洞,比如Apache的*.php.jpg解析漏洞,Nginx的%00截断漏洞(针对老版本),攻击者就能让服务器把图片当脚本执行。
第三种是路径穿越,上传文件名里包含../../这样的路径,如果代码直接用原始文件名拼接存储路径,攻击者就能把文件写到服务器的任意位置,比如/etc/cron.d/下写个计划任务脚本,或者覆盖Web应用自身的配置文件。
5.2 实战加固方案清单
结合自己做的文件和参考OWASP的指南,我整理了一份接地气的加固清单。
第一,存储文件名一律服务端重命名。用UUID或SecureRandom生成的随机字符串作为存储名,不带任何用户输入和原始文件名。原始文件名只存到数据库记录里,用于下载时展示原文件名。这一招直接让路径穿越攻击失效,因为服务端根本不使用用户提供的文件名来拼接路径。
第二,存储目录隔离在Web应用发布目录之外。上传文件不要放在src/main/resources/static/这类可以被Web容器直接访问的位置。标准做法是放到一个独立的绝对路径,比如/data/files/,再将这个路径配置为Nginx的反向代理或Spring的静态资源映射。核心目的是:即使文件被成功上传,攻击者也无法通过URL直接执行它。
第三,执行权限关闭。存放上传文件的目录,不授予执行权限,例如在Linux下chmod -R 644 /data/files。对于JSP、PHP这类脚本解析型语言,没有执行权限就无法运行。
第四,扩展名白名单校验。只允许上传业务需要的类型,jpg/png/pdf/xlsx这些。不要使用黑名单,因为你不知道攻击者还能用什么奇奇怪怪的扩展名绕过黑名单。扩展名校验要放在内容校验之后,或者两者组合,参考第3.3节的实现。
第五,WAF层辅助拦截。如果前面有WAF,可以加一条规则:所有上传请求的Content-Type必须是multipart/form-data,且上传文件头必须是允许的类型。OWASP ZAP这类工具可以用来做渗透测试,验证自己的上传接口到底能不能扛住恶意样本,建议在上线前跑一轮。
5.3 文件下载的路径穿越与越权防护
下载接口的安全防护,核心是防两件事:路径穿越和越权访问。
路径穿越的典型场景是:接口接收一个文件名参数,直接用它对存储路径做拼接,攻击者传入../../etc/passwd。防护办法是:接口不接收文件路径,只接收fileId,由服务端根据fileId查询真实的存储路径;同时对标准化后的路径做校验,确保最终路径在允许的根目录之内。Java里有Path.normalize()可以标准化路径,再用startsWith判断前缀:
Path basePath = Paths.get("/data/files").toAbsolutePath().normalize(); Path filePath = basePath.resolve(relativePath).normalize(); if (!filePath.startsWith(basePath)) { throw new ForbiddenException("非法路径"); }越权访问的典型场景是:任何登录用户都能下载任意fileId对应的文件。防护办法是:下载接口先查文件记录,再校验当前用户与文件所有者的关系,或者校验文件所属业务对象是否对当前用户可见。fileId不可枚举,能降低被批量扫文件的风险,但不能替代权限校验,两个必须同时做。
6. 扩展:集成MinIO实现对象存储管理
6.1 MinIO是什么,要不要上
MinIO是一个开源的、基于S3协议的对象存储服务,单机部署只需要一条命令,却提供了完整的对象存储能力,包括桶管理、版本控制、生命周期规则、预签名URL。我在实践中用它替代本地磁盘存储,主要是因为下面的场景:应用要扩容到多节点、上传的文件需要做异地备份、运维希望有一个可视化的管理界面。
如果你还在单机开发阶段,本地磁盘完全够用。但如果你已经预见到项目会往集群方向走,或者产品本身就有大量文件管理需求,那从第一天就使用MinIO式接口会更合理。
用MinIO还有一个额外的好处:接口完全兼容S3协议,如果以后要切到AWS S3或者阿里云OSS,只需要换endpoint和凭证,业务代码几乎不需要改。这是存储逻辑抽象带来的红利。
6.2 集成MinIO的上传下载实现
先在pom.xml里加上MinIO的客户端依赖:
<dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> </dependency>配置文件:
minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin bucket: myapp然后封装一个MinioStorageService实现前面说的FileStorageService接口:
@Service public class MinioStorageService implements FileStorageService { private final MinioClient minioClient; private final String bucket; public MinioStorageService(MinioProperties properties) { this.bucket = properties.getBucket(); this.minioClient = MinioClient.builder() .endpoint(properties.getEndpoint()) .credentials(properties.getAccessKey(), properties.getSecretKey()) .build(); } @Override public String store(MultipartFile file) { String objectName = UUID.randomUUID().toString() + "." + getExtension(file.getOriginalFilename()); try { minioClient.putObject(PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return objectName; } catch (Exception e) { throw new BizException("文件上传失败", e); } } @Override public StoredFile load(String fileId) { // 通过 getObject 读取文件元信息和流 StatObjectResponse stat = minioClient.statObject( StatObjectArgs.builder().bucket(bucket).object(fileId).build()); return new StoredFile(fileId, stat.objectName(), stat.contentType(), null); } @Override public void delete(String fileId) { try { minioClient.removeObject( RemoveObjectArgs.builder().bucket(bucket).object(fileId).build()); } catch (Exception e) { throw new BizException("文件删除失败", e); } } }MinIO的下载如果走应用转发,和使用getObject的流,直接拼到响应体里即可。但生产环境更推荐用预签名URL:
String url = minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucket) .object(objectName) .expiry(60) .build());后端把这个URL返回给前端,前端直接window.location.href = url就能下载,文件流完全不经应用服务器,极大地减轻了应用层带宽压力。URL默认60秒过期,防止被第三方滥用。这是对象存储最优雅的用法,强烈推荐。
7. 常见问题与排查技巧实录
7.1 上传报错405/413的排查思路
前端上传时如果遇到405,首先要检查接口路径和请求方法。POST接口用GET请求,或者Controller方法上的@PostMapping路径和前端不一致,都会导致405。遇到这种问题别急着改代码,先看浏览器Network面板里请求的Method和URL是否和后端接口定义一致。
如果遇到413,这个状态码的意思是请求体太大。先在application.yml检查max-file-size和max-request-size,但要注意还有第二层:Nginx的client_max_body_size,默认是1MB。很多Spring Boot应用前面都有一层Nginx反向代理,这个配置项不调大,你在Spring里把上传限制调到100MB也没用,请求在Nginx就被拦了。
Nginx配置如下:
server { listen 80; client_max_body_size 100m; location / { proxy_pass http://127.0.0.1:8080; } }7.2 多文件上传部分失败的处理策略
多文件上传,某一个文件校验不通过,是全部回滚还是部分成功?这个要看业务场景。如果这批文件是用户一次性提交的合同附件,建议全部回滚,并提示用户哪几个文件有问题,让用户改完重新传。如果这批文件是新闻编辑上传的新闻图集,图片之间存在弱的关联关系,可以接受部分成功。
实现时要注意:部分成功的逻辑意味着已经成功存储的文件,要么保留并返回对应的文件ID列表,要么把已存储的文件清理掉。如果选清理,就用try/catch把已成功的文件挨个删除,但删除失败要记日志,否则会产生孤儿文件。
还有一个务实方案:把文件校验前置。先创建一个不落盘的预检接口,前端上传前逐个调用预检接口,只把通过预检的文件加入上传队列。这样上传接口基本不会出现校验失败的情况,且使用体验更好。缺点是多了几次HTTP请求,但一般文件上传都是低频操作,性能完全没有压力。
7.3 文件名乱码与编码处理
下载文件时浏览器弹出来的文件名总是乱码,这个问题的根源是HTTP头只支持ASCII字符,而中文文件名需要正确编码。前面第4.1节已经给出过编码写法,我用URLEncoder.encode处理,再把+替换成%20。
为了兼容老版本浏览器,Content-Disposition里的filename="..."也填上编码后的文件名。现代浏览器优先认filename*,老浏览器认filename,两个都填是最稳的。
附带提一个上传场景的编码坑:MultipartFile.getOriginalFilename()返回的文件名,在不同的Web容器中处理方式不太一样。Tomcat 8以上默认使用UTF-8解码表单,基本正常。但如果部署在某些特殊环境下,文件名里的中文可能乱码,需要在application.yml里设置server.tomcat.uri-encoding: UTF-8。
7.4 前后端联调中的其他常见坑
前端传文件没反应,后端日志也不报错,这种问题十有八九是请求头不对。axios如果手动设置了错误的Content-Type,比如application/json,Mulitpart解析器就找不到文件。同步检查后端有没有配置multipart.enabled=true,虽然Spring Boot默认开启,但有些老工程或者自定义配置里可能把它禁了。
还有一种情况:前端是微信小程序或App,multipart/form-data的字段名和后端不一致,也会导致MultipartFile接收为null。排查时可以先把后端日志的请求体打出来,看收到的是什么字段名。
从前端视角来看,文件上传进度条最好用XMLHttpRequest的upload.onprogress事件或者axios的onUploadProgress回调,不要自己根据耗时模拟进度条。真实进度条会让用户心里有底,尤其大文件上传时体验差异很大。
7.5 上传超大文件的j减负方案
如果业务里要支持超过100MB甚至1GB的大文件,直接走普通POST上传,问题会非常多:网络中断全盘重来、请求超时、Nginx超时、应用内存压力大。这时候需要引入分片上传。
大致思路是:前端把文件切成若干分片,每个分片单独调用Upload接口,后端收到分片后先转存到临时目录,等全部分片上传完成后,业务接口触发合并操作,按顺序把所有分片字节流拼接成完整文件。
这个方案涉及的细节很多:分片大小怎么定、并发上传的并发数、分片失败重传策略、合并时怎么保证顺序和完整性。一套完整的实现大概需要一两千行代码,建议直接使用现成的方案,比如MinIO支持的分片上传、或者OSS SDK的多部分上传(Multipart Upload)。这类方案的优势在于断点续传、并发上传、服务端合并都是现成的,没有必要自己从零造轮子。
写在最后
文件上传下载功能,从表面看就是一个接口、一个注解的问题,但深入下去,存储选型、安全加固、断点续传、权限控制,每一环里面都藏着无数细节。这篇文章里提到的绝大部分经验和问题,都是我一个个坑踩出来的,也希望它能帮你少踩一些。
最后分享一个我自己很受益的工作习惯:写文件上传下载这类通用功能时,一定要把存储逻辑抽象成接口,把校验策略单独拆出来,把安全加固措施列成清单。这样在项目交付以后,不管是加限制规则、换存储方案、还是过安全审计,你都能快速响应。按这个结构去写,第一版代码可能要多花半天到一天的时间,但后面省下的时间,远不止这一天。