feat(material): remove video cover extraction and simplify upload API
- Remove extractVideoCoverOptional function and related video cover processing - Update MaterialService.uploadFile method signature to remove coverBase64 parameter - Simplify uploadAndIdentifyVideo function by removing cover generation logic - Remove loading indicator from VideoSelector component during video preview - Add presignGetUrlWithProcess method to FileClient interface for processed file URLs - Add logging support to S3FileClient implementation
This commit is contained in:
425
openspec/proposals/video-cover-optimization.md
Normal file
425
openspec/proposals/video-cover-optimization.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# 视频封面加载优化方案
|
||||
|
||||
## 问题背景
|
||||
|
||||
### 当前问题
|
||||
`/api/tik/file/page` 接口加载时间长,主要原因是返回了 `coverBase64` 字段。
|
||||
|
||||
### 性能影响分析
|
||||
| 指标 | 当前状态 | 影响 |
|
||||
|------|---------|------|
|
||||
| 单个视频封面 base64 | 300KB - 2MB | 响应体膨胀 |
|
||||
| 分页 20 条记录 | 6MB - 40MB | 严重拖慢加载 |
|
||||
| 数据库存储 | 大文本字段 | 查询性能下降 |
|
||||
|
||||
### 根本原因
|
||||
1. 前端上传时传了 `coverBase64`
|
||||
2. `coverBase64` 直接存数据库,未上传到 OSS
|
||||
3. 分页查询返回了完整的 base64 数据
|
||||
|
||||
---
|
||||
|
||||
## 解决方案:阿里云 OSS 视频截帧
|
||||
|
||||
### 方案概述
|
||||
利用阿里云 OSS 的视频截帧能力,通过 URL 参数直接获取视频封面,无需额外存储。
|
||||
|
||||
### 方案优势
|
||||
| 维度 | 优势 |
|
||||
|------|------|
|
||||
| **存储成本** | 零额外存储,不存封面图 |
|
||||
| **代码改动** | 小,精简冗余逻辑 |
|
||||
| **维护成本** | 低,OSS 自动处理 |
|
||||
| **性能** | CDN 加速,加载快 |
|
||||
| **兼容性** | 历史数据自动支持 |
|
||||
|
||||
### 技术原理
|
||||
阿里云 OSS 视频截帧 API:
|
||||
```
|
||||
https://bucket.oss-cn-hangzhou.aliyuncs.com/video.mp4?x-oss-process=video/snapshot,t_0,f_jpg,w_300,m_fast
|
||||
```
|
||||
|
||||
参数说明:
|
||||
- `t_0` - 截取第 0 毫秒(第一帧)
|
||||
- `f_jpg` - 输出 JPG 格式
|
||||
- `w_300` - 宽度 300px(高度自动)
|
||||
- `m_fast` - 快速模式
|
||||
|
||||
---
|
||||
|
||||
## 实施计划
|
||||
|
||||
### 改动文件清单
|
||||
|
||||
| 文件 | 改动内容 |
|
||||
|------|---------|
|
||||
| `TikUserFileServiceImpl.java` | 修改 3 个方法,删除 1 个方法 |
|
||||
| `AppTikUserFileController.java` | 移除 coverBase64 参数 |
|
||||
| `TikUserFileDO.java` | 可选:移除 coverBase64 字段 |
|
||||
| `AppTikUserFileRespVO.java` | 可选:移除 coverBase64 字段 |
|
||||
| `useUpload.js`(前端) | 移除 coverBase64 参数 |
|
||||
|
||||
---
|
||||
|
||||
### 阶段 1:后端代码修改
|
||||
|
||||
#### 1.1 修改 `getFilePage` 方法(分页查询)
|
||||
**文件**:`TikUserFileServiceImpl.java`
|
||||
|
||||
**修改前**(第 268-303 行):
|
||||
```java
|
||||
// 视频文件不生成OSS预签名URL(前端使用coverBase64缓存)
|
||||
if (isVideo) {
|
||||
vo.setCoverUrl(null);
|
||||
vo.setThumbnailUrl(null);
|
||||
vo.setPreviewUrl(null);
|
||||
return vo;
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```java
|
||||
// 视频文件:使用 OSS 截帧,不返回 base64
|
||||
if (isVideo) {
|
||||
vo.setCoverBase64(null);
|
||||
vo.setCoverUrl(null);
|
||||
vo.setThumbnailUrl(null);
|
||||
// 使用 OSS 视频截帧作为封面
|
||||
if (StrUtil.isNotBlank(file.getFileUrl())) {
|
||||
String snapshotUrl = generateVideoSnapshotUrl(file.getFileUrl());
|
||||
vo.setPreviewUrl(snapshotUrl);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 新增 `generateVideoSnapshotUrl` 方法
|
||||
**文件**:`TikUserFileServiceImpl.java`
|
||||
|
||||
```java
|
||||
/**
|
||||
* 生成阿里云 OSS 视频截帧 URL
|
||||
* @param videoUrl 视频文件 URL
|
||||
* @return 截帧 URL
|
||||
*/
|
||||
private String generateVideoSnapshotUrl(String videoUrl) {
|
||||
if (StrUtil.isBlank(videoUrl)) {
|
||||
return null;
|
||||
}
|
||||
// 阿里云 OSS 视频截帧参数
|
||||
return videoUrl + "?x-oss-process=video/snapshot,t_0,f_jpg,w_300,m_fast";
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 修改 `completeUpload` 方法(直传确认)
|
||||
**文件**:`TikUserFileServiceImpl.java`(第 480-542 行)
|
||||
|
||||
**修改前**:
|
||||
```java
|
||||
String coverBase64 = (String) params.get("coverBase64");
|
||||
// ...
|
||||
String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory);
|
||||
// ...
|
||||
.setCoverUrl(coverUrl)
|
||||
.setCoverBase64(coverBase64)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```java
|
||||
// 移除 coverBase64 参数获取
|
||||
// String coverBase64 = (String) params.get("coverBase64"); // 删除
|
||||
|
||||
// 移除 handleCoverUpload 调用
|
||||
// String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory); // 删除
|
||||
|
||||
// 保存记录时不再存储封面相关字段
|
||||
TikUserFileDO userFile = new TikUserFileDO()
|
||||
.setUserId(userId)
|
||||
.setFileId(infraFileId)
|
||||
.setFileName(fileName)
|
||||
.setDisplayName(displayName)
|
||||
.setFileType(fileType)
|
||||
.setFileCategory(fileCategory)
|
||||
.setFileSize(fileSize)
|
||||
.setFileUrl(fileUrl)
|
||||
.setFilePath(fileKey)
|
||||
// .setCoverUrl(coverUrl) // 删除
|
||||
// .setCoverBase64(coverBase64) // 删除
|
||||
.setDuration(duration)
|
||||
.setGroupId(groupId);
|
||||
```
|
||||
|
||||
#### 1.4 修改 `saveFileRecord` 方法(传统上传)
|
||||
**文件**:`TikUserFileServiceImpl.java`(第 205-243 行)
|
||||
|
||||
**修改前**:
|
||||
```java
|
||||
public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory,
|
||||
String fileUrl, String filePath, String coverBase64,
|
||||
String baseDirectory, Long infraFileId, Integer duration, Long groupId) {
|
||||
// ...
|
||||
String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory);
|
||||
// ...
|
||||
.setCoverUrl(coverUrl)
|
||||
.setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```java
|
||||
public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory,
|
||||
String fileUrl, String filePath,
|
||||
String baseDirectory, Long infraFileId, Integer duration, Long groupId) {
|
||||
// 移除 coverBase64 参数
|
||||
// ...
|
||||
// 移除 handleCoverUpload 调用
|
||||
// ...
|
||||
TikUserFileDO userFile = new TikUserFileDO()
|
||||
// ... 其他字段保持不变
|
||||
// .setCoverUrl(coverUrl) // 删除
|
||||
// .setCoverBase64(coverBase64) // 删除
|
||||
.setDuration(duration)
|
||||
.setGroupId(groupId);
|
||||
```
|
||||
|
||||
#### 1.5 修改 `uploadFile` 方法入口
|
||||
**文件**:`TikUserFileServiceImpl.java`(第 76-92 行)
|
||||
|
||||
**修改前**:
|
||||
```java
|
||||
public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64, Integer duration, Long groupId) {
|
||||
// ...
|
||||
return saveFileRecord(userId, file, fileCategory, context.fileUrl, context.filePath,
|
||||
coverBase64, baseDirectory, context.infraFileId, duration, groupId);
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```java
|
||||
public Long uploadFile(MultipartFile file, String fileCategory, Integer duration, Long groupId) {
|
||||
// 移除 coverBase64 参数
|
||||
// ...
|
||||
return saveFileRecord(userId, file, fileCategory, context.fileUrl, context.filePath,
|
||||
baseDirectory, context.infraFileId, duration, groupId);
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.6 删除 `handleCoverUpload` 方法
|
||||
**文件**:`TikUserFileServiceImpl.java`(第 183-203 行)
|
||||
|
||||
**直接删除整个方法**,不再需要。
|
||||
|
||||
#### 1.7 修改 Controller 接口
|
||||
**文件**:`AppTikUserFileController.java`(第 40-53 行)
|
||||
|
||||
**修改前**:
|
||||
```java
|
||||
@PostMapping("/upload")
|
||||
public CommonResult<Long> uploadFile(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("fileCategory") String fileCategory,
|
||||
@RequestParam(value = "coverBase64", required = false) String coverBase64,
|
||||
@RequestParam(value = "duration", required = false) Integer duration,
|
||||
@RequestParam(value = "groupId", required = false) Long groupId) {
|
||||
return success(userFileService.uploadFile(file, fileCategory, coverBase64, duration, groupId));
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```java
|
||||
@PostMapping("/upload")
|
||||
public CommonResult<Long> uploadFile(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("fileCategory") String fileCategory,
|
||||
@RequestParam(value = "duration", required = false) Integer duration,
|
||||
@RequestParam(value = "groupId", required = false) Long groupId) {
|
||||
return success(userFileService.uploadFile(file, fileCategory, duration, groupId));
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.8 修改 Service 接口
|
||||
**文件**:`TikUserFileService.java`
|
||||
|
||||
**修改前**:
|
||||
```java
|
||||
Long uploadFile(MultipartFile file, String fileCategory, String coverBase64, Integer duration, Long groupId);
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```java
|
||||
Long uploadFile(MultipartFile file, String fileCategory, Integer duration, Long groupId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 2:前端代码修改
|
||||
|
||||
#### 2.1 修改 `useUpload.js`
|
||||
**文件**:`frontend/app/web-gold/src/composables/useUpload.js`
|
||||
|
||||
**修改 upload 方法**(第 131-141 行):
|
||||
```javascript
|
||||
// 修改前
|
||||
const upload = async (file, options = {}) => {
|
||||
const {
|
||||
fileCategory,
|
||||
groupId = null,
|
||||
coverBase64 = null, // 删除此行
|
||||
duration: inputDuration,
|
||||
// ...
|
||||
} = options
|
||||
```
|
||||
|
||||
**修改 completeUpload 调用**(第 189-198 行):
|
||||
```javascript
|
||||
// 修改前
|
||||
const completeData = await MaterialService.completeUpload({
|
||||
fileKey: presignedData.data.fileKey,
|
||||
fileName: file.name,
|
||||
fileCategory,
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
groupId,
|
||||
coverBase64, // 删除此行
|
||||
duration
|
||||
})
|
||||
```
|
||||
|
||||
#### 2.2 修改 `MaterialUploadModal.vue`
|
||||
**文件**:`frontend/app/web-gold/src/components/material/MaterialUploadModal.vue`
|
||||
|
||||
**移除导入**:
|
||||
```javascript
|
||||
// 删除此行
|
||||
import { isVideoFile, extractVideoCover } from '@/utils/video-cover'
|
||||
```
|
||||
|
||||
**移除截屏逻辑**:
|
||||
```javascript
|
||||
// 删除相关变量和调用
|
||||
// const fileCoverMap = await extractVideoCovers(videoFiles)
|
||||
// coverBase64: fileWithCover.coverBase64
|
||||
```
|
||||
|
||||
#### 2.3 删除 `video-cover.ts`
|
||||
**文件**:`frontend/app/web-gold/src/utils/video-cover.ts`
|
||||
|
||||
**整个文件删除**,不再需要前端截屏。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 3:数据库清理(可选)
|
||||
|
||||
```sql
|
||||
-- 清理历史 coverBase64 数据,释放存储空间
|
||||
UPDATE tik_user_file
|
||||
SET cover_base64 = NULL, cover_url = NULL
|
||||
WHERE file_type LIKE '%video%';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 4:可选 - 移除废弃字段
|
||||
|
||||
如果确定不再需要,可以移除以下字段:
|
||||
|
||||
**TikUserFileDO.java**:
|
||||
```java
|
||||
// 删除这两个字段
|
||||
private String coverUrl;
|
||||
private String coverBase64;
|
||||
```
|
||||
|
||||
**AppTikUserFileRespVO.java**:
|
||||
```java
|
||||
// 删除这两个字段
|
||||
private String coverUrl;
|
||||
private String coverBase64;
|
||||
```
|
||||
|
||||
> ⚠️ 注意:移除字段需要数据库迁移,建议先保留字段但不再使用。
|
||||
|
||||
---
|
||||
|
||||
## 性能对比
|
||||
|
||||
### 优化前
|
||||
```
|
||||
请求: /api/tik/file/page?pageNo=1&pageSize=20
|
||||
响应体: 10MB - 40MB (含 base64)
|
||||
加载时间: 5-15 秒
|
||||
```
|
||||
|
||||
### 优化后
|
||||
```
|
||||
请求: /api/tik/file/page?pageNo=1&pageSize=20
|
||||
响应体: 20KB - 50KB (仅 URL)
|
||||
加载时间: < 500ms
|
||||
封面加载: CDN 并行加载,每张 ~30KB
|
||||
```
|
||||
|
||||
### 提升效果
|
||||
| 指标 | 优化前 | 优化后 | 提升 |
|
||||
|------|--------|--------|------|
|
||||
| 响应体大小 | 10-40MB | 20-50KB | **99%↓** |
|
||||
| 接口响应时间 | 5-15s | <500ms | **95%↓** |
|
||||
| 数据库存储 | 大文本 | 无 | **100%↓** |
|
||||
|
||||
---
|
||||
|
||||
## 改动汇总
|
||||
|
||||
| 类型 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| 后端 | `TikUserFileServiceImpl.java` | 修改 4 个方法,删除 1 个方法 |
|
||||
| 后端 | `TikUserFileService.java` | 修改接口签名 |
|
||||
| 后端 | `AppTikUserFileController.java` | 移除 coverBase64 参数 |
|
||||
| 前端 | `useUpload.js` | 移除 coverBase64 参数 |
|
||||
| 前端 | `MaterialUploadModal.vue` | 移除截屏调用 |
|
||||
| 前端 | `video-cover.ts` | **删除整个文件** |
|
||||
| 数据库 | `tik_user_file` 表 | 清理历史数据(可选) |
|
||||
|
||||
---
|
||||
|
||||
## 风险与应对
|
||||
|
||||
### 风险 1:OSS 截帧计费
|
||||
**风险**:阿里云 OSS 视频截帧会产生少量费用
|
||||
**应对**:费用极低(每千次约 0.025 元),可接受
|
||||
|
||||
### 风险 2:截帧失败
|
||||
**风险**:某些视频格式可能截帧失败
|
||||
**应对**:前端增加默认封面兜底显示
|
||||
|
||||
### 风险 3:历史数据
|
||||
**风险**:历史数据有 coverBase64 但无 fileUrl
|
||||
**应对**:历史数据保留,查询时优先使用 fileUrl 截帧
|
||||
|
||||
---
|
||||
|
||||
## 回滚方案
|
||||
|
||||
如果 OSS 截帧方案出现问题,可快速回滚:
|
||||
|
||||
1. 恢复 `coverBase64` 参数
|
||||
2. 恢复 `handleCoverUpload` 方法
|
||||
3. 分页查询返回 `coverBase64`
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
### 功能验收
|
||||
- [ ] 视频上传成功,不传 coverBase64
|
||||
- [ ] 分页接口返回 `previewUrl`(OSS 截帧 URL)
|
||||
- [ ] 分页接口不返回 `coverBase64`
|
||||
- [ ] 前端正常显示视频封面
|
||||
|
||||
### 性能验收
|
||||
- [ ] 分页接口响应体 < 100KB
|
||||
- [ ] 分页接口响应时间 < 1 秒
|
||||
- [ ] 封面图片正常加载
|
||||
|
||||
---
|
||||
|
||||
*文档版本:v1.1*
|
||||
*更新日期:2026-03-04*
|
||||
Reference in New Issue
Block a user