简介这是一份面向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_sizesql/文件记录表建表语句字段是否含原始文件名、存储路径、上传人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-urlJava 只返回相对路径或完整 URL静态服务按同一个前缀映射。还有个常见误区把上传目录放在 Java 服务的静态资源目录下靠 Spring 直接对外提供访问。小流量可以但不建议长期这么干。文件服务和业务服务混在一起未来换存储、加缓存、做下载鉴权都会受牵制。资源包里如果已经拆出静态文件配置优先按拆分的方式跑。2.3 接口契约先定死字段名、Header 与返回结构在改任何一端代码之前先把接口契约写下来。小程序wx.uploadFile的name必须和 Java 端接收参数名一致这个点后面会反复提到。下面是一份够用的契约项约定请求方法POST路径/api/file/uploadContent-Typemultipart/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 问题重试之前先刷新 token413 是文件太大重试多少次都一样。下面是一个带次数限制的重试封装// 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、落盘路径与可访问 URL4.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 ResultUploadResult 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接收来自formDataoriginalName只用于展示不参与存储路径。鉴权头从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 SetString 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()双重判断对安全要求高的场景再读文件头做魔数校验。普通业务系统做到扩展名白名单加大小限制已经能挡住大部分误传。数据库记录表至少要有这些字段字段类型说明idbigint主键original_namevarchar原始展示名保留中文stored_pathvarchar相对路径不含域名sizebigint字节数content_typevarcharMIME 类型uploader_idbigint上传人created_atdatetime创建时间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-age86400; } }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:0data.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 头里反向代理转发时漏了Authorizationtoken 过期时间短上传大文件时刚好过期。真机和开发者工具的请求头表现也可能有差异。解决token 只放header.AuthorizationformData只放业务字段代理配置显式转发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 bizTypecontract \ -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 再动小程序端希望帮到你。本文还有配套的精品资源点击获取