你的文件上传接口是否也要缓慢加载是否也要等待上传的小圈圈一圈一圈转个不停。下面我将介绍一种文件上传接口的实现技术从此告别文件上传的长时间接口延迟做到文件上传极速响应全程零延迟流畅拉升用户使用体验。任务流程前端调取接口/uploadFileMp4Submit请求访问上传File到服务器此时接口不对文件做任何处理只保存一条任务数据到数据库记录是谁上传的视频任务所有者以及任务的状态task_state然后直接把上传视频的任务丢给异步进程去做给前端返回一个请求结果。至此前端的交互完成响应在10ms以内做到无感上传。异步进程先将File文件解析为MultipartFile为什么要解析为MultipartFile可见下文然后利用VideoUtils生成封面并上传 COS存储桶同时修改task的任务状态task_state为封面已上传,接着把视频上传到COS存储桶至此整个任务结束。1.为什么要先传封面再传视频先截帧上传封面前端可以先拿到封面展示提高用户体验。2.可以将上传封面和视频放在不同的进程异步上传吗当然可以只是在这里没必要。任务都交给后端服务器异步处理了前端怎么知道有没有上传成功呢这个时候就可以用上我们的task任务记录了我们可以再写一个接口专门查询这个任务的状态让前端轮询这个接口当监听到状态为已完成时前端便知道我们上传成功了。那前端怎么展示这个视频呢不能等到上传成功之后才给展示吧这样用户体验感相当的差了。展示给用户的时候其实他们只会去看个封面前面说到视频截帧封面上传到存储桶当上传到存储桶之后前端便可以进行展示。我们可以在上传文件的时候就把封面和视频的路径给写死然后传先返回给前端同时把这两个上传路径传给异步任务让他们上传到存储桶的路径必须按照这个来。这个时候只要当封面和视频成功上传之后前端就可以查看了。那还有一个问题视频可以先不给用户看那封面什么时候展示呢一直不展示封面也不太好吧针对这个问题封面也就是图片上传到存储桶很快几十毫秒便可完成费时间的是截帧生成封面需要一秒钟左右大文件小得文件也就几十毫秒和File文件转为MultipartFile文件的时间大约一秒钟左右具体取决于文件的大小也就是从选择完视频点击上传开始到前端展示只需要一秒钟左右的延迟后续视频可以慢慢上传反正前端已经可以展示视频上传中了。那这一秒的空白时间有没有优化空间呢当然我们可以让前端先展示一个预先准备好的图片然后在轮询监听task的时候当监听到封面上传成功之后前端立刻把视频封面换成该视频的封面这样就可以无缝衔接提高用户体验。废话不多说先上代码1、暴露给前端的接口/uploadFileMp4SubmitPostMapping(/uploadFileMp4Submit) public R? submitUploadMp4(RequestParam(file) MultipartFile multipartFile, RequestParam(value dir, required false) String dir, RequestParam(value userId, required false) String userId) { if (multipartFile null || multipartFile.isEmpty()) { return R.fail(请选择要上传的视频文件); } userId StringUtils.isEmpty(userId) ? SecurityUtils.getOID() : userId; dir StringUtils.isEmpty(dir) ? syb : dir; String originalName multipartFile.getOriginalFilename(); if (StringUtils.isEmpty(originalName)) { return R.fail(文件名不能为空); } // 校验格式 String suffix originalName.substring(originalName.lastIndexOf(.) 1); if (!isValidVideoFormat(suffix)) { return R.fail(不支持的格式: suffix); } String taskId UUID.randomUUID().toString(); // 预生成固定路径 String dateStr LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); String fileUuid UUID.randomUUID().toString(); String coverUuid UUID.randomUUID().toString(); // 固定路径前端可以直接用 String videoKey dir / dateStr / fileUuid . suffix; String coverKey dir /covers/ coverUuid .jpg; //存储桶域名路径 String cosUrl https://ttc.com/ videoKey; String coverUrl https://ttc.com/ coverKey; try { taskMapper.insert(taskId, userId, originalName, cosUrl, coverUrl); } catch (Exception e) { log.error(创建任务失败, e); return R.fail(创建任务失败); } // 启动异步任务 videoUploadAsyncService.doUploadAsync(taskId, multipartFile, videoKey, coverKey, originalName); MapString, Object result new HashMap(); result.put(taskId, taskId); result.put(cosUrl, cosUrl); // 固定路径上传完成后自动生效 result.put(coverUrl, coverUrl); // 固定路径上传完成后自动生效 result.put(message, 视频上传任务已提交正在后台处理); return R.ok(result); }2、文件格式校验isValidVideoFormat防止非法文件上传。private boolean isValidVideoFormat(String suffix) { if (StringUtils.isEmpty(suffix)) { return false; } SetString allowedFormats new HashSet(Arrays.asList( mp4, avi, mov, flv, wmv, mkv, webm, m4v )); return allowedFormats.contains(suffix.toLowerCase()); }3、异步文件上传VideoUploadAsyncServiceService Slf4j public class VideoUploadAsyncService { Autowired private UploadTaskMapper taskMapper; private static final DateTimeFormatter DATE_FORMATTER DateTimeFormatter.ofPattern(yyyyMMdd); //存储桶文件名称 private static final String BUCKET_NAME syb-1328371015; //存储访问域名 private static final String CDN_DOMAIN https://ttc.com/; // 硬编码初始化 COSClient //存储桶私钥 String secretId AKIDw14Karx6DXp; String secretKey 9GINeyeimx8HQf; COSCredentials cred new BasicCOSCredentials(secretId, secretKey); Region region new Region(ap-shanghai); ClientConfig clientConfig new ClientConfig(region); COSClient cosClient new COSClient(cred, clientConfig); Async(uploadExecutor) public void doUploadAsync(String taskId, MultipartFile multipartFile, String videoKey, String coverKey, String originalName){ long startTime System.currentTimeMillis(); File videoFile null; try { videoFile multipartFileToFile(multipartFile); } catch (IOException e) { taskMapper.updateFailed(taskId, 转临时文件失败); return; // 必须 return否则 videoFile 未初始化 } try { taskMapper.updateProcessing(taskId); log.info(开始异步处理, taskId: {}, fileName: {}, taskId, originalName); // 1. 生成并上传封面 boolean s1 generateAndUploadCover(videoFile, coverKey); if(s1){ taskMapper.updateSuccess1(taskId); }else { taskMapper.updateFailed(taskId, 封面上传失败失败); } // 2. 上传视频 PutObjectRequest putRequest new PutObjectRequest(BUCKET_NAME, videoKey, videoFile); cosClient.putObject(putRequest); //taskMapper.updateSuccess2(taskId); String duration formatDuration(videoFile); // 3. 更新成功 long costTime System.currentTimeMillis() - startTime; taskMapper.updateSuccess(taskId, duration); log.info(视频上传成功, taskId: {}, url: {}, coverUrl: {}, costTime: {}ms, taskId, videoKey, coverKey, costTime); } catch (Exception e) { long costTime System.currentTimeMillis() - startTime; log.error(视频上传失败, taskId: {}, costTime: {}ms, taskId, costTime, e); taskMapper.updateFailed(taskId, 上传失败: e.getMessage()); } finally { safeDelete(videoFile); } } /** * 使用 VideoUtils 生成封面并上传 COS */ private boolean generateAndUploadCover(File videoFile, String coverKey) { String tempDir System.getProperty(java.io.tmpdir) video_temp/; new File(tempDir).mkdirs(); File thumbnailFile new File(tempDir UUID.randomUUID() .jpg); FFmpegFrameGrabber grabber null; try { // 1. 截图 grabber new FFmpegFrameGrabber(videoFile); grabber.start(); // 获取第一帧跳过可能的黑屏 Frame frame null; for (int i 0; i 10; i) { frame grabber.grabImage(); if (frame ! null frame.image ! null) { break; } } if (frame null || frame.image null) { log.warn(无法获取视频帧, file: {}, videoFile.getName()); return false; } Java2DFrameConverter converter new Java2DFrameConverter(); BufferedImage image converter.convert(frame); // 保存为 jpg if (!ImageIO.write(image, jpg, thumbnailFile)) { log.warn(保存缩略图失败: {}, thumbnailFile.getAbsolutePath()); return false; } grabber.stop(); // 2. 上传到 COS 指定路径 PutObjectRequest coverRequest new PutObjectRequest(BUCKET_NAME, coverKey, thumbnailFile); cosClient.putObject(coverRequest); log.info(封面上传成功, coverKey: {}, coverKey); return true; } catch (Exception e) { log.warn(截图或上传失败: {}, e.getMessage()); return false; } finally { safeDelete(thumbnailFile); if (grabber ! null) { try { grabber.close(); } catch (Exception ignored) {} } } } private void safeDelete(File file) { if (file ! null file.exists() !file.delete()) { file.deleteOnExit(); } } private String formatDuration(File file) { MultimediaObject multimediaObject new MultimediaObject(file); MultimediaInfo result null; try { result multimediaObject.getInfo(); } catch (EncoderException e) { e.printStackTrace(); } Long durationInSeconds result.getDuration() / 1000; return durationInSeconds.intValue() ; } public static File multipartFileToFile(MultipartFile mulFile) throws IOException { InputStream ins mulFile.getInputStream(); String fileName mulFile.getOriginalFilename(); String prefix getFileNameNoEx(fileName) UUID.fastUUID(); String suffix . getExtensionName(fileName); File toFile File.createTempFile(prefix, suffix); OutputStream os new FileOutputStream(toFile); int bytesRead 0; byte[] buffer new byte[8192]; while ((bytesRead ins.read(buffer, 0, 8192)) ! -1) { os.write(buffer, 0, bytesRead); } os.close(); ins.close(); return toFile; } public static String getFileNameNoEx(String filename) { if ((filename ! null) (filename.length() 0)) { int dot filename.lastIndexOf(.); if ((dot -1) (dot (filename.length()))) { return filename.substring(0, dot); } } return filename; } public static String getExtensionName(String filename) { if ((filename ! null) (filename.length() 0)) { int dot filename.lastIndexOf(.); if ((dot -1) (dot (filename.length() - 1))) { return filename.substring(dot 1); } } return filename; } }3.1、封面截帧、上传generateAndUploadCoverprivate boolean generateAndUploadCover(File videoFile, String coverKey) { String tempDir System.getProperty(java.io.tmpdir) video_temp/; new File(tempDir).mkdirs(); File thumbnailFile new File(tempDir UUID.randomUUID() .jpg); FFmpegFrameGrabber grabber null; try { // 1. 截图 grabber new FFmpegFrameGrabber(videoFile); grabber.start(); // 获取第一帧跳过可能的黑屏 Frame frame null; for (int i 0; i 10; i) { frame grabber.grabImage(); if (frame ! null frame.image ! null) { break; } } if (frame null || frame.image null) { log.warn(无法获取视频帧, file: {}, videoFile.getName()); return false; } Java2DFrameConverter converter new Java2DFrameConverter(); BufferedImage image converter.convert(frame); // 保存为 jpg if (!ImageIO.write(image, jpg, thumbnailFile)) { log.warn(保存缩略图失败: {}, thumbnailFile.getAbsolutePath()); return false; } grabber.stop(); // 2. 上传到 COS 指定路径 PutObjectRequest coverRequest new PutObjectRequest(BUCKET_NAME, coverKey, thumbnailFile); cosClient.putObject(coverRequest); log.info(封面上传成功, coverKey: {}, coverKey); return true; } catch (Exception e) { log.warn(截图或上传失败: {}, e.getMessage()); return false; } finally { safeDelete(thumbnailFile); if (grabber ! null) { try { grabber.close(); } catch (Exception ignored) {} } } }3.2、获取视频时长formatDurationprivate String formatDuration(File file) { MultimediaObject multimediaObject new MultimediaObject(file); MultimediaInfo result null; try { result multimediaObject.getInfo(); } catch (EncoderException e) { e.printStackTrace(); } Long durationInSeconds result.getDuration() / 1000; return durationInSeconds.intValue() ; }3.3、MultipartFile转换为临时FilemultipartFileToFilepublic static File multipartFileToFile(MultipartFile mulFile) throws IOException { InputStream ins mulFile.getInputStream(); String fileName mulFile.getOriginalFilename(); String prefix getFileNameNoEx(fileName) UUID.fastUUID(); String suffix . getExtensionName(fileName); File toFile File.createTempFile(prefix, suffix); OutputStream os new FileOutputStream(toFile); int bytesRead 0; byte[] buffer new byte[8192]; while ((bytesRead ins.read(buffer, 0, 8192)) ! -1) { os.write(buffer, 0, bytesRead); } os.close(); ins.close(); return toFile; }3.3.1、为什么要转MultipartFile是内存/临时文件File是磁盘文件特性MultipartFileFile存储位置内存或临时目录磁盘指定位置生命周期请求结束后释放持久化需手动删除适用场景小文件快速处理大文件、需要反复读取第三方库支持部分不支持几乎所有库都支持3.3.2、什么情况下需要转File1. 第三方库只接受File参数// FFmpeg 处理视频 ProcessBuilder pb new ProcessBuilder(ffmpeg, -i, file.getAbsolutePath(), ...); // 阿里云 OSS 上传 ossClient.putObject(bucket, key, file); // 接受 File // 腾讯云 COS 上传 cosClient.upload(key, file); // 接受 File // PDF 处理 PDFParser parser new PDFParser(new FileInputStream(file));很多 SDK 的 API 设计就是接收File不接收InputStream。2. 需要多次读取文件内容// MultipartFile 的 InputStream 只能读一次 InputStream is1 multipartFile.getInputStream(); // 第一次读取 // is1 读完后multipartFile.getInputStream() 可能为空或已读完 // File 可以反复打开读取 new FileInputStream(file); // 第一次 new FileInputStream(file); // 第二次没问题3. 大文件处理避免内存溢出// MultipartFile 默认存在内存大文件会撑爆内存 byte[] bytes multipartFile.getBytes(); // 100MB 文件 100MB 内存 // 转成 File 后可以流式处理 FileInputStream fis new FileInputStream(file); // 逐块读取内存占用小4. 异步处理请求结束后文件还在PostMapping(/upload) public R? upload(MultipartFile file) { // 请求结束后multipartFile 会被清理 // 转成 File 保存到磁盘异步任务可以继续用 File tempFile multipartFileToFile(file); // 异步处理请求立即返回 CompletableFuture.runAsync(() - { // 这里 multipartFile 可能已经失效但 tempFile 还在 processVideo(tempFile); }); return R.ok(); }4、查询任务状态接口uploadFileMp4/status/GetMapping(uploadFileMp4/status/{taskId}) public R? getUploadStatus(PathVariable String taskId) { MapString, Object task taskMapper.selectById(taskId); if (task null) { return R.fail(任务不存在); } return R.ok(task); }5、任务执行时间细分16:26:41.051 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,706] - 步骤1-空校验: 0ms 16:26:41.051 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,709] - 步骤2-SecurityUtils: 0ms 16:26:41.051 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,723] - 步骤3-格式校验: 0ms 16:26:41.054 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,738] - 步骤4-生成路径: 3ms Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSessiondc49ab6] was not registered for synchronization because synchronization is not active JDBC Connection [com.alibaba.druid.proxy.jdbc.ConnectionProxyImpl2c47135c] will not be managed by Spring Preparing: INSERT INTO upload_task (id, user_id, original_name, cos_url, cover_url ,status, created_at) VALUES (?, ?, ?, ?, ?, 0, NOW()) Parameters: ac86e835-8be1-40de-9864-0fef967e72ce(String), 1491109363820003328(String), 3ea20459-b262-460d-9b58-809af5192507 (1).mp4(String), https://sybfile.sybmiaoda.com/syb/20260626/6060d24c-6f6d-413d-b6e6-8c4fbbfbec45.mp4(String), https://sybfile.sybmiaoda.com/syb/covers/2be4539f-bdd9-409d-b246-1bece71623e6.jpg(String) Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSessiondc49ab6] 16:26:41.057 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,742] - 步骤5-数据库插入: 1ms 16:26:41.060 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,753] - 步骤6-启动异步: 5ms 16:26:41.060 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,760] - 步骤7-组装返回: 0ms 16:26:41.060 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,762] - 总耗时: 9ms Creating a new SqlSession在服务器上执行时间只有9ms但接口请求时间却需要1423ms这是因为文件上传要需要经过我们的后端服务器再上传。5.1、什么情况下必须走服务器1. 需要服务器处理文件内容场景说明视频转码需要服务器用 FFmpeg 转码不同清晰度图片压缩/裁剪生成缩略图、加水印文件格式校验需要读取文件头验证真实格式病毒扫描上传前杀毒内容审核AI 鉴黄、鉴暴等提取元数据读取视频时长、分辨率等2. 需要服务器记录或控制场景说明权限校验判断用户是否有上传权限配额限制检查用户剩余空间计费统计按流量/容量计费日志审计记录谁上传了什么敏感词过滤文件名、内容过滤3. 存储后端不支持直传场景说明私有存储自建 MinIO、NAS 等没有预签名功能旧系统兼容老系统只支持服务端上传多存储聚合需要服务器决定存哪个后端高阶前端直传 COS上面的方法必须要经过后端服务器浪费时间。那有没有不经过后端的方案有的兄弟有的后端提供临时签名PostMapping(/getUploadUrl) public R? getUploadUrl(RequestParam(fileName) String fileName, RequestParam(fileSize) long fileSize) { // 生成唯一路径 String dateStr LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); String fileUuid UUID.randomUUID().toString(); String suffix fileName.substring(fileName.lastIndexOf(.) 1); String videoKey syb/ dateStr / fileUuid . suffix; String coverKey syb/covers/ UUID.randomUUID().toString() .jpg; // COS 临时上传 URL有效期5分钟 String uploadUrl cosClient.getPresignedUploadUrl(videoKey, 5 * 60); // 预创建任务记录 String taskId UUID.randomUUID().toString(); taskMapper.insert(taskId, SecurityUtils.getOID(), fileName, https://ttc.com/ videoKey, https://ttc.com/ coverKey); MapString, Object result new HashMap(); result.put(taskId, taskId); result.put(uploadUrl, uploadUrl); // 前端直传到这里 result.put(videoUrl, https://ttc.com/ videoKey); result.put(coverUrl, https://ttc.com/ coverKey); return R.ok(result); }前端直传 COS// 1. 获取上传URL const res await fetch(/api/getUploadUrl, { method: POST, body: JSON.stringify({fileName: video.mp4, fileSize: 10240000}) }); const { uploadUrl, videoUrl, taskId } await res.json(); // 2. 直接上传到COS不经过你的服务器 await fetch(uploadUrl, { method: PUT, body: file, // File 对象 headers: { Content-Type: file.type } }); // 3. 通知后端上传完成 await fetch(/api/confirmUpload, { method: POST, body: JSON.stringify({taskId, status: SUCCESS}) });这样前后端搭配让前端直接上传文件到COS存储桶就不用再让文件先传到后端浪费时间了。省时有省力。省事请求回调现在遇到与上面同样的问题前端怎么知道上传是否成功。下面我为您提供了两个方法。方式一COS 回调通知// 生成带回调的上传 URL String uploadUrl cosClient.getPresignedUploadUrl(videoKey, 5 * 60, CosCallback.builder() .url(https://your-api.com/cos/callback) // 你的回调地址 .body({\taskId\:\ taskId \}) .build() );后端接收回调PostMapping(/cos/callback) public void cosCallback(RequestBody CosCallbackBody body) { String taskId body.getTaskId(); String cosStatus body.getStatus(); // SUCCESS / FAILED // 更新任务状态 taskMapper.updateStatus(taskId, SUCCESS.equals(cosStatus) ? TaskStatus.SUCCESS : TaskStatus.FAILED); // 如果成功触发后续处理如转码 if (SUCCESS.equals(cosStatus)) { videoProcessService.startProcess(taskId); } }优点最可靠不受前端网络影响。方式二前端轮询查询// 1. 上传 await fetch(uploadUrl, { method: PUT, body: file }); // 2. 轮询查询状态 let retries 0; const maxRetries 30; // 最多轮询30次 const timer setInterval(async () { const res await fetch(/api/taskStatus?taskId${taskId}); const { status } await res.json(); if (status SUCCESS) { clearInterval(timer); showSuccess(); } else if (status FAILED) { clearInterval(timer); showError(); } else if (retries maxRetries) { clearInterval(timer); showTimeout(); } }, 2000); // 每2秒轮询一次后端提供查询接口GetMapping(/taskStatus) public R? getTaskStatus(RequestParam String taskId) { UploadTask task taskMapper.selectById(taskId); return R.ok(Map.of(status, task.getStatus())); }完整流程前端 你的服务器 COS│ │ ││──► GET /getUploadUrl ──►│ ││◄─── uploadUrl, taskId ──┤ ││ │ ││──► PUT uploadUrl ───────┼────────────────────►││ │ ││◄─── 200 OK ─────────────┼◄─────────────────────┤│ │ ││──► GET /taskStatus ────►│ ││◄──── status: PENDING ───┤ ││ │ ││ [轮询...] │ ││ │ ││◄──── status: SUCCESS ───┤◄─── COS 回调 ────────┤│ │ │视频封面怎么处理方案 1前端上传封面用户自选1.流程前端选择视频 封面图 ──► 同时直传 COS ──► 后端记录两个 URL2.后端接口PostMapping(/getUploadUrls) public R? getUploadUrls(RequestParam(hasCover) boolean hasCover, RequestParam(fileName) String fileName, RequestParam(coverName) String coverName) { String videoKey generateVideoKey(fileName); String coverKey generateCoverKey(coverName); // 两个预签名 URL String videoUploadUrl cosClient.getPresignedUploadUrl(videoKey); String coverUploadUrl cosClient.getPresignedUploadUrl(coverKey); String taskId UUID.randomUUID().toString(); MapString, Object result new HashMap(); result.put(taskId, taskId); result.put(videoUploadUrl, videoUploadUrl); result.put(coverUploadUrl, hasCover ? coverUploadUrl : null); result.put(videoUrl, https://sybfile.sybmiaoda.com/ videoKey); result.put(coverUrl, https://sybfile.sybmiaoda.com/ coverKey); // 预创建任务 taskMapper.insert(taskId, videoKey, coverKey, hasCover ? 1 : 0); return R.ok(result); }3.前端// 同时上传视频和封面 await Promise.all([ fetch(videoUploadUrl, { method: PUT, body: videoFile }), hasCover ? fetch(coverUploadUrl, { method: PUT, body: coverFile }) : Promise.resolve() ]);方案 2后端自动生成封面视频首帧1.流程前端上传视频 ──► COS ──► 后端回调 ──► FFmpeg 截取首帧 ──► 上传封面到 COS2.后端处理Service public class VideoProcessService { Autowired private CosClient cosClient; Async public void generateCover(String taskId, String videoKey) { try { // 1. 从 COS 下载视频到本地临时文件 File tempVideo cosClient.downloadToTemp(videoKey); // 2. FFmpeg 截取首帧 File coverFile new File(tempVideo.getParent(), cover.jpg); ProcessBuilder pb new ProcessBuilder( ffmpeg, -i, tempVideo.getAbsolutePath(), -ss, 00:00:00.001, // 第1毫秒 -vframes, 1, -q:v, 2, coverFile.getAbsolutePath() ); pb.inheritIO(); Process process pb.start(); process.waitFor(); // 3. 上传封面到 COS String coverKey videoKey.replace(.mp4, .jpg) .replace(/videos/, /covers/); cosClient.upload(coverKey, coverFile); // 4. 更新任务 String coverUrl https://sybfile.sybmiaoda.com/ coverKey; taskMapper.updateCoverUrl(taskId, coverUrl); } catch (Exception e) { log.error(生成封面失败: taskId{}, taskId, e); } } }方案 3COS 智能封面腾讯云能力1.腾讯云 COS 支持数据万象CI自动截取封面视频上传到 COS ──► 配置 CI 规则 ──► 自动生成封面缩略图配置方式在 COS 控制台开启数据万象配置视频截帧规则上传视频后自动触发2.获取封面 URL// COS 数据万象截帧 URL 格式 String coverUrl https://sybfile.sybmiaoda.com/ videoKey ?ci-processsnapshot // 截帧处理 time1 // 第1秒 formatjpg // 输出格式 width480 // 宽度 height270; // 高度无需后端处理直接拼接 URL 即可方案对比方案实现难度用户体验成本适用场景前端上传封面低好用户自选低用户需要自定义封面后端 FFmpeg 截帧中一般首帧可能黑屏中服务器资源需要自定义逻辑COS 数据万象低好多种截帧策略低按量计费推荐最省心