资讯详情

资讯详情

大文件上传实战:SpringBoot+Uniapp分片上传与断点续传

在视频推荐类小程序里上传视频几乎是最高频也最容易翻车的功能。普通图片上传几秒就能完成但一段几百 MB 的短视频如果直接走表单 POST要么请求超时、要么进度条卡死服务端还容易内存飙高。如果你是第一次接触大文件上传或者已经在网上搜过“分片上传、断点续传、秒传”但一直没跑通一整套流程那这一讲值得认真看完。本文是“基于 SpringBoot Vue Uniapp LangChain4j SpringAI 的智能视频推荐小程序”系列第十四讲主题是大文件上传。我会从为什么不能直接上传、分片上传的核心设计、SpringBoot 后端接口、Uniapp 前端封装到上传完成后如何接入 LangChain4j 与 SpringAI 做视频内容解析完整拆解一遍。无论你是在开发视频号、课程小程序还是企业内部培训系统这套方案都能直接参考。1. 背景与核心概念为什么视频上传必须分片1.1 普通上传的瓶颈在哪里小程序里最常见的上传方式是uni.uploadFile本质上还是 HTTP 的multipart/form-data请求。文件作为请求体的一部分一次性从客户端发到服务端。这种方式对付图片、小文件没问题但面对视频就会出现三个典型问题。第一个问题是请求体限制。无论是 Nginx、网关还是 SpringBoot 自带的 Tomcat对请求大小都有默认限制。比如 SpringBoot 默认的单文件上限是 1MB生产环境就算调大也不可能无限调一旦超过限制客户端收到的是 413 或 500用户只看到一个“上传失败”体验非常差。第二个问题是内存和 IO 压力。大文件整包发送时服务端需要把整个请求体读入内存或临时文件再进行解析。如果同时有多个用户上传线程池和内存很容易被打满最终拖垮整个应用。第三个问题是无法续传。普通上传一旦网络抖动或者用户切到后台导致请求中断整段视频都要重新上传。在弱网环境下用户几乎不可能完成一次几百 MB 的上传。所以大文件上传在工程上几乎不会采用“一次 POST 整个文件”的方案而是普遍采用分片上传Chunked Upload加断点续传加秒传的组合方案。1.2 什么是分片上传分片上传的思路很直接客户端把一个大文件按固定大小切开比如每片 5MB然后一片一片上传到服务端服务端先把这些分片存在临时目录等所有分片都上传完成再按顺序把分片合并成完整文件。这样做有三个好处每个分片都足够小单个请求不会达到服务端限制上传成功率更高。某个分片失败时只需要重传这个分片不需要重传整个文件。可以基于已上传的分片实现断点续传也可以显示真实的上传百分比。在智能视频推荐小程序这个场景里用户上传的短视频经常在 20MB 到 500MB 之间浮动分片上传几乎是唯一可行的方案。1.3 断点续传与秒传的设计思路断点续传和秒传都依赖同一个东西文件的唯一标识。最常用的标识是文件内容的 MD5 值。客户端在上传前先计算整个文件的 MD5把这个值传给服务端。服务端拿到 MD5 后做两件事查数据库看是否已经存在相同 MD5 的视频。如果存在直接返回“文件已存在”客户端不用再传任何数据这就是“秒传”。查缓存或者数据库看当前这个 MD5 已经上传了哪些分片。客户端只需要补传缺失的分片这就是“断点续传”。三种上传方式的简单对比可以看这张表上传方式适用场景优点缺点普通表单上传图片、小文件实现简单、代码量少受请求限制、无法续传、进度体验差分片上传大视频、大安装包可续传、可实时进度、单请求压力小需要拆片和合并逻辑复杂秒传重复文件较多省流量、速度快需要计算文件 MD5大文件计算耗时需要说明一下MD5 并不能完全代表“文件唯一”理论上存在碰撞可能但在上传场景里作为工程标识已经足够。如果你对安全性要求极高可以改用 SHA-256代价是计算时间更长。2. 环境准备与版本说明2.1 技术栈与版本约定本系列前面十三讲已经搭建了完整的智能视频推荐小程序项目本讲的所有代码都基于这个已有工程继续扩展。示例环境如下实际版本以你当前工程为准技术作用版本参考JDK后端运行环境17 或 21SpringBoot后端基础框架3.xMyBatis-Plus数据库访问3.5.xMySQL业务数据存储8.xRedis分片记录缓存、分布式锁7.xUniapp小程序前端HBuilderX 最新稳定版Vue前端页面框架Vue 3LangChain4j大模型链路编排按 Maven 版本SpringAISpring 官方 AI 集成按 Maven 版本这里要提醒一句SpringAI 和 LangChain4j 都属于迭代比较快的框架不同版本的类名、方法名差异很大。本讲后面的 AI 集成代码展示的是调用思路不是某一版 API 的完整定义你在落地时一定要以 Maven 仓库里实际引入的版本为准。2.2 示例工程结构本讲涉及的模块结构如下recsys-server ├── src/main/java/com/recsys │ ├── common // 通用模块包含 Result 返回体 │ ├── video │ │ ├── controller // 上传接口、视频管理接口 │ │ ├── service // 上传服务 │ │ ├── mapper // 视频表 mapper │ │ ├── entity // VideoInfo 实体 │ │ └── config // 上传配置 │ └── ai // SpringAI LangChain4j 调用入口 ├── src/main/resources │ ├── application.yml │ └── mapper └── pom.xml前端小程序部分我们在utils目录下新增一个videoUploader.js把分片上传逻辑封装成独立模块页面只负责选择视频和展示进度避免把上传逻辑写进每个页面。3. 核心设计分片上传、合并与秒传3.1 整体流程为了让后面的代码更容易理解我先把一次完整的上传流程拆成六步客户端选择视频读取文件大小和文件名。客户端计算整个文件的 MD5。调用/api/upload/check接口把 MD5 和文件名传给服务端。服务端返回existtrue表示可以秒传否则返回uploadedChunks表示已经上传过的分片编号。客户端按分片大小切分文件跳过uploadedChunks中已有的分片逐个上传。全部分片上传完成后调用/api/upload/merge服务端合并分片生成视频记录并触发后续 AI 分析任务。这里有个细节check接口返回的uploadedChunks是断点续传的关键。用户在弱网环境下上传到一半断掉了下次重新选择同一个文件客户端算出同一个 MD5服务端把已传分片编号返回客户端只补传缺失的部分不用全部重来。3.2 后端接口设计按上面的流程本讲需要三个接口接口方法参数返回/api/upload/checkPOSTfileMd5,fileNameexist,uploadedChunks/api/upload/chunkPOSTfile,chunkIndex,totalChunks,fileMd5成功或失败/api/upload/mergePOSTfileMd5,fileName,totalChunksvideoId接口设计上有几个约定需要说明。chunkIndex表示分片编号从 0 开始totalChunks表示总分片数。合并时服务端必须严格按chunkIndex的顺序写入否则整个视频会花屏。fileMd5在三个接口中都会用到它既是文件唯一标识也是临时分片目录名。为了防止用户传恶意路径服务端必须对 MD5 做格式校验只允许 32 位十六进制字符。3.3 分片大小与并发策略分片大小一般取 5MB 到 10MB。太小会导致请求数量过多HTTP 握手开销变大太大会降低断点续传的粒度弱网下失败率上升。我们示例以 5MB 为默认值。并发方面理论上可以多个分片同时上传但并发太高会增加服务端连接压力也容易造成大量中间态日志。建议小程序端每次同时上传 2 到 3 个分片即可或者采用串行上传代码更简单也更容易排查问题。本文示例采用串行上传先把流程跑通并发优化放到“最佳实践”一节再展开。4. 后端实现SpringBoot 分片上传接口4.1 新增上传配置分片大小、临时目录、合并目录这些参数不建议写死在代码里而是放到application.yml中方便不同环境切换。spring: servlet: multipart: # 单个分片最大大小必须大于分片大小 max-file-size: 10MB # 单次请求最大大小留出 formData 参数空间 max-request-size: 50MB upload: # 分片大小5MB chunk-size: 5MB # 分片临时目录 temp-dir: ./data/upload/tmp # 合并后视频目录 merge-dir: ./data/upload/video # 单文件最大限制防止恶意超大文件 max-file-size: 2GB # 允许上传的扩展名白名单 allow-types: - mp4 - mov - avi - mkv需要注意spring.servlet.multipart.max-file-size必须大于单个分片的大小。比如我们把分片设为 5MB这里至少要配 10MB留出余量。max-request-size则要能容纳分片文件加表单参数配 50MB 是安全的。然后创建对应的配置类package com.recsys.video.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.List; Data Component ConfigurationProperties(prefix upload) public class UploadProperties { /** * 单分片大小例如 5MB */ private String chunkSize 5MB; /** * 分片临时目录 */ private String tempDir ./data/upload/tmp; /** * 合并后视频目录 */ private String mergeDir ./data/upload/video; /** * 单文件最大大小例如 2GB */ private String maxFileSize 2GB; /** * 允许上传的扩展名 */ private ListString allowTypes; }这里使用ConfigurationProperties绑定前缀SpringBoot 会自动把 yml 里upload开头的配置映射到对象字段上比一个个Value注入更整洁。4.2 编写上传接口 Controller接下来创建UploadController。三个接口都放在这个 Controller 里职责单一方便后续维护。package com.recsys.video.controller; import com.recsys.common.api.Result; import com.recsys.video.service.UploadService; 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 UploadService uploadService; /** * 上传前检查判断是否支持秒传并返回已上传分片列表 */ PostMapping(/check) public ResultMapString, Object check( RequestParam String fileMd5, RequestParam String fileName) { return Result.success(uploadService.checkFile(fileMd5, fileName)); } /** * 上传单个分片 */ PostMapping(/chunk) public ResultVoid uploadChunk( RequestParam(file) MultipartFile file, RequestParam Integer chunkIndex, RequestParam Integer totalChunks, RequestParam String fileMd5) throws Exception { uploadService.uploadChunk(file, chunkIndex, totalChunks, fileMd5); return Result.success(); } /** * 合并分片并生成视频记录 */ PostMapping(/merge) public ResultLong merge( RequestParam String fileMd5, RequestParam String fileName, RequestParam Integer totalChunks) throws Exception { Long videoId uploadService.mergeChunks(fileMd5, fileName, totalChunks); return Result.success(videoId); } }你可能会问为什么不把fileMd5、fileName这些参数放进一个 DTO因为MultipartFile不能直接通过 JSON 传递/chunk接口必然使用multipart/form-data其他接口为了调用方便也统一用 form 参数这样前端封装会比较顺手。4.3 实现上传服务分片检查逻辑UploadServiceImpl是本讲后端代码的核心。先看检查逻辑。package com.recsys.video.service.impl; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.recsys.common.exception.BizException; import com.recsys.video.config.UploadProperties; import com.recsys.video.entity.VideoInfo; import com.recsys.video.mapper.VideoInfoMapper; import com.recsys.video.service.UploadService; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.FileChannel; import java.nio.file.Files; import java.nio.file.StandardOpenOption; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; Service RequiredArgsConstructor public class UploadServiceImpl implements UploadService { private final StringRedisTemplate redisTemplate; private final UploadProperties uploadProperties; private final VideoInfoMapper videoInfoMapper; /** * Redis 中分片记录 key 的前缀 */ private static final String CHUNK_KEY_PREFIX video:upload:; Override public MapString, Object checkFile(String fileMd5, String fileName) { String safeMd5 safeFileMd5(fileMd5); MapString, Object result new HashMap(); // 秒传判断视频表中是否已存在相同 MD5 的记录 Long count videoInfoMapper.selectCount( Wrappers.VideoInfolambdaQuery() .eq(VideoInfo::getFileMd5, safeMd5) .eq(VideoInfo::getStatus, 1)); result.put(exist, count ! null count 0); // 断点续传判断从 Redis Hash 中读取已上传分片编号 SetObject keys redisTemplate.opsForHash() .keys(CHUNK_KEY_PREFIX safeMd5); ListInteger uploadedChunks keys.stream() .map(k - Integer.parseInt(k.toString())) .sorted() .collect(Collectors.toList()); result.put(uploadedChunks, uploadedChunks); return result; } /** * 分片上传 */ Override public void uploadChunk(MultipartFile file, Integer chunkIndex, Integer totalChunks, String fileMd5) throws Exception { if (file null || file.isEmpty()) { throw new BizException(分片文件不能为空); } String safeMd5 safeFileMd5(fileMd5); if (chunkIndex 0 || totalChunks 0 || chunkIndex totalChunks) { throw new BizException(分片参数不合法); } // 每个 MD5 对应一个分片目录 File chunkDir new File(uploadProperties.getTempDir(), safeMd5); if (!chunkDir.exists()) { chunkDir.mkdirs(); } // 分片文件名格式索引_总分片数.part例如 0_40.part String chunkName chunkIndex _ totalChunks .part; File dest new File(chunkDir, chunkName); file.transferTo(dest.getAbsoluteFile()); // 记录到 Redis合并时用来判断分片是否齐全 redisTemplate.opsForHash().put( CHUNK_KEY_PREFIX safeMd5, String.valueOf(chunkIndex), 1); } /** * 合并分片 */ Override public Long mergeChunks(String fileMd5, String fileName, Integer totalChunks) throws Exception { String safeMd5 safeFileMd5(fileMd5); File chunkDir new File(uploadProperties.getTempDir(), safeMd5); if (!chunkDir.exists()) { throw new BizException(分片目录不存在无法合并); } // 校验分片是否齐全 for (int i 0; i totalChunks; i) { File chunk new File(chunkDir, i _ totalChunks .part); if (!chunk.exists()) { throw new BizException(缺少分片 i); } } String ext getExtension(fileName); File mergeDir new File(uploadProperties.getMergeDir()); if (!mergeDir.exists()) { mergeDir.mkdirs(); } String finalName safeMd5 _ System.currentTimeMillis() . ext; File target new File(mergeDir, finalName); // 按分片顺序写入保证视频数据顺序正确 try (FileChannel out FileChannel.open(target.toPath(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i 0; i totalChunks; i) { File chunk new File(chunkDir, i _ totalChunks .part); try (FileChannel in FileChannel.open(chunk.toPath(), StandardOpenOption.READ)) { in.transferTo(0, in.size(), out); } } } // 清理临时分片 for (int i 0; i totalChunks; i) { Files.deleteIfExists( new File(chunkDir, i _ totalChunks .part).toPath()); } Files.deleteIfExists(chunkDir.toPath()); redisTemplate.delete(CHUNK_KEY_PREFIX safeMd5); // 写入视频记录状态为“待分析” VideoInfo video new VideoInfo(); video.setFileMd5(safeMd5); video.setOriginName(fileName); video.setStorePath(target.getAbsolutePath()); video.setStatus(0); videoInfoMapper.insert(video); return video.getId(); } /** * 校验 MD5 格式防止路径穿越 */ private String safeFileMd5(String fileMd5) { if (fileMd5 null || !fileMd5.matches([0-9a-fA-F]{32})) { throw new BizException(非法的文件指纹); } return fileMd5.toLowerCase(); } /** * 获取扩展名并校验白名单 */ private String getExtension(String fileName) { if (fileName null || !fileName.contains(.)) { throw new BizException(文件名缺少扩展名); } String ext fileName.substring(fileName.lastIndexOf(.) 1).toLowerCase(); if (uploadProperties.getAllowTypes() ! null !uploadProperties.getAllowTypes().contains(ext)) { throw new BizException(不支持的视频格式 ext); } return ext; } }这里有几个实现细节值得关注。第一分片文件命名为0_40.part这样的格式既包含索引又包含总分片数合并时可以直接用索引定位也便于人工排查缺了哪个分片。第二使用FileChannel.transferTo合并文件是零拷贝实现性能比逐字节复制高很多。对于几百 MB 的视频合并这种写法能明显减少 CPU 开销。第三safeFileMd5方法非常重要。如果把用户传来的字符串直接拼接成路径攻击者可以传../../etc/passwd之类的内容导致路径穿越。强制校验 32 位十六进制后MD5 只能作为安全目录名使用。第四分片上传状态我用 Redis Hash 保存不用每传一个分片都写一次数据库性能更好也天然支持过期时间避免临时分片占满磁盘。如果你的项目里没有 Redis也可以换成一张upload_chunk_record表逻辑不变只是性能会差一些。4.4 关于合并接口的幂等性合并接口还有一个容易忽略的问题客户端调用/merge时可能因为网络原因服务端已经合并成功但客户端没收到响应于是再次调用/merge这时候就会重复合并、重复插入视频记录。解决方案有两种第一种是合并前先查数据库如果该 MD5 已经存在直接返回已有的videoId不重复创建。这也是最推荐的方式。第二种是使用 Redis 分布式锁包裹合并逻辑保证同一 MD5 的合并操作只有一个线程在执行。锁的 key 可以用video:upload:lock:加 MD5锁过期时间根据视频大小设置比如 5 分钟。在实际项目中优先把查重逻辑加上再配合锁使用基本可以保证幂等。5. 前端实现Uniapp Vue 分片上传封装5.1 计算视频文件 MD5后端设计完成后接下来是前端。小程序端和 H5 端读写文件的方式有差异本讲示例以微信小程序端为演示环境H5 端可以把文件读取部分替换成Blob.slice方案分片上传的核心逻辑完全一样。在utils目录下新增videoUploader.js。首先实现 MD5 计算和分片切分。// 文件路径utils/videoUploader.js import SparkMD5 from /utils/spark-md5.js /** * 读取整个视频文件的 ArrayBuffer * 注意超大文件会占用较多内存生产环境建议分段读取 */ function readFileBuffer(filePath) { const fs uni.getFileSystemManager() return fs.readFileSync(filePath) } /** * 基于文件内容计算 MD5 */ function calcFileMd5(filePath) { const buffer readFileBuffer(filePath) const spark new SparkMD5.ArrayBuffer() spark.append(buffer) return spark.end() } /** * 把文件按分片大小切分为多个临时文件 */ function createChunks(filePath, chunkSize) { const fs uni.getFileSystemManager() const buffer readFileBuffer(filePath) const total buffer.byteLength const chunks [] let index 0 for (let offset 0; offset total; offset chunkSize) { const part buffer.slice(offset, Math.min(offset chunkSize, total)) const partPath ${filePath}_part_${index}_${Date.now()}.tmp fs.writeFileSync(partPath, part) chunks.push({ path: partPath, index: index }) index } return chunks }这段代码的逻辑是先把整个视频读成ArrayBuffer然后按chunkSize切片再把每个分片写成临时文件。临时文件的作用是传给uni.uploadFile的filePath因为系统自带的uploadFile接口接收的是本地文件路径不能直接传ArrayBuffer。这种一次性读入内存的写法在实现上最简单适合 100MB 以内的视频。如果业务要求支持更大文件就需要用FileSystemManager的readFile分段读取能力或者直接改造成服务端分片下载模式。这个优化方向我会在最佳实践里再展开。5.2 封装分段上传方法有了 MD5 和分片列表下一步封装三个网络方法检查、传分片、合并。// 文件路径utils/videoUploader.js const BASE_URL https://你的后端域名 /** * 上传前检查是否可秒传以及已上传分片 */ function checkUpload(fileMd5, fileName) { return new Promise((resolve, reject) { uni.request({ url: ${BASE_URL}/api/upload/check, method: POST, data: { fileMd5, fileName }, success: (res) resolve(res.data.data), fail: reject }) }) } /** * 上传单个分片 */ function uploadChunk(filePath, chunkIndex, totalChunks, fileMd5) { return new Promise((resolve, reject) { uni.uploadFile({ url: ${BASE_URL}/api/upload/chunk, filePath: filePath, name: file, formData: { chunkIndex: String(chunkIndex), totalChunks: String(totalChunks), fileMd5: fileMd5 }, success: (res) { const data JSON.parse(res.data) if (data.code 200) { resolve(data) } else { reject(new Error(data.message || 分片上传失败)) } }, fail: reject, complete: () { // 无论成功失败清理临时分片文件 try { uni.getFileSystemManager().unlinkSync(filePath) } catch (e) { // 忽略清理异常不影响主流程 } } }) }) } /** * 合并分片 */ function mergeChunks(fileMd5, fileName, totalChunks) { return new Promise((resolve, reject) { uni.request({ url: ${BASE_URL}/api/upload/merge, method: POST, data: { fileMd5, fileName, totalChunks: String(totalChunks) }, success: (res) resolve(res.data.data), fail: reject }) }) }这里有几个容易踩坑的点uni.uploadFile的formData字段会被序列化成表单参数一定要转成字符串再传某些平台上数字类型会在请求体里被错误解析。complete回调里清理临时文件是必须的。小程序本地存储空间有限如果每上传一个视频就残留几十个分片临时文件用户手机空间很快会被占满。5.3 主流程封装与断点续传有了上面的基础方法就可以封装对外暴露的上传入口了。// 文件路径utils/videoUploader.js const DEFAULT_CHUNK_SIZE 5 * 1024 * 1024 // 5MB /** * 对外主方法选择文件后调用 * param {String} filePath 视频本地路径 * param {String} fileName 视频文件名 * param {Function} onProgress 进度回调 * returns {PromiseNumber} videoId */ export async function uploadVideo(filePath, fileName, onProgress) { // 1. 计算 MD5 const fileMd5 await calcFileMd5(filePath) // 2. 检查秒传与断点续传状态 const checkResult await checkUpload(fileMd5, fileName) if (checkResult.exist) { onProgress onProgress(100) return { videoId: null, exist: true } } // 3. 切分文件 const chunks createChunks(filePath, DEFAULT_CHUNK_SIZE) const uploadedChunks new Set(checkResult.uploadedChunks || []) const total chunks.length // 4. 串行上传缺失分片 for (let i 0; i total; i) { if (uploadedChunks.has(i)) { continue } await uploadChunk(chunks[i].path, chunks[i].index, total, fileMd5) const percent Math.floor(((i 1) / total) * 100) onProgress onProgress(percent) } // 5. 合并 const videoId await mergeChunks(fileMd5, fileName, total) onProgress onProgress(100) return { videoId, exist: false } }这段代码把整个流程串起来了先检查再切分跳过已有分片串行上传剩余分片最后合并。onProgress回调让页面能实时展示进度。串行上传的优点是实现清晰、请求数量可控缺点是速度相对慢。如果业务对上传速度要求高可以把循环改成并发两三个分片但要注意微信小程序并发请求数是有限制的并发太高反而会被系统排队。5.4 Vue 页面接入上传逻辑上传逻辑封装好之后页面侧就非常简单。假设这是一个“发布视频”页面核心代码如下。template view classpublish-page button typeprimary taponChooseVideo :disableduploading {{ uploading ? 上传中... : 选择视频 }} /button view v-ifprogress 0 classprogress-bar view classprogress-inner :style{ width: progress % }/view /view text v-ifuploading classprogress-text{{ progress }}%/text view v-ifvideoId classsuccess-tip视频上传成功ID{{ videoId }}/view /view /template script setup import { ref } from vue import { uploadVideo } from /utils/videoUploader.js const uploading ref(false) const progress ref(0) const videoId ref(null) function onChooseVideo() { uni.chooseVideo({ sourceType: [album, camera], maxDuration: 60, success: async (res) { uploading.value true progress.value 0 try { const fileName res.tempFilePath.split(/).pop() || video.mp4 const result await uploadVideo( res.tempFilePath, fileName, (percent) { progress.value percent } ) videoId.value result.videoId if (result.exist) { uni.showToast({ title: 检测到相同视频已秒传, icon: none }) } } catch (e) { uni.showToast({ title: e.message || 上传失败, icon: none }) } finally { uploading.value false } } }) } /script页面在success回调里拿到视频临时路径后直接调用uploadVideo并通过回调函数更新进度条。用户在真实场景中看到的进度是“分片进度”也就是已成功上传的分片数量占总分片数的百分比。6. 与智能视频推荐系统的集成6.1 上传完成后的异步分析流程视频上传成功之后只相当于拿到了一个“原始素材”还不能立刻进入推荐列表。要让视频被推荐给用户需要做内容分析生成标题标签、提取关键词、计算向量表示最后写入召回链路。推荐的做法是上传合并完成后立即把videoId投递到消息队列或者使用 Spring 的Async异步方法触发分析任务。这样上传接口可以快速返回分析任务在后台慢慢执行用户不用等。// 伪代码合并成功后触发分析 videoInfoMapper.insert(video); applicationEventPublisher.publishEvent(new VideoUploadedEvent(video.getId())); return video.getId();监听器里再调用VideoAnalysisService完成后续处理。如果分析耗时较长建议使用 MQ比如 RocketMQ 或 RabbitMQ如果只是中小规模项目Async事件监听也够用。6.2 使用 SpringAI 生成推荐标签SpringAI 是 Spring 生态官方的 AI 集成组件。在我们这个项目里可以用它把视频信息发送给大模型自动生成标签和推荐语。package com.recsys.ai.service; import org.springframework.ai.chat.client.ChatClient; import org.springframework.stereotype.Service; import java.util.Map; Service public class VideoAnalysisService { private final ChatClient chatClient; public VideoAnalysisService(ChatClient.Builder builder) { this.chatClient builder.build(); } public MapString, Object analyzeVideo(String title, String description) { String prompt 你是一名短视频运营专家。请根据下面的视频信息生成 5 个推荐标签和一句 50 字以内的推荐语。 视频标题%s 视频描述%s 要求标签用英文逗号分隔并返回 JSON。 .formatted(title, description); String result chatClient.call(prompt); // 解析 JSON写入 video_tags 表 return Map.of(analyzeResult, result); } }SpringAI 的ChatClient封装了与大模型对话的通用流程不同模型供应商只需要替换底层配置。实际使用时你需要确保application.yml中已经配置了模型供应商的api-key、base-url和模型名称。这些配置项属于项目敏感信息不要提交到 Git 仓库建议放到环境变量或配置中心。6.3 通过 LangChain4j 完成标签向量化与召回准备LangChain4j 是 Java 生态里非常活跃的 LLM 编排框架它提供了模型调用、提示词模板、向量存储等能力。在本项目中LangChain4j 主要承担“结构化输出”和“向量化”两部分工作。比如我们希望分析接口返回稳定的 JSON而不是带各种解释性文字的文本可以先定义数据结构再让 LangChain4j 以这个结构作为输出约束。package com.recsys.ai.service; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.model.output.Response; /** * 演示 LangChain4j 的调用入口。 * 不同版本的 API 存在差异请以实际 Maven 版本为准。 */ public class LangChain4jDemoService { public String generateTags(String title) { ChatLanguageModel model OpenAiChatModel.builder() .apiKey(${llm.api-key}) .modelName(your-model-name) .build(); String prompt 为视频标题生成推荐标签 title; ResponseAiMessage response model.generate(prompt); return response.content().text(); } }生成标签后下一步是对标签做向量化写入向量数据库。向量数据库的选取可以根据项目情况决定比如 Milvus、Redisearch 或者云厂商向量服务。推荐系统的召回阶段通过“用户兴趣向量”与“视频标签向量”的相似度计算就能把新上传且分析完成的视频快速投放到“相似内容”推荐流中。这里我不再展开向量数据库的具体配置因为那已经属于推荐算法模块的内容。本讲的重点是让上传链路和 AI 分析链路打通上传完成 → 入库 → 异步分析 → 向量化 → 进入推荐池。7. 常见问题与排查思路7.1 典型问题对照表大文件上传涉及网络、文件系统、缓存、数据库多个环节出问题的地方很多。我把实际开发中最高频的几个问题整理成了表格问题现象常见原因排查与解决思路上传分片返回 413前置 Nginx 或网关请求体限制过小检查 Nginxclient_max_body_size调整到总分片或单分片合理大小检查接口提示可秒传但实际没有文件数据库里存在脏数据或 MD5 冲突核对视频表数据清理异常记录确保fileMd5唯一索引合并时报“缺少分片”客户端传了错误的totalChunks或者部分分片上传失败打印前端分片总数与 Redis 已记录分片数比对差异合并完成的视频无法播放分片合并顺序错乱或中途混入损坏分片确认合并循环严格按chunkIndex递增写入重传损坏分片大视频上传时小程序内存暴涨一次性readFileSync读入整个文件改用分段读取或限制上传大小让用户先压缩视频合并耗时过长分片数量多且并发合并逻辑不当使用FileChannel.transferTo避免逐字节复制上传完成后视频记录重复/merge被客户端重复调用合并前按 MD5 查重已存在则直接返回原videoId服务端临时目录被占满用户上传后中断临时分片未清理配置定时任务清理超过 24 小时的临时分片目录7.2 排查问题时的检查清单如果你遇到上传问题建议按下面的顺序排查不要直接猜先用Postman或Apifox模拟上传一个 5MB 的临时文件确认后端三个接口都正常。逐个测试分片上传不要一上来就传完整视频。检查 Redis 中video:upload:文件MD5这个 Hash 的实际内容确认分片编号记录是否完整。查看服务端日志重点看/api/upload/merge执行时是否有异常或卡住。检查服务器磁盘空间df -h看一下临时目录所在分区是否被打满。确认 Nginx 和网关层没有设置过小的上传限制。大概率情况下问题都出在“分片数量不一致”或者“临时文件没有写进预期目录”这两类。8. 最佳实践与工程建议8.1 存储与命名规范不要把用户原始文件名直接作为磁盘文件名存在两个问题一是中文文件名在跨平台存储时容易乱码二是路径中包含不可控字符串存在安全风险。正确的做法是用MD5_时间戳.扩展名生成新的文件名数据库里再单独保存originName字段保留原始文件名用于展示。目录结构建议按日期分层比如/data/upload/video/2025/06/避免单个目录下文件数量过多影响文件系统检索性能。8.2 安全边界与合法授权文件上传是安全攻击的高发区至少要做这几道防线扩展名白名单校验只允许mp4、mov等视频格式。单文件大小上限防止用户上传超大文件拖垮磁盘。文件内容校验对于视频可以校验文件头魔数不要只信扩展名。MD5 参数必须做格式白名单校验防止路径穿越。上传接口必须做登录态校验确认当前用户是否有上传权限。本讲示例中safeFileMd5方法属于最小必要防护生产环境还需要把登录用户 ID 与视频归属绑定确保用户只能管理自己的视频。8.3 性能优化方向第一个方向是避免一次性读入文件。小程序端可以把readFileSync改成多位读取或者使用官方提供的分段读取 API。如果后端支持也可以直接在小程序里用FileSystemManager分段读取文件内容后直接通过uni.request发送ArrayBuffer避免本地写临时文件。第二个方向是控制分片并发数。建议分片大小 5MB同时并发 2 到 3 个分片。并发过多会让小程序请求队列阻塞也会增加服务端连接数。第三个方向是大文件合并时的性能。合并时使用FileChannel.transferTo它走的是内核态零拷贝比InputStream循环读写快很多。如果分片数量非常大还可以考虑把分片直接存储到对象存储最终由对象存储服务本身提供合并能力这属于“服务端直传 云端合并”的思路代码复杂度会大幅降低适合把视频存储放到云上的项目。8.4 生产环境的幂等与容错生产环境必须假设任何一次请求都可能失败所以幂等性设计非常关键。合并接口判定“该 MD5 已存在”时要保证并发安全推荐在 Redis 上加一个锁锁的 key 使用upload:lock:MD5过期时间设为 5 分钟。拿到锁之后再做查重和合并操作这样即使前端反复调用/merge也只会生成一条视频记录。另外临时分片的清理不能只依赖前端调用删除接口必须后端做兜底。建议写一个定时任务每天扫描临时目录删除创建时间超过 24 小时且仍未合并的分片目录同时清理对应的 Redis Key。9. 总结与下一步学习路线本讲围绕“大文件上传”完成了三件事第一讲清楚了为什么视频不能走普通表单上传分片上传和断点续传的核心思路是什么第二用 SpringBoot 实现了检查、分片上传、合并三个接口并给出了完整的后端代码第三在 Uniapp 小程序侧封装了 MD5 计算、分片切分、串行上传、进度回调的完整工具模块最后把上传链路接到了 SpringAI 与 LangChain4j 的视频分析环节。其实这一讲的难度不在代码本身而在“边界情况”。比如分片丢了怎么补、合并重复了怎么处理、临时文件什么时候清理、MD5 参数能不能直接拼路径这些才是真正影响线上稳定性的点。我建议你先不要直接上 500MB 的视频找一个 20MB 左右的样例把整个流程跑通确认 Redis 分片记录、临时目录、合并文件都正确之后再逐步增大文件尺寸。下一讲可以继续往下走的方向有三个一是把本地磁盘存储替换成对象存储直传让上传和存储彻底解耦二是基于 SpringAI 的多模态模型直接分析视频关键帧并生成标签替代目前只依赖标题和描述的方案三是完善推荐召回链路把本讲生成的视频向量真正用起来。你可以按照自己的项目进度先从最迫切的一个方向开始实践。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →