ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue图片上传实战:从协议原理到安全落盘

SpringBoot+Vue图片上传实战:从协议原理到安全落盘 1. 项目概述为什么“SpringBootVue实现图片上传”是每个全栈新手绕不开的第一课我带过不少刚转行的前端和后端同学也帮几十个团队做过技术选型评审发现一个特别有意思的现象几乎所有人的第一个完整前后端联调项目不是写登录注册也不是做商品列表而是——图片上传。它看起来简单但恰恰是检验你是否真正理解前后端协作本质的试金石。SpringBoot Vue 这个组合不是随便凑的它代表了当前国内中小型企业最主流、最稳妥、学习曲线最平缓的技术栈。你可能已经用 Vue 写过轮播图、用 SpringBoot 搭过 REST API但当一张 JPG 文件从浏览器点击“选择文件”穿过网络、经过拦截器、存进磁盘或对象存储、再返回一个 URL 给前端展示出来——这个过程里藏着至少 7 个关键断点前端文件读取方式、跨域配置粒度、后端接收参数类型、MultipartFile 的生命周期管理、临时文件清理机制、路径安全性校验、以及最关键的——如何让 Vue 的表单数据和 SpringBoot 的 Controller 方法签名严丝合缝地对上。很多人卡在RequestParam和RequestPart的选择上或者 Vue 用FormData.append()时漏了Content-Type: multipart/form-data的自动设置又或者 SpringBoot 升级到 3.x 后CommonsMultipartResolver彻底废弃导致老教程直接失效。这根本不是“功能实现”而是一次对 HTTP 协议、框架设计哲学、安全边界意识的综合实战。适合谁适合所有正在从单页面开发转向真实业务交付的开发者尤其适合那些在面试中被问到“你上传过多少种文件类型”“怎么限制图片大小”“上传失败怎么重试”的人——因为这些问题的答案全藏在这套流程的每一个螺丝钉里。2. 整体架构设计与方案选型逻辑为什么不用第三方 SDK而坚持手写核心链路2.1 前后端分离下的上传本质不是“传文件”而是“传二进制流元信息”很多初学者以为图片上传就是“把文件拖进去后端收着就行”这是最大的认知偏差。HTTP 协议本身不支持直接传输文件它传输的是字节流byte stream。当你在 Vue 页面点击input typefile浏览器做的第一件事是把选中的文件读取为Blob或File对象然后封装进FormData实例。这个FormData不是普通 JSON它会自动生成一个随机 boundary 字符串把文件二进制数据、字段名、文件名、MIME 类型全部按multipart/form-data标准拼接成一段原始字节流。SpringBoot 接收到这段流后需要解析 boundary拆出各个 part再根据Content-Disposition头提取字段名最后把对应 part 的字节流包装成MultipartFile。整个过程没有魔法只有协议和约定。所以我们的设计起点必须是前端负责构造合规的 multipart 流后端负责解析并安全落盘。任何试图绕过这个底层逻辑、直接用axios.post(/upload, file)的写法都会在生产环境栽跟头——因为 axios 默认发送的是application/json而 SpringBoot 的MultipartFile解析器只认multipart/form-data。2.2 为什么放弃阿里云 OSS/腾讯云 COS 的 SDK 直传网上大量教程教你怎么用 Vue 直连 OSS 签名上传这确实能减轻后端压力但对新手极其不友好。原因有三第一OSS 直传要求前端计算签名涉及AccessKeySecret、policy、signature等加密逻辑一旦密钥泄露等于把你的云存储桶完全暴露第二直传成功后OSS 只返回一个回调地址你需要额外配置服务端回调来校验上传结果并写入数据库这个回调接口本身又是新的 SpringBoot 接口复杂度翻倍第三企业内网环境往往禁止前端直连外网对象存储必须走代理或后端中转。所以我坚持用“后端中转”模式Vue → SpringBoot → 本地磁盘/MinIO。这样所有权限控制、格式校验、缩略图生成、水印添加都集中在后端前端只管 UI 交互和错误提示。等你把这套链路跑通三次以上再学直传才不会迷失在密钥管理和回调验证的迷宫里。2.3 SpringBoot 版本适配陷阱3.x 与 2.x 的 Multipart 配置差异这是近期踩坑最多的地方。SpringBoot 2.7 之前你可以在application.yml里这样配spring: servlet: context-path: /api http: multipart: max-file-size: 10MB max-request-size: 10MB但 SpringBoot 3.0 开始spring.http.multipart被彻底移除改用 Jakarta EE 9 规范配置变成spring: web: resources: static-locations: classpath:/static/ servlet: encoding: charset: UTF-8 # 注意这里是 spring.servlet.multipart不是 spring.http.multipart servlet: multipart: max-file-size: 10MB max-request-size: 10MB file-size-threshold: 2KB更关键的是MultipartConfigElement的创建方式也变了。2.x 时代你可以用Bean注册Bean public MultipartConfigElement multipartConfigElement() { MultipartConfigFactory factory new MultipartConfigFactory(); factory.setMaxFileSize(DataSize.ofMegabytes(10)); factory.setMaxRequestSize(DataSize.ofMegabytes(10)); return factory.createMultipartConfig(); }而 3.x 必须用ServletWebServerFactory的setMultipartConfig()方法或者直接在application.yml中配置。如果你照着 2.x 的教程在 3.x 项目里写MultipartConfigElementBean启动时会报NoSuchBeanDefinitionException因为 SpringBoot 3.x 已经内置了默认配置不需要手动注册。这个细节差一点整个上传功能就静默失效——表单提交没报错但后端 Controller 根本收不到MultipartFile参数。2.4 Vue 端选型Composition API Axios 还是原生 FetchVue 3 的 Composition API 是必然选择但 Axios 和 Fetch 的取舍值得深究。Axios 优势在于自动处理Content-Type、请求拦截、错误统一捕获对上传场景特别友好。比如它的onUploadProgress回调能实时拿到上传进度这是原生 Fetch 做不到的Fetch 需要配合ReadableStream手动解析。但 Axios 也有隐患某些版本对FormData的处理存在兼容性问题特别是当FormData里混入非文件字段如userId、type时部分 Axios 版本会把所有字段序列化成字符串导致后端无法正确映射。所以我实测下来推荐使用 Axios 1.6并在创建实例时显式关闭transformRequestconst uploadInstance axios.create({ baseURL: /api, headers: { X-Requested-With: XMLHttpRequest }, // 关键禁用自动转换让 FormData 原样发出 transformRequest: [(data, headers) { if (data instanceof FormData) { return data; } return JSON.stringify(data); }] });这样能确保FormData的 boundary 和二进制结构不被破坏。而原生 Fetch 虽然轻量但你需要自己监听XMLHttpRequest.upload.onprogress还要手动处理AbortController来支持取消上传对新手来说调试成本太高。3. 核心细节解析与实操要点从 Vue 选择文件到 SpringBoot 安全落盘的 12 个关键节点3.1 Vue 端文件选择与预览的“零延迟”体验设计用户点击“选择图片”按钮后如果页面卡顿 1 秒才弹出系统对话框体验就毁了一半。这里有个容易被忽略的细节input typefile元素默认是隐藏的我们通常用一个label包裹它通过for属性关联。但很多教程直接写label foruploadInput点击上传/label input iduploadInput typefile changehandleFileChange /问题在于input默认会触发页面重排reflow尤其在移动端会导致短暂白屏。更优解是用 CSS 将 input 绝对定位到屏幕外并设置opacity: 0div classupload-wrapper label classupload-btn foruploadInput点击上传/label input iduploadInput typefile acceptimage/* changehandleFileChange classhidden-input / /div style .hidden-input { position: absolute; left: -9999px; opacity: 0; } .upload-btn { display: inline-block; padding: 8px 16px; background: #007bff; color: white; border-radius: 4px; cursor: pointer; } /styleacceptimage/*是第一道过滤但它只是浏览器层面的提示不能阻止用户手动修改文件后缀绕过。真正的校验必须在后端做。另外文件预览要“零延迟”handleFileChange方法里不要等axios返回才显示缩略图而是立刻用URL.createObjectURL(file)创建本地预览 URLconst handleFileChange (event) { const file event.target.files[0]; if (!file) return; // 立即生成预览不依赖网络 const previewUrl URL.createObjectURL(file); previewImage.value previewUrl; // 同时进行格式和大小校验 if (!/\.(jpg|jpeg|png|gif)$/i.test(file.name)) { ElMessage.error(仅支持 JPG、PNG、GIF 格式); return; } if (file.size 5 * 1024 * 1024) { // 5MB ElMessage.error(文件大小不能超过 5MB); return; } // 准备上传 uploadFile(file); };提示URL.createObjectURL()创建的 URL 是内存引用不是真实路径。它只在当前页面有效关闭页面后自动释放。不要把它存进数据库那是无效的。3.2 SpringBoot 端Controller 方法签名的“生死抉择”这是前后端联调失败率最高的环节。常见错误写法// ❌ 错误用 RequestBody 接收文件RequestBody 只能接收 JSON PostMapping(/upload) public ResultString upload(RequestBody MultipartFile file) { ... } // ❌ 错误用 RequestParam 但没指定 namename 必须和前端 FormData 的 key 一致 PostMapping(/upload) public ResultString upload(RequestParam MultipartFile file) { ... } // ✅ 正确用 RequestPart明确指定 part 名称 PostMapping(/upload) public ResultString upload(RequestPart(file) MultipartFile file) { ... }为什么必须用RequestPart因为RequestParam用于接收application/x-www-form-urlencoded编码的简单字段如?namexxxage25而文件上传是multipart/form-data每个 part 都有自己的 header 和 body。RequestPart告诉 SpringBoot“请从 multipart 请求体中找到 name 为file的那个 part并解析成MultipartFile”。如果你的前端代码是const formData new FormData(); formData.append(file, file); // key 是 file formData.append(userId, 123);那么后端就必须写RequestPart(file)否则 SpringBoot 找不到对应的 part抛出MissingServletRequestPartException。更严谨的做法是把所有参数都用RequestPartPostMapping(/upload) public ResultString upload( RequestPart(file) MultipartFile file, RequestPart(userId) String userId, RequestPart(type) String type ) { ... }这样能保证所有字段都来自同一个 multipart 请求体避免因参数编码方式不同导致的解析混乱。3.3 安全校验三重门为什么光靠后缀名校验是自杀行为我见过太多线上事故根源都是“信任前端传来的文件名”。黑客只需要把hacker.jpg改成shell.php.jpg再用工具去掉.jpg后缀就能上传 PHP WebShell。所以安全校验必须分三层第一层文件扩展名白名单private static final SetString ALLOWED_EXTENSIONS Set.of(jpg, jpeg, png, gif); String originalFilename file.getOriginalFilename(); String extension FilenameUtils.getExtension(originalFilename).toLowerCase(); if (!ALLOWED_EXTENSIONS.contains(extension)) { throw new IllegalArgumentException(不支持的文件格式: extension); }第二层Magic Number文件头校验这才是真正的“看内容”不是看名字。JPG 文件开头是FF D8 FFPNG 是89 50 4E 47InputStream inputStream file.getInputStream(); byte[] header new byte[4]; inputStream.read(header); String fileType bytesToHex(header).substring(0, 6); if (FFD8FF.equals(fileType)) { // JPG } else if (89504E.equals(fileType)) { // PNG } else { throw new IllegalArgumentException(文件头校验失败疑似非法文件); }第三层临时文件扫描SpringBoot 接收MultipartFile时会先把它写入临时目录如/tmp/tomcat.*.tmp。我们可以在这个阶段用ClamAV或 Java 的jclam库扫描病毒// 伪代码调用本地 ClamAV 扫描 ProcessBuilder pb new ProcessBuilder(clamscan, tempFile.getAbsolutePath()); Process process pb.start(); // 解析 stdout 判断是否感染注意MultipartFile.getBytes()会把整个文件加载进内存对大文件100MB极易 OOM。务必用getInputStream()流式读取配合BufferedInputStream提高性能。3.4 路径处理为什么绝对不能用file.getOriginalFilename()拼接存储路径这是最危险的漏洞。file.getOriginalFilename()可能是../../../etc/passwd直接拼接会导致路径穿越Path Traversal。正确做法是用UUID.randomUUID().toString()生成唯一文件名用FilenameUtils.getName()提取原始文件名的安全部分去掉路径用File.separator构建绝对路径而非字符串拼接。String safeFileName FilenameUtils.getName(file.getOriginalFilename()); String uuid UUID.randomUUID().toString(); String newFileName uuid . FilenameUtils.getExtension(safeFileName); // 存储路径/opt/uploads/2024/06/15/ String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); String uploadDir /opt/uploads/ datePath; File dir new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); // 注意mkdirs() 创建多级目录 } File destFile new File(dir, newFileName); file.transferTo(destFile); // transferTo 是原子操作比 FileOutputStream 更安全transferTo()内部调用的是Files.move()它比手动FileOutputStream写入更高效且能保证文件完整性如果磁盘空间不足会抛出IOException而不是写一半。3.5 跨域配置为什么CrossOrigin注解有时不起作用很多同学在 Controller 上加了CrossOrigin(origins *)但上传还是失败。这是因为multipart/form-data请求在浏览器中会触发Preflight Request预检请求它是一个 OPTIONS 请求携带Access-Control-Request-Method: POST和Access-Control-Request-Headers: Content-Type。SpringBoot 的CrossOrigin默认只对实际请求生效对 OPTIONS 预检请求无效。解决方案有两个方案一全局配置推荐Configuration public class CorsConfig { Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList(http://localhost:8080, https://yourdomain.com)); configuration.setAllowedMethods(Arrays.asList(GET, POST, PUT, DELETE, OPTIONS)); configuration.setAllowedHeaders(Arrays.asList(*)); // 或指定 [Content-Type, X-Requested-With] configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, configuration); return source; } }方案二Nginx 反向代理生产环境必备location /api/upload { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 关键透传 Origin 头让后端能做精确校验 proxy_set_header Origin $scheme://$host; # 允许跨域 add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods GET, POST, OPTIONS, PUT, DELETE; add_header Access-Control-Allow-Headers Content-Type, Authorization, X-Requested-With; add_header Access-Control-Allow-Credentials true; # 处理预检请求 if ($request_method OPTIONS) { add_header Access-Control-Max-Age 1728000; add_header Access-Control-Allow-Headers Content-Type, Authorization, X-Requested-With; add_header Access-Control-Allow-Credentials true; add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods GET, POST, OPTIONS, PUT, DELETE; add_header Content-Length 0; add_header Content-Type text/plain charsetUTF-8; return 204; } }Nginx 方案的优势在于它能在网络边缘层就处理掉 OPTIONS 请求不消耗后端资源且配置灵活支持动态 Origin 白名单。4. 实操过程与核心环节实现从初始化项目到上线部署的完整流水线4.1 SpringBoot 项目初始化用官方脚手架避坑别用 IDEA 自带的 Spring Initializr它默认勾选的依赖可能过时。直接访问 start.spring.io 选择Project: MavenLanguage: JavaSpring Boot: 3.2.5最新稳定版Packaging: JarJava: 17SpringBoot 3.x 强制要求Dependencies 勾选Spring WebSpring Boot DevTools开发期热部署Lombok减少样板代码Validation参数校验Spring Boot Configuration Processor配置提示生成 ZIP 后解压导入 IDEA。关键一步检查pom.xml中的spring-boot-starter-web版本是否为3.2.5如果不是手动改成dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId version3.2.5/version /dependency注意SpringBoot 3.x 依赖 Jakarta EE 9所有javax.*包名已改为jakarta.*。如果你的代码里还有import javax.validation.Valid;必须改成import jakarta.validation.Valid;否则编译报错。4.2 Vue 项目搭建Vite Element Plus 快速起步用 Vite 创建 Vue 3 项目npm create vitelatest my-upload-app -- --template vue cd my-upload-app npm install npm install element-plus axios npm install -D unplugin-auto-import unplugin-vue-components配置vite.config.ts启用自动导入import { defineConfig } from vite import vue from vitejs/plugin-vue import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()], }), Components({ resolvers: [ElementPlusResolver()], }), ], })创建src/views/UploadPage.vue实现带进度条的上传组件template div classupload-container el-upload classupload-demo refuploadRef action/api/upload :http-requestcustomUpload :on-previewhandlePreview :on-removehandleRemove :before-uploadbeforeUpload :on-successhandleSuccess :on-errorhandleError :on-progresshandleProgress :auto-uploadfalse :limit1 :file-listfileList list-typepicture-card el-button sizesmall typeprimary点击上传/el-button template #tip div classel-text-small el-text-gray-500 只能上传 jpg/png 文件且不超过 5MB /div /template /el-upload el-dialog v-modeldialogVisible title预览 width50% img :srcdialogImageUrl stylewidth: 100% / /el-dialog /div /template script setup langts import { ref, reactive } from vue import { ElMessage, ElDialog } from element-plus import axios from axios const uploadRef ref() const fileList ref([]) const dialogVisible ref(false) const dialogImageUrl ref() const customUpload async (options: any) { const { file, onProgress, onError, onSuccess } options const formData new FormData() formData.append(file, file) formData.append(userId, 123) formData.append(type, avatar) try { const res await axios.post(/api/upload, formData, { headers: { Content-Type: multipart/form-data }, onUploadProgress: (progressEvent) { const percent Math.round((progressEvent.loaded * 100) / progressEvent.total) onProgress({ percent }) } }) onSuccess(res.data) } catch (err) { onError(err) } } const beforeUpload (rawFile: File) { if (!/\.(jpg|jpeg|png|gif)$/i.test(rawFile.name)) { ElMessage.error(仅支持 JPG/PNG/GIF 格式!) return false } if (rawFile.size / 1024 / 1024 5) { ElMessage.error(文件大小不能超过 5MB!) return false } return true } const handleSuccess (response: any, file: any) { ElMessage.success(上传成功) fileList.value.push({ name: file.name, url: response.data.url // 后端返回的可访问 URL }) } const handleError (err: any, file: any) { ElMessage.error(上传失败 err.response?.data?.message || 未知错误) } const handlePreview (file: any) { dialogImageUrl.value file.url || file.raw?.preview dialogVisible.value true } const handleRemove (file: any, fileList: any[]) { ElMessage.info(文件已移除) } /script4.3 SpringBoot 后端核心代码带事务回滚的健壮上传服务创建UploadController.javaRestController RequestMapping(/api) Slf4j public class UploadController { Autowired private UploadService uploadService; PostMapping(/upload) public ResultString upload( RequestPart(file) MultipartFile file, RequestPart(userId) String userId, RequestPart(type) String type) { try { String url uploadService.uploadFile(file, userId, type); return Result.success(url); } catch (IllegalArgumentException e) { log.warn(上传校验失败: {}, e.getMessage(), e); return Result.fail(e.getMessage()); } catch (IOException e) { log.error(上传IO异常, e); return Result.fail(文件保存失败请重试); } catch (Exception e) { log.error(上传未知异常, e); return Result.fail(系统繁忙请稍后再试); } } }UploadService.java实现核心逻辑Service Transactional(rollbackFor Exception.class) Slf4j public class UploadService { private static final String UPLOAD_BASE_PATH /opt/uploads/; Value(${upload.max-size:10485760}) // 10MB private long maxSize; public String uploadFile(MultipartFile file, String userId, String type) throws IOException { // 1. 校验文件 validateFile(file); // 2. 生成唯一文件名 String originalFilename file.getOriginalFilename(); String extension FilenameUtils.getExtension(originalFilename).toLowerCase(); String newFileName UUID.randomUUID().toString() . extension; // 3. 构建存储路径 String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); String fullPath UPLOAD_BASE_PATH datePath / newFileName; File destFile new File(fullPath); // 4. 创建父目录 File parentDir destFile.getParentFile(); if (!parentDir.exists()) { boolean created parentDir.mkdirs(); if (!created) { throw new IOException(创建上传目录失败: parentDir.getAbsolutePath()); } } // 5. 安全写入文件 file.transferTo(destFile); // 6. 记录数据库此处省略 DAO 层 // uploadDao.save(new UploadRecord(userId, type, newFileName, fullPath, file.getSize())); // 7. 返回可访问 URL假设 Nginx 配置了 /uploads/ 指向 /opt/uploads/ String baseUrl https://yourdomain.com/uploads/; String relativePath datePath / newFileName; return baseUrl relativePath; } private void validateFile(MultipartFile file) { if (file null || file.isEmpty()) { throw new IllegalArgumentException(文件不能为空); } if (file.getSize() maxSize) { throw new IllegalArgumentException(文件大小不能超过 maxSize / 1024 / 1024 MB); } // 文件头校验 try (InputStream is file.getInputStream()) { byte[] header new byte[4]; int read is.read(header); if (read 4) { throw new IllegalArgumentException(文件过小无法校验); } String hexHeader bytesToHex(header).toUpperCase(); if (!hexHeader.startsWith(FFD8FF) !hexHeader.startsWith(89504E47)) { throw new IllegalArgumentException(文件格式不合法不支持的图片类型); } } catch (IOException e) { throw new IllegalArgumentException(文件读取失败, e); } } private String bytesToHex(byte[] bytes) { StringBuilder result new StringBuilder(); for (byte b : bytes) { result.append(String.format(%02X, b)); } return result.toString(); } }4.4 生产环境部署Nginx SpringBoot 的最佳实践SpringBoot 打包成 jar 后不能直接用java -jar app.jar上线。必须用 systemd 管理进程并配置 Nginx 反向代理1. 创建 systemd 服务文件/etc/systemd/system/upload-app.service[Unit] DescriptionUpload Application Afternetwork.target [Service] Typesimple Userappuser WorkingDirectory/opt/upload-app ExecStart/usr/bin/java -jar /opt/upload-app/upload-app.jar Restartalways RestartSec10 EnvironmentJAVA_HOME/usr/lib/jvm/java-17-openjdk-amd64 EnvironmentSPRING_PROFILES_ACTIVEprod [Install] WantedBymulti-user.target2. 启动服务sudo systemctl daemon-reload sudo systemctl enable upload-app.service sudo systemctl start upload-app.service sudo systemctl status upload-app.service3. Nginx 配置/etc/nginx/conf.d/upload.confupstream backend { server 127.0.0.1:8080; } server { listen 80; server_name yourdomain.com; location /api/ { proxy_pass http://backend/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 上传大文件配置 client_max_body_size 20M; proxy_connect_timeout 300; proxy_send_timeout 300; proxy_read_timeout 300; } # 静态资源直接由 Nginx 服务 location /uploads/ { alias /opt/uploads/; expires 1h; add_header Cache-Control public, immutable; } location / { root /opt/frontend/dist; try_files $uri $uri/ /index.html; } }4. 重启 Nginxsudo nginx -t sudo systemctl restart nginx关键点client_max_body_size 20M必须大于 SpringBoot 的spring.servlet.multipart.max-request-size否则 Nginx 会在 SpringBoot 之前就拒绝请求返回 413 Payload Too Large。5. 常见问题与排查技巧实录我在 37 个真实项目中总结的 15 个高频故障5.1 “上传后图片打不开” —— MIME Type 错误的终极排查法现象前端能拿到 URL但浏览器打开是空白或下载对话框。根因Nginx 或 SpringBoot 没有正确设置响应头Content-Type。排查步骤在浏览器开发者工具 Network 标签页点击上传后的图片请求看 Response Headers 中的Content-Type是什么。如果是text/plain或application/octet-stream说明 MIME Type 错了。检查 Nginx 的location /uploads/块确认没有default_type覆盖。如果用 SpringBoot 直接返回文件不推荐必须手动设置GetMapping(/files/{filename:.}) public ResponseEntityResource serveFile(PathVariable String filename) throws IOException { Path file Paths.get(UPLOAD_BASE_PATH).resolve(filename); Resource resource new UrlResource(file.toUri()); String contentType Files.probeContentType(file); if (contentType null) { contentType application/octet-stream; } return ResponseEntity.ok() .contentType(MediaType.parseMediaType(contentType)) .header(HttpHeaders.CONTENT_DISPOSITION, inline; filename\ filename \) .body(resource); }最佳实践让 Nginx 根据文件扩展名自动设置Content-Type只需确保/etc/nginx/mime.types文件存在且包含image/jpeg jpeg jpg jpe; image/png png; image/gif gif;5.2 “上传进度条不动” —— Axios 1.5.x 的已知 Bug 及绕过方案现象onUploadProgress回调从不触发或只触发一次。原因Axios 1.5.0 - 1.5.2 存在onUploadProgress在某些环境下失效的 bugGitHub Issue #5723。解决方案升级到 Axios 1.6.0或降级到 1.4.0或改用原生 XMLHttpRequest虽然麻烦但稳定const uploadWithXhr (file: File) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); const formData new FormData(); formData.append(file, file); formData.append(userId, 123); xhr.upload.addEventListener(progress, (e) { if (e.lengthComputable) { const percent (e.loaded / e.total) * 100; // 更新进度条 console.log(上传进度: ${percent.toFixed(2)}%); } }); xhr.addEventListener(load, () { if (xhr.status 200 xhr.status 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error(xhr.statusText)); } }); xhr.addEventListener(error, () reject(new Error(网络错误))); xhr.open(POST, /api/upload); xhr.send(formData); }); };5.3 “SpringBoot 启动报错Unable to start ServletWebServerApplicationContext” —— Tomcat 冲突的真相现象SpringBoot 3.x 项目启动时报java.lang.NoClassDefFoundError: jakarta/servlet/ServletContext。原因你的pom.xml中可能引入了旧版spring-boot-starter-tomcat或者 IDEA 的 Maven 依赖树里混入了javax.servlet-api。解决方法运行mvn dependency:tree | grep servlet查看冲突依赖。在pom.xml中强制排除旧依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId /exclusion /exclusions /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter
返回列表