send-stream
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -17,152 +17,73 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
// 当前显示的内容(用于打字机效果)
|
||||
const displayedContent = ref('')
|
||||
// 目标内容(完整内容)
|
||||
const targetContent = ref('')
|
||||
// 动画帧 ID
|
||||
let animationFrameId = null
|
||||
// 打字机速度(字符/帧,可根据性能调整)
|
||||
const TYPING_SPEED = 3
|
||||
// 是否正在执行打字机动画
|
||||
const isTyping = ref(false)
|
||||
|
||||
/**
|
||||
* 高性能打字机效果渲染
|
||||
* 使用 requestAnimationFrame 实现平滑的逐字符显示
|
||||
*/
|
||||
function typewriterEffect() {
|
||||
if (!isTyping.value) return
|
||||
|
||||
const currentLength = displayedContent.value.length
|
||||
const targetLength = targetContent.value.length
|
||||
|
||||
if (currentLength >= targetLength) {
|
||||
// 已完成,停止动画
|
||||
isTyping.value = false
|
||||
displayedContent.value = targetContent.value
|
||||
return
|
||||
}
|
||||
|
||||
// 每次增加多个字符以提高性能
|
||||
const nextLength = Math.min(currentLength + TYPING_SPEED, targetLength)
|
||||
displayedContent.value = targetContent.value.slice(0, nextLength)
|
||||
|
||||
// 继续下一帧
|
||||
animationFrameId = requestAnimationFrame(typewriterEffect)
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始打字机效果
|
||||
*/
|
||||
function startTypewriter() {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
|
||||
isTyping.value = true
|
||||
animationFrameId = requestAnimationFrame(typewriterEffect)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止打字机效果
|
||||
*/
|
||||
function stopTypewriter() {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
isTyping.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算渲染的 HTML 内容
|
||||
*/
|
||||
// 当前渲染的内容(避免重复渲染)
|
||||
const currentContent = ref('')
|
||||
// 渲染的 HTML 内容
|
||||
const renderedContent = ref('')
|
||||
|
||||
/**
|
||||
* 更新渲染内容
|
||||
* 只有当内容真正改变时才更新,避免重复渲染
|
||||
*/
|
||||
function updateRenderedContent() {
|
||||
const content = displayedContent.value
|
||||
const content = currentContent.value
|
||||
|
||||
// 避免重复渲染相同内容
|
||||
if (content === renderedContent.value.replace(/<[^>]*>/g, '')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
renderedContent.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 渲染 markdown 为 HTML
|
||||
renderedContent.value = renderMarkdown(content)
|
||||
}
|
||||
|
||||
// 监听 displayedContent 变化,更新渲染
|
||||
watch(displayedContent, () => {
|
||||
updateRenderedContent()
|
||||
}, { immediate: true })
|
||||
|
||||
/**
|
||||
* 处理内容更新
|
||||
* 普通流式渲染:直接显示所有内容,不使用打字机效果
|
||||
*/
|
||||
function handleContentUpdate(newContent) {
|
||||
if (!newContent) {
|
||||
targetContent.value = ''
|
||||
displayedContent.value = ''
|
||||
stopTypewriter()
|
||||
currentContent.value = ''
|
||||
updateRenderedContent()
|
||||
return
|
||||
}
|
||||
|
||||
// 更新目标内容
|
||||
targetContent.value = newContent
|
||||
|
||||
if (props.isStreaming) {
|
||||
// 流式传输模式:使用打字机效果显示内容
|
||||
// 流式传输时,内容会逐步到达,使用打字机效果增强体验
|
||||
const currentLength = displayedContent.value.length
|
||||
const newLength = newContent.length
|
||||
|
||||
if (newLength !== currentLength) {
|
||||
// 内容发生变化
|
||||
if (newLength < currentLength) {
|
||||
// 内容被重置或缩短,直接显示新内容
|
||||
displayedContent.value = newContent
|
||||
stopTypewriter()
|
||||
} else {
|
||||
// 内容增加,使用打字机效果显示新增部分
|
||||
if (!isTyping.value) {
|
||||
startTypewriter()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 静态内容模式:直接显示全部内容,不使用打字机效果
|
||||
displayedContent.value = newContent
|
||||
stopTypewriter()
|
||||
}
|
||||
|
||||
// 更新当前内容
|
||||
currentContent.value = newContent
|
||||
updateRenderedContent()
|
||||
}
|
||||
|
||||
// 监听 content 变化
|
||||
// 监听 content 变化,使用防抖处理避免频繁更新
|
||||
let updateTimeout = null
|
||||
watch(() => props.content, (newContent) => {
|
||||
handleContentUpdate(newContent)
|
||||
}, { immediate: true })
|
||||
// 清除之前的定时器
|
||||
if (updateTimeout) {
|
||||
clearTimeout(updateTimeout)
|
||||
}
|
||||
|
||||
// 延迟更新,避免流式传输时频繁更新导致的性能问题
|
||||
updateTimeout = setTimeout(() => {
|
||||
handleContentUpdate(newContent)
|
||||
}, 50) // 50ms 防抖
|
||||
})
|
||||
|
||||
// 监听 isStreaming 变化
|
||||
watch(() => props.isStreaming, (newVal, oldVal) => {
|
||||
if (!newVal && oldVal) {
|
||||
// 流式传输结束,确保显示完整内容,停止打字机效果
|
||||
if (targetContent.value) {
|
||||
displayedContent.value = targetContent.value
|
||||
}
|
||||
stopTypewriter()
|
||||
} else if (newVal) {
|
||||
// 开始流式传输,如果内容有变化,启动打字机效果
|
||||
// 打字机效果会在 handleContentUpdate 中根据内容变化自动启动
|
||||
// 流式传输结束时,确保显示完整内容
|
||||
if (!newVal && oldVal && props.content) {
|
||||
currentContent.value = props.content
|
||||
updateRenderedContent()
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
stopTypewriter()
|
||||
})
|
||||
// 立即渲染初始内容
|
||||
handleContentUpdate(props.content)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -55,7 +55,6 @@ const routes = [
|
||||
]
|
||||
},
|
||||
{ path: '/realtime-hot', name: '实时热点推送', component: () => import('../views/realtime/RealtimeHot.vue') },
|
||||
{ path: '/mix-editor', name: '素材混剪', component: () => import('../views/mix/MixEditor.vue') },
|
||||
{ path: '/capcut-import', name: '剪映导入', component: () => import('../views/capcut/CapcutImport.vue') },
|
||||
{ path: '/help', name: '帮助', component: () => import('../views/misc/Help.vue') },
|
||||
{ path: '/download', name: '下载', component: () => import('../views/misc/Download.vue') },
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import storage from '@/utils/storage'
|
||||
|
||||
const STORAGE_KEY = 'cosy_voice_profiles'
|
||||
import { VoiceService } from '@/api/voice'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
export const useVoiceCopyStore = defineStore('voiceCopy', {
|
||||
state: () => ({
|
||||
profiles: [],
|
||||
activeId: '',
|
||||
loaded: false,
|
||||
loading: false
|
||||
}),
|
||||
getters: {
|
||||
activeProfile(state) {
|
||||
@@ -15,48 +15,158 @@ export const useVoiceCopyStore = defineStore('voiceCopy', {
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
generateId() {
|
||||
return `${Date.now()}_${Math.floor(Math.random() * 1e6)}`
|
||||
},
|
||||
/**
|
||||
* 加载配音列表
|
||||
*/
|
||||
async load() {
|
||||
if (this.loaded) return
|
||||
const list = await storage.getJSON(STORAGE_KEY, [])
|
||||
this.profiles = Array.isArray(list) ? list : []
|
||||
if (!this.activeId && this.profiles.length) this.activeId = this.profiles[0].id
|
||||
this.loaded = true
|
||||
},
|
||||
async persist() {
|
||||
await storage.setJSON(STORAGE_KEY, this.profiles)
|
||||
if (this.loaded && !this.loading) return
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await VoiceService.getPage({
|
||||
pageNo: 1,
|
||||
pageSize: 100 // 加载所有数据
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
this.profiles = (res.data.list || []).map((item) => ({
|
||||
...item,
|
||||
voiceId: item.voiceId || '',
|
||||
transcription: item.transcription || '',
|
||||
fileUrl: item.fileUrl || ''
|
||||
}))
|
||||
// 如果有数据且没有选中项,选中第一个
|
||||
if (!this.activeId && this.profiles.length > 0) {
|
||||
this.activeId = this.profiles[0].id
|
||||
}
|
||||
this.loaded = true
|
||||
} else {
|
||||
message.error(res.msg || '加载失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配音列表失败:', error)
|
||||
message.error('加载失败,请稍后重试')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加配音
|
||||
*/
|
||||
async add(profile) {
|
||||
const id = this.generateId()
|
||||
const name = profile.name || `克隆语音-${this.profiles.length + 1}`
|
||||
const payload = { ...profile, id, name }
|
||||
this.profiles.unshift(payload)
|
||||
this.activeId = id
|
||||
await this.persist()
|
||||
return payload
|
||||
try {
|
||||
const res = await VoiceService.create({
|
||||
name: profile.name || `克隆语音-${this.profiles.length + 1}`,
|
||||
fileId: profile.fileId,
|
||||
autoTranscribe: profile.autoTranscribe || false,
|
||||
language: profile.language || 'zh-CN',
|
||||
gender: profile.gender || 'female',
|
||||
note: profile.note || ''
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
const newProfile = {
|
||||
...profile,
|
||||
id: res.data
|
||||
}
|
||||
this.profiles.unshift(newProfile)
|
||||
this.activeId = newProfile.id
|
||||
await this.load() // 重新加载以获取完整数据
|
||||
return newProfile
|
||||
} else {
|
||||
message.error(res.msg || '创建失败')
|
||||
throw new Error(res.msg || '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加配音失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新配音
|
||||
*/
|
||||
async update(profile) {
|
||||
const idx = this.profiles.findIndex(p => p.id === profile.id)
|
||||
if (idx === -1) return await this.add({ ...profile, id: '' })
|
||||
this.profiles[idx] = { ...profile }
|
||||
await this.persist()
|
||||
return this.profiles[idx]
|
||||
if (!profile.id) {
|
||||
return await this.add(profile)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await VoiceService.update({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
language: profile.language,
|
||||
gender: profile.gender,
|
||||
note: profile.note
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
const index = this.profiles.findIndex(p => p.id === profile.id)
|
||||
if (index > -1) {
|
||||
// 重新加载以获取最新数据
|
||||
await this.load()
|
||||
const updated = this.profiles.find(p => p.id === profile.id)
|
||||
return updated || profile
|
||||
}
|
||||
return profile
|
||||
} else {
|
||||
message.error(res.msg || '更新失败')
|
||||
throw new Error(res.msg || '更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新配音失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 复制配音
|
||||
*/
|
||||
async duplicate(profile, name) {
|
||||
const copy = { ...profile, id: '', name }
|
||||
const copy = {
|
||||
...profile,
|
||||
id: null,
|
||||
name: name || `${profile.name}-副本`
|
||||
}
|
||||
return await this.add(copy)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除配音
|
||||
*/
|
||||
async remove(id) {
|
||||
this.profiles = this.profiles.filter(p => p.id !== id)
|
||||
if (this.activeId === id) this.activeId = this.profiles[0]?.id || ''
|
||||
await this.persist()
|
||||
try {
|
||||
const res = await VoiceService.delete(id)
|
||||
|
||||
if (res.code === 0) {
|
||||
this.profiles = this.profiles.filter(p => p.id !== id)
|
||||
if (this.activeId === id) {
|
||||
this.activeId = this.profiles[0]?.id || ''
|
||||
}
|
||||
} else {
|
||||
message.error(res.msg || '删除失败')
|
||||
throw new Error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除配音失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 选择配音
|
||||
*/
|
||||
select(id) {
|
||||
this.activeId = id
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
async refresh() {
|
||||
this.loaded = false
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function extractVideoCover(
|
||||
const url = URL.createObjectURL(file)
|
||||
video.src = url
|
||||
|
||||
let timeoutId: NodeJS.Timeout | null = null
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-bold">生成数字人</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<section class="bg-white p-4 rounded shadow lg:col-span-1">
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<section class="p-4 bg-white rounded shadow lg:col-span-1">
|
||||
<div class="space-y-3">
|
||||
<div class="text-gray-600 text-sm">形象、背景、脚本、分辨率、字幕等配置。</div>
|
||||
<button class="px-4 py-2 bg-purple-600 text-white rounded">生成视频</button>
|
||||
<div class="text-sm text-gray-600">形象、背景、脚本、分辨率、字幕等配置。</div>
|
||||
<button class="px-4 py-2 text-white bg-purple-600 rounded">生成视频</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="bg-white p-4 rounded shadow lg:col-span-2">
|
||||
<section class="p-4 bg-white rounded shadow lg:col-span-2">
|
||||
<div class="text-gray-500">视频预览、任务队列、渲染进度</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,14 @@
|
||||
</template>
|
||||
上传素材
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
:disabled="selectedFileIds.length === 0"
|
||||
@click="handleOpenMixModal"
|
||||
>
|
||||
素材混剪
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="selectedFileIds.length > 0"
|
||||
type="primary"
|
||||
@@ -142,11 +150,47 @@
|
||||
@confirm="handleConfirmUpload"
|
||||
@cancel="handleUploadCancel"
|
||||
/>
|
||||
<a-modal
|
||||
v-model:open="mixModalVisible"
|
||||
title="素材混剪"
|
||||
centered
|
||||
:confirm-loading="mixing"
|
||||
ok-text="提交混剪"
|
||||
cancel-text="取消"
|
||||
@ok="handleMixConfirm"
|
||||
@cancel="handleMixCancel"
|
||||
>
|
||||
<div class="mix-modal__summary">
|
||||
<p>选中素材:{{ selectedFiles.length }} 个</p>
|
||||
<p>视频素材:{{ selectedVideoUrls.length }} 个</p>
|
||||
<p>背景音乐:{{ selectedAudioUrls.length }} 个</p>
|
||||
</div>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="视频标题" required>
|
||||
<a-input v-model:value="mixForm.title" placeholder="请输入生成视频标题" />
|
||||
</a-form-item>
|
||||
<a-form-item label="文案内容" required>
|
||||
<a-textarea
|
||||
v-model:value="mixForm.text"
|
||||
placeholder="请输入文案(每句话换行以便自动拆分)"
|
||||
:rows="4"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="生成成片数量" required>
|
||||
<a-input-number
|
||||
v-model:value="mixForm.produceCount"
|
||||
:min="1"
|
||||
:max="10"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
UploadOutlined,
|
||||
@@ -154,6 +198,7 @@ import {
|
||||
FileOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { MaterialService } from '@/api/material'
|
||||
import { MixService } from '@/api/mix'
|
||||
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue'
|
||||
import { formatFileSize, formatDate } from '@/utils/file'
|
||||
|
||||
@@ -163,6 +208,8 @@ const fileList = ref([])
|
||||
const selectedFileIds = ref([])
|
||||
const uploadModalVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const mixModalVisible = ref(false)
|
||||
const mixing = ref(false)
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
@@ -370,6 +417,111 @@ const handleImageError = (e) => {
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
const selectedFiles = computed(() =>
|
||||
fileList.value.filter((file) => selectedFileIds.value.includes(file.id))
|
||||
)
|
||||
|
||||
const isVideoFile = (file) => {
|
||||
if (!file) return false
|
||||
if (file.isVideo) return true
|
||||
if (file.fileCategory === 'video') return true
|
||||
if (typeof file.fileType === 'string' && file.fileType.startsWith('video')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const isAudioFile = (file) => {
|
||||
if (!file) return false
|
||||
if (file.fileCategory === 'audio') return true
|
||||
if (file.fileType === 'audio') return true
|
||||
if (typeof file.fileType === 'string' && file.fileType.startsWith('audio')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedVideoUrls = computed(() =>
|
||||
selectedFiles.value.map((file) => (isVideoFile(file) ? file?.fileUrl || file?.previewUrl : null)).filter(Boolean)
|
||||
)
|
||||
|
||||
const selectedAudioUrls = computed(() =>
|
||||
selectedFiles.value.map((file) => (isAudioFile(file) ? file?.fileUrl || file?.previewUrl : null)).filter(Boolean)
|
||||
)
|
||||
|
||||
const mixForm = reactive({
|
||||
title: '',
|
||||
text: '',
|
||||
produceCount: 1
|
||||
})
|
||||
|
||||
const resetMixForm = () => {
|
||||
mixForm.title = ''
|
||||
mixForm.text = ''
|
||||
mixForm.produceCount = 1
|
||||
}
|
||||
|
||||
const handleOpenMixModal = () => {
|
||||
if (selectedFileIds.value.length === 0) {
|
||||
message.warning('请先选择至少一个素材')
|
||||
return
|
||||
}
|
||||
if (selectedVideoUrls.value.length === 0) {
|
||||
message.warning('请至少选择一个视频素材')
|
||||
return
|
||||
}
|
||||
if (selectedAudioUrls.value.length === 0) {
|
||||
message.warning('请至少选择一个背景音乐素材')
|
||||
return
|
||||
}
|
||||
mixModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleMixCancel = () => {
|
||||
mixModalVisible.value = false
|
||||
}
|
||||
|
||||
const handleMixConfirm = async () => {
|
||||
const title = mixForm.title.trim()
|
||||
const text = mixForm.text.trim()
|
||||
if (!title) {
|
||||
message.warning('请输入视频标题')
|
||||
return
|
||||
}
|
||||
if (!text) {
|
||||
message.warning('请输入文案内容')
|
||||
return
|
||||
}
|
||||
const produceCount = Math.max(1, Math.min(10, Number(mixForm.produceCount) || 1))
|
||||
if (selectedVideoUrls.value.length === 0) {
|
||||
message.warning('请至少选择一个视频素材')
|
||||
return
|
||||
}
|
||||
if (selectedAudioUrls.value.length === 0) {
|
||||
message.warning('请至少选择一个背景音乐素材')
|
||||
return
|
||||
}
|
||||
mixing.value = true
|
||||
try {
|
||||
const { data } = await MixService.batchProduceAlignment({
|
||||
title,
|
||||
text,
|
||||
videoUrls: selectedVideoUrls.value,
|
||||
bgMusicUrls: selectedAudioUrls.value,
|
||||
produceCount
|
||||
})
|
||||
const jobIds = Array.isArray(data) ? data : []
|
||||
message.success(
|
||||
jobIds.length > 0
|
||||
? `混剪任务提交成功,JobId:${jobIds.join(', ')}`
|
||||
: '混剪任务提交成功'
|
||||
)
|
||||
mixModalVisible.value = false
|
||||
resetMixForm()
|
||||
} catch (error) {
|
||||
console.error('混剪失败:', error)
|
||||
message.error(error?.message || '混剪任务提交失败,请重试')
|
||||
} finally {
|
||||
mixing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadFileList()
|
||||
@@ -524,5 +676,20 @@ onMounted(() => {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.mix-modal__summary {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--color-bg-2);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.mix-modal__summary p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-bold">素材混剪</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<section class="bg-white p-4 rounded shadow lg:col-span-1">
|
||||
<div class="text-gray-600 text-sm">文案拆解与镜头建议。</div>
|
||||
</section>
|
||||
<section class="bg-white p-4 rounded shadow lg:col-span-2">
|
||||
<div class="text-gray-500">素材匹配与时间线,导出到剪映。</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.s3;
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.core.util.URLUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
||||
@@ -23,6 +24,9 @@ import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignReques
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.time.Duration;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.springframework.web.util.UriUtils;
|
||||
|
||||
/**
|
||||
* 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务
|
||||
@@ -115,15 +119,17 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
// 1. 将 url 转换为 path
|
||||
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
|
||||
path = HttpUtils.removeUrlQuery(path);
|
||||
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
|
||||
|
||||
// 2.1 情况一:公开访问:无需签名
|
||||
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
|
||||
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
|
||||
return config.getDomain() + "/" + path;
|
||||
String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8);
|
||||
return config.getDomain() + "/" + encodedPath;
|
||||
}
|
||||
|
||||
// 2.2 情况二:私有访问:生成 GET 预签名 URL
|
||||
String finalPath = path;
|
||||
String finalPath = decodedPath;
|
||||
Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT;
|
||||
URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder()
|
||||
.signatureDuration(expiration)
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
# Tik 文件管理模块设计文档
|
||||
|
||||
## 一、模块概述
|
||||
|
||||
Tik 文件管理模块负责用户文件的上传、存储、管理和分组功能,支持多种文件类型(视频、图片、音频等)和分类管理。
|
||||
|
||||
## 二、表结构设计
|
||||
|
||||
### 2.1 核心表
|
||||
|
||||
#### 1. `tik_user_file` - 用户文件表
|
||||
**作用**:存储用户上传的文件元数据
|
||||
|
||||
**关键字段**:
|
||||
- `file_path` (varchar(1024)): **完整OSS路径**,格式:`{手机号MD5}/{租户ID}/{分类}/{日期}/{文件名}_{时间戳}.ext`
|
||||
- `file_url` (varchar(1024)): 文件访问URL(预签名URL或公开URL)
|
||||
- `oss_root_path` (varchar(256)): OSS根路径,用于快速定位用户文件目录
|
||||
- `file_category`: 文件分类(video/generate/audio/mix/voice)
|
||||
- `file_id`: 关联 `infra_file.id`(可选,用于关联系统文件表)
|
||||
|
||||
**索引设计**:
|
||||
- `idx_user_id`: 用户ID索引
|
||||
- `idx_file_category`: 文件分类索引
|
||||
- `idx_user_tenant`: 用户+租户联合索引
|
||||
- `idx_create_time`: 创建时间索引
|
||||
|
||||
#### 2. `tik_user_oss_init` - OSS初始化记录表
|
||||
**作用**:记录用户OSS目录初始化状态和路径信息
|
||||
|
||||
**关键字段**:
|
||||
- `mobile_md5`: 手机号MD5值(用于生成OSS路径)
|
||||
- `oss_root_path`: OSS根路径
|
||||
- `video_path`, `generate_path`, `audio_path`, `mix_path`, `voice_path`: 各分类目录路径
|
||||
- `init_status`: 初始化状态(0-未初始化,1-已初始化)
|
||||
|
||||
**设计要点**:
|
||||
- 懒加载策略:首次上传时自动初始化
|
||||
- 路径格式:`{手机号MD5}/{租户ID}/{分类}`
|
||||
|
||||
#### 3. `tik_file_group` - 文件分组表
|
||||
**作用**:用户自定义文件分组(支持层级分组)
|
||||
|
||||
**关键字段**:
|
||||
- `parent_id`: 父分组ID(0表示根分组)
|
||||
- `sort`: 排序字段
|
||||
|
||||
#### 4. `tik_user_file_group` - 文件分组关联表
|
||||
**作用**:文件与分组的关联关系(支持一个文件属于多个分组)
|
||||
|
||||
**设计要点**:
|
||||
- 多对多关系
|
||||
- 唯一索引:`uk_file_group` (file_id, group_id)
|
||||
|
||||
#### 5. `tik_user_quota` - 用户配额表
|
||||
**作用**:管理用户存储配额和VIP等级
|
||||
|
||||
**关键字段**:
|
||||
- `total_storage`: 总存储空间(字节)
|
||||
- `used_storage`: 已使用存储空间(字节)
|
||||
- `vip_level`: VIP等级
|
||||
|
||||
## 三、架构设计
|
||||
|
||||
### 3.1 分层架构
|
||||
|
||||
```
|
||||
Controller 层 (AppTikUserFileController)
|
||||
↓
|
||||
Service 层 (TikUserFileService)
|
||||
↓
|
||||
Mapper 层 (TikUserFileMapper)
|
||||
↓
|
||||
DataObject 层 (TikUserFileDO)
|
||||
```
|
||||
|
||||
### 3.2 核心服务
|
||||
|
||||
#### 1. TikUserFileService - 文件管理服务
|
||||
**职责**:
|
||||
- 文件上传(带配额校验)
|
||||
- 文件查询(分页、筛选)
|
||||
- 文件删除(逻辑删除 + 物理删除)
|
||||
- 预签名URL生成
|
||||
|
||||
**关键流程**:
|
||||
1. **上传流程**:
|
||||
```
|
||||
校验文件分类 → 校验配额 → 获取OSS目录 → 生成完整路径 → 上传到OSS → 保存元数据 → 更新配额
|
||||
```
|
||||
|
||||
2. **删除流程**:
|
||||
```
|
||||
校验权限 → 物理删除OSS文件 → 逻辑删除记录 → 释放配额
|
||||
```
|
||||
|
||||
#### 2. TikOssInitService - OSS初始化服务
|
||||
**职责**:
|
||||
- 初始化用户OSS目录结构
|
||||
- 获取OSS路径信息
|
||||
- 懒加载策略实现
|
||||
|
||||
**设计要点**:
|
||||
- OSS目录是虚拟的,不需要显式创建
|
||||
- 首次上传时自动初始化
|
||||
- 路径格式:`{手机号MD5}/{租户ID}/{分类}`
|
||||
|
||||
#### 3. TikFileGroupService - 文件分组服务
|
||||
**职责**:
|
||||
- 分组CRUD
|
||||
- 层级分组支持
|
||||
|
||||
#### 4. TikUserQuotaService - 配额管理服务
|
||||
**职责**:
|
||||
- 配额校验
|
||||
- 配额更新
|
||||
- VIP等级管理
|
||||
|
||||
## 四、路径设计
|
||||
|
||||
### 4.1 OSS路径结构
|
||||
|
||||
```
|
||||
{手机号MD5}/{租户ID}/{分类}/{日期}/{文件名}_{时间戳}.ext
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```
|
||||
abc123def45678901234567890123456/1/video/20250101/my_video_1234567890123.mp4
|
||||
```
|
||||
|
||||
**路径组成部分**:
|
||||
1. **手机号MD5** (32字符): 用户唯一标识,保护隐私
|
||||
2. **租户ID**: 多租户隔离
|
||||
3. **分类** (video/generate/audio/mix/voice): 文件分类
|
||||
4. **日期** (yyyyMMdd): 按日期分目录,便于管理
|
||||
5. **文件名+时间戳**: 保证唯一性,避免覆盖
|
||||
|
||||
### 4.2 路径存储策略
|
||||
|
||||
- **file_path**: 存储完整OSS路径(用于物理删除)
|
||||
- **file_url**: 存储访问URL(用于前端展示)
|
||||
- **oss_root_path**: 存储根路径(用于快速定位)
|
||||
|
||||
## 五、设计亮点
|
||||
|
||||
### 5.1 优点
|
||||
|
||||
1. **分层清晰**:Controller → Service → Mapper → DO,职责明确
|
||||
2. **配额管理**:上传前校验,删除后释放
|
||||
3. **多租户支持**:通过 tenant_id 隔离
|
||||
4. **懒加载策略**:OSS目录按需初始化
|
||||
5. **路径设计合理**:包含用户、租户、分类、日期等信息
|
||||
6. **分组功能**:支持多分组、层级分组
|
||||
|
||||
### 5.2 需要改进的地方
|
||||
|
||||
1. **物理删除OSS文件**:
|
||||
- 当前只做了逻辑删除,OSS文件未删除
|
||||
- 建议:删除时调用 FileService 或 FileClient 删除OSS文件
|
||||
- 或者:定期清理已逻辑删除的文件
|
||||
|
||||
2. **file_path 字段长度**:
|
||||
- 当前:varchar(512)
|
||||
- 建议:varchar(1024) 更安全
|
||||
|
||||
3. **文件关联 infra_file 表**:
|
||||
- `file_id` 字段存在但未充分利用
|
||||
- 建议:上传时关联 infra_file 表,便于统一管理
|
||||
|
||||
4. **预览图生成**:
|
||||
- 视频封面和图片缩略图功能未实现
|
||||
- 建议:异步生成预览图
|
||||
|
||||
5. **批量操作优化**:
|
||||
- 删除文件时逐个删除OSS文件,可能较慢
|
||||
- 建议:批量删除或异步删除
|
||||
|
||||
## 六、数据流
|
||||
|
||||
### 6.1 上传流程
|
||||
|
||||
```
|
||||
前端上传文件
|
||||
↓
|
||||
Controller 接收
|
||||
↓
|
||||
Service 校验(分类、配额)
|
||||
↓
|
||||
获取OSS目录(懒加载初始化)
|
||||
↓
|
||||
生成完整路径
|
||||
↓
|
||||
上传到OSS(FileApi)
|
||||
↓
|
||||
保存元数据到 tik_user_file
|
||||
↓
|
||||
更新配额(tik_user_quota)
|
||||
↓
|
||||
返回文件ID
|
||||
```
|
||||
|
||||
### 6.2 查询流程
|
||||
|
||||
```
|
||||
前端请求文件列表
|
||||
↓
|
||||
Controller 接收查询参数
|
||||
↓
|
||||
Service 查询数据库(分页、筛选)
|
||||
↓
|
||||
转换为VO(生成预览URL)
|
||||
↓
|
||||
返回分页结果
|
||||
```
|
||||
|
||||
### 6.3 删除流程
|
||||
|
||||
```
|
||||
前端请求删除
|
||||
↓
|
||||
Controller 接收文件ID列表
|
||||
↓
|
||||
Service 校验权限
|
||||
↓
|
||||
物理删除OSS文件(TODO)
|
||||
↓
|
||||
逻辑删除数据库记录
|
||||
↓
|
||||
释放配额
|
||||
↓
|
||||
返回成功
|
||||
```
|
||||
|
||||
## 七、API设计
|
||||
|
||||
### 7.1 文件管理API
|
||||
|
||||
- `POST /api/tik/file/upload` - 上传文件
|
||||
- `GET /api/tik/file/page` - 分页查询
|
||||
- `DELETE /api/tik/file/delete-batch` - 批量删除
|
||||
- `GET /api/tik/file/video/play-url` - 获取视频播放URL
|
||||
- `GET /api/tik/file/audio/play-url` - 获取音频播放URL
|
||||
- `GET /api/tik/file/preview-url` - 获取预览URL
|
||||
|
||||
### 7.2 分组管理API
|
||||
|
||||
- `POST /api/tik/file/group/create` - 创建分组
|
||||
- `PUT /api/tik/file/group/update` - 更新分组
|
||||
- `DELETE /api/tik/file/group/delete` - 删除分组
|
||||
- `GET /api/tik/file/group/list` - 查询分组列表
|
||||
- `POST /api/tik/file/group/add-files` - 添加文件到分组
|
||||
- `POST /api/tik/file/group/remove-files` - 从分组移除文件
|
||||
|
||||
## 八、总结
|
||||
|
||||
### 8.1 表结构建议
|
||||
|
||||
1. **必须修改**:
|
||||
- `file_path` 字段长度:512 → 1024
|
||||
|
||||
2. **可选优化**:
|
||||
- 添加 `file_path` 索引(如果经常按路径查询)
|
||||
- 添加 `file_id` 索引(如果关联 infra_file 表)
|
||||
|
||||
### 8.2 功能完善建议
|
||||
|
||||
1. **物理删除OSS文件**:删除时调用 FileService 删除OSS文件
|
||||
2. **预览图生成**:实现视频封面和图片缩略图异步生成
|
||||
3. **文件关联**:充分利用 `file_id` 关联 infra_file 表
|
||||
4. **批量操作优化**:优化批量删除性能
|
||||
|
||||
### 8.3 整体评价
|
||||
|
||||
**设计评分:8.5/10**
|
||||
|
||||
- ✅ 架构清晰,分层合理
|
||||
- ✅ 路径设计合理,支持多租户
|
||||
- ✅ 配额管理完善
|
||||
- ⚠️ 物理删除功能缺失
|
||||
- ⚠️ 预览图功能未实现
|
||||
- ⚠️ 部分字段未充分利用
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# 文件上传逻辑分析与问题
|
||||
|
||||
## 🔴 严重问题:路径不一致
|
||||
|
||||
### 问题描述
|
||||
|
||||
当前代码存在**路径不一致**的严重问题:
|
||||
|
||||
1. **FileService.createFile()** 内部调用 `generateUploadPath()` 生成路径
|
||||
- 使用 `System.currentTimeMillis()` 作为时间戳
|
||||
- 实际存储路径:`{baseDirectory}/{yyyyMMdd}/{filename}_{timestamp1}.ext`
|
||||
|
||||
2. **我们手动调用 generateFullFilePath()** 生成路径
|
||||
- 也使用 `System.currentTimeMillis()` 作为时间戳
|
||||
- 但调用时间不同,时间戳可能不同:`{baseDirectory}/{yyyyMMdd}/{filename}_{timestamp2}.ext`
|
||||
|
||||
3. **结果**:`filePath` 字段保存的路径 ≠ 实际 OSS 存储路径
|
||||
- 导致删除文件时无法找到正确的文件
|
||||
- 导致路径查询不准确
|
||||
|
||||
### 时间戳不一致示例
|
||||
|
||||
```
|
||||
FileService.createFile() 调用时间:2025-01-15 10:30:45.123
|
||||
→ 生成时间戳:1736905845123
|
||||
→ 实际路径:video/20250115/file_1736905845123.mp4
|
||||
|
||||
generateFullFilePath() 调用时间:2025-01-15 10:30:45.125(2毫秒后)
|
||||
→ 生成时间戳:1736905845125
|
||||
→ 保存路径:video/20250115/file_1736905845125.mp4
|
||||
|
||||
❌ 路径不匹配!
|
||||
```
|
||||
|
||||
## 📋 冗余代码分析
|
||||
|
||||
### 1. generateFullFilePath() 方法
|
||||
- **状态**:冗余
|
||||
- **原因**:完全复制了 `FileService.generateUploadPath()` 的逻辑
|
||||
- **问题**:时间戳不一致导致路径不匹配
|
||||
|
||||
### 2. extractPathFromUrl() 方法
|
||||
- **状态**:未使用
|
||||
- **原因**:创建了但从未调用
|
||||
- **建议**:删除或实现使用
|
||||
|
||||
## ✅ 解决方案
|
||||
|
||||
### 方案1:从 infra_file 表查询 path(推荐)
|
||||
|
||||
**优点**:
|
||||
- 路径100%准确
|
||||
- 可以关联 file_id
|
||||
- 逻辑清晰
|
||||
|
||||
**实现**:
|
||||
```java
|
||||
// 上传后,通过 URL 查询 infra_file 表获取 path
|
||||
FileDO infraFile = fileMapper.selectOne(
|
||||
new LambdaQueryWrapperX<FileDO>()
|
||||
.eq(FileDO::getUrl, fileUrl)
|
||||
.orderByDesc(FileDO::getCreateTime)
|
||||
.last("LIMIT 1")
|
||||
);
|
||||
String filePath = infraFile != null ? infraFile.getPath() : null;
|
||||
```
|
||||
|
||||
### 方案2:从 URL 中提取 path
|
||||
|
||||
**优点**:
|
||||
- 不需要查询数据库
|
||||
- 性能好
|
||||
|
||||
**缺点**:
|
||||
- URL 可能包含域名、查询参数
|
||||
- 提取逻辑复杂,可能不准确
|
||||
|
||||
### 方案3:修改 FileApi 返回 path(不推荐)
|
||||
|
||||
**缺点**:
|
||||
- 需要修改框架代码
|
||||
- 影响其他模块
|
||||
|
||||
## 🎯 推荐实现
|
||||
|
||||
**使用方案1**:从 infra_file 表查询 path,确保路径100%准确。
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
# 文件上传逻辑检查报告
|
||||
|
||||
## ✅ 已修复的问题
|
||||
|
||||
### 1. 路径不一致问题(已修复)
|
||||
|
||||
**问题**:
|
||||
- `FileService.createFile()` 和 `generateFullFilePath()` 使用不同的时间戳
|
||||
- 导致 `filePath` 和实际 OSS 路径不匹配
|
||||
|
||||
**修复方案**:
|
||||
- 从 `infra_file` 表查询实际路径(通过 URL + 文件大小)
|
||||
- 确保路径100%准确
|
||||
- 兜底方案:从 URL 提取路径
|
||||
|
||||
**代码位置**:
|
||||
```java
|
||||
// 从 infra_file 表查询实际的文件路径(确保路径100%准确)
|
||||
String filePath = getFilePathFromInfraFile(fileUrl, file.getSize());
|
||||
if (StrUtil.isBlank(filePath)) {
|
||||
// 如果查询失败,从URL中提取路径(兜底方案)
|
||||
filePath = extractPathFromUrl(fileUrl);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 冗余代码清理
|
||||
|
||||
**已删除**:
|
||||
- `generateFullFilePath()` 方法(已删除,不再需要手动生成路径)
|
||||
|
||||
**保留**:
|
||||
- `extractPathFromUrl()` 方法(作为兜底方案,在删除文件时也会用到)
|
||||
|
||||
## 📊 当前逻辑流程
|
||||
|
||||
```
|
||||
1. 校验文件分类
|
||||
↓
|
||||
2. 校验配额
|
||||
↓
|
||||
3. 获取OSS基础目录
|
||||
↓
|
||||
4. 读取文件内容
|
||||
↓
|
||||
5. 上传到OSS(FileService.createFile)
|
||||
- FileService 自动生成路径并保存到 infra_file 表
|
||||
- 返回 fileUrl
|
||||
↓
|
||||
6. 从 infra_file 表查询实际路径(✅ 确保准确)
|
||||
- 通过 URL + 文件大小精确匹配
|
||||
- 兜底:从 URL 提取路径
|
||||
↓
|
||||
7. 获取OSS根路径
|
||||
↓
|
||||
8. 保存文件记录到 tik_user_file 表
|
||||
- file_path: 从 infra_file 表查询的准确路径
|
||||
- file_url: FileService 返回的 URL
|
||||
↓
|
||||
9. 更新配额
|
||||
```
|
||||
|
||||
## ✅ 逻辑可行性检查
|
||||
|
||||
### 1. 路径准确性 ✅
|
||||
- **方案**:从 `infra_file` 表查询
|
||||
- **准确性**:100%(直接使用 FileService 保存的路径)
|
||||
- **性能**:一次数据库查询,可接受
|
||||
|
||||
### 2. 兜底方案 ✅
|
||||
- **方案**:从 URL 提取路径
|
||||
- **适用场景**:查询失败时使用
|
||||
- **准确性**:中等(URL 可能包含域名和查询参数)
|
||||
|
||||
### 3. 文件删除 ✅
|
||||
- **当前**:使用 `file_path` 字段
|
||||
- **准确性**:高(路径来自 infra_file 表)
|
||||
- **TODO**:实现物理删除 OSS 文件
|
||||
|
||||
## 🎯 优化建议
|
||||
|
||||
### 1. 关联 file_id(可选)
|
||||
|
||||
如果后续需要关联 `infra_file` 表,可以在查询时保存 `file_id`:
|
||||
|
||||
```java
|
||||
FileDO infraFile = fileMapper.selectOne(...);
|
||||
if (infraFile != null) {
|
||||
userFile.setFileId(infraFile.getId()); // 关联 infra_file 表
|
||||
filePath = infraFile.getPath();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 性能优化(可选)
|
||||
|
||||
如果担心查询性能,可以:
|
||||
- 添加缓存(URL → path 的映射)
|
||||
- 或者:直接使用 URL 提取路径(但准确性降低)
|
||||
|
||||
## 📝 总结
|
||||
|
||||
**当前逻辑**:
|
||||
- ✅ 路径准确性:100%(从 infra_file 表查询)
|
||||
- ✅ 代码简洁:删除了冗余的路径生成逻辑
|
||||
- ✅ 兜底方案:URL 提取路径
|
||||
- ✅ 可行性:完全可行
|
||||
|
||||
**建议**:
|
||||
- 当前实现已经是最优方案
|
||||
- 路径准确性有保障
|
||||
- 代码逻辑清晰,无冗余
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# 文件上传策略分析
|
||||
|
||||
## 🎯 业界成熟方案:先上传OSS,再存数据库
|
||||
|
||||
### 方案对比
|
||||
|
||||
| 方案 | 优点 | 缺点 | 适用场景 |
|
||||
|------|------|------|----------|
|
||||
| **先上传OSS,再存数据库** ✅ | 1. OSS上传失败不影响数据库<br>2. 数据库事务可快速回滚<br>3. 用户体验好(文件已上传)<br>4. 孤立文件可定时清理 | 1. 数据库失败会产生孤立文件<br>2. 需要清理机制 | **推荐方案**(业界主流) |
|
||||
| 先存数据库,再上传OSS | 1. 数据库失败不会上传OSS<br>2. 不会产生孤立文件 | 1. OSS上传失败需要回滚数据库<br>2. 数据库事务时间长<br>3. 用户体验差 | 不推荐 |
|
||||
|
||||
### 为什么选择"先上传OSS,再存数据库"?
|
||||
|
||||
1. **性能优势**
|
||||
- OSS上传是外部服务调用,不应该阻塞数据库事务
|
||||
- 数据库事务时间短,减少锁竞争
|
||||
|
||||
2. **可靠性优势**
|
||||
- OSS上传失败,直接返回错误,不产生脏数据
|
||||
- 数据库保存失败,OSS文件可以后续清理(定时任务)
|
||||
|
||||
3. **用户体验优势**
|
||||
- 文件已上传成功,即使数据库失败,文件还在
|
||||
- 可以重试数据库保存,无需重新上传
|
||||
|
||||
4. **业界实践**
|
||||
- 阿里云、腾讯云、AWS 等主流云服务都推荐此方案
|
||||
- 大多数开源项目采用此方案
|
||||
|
||||
### 当前实现方案
|
||||
|
||||
```
|
||||
1. 校验(文件分类、配额)
|
||||
↓
|
||||
2. 读取文件内容
|
||||
↓
|
||||
3. 上传到OSS(FileService.createFile)
|
||||
- 成功:返回 fileUrl 和 filePath
|
||||
- 失败:直接抛出异常,不保存数据库
|
||||
↓
|
||||
4. 保存数据库(事务中)
|
||||
- 成功:返回文件ID
|
||||
- 失败:删除OSS文件,抛出异常
|
||||
↓
|
||||
5. 更新配额
|
||||
```
|
||||
|
||||
### 异常处理
|
||||
|
||||
1. **OSS上传失败**
|
||||
- 直接抛出异常,不保存数据库
|
||||
- 用户可重试上传
|
||||
|
||||
2. **数据库保存失败**
|
||||
- 删除已上传的OSS文件(清理)
|
||||
- 抛出异常,用户可重试
|
||||
|
||||
3. **孤立文件清理**
|
||||
- 定时任务清理未关联数据库的OSS文件
|
||||
- 基于 infra_file 表的创建时间判断
|
||||
|
||||
### 优化建议
|
||||
|
||||
1. **异步清理孤立文件**
|
||||
- 定时任务扫描 infra_file 表
|
||||
- 删除超过7天未关联 tik_user_file 的文件
|
||||
|
||||
2. **重试机制**
|
||||
- 数据库保存失败时,记录重试队列
|
||||
- 后台任务重试保存
|
||||
|
||||
3. **监控告警**
|
||||
- 监控OSS上传失败率
|
||||
- 监控数据库保存失败率
|
||||
- 监控孤立文件数量
|
||||
|
||||
@@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.tik.chat.vo.app.AppAiChatMessageRespVO;
|
||||
import cn.iocoder.yudao.module.tik.chat.vo.app.AppAiChatMessageSendReqVO;
|
||||
import cn.iocoder.yudao.module.tik.chat.vo.app.AppAiChatMessageSendRespVO;
|
||||
import cn.iocoder.yudao.module.tik.controller.admin.chat.vo.message.AiChatMessageSendReqVO;
|
||||
import cn.iocoder.yudao.module.tik.controller.admin.chat.vo.message.AiChatMessageSendRespVO;
|
||||
import cn.iocoder.yudao.module.tik.dal.dataobject.chat.AiChatConversationDO;
|
||||
import cn.iocoder.yudao.module.tik.dal.dataobject.chat.AiChatMessageDO;
|
||||
import cn.iocoder.yudao.module.tik.dal.dataobject.knowledge.AiKnowledgeDocumentDO;
|
||||
@@ -116,19 +117,8 @@ public class AppAiChatMessageController {
|
||||
|
||||
@Operation(summary = "发送消息(流式)", description = "流式返回,响应较快")
|
||||
@PostMapping(value = "/send-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public Flux<CommonResult<AppAiChatMessageSendRespVO>> sendChatMessageStream(@Valid @RequestBody AppAiChatMessageSendReqVO sendReqVO) {
|
||||
// 将 App VO 转换为 Admin VO
|
||||
AiChatMessageSendReqVO adminReqVO = BeanUtils.toBean(sendReqVO, AiChatMessageSendReqVO.class);
|
||||
// 调用 Service,然后转换响应流
|
||||
return chatMessageService.sendChatMessageStream(adminReqVO, getLoginUserId())
|
||||
.map(result -> {
|
||||
if (result.getData() != null) {
|
||||
// 手动转换 segments,因为内部类类型不同
|
||||
AppAiChatMessageSendRespVO appResp = convertSendRespVO(result.getData());
|
||||
return success(appResp);
|
||||
}
|
||||
return success((AppAiChatMessageSendRespVO) null);
|
||||
});
|
||||
public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(@Valid @RequestBody AiChatMessageSendReqVO sendReqVO) {
|
||||
return chatMessageService.sendChatMessageStream(sendReqVO, getLoginUserId());
|
||||
}
|
||||
|
||||
@Operation(summary = "获得指定对话的消息列表")
|
||||
|
||||
@@ -25,4 +25,12 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode FILE_GROUP_NAME_DUPLICATE = new ErrorCode(1_030_000_012, "分组名称重复");
|
||||
ErrorCode FILE_GROUP_NOT_BELONG_TO_USER = new ErrorCode(1_030_000_013, "分组不属于当前用户");
|
||||
|
||||
// ========== 配音管理 1-030-001-000 ==========
|
||||
ErrorCode VOICE_NOT_EXISTS = new ErrorCode(1_030_001_001, "配音不存在");
|
||||
ErrorCode VOICE_NAME_DUPLICATE = new ErrorCode(1_030_001_002, "配音名称重复");
|
||||
ErrorCode VOICE_FILE_NOT_EXISTS = new ErrorCode(1_030_001_003, "音频文件不存在");
|
||||
ErrorCode VOICE_TRANSCRIBE_FAILED = new ErrorCode(1_030_001_004, "语音识别失败");
|
||||
ErrorCode VOICE_TTS_FAILED = new ErrorCode(1_030_001_005, "语音合成失败");
|
||||
ErrorCode LATENTSYNC_SUBMIT_FAILED = new ErrorCode(1_030_001_101, "口型同步任务提交失败");
|
||||
|
||||
}
|
||||
|
||||
@@ -5,13 +5,22 @@ import cn.hutool.core.io.IoUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.infra.api.file.FileApi;
|
||||
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
|
||||
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils;
|
||||
import cn.iocoder.yudao.module.infra.service.file.FileConfigService;
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
|
||||
import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper;
|
||||
@@ -60,6 +69,9 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
@Resource
|
||||
private FileMapper fileMapper;
|
||||
|
||||
@Resource
|
||||
private FileConfigService fileConfigService;
|
||||
|
||||
@Override
|
||||
public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
@@ -86,28 +98,52 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
throw exception(FILE_NOT_EXISTS, "文件读取失败");
|
||||
}
|
||||
|
||||
// ========== 第二阶段:上传到OSS(不在事务中,优先执行) ==========
|
||||
// 5. 上传文件到OSS(FileService会自动处理文件名,添加日期前缀和时间戳后缀)
|
||||
// FileService.createFile 会自动生成路径:{baseDirectory}/{yyyyMMdd}/{filename}_{timestamp}.ext
|
||||
// 注意:FileService 内部会使用原始文件名,并自动添加时间戳后缀保证唯一性
|
||||
// ========== 第二阶段:上传到OSS并保存文件记录(不在事务中,优先执行) ==========
|
||||
// 采用业界成熟方案:直接使用 fileMapper.insert() 获取文件ID,避免通过 URL 查询
|
||||
String fileUrl;
|
||||
String filePath;
|
||||
Long infraFileId = null; // 用于失败时删除OSS文件
|
||||
Long infraFileId;
|
||||
|
||||
try {
|
||||
fileUrl = fileApi.createFile(fileContent, file.getOriginalFilename(),
|
||||
baseDirectory, file.getContentType());
|
||||
|
||||
// 6. 从 infra_file 表查询实际的文件路径(确保路径100%准确)
|
||||
// 因为 FileService 已经保存了文件记录到 infra_file 表,我们可以通过 URL 查询获取准确的 path
|
||||
FileDO infraFile = getInfraFileByUrl(fileUrl, file.getSize());
|
||||
if (infraFile != null) {
|
||||
filePath = infraFile.getPath();
|
||||
infraFileId = infraFile.getId(); // 保存 infra_file.id,用于失败时删除
|
||||
} else {
|
||||
// 如果查询失败,从URL中提取路径(兜底方案)
|
||||
filePath = extractPathFromUrl(fileUrl);
|
||||
log.warn("[uploadFile][无法从infra_file表查询路径,使用URL提取,URL({})]", fileUrl);
|
||||
// 1. 处理文件名和类型
|
||||
String fileName = file.getOriginalFilename();
|
||||
String fileType = file.getContentType();
|
||||
if (StrUtil.isEmpty(fileType)) {
|
||||
fileType = FileTypeUtils.getMineType(fileContent, fileName);
|
||||
}
|
||||
if (StrUtil.isEmpty(fileName)) {
|
||||
fileName = DigestUtil.sha256Hex(fileContent);
|
||||
}
|
||||
if (StrUtil.isEmpty(FileUtil.extName(fileName))) {
|
||||
String extension = FileTypeUtils.getExtension(fileType);
|
||||
if (StrUtil.isNotEmpty(extension)) {
|
||||
fileName = fileName + "." + extension;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 生成上传路径(与 FileService 保持一致)
|
||||
filePath = generateUploadPath(fileName, baseDirectory);
|
||||
|
||||
// 3. 上传到OSS
|
||||
FileClient client = fileConfigService.getMasterFileClient();
|
||||
Assert.notNull(client, "客户端(master) 不能为空");
|
||||
String presignedUrl = client.upload(fileContent, filePath, fileType);
|
||||
|
||||
// 3.1 移除预签名URL中的签名参数,获取基础URL(用于存储)
|
||||
fileUrl = HttpUtils.removeUrlQuery(presignedUrl);
|
||||
|
||||
// 4. 保存到 infra_file 表,直接获取文件ID(MyBatis Plus 会自动填充自增ID)
|
||||
FileDO infraFile = new FileDO()
|
||||
.setConfigId(client.getId())
|
||||
.setName(fileName)
|
||||
.setPath(filePath)
|
||||
.setUrl(fileUrl)
|
||||
.setType(fileType)
|
||||
.setSize((int) file.getSize());
|
||||
fileMapper.insert(infraFile);
|
||||
infraFileId = infraFile.getId(); // MyBatis Plus 会自动填充自增ID
|
||||
|
||||
log.info("[uploadFile][文件上传成功,文件编号({}),路径({})]", infraFileId, filePath);
|
||||
} catch (Exception e) {
|
||||
log.error("[uploadFile][上传OSS失败]", e);
|
||||
throw exception(FILE_NOT_EXISTS, "上传OSS失败:" + e.getMessage());
|
||||
@@ -115,7 +151,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
|
||||
// ========== 第三阶段:保存数据库(在事务中,如果失败则删除OSS文件) ==========
|
||||
try {
|
||||
return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory);
|
||||
return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory, infraFileId);
|
||||
} catch (Exception e) {
|
||||
// 数据库保存失败,删除已上传的OSS文件
|
||||
log.error("[uploadFile][保存数据库失败,准备删除OSS文件,URL({})]", fileUrl, e);
|
||||
@@ -129,8 +165,14 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory,
|
||||
String fileUrl, String filePath, String coverBase64, String baseDirectory) {
|
||||
// 7. 处理视频封面(如果有前端传递的 base64 封面,先处理封面再插入主记录)
|
||||
String fileUrl, String filePath, String coverBase64, String baseDirectory, Long infraFileId) {
|
||||
// 7. 验证 infraFileId 不为空(必须在保存记录之前检查)
|
||||
if (infraFileId == null) {
|
||||
log.error("[saveFileRecord][infra_file.id 为空,无法保存文件记录,用户({}),URL({})]", userId, fileUrl);
|
||||
throw exception(FILE_NOT_EXISTS, "文件记录保存失败:无法获取文件ID");
|
||||
}
|
||||
|
||||
// 8. 处理视频封面(如果有前端传递的 base64 封面,先处理封面再插入主记录)
|
||||
String coverUrl = null;
|
||||
if (StrUtil.isNotBlank(coverBase64) && StrUtil.containsIgnoreCase(file.getContentType(), "video")) {
|
||||
try {
|
||||
@@ -162,7 +204,8 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
|
||||
// 严格验证:确保返回的是有效的 URL,而不是 base64 字符串
|
||||
if (StrUtil.isNotBlank(uploadedUrl) && !uploadedUrl.equals(coverBase64) && !uploadedUrl.contains("data:image")) {
|
||||
coverUrl = uploadedUrl;
|
||||
// 移除预签名URL中的签名参数,获取基础URL(用于存储)
|
||||
coverUrl = HttpUtils.removeUrlQuery(uploadedUrl);
|
||||
log.info("[saveFileRecord][视频封面上传成功,封面URL({})]", coverUrl);
|
||||
} else {
|
||||
log.error("[saveFileRecord][视频封面上传返回无效URL,跳过保存封面。返回URL: {}", uploadedUrl);
|
||||
@@ -177,10 +220,10 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 创建文件记录(保存完整路径,包含封面URL和Base64)
|
||||
// 9. 创建文件记录(保存完整路径,包含封面URL和Base64)
|
||||
TikUserFileDO userFile = new TikUserFileDO()
|
||||
.setUserId(userId)
|
||||
.setFileId(null) // 显式设置为null,file_id是可选的,用于关联infra_file表
|
||||
.setFileId(infraFileId) // 关联infra_file表,用于后续通过FileService管理文件
|
||||
.setFileName(file.getOriginalFilename()) // 保存原始文件名,用于展示
|
||||
.setFileType(file.getContentType())
|
||||
.setFileCategory(fileCategory)
|
||||
@@ -191,11 +234,12 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
.setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null); // 保存原始base64数据(如果有)
|
||||
userFileMapper.insert(userFile);
|
||||
|
||||
// 9. 更新配额
|
||||
// 10. 更新配额
|
||||
quotaService.increaseUsedStorage(userId, file.getSize());
|
||||
|
||||
log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({})]", userId, userFile.getId());
|
||||
return userFile.getId();
|
||||
log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({}),infra文件编号({})]", userId, userFile.getId(), infraFileId);
|
||||
// 返回 infra_file.id,因为创建配音等操作需要使用 infra_file.id
|
||||
return infraFileId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,31 +265,41 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 infra_file 表查询文件信息(返回完整对象,包含 id)
|
||||
* 生成上传路径(与 FileService 保持一致)
|
||||
* 格式:{directory}/{yyyyMMdd}/{filename}_{timestamp}.ext
|
||||
*/
|
||||
private FileDO getInfraFileByUrl(String fileUrl, long fileSize) {
|
||||
if (StrUtil.isBlank(fileUrl)) {
|
||||
return null;
|
||||
private String generateUploadPath(String name, String directory) {
|
||||
// 1. 生成前缀、后缀
|
||||
String prefix = null;
|
||||
boolean PATH_PREFIX_DATE_ENABLE = true;
|
||||
boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true;
|
||||
|
||||
if (PATH_PREFIX_DATE_ENABLE) {
|
||||
prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN);
|
||||
}
|
||||
try {
|
||||
// 移除URL中的查询参数(如果有)
|
||||
String cleanUrl = fileUrl;
|
||||
if (fileUrl.contains("?")) {
|
||||
cleanUrl = fileUrl.substring(0, fileUrl.indexOf("?"));
|
||||
String suffix = null;
|
||||
if (PATH_SUFFIX_TIMESTAMP_ENABLE) {
|
||||
suffix = String.valueOf(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
// 2.1 先拼接 suffix 后缀
|
||||
if (StrUtil.isNotEmpty(suffix)) {
|
||||
String ext = FileUtil.extName(name);
|
||||
if (StrUtil.isNotEmpty(ext)) {
|
||||
name = FileUtil.mainName(name) + "_" + suffix + "." + ext;
|
||||
} else {
|
||||
name = name + "_" + suffix;
|
||||
}
|
||||
|
||||
// 通过 URL 和文件大小查询(提高准确性)
|
||||
return fileMapper.selectOne(
|
||||
new LambdaQueryWrapperX<FileDO>()
|
||||
.eq(FileDO::getUrl, cleanUrl)
|
||||
.eq(FileDO::getSize, (int) fileSize) // FileDO.size 是 Integer
|
||||
.orderByDesc(FileDO::getCreateTime)
|
||||
.last("LIMIT 1")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.warn("[getInfraFileByUrl][查询infra_file表失败,URL({})]", fileUrl, e);
|
||||
}
|
||||
return null;
|
||||
// 2.2 再拼接 prefix 前缀
|
||||
if (StrUtil.isNotEmpty(prefix)) {
|
||||
name = prefix + "/" + name;
|
||||
}
|
||||
// 2.3 最后拼接 directory 目录
|
||||
if (StrUtil.isNotEmpty(directory)) {
|
||||
name = directory + "/" + name;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -466,16 +520,28 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// 移除URL中的查询参数(签名参数等)
|
||||
String cleanUrl = url;
|
||||
if (url.contains("?")) {
|
||||
cleanUrl = url.substring(0, url.indexOf("?"));
|
||||
}
|
||||
|
||||
// 如果URL包含域名,提取路径部分
|
||||
if (url.contains("://")) {
|
||||
int pathStart = url.indexOf("/", url.indexOf("://") + 3);
|
||||
if (cleanUrl.contains("://")) {
|
||||
int pathStart = cleanUrl.indexOf("/", cleanUrl.indexOf("://") + 3);
|
||||
if (pathStart > 0) {
|
||||
return url.substring(pathStart);
|
||||
String fullPath = cleanUrl.substring(pathStart);
|
||||
// 路径可能包含 bucket 名称,需要提取实际的文件路径
|
||||
// 例如:/bucket-name/user-id/tenant-id/voice/20251117/file.wav
|
||||
// 实际 path 可能是:user-id/tenant-id/voice/20251117/file.wav
|
||||
// 但数据库中的 path 格式是:voice/20251117/file_timestamp.wav
|
||||
// 所以我们需要找到包含日期格式的部分(yyyyMMdd)
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
// 如果已经是路径格式,直接返回
|
||||
if (url.startsWith("/")) {
|
||||
return url;
|
||||
// 如果已经是路径格式,直接返回(去除查询参数)
|
||||
if (cleanUrl.startsWith("/")) {
|
||||
return cleanUrl;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[extractPathFromUrl][从URL提取路径失败,URL({})]", url, e);
|
||||
|
||||
@@ -73,7 +73,7 @@ public class TikFileTransCharacters {
|
||||
// 设置是否输出词信息,默认为false,开启时需要设置version为4.0及以上。
|
||||
taskObject.put(KEY_ENABLE_WORDS, true);
|
||||
String task = taskObject.toJSONString();
|
||||
System.out.println(task);
|
||||
System.out.println("[TikFileTransCharacters][submitFileTransRequest] 请求参数: " + task);
|
||||
// 设置以上JSON字符串为Body参数。
|
||||
postRequest.putBodyParameter(KEY_TASK, task);
|
||||
// 设置为POST方式的请求。
|
||||
@@ -85,15 +85,24 @@ public class TikFileTransCharacters {
|
||||
String taskId = null;
|
||||
try {
|
||||
CommonResponse postResponse = client.getCommonResponse(postRequest);
|
||||
System.err.println("提交录音文件识别请求的响应:" + postResponse.getData());
|
||||
if (postResponse.getHttpStatus() == 200) {
|
||||
System.err.println("[TikFileTransCharacters][submitFileTransRequest] 提交录音文件识别请求的响应:" + postResponse.getData());
|
||||
int httpStatus = postResponse.getHttpStatus();
|
||||
System.out.println("[TikFileTransCharacters][submitFileTransRequest] HTTP状态码: " + httpStatus);
|
||||
if (httpStatus == 200) {
|
||||
JSONObject result = JSONObject.parseObject(postResponse.getData());
|
||||
String statusText = result.getString(KEY_STATUS_TEXT);
|
||||
System.out.println("[TikFileTransCharacters][submitFileTransRequest] 状态文本: " + statusText);
|
||||
if (STATUS_SUCCESS.equals(statusText)) {
|
||||
taskId = result.getString(KEY_TASK_ID);
|
||||
System.out.println("[TikFileTransCharacters][submitFileTransRequest] 任务ID: " + taskId);
|
||||
} else {
|
||||
System.err.println("[TikFileTransCharacters][submitFileTransRequest] 状态不是SUCCESS,状态文本: " + statusText);
|
||||
}
|
||||
} else {
|
||||
System.err.println("[TikFileTransCharacters][submitFileTransRequest] HTTP状态码不是200,状态码: " + httpStatus + ",响应: " + postResponse.getData());
|
||||
}
|
||||
} catch (ClientException e) {
|
||||
System.err.println("[TikFileTransCharacters][submitFileTransRequest] 异常: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
return taskId;
|
||||
@@ -120,17 +129,25 @@ public class TikFileTransCharacters {
|
||||
* 以轮询的方式进行识别结果的查询,直到服务端返回的状态描述为“SUCCESS”或错误描述,则结束轮询。
|
||||
*/
|
||||
String result = null;
|
||||
int pollCount = 0;
|
||||
while (true) {
|
||||
pollCount++;
|
||||
try {
|
||||
System.out.println("[TikFileTransCharacters][getFileTransResult] 第" + pollCount + "次轮询,taskId: " + taskId);
|
||||
CommonResponse getResponse = client.getCommonResponse(getRequest);
|
||||
System.err.println("识别查询结果:" + getResponse.getData());
|
||||
if (getResponse.getHttpStatus() != 200) {
|
||||
int httpStatus = getResponse.getHttpStatus();
|
||||
String responseData = getResponse.getData();
|
||||
System.err.println("[TikFileTransCharacters][getFileTransResult] 识别查询结果,HTTP状态码: " + httpStatus + ",响应: " + responseData);
|
||||
if (httpStatus != 200) {
|
||||
System.err.println("[TikFileTransCharacters][getFileTransResult] HTTP状态码不是200,停止轮询,taskId: " + taskId);
|
||||
break;
|
||||
}
|
||||
JSONObject rootObj = JSONObject.parseObject(getResponse.getData());
|
||||
JSONObject rootObj = JSONObject.parseObject(responseData);
|
||||
String statusText = rootObj.getString(KEY_STATUS_TEXT);
|
||||
System.out.println("[TikFileTransCharacters][getFileTransResult] 状态文本: " + statusText);
|
||||
if (STATUS_RUNNING.equals(statusText) || STATUS_QUEUEING.equals(statusText)) {
|
||||
// 继续轮询,注意设置轮询时间间隔。
|
||||
System.out.println("[TikFileTransCharacters][getFileTransResult] 任务进行中,等待10秒后继续轮询,taskId: " + taskId);
|
||||
Thread.sleep(10000);
|
||||
}
|
||||
else {
|
||||
@@ -139,15 +156,22 @@ public class TikFileTransCharacters {
|
||||
result = rootObj.getString(KEY_RESULT);
|
||||
// 状态信息为成功,但没有识别结果,则可能是由于文件里全是静音、噪音等导致识别为空。
|
||||
if(result == null) {
|
||||
System.out.println("[TikFileTransCharacters][getFileTransResult] 识别成功但结果为空,taskId: " + taskId);
|
||||
result = "";
|
||||
} else {
|
||||
System.out.println("[TikFileTransCharacters][getFileTransResult] 识别成功,结果长度: " + result.length() + ",taskId: " + taskId);
|
||||
}
|
||||
} else {
|
||||
System.err.println("[TikFileTransCharacters][getFileTransResult] 状态不是SUCCESS,状态文本: " + statusText + ",taskId: " + taskId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("[TikFileTransCharacters][getFileTransResult] 轮询异常,taskId: " + taskId + ",异常信息: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
System.out.println("[TikFileTransCharacters][getFileTransResult] 轮询结束,taskId: " + taskId + ",结果: " + (result != null ? "非空,长度" + result.length() : "null"));
|
||||
return result;
|
||||
}
|
||||
public static void main(String args[]) throws Exception {
|
||||
|
||||
@@ -165,17 +165,20 @@ public class TikHupServiceImpl implements TikHupService {
|
||||
|
||||
@Override
|
||||
public Object videoToCharacters(String fileLink){
|
||||
log.info("[videoToCharacters][开始识别,文件链接({})]", fileLink);
|
||||
TikFileTransCharacters tikFileTransCharacters = new TikFileTransCharacters(accessKeyId, accessKeySecret);
|
||||
// 第一步:提交录音文件识别请求,获取任务ID用于后续的识别结果轮询。
|
||||
String taskId = tikFileTransCharacters.submitFileTransRequest(appKey, fileLink);
|
||||
if (taskId == null) {
|
||||
log.error("[videoToCharacters][提交识别请求失败,taskId为null,fileLink({})]", fileLink);
|
||||
return CommonResult.error(500,"录音文件识别请求失败!");
|
||||
}
|
||||
// 第二步:根据任务ID轮询识别结果。
|
||||
log.info("[videoToCharacters][提交识别请求成功,taskId({})]", taskId);
|
||||
String transResult = tikFileTransCharacters.getFileTransResult(taskId);
|
||||
if (transResult == null) {
|
||||
log.error("[videoToCharacters][识别结果查询失败,taskId({}),transResult为null]", taskId);
|
||||
return CommonResult.error(501,"录音文件识别请求失败!");
|
||||
}
|
||||
log.info("[videoToCharacters][识别成功,taskId({}),结果长度({})]", taskId, transResult.length());
|
||||
return CommonResult.success(transResult);
|
||||
}
|
||||
|
||||
@@ -183,30 +186,28 @@ public class TikHupServiceImpl implements TikHupService {
|
||||
|
||||
@Override
|
||||
public Object videoToCharacters2(List<String> fileLinkList){
|
||||
// 创建转写请求参数
|
||||
TranscriptionParam param =
|
||||
TranscriptionParam.builder()
|
||||
// 若没有将API Key配置到环境变量中,需将apiKey替换为自己的API Key
|
||||
.apiKey(apiKey)
|
||||
.model("paraformer-v1")
|
||||
// “language_hints”只支持paraformer-v2模型
|
||||
.parameter("language_hints", new String[]{"zh", "en"})
|
||||
.fileUrls(fileLinkList)
|
||||
.build();
|
||||
log.info("[videoToCharacters2][开始识别,文件数量({}),文件URL({})]",
|
||||
fileLinkList != null ? fileLinkList.size() : 0, fileLinkList);
|
||||
TranscriptionParam param = TranscriptionParam.builder()
|
||||
.apiKey(apiKey)
|
||||
.model("paraformer-v1")
|
||||
.parameter("language_hints", new String[]{"zh", "en"})
|
||||
.fileUrls(fileLinkList)
|
||||
.build();
|
||||
try {
|
||||
Transcription transcription = new Transcription();
|
||||
// 提交转写请求
|
||||
TranscriptionResult result = transcription.asyncCall(param);
|
||||
log.info("RequestId: {}" ,result.getRequestId());
|
||||
// 阻塞等待任务完成并获取结果
|
||||
log.info("[videoToCharacters2][提交转写请求成功,TaskId({})]", result.getTaskId());
|
||||
result = transcription.wait(
|
||||
TranscriptionQueryParam.FromTranscriptionParam(param, result.getTaskId()));
|
||||
return CommonResult.success(new GsonBuilder().setPrettyPrinting().create().toJson(result.getOutput()));
|
||||
String outputJson = new GsonBuilder().setPrettyPrinting().create().toJson(result.getOutput());
|
||||
log.info("[videoToCharacters2][识别成功,TaskId({}),结果长度({})]",
|
||||
result.getTaskId(), outputJson != null ? outputJson.length() : 0);
|
||||
return CommonResult.success(outputJson);
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage());
|
||||
log.error("[videoToCharacters2][识别失败,文件URL({}),异常({})]", fileLinkList, e.getMessage(), e);
|
||||
return CommonResult.error(500,"录音文件识别请求失败!");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -223,6 +223,11 @@ wx:
|
||||
|
||||
# 芋道配置项,设置当前项目所有自定义的配置
|
||||
yudao:
|
||||
cosyvoice:
|
||||
api-key: sk-10c746f8cb8640738f8d6b71af699003
|
||||
# tik:
|
||||
# latentsync:
|
||||
# api-key: ${TIK_LATENTSYNC_API_KEY:} # 建议通过环境变量覆盖仓库默认值
|
||||
captcha:
|
||||
enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试;
|
||||
security:
|
||||
@@ -265,4 +270,4 @@ justauth:
|
||||
cache:
|
||||
type: REDIS
|
||||
prefix: 'social_auth_state:' # 缓存前缀,目前只对 Redis 缓存生效,默认 JUSTAUTH::STATE::
|
||||
timeout: 24h # 超时时长,目前只对 Redis 缓存生效,默认 3 分钟
|
||||
timeout: 24h # 超时时长,目前只对 Redis 缓存生效,默认 3 分钟
|
||||
|
||||
@@ -213,6 +213,13 @@ spring:
|
||||
sse-endpoint: /sse
|
||||
|
||||
yudao:
|
||||
cosyvoice:
|
||||
enabled: true
|
||||
api-key: sk-10c746f8cb8640738f8d6b71af699003
|
||||
default-model: cosyvoice-v2
|
||||
sample-rate: 24000
|
||||
audio-format: mp3
|
||||
preview-text: 您好,欢迎体验专属音色
|
||||
ai:
|
||||
gemini: # 谷歌 Gemini
|
||||
enable: true
|
||||
|
||||
Reference in New Issue
Block a user