
视频上传功能上线后遇到最多的反馈不是“传得慢”而是“同一个视频重复传了好几次”既浪费用户时间又把 OSS 存储和带宽成本抬得很高。我们在做智能视频推荐小程序时也踩了这个坑。后来在整体链路里补上了“秒传检查”这一步效果非常明显相同文件第二次上传基本是瞬间完成服务端存储成本也降了下来。这篇文章是《基于 SpringBoot Vue Uniapp LangChain4j SpringAI 的智能视频推荐小程序》系列的第十三讲重点拆解“秒传检查”这个功能。内容包括原理、数据表设计、后端接口、Uniapp 端和 Vue 管理端如何计算文件 MD5、如何与分片上传配合以及线上经常遇到的坑和排查思路。已经熟悉上传流程的朋友可以直接看第 3 章之后的内容。1. 为什么要做秒传检查视频上传的流量与体验痛点1.1 秒传检查是什么秒传检查又叫“文件去重检查”。用户在客户端选择一个文件后客户端先计算这个文件的唯一指纹最常见的是 MD5然后把这个指纹发送给服务端。服务端在数据库里查询这个指纹是否已经存在。如果存在就直接告诉客户端“文件已经有了不用再传了”这就是秒传。用一句话说明秒传不是上传速度变快而是把“重复上传”变成“上传 0 字节”。1.2 秒传检查在智能视频推荐小程序中的位置在这个项目里用户通过 Uniapp 小程序录制、上传视频视频经过服务端存储后会进入推荐系统的数据管道结合 LangChain4j 和 SpringAI 做内容理解和个性化推荐。整个链路的入口就是“视频上传”。如果入口不做去重会出现两类问题用户体验差同一个视频反复上传等待时间长、流量消耗大。服务端压力大重复文件反复写存储、反复做转码和 AI 分析浪费计算资源。秒传检查的作用是在用户真正上传文件之前先用一次轻量请求拦截重复文件。这样既改善了体验也保护了下游存储和 AI 分析链路。1.3 秒传、分片上传、断点续传三者的关系这三个概念经常一起出现但别混淆秒传文件已经存在客户端不用再传。分片上传一个大文件切成多个小块逐块上传解决单文件过大、网络波动问题。断点续传上传中断后从已上传的分片继续传而不是重头开始。三者的配合关系是先做秒传检查未命中再进入分片上传分片上传过程中可以通过已上传分片记录实现断点续传所有分片合并完成后服务端登记文件指纹下一次再上传相同文件时就能走秒传。秒传检查是这条链路的第一道关卡。2. 整体技术栈与环境准备2.1 项目技术架构本系列项目后端采用 SpringBoot持久层使用 MyBatis-Plus文件元信息存储在 MySQL 中文件本体可存放在本地磁盘或 OSS 上实际部署时按团队情况选择。前端有两个端Uniapp 小程序端面向普通用户上传视频。Vue 管理端用于运营人员后台管理视频。LangChain4j 和 SpringAI 主要用于推荐侧的文本理解、标签提取和推荐结果生成秒传检查本身不依赖它们但上传完成的文件会通过消息队列进入推荐分析链路。2.2 环境与版本说明下面这些环境以我们项目中的常见配置为例你可以根据自己的实际项目调整版本组件说明JDK17SpringBoot 3.x 环境SpringBoot3.xMyBatis-Plus3.5.xMySQL8.xRedis6.x用于缓存秒传哈希Node.js18运行 Vue3 / Uniapp 构建UniappHBuilderX 创建的项目Vue3.x版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。2.3 示例工程结构后端工程结构如下video-recommend-server/ ├── src/main/java/com/example/video/ │ ├── controller/ │ │ └── FileUploadController.java │ ├── service/ │ │ ├── FileCheckService.java │ │ └── FileUploadService.java │ ├── mapper/ │ │ └── VideoFileRecordMapper.java │ ├── entity/ │ │ └── VideoFileRecord.java │ ├── common/ │ │ └── Result.java │ └── VideoRecommendApplication.java └── src/main/resources/ └── application.yml前端使用 HBuilderX 创建 Uniapp 项目文件结构大致为uniapp-video-app/ ├── pages/ │ └── upload/ │ └── upload.vue ├── utils/ │ └── md5.js └── api/ └── file.js3. 秒传检查整体设计与数据表3.1 秒传检查的执行流程秒传检查的完整流程可以用下面这个图来理解选择视频文件 ↓ 计算文件 MD5本地不消耗上传流量 ↓ 请求后端 /api/file/check ↓ 服务端按 MD5 查询数据库 ↓ ├─ 命中返回秒传成功直接进入后续业务如发布视频 └─ 未命中返回需要上传客户端进入分片上传流程这里有两个关键细节MD5 必须由客户端计算因为服务端如果先接收文件再计算哈希就没有节省上传流量。服务端查询时不能只看 MD5还要看文件大小进一步降低哈希冲突概率。3.2 文件记录表设计秒传检查需要一个文件元信息表用于登记所有已上传完成的文件。表结构如下CREATE TABLE video_file_record ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, file_md5 VARCHAR(64) NOT NULL DEFAULT COMMENT 文件MD5, file_name VARCHAR(255) NOT NULL DEFAULT COMMENT 文件原始名称, file_size BIGINT NOT NULL DEFAULT 0 COMMENT 文件大小单位字节, file_path VARCHAR(512) NOT NULL DEFAULT COMMENT 文件存储路径, upload_status TINYINT NOT NULL DEFAULT 0 COMMENT 上传状态0-上传中1-上传完成, video_id BIGINT DEFAULT NULL COMMENT 关联视频ID用于推荐链路, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_md5_status (file_md5, upload_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT视频文件秒传记录表;注意几个设计点file_md5字段长度设为 64因为 MD5 是 32 位十六进制字符串留出扩展空间。upload_status区分上传中和上传完成避免用户在上传过程中再次检查时错误命中一条未完成的记录。索引使用file_md5和upload_status的组合索引满足秒传查询条件。3.3 分片记录表与哈希索引当秒传未命中时大文件会走分片上传。分片记录表如下CREATE TABLE video_file_chunk ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, file_md5 VARCHAR(64) NOT NULL DEFAULT COMMENT 文件整体MD5, chunk_index INT NOT NULL DEFAULT 0 COMMENT 分片序号从0开始, chunk_size INT NOT NULL DEFAULT 0 COMMENT 分片大小单位字节, chunk_path VARCHAR(512) NOT NULL DEFAULT COMMENT 分片存储路径, PRIMARY KEY (id), KEY idx_md5_index (file_md5, chunk_index) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT视频文件分片记录表;分片表通过file_md5关联同一文件的多个分片。这里存的file_md5是整个文件的 MD5而不是每个分片的 MD5这样分片合并后可以直接复用秒传检查的哈希结果。4. 后端 SpringBoot 实现4.1 实体类后端工程中我们使用 MyBatis-Plus。先创建对应实体类。// 文件路径src/main/java/com/example/video/entity/VideoFileRecord.java package com.example.video.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.util.Date; Data TableName(video_file_record) public class VideoFileRecord { TableId(type IdType.AUTO) private Long id; private String fileMd5; private String fileName; private Long fileSize; private String filePath; private Integer uploadStatus; private Long videoId; private Date createTime; private Date updateTime; }4.2 MapperMapper 继承BaseMapper同时提供两个秒传检查需要的方法。// 文件路径src/main/java/com/example/video/mapper/VideoFileRecordMapper.java package com.example.video.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.video.entity.VideoFileRecord; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; public interface VideoFileRecordMapper extends BaseMapperVideoFileRecord { Select(SELECT * FROM video_file_record WHERE file_md5 #{md5} AND file_size #{fileSize} AND upload_status 1 LIMIT 1) VideoFileRecord selectByMd5AndSize(Param(md5) String md5, Param(fileSize) Long fileSize); Select(SELECT COUNT(1) FROM video_file_record WHERE file_md5 #{md5} AND upload_status 1) int countByMd5(Param(md5) String md5); }4.3 ServiceService 层负责保存记录和查询记录。秒传检查的 Service 代码如下// 文件路径src/main/java/com/example/video/service/FileCheckService.java package com.example.video.service; import com.example.video.entity.VideoFileRecord; import com.example.video.mapper.VideoFileRecordMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; Service public class FileCheckService { Autowired private VideoFileRecordMapper videoFileRecordMapper; /** * 秒传检查 * param fileMd5 文件MD5 * param fileSize 文件大小 * return 已存在的文件记录没有则返回 null */ public VideoFileRecord checkFile(String fileMd5, Long fileSize) { if (StringUtils.isEmpty(fileMd5) || fileSize null || fileSize 0) { return null; } return videoFileRecordMapper.selectByMd5AndSize(fileMd5, fileSize); } }这里有个容易被忽略的问题不能只查 MD5必须同时带文件大小。MD5 虽然冲突概率很低但理论上存在不同文件相同哈希的可能。加上文件大小条件后命中记录的可信度更高。4.4 ControllerController 提供秒传检查接口和上传接口入口。先定义统一返回结构// 文件路径src/main/java/com/example/video/common/Result.java package com.example.video.common; import lombok.Data; Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }请求参数和响应对象// 文件路径src/main/java/com/example/video/controller/FileCheckRequest.java package com.example.video.controller; import lombok.Data; Data public class FileCheckRequest { private String fileMd5; private Long fileSize; }// 文件路径src/main/java/com/example/video/controller/FileCheckResponse.java package com.example.video.controller; import lombok.Data; Data public class FileCheckResponse { private Boolean exists; private String fileUrl; public FileCheckResponse(Boolean exists) { this.exists exists; } }Controller 核心代码// 文件路径src/main/java/com/example/video/controller/FileUploadController.java package com.example.video.controller; import com.example.video.common.Result; import com.example.video.entity.VideoFileRecord; import com.example.video.service.FileCheckService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/file) public class FileUploadController { Autowired private FileCheckService fileCheckService; /** * 秒传检查接口 * 客户端上传文件前先调用该接口判断文件是否已存在 */ PostMapping(/check) public ResultFileCheckResponse check(RequestBody FileCheckRequest request) { VideoFileRecord record fileCheckService.checkFile( request.getFileMd5(), request.getFileSize() ); if (record ! null) { FileCheckResponse response new FileCheckResponse(true); response.setFileUrl(record.getFilePath()); return Result.success(response); } return Result.success(new FileCheckResponse(false)); } }到这里秒传检查接口已经可以工作。客户端拿到existstrue时就可以跳过上传直接进入发布成功流程拿到existsfalse时继续走分片上传。4.5 上传完成后的登记逻辑秒传检查依赖“已经上传完成并登记”的文件记录。如果分片合并完成后没有登记 MD5秒传永远命中不了。下面给一个合并完成后登记记录的核心片段// 文件路径src/main/java/com/example/video/service/FileUploadService.java package com.example.video.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.video.entity.VideoFileRecord; import com.example.video.mapper.VideoFileRecordMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class FileUploadService { Autowired private VideoFileRecordMapper videoFileRecordMapper; /** * 分片合并完成后登记文件指纹。 * 同一个 MD5 重复登记时通过唯一记录判断幂等。 */ Transactional(rollbackFor Exception.class) public VideoFileRecord registerFile(String md5, String fileName, Long fileSize, String filePath) { VideoFileRecord existing videoFileRecordMapper.selectOne( new LambdaQueryWrapperVideoFileRecord() .eq(VideoFileRecord::getFileMd5, md5) .eq(VideoFileRecord::getFileSize, fileSize) .eq(VideoFileRecord::getUploadStatus, 1) .last(LIMIT 1) ); if (existing ! null) { return existing; } VideoFileRecord record new VideoFileRecord(); record.setFileMd5(md5); record.setFileName(fileName); record.setFileSize(fileSize); record.setFilePath(filePath); record.setUploadStatus(1); videoFileRecordMapper.insert(record); return record; } }需要注意registerFile的幂等性。现实中可能出现两个用户同时上传同一个文件两个请求几乎同时完成合并如果不做控制会产生两条相同 MD5 的记录。此时需要配合数据库唯一索引或使用 Redis 锁来保证幂等。5. 前端 Uniapp 实现5.1 引入哈希计算库小程序端计算文件 MD5最常见的是使用spark-md5。在 Uniapp 项目的根目录执行npm install spark-md5然后在工具文件中引入// 文件路径utils/md5.js import SparkMD5 from spark-md5;如果项目没有 npm 环境也可以通过 “原生 JS 实现 MD5” 的方式但建议优先使用成熟的spark-md5它支持增量计算处理大文件时更友好。5.2 跨端文件读取与 MD5 计算Uniapp 运行在不同的平台上获取文件的方式不同所以需要写跨端逻辑微信小程序使用uni.getFileSystemManager().readFile()读取文件内容。App 端使用plus.io读取文件。H5 端通过input typefile获取文件后使用 FileReader 读取。下面是一个封装好的 MD5 计算方法// 文件路径utils/md5.js import SparkMD5 from spark-md5; /** * 计算文件 MD5 * param {String} filePath 文件路径 * returns {PromiseString} */ export function calcFileMd5(filePath) { return new Promise((resolve, reject) { // #ifdef MP-WEIXIN const fs uni.getFileSystemManager(); fs.readFile({ filePath: filePath, success(res) { try { const spark new SparkMD5.ArrayBuffer(); spark.append(res.data); const md5 spark.end(); resolve(md5); } catch (e) { reject(e); } }, fail(err) { reject(err); } }); // #endif // #ifdef APP-PLUS plus.io.resolveLocalFileSystemURL(filePath, (entry) { entry.file((file) { const reader new plus.io.FileReader(); reader.readAsArrayBuffer(file); reader.onloadend (e) { try { const spark new SparkMD5.ArrayBuffer(); spark.append(e.target.result); const md5 spark.end(); resolve(md5); } catch (err) { reject(err); } }; reader.onerror reject; }); }, reject); // #endif }); }这里要注意两种readFile方式在大文件场景下的区别。微信小程序的readFile会一次性把文件读进内存视频文件较大时可能出现内存占用过高。更稳妥的做法是使用FileSystemManager.readSplitString或分片读取但小程序的读取能力有限所以一般建议在 100MB 以下的个人短视频场景直接读取超大文件则优先考虑 Web 端或原生 App 端上传。5.3 调用秒传检查接口拿到 MD5 后上传前先请求后端接口// 文件路径api/file.js export function checkFile(fileMd5, fileSize) { return uni.request({ url: /api/file/check, method: POST, data: { fileMd5, fileSize } }); }上传页面的核心逻辑// 文件路径pages/upload/upload.vue async handleChooseVideo(res) { const file res.tempFiles[0]; const filePath file.path; const fileSize file.size; const fileMd5 await calcFileMd5(filePath); const checkRes await checkFile(fileMd5, fileSize); if (checkRes.data.exists) { uni.showToast({ title: 检测到重复文件已秒传, icon: success }); // 直接进入后续业务比如绑定视频信息、触发推荐分析 this.handlePublishSuccess(checkRes.data.fileUrl); return; } // 未命中进入分片上传流程 this.uploadByChunks(filePath, fileMd5, fileSize); }注意checkFile的返回值在不同平台略有差异实际项目里建议统一用 Promise 封装uni.request避免回调嵌套。6. Vue 管理端秒传检查实现6.1 Web 端文件读取与 MD5 计算Vue 管理端运行在浏览器中计算 MD5 的方式与小程序不同。Web 端使用FileReader读取文件内容交给spark-md5计算。!-- 文件路径src/views/upload/FileUpload.vue -- template input typefile changehandleFileChange / /template script setup import SparkMD5 from spark-md5; function handleFileChange(event) { const file event.target.files[0]; if (!file) return; const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); reader.readAsArrayBuffer(file); reader.onprogress (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); console.log(读取进度${percent}%); } }; reader.onload (e) { spark.append(e.target.result); const fileMd5 spark.end(); // 调用秒传检查接口 checkFile(fileMd5, file.size).then((res) { if (res.data.exists) { alert(文件已存在秒传成功); } else { // 普通上传 uploadFile(file); } }); }; reader.onerror (err) { console.error(文件读取失败, err); }; } /scriptWeb 端需要注意两个问题FileReader也是一次性读取整个文件大文件时建议使用Blob.prototype.slice分片增量计算。视频文件通常比较大建议在计算 MD5 时显示“正在计算文件指纹”的进度提示否则用户会以为页面卡死了。6.2 管理端上传组件接入管理端上传场景和用户端略有不同。运营人员上传的视频尺寸可能更大还可能需要批量上传。在接入秒传检查时建议把 MD5 计算封装成通用函数并放在上传前置钩子中执行// 文件路径src/utils/fileHash.js import SparkMD5 from spark-md5; export function calcFileHashWithProgress(file, onProgress) { return new Promise((resolve, reject) { const chunkSize 2 * 1024 * 1024; // 2MB const spark new SparkMD5.ArrayBuffer(); let currentChunk 0; const blobSlice File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; const totalChunks Math.ceil(file.size / chunkSize); const loadNext () { const start currentChunk * chunkSize; const end start chunkSize file.size ? file.size : start chunkSize; const reader new FileReader(); reader.readAsArrayBuffer(blobSlice.call(file, start, end)); reader.onload (e) { spark.append(e.target.result); currentChunk; if (onProgress) { onProgress(Math.round((currentChunk / totalChunks) * 100)); } if (currentChunk totalChunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror reject; }; loadNext(); }); }这种分块计算方式适合浏览器端使用能有效避免大文件一次读入内存导致浏览器崩溃。7. 完整联调流程与验证7.1 联调时序完整的联调过程可以拆成下面几步步骤角色动作1客户端用户选择视频文件2客户端计算文件 MD5 和文件大小3客户端POST 请求/api/file/check4服务端按 MD5 size 查询记录表5服务端返回 existstrue 或 false6客户端true秒传成功进入发布流程7客户端false进入分片上传8服务端合并分片登记文件 MD59客户端再次请求秒传接口此时可以命中在实际测试时建议先用手动上传的方式登记一个文件再用相同文件做一次秒传检查验证接口返回结果。7.2 使用 curl 验证后端接口如果暂时没有前端页面可以直接用 curl 验证后端接口curl -X POST http://localhost:8080/api/file/check \ -H Content-Type: application/json \ -d { fileMd5: d41d8cd98f00b204e9800998ecf8427e, fileSize: 0 }注意以上是一个空文件的 MD5实际测试时需要使用你真实计算出的文件 MD5。可以通过下面的命令计算本地文件的 MD5md5sum /path/to/video.mp47.3 预期结果如果数据库中没有对应记录接口返回{ code: 200, message: success, data: { exists: false, fileUrl: null } }如果数据库中存在相同 MD5 和文件大小的记录接口返回{ code: 200, message: success, data: { exists: true, fileUrl: /data/video/2024/xxx.mp4 } }existstrue时客户端不需要再调用分片上传直接把fileUrl作为文件地址写入视频发布记录即可。8. 常见问题与排查思路8.1 问题排查清单我把落地过程中经常遇到的几类问题整理成一张表格方便快速定位问题现象常见原因解决思路前端计算的 MD5 和后端不一致文件读取方式不同或使用了 base64 转换统一使用 ArrayBuffer 读取避免二次编码大文件计算 MD5 卡死一次性读取整个文件内存占用过高使用分块读取、增量 append 计算秒传接口命中但视频播放不了源文件被清理或迁移记录成了脏数据秒传检查时同时校验物理文件是否存在同一个文件重复入库并发上传缺少幂等控制加唯一索引 数据库锁或 Redis 锁小程序端读取不到文件filePath 失效或权限不足使用wx.chooseVideo返回的临时文件路径秒传一直不命中上传完成后的登记逻辑没写检查合并流程是否落库video_file_record分片上传合并后 MD5 变化合并顺序错误或漏掉分片按 chunk_index 严格排序并校验分片数量8.2 典型问题详解第一个高频问题前端计算 MD5 时使用 base64而后端使用文件的二进制内容计算 MD5结果永远对不上。原因是把文件转成 base64 后计算哈希的对象已经不是原始文件字节了。解决方法是前后端统一使用 ArrayBuffer 或原始二进制数据计算。第二个高频问题秒传检查命中后直接返回成功但没有验证服务器上的真实文件是否完整。如果文件曾经上传成功但后来被人工清理掉数据库里还有记录秒传接口会把一条失效记录返回给用户。生产环境建议在秒传命中时增加一个文件存在性校验例如本地存储判断File是否存在。OSS 存储调用 SDK 的doesObjectExist方法。如果文件不存在则删除对应的数据库记录并让用户重新上传。这一步不能省否则秒传功能会成为线上故障的隐患。第三个高频问题并发重复上传同一个文件。比如两个用户同时上传同一个热门视频两个请求同时完成分片合并然后同时执行registerFile可能产生两条相同 MD5 的记录。建议在video_file_record表上增加唯一索引ALTER TABLE video_file_record ADD UNIQUE KEY uk_md5_size (file_md5, file_size);同时在registerFile中捕获重复插入异常并返回已有记录保证接口幂等。9. 最佳实践与工程建议9.1 哈希策略MD5 计算速度快是秒传检查的主流选择但它的碰撞安全性并不适合做签名认证。如果你的业务对哈希冲突特别敏感可以考虑两层校验第一层MD5 文件大小。第二层记录文件的前 128 字节或 CRC32在秒传命中时二次确认。对视频推荐场景来说MD5 文件大小已经足够不需要过度设计。9.2 并发幂等秒传检查接口本身是只读操作不会有并发写入问题。真正的并发压力在“分片合并完成后的登记动作”。除了加唯一索引还可以在 Redis 中设置一个upload:file:{md5}锁获取锁后再执行查询和插入避免数据库重复写入。示例思路String lockKey upload:file: md5; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofMinutes(5)); if (!locked) { // 说明其他线程正在处理相同文件直接返回已有状态 return videoFileRecordMapper.selectByMd5AndSize(md5, fileSize); } try { // 执行登记逻辑 } finally { redisTemplate.delete(lockKey); }9.3 存储与安全文件路径不要使用用户传入的文件名应使用系统生成的 UUID 或日期目录防止路径穿越和文件名冲突。秒传接口需要登录鉴权避免未登录用户探测服务器上是否存在某个文件。上传接口要限制文件大小、类型和视频时长防止恶意上传超大文件耗尽磁盘和带宽。生产环境启用 HTTPS避免用户上传的视频内容在传输过程中被抓包。9.4 与 AI 推荐链路打通秒传检查命中时文件已经存在但 video 记录可能还没有。用户发布视频后需要把 videoId 和文件记录关联起来并触发推荐分析流程。建议流程为秒传命中后创建 video 记录关联已有文件记录。发送一个 MQ 消息例如video.upload.complete。消费者收到消息后调用 LangChain4j / SpringAI 做视频标题理解、标签提取和向量化。推荐服务根据标签和向量结果更新用户推荐池。这样以来同一个视频即使被多个用户上传也只会在第一次上传时进入 AI 分析链路后续秒传直接复用分析结果整体系统压力会小很多。10. 总结与下一步学习方向这一讲我们完成了秒传检查的完整闭环客户端计算文件 MD5服务端查询文件指纹命中则跳过上传未命中则进入分片上传合并后登记指纹。通过秒传检查重复文件的带宽消耗基本降为零用户上传视频的体验也明显提升。在实际项目落地中我建议你优先关注两件事一是“合并完成后登记 MD5”的幂等处理这是秒传能否命中的前提二是“秒传命中后校验物理文件是否存在”这是秒传功能在线上长期稳定运行的关键。下一步可以继续学习分片上传的合并细节、断点续传的实现以及把文件上传事件接入 RabbitMQ / RocketMQ 后触发 AI 推荐的完整链路。秒传检查只是上传模块的第一环后面还有很多工程细节值得深入实践。如果你在实现秒传检查时遇到过其他奇怪的坑欢迎在评论区留言我们一起讨论排查思路。