ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序+Java文件上传:MultipartFile落盘与静态访问

微信小程序+Java文件上传:MultipartFile落盘与静态访问

简介:这份压缩包面向正在学习微信小程序与 Java 后端联调的开发者,聚焦“小程序端选择文件并上传至服务器”这一常见需求,提供前端与后端可对照的完整示例。代码按微信小程序前端和 Java 后台分层组织,并给出两种上传路径:一是将文件提交到 Java 本地服务器,由 IndexController 接收处理;二是上传至文件服务器,NginxController 调用 service 与实体类完成流程,UploadController 则直接编写上传逻辑,便于比较分层实现与快速实现两种思路。压缩包共 23 个文件,以 9 个 Java 源文件为核心,辅以 json 配置、js 脚本、wxss 样式和 wxml 页面等小程序侧文件,整体约 15KB,体量轻便,适合按模块阅读和二次改造。已有 2195 人学习,能够帮助读者理解上传接口设计、前后端参数衔接及文件服务器接入方式,也可作为课程设计或小型项目中文件上传模块的参考底稿。

1. 从一次选文件上传说起:这套 Java 文件服务到底能干什么

微信小程序上传文件到文件服务器,听起来像一句话,真落地时却会在文件名、大小限制和访问路径上接连翻车。这份压缩包把两端放在一起:一端是小程序选文件、看进度、拿回访问地址;另一端是 Java 服务接收 multipart 请求、校验类型和大小、把文件落到磁盘,再暴露静态访问路径。适合正在做后台附件、工单图片、课程资料上传的开发者,也适合想拆一套最小上传链路、再换成对象存储的人。

2. 工程骨架与技术选型:小程序端、Java 服务端和文件落盘怎么分工

拿到压缩包,我一般先把两端拆开看:小程序端只关心“选什么文件、传给谁、进度到哪、失败怎么提示”;Java 服务端关心“请求是不是 multipart、文件空不空、类型允不允许、落到哪个目录、返回什么 URL”。这条边界划清楚,后面改存储、加鉴权、换前端都不容易乱。

2.1 目录结构:先分清页面、接口和存储三层

这类双端包常见组织方式大致如下,具体文件名可能不同,但职责基本跑不出这张表:

位置典型文件职责
miniprogram/pages/upload/upload.wxml页面结构放选择按钮、进度条、结果提示
miniprogram/pages/upload/upload.js页面逻辑调wx.chooseMessageFile、wx.uploadFile,处理回调和进度
server/.../FileUploadController.java接口层接收MultipartFile,收业务参数,返回统一结构
server/.../FileStorageService.java存储层生成文件名、建目录、落盘、拼访问 URL
server/.../WebConfig.javaWeb 配置把上传目录映射成可访问的静态路径
server/src/main/resources/application.yml运行配置端口、上传大小、上传目录、访问前缀

我一般会把上传目录放在代码目录之外,比如开发环境用./data/uploads,部署环境用/data/uploads。这样打包、重启、清缓存都不会把用户文件误删。小程序端也不要直接拼服务端磁盘路径,页面只认服务端返回的url或fileId,否则后面换存储会牵一发动全身。

2.2 技术栈组合:Spring Boot + MultipartFile 足够跑通最小闭环

这个场景常见做法是 Java 8/11 + Spring Boot 2.x,前端用原生小程序,不引 UI 框架也能跑。后端核心就是MultipartFile:Spring MVC 收到multipart/form-data后,把文件部分封装成MultipartFile,业务代码只管调用transferTo或读流写盘。压缩包里的“文件服务器”如果是最小实现,通常就是本地磁盘;生产环境再换成独立文件服务或对象存储,接口层不用大改。

先在服务端目录启动:

cd server mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=8080"

-Dspring-boot.run.arguments用来临时覆盖端口,避免和本机其他服务撞车。启动后先看控制台有没有Tomcat started on port(s): 8080,再用浏览器或 curl 打一个不存在的接口,确认服务确实起来了。小程序端在开发阶段可以把请求地址指向本机,但真机调试时手机访问不到电脑的127.0.0.1,需要换成同一局域网内的电脑 IP 或测试环境 HTTPS 地址。

2.3 上传链路:一次点击经过哪些层

一次上传的完整链路是:页面按钮触发选择文件,拿到临时路径tempFilePath;wx.uploadFile把文件和formData组装成 multipart 请求;Spring 的DispatcherServlet找到@PostMapping("/upload");MultipartFile参数按name匹配;FileStorageService校验、生成 UUID 文件名、按日期建目录、写盘;控制器返回 JSON;小程序解析res.data,把服务端 URL 存到页面数据里。

这条链路里最容易断的是两个点:小程序name: 'file'必须和服务端@RequestParam("file")对上;服务端返回的url必须和静态资源映射前缀对上。前者不对,服务端收到空文件;后者不对,上传成功但访问 404。先把这两个字符串对齐,再谈优化。

提示:上传目录不要用相对路径里的../拼,也不要直接拿用户原始文件名当磁盘文件名。原始文件名只用于展示,磁盘名用 UUID 更省心。

3. 核心上传接口拆解:一个 multipart 请求怎样被接收、校验、保存

接口层我习惯先定返回结构,再写控制器。小程序端只认code、message、data三个字段,成功时data.url能直接访问,失败时message能直接弹给用户。这样前端不用为每个接口写一套解析逻辑,联调时也容易定位是网络错、参数错还是服务端异常。

3.1 接口契约:返回体先定,前端才好写

{ "code": 0, "message": "ok", "data": { "fileId": "8f3c2b1a9d7e4c6f8a1b2c3d4e5f6a7b.png", "originalName": "封面图.png", "url": "/files/cover/2025/03/12/8f3c2b1a9d7e4c6f8a1b2c3d4e5f6a7b.png", "size": 182034, "bizId": "demo-1" } }

code用0表示成功,非0表示业务失败;data.url是相对访问路径,前端可以拼上服务端域名再展示;fileId是磁盘上的真实文件名,后面做删除、替换、审计时用得上;originalName保留用户看得懂的名字,方便下载时回显。注意url不要直接返回磁盘绝对路径,否则既暴露目录结构,也不方便换存储。

3.2 控制器:接住文件、业务类型和业务 ID

package com.example.fileserver.controller; import com.example.fileserver.common.ApiResponse; import com.example.fileserver.service.FileStorageService; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.util.Map; @RestController @RequestMapping("/api/files") public class FileUploadController { private final FileStorageService storageService; public FileUploadController(FileStorageService storageService) { this.storageService = storageService; } @PostMapping("/upload") public ApiResponse<Map<String, Object>> upload( @RequestParam("file") MultipartFile file, @RequestParam(value = "bizType", defaultValue = "common") String bizType, @RequestParam(value = "bizId", required = false) String bizId) { // 空文件直接拦掉,避免后面生成无意义的磁盘文件 if (file == null || file.isEmpty()) { return ApiResponse.fail(40001, "文件为空"); } Map<String, Object> data = storageService.save(file, bizType, bizId); return ApiResponse.ok(data); } }

@RequestParam("file")里的file必须和小程序wx.uploadFile的name一致;bizType用来分目录,比如cover、attachment、avatar,默认common防止前端漏传;bizId是业务主键,方便以后按业务反查文件。ApiResponse是项目里的统一返回包装类,如果没有,补一个带code、message、data的泛型类即可。控制器不直接碰Files.createDirectories,存储细节留给服务层,后面换对象存储时控制器不用改。

3.3 存储服务:UUID 文件名、日期目录和路径校验

package com.example.fileserver.service; import com.example.fileserver.common.BizException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.*; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.*; @Service public class FileStorageService { @Value("${file.upload-dir}") private String uploadDir; @Value("${file.access-prefix}") private String accessPrefix; private static final Set<String> ALLOWED_EXT = new HashSet<>(Arrays.asList( "jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "xls", "xlsx", "zip", "txt" )); public Map<String, Object> save(MultipartFile file, String bizType, String bizId) { if (file == null || file.isEmpty()) { throw new BizException(40001, "文件为空"); } String originalName = StringUtils.cleanPath( Optional.ofNullable(file.getOriginalFilename()).orElse("unknown") ); if (originalName.contains("..")) { throw new BizException(40002, "文件名不合法"); } String ext = getExt(originalName); if (!ALLOWED_EXT.contains(ext)) { throw new BizException(40003, "不支持的文件类型"); } String safeBizType = bizType == null || !bizType.matches("[a-zA-Z0-9_-]{1,20}") ? "common" : bizType; String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext; Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize(); Path targetDir = baseDir.resolve(safeBizType).resolve(datePath).normalize(); Path targetFile = targetDir.resolve(storedName).normalize(); // 防止 bizType 或文件名把路径带出上传根目录 if (!targetFile.startsWith(baseDir)) { throw new BizException(40004, "保存路径越界"); } try { Files.createDirectories(targetDir); file.transferTo(targetFile.toFile()); } catch (IOException | IllegalStateException e) { throw new BizException(50001, "文件保存失败"); } String relativeUrl = accessPrefix + "/" + safeBizType + "/" + datePath + "/" + storedName; Map<String, Object> result = new LinkedHashMap<>(); result.put("fileId", storedName); result.put("originalName", originalName); result.put("url", relativeUrl); result.put("size", file.getSize()); result.put("bizId", bizId); return result; } private String getExt(String name) { int dot = name.lastIndexOf('.'); if (dot < 0 || dot == name.length() - 1) { return ""; } return name.substring(dot + 1).toLowerCase(Locale.ROOT); } }

这段代码的关键点有四个:cleanPath清掉路径里的杂字符,contains("..")拦住明显的目录穿越;ALLOWED_EXT做白名单,别等上传了.jsp再补救;UUID生成磁盘名,避免同名覆盖和中文名乱码;targetFile.startsWith(baseDir)做最后一道路径校验。bizType用正则限制成字母、数字、下划线和中划线,防止有人传../../把目录带跑。file.transferTo写盘失败时统一转成业务异常,后面由全局异常处理器返回50001,前端能看到明确提示。

3.4 静态映射与上传限制:让文件存得下也取得到

server: port: 8080 spring: servlet: multipart: max-file-size: 20MB max-request-size: 25MB file: upload-dir: ./data/uploads access-prefix: /files

max-file-size是单个文件上限,max-request-size是整个 multipart 请求上限,后者要略大于前者,因为请求里还有 boundary、表单字段和头部开销。upload-dir在开发环境可以用相对路径,部署时建议改成绝对路径,比如/data/uploads;access-prefix要和下面的静态映射保持一致,否则返回的 URL 能看不能用。

package com.example.fileserver.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.nio.file.Paths; @Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String location = Paths.get(uploadDir).toAbsolutePath().normalize().toUri().toString(); if (!location.endsWith("/")) { location = location + "/"; } registry.addResourceHandler("/files/**") .addResourceLocations(location); } }

addResourceHandler("/files/**")对外暴露/files开头的路径,addResourceLocations指向上传目录的绝对 URI。这里常见翻车是location少了结尾斜杠,Spring 会把最后一段当文件名而不是目录;另一个是上传目录用了相对路径,启动目录一变,映射就指到别处。把uploadDir转成绝对路径再拼 URI,能少排很多 404。

4. 小程序端上传实现:选文件、组装 formData、处理进度和失败重试

小程序端不用把逻辑写得太花,核心就是三步:选到文件、拿到临时路径、调上传接口。页面状态只保留percent、message、fileUrl三个字段,用户能看见进度和结果就够了。真正容易出问题的是name对不上、超时太短、重复点击和返回体不是 JSON。

4.1 页面结构:一个按钮、一个进度条、一个结果区

<view class="upload-box"> <button type="primary" bindtap="chooseAndUpload">选择文件并上传</button> <progress percent="{{percent}}" show-info stroke-width="6" /> <view class="tip">{{message}}</view> <view wx:if="{{fileUrl}}" class="url">{{fileUrl}}</view> </view>

bindtap绑定选择事件,progress的percent由上传进度回调更新,show-info让用户看到百分比,wx:if只在拿到 URL 后展示结果。页面不用把服务端返回的所有字段都铺出来,fileUrl足够验证链路。样式类名可以按项目现有风格改,结构不变。

4.2 上传逻辑:wx.uploadFile 的关键参数

Page({ data: { percent: 0, message: '未上传', fileUrl: '' }, chooseAndUpload() { wx.chooseMessageFile({ count: 1, type: 'all', success: (res) => { const file = res.tempFiles[0] if (!file || !file.path) { wx.showToast({ title: '没有拿到文件', icon: 'none' }) return } if (file.size > 20 * 1024 * 1024) { wx.showToast({ title: '不能超过20MB', icon: 'none' }) return } this.upload(file) }, fail: () => { wx.showToast({ title: '选择文件失败', icon: 'none' }) } }) }, upload(file) { this.setData({ percent: 0, message: '上传中' }) const task = wx.uploadFile({ url: 'https://your-domain.com/api/files/upload', filePath: file.path, name: 'file', formData: { bizType: 'cover', bizId: 'demo-1' }, timeout: 30000, success: (res) => { if (res.statusCode !== 200 <p> <a href="https://download.csdn.net/download/RitaNBBB/12838721" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进