Note 18. API 安全与交互:CORS 跨域、拦截器与文件处理

第十八章. API 安全与交互:CORS 跨域、拦截器与文件处理

环境版本锁定
在开始本章的实战之前,我们需要确保开发环境的一致性,以避免因版本差异导致的配置失效。

技术组件版本号说明
JDK17 (LTS)推荐使用 Amazon Corretto 或 OpenJDK
Spring Boot3.2.0核心框架,基于 Spring Framework 6
Servlet API6.0Jakarta Servlet 规范
Nginx1.24+用于生产环境反向代理演示
Node.js18+用于运行前端环境 (新增)
Vue 3Latest前端演示框架 (新增)
PostmanLatestAPI 调试工具

摘要: 本章将系统性地解决 Web 开发中“最后一公里”的交互难题。我们将深入浏览器内核,剖析 同源策略 (SOP) 的安全本质与 CORS 跨域机制的底层握手流程;构建基于 拦截器 (Interceptor) 的请求链路防御体系,实现无侵入式的登录校验与上下文管理;最后,我们将从 HTTP 协议层透视 Multipart 文件传输细节,完成从本地磁盘存储到企业级 MinIO 对象存储 的架构演进。

本章学习路径

  • 阶段一:跨域攻坚 (18.1) —— 从 HTTP 协议底层理解跨域,掌握 Spring MVC 全局配置与 Nginx 生产级解决方案。
  • 阶段二:链路防御 (18.2) —— 掌握拦截器原理,实现基于 Token 的身份认证与 ThreadLocal 上下文隔离。
  • 阶段三:数据交互 (18.3) —— 深入文件上传协议,解决大文件限制与安全漏洞,实现分布式文件存储。

18.1. 深度解析:跨域 (CORS) 的底层机制与防御

在前后端分离的开发模式下,“跨域报错”是每一位开发者都会遇到的“拦路虎”。这不仅是一个配置问题,更是一个涉及浏览器安全模型、HTTP 协议握手以及服务器响应策略的综合性课题。

18.1.1. 现场还原:同源策略 (SOP) 的触发机制

当我们使用 Vue 或 React 启动前端项目(通常在 localhost:51738000),并尝试调用后端的 Spring Boot 接口(localhost:8080)时,控制台往往会弹出一片红色的错误信息:

Access to XMLHttpRequest at 'http://localhost:8080/api/users' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

要解决这个问题,我们首先必须理解浏览器为什么会产生这个错误。这一切的根源在于 同源策略 (Same-Origin Policy, SOP)

1. 什么是“源” (Origin)?

浏览器通过 协议 (Protocol)域名 (Host)端口 (Port) 这三个要素来判定两个 URL 是否属于“同源”。只要其中任何一个不同,就被视为跨域。

当前页面 URL目标 URL结果原因
http://a.com/pagehttp://a.com/api✅ 同源三要素完全一致
http://a.com/pagehttps://a.com/api❌ 跨域协议 不同 (http vs https)
http://a.com/pagehttp://b.com/api❌ 跨域域名 不同
http://a.com/pagehttp://a.com:8080/api❌ 跨域端口 不同 (80 vs 8080)

2. 浏览器的“多管闲事”与安全底线

初学者常常有一个误区:以为跨域请求是“发不出去”的。事实恰恰相反

在跨域场景下,浏览器依然会忠实地将 HTTP 请求发送给服务器,服务器也正常接收并处理了请求,甚至生成了正确的响应结果并发回给了浏览器。但是,浏览器在接收到响应后,检查发现当前页面与服务器不同源,且响应头中没有包含允许跨域的许可标记,于是浏览器拦截了数据,拒绝将响应内容交给 JavaScript 代码

Mermaid_Chart_-_Create_complex,_visual_diagrams_with_text.-2025-12-22-034112

为什么浏览器要做这种“损人不利己”的事?

这是为了防御 CSRF (跨站请求伪造) 等恶意攻击。试想一下,如果你登录了银行网站 bank.com,浏览器保存了你的 Cookie。随后你误点了一个恶意网站 evil.com。如果没有同源策略:

  1. evil.com 的脚本可以向 bank.com/transfer 发起转账请求。
  2. 请求会自动带上你在 bank.com 的 Cookie。
  3. 银行服务器验证 Cookie 通过,转账成功。
  4. evil.com 甚至能读取转账后的余额响应。

有了同源策略,evil.com 无法读取 bank.com 的响应数据(虽然请求可能发送成功,但无法获取结果,且无法携带 Session Cookie,除非服务器显式允许)。


18.1.2. 实战演练:手把手搭建“事故现场”

光说不练假把式。为了深刻理解跨域,我们将快速搭建一个最小化的前后端分离环境,亲手触发并观测这个错误。

第一步:初始化后端项目 (Spring Boot)

我们需要一个运行在 8080 端口的后端服务。

  1. 创建项目:使用 IDEA 新建 Spring Boot 项目,名为 demo-cors-backend
  2. 依赖选择:仅需勾选 Spring Web
  3. 编写测试接口:在 src/main/java/com/example/demo/controller 下创建 CorsController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.example.demo.controller;

import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
@RequestMapping("/api")
public class CorsController {

// 模拟一个查询接口
@GetMapping("/data")
public Map<String, Object> getData() {
System.out.println(">>> 后端接收到了 /api/data 请求");
return Map.of(
"code", 200,
"message", "数据获取成功",
"data", "这是来自 8080 端口的机密数据"
);
}

// 模拟一个修改接口 (非简单请求)
@PostMapping("/update")
public Map<String, Object> updateData(@RequestBody Map<String, String> payload) {
System.out.println(">>> 后端接收到了 /api/update 请求,参数:" + payload);
return Map.of("code", 200, "message", "修改成功");
}
}
  1. 启动后端:确认控制台输出 Tomcat initialized with port(s): 8080

第二步:初始化前端项目 (Vue 3 + Vite)

我们需要一个运行在非 8080 端口的前端服务。这里使用 Vite 快速创建。

创建项目:打开终端(Terminal),在任意工作目录下执行:

1
2
# 使用 npm 创建 Vue 3 项目
pnpm create vite@latest demo-cors-client -- --template vue

安装依赖

1
2
3
cd demo-cors-client
pnpm install
pnpm install axios # 安装 axios 用于发起 HTTP 请求

编写前端代码:打开 src/App.vue,清空内容并替换为以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script setup>
import { ref } from 'vue'
import axios from 'axios'

const message = ref('等待请求...')
const backendUrl = 'http://localhost:8080/api'

// 1. 发起简单 GET 请求
const sendGetRequest = async () => {
try {
const res = await axios.get(`${backendUrl}/data`)
message.value = `请求成功: ${res.data.data}`
} catch (error) {
console.error(error)
message.value = `请求失败: ${error.message}`
}
}

// 2. 发起非简单 POST 请求 (JSON)
const sendPostRequest = async () => {
try {
const res = await axios.post(`${backendUrl}/update`, { name: 'test' })
message.value = `POST 成功: ${res.data.message}`
} catch (error) {
console.error(error)
message.value = `POST 失败: ${error.message}`
}
}
</script>

<template>
<div style="padding: 50px; text-align: center;">
<h1>CORS 跨域测试实验室</h1>
<div style="margin: 20px;">
<button @click="sendGetRequest" style="margin-right: 10px; padding: 10px;">发起 GET 请求</button>
<button @click="sendPostRequest" style="padding: 10px;">发起 POST 请求</button>
</div>
<div style="color: red; font-weight: bold;">
{{ message }}
</div>
</div>
</template>
  1. 启动前端
    1
    npm run dev
    终端通常会显示运行在 http://localhost:5173

第三步:触发并观测错误

  1. 打开浏览器访问 http://localhost:5173
  2. F12 打开开发者工具,切换到 Network (网络) 面板。
  3. 点击页面上的 “发起 GET 请求” 按钮。

观测结果

image-20251222135710253

页面表现:显示“请求失败: Network Error”。

Console 面板:出现红色的 CORS 报错 Access to XMLHttpRequest... blocked by CORS policy

后端控制台:神奇的事情发生了!你会看到 >>> 后端接收到了 /api/data 请求

  • 这有力地证明了跨域请求实际上已经发送到了服务器,且服务器执行了代码,只是响应被浏览器拦截了。

18.1.3. 协议解剖:HTTP 握手与预检请求 (OPTIONS)

为了在保证安全的前提下允许合法的跨域访问(例如前端调用后端 API),W3C 定义了 CORS (Cross-Origin Resource Sharing) 标准。CORS 将请求分为两类:简单请求非简单请求

1. 简单请求 (Simple Request)

如果请求同时满足以下条件,浏览器会直接发送请求:

  • 方法是 GETPOSTHEAD
  • HTTP 头仅包含浏览器自动设置的字段(如 AcceptUser-Agent)或 Content-Type 仅限于 text/plainmultipart/form-dataapplication/x-www-form-urlencoded

即便如此,浏览器发出的请求头中会携带 Origin: http://localhost:5173。如果服务器的响应头没有 Access-Control-Allow-Origin: http://localhost:5173*,响应依然会被浏览器拦截。

2. 非简单请求与预检 (Preflight)

在现代前后端分离开发中,我们几乎都在发送 非简单请求。最典型的场景是:

  • 请求方法是 PUTDELETE
  • Content-Typeapplication/json
  • 请求头中携带了自定义 Header(如 Authorization: Bearer token...)。

对于这类请求,浏览器会 自动 先发送一个 OPTIONS 方法的请求,称为“预检请求”。

HTTP 交互流程图解:

mermaid-ai-diagram-2025-12-22-034515

核心响应头解析:

  • Access-Control-Allow-Origin: 服务器告诉浏览器,“我允许这个域名来访问我”。
  • Access-Control-Allow-Methods: 允许的 HTTP 方法(如 PUT, DELETE)。
  • Access-Control-Allow-Headers: 允许携带的自定义头(如 Authorization)。
  • Access-Control-Max-Age: 预检请求的有效期(秒)。在有效期内,浏览器不会重复发送 OPTIONS 请求,减少网络开销。

18.1.4. 实战演练:捕捉“幽灵”般的 OPTIONS 请求

回到我们的实战项目,点击 “发起 POST 请求” 按钮。我们的代码中 axios.post 默认发送的是 JSON 数据(Content-Type: application/json),这正是一个典型的 非简单请求

操作步骤

  1. 清除 Network 面板的日志。
  2. 点击 “发起 POST 请求”
  3. 仔细观察 Network 面板。

观测结果:你可能会看到 两个 红色的请求(或者一个 OPTIONS 失败导致后续请求未发送):

  1. 第一个请求

    • Method: OPTIONS
    • Name: update
    • Status: 403 Forbidden (因为 Spring Security 默认拦截) 或 401200 (取决于后端配置,默认 Spring Boot 无配置时可能不返回 CORS 头)。
    • Request Headers:
      • Access-Control-Request-Method: POST
      • Access-Control-Request-Headers: content-type
      • Origin: http://localhost:5173
    • 解释:这就是浏览器在“问路”:“你好服务器,我是 5173,我想发一个 POST 请求,还带了 content-type 头,可以吗?”
  2. 结果:由于我们的后端目前是个“哑巴”(没有配置 CORS),它没有回答“可以”,或者回答了“403 禁止”。于是浏览器判定:预检失败。真正的 POST 请求根本不会发出(或者发出了也会被拦截响应)。


18.1.5. 解决方案一:Spring Security 前置配置 (如有)

在进入 Spring MVC 的配置之前,我们需要特别注意:如果你的项目中引入了 Spring Security,它会作为第一道防火墙拦截所有请求

Spring Security 的过滤器链执行优先级高于 Spring MVC 的 DispatcherServlet。如果 Security 拒绝了 OPTIONS 预检请求,请求根本到不了 MVC 层。

如果存在 Spring Security,必须在 SecurityFilterChain 中优先配置 CORS:

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/main/java/com/example/config/SecurityConfig.java

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 开启 CORS 配置,使用默认的 CorsConfigurationSource
.cors(Customizer.withDefaults())
// 禁用 CSRF (前后端分离通常使用 Token,不需要 Session based CSRF 保护)
.csrf(csrf -> csrf.disable())
// ... 其他配置
;
return http.build();
}

注:若未引入 Spring Security,请直接跳至下一节,Security 是我们后续的教学内容,这里只是提及


18.1.6. 解决方案二:Spring MVC 全局配置 (标准)

对于大多数 Spring Boot 项目,实现 WebMvcConfigurer 接口是解决跨域问题的标准姿势。这种方式配置简单,且能够精确控制哪些路径允许跨域。

我们不再推荐在每个 Controller 上加 @CrossOrigin 注解,因为分散的配置难以维护,且容易遗漏。

步骤 1:创建配置类
在后端项目 demo-cors-backendcom.example.demo.config 包下新建 WebMvcConfig.java

步骤 2:覆盖 addCorsMappings 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* Web MVC 全局配置
* 用于配置跨域、拦截器、资源映射等
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
// 这里的配置相当于告诉浏览器:凡是 /api/** 下的接口,都允许跨域
registry.addMapping("/**") // 1. 匹配所有路径
// .allowedOrigins("*") // 注意:Spring Boot 2.4+ 配合 allowCredentials 时禁止使用 *
.allowedOriginPatterns("*") // 2. 允许所有源 (支持通配符)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 3. 允许的方法
.allowedHeaders("*") // 4. 允许前端携带所有 Header (如 Authorization)
.exposedHeaders("Authorization") // 5. 暴露响应头 (让前端能读取到 Token)
.allowCredentials(true) // 6. 允许携带凭证 (Cookie)
.maxAge(3600); // 7. 预检请求缓存 1 小时
}
}

关键点解析:

  1. **allowedOriginPatterns("*") vs allowedOrigins("*")**:在 Spring Boot 2.4 之前,我们常用 allowedOrigins("*")。但 RFC 协议规定,当 allowCredentialstrue 时,Access-Control-Allow-Origin **不能为通配符 *,必须是具体的域名。
    Spring Boot 2.4+ 对此进行了严格检查。如果使用 allowedOrigins("*") 且开启了凭证,启动可能会报错。使用 allowedOriginPatterns("*") 是推荐的新写法,Spring 会自动将请求中的 Origin 回填到响应头中,既满足了协议要求,又实现了“允许所有”的效果。
  2. exposedHeaders:默认情况下,前端 axios/fetch 只能读取到基本的响应头(如 Content-Type)。如果你在 Header 中返回了 Token 或分页信息,必须在这里显式“暴露”,前端才能通过 response.headers['authorization'] 读取到。

配置完成后,我们再次验证。

  1. 重启后端项目:确保新的配置类生效。
  2. 刷新前端页面http://localhost:5173
  3. 点击“发起 GET 请求”
    • 页面显示:请求成功: 这是来自 8080 端口的机密数据
    • 控制台无红字报错。
  4. 点击“发起 POST 请求”
    • Network 面板中,OPTIONS 请求状态变为 200 OK (或 204 No Content)。
    • 查看 OPTIONS 请求的 Response Headers,你会看到:
      • Access-Control-Allow-Origin: http://localhost:5173
      • Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
    • 紧接着,真正的 POST 请求发送成功,页面显示 POST 成功: 修改成功

至此,我们通过代码配置完美解决了开发环境下的跨域问题。


在大多数企业级应用(尤其是从单体架构迁移到前后端分离的项目)中,我们依然依赖 Cookie 来维持会话(Session)或传递 SSO 令牌。

默认情况下,浏览器发起的跨域 AJAX 请求是 不携带 Cookie 的。要打通这条“凭证传输通道”,必须通过极其严格的协议握手。任何一个环节的疏忽,都会导致 Access to XMLHttpRequest ... has been blocked

  1. 双向握手的必要性

这是一个“双向奔赴”的过程,缺一不可:

  • 前端 (Client):必须显式告诉浏览器“这次请求我要带上私密数据(Cookie)”。
  • 后端 (Server):必须显式回复浏览器“我允许你带上私密数据,且我信任你的来源”。
  1. 前端配置:withCredentials

以 Axios 为例,必须配置 withCredentials: true

1
2
3
4
5
6
7
8
9
10
11
// 前端代码示例 (Vue/React)
import axios from 'axios';

// 方式 A:单次请求配置
axios.get('http://api.example.com/user', {
withCredentials: true // 关键:告诉浏览器携带目标域名的 Cookie
});

// 方式 B:全局配置 (推荐)
axios.defaults.withCredentials = true;

  1. 后端配置的“互斥陷阱”

这是新手最容易撞墙的地方。当浏览器检测到请求携带了 Cookie(withCredentials=true)时,它对响应头有了更苛刻的要求:

铁律Access-Control-Allow-Origin 绝对不能 是通配符 *

如果后端配置了 allowedOrigins("*")allowCredentials(true),浏览器会直接抛出如下错误:

The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard '’ when the request’s credentials mode is ‘include’.*

为什么协议要这样设计?
这是一个安全考量。* 代表“谁都可以来访问”。如果允许 * 配合 Cookie,意味着任何钓鱼网站都可以读取你的敏感数据。协议强制要求服务器“明确点名”信任的域名(例如 http://localhost:5173),以表明服务器确实验证了来源。

  1. Spring Boot 的优雅解法 (Origin 反射)

为了解决“既要允许 Cookie,又不想把域名写死(硬编码)”的矛盾,Spring Boot 2.4+ 引入了 allowedOriginPatterns

它的工作原理是 “动态反射”

  1. Spring 拦截到请求,读取请求头中的 Origin 字段(例如 http://localhost:5173)。
  2. Spring 拿着这个 Origin 去匹配你配置的 Pattern(例如 *http://*.example.com)。
  3. 如果匹配成功,Spring 会把 请求中的这个 Origin 值 原封不动地写入响应头的 Access-Control-Allow-Origin 字段。
  4. 浏览器看到的响应头是 Access-Control-Allow-Origin: http://localhost:5173(而不是 *),从而满足了凭证传输的协议要求。

修正后的标准代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
// ❌ 错误示范:在 allowCredentials(true) 时,严禁使用 allowedOrigins("*")
// .allowedOrigins("*")

// ✅ 正确示范:使用 Pattern 进行动态匹配和反射
.allowedOriginPatterns("*")

.allowCredentials(true) // 允许 Cookie
.allowedMethods("*")
.allowedHeaders("*");
}


18.1.8. 生产架构:Nginx 反向代理层面的“欺骗”

在开发环境(Localhost)我们用 Spring Boot 全局配置解决跨域。但在 生产环境(Production),最佳实践是完全移除后端的 CORS 配置,改用 Nginx 反向代理

这不仅仅是为了解决跨域,更是为了 网络架构的统一性

  1. 核心思想:同源伪装

浏览器的同源策略只针对“浏览器 <-> 服务器”的通信。服务器 <-> 服务器 之间的通信是不受 CORS 限制的。

Nginx 充当了一个“中间人”。浏览器只和 Nginx 说话,Nginx 再在内网分别去找前端文件和后端接口。在浏览器看来,它访问的所有资源都来自同一个域名(同源),因此 CORS 机制根本不会被触发,详细的 Nginx 配置我们就不在 Springboot 的环节讲解了

  1. 架构拓扑图

mermaid-ai-diagram-2025-12-22-061104


18.2. 核心链路:拦截器 (Interceptor) 体系构建

本节摘要:请求从浏览器出发,跨越了 CORS 的护城河,终于抵达了服务器的城门。但城门之内并非毫无防备。本节我们将深入 Spring MVC 的核心调度链路,构建一道基于 拦截器 (Interceptor) 的精密防线。我们将彻底理清 Filter 与 Interceptor 的纠葛,手写无侵入式的登录校验逻辑,并攻克“如何在 Controller 层优雅获取用户信息”这一架构难题(ThreadLocal 上下文透传),最后掌握多拦截器协作的编排艺术。


18.2.1. 混淆厘清:Servlet Filter vs Spring Interceptor

在 Spring Boot 的请求处理流程中,有两个极易混淆的概念:过滤器 (Filter)拦截器 (Interceptor)。很多开发者在做“登录校验”或“日志记录”时,往往只是凭感觉随便选一个。

作为架构设计者,我们需要从 执行时机控制粒度 两个维度来精准选型。

  1. 执行链路图解

想象一个请求穿过层层关卡到达 Controller 的过程:

mermaid-ai-diagram-2025-12-22-061659

  1. 核心维度对比表
维度Servlet Filter (过滤器)Spring Interceptor (拦截器)
归属Jakarta Servlet 规范 (Tomcat 容器层)Spring MVC 框架 (Spring 容器层)
核心能力能修改 Request/Response 的 原始输入流能获取到 具体的方法 (Method)Bean
感知范围只能看到 HttpServletRequest,不知道是哪个 Controller 处理能看到是 UserControllerlogin 方法在处理
异常处理只能由 Filter 内部捕获,无法被 @ExceptionHandler 全局异常捕获抛出的异常会被 Spring 的全局异常处理器捕获
依赖注入较难注入 Spring Bean (但在 Spring Boot 中已无缝支持)天然支持 @Autowired 注入 Service
适用场景字符编码、跨域配置、Gzip 压缩、XSS 清洗登录校验、权限鉴权、接口日志、参数篡改

选型铁律:如果你需要基于 业务逻辑(比如判断用户权限、记录具体方法的耗时)做处理,首选拦截器。如果你需要处理 底层协议(比如解压请求体、设置字符集),首选过滤器


18.2.2. 基础实战:实现 HandlerInterceptor 接口

Spring MVC 提供了 HandlerInterceptor 接口,它像是一个环绕在 Controller 周围的切面,提供了三个精细的“钩子函数”。

文件路径: src/main/java/com/example/demo/interceptor/LoginInterceptor.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.example.demo.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

@Slf4j
@Component // 交给 Spring 管理,方便后续注入 Service
public class LoginInterceptor implements HandlerInterceptor {

/**
* 【前置钩子】
* 执行时机:进入 Controller 方法之前
* 核心作用:权限检查、Token 校验、限流
* 返回值:true = 放行;false = 拦截(后续环节全部中止)
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info(">>> 1. 拦截器前置处理: {}", request.getRequestURI());

// 1. 获取 Token (通常在 Header 中)
String token = request.getHeader("Authorization");

// 2. 模拟校验逻辑 (真实场景应调用 JWTUtil 解析)
if (token == null || !"valid-token-123".equals(token)) {
log.warn(">>> Token 校验失败,请求拦截");

// 3. 校验失败响应:直接设置状态码并写入 JSON
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\": 401, \"message\": \"未登录或 Token 失效\"}");

return false; // 🚫 拦截!不再执行后续 Controller
}

// 4. 校验通过
return true; // ✅ 放行
}

/**
* 【后置钩子】
* 执行时机:Controller 方法执行完毕,但视图渲染(ViewResolver)之前
* 核心作用:修改 ModelAndView 数据(前后端分离模式下较少使用)
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
log.info(">>> 2. 拦截器后置处理 (Controller 执行完毕)");
}

/**
* 【最终钩子】
* 执行时机:整个请求结束(视图渲染完毕),或 Controller 抛出异常后
* 核心作用:资源清理(如 ThreadLocal.remove)、异常日志记录
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
log.info(">>> 3. 请求最终结束");
// 注意:这里的 ex 只有在异常未被 @ExceptionHandler 捕获时才会有值
// 如果被全局异常处理器捕获并处理了,这里 ex 为 null
}
}


18.2.3. 注册策略:WebMvcConfigurer 的配置细节

写好拦截器只是第一步,它现在只是一个孤立的 Bean。我们需要把它“挂载”到 Spring MVC 的请求链路上。

文件路径: src/main/java/com/example/demo/config/WebMvcConfig.java (追加内容)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Autowired
private LoginInterceptor loginInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册拦截器
registry.addInterceptor(loginInterceptor)
// 1. 拦截规则:所有路径
.addPathPatterns("/**")

// 2. 排除规则:白名单路径
// 注意:Spring Boot 默认会自动处理静态资源,但为了保险,建议显式排除
.excludePathPatterns(
"/users/login", // 登录接口
"/users/register", // 注册接口
"/error", // Spring Boot 默认错误转发路径
"/swagger-ui/**", // Swagger 文档
"/v3/api-docs/**", // Swagger 文档数据
"/static/**" // 静态资源
);
}

配置避坑指南

  • 路径匹配符

  • /*:只匹配一级路径(如 /users),不匹配 /users/1

  • /**:匹配所有层级路径(递归匹配)。这是最常用的配置

  • 静态资源:如果你的 addPathPatterns 配置了 /**,一定要记得排除 /static/**/public/**,否则用户请求一张图片也会触发 Token 校验,导致图片加载失败(除非你希望图片也需要鉴权)。


18.2.4. 架构难点:ThreadLocal 实现用户上下文透传

这是一个区分“初级代码”和“高级架构”的关键点。

痛点场景:在 Controller 中,我们经常需要用到当前登录用户的信息(ID、角色等)。

  • 笨办法:在每个 Controller 方法参数里加 HttpServletRequest request,然后解析 Header 拿 Token,再查库。代码重复度极高。
  • 优雅法:在拦截器校验通过后,把用户信息存起来,Controller 里随用随取。

问题来了:存哪儿?
存在成员变量里?不行,Controller 是单例的,多线程下会数据覆盖。存在 Session 里?前后端分离通常不用 Session。
答案:ThreadLocal

步骤 1:构建上下文工具类 (UserContext)

文件路径: src/main/java/com/example/demo/context/UserContext.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.demo.context;

import lombok.Data;

import java.util.Map;

/**
* 基于 ThreadLocal 的用户上下文
* 作用:在同一线程的请求链路中,随时随地获取当前用户信息
*/
@Data
public class UserContext {
// 使用 ThreadLocal 存储当前线程的 User ID (或其他信息)
private static final ThreadLocal<Long> USER_ID_HOLDER = new ThreadLocal<>();

// 务必提供清理方法,防止内存泄漏
public static void remove() {
USER_ID_HOLDER.remove();
}
}

步骤 2:在拦截器中写入 (PreHandle)

修改 LoginInterceptor.javapreHandle 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public boolean preHandle(...) {
// ... Token 校验逻辑 ...

// 假设从 Token 中解析出了 userId = 10086
Long userId = JwtUtil.getUserId(token);

// 🔑 核心操作:将 userId 放入当前线程的上下文
UserContext.setUserId(userId);

return true;
}

步骤 3:在 Controller 中读取

1
2
3
4
5
6
7
8
@GetMapping("/my-info")
public Result<User> getMyInfo() {
// 🎉 优雅!任何地方都能直接拿到当前用户 ID,不需要传参
Long currentUserId = UserContext.getUserId();

// 根据 ID 查询数据库...
return Result.success(userService.getById(currentUserId));
}

18.2.5. 内存安全:afterCompletion 的清理责任

警告:这是一个 如果不做,上线必炸 的细节。

Spring Boot 内置的 Tomcat 使用了 线程池 来处理请求。这意味着一个线程在处理完请求 A 后,不会被销毁,而是回到池子里等待处理请求 B。

如果在请求 A 结束时,没有清理 ThreadLocal 中的数据:

  1. 线程被复用给请求 B。
  2. 请求 B 是一个未登录的公共请求(不走 LoginInterceptor 的 set 逻辑)。
  3. 请求 B 的业务代码调用 UserContext.getUserId()
  4. 灾难发生:请求 B 竟然拿到了请求 A 的用户 ID!这就造成了严重的 数据串号 事故。

修正后的代码 (LoginInterceptor.java)

1
2
3
4
5
6
7
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 🧹 无论请求成功还是失败,必须强制清理 ThreadLocal
UserContext.remove();
log.info(">>> 清理用户上下文完毕");
}


18.2.6. 链路编排:多拦截器的执行顺序 (Order)

真实项目中,通常不止一个拦截器。比如:

  1. LogInterceptor: 记录请求耗时。
  2. AuthInterceptor: 校验登录。
  3. BlacklistInterceptor: 黑名单 IP 检查。

它们的执行顺序至关重要。例如,BlacklistInterceptor 应该最先执行,如果 IP 被拉黑,直接拒绝,省得后续还要去校验 Token。

配置方法:在 addInterceptors 时,通过 .order(int) 指定优先级。数字越小,优先级越高

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public void addInterceptors(InterceptorRegistry registry) {
// Order 1: 黑名单 (最先执行)
registry.addInterceptor(blacklistInterceptor).order(1);

// Order 2: 登录校验
registry.addInterceptor(loginInterceptor).order(2);

// Order 3: 日志 (最后执行,因为可能需要记录前面是否失败)
registry.addInterceptor(logInterceptor).order(3);
}

责任链模式的“洋葱模型”

注意拦截器的执行流程是“先进后出”的(类似于栈):

  • PreHandle 顺序:1 -> 2 -> 3
  • Controller 执行
  • PostHandle 顺序:3 -> 2 -> 1
  • AfterCompletion 顺序:3 -> 2 -> 1

这意味着:order(1) 的拦截器拥有 最大的包围圈,它最早开始处理,最晚结束清理。这正是我们在 ThreadLocal 清理等场景下需要深刻理解的模型。


18.3. 数据交互:本地文件上传下载解析

本节目标:从 HTTP 协议的 Multipart 报文结构出发,构建一个生产级的本地文件存储系统。我们将深入研究 MultipartFile 的流式处理机制,引入 Apache Tika 进行“指纹级”安全校验,利用 Java NIO 实现高性能读写,并设计支持 GB 级大文件下载的流式接口。最后,采用 接口隔离原则 设计架构,确保系统能平滑切换至分布式存储(如 MinIO/OSS)。

18.3.1. 协议透视:Multipart/form-data 数据流

在之前的章节中,我们传输的都是 JSON 文本数据(application/json)。但文件本质上是二进制数据(Binary),如果直接转成 JSON 字符串传输,编解码效率极低且体积会因 Base64 编码膨胀约 33%。

因此,RFC 1867 定义了 multipart/form-data 内容类型,这是 Web 文件传输的基石。

  1. 报文解剖:流式传输的奥秘

当你在前端使用 <input type="file"> 上传文件时,浏览器发送的 HTTP 报文结构非常特殊。它并没有像 JSON 那样使用 {} 包裹,而是使用一个 Boundary (分界线) 来切割不同的字段。

我们通过 Postman 模拟上传一个 avatar.png 和一个文本字段 username,其原始报文如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /files/upload HTTP/1.1
Host: localhost:8080
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 10485760

----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"

jerry
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="avatar.png"
Content-Type: image/png

(这里是 PNG 文件的二进制乱码数据...)
----WebKitFormBoundary7MA4YWxkTrZu0gW--

底层原理深度解析

  • Boundary (分界线):HTTP Header 中定义的 boundary 是一串随机生成的字符。它像一把刀,将请求体切分成多个部分(Part)。服务器并不需要一次性读取整个 Body,而是寻找这个分界线,从而实现 流式解析
  • 内存 vs 磁盘 (Memory vs Disk)
    • Spring MVC 的 DispatcherServlet 使用 StandardServletMultipartResolver 解析请求。
    • 关键机制:Servlet 容器(如 Tomcat)并不会将所有上传的文件全部加载到 RAM 中。它会根据配置的阈值(Threshold),将小文件暂存在内存中,而将大文件直接 流式写入操作系统的临时文件夹
    • 这正是为什么上传 1GB 视频不会导致 JVM 内存溢出(OOM)的原因。
  • 生命周期:Controller 中接收到的 MultipartFile 对象,本质上是指向这些临时文件的“句柄”。注意:一旦 HTTP 请求响应结束,Servlet 容器通常会清理这些临时文件。因此,必须在 Controller 返回前将文件 transferTo 到永久存储区。

18.3.2. 生产级配置:容量规划与临时区管理

在生产环境中,默认配置往往过于保守或不安全。我们需要针对 DDoS 攻击防御磁盘 IO 性能 进行双重优化。

配置文件:src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
servlet:
multipart:
max-file-size: 100MB # 单个文件上限 (默认仅 1MB,生产环境需调大)
max-request-size: 100MB # 单次请求总上限 (例如一次传5张图的总大小)
file-size-threshold: 2KB # 【性能核心】超过此大小直接写入磁盘临时文件
location: /data/tmp/tomcat-upload # 【稳定性核心】显式指定临时目录

# 自定义文件存储配置 (与 spring 节点平级)
file:
storage:
local:
path: /data/files/uploads/ # 物理存储路径 (建议使用绝对路径)

配置详解与最佳实践

  1. file-size-threshold (阈值优化)

    • 设为 0:所有文件(哪怕 1KB)都会写入磁盘临时文件。这会产生大量的磁盘 IO,降低并发能力。
    • 设为 2KB:小头像、文本文件直接在内存处理,速度极快;大视频则自动落盘,保护堆内存。这是平衡性能与稳定性的最佳实践。
  2. location (临时目录陷阱)

    • 在 Linux 系统中,默认的临时目录 /tmp 会被系统定时任务清理(例如 10 天未访问的文件)。
    • 如果应用长期运行未重启,Tomcat 可能会发现自己的临时目录“凭空消失”了,导致上传时抛出 FileNotFoundException (No such file or directory)
    • 强烈建议:显式指定一个持久化的临时路径,避免被操作系统误删。

18.3.3. 安全防御体系:构建“指纹级”校验

高危预警:文件上传是 Web 安全漏洞的重灾区。初学者最容易犯的错误是只检查后缀名 originalFilename.endsWith(".png")。这相当于只看身份证外壳,不看防伪芯片。

常见攻击手段

  1. WebShell 攻击:黑客将 .jsp.php 脚本文件强行修改后缀为 .jpg 上传。如果服务器配置不当,配合“文件包含漏洞”,该图片可能被当作脚本执行,导致服务器沦陷。
  2. MIME 欺骗:黑客使用工具拦截 HTTP 请求,将 Content-Type 强行修改为 image/png,轻松绕过简单的 MIME 检查。
  3. 路径遍历 (Path Traversal):上传文件名为 ../../etc/passwd 的文件,试图覆盖服务器的关键系统文件。

为了防御这些攻击,我们将构建由 Apache Tika 驱动的深度检测机制。

  1. 引入 Apache Tika (业界标准)

魔数 (Magic Number) 是文件开头的几个固定字节(例如 PNG 是 89 50 4E 47)。虽然可以手动比对,但维护成千上万种格式的特征码极其困难。Apache Tika 是内容分析领域的行业标准库,它能解析文件内部结构,精准识别数千种格式。

步骤 1:添加 Maven 依赖

1
2
3
4
5
6
<!-- Apache Tika: 用于文件类型深度检测 -->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>2.9.1</version>
</dependency>
  1. 编写安全工具类 UploadUtils

我们需要一个集成了 Tika 检测、文件名净化(Sanitization)和防冲突逻辑的工具类。

路径src/main/java/com/example/demo/utils/UploadUtils.java

注:路径遍历攻击(Path Traversal)是指攻击者通过在输入中利用 ../(回退上级目录)等特殊字符,突破应用程序设定的目录限制,从而非法访问服务器文件系统中的敏感文件(如配置文件或系统密码)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package com.example.demo.utils;

import org.apache.tika.Tika;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.UUID;

public class UploadUtils {

// Tika 实例加载成本较高但线程安全,建议静态单例复用
private static final Tika TIKA = new Tika();

// 白名单机制:仅允许业务需要的 MIME 类型
// 相比黑名单(禁止 .exe),白名单(只许 .png)更安全
private static final List<String> ALLOWED_MIME_TYPES = List.of(
"image/jpeg",
"image/png",
"image/gif",
"application/pdf"
);

/**
* 深度安全校验:使用 Tika 检测文件的真实“指纹”
* @param file 上传的文件对象
* @return 是否安全
*/
public static boolean isSafeFile(MultipartFile file) {
if (file == null || file.isEmpty()) return false;

try (InputStream inputStream = file.getInputStream()) {
// Tika 不仅读取文件头,还会尝试解析文件结构来判断类型
String detectedMimeType = TIKA.detect(inputStream);

// 严格比对白名单
if (!ALLOWED_MIME_TYPES.contains(detectedMimeType)) {
// 实际生产中应记录安全日志:System.log("疑似恶意上传: " + detectedMimeType);
return false;
}
return true;
} catch (IOException e) {
// 无法读取流,视为不安全
return false;
}
}

/**
* 生成安全的文件名 (UUID + 净化后的后缀)
* 防御路径遍历攻击
*/
public static String generateSafeFileName(String originalFilename) {
// 1. 提取后缀名
String suffix = "";
if (originalFilename != null && originalFilename.contains(".")) {
suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
}

// 2. 使用 UUID 替换原始文件名
// 这一步至关重要,它同时解决了三个问题:
// A. 文件名冲突:UUID 保证唯一性。
// B. 隐私泄露:隐藏了用户本地的原始文件名(如 "工资条.pdf")。
// C. 路径遍历:彻底消除了 "../../" 等恶意字符存在的可能性。
return UUID.randomUUID().toString().replace("-", "") + suffix;
}
}

18.3.4. 架构设计:基于接口的存储策略

架构原则依赖倒置 (DIP)。上层业务(Controller)不应依赖底层细节(本地磁盘)。

如果我们在 Controller 中直接写 new File(...),未来若需迁移到 阿里云 OSSAWS S3MinIO,将面临灾难性的代码修改。我们必须采用 策略模式,定义一个抽象的存储服务接口。

  1. 定义 FileStorageService 接口

路径src/main/java/com/example/demo/service/FileStorageService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.demo.service;

import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;

public interface FileStorageService {
/**
* 上传文件
* @param file 前端传输的文件对象
* @return 存储后的相对路径(用于后续访问)
*/
String upload(MultipartFile file);

/**
* 下载文件(获取输入流)
* 这里的返回值是 InputStream,因为无论是本地文件还是网络流,
* 都可以统一抽象为输入流,从而屏蔽底层差异。
* @param fileName 文件名或路径
* @return 文件输入流
*/
InputStream download(String fileName);

/**
* 删除文件
* @param fileName 文件名
*/
void delete(String fileName);
}
  1. 实现本地存储策略 (LocalFileStorageServiceImpl)

我们将使用 Java NIO (java.nio.file.*) 替代老旧的 java.io.File。NIO 提供了更现代的文件操作 API,支持原子性操作和更好的异常处理。

路径src/main/java/com/example/demo/service/impl/LocalFileStorageServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
package com.example.demo.service.impl;

import com.example.demo.service.FileStorageService;
import com.example.demo.utils.UploadUtils;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

@Service("localStorage") // 命名 Bean,方便后续通过 @Qualifier 切换不同实现
public class LocalFileStorageServiceImpl implements FileStorageService {

@Value("${file.storage.local.path}")
private String uploadDir; // 注入配置文件中的路径

// 初始化:Spring 容器启动时自动创建根目录
@PostConstruct
public void init() {
try {
Files.createDirectories(Paths.get(uploadDir));
} catch (IOException e) {
throw new RuntimeException("严重错误:无法初始化文件存储目录", e);
}
}

@Override
public String upload(MultipartFile file) {
// 1. 前置检查:安全校验
if (!UploadUtils.isSafeFile(file)) {
throw new IllegalArgumentException("文件类型非法或内容受损");
}

try {
// 2. 目录规划:按日期归档 (uploads/2025/10/01/)
// 避免一个文件夹下存放数百万个文件,导致文件系统索引性能下降
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
Path folderPath = Paths.get(uploadDir, datePath);
Files.createDirectories(folderPath);

// 3. 文件名生成
String newFileName = UploadUtils.generateSafeFileName(file.getOriginalFilename());

// 4. 执行写入 (NIO 核心)
// Files.copy 利用了操作系统的文件系统优化
// StandardCopyOption.REPLACE_EXISTING 保证了如果 UUID 碰撞(极低概率)也能覆盖
Path targetPath = folderPath.resolve(newFileName);
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);

// 5. 返回相对路径供业务层使用
return datePath + "/" + newFileName;

} catch (IOException e) {
throw new RuntimeException("文件存储失败", e);
}
}

@Override
public InputStream download(String fileName) {
try {
// 路径安全检查:防止 ../../etc/passwd 攻击
Path rootPath = Paths.get(uploadDir).toAbsolutePath();
Path filePath = rootPath.resolve(fileName).normalize().toAbsolutePath();

// 只有当解析后的路径 确实是以 根目录 开头时,才允许访问
if (!filePath.startsWith(rootPath)) {
throw new SecurityException("非法的文件访问路径");
}

// 返回文件流,由 Controller 层决定如何通过网络发送
return new FileInputStream(filePath.toFile());
} catch (Exception e) {
throw new RuntimeException("文件读取失败: " + fileName, e);
}
}

@Override
public void delete(String fileName) {
try {
Path filePath = Paths.get(uploadDir).resolve(fileName);
Files.deleteIfExists(filePath);
} catch (IOException e) {
throw new RuntimeException("删除失败", e);
}
}
}

18.3.5. Web 层实现:流式下载与零拷贝优化

Controller 层的职责是处理 HTTP 协议。在这里,我们将实现一个 高性能的流式下载接口

普通下载 vs 流式下载

  • 普通方式:将文件读取为 byte[] 并在内存中组装,然后一次性返回。这对于小文件没问题,但如果下载 500MB 的文件,瞬间就会消耗掉服务器 500MB 的堆内存。并发 10 个人下载,服务器直接 OOM 崩溃。
  • 流式方式:像接水管一样,一边从磁盘读(InputStream),一边往网络写(OutputStream)。内存中永远只有极小的缓冲区(如 8KB),下载 10GB 的文件也只会占用几 KB 内存。

路径src/main/java/com/example/demo/controller/FileController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package com.example.demo.controller;

import com.example.demo.service.FileStorageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

import java.io.InputStream;
import java.io.OutputStream;
import java.util.Map;

@RestController
@RequestMapping("/api/files")
public class FileController {

// 注入接口,解耦实现
@Autowired
@Qualifier("localStorage")
private FileStorageService fileStorageService;

/**
* 文件上传接口
*/
@PostMapping("/upload")
public Map<String, Object> upload(@RequestParam("file") MultipartFile file) {
String path = fileStorageService.upload(file);
// 返回用于前端展示的 URL(配合后续的静态资源映射)
String accessUrl = "/static/" + path;
return Map.of("code", 200, "url", accessUrl, "path", path);
}

/**
* 高性能流式下载接口
* 场景:下载私有文件、大文件,或强制浏览器弹出“保存”对话框
*
* @param filename 相对路径,如 2025/10/01/xxx.png
*/
@GetMapping("/download")
public ResponseEntity<StreamingResponseBody> download(@RequestParam("filename") String filename) {

// 1. 获取文件输入流 (抽象层)
InputStream inputStream = fileStorageService.download(filename);

// 2. 封装为 StreamingResponseBody (Spring MVC 异步流处理)
// 这个回调函数会在 Spring 托管的异步线程中执行,不会阻塞 Servlet 主线程
StreamingResponseBody responseBody = new StreamingResponseBody() {
@Override
public void writeTo(OutputStream outputStream) throws java.io.IOException {
// 定义 8KB 缓冲区,分块读取并写入响应流
// 这就是“零拷贝”思想的应用层体现:数据在流之间直接传递
byte[] buffer = new byte[8192];
int bytesRead;
// try-with-resources 自动关闭输入流
try (inputStream) {
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
}
}
};

// 3. 提取下载时的文件名 (从路径中截取)
String downloadName = filename.substring(filename.lastIndexOf("/") + 1);

// 4. 构建响应头
return ResponseEntity.ok()
// Content-Disposition: attachment 强制浏览器下载而非预览
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(responseBody);
}
}

18.3.6. 静态资源映射:打通最后一公里

至此,文件已经安全上传,并且有了专门的下载接口。但在很多场景下(如 <img src="...">),我们需要通过 URL 直接访问图片,而不是触发下载。

由于我们的文件存储在项目外部的磁盘路径(例如 /data/files/uploads/),Spring Boot 默认的静态资源处理机制(只看 classpath:/static/)无法找到它们。我们需要配置一个虚拟映射。

路径src/main/java/com/example/demo/config/WebMvcConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.example.demo.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.concurrent.TimeUnit;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

@Value("${file.storage.local.path}")
private String storagePath;

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 关键逻辑:构建 file 协议路径
// 如果 storagePath 是 "/data/files/uploads/"
// 则 location 必须是 "file:/data/files/uploads/"
String resourceLocation = "file:" + storagePath;
if (!resourceLocation.endsWith("/")) {
resourceLocation += "/"; // 规范要求必须以斜杠结尾
}

// 映射规则:URL 中的 /static/** ==> 磁盘上的 file: path
registry.addResourceHandler("/static/**")
.addResourceLocations(resourceLocation)
// 性能优化:为静态资源设置 7 天的浏览器缓存
// 这能显著减少服务器带宽压力,提升用户加载速度
.setCacheControl(CacheControl.maxAge(7, TimeUnit.DAYS).cachePublic());
}
}