SpringBoot+Vue+Uniapp大文件上传:分片上传、断点续传与秒传实战
在视频类产品里有一个画面非常典型用户录好一节课或者剪辑完一段短视频信心满满地点击上传进度条走到 60% 时网络抖动一下上传直接失败稍微大一点的文件后端还会报一个413 Request Entity Too Large前端只能让用户重新再传。我们正在做的这一套智能视频推荐小程序前面十几讲已经完成了项目骨架、用户体系、视频信息管理和推荐底座但视频上传这个模块一直属于“能用但不好用”的状态。现在用户真正开始使用产品时第一个体验瓶颈就暴露在这里。很多人会下意识认为大文件上传不就是把max-file-size调大一点吗如果只是做一个 Demo确实可以这么糊弄过去但在真实的视频推荐业务里大文件上传要解决的是四件事文件断了能续传、进度慢了能看清、传完不会浪费服务器资源、后端内存和磁盘不会被瞬时打爆。这一讲我们就专门来拆解基于 SpringBoot Vue Uniapp 的大文件上传完整实现不仅讲明白原理还会给出可以直接落地的分片上传代码、断点续传设计、秒传判断逻辑和常见坑位排查。这一讲的工程链路是用户端小程序上传视频、运营端 Vue 管理后台上传视频素材、SpringBoot 后端统一接收并落盘最终进入转码和 AI 特征提取环节。内容偏实战建议你先收藏本文等做到上传功能时直接对照。1. 为什么视频推荐小程序必须解决大文件上传很多小程序项目的技术选型文档里文件上传只会写一句话“使用 MultipartFile 接收文件”。这句话在文件只有几百 KB 时没有问题但视频文件动辄几十 MB 甚至 1GB 以上时传统的单文件表单上传会出现三个层面的问题。第一个是内存和磁盘压力。服务器在接收超大请求体时会先把内容写入临时目录再转成MultipartFile对象一旦临时目录空间不足或者并发上传数量上去服务不仅会响应变慢还可能把磁盘写满拖垮整台机器。第二个是请求超时。在业务系统前面通常还有 Nginx 或其他网关默认的client_max_body_size往往只有 1m 到 10m就算后端愿意接收大文件网关也会在最前面就把请求拦下来即便放行几分钟甚至几十分钟的传输过程里任何一次网络闪断都会让整个请求失败。第三个是用户体验。没有进度条、没有失败重试、断了只能从头再来这对 C 端用户来说几乎不可接受。在视频推荐项目里上传是整个 AI 链路的第一环。用户上传视频之后后端需要转码、抽帧、提取音频、做内容理解再由 LangChain4j 和 SpringAI 负责生成标签、摘要和向量特征最终进入召回和排序。如果上传环节不稳定后面所有 AI 能力都没有数据可用。所以大文件上传不是一个“能不能传”的问题而是一整套涉及网络传输、文件存储、状态管理和异常恢复的工程问题。2. 大文件上传的三种主流方案与选型在开始写代码之前先看当前生产环境里最常见的三种方案。方案核心思路优点缺点适用场景调大限制直传后端和网关放宽请求体限制一次性接收完整视频改动最小不解决超时、断点续传、失败重传大文件并发时服务压力大文件较小、内网环境、临时 Demo对象存储直传客户端向 OSS 申请 STS 临时凭证直传云端并回调免运维、自带断点续传、支持 CDN 分发依赖云厂商需要学习签名和回调机制正式商业项目、文件量大的场景自建服务 分片上传客户端将大文件切成多个分片逐个上传服务端合并原理透明、可控、可私有化部署需要自己实现分片、合并、状态和清理教学项目、私有化部署、学习实践我们这一讲选择第三种方案核心原因有两个。第一作为系列教学项目把分片上传的原理和实现完整过一遍后面你去看 OSS 的 Multipart 分片上传文档时会觉得豁然开朗因为原理完全一致第二自建方案不绑定云厂商适合本地开发、考试项目以及内网部署场景。如果你将来进入商业项目可以直接把“自建分片”替换成“OSS 直传”但分片、断点续传和合并这些概念依然成立。这里有一个容易误解的点分片上传不等于断点续传。分片上传只是把大文件拆成小块传输断点续传还需要一个“已上传分片状态查询”的机制秒传则是另一个更高级的能力。下面一节把这三个概念彻底理清。3. 分片上传的核心原理切片、合并、断点续传与秒传3.1 分片上传把一个大文件拆成多个小请求分片上传的思路并不复杂。假设一个视频文件是 200MB客户端把它切成 40 个 5MB 的分片然后逐个调用后端接口上传。后端收到分片后先把每个分片存到临时目录等所有分片都传完再按顺序把分片合并成完整的视频文件。用快递来类比会更好理解原来的文件是一个超大的包裹直接递交容易被卡在门口切片之后相当于把大包裹拆成很多标准尺寸小包裹每个小包裹独立发出到目的地后再按编号组装。这样任何一个包裹丢失只需要重新发那一个包裹而不是把整车货物再运一遍。分片大小一般选择 5MB 到 10MB 比较合适。分片太小会导致请求数量过多增加网络开销分片太大则失去了切片的意义。在实际项目中还要把分片大小做成一个可配置参数。3.2 断点续传记录已上传的分片状态分片上传解决了单请求超时的问题但还没有解决网络中断的问题。如果上传到第 20 个分片时客户端断网重新打开页面后能不能跳过前 20 个已上传的分片直接从第 21 个继续传要实现断点续传必须要有一个“文件标识”。我们把这个标识称为chunkKey它可以由文件名、文件大小、修改时间和用户 ID 组合生成。上传每个分片时都携带同一个chunkKey和分片序号chunkIndex。服务端保存分片后客户端如果中断可以调用一个查询接口拿到这个chunkKey下已经上传了哪些分片序号然后跳过这些分片继续上传。这里的核心是幂等性同一个chunkKey下的同一个chunkIndex无论上传多少次最终落盘的内容不变。服务端设计接口时要允许分片被重复覆盖写入。3.3 秒传检测服务端是否已经有同样内容的文件秒传解决的不是传输速度而是“不传”。当客户端发起上传前先把文件生成一个唯一标识通常使用内容级别的 MD5 值。服务端收到这个标识后先检查自己的正式文件目录中是否已经存在相同标识的文件。如果存在直接告诉前端“秒传成功”前端不再上传任何分片。注意用文件名加大小生成的标识只能用于演示因为重命名文件后标识就会改变。真正可靠的秒传需要基于文件内容的哈希值。大文件计算 MD5 会额外消耗 CPU 和读取时间在工程上通常用抽样分片哈希来折中例如spark-md5的分片 hash 模式。4. 环境准备与前置条件在动手写代码之前先确认环境。下面的版本以主流稳定版为例实际项目以你的工程配置为准思路完全一致。后端需要JDK 17Spring Boot 3.x如果你还在使用 Spring Boot 2.x注意javax.servlet和jakarta.servlet包名差异Maven 3.6一个可写的本地磁盘目录用于存储临时分片和最终视频文件前端需要Uniapp 工程使用 HBuilderX 或 CLI 创建均可Vue 管理端使用 Vite Vue 3Node.js 18在项目正式目录下规划存储结构建议分两个区。chunk目录放临时分片final目录放合并后的视频文件。/data/video-storage/ ├── chunk/ │ └── {chunkKey}/ │ ├── chunk_0.part │ ├── chunk_1.part │ └── ... └── final/ └── {chunkKey}_20240101120000_demo.mp4SpringBoot 后端需要调整application.yml中的spring.servlet.multipart配置。分片接口的单个分片只有 5MB理论上不需要调太大但后面 Uniapp 小程序端要走完整的单文件上传所以必须放开上限。server: port: 8080 spring: servlet: multipart: max-file-size: 1024MB max-request-size: 1100MB video: storage: base-path: /data/video-storage如果项目部署在 Nginx 后面还需要同步修改 Nginx 配置client_max_body_size 1100m;这个细节很多人容易忽略后端配好了前端却报 413错误往往就在这层网关。5. SpringBoot 后端双通道上传接口设计5.1 接口规划这一讲的后端要支持两种上传方式分别对应不同的前端场景。接口方法说明使用端/api/upload/chunkPOST上传单个分片Vue 管理端分片上传/api/upload/mergePOST通知后端合并分片Vue 管理端/api/upload/checkGET查询已上传分片和最终文件Vue 管理端/api/uploadPOST完整上传单个视频文件Uniapp 小程序端下面代码中的Result是我们项目里的统一返回封装你可以在自己的项目里替换成已有的R类或ApiResponse。5.2 上传接口 Controllerpackage com.example.video.controller; import com.example.video.common.Result; import com.example.video.service.FileUploadService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.util.Map; RestController RequestMapping(/api/upload) RequiredArgsConstructor public class UploadController { private final FileUploadService uploadService; PostMapping(/chunk) public Result? uploadChunk(RequestParam(file) MultipartFile file, RequestParam(chunkKey) String chunkKey, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(totalChunks) Integer totalChunks) { uploadService.saveChunk(file, chunkKey, chunkIndex, totalChunks); return Result.ok(); } PostMapping(/merge) public Result? merge(RequestParam(chunkKey) String chunkKey, RequestParam(fileName) String fileName, RequestParam(totalChunks) Integer totalChunks) { String filePath uploadService.mergeChunk(chunkKey, fileName, totalChunks); return Result.ok(filePath); } GetMapping(/check) public Result? check(RequestParam(chunkKey) String chunkKey, RequestParam(value totalChunks, required false) Integer totalChunks) { MapString, Object data uploadService.checkChunk(chunkKey, totalChunks); return Result.ok(data); } PostMapping() public Result? uploadWhole(RequestParam(file) MultipartFile file, RequestParam(userId) String userId, RequestParam(value videoTitle, required false) String videoTitle) { String filePath uploadService.saveWholeFile(file); return Result.ok(filePath); } }chunkKey是整个分片上传流程的核心标识。它必须能够唯一代表“某一个用户上传的某一个文件”所以正式项目中通常把userId拼进去。chunkIndex表示分片序号从 0 开始totalChunks是总分片数后端合并时需要用到。5.3 文件存储与合并 Servicepackage com.example.video.service; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; import java.util.*; Service public class FileUploadService { private static final String CHUNK_DIR chunk; private static final String FINAL_DIR final; Value(${video.storage.base-path}) private String basePath; public void saveChunk(MultipartFile file, String chunkKey, int chunkIndex, int totalChunks) { File chunkDir getChunkDir(chunkKey); if (!chunkDir.exists()) { chunkDir.mkdirs(); } File chunkFile new File(chunkDir, chunk_ chunkIndex .part); try { file.transferTo(chunkFile); } catch (IOException e) { throw new RuntimeException(分片保存失败, e); } } public String mergeChunk(String chunkKey, String fileName, int totalChunks) { File chunkDir getChunkDir(chunkKey); if (!chunkDir.exists()) { throw new RuntimeException(分片目录不存在无法合并); } File finalDir new File(basePath, FINAL_DIR); if (!finalDir.exists()) { finalDir.mkdirs(); } String safeName fileName.replaceAll([^a-zA-Z0-9._-], _); String finalFileName chunkKey _ System.currentTimeMillis() _ safeName; File target new File(finalDir, finalFileName); try (OutputStream out new FileOutputStream(target)) { for (int i 0; i totalChunks; i) { File chunk new File(chunkDir, chunk_ i .part); if (!chunk.exists()) { throw new RuntimeException(分片缺失无法合并: i); } Files.copy(chunk.toPath(), out); } } catch (IOException e) { throw new RuntimeException(合并失败, e); } deleteQuietly(chunkDir); return finalFileName; } public MapString, Object checkChunk(String chunkKey, Integer totalChunks) { MapString, Object result new HashMap(); ListInteger uploaded new ArrayList(); File chunkDir getChunkDir(chunkKey); if (chunkDir.exists()) { File[] chunks chunkDir.listFiles((dir, name) - name.startsWith(chunk_) name.endsWith(.part)); if (chunks ! null) { for (File f : chunks) { String name f.getName(); int index Integer.parseInt(name.substring(chunk_.length(), name.indexOf(.part))); uploaded.add(index); } } } Collections.sort(uploaded); File finalDir new File(basePath, FINAL_DIR); File[] matches finalDir.listFiles((dir, name) - name.startsWith(chunkKey _)); boolean exists matches ! null matches.length 0; result.put(uploadedChunks, uploaded); result.put(exists, exists); if (exists) { result.put(fileUrl, /files/ matches[0].getName()); } return result; } public String saveWholeFile(MultipartFile file) { File finalDir new File(basePath, FINAL_DIR); if (!finalDir.exists()) { finalDir.mkdirs(); } String originalName Optional.ofNullable(file.getOriginalFilename()).orElse(default.mp4); String safeName originalName.replaceAll([^a-zA-Z0-9._-], _); String fileName System.currentTimeMillis() _ safeName; File target new File(finalDir, fileName); try { file.transferTo(target); } catch (IOException e) { throw new RuntimeException(文件保存失败, e); } return /files/ fileName; } private File getChunkDir(String chunkKey) { return new File(new File(basePath, CHUNK_DIR), chunkKey); } private void deleteQuietly(File dir) { if (dir null || !dir.exists()) { return; } File[] files dir.listFiles(); if (files ! null) { for (File f : files) { f.delete(); } } dir.delete(); } }合并时服务端按chunkIndex从 0 到totalChunks - 1顺序遍历分片文件逐个写入输出流。这里真正容易踩坑的地方是不能依赖客户端上传分片的完成顺序来合并必须根据分片序号重新排序。所以我建议在保存分片时就以chunk_0.part、chunk_1.part这种固定命名方式落盘避免额外维护索引表。final目录中保存的文件名拼接了chunkKey和时间戳这样做有两个目的一是避免不同文件重名覆盖二是让checkChunk可以在秒传场景下通过前缀找到已经存在的最终文件。5.4 后端自测用 curl 模拟分片上传在编写前端之前先用 curl 把后端接口跑通能省下大量前后端联调时间。这里以 Linux/macOS 命令为例Windows 可以用 PowerShell 或者直接使用前端页面验证。# 生成一个 10MB 的测试文件并切分成两个 5MB 分片 dd if/dev/urandom ofdemo.mp4 bs1M count10 split -b 5m demo.mp4 part_ # 上传分片 0 curl -X POST http://localhost:8080/api/upload/chunk \ -F filepart_aa \ -F chunkKeytest_demo_001 \ -F chunkIndex0 \ -F totalChunks2 # 上传分片 1 curl -X POST http://localhost:8080/api/upload/chunk \ -F filepart_ab \ -F chunkKeytest_demo_001 \ -F chunkIndex1 \ -F totalChunks2 # 查询已上传状态 curl http://localhost:8080/api/upload/check?chunkKeytest_demo_001 # 触发合并 curl -X POST http://localhost:8080/api/upload/merge?chunkKeytest_demo_001fileNamedemo.mp4totalChunks2如果接口正常你应该在check返回结果中看到{ uploadedChunks: [0, 1], exists: false }合并成功后在final目录下能看到一个完整的视频文件同时chunk/test_demo_001目录应该被清理掉。6. Uniapp 端视频上传整传、进度与失败重试6.1 为什么小程序端不建议自己切片在开始写 Uniapp 代码之前先说一个很容易被忽略的平台差异。Vue 管理端运行在浏览器里可以通过File.slice()轻松把文件切成 Blob 分片小程序端则不同uni.chooseVideo返回的是临时文件路径并没有把文件内容暴露成可以自由切片的File对象。小程序的文件系统 API 也没有提供按区间读大文件的通用能力硬要在小程序端做切片只能先读取整个文件到内存这对几百 MB 的视频来说不现实很容易把小程序端内存撑爆。因此我们的方案是小程序用户端使用uni.uploadFile做完整上传配合进度事件和失败重试Vue 管理端因为要上传更大体积的素材走完整的分片上传。这是一个基于平台能力差异做出的合理设计不是偷懒而是生产环境里真实存在的取舍。6.2 小程序端选择视频并上传// pages/upload/upload.vue 脚本核心 async onChooseVideo() { const res await uni.chooseVideo({ sourceType: [album, camera], maxDuration: 600, compressed: true }); if (!res.tempFilePath) { uni.showToast({ title: 未选择视频, icon: none }); return; } this.videoPath res.tempFilePath; this.videoSize res.size; this.uploadVideo(); } uploadVideo() { const uploadTask uni.uploadFile({ url: https://api.example.com/api/upload, filePath: this.videoPath, name: file, formData: { userId: this.userId, videoTitle: this.videoTitle }, success: (res) { const data JSON.parse(res.data); if (data.code 0) { uni.showToast({ title: 上传成功, icon: success }); this.videoId data.data; } else { this.showRetryModal(data.msg); } }, fail: (err) { this.showRetryModal(上传失败请检查网络); } }); uploadTask.onProgressUpdate((res) { this.progress res.progress; }); }小程序端完整上传同样需要后端spring.servlet.multipart.max-file-size支持到较大的值这也是我们在第 4 节把后端和网关限制调大的原因。这里真正容易踩坑的是小程序开发者工具和真机对上传行为的模拟并不完全一致大文件上传的稳定性建议直接以真机测试为准开发者工具里看不出真实网络环境下的表现。失败重试的逻辑不要写得过于复杂基本思路是保留videoPath和用户填写的表单数据点击重试时重新调用uploadVideo()。对于真正的大视频如果失败多次更好的做法是引导用户切换到 WiFi 网络因为移动网络切后台、断流是上传失败的最常见诱因。7. Vue 管理端分片上传与断点续传实现7.1 上传流程check 优先运营管理端的视频素材往往比用户端更大而且运营人员对“上传失败”的容忍度更低。前端流程可以设计为四步。选择文件后生成chunkKey。调用/api/upload/check判断最终文件是否已存在如果存在直接秒传。用返回的uploadedChunks过滤已上传的分片只上传缺失部分实现断点续传。所有分片上传完成后调用/api/upload/merge服务端合并并返回最终文件地址。7.2 分片上传器的核心封装下面是管理端的分片上传器使用普通 JavaScript Class 封装便于在任意 Vue 组件或模块中复用。// src/utils/chunkUploader.js import request from /utils/request; export class ChunkUploader { constructor(file, options {}) { this.file file; this.chunkSize options.chunkSize || 5 * 1024 * 1024; this.totalChunks Math.ceil(file.size / this.chunkSize); this.chunkKey options.chunkKey || ${file.name}_${file.size}_${file.lastModified}; this.uploadedChunks new Set(options.uploadedChunks || []); this.onProgress options.onProgress || (() {}); this.request options.request || request; } async check() { return this.request.get(/api/upload/check, { params: { chunkKey: this.chunkKey, totalChunks: this.totalChunks } }); } uploadChunk(blob, index) { const formData new FormData(); formData.append(file, blob); formData.append(chunkKey, this.chunkKey); formData.append(chunkIndex, String(index)); formData.append(totalChunks, String(this.totalChunks)); return this.request.post(/api/upload/chunk, formData, { onUploadProgress: (e) { const current (this.uploadedChunks.size e.loaded / e.total) / this.totalChunks; this.onProgress(Math.min(current, 1)); } }).then(() { this.uploadedChunks.add(index); }); } async uploadRemaining() { const tasks []; for (let i 0; i this.totalChunks; i) { if (this.uploadedChunks.has(i)) continue; const start i * this.chunkSize; const end Math.min(start this.chunkSize, this.file.size); const chunk this.file.slice(start, end); tasks.push(() this.uploadChunk(chunk, i)); } return runWithConcurrency(tasks, 3); } async merge() { return this.request.post(/api/upload/merge, null, { params: { chunkKey: this.chunkKey, fileName: this.file.name, totalChunks: this.totalChunks } }); } } function runWithConcurrency(tasks, limit) { const results []; let index 0; const workers Array.from({ length: Math.min(limit, tasks.length) }, async () { while (index tasks.length) { const current index; results[current] await tasks[current](); } }); return Promise.all(workers).then(() results); }这个runWithConcurrency函数是并发控制的关键。不建议一次性把所有分片全部抛出并发上传因为几十个分片同时请求会把网络连接数和后端磁盘 IO 同时打满。实践中并发数控制在 2 到 3 是最稳妥的。关于进度条上面代码用的是一种近似计算已完成分片数加当前分片的完成比例再除以总分片数。并发场景下这个数值会有轻微波动但足够给用户一个直观进度。如果要做精确进度可以让后端在check或合并状态接口中返回已收到的分片总数前端用服务端数据来算。7.3 在 Vue 组件中串联上传流程// src/views/video/VideoUpload.vue 脚本核心 async handleUpload() { const file this.fileInput.files[0]; if (!file) return; this.status checking; this.progress 0; const uploader new ChunkUploader(file, { onProgress: (percent) { this.progress Math.round(percent * 100); } }); const checkData await uploader.check(); if (checkData.exists) { this.status success; this.fileUrl checkData.fileUrl; uni.showToast({ title: 秒传成功, icon: success }); return; } checkData.uploadedChunks.forEach(index uploader.uploadedChunks.add(index)); this.status uploading; await uploader.uploadRemaining(); await uploader.merge(); this.status success; }如果用户上一次上传到一半网络中断重新选择同一个文件后chunkKey没变checkData.uploadedChunks就会返回已经上传过的分片序号前端把它们填充到uploadedChunks集合中后续循环自动跳过这些分片。这就是断点续传的完整闭环。7.4 用 SparkMD5 生成内容级 chunkKey演示代码中用文件名、大小和修改时间生成chunkKey已经足够跑通流程但要做到真正的秒传还需要内容级指纹。推荐使用spark-md5它支持分布式分片计算常用于大文件 hashnpm install spark-md5// src/utils/fileHash.js import SparkMD5 from spark-md5; export function calcFileHash(file) { return new Promise((resolve, reject) { const chunkSize 2 * 1024 * 1024; const md5 new SparkMD5.ArrayBuffer(); const reader new FileReader(); let offset 0; const loadNext () { const slice file.slice(offset, offset chunkSize); reader.readAsArrayBuffer(slice); }; reader.onload (e) { md5.append(e.target.result); offset chunkSize; if (offset file.size) { resolve(md5.end()); } else { loadNext(); } }; reader.onerror () reject(new Error(文件读取失败)); loadNext(); }); }拿到内容级chunkKey之后如果不同用户上传同一个视频服务端check接口就能识别出已有最终文件直接返回秒传成功。这对运营端重复上传热门素材的体验提升非常明显。8. 秒传、合并校验与状态存储设计8.1 三个概念如何协同很多人把分片上传、断点续传和秒传混在一起说这里用一个表格把它们彻底分开。能力解决什么问题关键依赖是否必须分片上传单个请求体积过大、传输耗时过长前端切片、后端合并大文件建议必须断点续传网络中断后从断点继续已上传分片状态记录体验优化建议开启秒传相同内容文件不重复上传内容级文件指纹非必须但收益明显合并不只是简单把分片拼起来还要做基础校验。在我们的mergeChunk实现中如果某个分片缺失会直接抛出异常不会生成一个损坏的半成品文件。更强的校验是合并后比对文件总大小甚至再算一次总文件 MD5但这要求前端在分片时额外传输每个分片的 MD5工程复杂度会上升。比较现实的折中方案是合并前校验分片数量是否等于totalChunks合并后校验文件大小是否等于客户端传入的file.size。8.2 分片状态存储的演进当前实现把分片状态保存在本地目录结构里通过文件名判断哪些分片已上传这种方式适合学习和小规模部署。随着业务量上升可以演进到以下层级。本地磁盘实现最简单但状态分散在文件中查询和清理都要遍历目录。数据库表新增file_chunk表记录chunkKey、chunkIndex、userId、createTime支持按用户和文件查询适合中小规模。Redis使用Set结构存储已上传分片序号查询快、过期策略灵活适合并发量高的场景。合并前把分片从临时目录写入永久目录再删除 Redis 中的状态。无论使用哪种存储chunkKey的生成规则都要保持稳定。如果客户端每次打开页面重新生成了一个chunkKey断点续传和秒传都会失效。所以chunkKey要么基于文件内容 hash要么在用户确认文件后立刻生成并持久保存在前端 session 或本地存储中。9. 常见问题与排查方法问题现象可能原因排查方式解决方案后端返回 500日志出现MaxUploadSizeExceededException上传超过 multipart 限制查看异常堆栈和当前配置调大spring.servlet.multipart.max-file-size和max-request-size前端报 413后端没有收到请求Nginx 或其他网关限制请求体大小抓包或查看网关错误日志调整client_max_body_size建议值为最大文件大小上浮 10%小程序上传大视频频繁失败移动网络不稳定或切后台真机复现查看 fail 回调信息增加失败重试提示用户使用 WiFi不要自己做切片合并后的视频无法播放分片缺失或合并顺序错误查看 merge 日志和临时分片目录后端按chunkIndex顺序合并缺失分片直接报错断点续传失效chunkKey每次打开页面都会变化在前端打印生成的 chunkKey 并对比使用内容 hash 或持久化 chunkKey并发上传导致磁盘 IO 高并发数过多或分片过小观察服务器 IO 使用率前端并发限制 2~3 个分片调整到 5MB~10MB上传完成后找不到文件临时目录空间不足或合并失败检查基础路径和目录权限保证video.storage.base-path对应的磁盘空间充足秒传一直不生效客户端没有生成稳定的内容 hash检查 hash 计算耗时和结果引入 spark-md5 分片计算内容指纹这里再强调一次小程序端的边界不要试图在 Uniapp 小程序端用File.slice()做切片小程序里拿到的只是临时文件路径不是浏览器里的File对象。如果你非要在用户端实现分片上传正确的路线是把视频先传到自己的中转服务器再由服务端对源文件做二次分片或者直接接入对象存储的分片上传能力这个成本比看起来高得多。10. 最佳实践与工程建议10.1 上传链路要与业务解耦后端接收到完整视频后不要立刻更新业务表中的video_url就算完成。更合理的流程是先落盘再写入待处理队列由异步任务完成转码、抽帧、AI 特征提取最后把处理结果回写业务表。这样即使 FFmpeg 转码失败或 AI 服务抖动也不会阻塞用户上传。视频转码方向可以先用一条命令验证ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset veryfast -c:a aac output.mp4在小规模项目中把这条命令封装成异步任务即可在生产环境中建议引入消息队列和任务调度来管理状态。10.2 文件安全与合规不能省在上传接口中至少要做三类校验。文件扩展名白名单只允许.mp4、.mov、.m4v等常见视频格式必要时校验 Content-Type。文件大小上限分片接口校验单个分片大小整个文件大小在merge时校验。用户鉴权上传接口必须携带用户身份chunkKey尽量拼接userId防止不同用户的分片互相覆盖。同时视频内容必须经过审核后才能进入推荐池。这一条在正式的推荐系统中属于红线要求技术上可以先把上传成功但未审核的视频隔离在一个状态为PENDING的目录中审核通过后再移动或更新可见状态。10.3 临时分片必须兜底清理上传到一半放弃的用户很容易留下大量孤儿分片它们会长期占着磁盘空间。建议增加一个定时任务扫描chunk目录清理超过 24 小时或 48 小时没有更新的分片目录。注意删除前要确认没有正在进行的合并任务。日志方面为每个上传任务打一条链路日志会很有价值chunkKeytest_demo_001 userId1001 chunkIndex0 totalChunks2 cost120ms有了这些数据你可以快速定位是网络问题、磁盘问题还是某个分片丢失。11. 总结与后续方向这一讲完成了视频推荐小程序里最关键的一块地基后端同时支持普通上传和分片上传Vue 管理端实现了分片上传、断点续传、秒传和并发控制Uniapp 小程序端用完整上传加进度事件保证了用户可用性。你从前面的章节一路跟到这里现在项目里应该已经能稳定接收大体积视频文件了。如果你的下一阶段还没想好做什么建议按这个顺序推进先把临时分片清理任务写掉再接入 FFmpeg 转码流水线最后才是 LangChain4j 和 SpringAI 做的视频内容理解和推荐召回。没有稳定的上传和转码链路后面所有 AI 能力都是空中楼阁。大文件上传这类问题在面试和真实开发中都经常出现。它的价值不在于用某个高深算法而在于你是否有能力把一个看起来“很简单”的接口做成一个能扛住真实用户、真实网络和真实磁盘压力的小系统。建议你现在就用一个 200MB 左右的视频文件在自己的环境下把整条链路完整跑一遍断点续传、秒传、进度条和失败重试四个场景逐一验证这远比背概念更有用。

相关新闻

大文件上传实战:分片上传、断点续传与秒传的完整方案

大文件上传实战:分片上传、断点续传与秒传的完整方案

做视频类小程序,开发团队通常会花大量精力在推荐算法和内容分发上。但真正上线之后,第一个被用户大量投诉的往往不是推荐不准,而是上传一个几百 MB 的视频失败,或者传到 90% 网络一波动又得从头再来。视频上传虽然不是核心业务&am…

2026/10/18 12:30:26 阅读更多 →
Shapefile底层机制拆解:多文件结构、属性关联与坐标系避坑

Shapefile底层机制拆解:多文件结构、属性关联与坐标系避坑

简介:甘肃省基础矢量数据完整版(shp格式)面向GIS学习者、规划人员与地理数据分析者,涵盖交通、水系、行政边界、地名注记及服务设施等多类地理要素,可直接支撑地图制图、空间查询、规划决策等应用场景。压缩包共225个文…

2026/10/18 12:30:26 阅读更多 →
未盈利却估值千亿?AI视频生成赛道的冷思考与商业化解构

未盈利却估值千亿?AI视频生成赛道的冷思考与商业化解构

最近科技圈里最让我觉得微妙的一条消息,不是什么新功能上线,而是一则关于融资的传闻:某家专注AI视频生成的产品,公司还没盈利,可一级市场已经有人按千亿级别的估值在给它标价了。我关注这个赛道有两三年的时间&#xf…

2026/10/18 12:30:26 阅读更多 →

最新新闻

Java AI应用开发实战:框架停更后的技术选型与工程化路径

Java AI应用开发实战:框架停更后的技术选型与工程化路径

1. 一个框架停更引发的技术选型焦虑前几天在一个技术交流群里,有位做Java后端的兄弟抛出一个问题:“Spring AI Alibaba是不是停更了?我看仓库好久没动静了,Java搞AI这块是不是没戏了?”群里瞬间热闹起来,有…

2026/10/18 14:08:20 阅读更多 →
Python批量提取PDF表格到Excel:pdfplumber与pandas实战

Python批量提取PDF表格到Excel:pdfplumber与pandas实战

简介:这份资源面向需要批量处理PDF表格的Python学习者与办公自动化开发者,提供从PDF中一键提取表格并导出Excel的完整实例。包内共14个文件,以png截图、xlsx结果表、pdf样本、ipynb笔记本和py脚本为主,压缩包约18.34MB&#xff0c…

2026/10/18 14:08:20 阅读更多 →
Docker 常用命令速查

Docker 常用命令速查

Docker 常用命令速查 一、镜像(Image) # 搜索镜像 docker search nginx# 拉取镜像(不指定标签默认 latest) docker pull nginx docker pull nginx:1.25# 查看本地镜像 docker images docker image ls# 删除镜像 docker rmi nginx:…

2026/10/18 14:08:20 阅读更多 →
强化学习智能体 vs 大模型智能体:选型、落地与避坑指南

强化学习智能体 vs 大模型智能体:选型、落地与避坑指南

简介:由快手科技快意大模型知识增强研发负责人毛航宇分享的《强化学习(多)智能体 和 大语言模型(多)智能体》PDF,系统梳理了近十年强化学习智能体与LLM智能体的研究脉络,并沿Deep RL、Transformer-based RL、LLM-based AI Agent的演化路径选讲…

2026/10/18 14:08:20 阅读更多 →
Idea断点调试(debug)详解:从条件断点到远程调试的完整实践

Idea断点调试(debug)详解:从条件断点到远程调试的完整实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 14:08:20 阅读更多 →
HTML/CSS智能补全:语义化编码与前端效率革命

HTML/CSS智能补全:语义化编码与前端效率革命

1. 这不是“快捷键教学”&#xff0c;而是前端效率革命的起点你有没有过这样的时刻&#xff1a;写HTML时&#xff0c;手在键盘上敲了十几遍<div class"container">&#xff0c;光是闭合标签就反复按了三次方向键&#xff1b;写CSS时&#xff0c;想加个margin: …

2026/10/18 14:07:20 阅读更多 →

日新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 0:55:02 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →