2026/10/7 21:36:58

Spring Boot文件下载从入门到实战:中文编码、断点续传与MinIO预签名URL

Spring Boot文件下载从入门到实战:中文编码、断点续传与MinIO预签名URL 最近陆陆续续有不少人问我同一个问题Spring Boot里的文件下载到底怎么写才不踩坑。有做毕设的在校生有刚转后端的新人也有已经上生产的兄弟在排查下载下来的文件为什么乱码文件一大就卡死这类线上问题。聊一圈下来我发现一个规律大多数人的下载接口都能跑但离能用还差着好几道坎——文件超过几十兆就内存飙升、中文文件名乱得没法看、路径参数随手拼进URL带着安全隐患、接上MinIO之后应用服务器还被下载流量直接打穿。这篇文章把Spring Boot文件下载从最基础的写法讲到对象存储场景下的进阶方案顺带把中文编码、断点续传、安全校验这些绕不开的细节全展开。不管是交毕设还是做公司项目里的文件管理模块或者马上要面试被问到文件下载怎么设计照着这套思路走基本能把这条路理顺。1. 一个下载接口从能跑到能用中间隔着四层设计先看一段很多教程里都会出现的经典写法GetMapping(/download/{filename}) public void download(PathVariable String filename, HttpServletResponse response) throws IOException { File file new File(/data/files/ filename); byte[] data Files.readAllBytes(file.toPath()); response.setContentType(application/octet-stream); response.getOutputStream().write(data); response.getOutputStream().flush(); }我见过不少项目直接把这段代码带进生产环境然后被运维找上门来。第一层问题是内存Files.readAllBytes会把整个文件一次性读进JVM堆内存一个50MB的文件就占50MB的byte数组同时来20个下载请求光文件数据就要占掉1GB堆GC压力瞬间拉满放在小规格容器里基本上就是OOM事故预告。所以下载接口设计的第一个问题就是文件内容到底在不在内存里待着能不能一行一行往响应流里搬。第二层问题是Content-Type。所有文件都固定成application/octet-stream浏览器拿到PDF、图片、Excel这些本来可以内置预览的文件也只能乖乖弹下载框体验大打折扣反过来如果你为了能预览把类型放开遇到HTML、SVG这类可以携带脚本的文件又会引入存储型XSS风险。类型怎么定后面第六节再展开。第三层问题是文件名。上面这段代码压根没有设置Content-Disposition响应头很多浏览器会拿URL最后一段当作下载文件名路径里一旦有中文要么乱码、要么文件名缺斤少两。我遇到过的下载乱码类工单十个里有六个是Content-Disposition没做对。第四层问题才是真正要命的路径拼接。new File(/data/files/ filename)这种写法只要传一个../etc/passwd就能顺着路径越过你预设的目录读系统文件。很多做毕设的同学意识不到这一点但面试官只要看到简历里有文件下载功能第一问大概率就是路径穿越怎么防。我的建议是拿到需求不要急着写代码先把四个决策点定下来决策点要问自己备选方案数据来源文件在本地磁盘、数据库还是对象存储File / byte[] / MinIO、OSS SDK响应方式文件多大、并发多少、要不要支持续传字节数组、InputStreamResource、静态映射类型策略全部强制下载还是允许浏览器预览attachment / inline / 扩展名白名单文件名编码有没有中文、前端要不要读响应头RFC 5987 filename*、Access-Control-Expose-Headers把这四个点盘一遍再动手写代码出来的接口通常不会返工。下面每个点都会单独展开讲咱们先看最核心的选型。2. Spring Boot三种主流下载姿势先把选型想明白2.1 小文件用ResponseEntitybyte[]省事但别贪心如果文件是配置模板、几KB的小图标、内存里临时组装出来的报表用字节数组直出是最清爽的GetMapping(/download/config) public ResponseEntitybyte[] downloadConfig() { byte[] data spring.application.namedemo.getBytes(StandardCharsets.UTF_8); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filename*UTF-8application.properties) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(data); }用ResponseEntity的好处是Spring MVC已经帮我们调好了响应头的写入顺序Content-Length也会按字节数组长度自动生成浏览器拿到的是完整HTTP响应。但这个方法的使用边界非常明确文件必须真的小。一旦发现业务里文件体量可能超过几十兆或者并发上来之后堆内存监控开始走高就要立刻换方案。这个小的标准我一般卡在1MB以内超过1MB我宁可用下面的流式方案。2.2 标准解法ResponseEntity Resource 做流式响应Spring从4.1开始给Resource提供了HttpMessageConverterController可以直接返回ResourceSpring会自己读InputStream并把数据写到响应里不需要你手动碰OutputStream。这应该算Spring Boot下载功能的标准解法GetMapping(/download/{filename}) public ResponseEntityResource download(PathVariable String filename) throws IOException { Path basePath Paths.get(UPLOAD_DIR).toAbsolutePath().normalize(); Path filePath basePath.resolve(filename).normalize(); if (!filePath.startsWith(basePath)) { return ResponseEntity.badRequest().build(); } if (!Files.exists(filePath)) { return ResponseEntity.notFound().build(); } FileSystemResource resource new FileSystemResource(filePath.toFile()); String encodedName URLEncoder.encode(filePath.getFileName().toString(), StandardCharsets.UTF_8) .replace(, %20); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filename\download\; filename*UTF-8 encodedName) .contentType(MediaType.APPLICATION_OCTET_STREAM) .contentLength(resource.contentLength()) .body(resource); }这里有几个细节值得单独圈出来。第一个是FileSystemResource和InputStreamResource的选择。FileSystemResource底层还是FileInputStream但它能正确返回contentLength()Spring的ResourceHttpMessageConverter写响应时会带上Content-Length客户端解析时就明确了数据边界InputStreamResource如果构造时没传长度Spring会退化到分块传输某些老旧代理、下载工具和断点续传工具会因此出问题。所以本地文件场景优先FileSystemResource。第二个是Content-Length头的意义。别小看这一行它决定了浏览器下载进度条正不正常也决定了客户端能不能判断下载是否被中断。很多进度条不动下载完文件打不开的工单最后查出来都是Content-Length没给对要么缺失要么长度比实际文件大。第三个是控制力。用ResponseEntityResource时404、403、400这些错误可以随ResponseEntity一起返回如果只写return resource虽然也能跑但错误状态码的处理就得依赖抛异常和ControllerAdvice控制力弱不少。2.3 公开文件用静态资源映射简单粗暴但控制力弱如果文件本身就是公开资源不想写Controller也不想碰下载逻辑可以直接让Spring Boot的静态资源配置接管Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceLocations(file:/data/files/); } }配置生效后访问/files/report.pdf就能直接取到本地文件。这个方案的好处是零业务代码、自带Last-Modified缓存协商浏览器第二次访问会带If-Modified-Since做304判断省流量。但代价也很明显没法方便地设置Content-Disposition也就是没法强制浏览器下载而不是打开。对于PDF、图片这类需要新标签页预览的场景反而刚刚好。顺带提一句热搜里那个vue打包放进springboot中。前端项目build之后把dist目录里的内容复制到src/main/resources/static下面Spring Boot启动后自动就当成静态资源吐出去了原理和上面的addResourceHandlers是一套。很多人做文件预览、文件下载都是从这步开始的所以别觉得静态资源映射和你无关。2.4 三种方案放到一起看方案适用场景内存占用是否支持断点续传能控制下载文件名ResponseEntitybyte[]小文件、动态生成内容高全量驻留不支持可以ResponseEntity本地文件、需要控制下载行为低流式可以配合Range可以静态资源映射公开资源、浏览器预览低支持容器自动处理不支持attachment选型上没有绝对的对错关键是知道自己为什么选。我在实际项目里的习惯是上传产物这类确定要下载的文件用Resource方案缩略图、操作手册这类需要预览的用静态映射模板导出这类内存里生成的才用字节数组。下面紧接着就是下载流程里最容易被骂的一个环节——文件名。3. 中文文件名这关过不去下载功能就废了一半3.1 Content-Disposition的历史包袱响应头Content-Disposition是下载功能最关键的信息它告诉浏览器这是要展示inline还是下载attachment并且通过filename参数指定下载文件的名字Content-Disposition: attachment; filenamereport.xlsx早期规范里filename只能写ASCII字符中文没法放进去。后来RFC 5987定义了filename*参数允许在头里放URL编码过的Unicode文件名所以正确写法是把原始中文文件名做百分号编码之后放在filename*里再给一个ASCII的普通filename当降级Content-Disposition: attachment; filenamedownload.xlsx; filename*UTF-8%E6%9C%88%E6%8A%A5.xlsx注意filename*的格式是三段式字符集、语言可选、百分号编码串中间用两个单引号隔开。很多新手直接把中文文件名塞进filename中文.xlsx老版本浏览器解析到非ASCII字符就直接截断或乱码这就是下载文件名乱码最常见的根源。3.2 后端编码工具方法服务端写的编码方法一般长这样private String encodeFileName(String fileName) { return URLEncoder.encode(fileName, StandardCharsets.UTF_8) .replace(, %20); }为什么要.replace(, %20)因为URLEncoder会把空格编码成加号而RFC 5987规定这里的空格应当编码成%20。要是不处理文件名里带空格的下载出来文件名会变成带加号的奇怪形状。虽然是小毛病但一旦你的接口面向外部用户这种细节很容易变成投诉素材。然后组合响应头时我建议固定写成response.setHeader(HttpHeaders.CONTENT_DISPOSITION, attachment; filename\download\; filename*UTF-8 encodeFileName(originalName));ASCII的降级名不要拿中文去转直接写一个固定的download或者download.data都行保证老浏览器还能落一个能用的文件名。现代的Chrome、Edge、Firefox、Safari都优先读filename*所以真正的下载名还是中文原名只是给老环境一个兜底。3.3 跨域场景下前端读不到文件名如果你用了Vue这类前端框架让前端自行从响应头解析文件名会踩到另一个坑浏览器跨域请求下前端只能读取到CORS白名单里暴露的响应头Content-Disposition默认不在里面。后端只要配了跨域就需要手动把响应头暴露出去response.setHeader(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION);加了这一行前端拿response.headers[content-disposition]才不会总是undefined。我见过不少前端同事在这个问题上排查了一下午最后发现不是代码问题是响应头根本没露出来。跨域联调的时候先把这个暴露头加上再往前端兜。4. 大文件下载从内存溢出到断点续传4.1 为什么大文件会OOM很多项目的下载接口最初都是字节数组写法。文件在10MB以内时毫无感觉等业务一天天跑起来文件变成了几百MB的压缩包、安装包问题就集中爆发了。原因很简单Files.readAllBytes、file.getBytes这类API会把文件一次性加载到堆内存JVM里多来几个并发下载内存瞬间暴涨。更麻烦的是内存里的byte数组还不容易被GC快速回收因为Controller方法栈、响应封装、容器缓冲区可能同时引用同一份数据。处理大文件的核心思路只有一句不要让文件内容在JVM内存中完整存在。要么流式读一段写一段要么直接让客户端和文件存储之间建立连接。4.2 流式拷贝的最优写法不依赖任何工具库原生写法如下GetMapping(/download/stream/{filename}) public void streamDownload(PathVariable String filename, HttpServletResponse response) throws IOException { Path basePath Paths.get(UPLOAD_DIR).toAbsolutePath().normalize(); Path filePath basePath.resolve(filename).normalize(); if (!filePath.startsWith(basePath) || !Files.exists(filePath)) { response.setStatus(HttpStatus.NOT_FOUND.value()); return; } String encodedName URLEncoder.encode(filePath.getFileName().toString(), StandardCharsets.UTF_8) .replace(, %20); response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, attachment; filename*UTF-8 encodedName); response.setContentLengthLong(Files.size(filePath)); try (InputStream in new BufferedInputStream(Files.newInputStream(filePath)); OutputStream out response.getOutputStream()) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } out.flush(); } }缓冲区定多少合适其实是性能和内存的平衡。8192字节是Apache Commons IO默认的拷贝缓冲也是我常年用的值。设成4KB更省内存但系统调用次数翻倍调到64KB传输更快但每个下载请求多占64KB堆按100并发算就是6.4MB通常也能接受。除非你做过压测并且对JVM内存有明确把握否则不要盲目贪大8KB起步最稳妥。这个写法和前面return Resource的差别在于手动控制输出流可以在每个写入循环里插入额外逻辑比如限速、记录下载进度。代价是要自己保证流的关闭try-with-resources能确保异常路径也释放文件描述符。另一个线程层面的坑这里一并说了。默认Tomcat线程池是200一个庞大的下载请求从开始写响应到完全结束会一直占住一个工作线程。如果有人发一堆下载请求但又慢慢读数据很容易把线程池占满其他普通接口跟着遭殃。所以下载接口要么配合下载总量限额要么把耗时下载放到独立线程池执行要么在前端层面限制并发。大文件下载做得严谨的项目基本都会在外面套一层网关限流。如果你已经升到Spring Boot 3.2并且运行在Java 21上可以开启spring.threads.virtual.enabledtrue这种阻塞式的流式下载会跑在虚拟线程上Tomcat的工作线程不再被慢下载占死前面说的线程池耗尽问题会缓解不少升级成本很低值得一试。4.3 Range请求与断点续传HTTP协议从1.1开始支持Range头客户端可以只请求文件的一部分服务器返回206 Partial Content。迅雷、浏览器自带下载器能断点续传多线程分段下载靠的就是这个机制。Spring为Range请求提供了现成的支持。Controller可以返回ResourceRegionResourceRegionHttpMessageConverter会自动处理Range头、生成206状态和Content-Range响应头GetMapping(/download/range/{filename}) public ResponseEntityResourceRegion downloadRange( PathVariable String filename, RequestHeader(value HttpHeaders.RANGE, required false) String range) throws IOException { Path basePath Paths.get(UPLOAD_DIR).toAbsolutePath().normalize(); Path filePath basePath.resolve(filename).normalize(); if (!filePath.startsWith(basePath) || !Files.exists(filePath)) { return ResponseEntity.notFound().build(); } FileSystemResource resource new FileSystemResource(filePath.toFile()); long contentLength resource.contentLength(); ListHttpRange ranges HttpRange.parseRanges(range); if (ranges.isEmpty()) { return ResponseEntity.ok() .header(HttpHeaders.ACCEPT_RANGES, bytes) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(new ResourceRegion(resource, 0, contentLength)); } HttpRange httpRange ranges.get(0); long start httpRange.getRangeStart(contentLength); long end httpRange.getRangeEnd(contentLength); long rangeLength Math.min(contentLength - 1, end) - start 1; return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.ACCEPT_RANGES, bytes) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(new ResourceRegion(resource, start, rangeLength)); }这里有两个容易忽略的点。一是完整响应没有Range头时也要加上Accept-Ranges: bytes告诉客户端这个接口支持分段下载二是Content-Range别手动拼ResourceRegionHttpMessageConverter会自动生成手动写反而可能出现重复头这是一个常见翻车细节。另外HttpRange.parseRanges遇到格式非法的Range头会抛IllegalArgumentException生产环境建议捕获一下非法Range就回退成完整下载或者返回400别让一个畸形请求把异常堆栈打到日志里。支持Range之后浏览器里点暂停、继续下载都走同一套逻辑用户体验直接上一个台阶。对下载工具类产品、安装包分发服务来说Range支持基本是必选项。4.4 限速下载防止单用户吃满带宽文件下载功能上线后最常见的运维投诉是某个人在拉大文件整个出口带宽被打满。加一个最简单的匀速限速逻辑按固定时间窗口控制写入量private static final int BYTES_PER_SECOND 1024 * 1024; // 限速 1MB/s long bytesWritten 0; long windowStart System.currentTimeMillis(); while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); bytesWritten len; long elapsed System.currentTimeMillis() - windowStart; long expectTime bytesWritten * 1000L / BYTES_PER_SECOND; if (expectTime elapsed) { try { Thread.sleep(expectTime - elapsed); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException(下载被中断, e); } } }这个算法按累计写入字节推算期望耗时实际耗时不够就sleep补上不需要引入令牌桶之类的重量级依赖。它按单连接限速用户开多个连接仍能绕过生产上更严格的做法是在网关层做IP维度限速但对内部系统来说单连接限速已经够用而且改动不超过十行代码。5. 接上MinIO/OSS以后下载的思路要从拉文件变成分流5.1 预签名URL让客户端直接找存储要文件Spring Boot很多项目里的文件其实存在MinIO或者阿里云OSS上本地磁盘反而没有完整文件。这种场景如果还用Controller去对象存储把文件拉回来再吐给浏览器就出现了一个经典的性能浪费文件数据在网络上多绕了一圈应用服务器的带宽、CPU、内存全被无谓消耗。对象存储普遍支持预签名URL。服务端给客户端生成一个带时效的GET链接浏览器直接朝这个链接发起请求文件完全不经过应用服务器GetMapping(/minio/presigned/{objectName}) public String presignedUrl(PathVariable String objectName, RequestParam(defaultValue 300) int expires) throws Exception { return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(BUCKET_NAME) .object(objectName) .expiry(expires) .build()); }OSS的写法也类似用generatePresignedUrl传bucket、object和时间。这种做法有三个好处应用服务器零下载流量、对象存储自带多线程与限速策略、URL过期自动失效相当于天然的防盗链。但它有一个硬前提客户端网络必须能直连对象存储地址。很多企业里MinIO部署在内网客户端在公网预签名URL生成出来客户端根本访问不到这种情况只能用下面的代理流式方案。5.2 代理流式文件不过内存、只过服务器当对象存储对客户端不可直接访问时Controller就退化成代理下载。注意这里的实现要点还是那句老话别把整个对象读进内存。MinIO的Java SDK里minioClient.getObject返回的GetObjectResponse本身就是InputStream的子类可以直接transferTo到响应的OutputStream上GetMapping(/minio/download/{objectName}) public void downloadFromMinio(PathVariable String objectName, HttpServletResponse response) throws Exception { GetObjectResponse object minioClient.getObject( GetObjectArgs.builder() .bucket(BUCKET_NAME) .object(objectName) .build()); response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); String originalName objectName.contains(/) ? objectName.substring(objectName.lastIndexOf(/) 1) : objectName; response.setHeader(HttpHeaders.CONTENT_DISPOSITION, attachment; filename*UTF-8 encodeFileName(originalName)); object.transferTo(response.getOutputStream()); }这里有个小坑objectName在MinIO里经常带目录前缀比如2023/12/report.pdf。如果直接把整个objectName拼进filename*客户端下载下来的文件名就会带着斜杠前缀和目录结构难看且容易让前端解析出错所以传输之前要先截取最后一段路径作为真实文件名。如果你用的是阿里云OSS对应的是ossClient.getObject(bucket, key)返回的OSSObject处理思路一样。5.3 服务间传递MultipartFileRestTemplate的正确姿势热搜词里有一个springboot中mutipart如何用resttemplate传虽然说的是上传不是下载但跟文件流转强相关顺手把坑也填了。MultipartFile本身不是可序列化的对象直接用RestTemplate.postForEntity传会报错或者丢失文件名。最常见做法是把文件内容包成ByteArrayResource并overridegetFilename方法PostMapping(/forward-upload) public String forwardUpload(RequestParam(file) MultipartFile file) throws IOException { HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); MultiValueMapString, Object body new LinkedMultiValueMap(); ByteArrayResource resource new ByteArrayResource(file.getBytes()) { Override public String getFilename() { return file.getOriginalFilename(); } }; body.add(file, resource); HttpEntityMultiValueMapString, Object requestEntity new HttpEntity(body, headers); return restTemplate.postForObject(http://target-service/api/upload, requestEntity, String.class); }RestTemplate默认注册了FormHttpMessageConverter它会把MultiValueMap里的Resource对象转成multipart/form-data请求体ByteArrayResource里必须重写getFilename否则对方服务拿到的是没有文件名的空壳part一些校验严谨的后端会直接拒绝。6. 下载接口的安全红线路径穿越、越权与文件类型6.1 目录穿越拼路径前先normalize前面提过new File(/data/files/ filename)是典型漏洞。更规整的防护是这样Path basePath Paths.get(UPLOAD_DIR).toAbsolutePath().normalize(); Path filePath basePath.resolve(filename).normalize(); if (!filePath.startsWith(basePath)) { throw new IllegalArgumentException(非法的文件路径); }关键在于先normalize再判断。resolve(../../etc/passwd)之后如果不normalize路径字符串还带着..startsWith判断很容易被绕过normalize之后..被真正解析掉再判断前缀就靠谱了。还有一个容易踩的坑Windows和Linux的路径分隔符不一样Windows下是\判断时最好统一交给Path处理别用字符串replaceAll去手工拼手工拼很容易拼出漏洞也容易拼出bug。6.2 文件类型attachment兜底扩展名白名单有人为了让PDF能在浏览器预览把所有文件都设成inline然后被HTML、SVG文件坑了——这两种文件里可以嵌脚本从你的域名下发HTML内容等于帮攻击者绕过了同源策略这是存储型XSS的变体。稳妥做法是下载接口默认全部走attachment强制下载任何类型都不让浏览器渲染如果确实需要在线预览PDF、图片再单独开一个预览接口并且只对白名单内的扩展名放行private static final SetString PREVIEW_EXTENSIONS Set.of(pdf, jpg, jpeg, png, gif, webp); private boolean allowPreview(String filename) { String ext filename.substring(filename.lastIndexOf(.) 1).toLowerCase(); return PREVIEW_EXTENSIONS.contains(ext); }扩展名判断别漏了大小写问题。PDF写成.PDF也要能放行统一toLowerCase()再比对。另外扩展名校验只能作为辅助真正决定行为的还是响应头的Content-Disposition和Content-Type这两个头必须由后端自己控制永远不要信任前端传过来的文件名或类型。6.3 鉴权顺序与越权防护下载接口最容易出现的业务漏洞是越权下载用户改URL里的文件名或文件ID去下别人的报告、别人的附件。鉴权必须放在打开文件、生成下载响应之前并且要做细粒度的归属校验不能只判断登录没登录。最粗的模型是文件ID 当前用户ID联合查询拿到记录才允许下发拿不到直接返回404不要返回403避免暴露文件是否存在的信息。如果走了对象存储预签名URL还有一个额外的越权风险点签名URL一旦发出拿到链接的人都能下载。所以生成预签名URL之前同样要做权限校验过期时间尽量短。能5分钟就不10分钟能在网关层或对象存储侧做Referer校验就加上别图省事给24小时。7. 联调时高频踩到的四个坑提前打好补丁7.1 前端responseType: blob却收到JSON错误前端用axios下载文件时一般会设置responseType: blob结果后端一旦返回业务错误前端收到的就是一坨Blob里面的内容实际是JSON字符串。处理方式很简单下载响应拿到后先看类型const res await axios.get(/api/download, { responseType: blob }); if (res.data instanceof Blob res.data.type res.data.type.includes(application/json)) { const text await res.data.text(); const error JSON.parse(text); console.error(error.message); return; } const blob new Blob([res.data]); // 创建 a 标签触发下载这个判断建议封装成公共工具函数。不然每个下载按钮的报错提示都会变成文件已损坏这类让人摸不着头脑的话排障时白白浪费半天。7.2 文件内容拿到了文件名却读不到前面讲了Access-Control-Expose-Headers的问题。前端从Content-Disposition解析文件名时还会遇到第二个问题多次编码。如果后端已经用URLEncoder编了一次前端拿到的是%25E4%25B8%25AD...这就是二次编码的典型症状。解决方案是统一约定后端只编码一次前端解析时用decodeURIComponent解一次不要再调encodeURIComponent。约定的东西最好写进接口文档不然前后端各自好心地多转一次乱码问题能循环出现。7.3 文件名里的时间戳与浏览器缓存有些下载文件的文件名带日期比如日报-2025-04-01.xlsx。浏览器会按URL缓存同一天下载内容相同的文件用户二次点击时浏览器可能复用旧响应。如果是动态生成的报表给响应头加Cache-Control: no-store让浏览器完全不要缓存如果是静态文件反过来要利用缓存让304生效别一刀切禁掉response.setHeader(HttpHeaders.CACHE_CONTROL, no-store);这里的判断标准就一条这个文件内容会不会随时间变化。会变就禁缓存不会变就敞开缓存别混着来。7.4 Excel导出这类异步任务别让HTTP请求等太久下载功能里很大一类是导出Excel/报表数据量大时同步生成可能让请求等几十秒甚至超时。通用的成熟模式是三步第一步提交导出任务返回taskId第二步后台线程生成文件到临时目录或者对象存储第三步前端轮询taskId状态文件就绪后用下载接口取文件。另外临时文件记得做定时清理我见过因为导出临时文件不清理、把磁盘打满的项目。清理策略一般按创建时间N天定期删除或者干脆用对象存储自带的生命周期规则。异步导出加定期清理这两件事看着不起眼但缺一个都会在生产环境出问题。文件下载这个功能表面上是把字节流还给客户端六个字真正落地时牵扯内存模型、HTTP语义、编码规则、对象存储、安全边界一长串细节。我的体会是只要把上面这四层设计决策先定下来再动手写代码大部分坑都能在设计阶段避开剩下的少数坑像Range头重复、二次编码、跨域读不到文件名都属于踩过一次就长记性的类型希望这篇能帮你提前把这些教训都领走。