Spring Boot文件上传下载实战:安全加固与MinIO集成全解析
在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.xJDK 8还是11都行但建议至少用11因为后面有些代码写法会更方便。核心依赖只需要两个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependencyspring-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 ResultString 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。如果前端用的是VueFormData里的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 ResultListString uploadBatch(RequestParam(files) MultipartFile[] files) { ListString 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;charsetutf-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 FFPNG是89 50 4E 47PDF是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是一个MapString, ListString键是扩展名值是该类型文件可能出现的魔数列表。判断逻辑用“内容里的魔数是否在允许列表”来算而不是用“扩展名对应的魔数是否匹配内容”这样更安全。魔数校验的局限性我也说清楚它只能识别文件头图省事的话一个攻击者在文件开头塞了合法图片的头部字节后面接恶意代码这种文件构造得好的话魔数校验拦不住。所以魔数校验只是提高了攻击门槛真正的兜底方案是第5章要讲的把上传目录和Web容器的代码执行能力彻底隔离。4. 文件下载核心实现4.1 普通文件下载与Content-Disposition下载接口的经典实现方式是先把文件从存储系统中读取出来然后通过ResponseEntity返回字节流GetMapping(/download/{fileId}) public ResponseEntityResource 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头比如bytes0-1023截取文件对应区间的内容作为响应体同时设置响应头Content-Range: bytes 0-1023/2048和状态码206 Partial Content。如果请求头里没有Range就返回完整文件状态码200。Spring提供了一种简化的写法使用Resource配合HttpRangeGetMapping(/download/{fileId}) public ResponseEntityResource 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) { ListHttpRange 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 groupIdio.minio/groupId artifactIdminio/artifactId version8.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的流直接拼到响应体里即可。但生产环境更推荐用预签名URLString 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/jsonMulitpart解析器就找不到文件。同步检查后端有没有配置multipart.enabledtrue虽然Spring Boot默认开启但有些老工程或者自定义配置里可能把它禁了。还有一种情况前端是微信小程序或Appmultipart/form-data的字段名和后端不一致也会导致MultipartFile接收为null。排查时可以先把后端日志的请求体打出来看收到的是什么字段名。从前端视角来看文件上传进度条最好用XMLHttpRequest的upload.onprogress事件或者axios的onUploadProgress回调不要自己根据耗时模拟进度条。真实进度条会让用户心里有底尤其大文件上传时体验差异很大。7.5 上传超大文件的j减负方案如果业务里要支持超过100MB甚至1GB的大文件直接走普通POST上传问题会非常多网络中断全盘重来、请求超时、Nginx超时、应用内存压力大。这时候需要引入分片上传。大致思路是前端把文件切成若干分片每个分片单独调用Upload接口后端收到分片后先转存到临时目录等全部分片上传完成后业务接口触发合并操作按顺序把所有分片字节流拼接成完整文件。这个方案涉及的细节很多分片大小怎么定、并发上传的并发数、分片失败重传策略、合并时怎么保证顺序和完整性。一套完整的实现大概需要一两千行代码建议直接使用现成的方案比如MinIO支持的分片上传、或者OSS SDK的多部分上传Multipart Upload。这类方案的优势在于断点续传、并发上传、服务端合并都是现成的没有必要自己从零造轮子。写在最后文件上传下载功能从表面看就是一个接口、一个注解的问题但深入下去存储选型、安全加固、断点续传、权限控制每一环里面都藏着无数细节。这篇文章里提到的绝大部分经验和问题都是我一个个坑踩出来的也希望它能帮你少踩一些。最后分享一个我自己很受益的工作习惯写文件上传下载这类通用功能时一定要把存储逻辑抽象成接口把校验策略单独拆出来把安全加固措施列成清单。这样在项目交付以后不管是加限制规则、换存储方案、还是过安全审计你都能快速响应。按这个结构去写第一版代码可能要多花半天到一天的时间但后面省下的时间远不止这一天。