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:
@@ -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 || '上传失败')
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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('获取视频地址失败,请重试')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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<VideoCoverResult> 封面 base64 数据
|
||||
*/
|
||||
export function extractVideoCover(
|
||||
file: File,
|
||||
options: VideoCoverOptions = {}
|
||||
): Promise<VideoCoverResult> {
|
||||
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<typeof setTimeout> | 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<Map<string, VideoCoverResult>> 文件到封面的映射(key: 文件名或 uid)
|
||||
*/
|
||||
export async function extractVideoCovers(
|
||||
files: File[],
|
||||
options: VideoCoverOptions = {}
|
||||
): Promise<Map<string, VideoCoverResult>> {
|
||||
const result = new Map<string, VideoCoverResult>()
|
||||
|
||||
// 并行提取所有视频封面
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -152,8 +152,8 @@
|
||||
<!-- 预览图 -->
|
||||
<div class="material-item__preview">
|
||||
<img
|
||||
v-if="file.coverBase64"
|
||||
:src="file.coverBase64"
|
||||
v-if="file.previewUrl"
|
||||
:src="file.previewUrl"
|
||||
:alt="file.fileName"
|
||||
@error="handleImageError"
|
||||
loading="lazy"
|
||||
|
||||
@@ -159,8 +159,8 @@
|
||||
<template #item="{ element: candidate, index: cIndex }">
|
||||
<div class="candidate-thumb">
|
||||
<img
|
||||
v-if="getFileById(candidate.fileId)?.coverBase64"
|
||||
:src="getFileById(candidate.fileId).coverBase64"
|
||||
v-if="getFileById(candidate.fileId)?.previewUrl"
|
||||
:src="getFileById(candidate.fileId).previewUrl"
|
||||
/>
|
||||
<div v-else class="thumb-placeholder">
|
||||
<VideoCameraOutlined />
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
@dblclick="handleQuickConfirm(file)"
|
||||
>
|
||||
<div class="card-cover">
|
||||
<img v-if="file.coverBase64" :src="file.coverBase64" />
|
||||
<img v-if="file.previewUrl" :src="file.previewUrl" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
|
||||
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*
|
||||
@@ -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域名)
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -74,6 +74,24 @@ public interface FileClient {
|
||||
throw new UnsupportedOperationException("不支持的操作");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件预签名地址(带 OSS 处理参数),用于读取
|
||||
*
|
||||
* <p>用于阿里云 OSS 视频截帧、图片处理等场景。
|
||||
* 将处理参数包含在签名中,避免私有桶访问时返回 403。
|
||||
*
|
||||
* <p><b>注意</b>:此方法仅阿里云 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("不支持的操作");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件配置
|
||||
*
|
||||
|
||||
@@ -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<S3FileClientConfig> {
|
||||
|
||||
private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24);
|
||||
@@ -198,24 +201,44 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
|
||||
@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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
* 创建文件
|
||||
*
|
||||
|
||||
@@ -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 的签名参数
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -59,14 +59,6 @@ public class TikUserFileDO extends TenantBaseDO {
|
||||
* 文件存储路径
|
||||
*/
|
||||
private String filePath;
|
||||
/**
|
||||
* 封面图URL(视频文件的封面图)
|
||||
*/
|
||||
private String coverUrl;
|
||||
/**
|
||||
* 封面图Base64(视频文件的封面图原始base64数据,可选)
|
||||
*/
|
||||
private String coverBase64;
|
||||
/**
|
||||
* 缩略图URL(图片文件的缩略图)
|
||||
*/
|
||||
|
||||
@@ -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(直传模式)
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user