send-stream

This commit is contained in:
wing
2025-11-19 00:12:47 +08:00
parent 7f53203245
commit 33abc33b58
21 changed files with 1630 additions and 2247 deletions

View File

@@ -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>

View File

@@ -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') },

View File

@@ -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()
}
}
})

View File

@@ -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) {

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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`: 父分组ID0表示根分组
- `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目录懒加载初始化
生成完整路径
上传到OSSFileApi
保存元数据到 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**
- ✅ 架构清晰,分层合理
- ✅ 路径设计合理,支持多租户
- ✅ 配额管理完善
- ⚠️ 物理删除功能缺失
- ⚠️ 预览图功能未实现
- ⚠️ 部分字段未充分利用

View File

@@ -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.1252毫秒后
→ 生成时间戳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%准确。

View File

@@ -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. 上传到OSSFileService.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 提取路径
- ✅ 可行性:完全可行
**建议**
- 当前实现已经是最优方案
- 路径准确性有保障
- 代码逻辑清晰,无冗余

View File

@@ -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. 上传到OSSFileService.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上传失败率
- 监控数据库保存失败率
- 监控孤立文件数量

View File

@@ -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 = "获得指定对话的消息列表")

View File

@@ -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, "口型同步任务提交失败");
}

View File

@@ -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. 上传文件到OSSFileService会自动处理文件名添加日期前缀和时间戳后缀
// 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 表直接获取文件IDMyBatis 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) // 显式设置为nullfile_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);

View File

@@ -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 {

View File

@@ -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为nullfileLink({})]", 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,"录音文件识别请求失败!");
}
}

View File

@@ -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 分钟

View File

@@ -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