diff --git a/frontend/app/web-gold/src/api/kling.js b/frontend/app/web-gold/src/api/kling.js index 02733e4c13..9758bfc589 100644 --- a/frontend/app/web-gold/src/api/kling.js +++ b/frontend/app/web-gold/src/api/kling.js @@ -6,22 +6,6 @@ import { MaterialService } from './material' // ========== 辅助函数 ========== -/** - * 从视频中提取封面(可选操作) - */ -async function extractVideoCoverOptional(file) { - try { - const { extractVideoCover } = await import('@/utils/video-cover') - const cover = await extractVideoCover(file, { - maxWidth: 800, - quality: 0.8 - }) - return cover.base64 - } catch { - return null - } -} - /** * 执行人脸识别并返回结果 */ @@ -103,9 +87,7 @@ export async function identifyUploadedVideo(videoFile) { * 上传视频并识别 */ export async function uploadAndIdentifyVideo(file) { - const coverBase64 = await extractVideoCoverOptional(file) - - const uploadRes = await MaterialService.uploadFile(file, 'digital_human', coverBase64, null, null) + const uploadRes = await MaterialService.uploadFile(file, 'digital_human', null, null) if (uploadRes.code !== 0) { throw new Error(uploadRes.msg || '上传失败') } diff --git a/frontend/app/web-gold/src/api/material.js b/frontend/app/web-gold/src/api/material.js index 05fde88824..879a85d9a1 100644 --- a/frontend/app/web-gold/src/api/material.js +++ b/frontend/app/web-gold/src/api/material.js @@ -65,12 +65,11 @@ export const MaterialService = { * 上传文件 * @param {File} file - 文件对象 * @param {string} fileCategory - 文件分类(video/generate/audio/mix/voice) - * @param {string} coverBase64 - 视频封面 base64(可选,data URI 格式) * @param {number} duration - 视频时长(秒,可选,自动获取) * @param {number} groupId - 分组编号(可选) * @returns {Promise} */ - async uploadFile(file, fileCategory, coverBase64 = null, duration = null, groupId = null) { + async uploadFile(file, fileCategory, duration = null, groupId = null) { if (duration === null && file.type.startsWith('video/')) { duration = await getVideoDuration(file); } @@ -83,10 +82,6 @@ export const MaterialService = { formData.append('duration', duration.toString()); } - if (coverBase64) { - formData.append('coverBase64', coverBase64) - } - if (groupId !== null) { formData.append('groupId', groupId.toString()) } diff --git a/frontend/app/web-gold/src/components/VideoSelector.vue b/frontend/app/web-gold/src/components/VideoSelector.vue index 795cb23581..f7a9c25ce5 100644 --- a/frontend/app/web-gold/src/components/VideoSelector.vue +++ b/frontend/app/web-gold/src/components/VideoSelector.vue @@ -251,12 +251,10 @@ const handlePreview = async (video) => { } // 显示加载提示 - const hideLoading = message.loading('正在获取视频地址...', 0) try { // 调用后端API获取带签名的视频播放URL const res = await MaterialService.getVideoPlayUrl(video.id) - hideLoading() if (res.code === 0 && res.data) { previewVideo.value = video @@ -266,7 +264,6 @@ const handlePreview = async (video) => { message.error(res.msg || '获取视频地址失败') } } catch (error) { - hideLoading() console.error('获取视频播放URL失败:', error) message.error('获取视频地址失败,请重试') } diff --git a/frontend/app/web-gold/src/components/material/MaterialUploadModal.vue b/frontend/app/web-gold/src/components/material/MaterialUploadModal.vue index c7163ff9a2..eaa60c419a 100644 --- a/frontend/app/web-gold/src/components/material/MaterialUploadModal.vue +++ b/frontend/app/web-gold/src/components/material/MaterialUploadModal.vue @@ -144,7 +144,6 @@ import { ref, watch, computed } from 'vue' import { message } from 'ant-design-vue' import { UploadOutlined, FileOutlined, LoadingOutlined, CheckCircleOutlined, CheckOutlined } from '@ant-design/icons-vue' -import { isVideoFile, extractVideoCover } from '@/utils/video-cover' import { getFileName, getFileSize, formatFileSize } from '@/utils/file' import { useUpload } from '@/composables/useUpload' @@ -153,10 +152,6 @@ const props = defineProps({ type: Boolean, default: false }, - uploading: { - type: Boolean, - default: false - }, fileCategory: { type: String, default: 'video' @@ -174,8 +169,6 @@ const { upload } = useUpload() // 数据 const fileList = ref([]) -const fileCoverMap = ref(new Map()) -const DEFAULT_FILE_CATEGORY = 'video' const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac' // 常量 @@ -216,7 +209,6 @@ const getTotalSize = () => { watch(() => props.visible, (newVal) => { if (!newVal) { fileList.value = [] - fileCoverMap.value.clear() resetUploadState() } }) @@ -266,7 +258,7 @@ const handleBeforeUpload = (file) => { } // 文件列表变化 -const handleFileChange = async (info) => { +const handleFileChange = (info) => { const { file, fileList: newFileList } = info if (file && file.status !== 'uploading') { @@ -278,25 +270,6 @@ const handleFileChange = async (info) => { return item }) .filter(item => item.status !== 'removed') - - // 如果是视频文件,自动提取封面 - const fileObj = file.file || file.originFileObj || file - if (fileObj instanceof File && isVideoFile(fileObj)) { - const fileKey = file.uid || fileObj.name - - if (!fileCoverMap.value.has(fileKey)) { - try { - const coverResult = await extractVideoCover(fileObj, { - maxWidth: 800, - quality: 0.8 - }) - fileCoverMap.value.set(fileKey, coverResult.base64) - console.log(`[封面提取成功] ${fileObj.name}`) - } catch (error) { - console.warn(`[封面提取失败] ${fileObj.name}:`, error) - } - } - } } } @@ -307,9 +280,6 @@ const handleRemove = (fileItem) => { (getFileName(item) === getFileName(fileItem)) ) if (index > -1) { - const removed = fileList.value[index] - const fileKey = removed.uid || getFileName(removed) - fileCoverMap.value.delete(fileKey) fileList.value.splice(index, 1) } } @@ -327,71 +297,65 @@ const handleConfirm = async () => { return } - // 提取文件对象和对应的封面 - const filesWithCover = fileList.value + // 提取文件对象 + const files = fileList.value .map(item => { const fileObj = item.file || item.originFileObj || item if (!(fileObj instanceof File)) return null - - const fileKey = item.uid || fileObj.name - return { - file: fileObj, - coverBase64: fileCoverMap.value.get(fileKey) || null - } + return fileObj }) .filter(item => item !== null) - if (filesWithCover.length === 0) { + if (files.length === 0) { message.error('无法获取文件对象,请重新选择文件') return } // 开始上传 - await performUpload(filesWithCover) + await performUpload(files) } // 执行上传 -const performUpload = async (filesWithCover) => { +const performUpload = async (files) => { uploading.value = true - totalUploadCount.value = filesWithCover.length + totalUploadCount.value = files.length currentUploadIndex.value = 0 completedFiles.value = [] - // 使用传入的fileCategory,如果没有则使用默认值 - const category = props.fileCategory || DEFAULT_FILE_CATEGORY + // 使用传入的fileCategory + const category = props.fileCategory || 'video' let successCount = 0 let failCount = 0 - for (let i = 0; i < filesWithCover.length; i++) { - const fileWithCover = filesWithCover[i] + for (let i = 0; i < files.length; i++) { + const file = files[i] currentUploadIndex.value = i + 1 - currentFileName.value = fileWithCover.file.name - currentFileSize.value = fileWithCover.file.size + currentFileName.value = file.name + currentFileSize.value = file.size currentFileUploaded.value = 0 currentFileProgress.value = 0 try { - await upload(fileWithCover.file, { + await upload(file, { fileCategory: category, groupId: props.groupId, - coverBase64: fileWithCover.coverBase64, onProgress: (progress) => { currentFileProgress.value = progress currentFileUploaded.value = Math.round((progress / 100) * currentFileSize.value) }, onSuccess: () => { - console.log('文件上传成功:', fileWithCover.file.name) + console.log('文件上传成功:', file.name) successCount++ completedFiles.value.push({ - name: fileWithCover.file.name, + name: file.name, success: true }) }, onError: (error) => { - console.error('文件上传失败:', fileWithCover.file.name, error) + console.error('文件上传失败:', file.name, error) failCount++ completedFiles.value.push({ - name: fileWithCover.file.name, + name: file.name, success: false }) } @@ -400,7 +364,7 @@ const performUpload = async (filesWithCover) => { console.error('上传异常:', error) failCount++ completedFiles.value.push({ - name: fileWithCover.file.name, + name: file.name, success: false }) } diff --git a/frontend/app/web-gold/src/composables/useUpload.js b/frontend/app/web-gold/src/composables/useUpload.js index 7a0f0c9e64..1e05e56572 100644 --- a/frontend/app/web-gold/src/composables/useUpload.js +++ b/frontend/app/web-gold/src/composables/useUpload.js @@ -47,7 +47,6 @@ function getVideoDuration(file) { * @typedef {Object} UploadOptions * @property {string} fileCategory - 文件分类(video/voice/audio/image/digital_human) * @property {number|null} groupId - 分组编号(可选,仅素材库模块使用) - * @property {string|null} coverBase64 - 封面base64(可选) * @property {number|null} duration - 视频时长(可选,视频文件自动获取) * @property {Function} onProgress - 进度回调(可选) * @property {Function} onStart - 开始回调(可选) @@ -132,7 +131,6 @@ export function useUpload() { const { fileCategory, groupId = null, - coverBase64 = null, duration: inputDuration, onProgress, onStart, @@ -193,7 +191,6 @@ export function useUpload() { fileSize: file.size, fileType: file.type, groupId, - coverBase64, duration }) diff --git a/frontend/app/web-gold/src/utils/video-cover.ts b/frontend/app/web-gold/src/utils/video-cover.ts deleted file mode 100644 index 8249e87e23..0000000000 --- a/frontend/app/web-gold/src/utils/video-cover.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * 视频封面提取工具 - * 使用 HTML5 Video API 在浏览器中提取视频第一帧作为封面 - */ - -export interface VideoCoverOptions { - /** 封面最大宽度(默认 800px) */ - maxWidth?: number - /** 封面质量 0-1(默认 0.8) */ - quality?: number - /** 提取时间点(秒),默认取视频的 1/4 位置或第1秒(取较小值) */ - seekTime?: number - /** 超时时间(毫秒,默认 10秒) */ - timeout?: number -} - -export interface VideoCoverResult { - /** 封面 base64 数据(data URI 格式) */ - base64: string - /** 封面宽度 */ - width: number - /** 封面高度 */ - height: number -} - -/** - * 判断文件是否为视频文件 - * @param file 文件对象 - * @returns 是否为视频文件 - */ -export function isVideoFile(file: File): boolean { - return file.type.startsWith('video/') -} - -/** - * 提取视频封面 - * @param file 视频文件 - * @param options 提取选项 - * @returns Promise 封面 base64 数据 - */ -export function extractVideoCover( - file: File, - options: VideoCoverOptions = {} -): Promise { - const { - maxWidth = 800, - quality = 0.8, - seekTime, - timeout = 10000 - } = options - - return new Promise((resolve, reject) => { - const video = document.createElement('video') - video.preload = 'metadata' - video.muted = true - video.playsInline = true - video.setAttribute('webkit-playsinline', 'true') - video.setAttribute('playsinline', 'true') - - const url = URL.createObjectURL(file) - video.src = url - - let timeoutId: ReturnType | null = null - - const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId) - timeoutId = null - } - URL.revokeObjectURL(url) - video.src = '' - video.remove() - } - - const handleError = (error: Error) => { - cleanup() - reject(error) - } - - // 设置超时 - timeoutId = setTimeout(() => { - handleError(new Error('提取封面超时')) - }, timeout) - - video.onloadedmetadata = () => { - try { - // 计算提取时间点 - let targetTime = seekTime - if (targetTime === undefined) { - // 默认取视频的 1/4 位置或第1秒(取较小值),避免黑屏 - targetTime = Math.min(1, video.duration / 4) - } - // 确保时间在有效范围内 - targetTime = Math.max(0, Math.min(targetTime, video.duration - 0.1)) - video.currentTime = targetTime - } catch (error) { - handleError(new Error('设置视频时间点失败')) - } - } - - video.onseeked = () => { - try { - if (timeoutId) { - clearTimeout(timeoutId) - timeoutId = null - } - - const canvas = document.createElement('canvas') - const videoWidth = video.videoWidth - const videoHeight = video.videoHeight - - if (videoWidth === 0 || videoHeight === 0) { - handleError(new Error('无法获取视频尺寸')) - return - } - - // 计算缩放比例,限制最大宽度 - const scale = Math.min(1, maxWidth / videoWidth) - canvas.width = Math.floor(videoWidth * scale) - canvas.height = Math.floor(videoHeight * scale) - - const ctx = canvas.getContext('2d') - if (!ctx) { - handleError(new Error('无法获取 Canvas 上下文')) - return - } - - // 绘制视频帧到 Canvas - ctx.drawImage(video, 0, 0, canvas.width, canvas.height) - - // 转换为 base64(JPEG 格式,指定质量) - const base64 = canvas.toDataURL('image/jpeg', quality) - - cleanup() - - resolve({ - base64, - width: canvas.width, - height: canvas.height - }) - } catch (error) { - handleError(error instanceof Error ? error : new Error('提取封面失败')) - } - } - - video.onerror = () => { - handleError(new Error('视频加载失败')) - } - }) -} - -/** - * 批量提取视频封面 - * @param files 文件数组 - * @param options 提取选项 - * @returns Promise> 文件到封面的映射(key: 文件名或 uid) - */ -export async function extractVideoCovers( - files: File[], - options: VideoCoverOptions = {} -): Promise> { - const result = new Map() - - // 并行提取所有视频封面 - const promises = files - .filter(isVideoFile) - .map(async (file) => { - try { - const cover = await extractVideoCover(file, options) - result.set(file.name, cover) - } catch (error) { - console.warn(`[extractVideoCovers] 提取封面失败: ${file.name}`, error) - // 提取失败不影响其他文件 - } - }) - - await Promise.all(promises) - return result -} - diff --git a/frontend/app/web-gold/src/views/dh/Video.vue b/frontend/app/web-gold/src/views/dh/Video.vue index c09dba0c09..51f0a73904 100644 --- a/frontend/app/web-gold/src/views/dh/Video.vue +++ b/frontend/app/web-gold/src/views/dh/Video.vue @@ -12,7 +12,6 @@ import { InboxOutlined, SoundOutlined, LoadingOutlined } from '@ant-design/icons import { VoiceService } from '@/api/voice' import { MaterialService } from '@/api/material' import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman' -import { extractVideoCover } from '@/utils/video-cover' import { useUpload } from '@/composables/useUpload' import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig' @@ -391,19 +390,6 @@ const handleVideoUpload = async (file) => { try { uploadedVideo.value = await toDataURL(file) uploadedVideoFile.value = file // 保存文件对象 - - // 提取视频封面 - try { - const cover = await extractVideoCover(file, { - maxWidth: 800, - quality: 0.8 - }) - uploadedVideoFile.value.coverBase64 = cover.base64 // 保存封面到文件对象 - } catch (coverError) { - console.warn('视频封面提取失败:', coverError) - // 封面提取失败不影响主流程 - } - message.success('视频上传成功') } catch (error) { console.error('视频上传失败:', error) @@ -513,14 +499,10 @@ const generateVideo = async () => { // 上传视频文件到后端 const uploadVideoFile = async (file) => { try { - // 获取封面base64 - const coverBase64 = file.coverBase64 || null - // 使用useUpload Hook上传文件 const fileId = await upload(file, { fileCategory: 'video', groupId: null, // 数字人视频模块不使用groupId - coverBase64, onStart: () => {}, onProgress: () => {}, onSuccess: (id) => { diff --git a/frontend/app/web-gold/src/views/dh/VoiceCopy.vue b/frontend/app/web-gold/src/views/dh/VoiceCopy.vue index ba5e128326..fb6ea57b75 100644 --- a/frontend/app/web-gold/src/views/dh/VoiceCopy.vue +++ b/frontend/app/web-gold/src/views/dh/VoiceCopy.vue @@ -375,7 +375,6 @@ async function handleCustomUpload(options) { const fileId = await upload(file, { fileCategory: 'voice', groupId: null, - coverBase64: null, onSuccess: async function(id, fileUrl) { formData.fileId = id formData.fileUrl = fileUrl diff --git a/frontend/app/web-gold/src/views/material/MaterialListNew.vue b/frontend/app/web-gold/src/views/material/MaterialListNew.vue index 53a500856b..93e3482026 100644 --- a/frontend/app/web-gold/src/views/material/MaterialListNew.vue +++ b/frontend/app/web-gold/src/views/material/MaterialListNew.vue @@ -152,8 +152,8 @@
diff --git a/frontend/app/web-gold/src/views/material/components/SceneSelectorModal.vue b/frontend/app/web-gold/src/views/material/components/SceneSelectorModal.vue index b9141ab002..9ec6a10c2f 100644 --- a/frontend/app/web-gold/src/views/material/components/SceneSelectorModal.vue +++ b/frontend/app/web-gold/src/views/material/components/SceneSelectorModal.vue @@ -69,7 +69,7 @@ @dblclick="handleQuickConfirm(file)" >
- +
diff --git a/openspec/proposals/video-cover-optimization.md b/openspec/proposals/video-cover-optimization.md new file mode 100644 index 0000000000..23acf3fd4c --- /dev/null +++ b/openspec/proposals/video-cover-optimization.md @@ -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 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 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* diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java index 376804913a..f66dc71acc 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java @@ -52,6 +52,19 @@ public interface FileApi { String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url, Integer expirationSeconds); + /** + * 生成文件预签名地址(带 OSS 处理参数),用于读取 + * 用于阿里云 OSS 视频截帧等图片处理场景 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @param processParam OSS 处理参数,如 "video/snapshot,t_1000,f_jpg,w_300" + * @return 文件预签名地址 + */ + String presignGetUrlWithProcess(@NotEmpty(message = "URL 不能为空") String url, + Integer expirationSeconds, + String processParam); + /** * 获取主文件配置的自定义域名(CDN域名) * diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java index 5cebe9b3d7..b5b5d01083 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java @@ -34,6 +34,11 @@ public class FileApiImpl implements FileApi { return fileService.presignGetUrl(url, expirationSeconds); } + @Override + public String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam) { + return fileService.presignGetUrlWithProcess(url, expirationSeconds, processParam); + } + @Override public String getMasterFileDomain() { FileClient client = fileConfigService.getMasterFileClient(); diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java index a6145a3d26..7653669de0 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java @@ -74,6 +74,24 @@ public interface FileClient { throw new UnsupportedOperationException("不支持的操作"); } + /** + * 生成文件预签名地址(带 OSS 处理参数),用于读取 + * + *

用于阿里云 OSS 视频截帧、图片处理等场景。 + * 将处理参数包含在签名中,避免私有桶访问时返回 403。 + * + *

注意:此方法仅阿里云 OSS 支持,其他云厂商会忽略 processParam 参数。 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @param processParam OSS 处理参数,如 "video/snapshot,t_1000,f_jpg,w_300" + * 参考:https://help.aliyun.com/document_detail/99285.html + * @return 文件预签名地址(处理参数已包含在签名中) + */ + default String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam) { + throw new UnsupportedOperationException("不支持的操作"); + } + /** * 获取文件配置 * diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java index 368dc39757..0d6792e367 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java @@ -34,11 +34,14 @@ import java.util.Date; import org.springframework.web.util.UriUtils; +import lombok.extern.slf4j.Slf4j; + /** * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务 * * @author 芋道源码 */ +@Slf4j public class S3FileClient extends AbstractFileClient { private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); @@ -198,24 +201,44 @@ public class S3FileClient extends AbstractFileClient { @Override public String presignGetUrl(String url, Integer expirationSeconds) { + return presignGetUrlWithProcess(url, expirationSeconds, null); + } + + @Override + public String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam) { // 1. 将 url 转换为 path(支持 CDN 域名和 OSS 原始域名) String path = extractPathFromUrl(url); String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8); - // 2. 公开访问:无需签名 + // 2. 公开访问:无需签名,直接拼接参数 if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8); - return config.getDomain() + "/" + encodedPath; + String resultUrl = config.getDomain() + "/" + encodedPath; + if (StrUtil.isNotBlank(processParam)) { + resultUrl = resultUrl + "?x-oss-process=" + processParam; + } + return resultUrl; } - // 3. 私有访问:生成预签名 URL + // 3. 私有访问:生成预签名 URL(需要将处理参数包含在签名中) int expiration = expirationSeconds != null ? expirationSeconds : (int) EXPIRATION_DEFAULT.getSeconds(); String signedUrl; if (isAliyun) { Date expirationDate = new Date(System.currentTimeMillis() + expiration * 1000L); - signedUrl = aliyunOssClient.generatePresignedUrl(config.getBucket(), decodedPath, expirationDate).toString(); + com.aliyun.oss.model.GeneratePresignedUrlRequest request = + new com.aliyun.oss.model.GeneratePresignedUrlRequest(config.getBucket(), decodedPath, HttpMethod.GET); + request.setExpiration(expirationDate); + // 关键:将 x-oss-process 参数包含在签名中 + if (StrUtil.isNotBlank(processParam)) { + request.addQueryParameter("x-oss-process", processParam); + } + signedUrl = aliyunOssClient.generatePresignedUrl(request).toString(); } else { + // 非阿里云不支持 OSS 处理参数,直接返回普通预签名 URL + if (StrUtil.isNotBlank(processParam)) { + log.warn("[presignGetUrlWithProcess] 非阿里云 OSS 不支持处理参数,已忽略。processParam={}", processParam); + } Duration duration = Duration.ofSeconds(expiration); signedUrl = s3Presigner.presignGetObject(GetObjectPresignRequest.builder() .signatureDuration(duration) diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java index 7edd72daf2..c7f8d99a6b 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java @@ -54,6 +54,17 @@ public interface FileService { */ String presignGetUrl(String url, Integer expirationSeconds); + /** + * 生成文件预签名地址(带 OSS 处理参数),用于读取 + * 用于阿里云 OSS 视频截帧等图片处理场景 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @param processParam OSS 处理参数,如 "video/snapshot,t_1000,f_jpg,w_300" + * @return 文件预签名地址 + */ + String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam); + /** * 创建文件 * diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java index 0ee59075ab..9a4f90733d 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java @@ -146,6 +146,12 @@ public class FileServiceImpl implements FileService { return fileClient.presignGetUrl(url, expirationSeconds); } + @Override + public String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam) { + FileClient fileClient = fileConfigService.getMasterFileClient(); + return fileClient.presignGetUrlWithProcess(url, expirationSeconds, processParam); + } + @Override public Long createFile(FileCreateReqVO createReqVO) { createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java index 670a1141e4..fbf3a1a48a 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java @@ -43,13 +43,11 @@ public class AppTikUserFileController { @RequestParam("file") MultipartFile file, @Parameter(description = "文件分类(video/generate/audio/mix/voice)", required = true) @RequestParam("fileCategory") String fileCategory, - @Parameter(description = "视频封面 base64(可选,data URI 格式)") - @RequestParam(value = "coverBase64", required = false) String coverBase64, @Parameter(description = "视频时长(秒)") @RequestParam(value = "duration", required = false) Integer duration, @Parameter(description = "分组编号(可选)") @RequestParam(value = "groupId", required = false) Long groupId) { - return success(userFileService.uploadFile(file, fileCategory, coverBase64, duration, groupId)); + return success(userFileService.uploadFile(file, fileCategory, duration, groupId)); } @GetMapping("/page") diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java index f58a443b1c..1073ef9ccd 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java @@ -59,14 +59,6 @@ public class TikUserFileDO extends TenantBaseDO { * 文件存储路径 */ private String filePath; - /** - * 封面图URL(视频文件的封面图) - */ - private String coverUrl; - /** - * 封面图Base64(视频文件的封面图原始base64数据,可选) - */ - private String coverBase64; /** * 缩略图URL(图片文件的缩略图) */ diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java index c8c61d8784..c76921ccd2 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java @@ -19,12 +19,11 @@ public interface TikUserFileService { * * @param file 文件 * @param fileCategory 文件分类(video/generate/audio/mix/voice) - * @param coverBase64 视频封面 base64(可选,data URI 格式) * @param duration 视频时长(秒,可选) * @param groupId 分组编号(可选) * @return 文件编号 */ - Long uploadFile(MultipartFile file, String fileCategory, String coverBase64, Integer duration, Long groupId); + Long uploadFile(MultipartFile file, String fileCategory, Integer duration, Long groupId); /** * 分页查询文件列表 @@ -75,6 +74,17 @@ public interface TikUserFileService { */ String getCachedPresignUrl(String url, Integer expirationSeconds); + /** + * 获取缓存的预签名URL(带 OSS 处理参数,带 Redis 缓存) + * 用于阿里云 OSS 视频截帧等图片处理场景 + * + * @param url 文件URL + * @param expirationSeconds 过期时间(秒) + * @param processParam OSS 处理参数,如 "video/snapshot,t_1000,f_jpg,w_300" + * @return 预签名URL + */ + String getCachedPresignUrlWithProcess(String url, Integer expirationSeconds, String processParam); + /** * 获取预签名URL(直传模式) * diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java index 1c3573004e..5b7cd9e440 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java @@ -74,7 +74,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { private FileConfigService fileConfigService; @Override - public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64, Integer duration, Long groupId) { + public Long uploadFile(MultipartFile file, String fileCategory, Integer duration, Long groupId) { Long userId = SecurityFrameworkUtils.getLoginUserId(); // 校验文件分类和配额 @@ -88,7 +88,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { // 保存数据库 return saveFileRecord(userId, file, fileCategory, context.fileUrl, context.filePath, - coverBase64, baseDirectory, context.infraFileId, duration, groupId); + context.infraFileId, duration, groupId); } /** @@ -179,44 +179,19 @@ public class TikUserFileServiceImpl implements TikUserFileService { } } - /** - * 处理视频封面上传 - */ - private String handleCoverUpload(String coverBase64, String fileName, String fileType, String baseDirectory) { - if (StrUtil.isBlank(coverBase64) || !StrUtil.containsIgnoreCase(fileType, "video")) { - return null; - } - try { - String base64Data = coverBase64.contains(",") - ? coverBase64.substring(coverBase64.indexOf(",") + 1) - : coverBase64; - byte[] coverBytes = java.util.Base64.getDecoder().decode(base64Data); - String coverFileName = fileName.replaceFirst("\\.[^.]+$", "_cover.jpg"); - String uploadedUrl = fileApi.createFile(coverBytes, coverFileName, baseDirectory, "image/jpeg"); - if (StrUtil.isNotBlank(uploadedUrl) && !uploadedUrl.contains("data:image")) { - return HttpUtils.removeUrlQuery(uploadedUrl); - } - } catch (Exception e) { - log.warn("[handleCoverUpload][视频封面处理失败: {}]", e.getMessage()); - } - return null; - } - /** * 保存文件记录到数据库 */ @Transactional(rollbackFor = Exception.class) public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory, - String fileUrl, String filePath, String coverBase64, - String baseDirectory, Long infraFileId, Integer duration, Long groupId) { + String fileUrl, String filePath, + Long infraFileId, Integer duration, Long groupId) { if (infraFileId == null) { log.error("[saveFileRecord][infra_file.id 为空,用户({}),URL({})]", userId, fileUrl); throw exception(FILE_NOT_EXISTS, "文件记录保存失败:无法获取文件ID"); } String fileName = file.getOriginalFilename(); - String fileType = file.getContentType(); - String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory); String displayName = FileUtil.mainName(fileName); TikUserFileDO userFile = new TikUserFileDO() @@ -224,13 +199,11 @@ public class TikUserFileServiceImpl implements TikUserFileService { .setFileId(infraFileId) .setFileName(fileName) .setDisplayName(displayName) - .setFileType(fileType) + .setFileType(file.getContentType()) .setFileCategory(fileCategory) .setFileSize(file.getSize()) .setFileUrl(fileUrl) .setFilePath(filePath) - .setCoverUrl(coverUrl) - .setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null) .setDuration(duration) .setGroupId(groupId); @@ -278,11 +251,19 @@ public class TikUserFileServiceImpl implements TikUserFileService { vo.setIsVideo(isVideo); vo.setIsImage(isImage); - // 视频文件不生成OSS预签名URL(前端使用coverBase64缓存) + // 视频文件:使用 OSS 截帧 if (isVideo) { - vo.setCoverUrl(null); - vo.setThumbnailUrl(null); - vo.setPreviewUrl(null); + // 使用 OSS 视频截帧作为封面(预签名时包含截帧参数,避免 403) + // t_1000: 截取1秒处(避免开头黑屏/非关键帧) + // f_jpg: 输出JPG格式 + // w_300: 宽度300px + if (StrUtil.isNotBlank(file.getFileUrl())) { + String snapshotUrl = getCachedPresignUrlWithProcess( + file.getFileUrl(), + PRESIGN_URL_EXPIRATION_SECONDS, + "video/snapshot,t_1000,f_jpg,w_300"); + vo.setPreviewUrl(snapshotUrl); + } return vo; } @@ -290,7 +271,6 @@ public class TikUserFileServiceImpl implements TikUserFileService { String thumbnailUrl = StrUtil.isNotBlank(file.getThumbnailUrl()) ? getCachedPresignUrl(file.getThumbnailUrl(), PRESIGN_URL_EXPIRATION_SECONDS) : null; - vo.setCoverUrl(null); vo.setThumbnailUrl(thumbnailUrl); // 图片预览URL:优先缩略图,否则原图 @@ -381,15 +361,12 @@ public class TikUserFileServiceImpl implements TikUserFileService { * 确定预览URL */ private String determinePreviewUrl(TikUserFileDO file, String type) { - // 视频文件:不返回任何OSS URL(前端使用coverBase64缓存) + // 视频文件:使用 OSS 截帧,通过 getFilePage 返回 previewUrl if (StrUtil.containsIgnoreCase(file.getFileType(), "video")) { return null; } // 明确指定类型 - if ("cover".equals(type) && StrUtil.isNotBlank(file.getCoverUrl())) { - return file.getCoverUrl(); - } if ("thumbnail".equals(type) && StrUtil.isNotBlank(file.getThumbnailUrl())) { return file.getThumbnailUrl(); } @@ -402,7 +379,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { } @Override - @Cacheable(value = "tik:file:presign", + @Cacheable(value = "tik:file:presign#23h", key = "#url + ':' + (#expirationSeconds != null ? #expirationSeconds : 86400)") public String getCachedPresignUrl(String url, Integer expirationSeconds) { if (StrUtil.isBlank(url)) { @@ -411,6 +388,18 @@ public class TikUserFileServiceImpl implements TikUserFileService { return fileApi.presignGetUrl(url, expirationSeconds != null ? expirationSeconds : 86400); } + @Override + @Cacheable(value = "tik:file:presign:process#23h", + key = "#url + ':' + (#expirationSeconds != null ? #expirationSeconds : 86400) + ':' + #processParam") + public String getCachedPresignUrlWithProcess(String url, Integer expirationSeconds, String processParam) { + if (StrUtil.isBlank(url)) { + return null; + } + return fileApi.presignGetUrlWithProcess(url, + expirationSeconds != null ? expirationSeconds : 86400, + processParam); + } + @Override public Object getPresignedUrl(String fileName, String fileCategory, Long groupId, Long fileSize, String contentType) { Long userId = SecurityFrameworkUtils.getLoginUserId(); @@ -490,7 +479,6 @@ public class TikUserFileServiceImpl implements TikUserFileService { Long fileSize = params.get("fileSize") != null ? ((Number) params.get("fileSize")).longValue() : 0L; String fileType = (String) params.get("fileType"); Long groupId = params.get("groupId") != null ? ((Number) params.get("groupId")).longValue() : null; - String coverBase64 = (String) params.get("coverBase64"); Integer duration = params.get("duration") != null ? ((Number) params.get("duration")).intValue() : null; validateUploadRequest(fileCategory, fileSize); @@ -509,8 +497,6 @@ public class TikUserFileServiceImpl implements TikUserFileService { fileMapper.insert(infraFile); Long infraFileId = infraFile.getId(); - String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory); - String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory); String displayName = FileUtil.mainName(fileName); TikUserFileDO userFile = new TikUserFileDO() @@ -523,8 +509,6 @@ public class TikUserFileServiceImpl implements TikUserFileService { .setFileSize(fileSize) .setFileUrl(fileUrl) .setFilePath(fileKey) - .setCoverUrl(coverUrl) - .setCoverBase64(coverBase64) .setDuration(duration) .setGroupId(groupId); userFileMapper.insert(userFile); diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java index e890f1d09f..927117fe05 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java @@ -47,12 +47,6 @@ public class AppTikUserFileRespVO { @Schema(description = "是否为图片文件", example = "false") private Boolean isImage; - @Schema(description = "封面图URL(视频文件的封面图)") - private String coverUrl; - - @Schema(description = "封面图Base64(视频文件的封面图原始base64数据,可选)") - private String coverBase64; - @Schema(description = "缩略图URL(图片文件的缩略图)") private String thumbnailUrl;