简介:这是一份面向Java后端与微信小程序开发者的文件上传示例包,围绕小程序端选取文件并上传至服务器的链路展开,重点演示两种方式:上传到Java本地服务器,以及对接文件服务器;后者分别给出调用Service与实体类的写法、直接编写上传逻辑的写法,便于对照理解分层设计与接入差异。压缩包共23个文件,以9个Java源码、4个JSON配置、3个JavaScript脚本和2个WXSS样式为主,另含少量XML、YML等工程配置,整体约15KB,轻便易读。已有2195人学习,适合用作课程练习、毕业设计或项目原型的参考。读者可从中梳理前后端接口组织、上传流程拆分及两种服务端实现的取舍,并据此排查配置与路径问题。
1. 上传成功却打不开:小程序文件上传链路的三个断点
文件上传接口返回 200,复制返回的 URL 到浏览器却 404;换一台手机再传,同一个接口又报 413。这类问题在 java 微信小程序 文件服务器 这条链路上太常见了,因为一次上传要跨小程序、Java 服务、静态文件服务三个进程,任何一段的路径、字段名、大小限制对不上,表面成功背后都是断的。
这个资源包做的就是把三端交接点固定下来:小程序端选文件并构造 multipart 请求,Java 端接收 MultipartFile、校验后落盘并写记录,文件服务器按约定 URL 对外提供访问。适合正在给小程序补文件上传能力、又不想从零搭一套接收端的后端和全栈开发者;如果你只传头像图片,也能用,但重点在文档、表格这类需要保留原名的文件。
2. 拆开资源包:Java 接收端与小程序端的职责边界怎么划
2.1 资源包目录与启动顺序
拿到压缩包后先别急着改代码,按目录把角色认清楚。这类资源包通常不是单一工程,而是“小程序端 + Java 服务端 + 部署配置 + 建表脚本”的组合。目录名可能略有差异,但核心文件跑不出下面几类:
| 目录/文件 | 作用 | 启动时先看什么 |
|---|---|---|
miniprogram/ | 小程序端页面、上传封装、请求工具 | app.js里的服务地址、请求封装里的 token 注入 |
server/ | Java 接收端,含 Controller、Service、配置 | application.yml的端口、上传大小、存储根目录 |
deploy/nginx.conf | 静态文件访问与反向代理配置 | alias路径、client_max_body_size |
sql/ | 文件记录表建表语句 | 字段是否含原始文件名、存储路径、上传人 |
README或说明文件 | 启动参数、默认账号、接口路径 | 先对齐接口路径和字段名,再改代码 |
启动顺序建议是:先建表,再起 Java 服务,接着配静态文件服务,最后改小程序端的请求地址。这个顺序不是形式主义,因为小程序端一联调就会暴露 Java 端的字段名和大小限制;如果静态文件服务没配好,上传成功也验证不了最终 URL。常见做法是先在本地用 curl 把 Java 接口跑通,再进小程序开发者工具,这样能把“网络问题”和“代码问题”分开。
Java 服务启动后,先确认三个点:上传接口是否注册、临时目录是否可写、数据库连接是否正常。临时目录容易被忽略,尤其在 Linux 上跑容器时,/tmp权限或磁盘配额不够,表现是上传到一半失败,日志里只看到FileSizeLimitExceededException或IOException。我一般会先手动touch一个测试文件到配置的上传目录,确认运行用户有写权限,再开始传真实文件。
2.2 三端职责边界:谁校验、谁落盘、谁对外提供 URL
这条链路最容易扯皮的地方是“文件到底该由谁保存”。小程序端只负责选文件、拿临时路径、构造 multipart 请求、带上鉴权头;Java 端负责接收、校验、生成存储路径、写数据库记录、拼出可访问 URL;静态文件服务只负责把落盘目录映射成 HTTP 路径。三端各做各的,边界清楚,后面排查才有抓手。
| 角色 | 该做的事 | 不该做的事 |
|---|---|---|
| 小程序端 | 选文件、显示进度、失败重试、带 token | 不保存长期文件、不拼接最终访问域名、不信任前端传的文件大小 |
| Java 接收端 | 校验大小/类型、生成文件名、落盘、写记录、返回 URL | 不把本地绝对路径直接返回给前端、不依赖原始文件名做存储名 |
| 静态文件服务 | 按约定路径提供只读访问、控制缓存和下载头 | 不开放目录列表、不把上传目录和执行目录混在一起 |
为什么不让小程序直传静态文件服务?因为那意味着要把写入凭证放到前端,凭证一旦泄露,上传目录就可能被塞满;而且前端传上来的大小、类型都不可信,服务端没有机会做二次校验。为什么不让 Java 返回本地绝对路径?因为文件服务和小程序看到的网络路径不是一回事,返回/data/upload/2025/06/01/a.pdf这种路径,前端根本拼不出可访问地址。正确做法是配置一个base-url,Java 只返回相对路径或完整 URL,静态服务按同一个前缀映射。
还有个常见误区:把上传目录放在 Java 服务的静态资源目录下,靠 Spring 直接对外提供访问。小流量可以,但不建议长期这么干。文件服务和业务服务混在一起,未来换存储、加缓存、做下载鉴权都会受牵制。资源包里如果已经拆出静态文件配置,优先按拆分的方式跑。
2.3 接口契约先定死:字段名、Header 与返回结构
在改任何一端代码之前,先把接口契约写下来。小程序wx.uploadFile的name必须和 Java 端接收参数名一致,这个点后面会反复提到。下面是一份够用的契约:
| 项 | 约定 |
|---|---|
| 请求方法 | POST |
| 路径 | /api/file/upload |
| Content-Type | multipart/form-data,由小程序自动生成 boundary |
| 文件字段名 | file |
| 业务字段 | bizType,如avatar、contract、temp |
| 鉴权头 | Authorization: Bearer <token> |
| 成功返回 | {"code":0,"data":{"url":"https://host/files/2025/06/01/uuid.pdf","name":"合同.pdf","size":10240}} |
| 失败返回 | {"code":400,"msg":"文件类型不支持"} |
字段名一旦定下,三端都按这个来。Java 端用@RequestPart("file") MultipartFile file接收,小程序端wx.uploadFile的name就写file;如果 Java 端写成@RequestParam("upload"),小程序端还传file,服务端会直接报“Required request part 'upload' is not present”。这种错误在日志里很明显,但新手容易在前后端各改各的,最后对不上。
返回结构建议统一包裹一层code/data/msg,不要把MultipartFile对象或异常堆栈直接吐给前端。URL 拼接放在服务端,前端只负责展示和复制。错误码也要提前分:参数错误 400、未登录 401、文件过大 413、服务端异常 500。小程序端根据错误码决定是否重试,401 和 413 重试没有意义,5xx 才可以退避重试。
3. 小程序端把文件送出去:chooseMedia、wx.uploadFile 与鉴权头
3.1 选文件:chooseMedia 与 chooseMessageFile 的差别
小程序端选文件有两个常用入口,别混着用。wx.chooseMedia适合图片、视频,返回的是临时文件路径;wx.chooseMessageFile适合从聊天记录里选文档、表格、压缩包,返回的也是临时路径,但附带原始文件名和大小。传合同、发票这类场景,用chooseMessageFile更合适,因为用户可以直接选聊天里的文件。
| API | 适用文件 | 关键返回字段 | 注意点 |
|---|---|---|---|
wx.chooseMedia | 图片、视频 | tempFiles[].tempFilePath、size | 有数量上限,视频体积容易超 |
wx.chooseMessageFile | 文档、表格、压缩包 | tempFiles[].path、name、size | 文件名可能含中文、空格、括号 |
临时路径不是永久地址,小程序重启或过一段时间就可能失效。所以选完文件要尽快上传,不要先把路径存到本地缓存,等用户下次打开再传。真机和开发者工具在临时路径表现上也有差异,开发者工具能读到的路径,真机上不一定还能读,联调时以真机为准。
选文件时先做一次前端大小过滤,超过服务端限制的直接提示,不要等传完才报 413。但前端过滤只是体验优化,服务端校验不能省。文件名里的特殊字符也不要直接拼到 URL 里,展示名和存储名要分开。
3.2 wx.uploadFile 的参数与鉴权头
下面是一段可直接抄的上传封装,重点看name、header和formData三个位置:
// miniprogram/utils/upload.js function uploadFile(filePath, fileName, token, bizType) { return new Promise((resolve, reject) => { const task = wx.uploadFile({ url: 'https://your-domain.com/api/file/upload', // 换成自己的服务地址 filePath: filePath, name: 'file', // 必须与 Java 端 @RequestPart("file") 一致 header: { Authorization: 'Bearer ' + token // 鉴权信息放 header // 不要手动设置 Content-Type,让小程序生成 boundary }, formData: { bizType: bizType || 'temp', // 业务字段放 formData originalName: fileName }, success(res) { // wx.uploadFile 的 res.data 是字符串,需要手动解析 let body; try { body = JSON.parse(res.data); } catch (e) { return reject(new Error('返回不是 JSON:' + res.data)); } if (res.statusCode === 200 && body.code === 0) { resolve(body.data); } else { reject(new Error(body.msg || '上传失败')); } }, fail(err) { reject(new Error(err.errMsg || '网络失败')); } }); // 进度回调挂到 task 上,页面里可以显示百分比 task.onProgressUpdate((p) => { console.log('上传进度', p.progress); }); }); } module.exports = { uploadFile };这段代码里最容易被改错的是name。它对应服务端 multipart 的表单字段名,不是文件名。文件名通过filePath指向的临时文件带过去,原始展示名放在formData.originalName里,由服务端单独保存。header里只放鉴权,不要手动写Content-Type: multipart/form-data,因为 boundary 是小程序自动生成的,手写会导致服务端解析失败。
formData里的字段会作为普通表单字段一起提交,适合放bizType、业务 ID 这类短文本,不要往里塞大 JSON。success回调里res.data是字符串,必须先JSON.parse,否则拿不到code。statusCode也要判断,有些失败场景 HTTP 状态不是 200,但success仍会进。
3.3 上传进度、失败重试与并发控制
上传进度用task.onProgressUpdate拿,但不要每次回调都setData,否则页面会卡。常见做法是每 5% 或每 200ms 更新一次,页面卸载时把 task 置空。进度到 100% 只代表小程序把数据发出去了,不代表服务端已经落盘成功,最终结果还是看success回调里的业务code。
失败重试要分错误类型。网络超时、5xx 可以退避重试;401 是 token 问题,重试之前先刷新 token;413 是文件太大,重试多少次都一样。下面是一个带次数限制的重试封装:
// miniprogram/utils/retry.js async function uploadWithRetry(uploadFn, maxRetry = 2) { let lastError; for (let i = 0; i <= maxRetry; i++) { try { return await uploadFn(); } catch (err) { lastError = err; const msg = err.message || ''; // 401、413 不重试,避免无意义请求 if (msg.includes('401') || msg.includes('413') || msg.includes('不支持')) { throw err; } // 指数退避:1s、2s await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, i))); } } throw lastError; } module.exports = { uploadWithRetry };并发控制同样重要。小程序同时发起的网络请求有上限,一次选 9 张图就并发传 9 个,容易出现部分失败、进度乱跳。常见做法是做一个简单队列,最大并发设 2 到 3,传完一个再取下一个。队列里记录每个文件的状态:等待、上传中、成功、失败,失败的文件单独提供“重试”入口,不要整批重传。
临时文件清理也要注意。上传成功后小程序不会自动删临时文件,长时间积累会占用户空间。可以在上传成功后调用wx.getFileSystemManager().unlink删掉临时路径,但失败时保留,方便用户重试。
4. Java 接收端落地:MultipartFile、落盘路径与可访问 URL
4.1 Controller 与参数绑定
服务端接收 multipart 请求,核心是把文件字段和普通字段分开接。下面是一段常见的 Spring MVC 写法:
// server/src/main/java/com/example/file/FileController.java @RestController @RequestMapping("/api/file") public class FileController { @Autowired private FileStorageService storageService; @PostMapping("/upload") public Result<UploadResult> upload( @RequestPart("file") MultipartFile file, // 字段名必须与小程序 name 一致 @RequestParam(value = "bizType", defaultValue = "temp") String bizType, @RequestParam(value = "originalName", required = false) String originalName, @RequestHeader(value = "Authorization", required = false) String authorization) { // 鉴权交给拦截器或统一组件,这里只做示例判断 Long userId = authService.parseUserId(authorization); if (userId == null) { return Result.fail(401, "未登录"); } // 业务字段只做白名单,避免把任意字符串拼进路径 if (!Arrays.asList("avatar", "contract", "temp").contains(bizType)) { return Result.fail(400, "bizType 不合法"); } UploadResult result = storageService.save(file, bizType, originalName, userId); return Result.ok(result); } }@RequestPart("file")里的file就是小程序wx.uploadFile的name,两边必须一字不差。bizType用@RequestParam接收,来自formData;originalName只用于展示,不参与存储路径。鉴权头从Authorization取,解析失败直接返回 401,不要让请求继续走到落盘逻辑。
异常处理要统一。文件为空、类型不支持、超过大小限制,都应该转成明确的业务错误码,而不是把MaxUploadSizeExceededException原样抛给前端。可以加一个@RestControllerAdvice捕获上传异常,返回 413 和可读提示。
4.2 存储路径与文件名策略
存储路径建议按“业务类型 + 年/月/日”分目录,文件名用 UUID,扩展名从原始文件名里取最后一个点后的部分,并且做白名单校验。不要直接用originalFilename拼路径,这是路径穿越和同名覆盖的源头。
// server/src/main/java/com/example/file/FileStorageService.java @Service public class FileStorageService { @Value("${file.upload-root:/data/upload}") private String uploadRoot; private static final Set<String> ALLOWED_EXT = new HashSet<>(Arrays.asList( "jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "xls", "xlsx", "zip" )); public UploadResult save(MultipartFile file, String bizType, String originalName, Long userId) { if (file == null || file.isEmpty()) { throw new BizException(400, "文件为空"); } String safeOriginalName = originalName == null ? file.getOriginalFilename() : originalName; String ext = getExtension(safeOriginalName); if (!ALLOWED_EXT.contains(ext)) { throw new BizException(400, "文件类型不支持"); } // 按天分目录,避免单目录文件过多 String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext; Path dir = Paths.get(uploadRoot, bizType, datePath).normalize(); Path target = dir.resolve(storedName).normalize(); // 防止路径穿越:目标必须仍在根目录下 if (!target.startsWith(Paths.get(uploadRoot).normalize())) { throw new BizException(400, "非法路径"); } try { Files.createDirectories(dir); file.transferTo(target.toFile()); } catch (IOException e) { throw new BizException(500, "保存失败"); } String relativePath = bizType + "/" + datePath + "/" + storedName; fileRecordMapper.insert(new FileRecord(safeOriginalName, relativePath, file.getSize(), file.getContentType(), userId)); return new UploadResult(relativePath, safeOriginalName, file.getSize()); } private String getExtension(String name) { if (name == null || !name.contains(".")) { return ""; } return name.substring(name.lastIndexOf('.') + 1).toLowerCase(); } }这段代码的关键点在normalize()和startsWith()。originalFilename可能包含../,直接resolve会跳到上传目录外;先规范化再判断是否还在根目录下,能挡住大部分路径穿越。扩展名取最后一个点,并且转小写,避免.PDF被白名单漏掉。UUID 去掉横线是为了 URL 干净,不是必须。
transferTo之后临时文件会被容器清理,但不要依赖这个行为做业务逻辑。如果后面还要读文件内容做校验,先读成字节或流,再落盘。
4.3 校验、数据库记录与返回 URL
文件大小和 MIME 类型要在服务端再校验一次。前端传的size不可信,Content-Type也可以伪造。常见做法是大小用MultipartFile.getSize(),类型用扩展名白名单 +getContentType()双重判断;对安全要求高的场景再读文件头做魔数校验。普通业务系统做到扩展名白名单加大小限制,已经能挡住大部分误传。
数据库记录表至少要有这些字段:
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint | 主键 |
original_name | varchar | 原始展示名,保留中文 |
stored_path | varchar | 相对路径,不含域名 |
size | bigint | 字节数 |
content_type | varchar | MIME 类型 |
uploader_id | bigint | 上传人 |
created_at | datetime | 创建时间 |
CREATE TABLE file_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, original_name VARCHAR(255) NOT NULL, stored_path VARCHAR(500) NOT NULL, size BIGINT NOT NULL, content_type VARCHAR(100), uploader_id BIGINT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );返回 URL 时不要在 Service 里硬编码域名,用配置项拼。相对路径存库,完整 URL 在返回时拼,后面换域名或加 CDN 只改配置。拼接时注意斜杠,base-url末尾有/和没有/会拼出双斜杠或缺少斜杠,浏览器一般能容错,但签名、缓存键可能对不上。
4.4 静态文件映射与上传大小限制
Java 端只负责落盘,对外访问交给静态文件服务。下面是一段常见的反向代理配置:
server { listen 443 ssl; server_name your-domain.com; # 上传大小限制要大于业务允许的最大文件 client_max_body_size 50m; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 转发鉴权头,避免后端拿不到 token proxy_set_header Authorization $http_authorization; } location /files/ { alias /data/upload/; # 末尾斜杠要和 location 对应 autoindex off; # 禁止目录列表 add_header Cache-Control "public, max-age=86400"; } }location /files/和alias /data/upload/的末尾斜杠要成对。写成alias /data/upload,访问/files/contract/2025/06/01/a.pdf时可能映射到/data/uploadcontract/...,表现就是 404。autoindex off必须加,否则上传目录能被列出来。client_max_body_size要和 Java 端的 multipart 限制一起改,只改一边仍会 413。
Java 端的上传限制在配置文件里:
# server/src/main/resources/application.yml spring: servlet: multipart: max-file-size: 50MB # 单文件上限 max-request-size: 60MB # 整个请求上限,要大于单文件 location: /data/tmp # 临时目录,确保运行用户可写 file: upload-root: /data/upload base-url: https://your-domain.com/filesmax-file-size是单文件,max-request-size是整个 multipart 请求,后者要大于前者,给表单字段留余量。临时目录单独配置,避免容器/tmp被清空或空间不足。改完配置要重启服务,并确认反向代理也 reload 了。
5. 避坑排查:上传链路上五个高频翻车点
5.1 上传返回成功,URL 打开 404
现象:接口返回code:0,data.url也有值,浏览器打开就是 404;换一张图片传,偶尔又能打开。
原因通常有三个:静态服务的alias路径和实际落盘路径不一致;Java 返回的base-url多了或少了/files前缀;文件名大小写在 Linux 上敏感,数据库里存的是小写,访问时写成了大写。还有一种情况是文件落到了容器内路径,静态服务挂载的是宿主机路径,两边根本不是同一个目录。
解决:先curl -I看响应头,确认是静态服务返回的 404 还是 Java 服务返回的 404。然后到服务器上ls落盘目录,确认文件真实存在。最后核对alias和base-url,把路径拼接规则写进配置注释。别靠记忆拼路径,血泪经验是每次换环境都要重新对一遍。
5.2 413 或 Maximum upload size exceeded
现象:小文件能传,超过 1MB 或 10MB 就失败;小程序端提示网络错误,Java 日志里出现MaxUploadSizeExceededException。
原因:Spring 的 multipart 默认限制和反向代理的client_max_body_size是两道独立的闸门。只改 Java 不改代理,请求在代理层就被拦了;只改代理不改 Java,到了后端仍会抛异常。临时目录空间不足也会表现成上传中断,但错误信息不同。
解决:两边一起改,单文件限制、请求总限制、代理限制按“业务最大值 + 余量”设置。改完重启 Java 服务并 reload 代理,再用一个接近上限的文件验证。不要只看配置文件,确认运行中的进程真的加载了新配置。
5.3 中文文件名乱码或丢失
现象:上传“合同扫描件.pdf”,数据库里存成了乱码,或者变成了blob、file这类名字。
原因:multipart 请求里原始文件名的编码在不同客户端、不同容器下表现不一致;有的实现会按 ISO-8859-1 解,有的按 UTF-8 解。另外,小程序chooseMessageFile返回的name和wx.uploadFile实际带过去的文件名可能不完全一致。
解决:存储名统一用 UUID,原始展示名单独通过formData.originalName传,服务端按 UTF-8 接收并保存。不要用原始文件名做磁盘文件名,既避免乱码,也避免同名覆盖。如果展示名仍有乱码,在服务端入口做一次编码转换,并加日志确认入参。
5.4 路径穿越与同名覆盖
现象:上传目录里出现奇怪层级的文件,或者后传的文件把先传的覆盖了;严重时文件被写到上传根目录之外。
原因:直接把originalFilename拼到路径里,文件名里带../或绝对路径;或者用“原文件名 + 时间戳”做存储名,并发下仍可能重复。Windows 和 Linux 对路径分隔符的处理不同,跨环境更容易翻车。
解决:存储名用 UUID,扩展名走白名单;拼接后normalize()并判断startsWith上传根目录;禁止originalFilename参与路径。数据库里记录相对路径,不记录绝对路径。文件服务目录只给读权限,写权限只给 Java 服务运行用户。
5.5 上传接口 401,普通接口正常
现象:同一个 token,普通 JSON 接口能过,wx.uploadFile调上传接口就 401;或者开发者工具正常,真机 401。
原因:token 放错了位置。wx.uploadFile的header和formData是两个东西,放进formData的 token 不会出现在 HTTP 头里;反向代理转发时漏了Authorization;token 过期时间短,上传大文件时刚好过期。真机和开发者工具的请求头表现也可能有差异。
解决:token 只放header.Authorization,formData只放业务字段;代理配置显式转发Authorization;上传前检查 token 有效期,快过期先刷新。排查时在 Java 端打印请求头名称(不要打印完整 token),确认到底有没有收到。
6. 验证与收尾:用 curl 回放和日志把问题钉死
接口写完先别进小程序,用 curl 回放一遍,能把字段名、鉴权、大小限制一次验完:
# 上传一个测试文件,-F 的 file 对应服务端字段名 curl -i -X POST 'https://your-domain.com/api/file/upload' \ -H 'Authorization: Bearer test-token' \ -F 'file=@./test.pdf' \ -F 'bizType=contract' \ -F 'originalName=测试合同.pdf' # 拿到返回 URL 后,检查静态访问是否真的通 curl -I 'https://your-domain.com/files/contract/2025/06/01/xxxx.pdf'第一条命令看 HTTP 状态和返回 JSON,重点确认code、url、size;第二条看静态服务返回 200 还是 404。两步都过,再进小程序开发者工具。日志里至少记录traceId、文件大小、耗时、存储相对路径、失败原因,别打印完整 token 和用户敏感信息。再进一步,可以每天跑一次数据库与磁盘对账:查stored_path对应文件是否存在,反过来查磁盘上有没有数据库没记录的文件。这个习惯不花哨,但能提前发现落盘失败和误删。
从那以后我每次接上传需求,都会先把字段名、大小限制、静态路径这三件事写成一张对照表,跑完 curl 再动小程序端;希望帮到你。
本文还有配套的精品资源,点击获取