diff --git a/CLAUDE.md b/CLAUDE.md index d27ddc69a1..e46da1ddf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -本文档为 Claude Code (claude.ai/code) 在此仓库中处理代码提供指导。 +本文档为 Claude Code (claude.ai/code) 在此仓库中处理代码提供指导。请始终用中文沟通 ## 项目概览 @@ -50,24 +50,6 @@ └── docs/ # 文档 ``` -## 常用开发命令 - -### 后端(Maven) - -**构建和运行:** -```bash -# 构建项目 -mvn clean package -DskipTests - -# 运行特定模块的测试 -mvn test -pl yudao-module-tik - -# 启动服务器 -cd yudao-server && mvn spring-boot:run -Dspring-boot.run.profiles=local - -# 使用特定配置构建 -mvn clean package -Pdev -DskipTests -``` **代码生成:** - 内置 CRUD 操作代码生成器 @@ -81,51 +63,19 @@ mvn clean package -Pdev -DskipTests cd frontend/app/web-gold # 安装依赖 -npm install +pnpm install # 启动开发服务器(代理到后端 9900 端口) -npm run dev +pnpm run dev # 生产构建 -npm run build +pnpm run build # 代码检查 -npm run lint +pnpm run lint # 代码格式化 -npm run format -``` - -**可用脚本:** -- `dev` - 带热重载的开发服务器 -- `build` - 生产构建 -- `preview` - 预览生产构建 -- `lint:oxlint` - 运行 OxLint 并自动修复 -- `lint:eslint` - 运行 ESLint 并自动修复 -- `lint` - 运行所有检查器 -- `format` - 使用 Prettier 格式化代码 - -### Docker - -**使用 Docker Compose:** -```bash -# 启动所有服务(MySQL、Redis、Server、Admin) -cd script/docker -docker-compose up -d - -# 启动特定服务 -docker-compose up -d mysql redis -``` - -**手动 Docker 构建:** -```bash -# 后端 -cd yudao-server -docker build -t yudao-server . - -# 前端 -cd frontend/app/web-gold -docker build -t web-gold . +pnpm run format ``` ## 模块架构 @@ -313,26 +263,6 @@ frontend/app/web-gold/src/ - Cypress 进行端到端测试 - 运行测试:`npm run test` -## 部署 - -**生产部署:** -```bash -# 使用部署脚本 -cd script/shell -./deploy.sh - -# 手动部署 -# 1. 构建 JAR -mvn clean package -DskipTests -Pprod - -# 2. 部署到服务器 -# deploy.sh 脚本处理: -# - 备份前一版本 -# - 停止当前服务 -# - 传输新 JAR -# - 启动服务 -# - 健康检查 -``` **JVM 选项:** - 默认:`-Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError` diff --git a/frontend/app/web-gold/src/api/digitalHuman.js b/frontend/app/web-gold/src/api/digitalHuman.js new file mode 100644 index 0000000000..ec9d5f8159 --- /dev/null +++ b/frontend/app/web-gold/src/api/digitalHuman.js @@ -0,0 +1,77 @@ +/** + * 数字人任务 API + */ +import request from './http' + +/** + * 创建数字人任务 + */ +export function createDigitalHumanTask(data) { + return request({ + url: '/api/tik/digital-human/task/create', + method: 'post', + data + }) +} + +/** + * 查询任务详情 + */ +export function getDigitalHumanTask(taskId) { + return request({ + url: '/api/tik/digital-human/task/get', + method: 'get', + params: { taskId } + }) +} + +/** + * 分页查询任务列表 + */ +export function getDigitalHumanTaskPage(params) { + return request({ + url: '/api/tik/digital-human/task/page', + method: 'get', + params + }) +} + +/** + * 查询任务统计 + */ +export function getTaskStatistics() { + return request({ + url: '/api/tik/digital-human/task/statistics', + method: 'get' + }) +} + +/** + * 取消任务 + */ +export function cancelTask(taskId) { + return request({ + url: `/api/tik/digital-human/task/${taskId}/cancel`, + method: 'post' + }) +} + +/** + * 重试任务 + */ +export function retryTask(taskId) { + return request({ + url: `/api/tik/digital-human/task/${taskId}/retry`, + method: 'post' + }) +} + +/** + * 删除任务 + */ +export function deleteTask(taskId) { + return request({ + url: `/api/tik/digital-human/task/${taskId}`, + method: 'delete' + }) +} diff --git a/frontend/app/web-gold/src/views/dh/Video.vue b/frontend/app/web-gold/src/views/dh/Video.vue index ae2e56b836..de39837c04 100644 --- a/frontend/app/web-gold/src/views/dh/Video.vue +++ b/frontend/app/web-gold/src/views/dh/Video.vue @@ -5,19 +5,26 @@ import { message } from 'ant-design-vue' import { InboxOutlined } from '@ant-design/icons-vue' import { useVoiceCopyStore } from '@/stores/voiceCopy' import { VoiceService } from '@/api/voice' +import { MaterialService } from '@/api/material' +import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman' const voiceStore = useVoiceCopyStore() // 状态管理 const uploadedVideo = ref('') +const uploadedVideoFile = ref(null) // 存储原始文件对象 const previewVideoUrl = ref('') const isGenerating = ref(false) const generationProgress = ref(0) +const currentTaskId = ref(null) +const currentTaskStatus = ref('') +const currentTaskStep = ref('') const isSynthesizing = ref(false) const synthesizedAudio = ref(null) const previewLoadingVoiceId = ref('') const isPlayingPreview = ref(false) // 是否正在播放试听音频 const isPlayingSynthesized = ref(false) // 是否正在播放已合成的音频 +const pollingInterval = ref(null) // 轮询间隔ID // TTS 配置 const ttsText = ref('') @@ -245,6 +252,7 @@ const handleVideoUpload = async (file) => { try { uploadedVideo.value = await toDataURL(file) + uploadedVideoFile.value = file // 保存文件对象 message.success('视频上传成功') } catch (error) { message.error('视频上传失败') @@ -255,6 +263,7 @@ const handleVideoUpload = async (file) => { const handleVideoDrop = (e) => console.log('Video drop:', e) const clearVideo = () => { uploadedVideo.value = '' + uploadedVideoFile.value = null previewVideoUrl.value = '' message.info('已清除视频') } @@ -270,26 +279,176 @@ const downloadPreview = () => { // 视频生成 const generateVideo = async () => { if (!canGenerate.value) return message.warning('请先完成配置') + if (!ttsText.value.trim()) return message.warning('请输入文本') + const voice = selectedVoiceMeta.value + if (!voice) return message.warning('请选择音色') isGenerating.value = true generationProgress.value = 0 + currentTaskStatus.value = 'PENDING' + currentTaskStep.value = 'prepare_files' try { - // 进度模拟 - const progressInterval = setInterval(() => { - generationProgress.value += 10 - generationProgress.value >= 100 && clearInterval(progressInterval) - }, 500) + // 1. 首先上传音频和视频文件到后端 + message.loading('正在上传文件...', 0) + + // 上传音频(使用合成后的音频或原始音频) + let audioFileId = null + let audioUrl = null + + if (synthesizedAudio.value?.fileId) { + // 如果有已合成的音频,使用其fileId + audioFileId = synthesizedAudio.value.fileId + } else { + // 否则使用voiceConfigId让后端处理 + audioFileId = voice.rawId || extractIdFromString(voice.id) + } + + // 上传视频文件 + const videoFileId = await uploadVideoFile(uploadedVideoFile.value) + if (!videoFileId) { + throw new Error('视频上传失败') + } - await new Promise(resolve => setTimeout(resolve, 5000)) - previewVideoUrl.value = uploadedVideo.value - generationProgress.value = 100 - message.success('视频生成成功') - clearInterval(progressInterval) + message.destroy() + + // 2. 创建数字人任务 + const taskData = { + taskName: `数字人任务_${Date.now()}`, + audioFileId: audioFileId, + videoFileId: videoFileId, + speechRate: speechRate.value, + emotion: emotion.value, + guidanceScale: 1, + seed: 8888 + } + + message.loading('正在创建任务...', 0) + const createRes = await createDigitalHumanTask(taskData) + message.destroy() + + if (createRes.code === 0) { + currentTaskId.value = createRes.data + message.success('任务创建成功,开始处理') + + // 3. 开始轮询任务状态 + startPollingTask() + } else { + throw new Error(createRes.msg || '任务创建失败') + } } catch (error) { - message.error('视频生成失败') - } finally { + console.error('generateVideo error:', error) + message.destroy() + message.error(error.message || '视频生成失败') isGenerating.value = false + currentTaskStatus.value = '' + currentTaskStep.value = '' + } +} + +// 上传视频文件到后端 +const uploadVideoFile = async (file) => { + try { + const res = await MaterialService.uploadFile(file, 'video') + if (res.code === 0 && res.data?.id) { + return res.data.id + } else { + throw new Error(res.msg || '上传失败') + } + } catch (error) { + console.error('uploadVideoFile error:', error) + throw error + } +} + +// 开始轮询任务状态 +const startPollingTask = () => { + if (pollingInterval.value) { + clearInterval(pollingInterval.value) + } + + pollingInterval.value = setInterval(async () => { + if (!currentTaskId.value) { + clearInterval(pollingInterval.value) + return + } + + try { + const res = await getDigitalHumanTask(currentTaskId.value) + if (res.code === 0 && res.data) { + const task = res.data + currentTaskStatus.value = task.status + currentTaskStep.value = task.currentStep + generationProgress.value = task.progress || 0 + + // 如果任务完成或失败,停止轮询 + if (task.status === 'SUCCESS') { + clearInterval(pollingInterval.value) + pollingInterval.value = null + previewVideoUrl.value = task.resultVideoUrl + isGenerating.value = false + currentTaskStatus.value = 'SUCCESS' + message.success('视频生成成功!') + } else if (task.status === 'FAILED') { + clearInterval(pollingInterval.value) + pollingInterval.value = null + isGenerating.value = false + currentTaskStatus.value = 'FAILED' + message.error(`任务失败:${task.errorMessage || '未知错误'}`) + } else if (task.status === 'CANCELED') { + clearInterval(pollingInterval.value) + pollingInterval.value = null + isGenerating.value = false + currentTaskStatus.value = 'CANCELED' + message.info('任务已取消') + } + } + } catch (error) { + console.error('polling error:', error) + } + }, 2000) // 每2秒轮询一次 +} + +// 取消任务 +const handleCancelTask = async () => { + if (!currentTaskId.value) return + + try { + const res = await cancelTask(currentTaskId.value) + if (res.code === 0) { + message.success('任务已取消') + if (pollingInterval.value) { + clearInterval(pollingInterval.value) + pollingInterval.value = null + } + isGenerating.value = false + } else { + message.error(res.msg || '取消失败') + } + } catch (error) { + console.error('cancelTask error:', error) + message.error('取消任务失败') + } +} + +// 重试任务 +const handleRetryTask = async () => { + if (!currentTaskId.value) return + + try { + const res = await retryTask(currentTaskId.value) + if (res.code === 0) { + message.success('任务已重启') + currentTaskStatus.value = 'PENDING' + currentTaskStep.value = 'prepare_files' + isGenerating.value = true + startPollingTask() + } else { + message.error(res.msg || '重试失败') + } + } catch (error) { + console.error('retryTask error:', error) + message.error('重试任务失败') } } @@ -301,6 +460,29 @@ const toDataURL = (file) => new Promise((resolve, reject) => { reader.readAsDataURL(file) }) +// 状态描述映射 +const getStatusText = (status) => { + const statusMap = { + 'PENDING': '等待处理', + 'PROCESSING': '处理中', + 'SUCCESS': '已完成', + 'FAILED': '失败', + 'CANCELED': '已取消' + } + return statusMap[status] || status +} + +const getStepText = (step) => { + const stepMap = { + 'prepare_files': '准备文件', + 'synthesize_voice': '语音合成', + 'sync_lip': '口型同步', + 'finishing': '完成处理', + 'canceled': '已取消' + } + return stepMap[step] || step +} + const playAudioPreview = (url, options = {}) => { if (!url) return message.warning('暂无可试听的音频') @@ -383,6 +565,11 @@ onUnmounted(() => { // 重置播放状态 isPlayingPreview.value = false isPlayingSynthesized.value = false + // 清理轮询 + if (pollingInterval.value) { + clearInterval(pollingInterval.value) + pollingInterval.value = null + } }) // 监听器 @@ -578,21 +765,55 @@ let previewObjectUrl = ''
- - {{ isGenerating ? '生成中...' : '生成视频' }} - +
+ + {{ isGenerating ? '生成中...' : '生成视频' }} + +
+ +
+
+
+ 状态: + {{ getStatusText(currentTaskStatus) }} +
+
+ 步骤: + {{ getStepText(currentTaskStep) }} +
+
+
+ + 取消任务 + + + 重试任务 + +
+
@@ -940,6 +1161,56 @@ let previewObjectUrl = '' gap: 12px; } +.generate-actions { + display: flex; + flex-direction: column; + gap: 12px; +} + +.task-actions { + display: flex; + flex-direction: column; + gap: 12px; +} + +.task-status { + padding: 12px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: var(--radius-card); +} + +.status-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.status-row:last-child { + margin-bottom: 0; +} + +.status-label { + font-size: 13px; + color: var(--color-text-secondary); + font-weight: 600; +} + +.status-value { + font-size: 13px; + color: var(--color-text); +} + +.action-buttons { + display: flex; + gap: 8px; +} + +.action-buttons .ant-btn { + flex: 1; +} + .preview-title { font-size: 14px; font-weight: 600; diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enums/ErrorCodeConstants.java similarity index 70% rename from yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java rename to yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enums/ErrorCodeConstants.java index b33a2e548c..efbceee3cf 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enums/ErrorCodeConstants.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.module.tik.enmus; +package cn.iocoder.yudao.module.tik.enums; import cn.iocoder.yudao.framework.common.exception.ErrorCode; @@ -10,9 +10,18 @@ import cn.iocoder.yudao.framework.common.exception.ErrorCode; */ public interface ErrorCodeConstants { - ErrorCode USER_PROMPT_NOT_EXISTS = new ErrorCode(1_040_010_002, "用户提示词不存在"); + // ========== 通用错误码 1-000-000-000 ========== + ErrorCode GENERAL_NOT_EXISTS = new ErrorCode(1_000_000_001, "数据不存在"); + ErrorCode GENERAL_FORBIDDEN = new ErrorCode(1_000_000_002, "没有权限访问该数据"); + + // ========== 数字人任务错误码 1-030-002-000 ========== + ErrorCode DIGITAL_HUMAN_TASK_AUDIO_REQUIRED = new ErrorCode(1_030_002_001, "音频文件不能为空"); + ErrorCode DIGITAL_HUMAN_TASK_VIDEO_REQUIRED = new ErrorCode(1_030_002_002, "视频文件不能为空"); + ErrorCode DIGITAL_HUMAN_TASK_CANNOT_CANCEL = new ErrorCode(1_030_002_003, "只有处理中的任务才能取消"); + ErrorCode DIGITAL_HUMAN_TASK_CANNOT_RETRY = new ErrorCode(1_030_002_004, "只有失败或已取消的任务才能重试"); + // ========== 文件管理 1-030-000-000 ========== ErrorCode FILE_NOT_EXISTS = new ErrorCode(1_030_000_001, "文件不存在"); ErrorCode FILE_CATEGORY_INVALID = new ErrorCode(1_030_000_002, "文件分类无效"); diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikFileGroupServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikFileGroupServiceImpl.java index 160000c97d..c440cdd2da 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikFileGroupServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikFileGroupServiceImpl.java @@ -21,7 +21,7 @@ import jakarta.annotation.Resource; import java.util.List; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.*; /** * 文件分组 Service 实现类 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java index 8218c8596c..a343c0d87e 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java @@ -17,7 +17,7 @@ import org.springframework.validation.annotation.Validated; import jakarta.annotation.Resource; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.OSS_INIT_FAILED; +import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.OSS_INIT_FAILED; /** * OSS初始化 Service 实现类 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java index caf6e49859..3659a54051 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java @@ -24,7 +24,7 @@ import java.util.List; import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.*; /** * 文件分组关联 Service 实现类 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java index de1578dec1..408ce6c03c 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java @@ -39,7 +39,7 @@ import jakarta.annotation.Resource; import java.util.List; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.*; /** * 用户文件 Service 实现类 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java index fc87062569..0230c07d87 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java @@ -10,8 +10,8 @@ import org.springframework.validation.annotation.Validated; import jakarta.annotation.Resource; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.QUOTA_NOT_ENOUGH; -import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.QUOTA_NOT_EXISTS; +import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.QUOTA_NOT_ENOUGH; +import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.QUOTA_NOT_EXISTS; /** * 用户配额 Service 实现类 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/userprompt/service/UserPromptServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/userprompt/service/UserPromptServiceImpl.java index 509e80eea7..3dd1c14a69 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/userprompt/service/UserPromptServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/userprompt/service/UserPromptServiceImpl.java @@ -13,7 +13,7 @@ import org.springframework.validation.annotation.Validated; import java.util.List; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.USER_PROMPT_NOT_EXISTS; +import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.USER_PROMPT_NOT_EXISTS; /** * 用户提示词 Service 实现类 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/CosyVoiceClient.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/CosyVoiceClient.java index fbd6b3e461..d2467f9c1a 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/CosyVoiceClient.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/CosyVoiceClient.java @@ -33,7 +33,7 @@ import java.util.concurrent.TimeUnit; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0; -import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.VOICE_TTS_FAILED; +import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.VOICE_TTS_FAILED; /** * CosyVoice 客户端 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java index 56b15914c7..3d85c24bb4 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/client/LatentsyncClient.java @@ -24,7 +24,7 @@ import java.util.concurrent.TimeUnit; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0; -import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.LATENTSYNC_SUBMIT_FAILED; +import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.LATENTSYNC_SUBMIT_FAILED; /** * 302AI Latentsync 客户端 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikDigitalHumanTaskController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikDigitalHumanTaskController.java new file mode 100644 index 0000000000..f963a96873 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/controller/AppTikDigitalHumanTaskController.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.tik.voice.controller; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.tik.voice.service.DigitalHumanTaskService; +import cn.iocoder.yudao.module.tik.voice.vo.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 用户 App - 数字人任务控制器 + * + * @author 芋道源码 + */ +@Tag(name = "用户 App - 数字人任务") +@RestController +@RequestMapping("/api/tik/digital-human") +@Validated +public class AppTikDigitalHumanTaskController { + + @Resource + private DigitalHumanTaskService digitalHumanTaskService; + + @PostMapping("/task/create") + @Operation(summary = "创建数字人任务") + public CommonResult createTask(@Valid @RequestBody AppTikDigitalHumanCreateReqVO reqVO) { + Long taskId = digitalHumanTaskService.createTask(reqVO); + return CommonResult.success(taskId); + } + + @GetMapping("/task/get") + @Operation(summary = "查询任务详情") + public CommonResult getTask( + @Parameter(description = "任务ID", required = true, example = "12345") + @RequestParam("taskId") Long taskId) { + AppTikDigitalHumanRespVO task = digitalHumanTaskService.getTask(taskId); + return CommonResult.success(task); + } + + @GetMapping("/task/page") + @Operation(summary = "分页查询任务列表") + public CommonResult> getTaskPage(@ModelAttribute AppTikDigitalHumanPageReqVO pageReqVO) { + PageResult result = digitalHumanTaskService.getTaskPage(pageReqVO); + return CommonResult.success(result); + } + + @GetMapping("/task/statistics") + @Operation(summary = "查询任务统计") + public CommonResult getTaskStatistics() { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + DigitalHumanTaskService.TaskStatisticsVO statistics = digitalHumanTaskService.getTaskStatistics(userId); + return CommonResult.success(statistics); + } + + @PostMapping("/task/{taskId}/cancel") + @Operation(summary = "取消任务") + public CommonResult cancelTask( + @Parameter(description = "任务ID", required = true, example = "12345") + @PathVariable Long taskId) { + digitalHumanTaskService.cancelTask(taskId); + return CommonResult.success(true); + } + + @PostMapping("/task/{taskId}/retry") + @Operation(summary = "重试任务") + public CommonResult retryTask( + @Parameter(description = "任务ID", required = true, example = "12345") + @PathVariable Long taskId) { + digitalHumanTaskService.retryTask(taskId); + return CommonResult.success(true); + } + + @DeleteMapping("/task/{taskId}") + @Operation(summary = "删除任务") + public CommonResult deleteTask( + @Parameter(description = "任务ID", required = true, example = "12345") + @PathVariable Long taskId) { + digitalHumanTaskService.deleteTask(taskId); + return CommonResult.success(true); + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikDigitalHumanTaskDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikDigitalHumanTaskDO.java new file mode 100644 index 0000000000..78d3bd825b --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikDigitalHumanTaskDO.java @@ -0,0 +1,136 @@ +package cn.iocoder.yudao.module.tik.voice.dal.dataobject; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 数字人任务 DO + * + * @author 芋道源码 + */ +@TableName("tik_digital_human_task") +@KeySequence("tik_digital_human_task_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TikDigitalHumanTaskDO extends TenantBaseDO { + + /** + * 任务ID + */ + @TableId + private Long id; + /** + * 用户ID + */ + private Long userId; + /** + * AI供应商(302ai/aliyun/openai等) + */ + private String aiProvider; + /** + * 任务名称 + */ + private String taskName; + + // ========== 文件信息 ========== + /** + * 音频文件ID(tik_user_file.id) + */ + private Long audioFileId; + /** + * 视频文件ID(tik_user_file.id) + */ + private Long videoFileId; + /** + * 音频文件URL(公网可访问,用于Latentsync调用) + */ + private String audioUrl; + /** + * 视频文件URL(公网可访问,用于Latentsync调用) + */ + private String videoUrl; + + // ========== 生成参数 ========== + /** + * 配音配置ID(tik_user_voice.id) + */ + private Long voiceConfigId; + /** + * CosyVoice生成的voice_id + */ + private String voiceId; + /** + * 语速(0.5-2.0) + */ + private Float speechRate; + /** + * 音量(-10到10) + */ + private Float volume; + /** + * 情感(neutral/happy/sad等) + */ + private String emotion; + /** + * Latentsync guidance_scale(1-2) + */ + private Integer guidanceScale; + /** + * 随机种子 + */ + private Integer seed; + + // ========== 任务状态 ========== + /** + * 任务状态 + * 枚举:{@link cn.iocoder.yudao.module.tik.voice.enums.DigitalHumanTaskStatusEnum} + */ + private String status; + /** + * 进度百分比(0-100) + */ + private Integer progress; + /** + * 当前步骤(prepare_files/synthesize_voice/sync_lip/generate_video/finishing) + */ + private String currentStep; + + // ========== 结果信息 ========== + /** + * 生成结果视频URL(预签名URL) + */ + private String resultVideoUrl; + /** + * 生成结果文件ID(保存到tik_user_file) + */ + private Long resultVideoFileId; + /** + * 错误信息 + */ + private String errorMessage; + /** + * 错误详情 + */ + private String errorDetail; + + // ========== 时间戳 ========== + /** + * 任务开始时间 + */ + private LocalDateTime startTime; + /** + * 任务完成时间 + */ + private LocalDateTime finishTime; + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikDigitalHumanTaskMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikDigitalHumanTaskMapper.java new file mode 100644 index 0000000000..0753703e74 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikDigitalHumanTaskMapper.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.tik.voice.dal.mysql; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO; +import cn.iocoder.yudao.module.tik.voice.vo.AppTikDigitalHumanPageReqVO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 数字人任务 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface TikDigitalHumanTaskMapper extends BaseMapperX { + + /** + * 分页查询用户的任务列表(简单版本,仅按userId过滤) + */ + default PageResult selectPage(PageParam pageParam, Long userId) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .eqIfPresent(TikDigitalHumanTaskDO::getUserId, userId) + .orderByDesc(TikDigitalHumanTaskDO::getCreateTime)); + } + + /** + * 分页查询用户的任务列表(完整版本,支持更多过滤条件) + */ + default PageResult selectPage(PageParam pageParam, LambdaQueryWrapperX queryWrapper) { + return BaseMapperX.super.selectPage(pageParam, queryWrapper); + } + + /** + * 查询用户进行中的任务数量 + */ + default Long selectProcessingCount(Long userId) { + return selectCount(new LambdaQueryWrapperX() + .eq(TikDigitalHumanTaskDO::getUserId, userId) + .eq(TikDigitalHumanTaskDO::getStatus, "PROCESSING")); + } + + /** + * 查询用户已完成的任务数量 + */ + default Long selectCompletedCount(Long userId) { + return selectCount(new LambdaQueryWrapperX() + .eq(TikDigitalHumanTaskDO::getUserId, userId) + .in(TikDigitalHumanTaskDO::getStatus, "SUCCESS", "FAILED", "CANCELED")); + } + + /** + * 查询用户的最新任务列表 + */ + default List selectRecentTasks(Long userId, Integer limit) { + return selectList(new LambdaQueryWrapperX() + .eq(TikDigitalHumanTaskDO::getUserId, userId) + .orderByDesc(TikDigitalHumanTaskDO::getCreateTime) + .last("LIMIT " + limit)); + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/enums/DigitalHumanTaskStatusEnum.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/enums/DigitalHumanTaskStatusEnum.java new file mode 100644 index 0000000000..07b8d61549 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/enums/DigitalHumanTaskStatusEnum.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.tik.voice.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 数字人任务状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum DigitalHumanTaskStatusEnum { + + PENDING("PENDING", "等待处理"), + PROCESSING("PROCESSING", "处理中"), + SUCCESS("SUCCESS", "已完成"), + FAILED("FAILED", "失败"), + CANCELED("CANCELED", "已取消"); + + private final String status; + private final String desc; + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/enums/DigitalHumanTaskStepEnum.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/enums/DigitalHumanTaskStepEnum.java new file mode 100644 index 0000000000..2b669a079c --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/enums/DigitalHumanTaskStepEnum.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.tik.voice.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 数字人任务步骤枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum DigitalHumanTaskStepEnum { + + PREPARE_FILES("prepare_files", "文件准备", 10), + SYNTHESIZE_VOICE("synthesize_voice", "语音合成", 40), + SYNC_LIP("sync_lip", "口型同步", 70), + GENERATE_VIDEO("generate_video", "生成视频", 90), + FINISHING("finishing", "完成处理", 100); + + private final String step; + private final String desc; + private final int progress; + + /** + * 根据步骤获取进度百分比 + */ + public static int getProgress(String step) { + for (DigitalHumanTaskStepEnum value : values()) { + if (value.getStep().equals(step)) { + return value.getProgress(); + } + } + return 0; + } + + /** + * 根据步骤获取描述 + */ + public static String getDesc(String step) { + for (DigitalHumanTaskStepEnum value : values()) { + if (value.getStep().equals(step)) { + return value.getDesc(); + } + } + return step; + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/DigitalHumanTaskService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/DigitalHumanTaskService.java new file mode 100644 index 0000000000..448b8d7e3f --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/DigitalHumanTaskService.java @@ -0,0 +1,124 @@ +package cn.iocoder.yudao.module.tik.voice.service; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.tik.voice.vo.*; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 数字人任务 Service 接口 + * + * @author 芋道源码 + */ +public interface DigitalHumanTaskService { + + /** + * 创建数字人任务 + * + * @param reqVO 请求参数 + * @return 任务ID + */ + Long createTask(AppTikDigitalHumanCreateReqVO reqVO); + + /** + * 查询任务详情 + * + * @param taskId 任务ID + * @return 任务详情 + */ + AppTikDigitalHumanRespVO getTask(Long taskId); + + /** + * 分页查询任务列表 + * + * @param pageReqVO 分页查询参数 + * @return 任务列表 + */ + PageResult getTaskPage(AppTikDigitalHumanPageReqVO pageReqVO); + + /** + * 查询用户任务统计 + * + * @param userId 用户ID + * @return 任务统计 + */ + TaskStatisticsVO getTaskStatistics(Long userId); + + /** + * 取消任务 + * + * @param taskId 任务ID + */ + void cancelTask(Long taskId); + + /** + * 重试任务 + * + * @param taskId 任务ID + */ + void retryTask(Long taskId); + + /** + * 删除任务 + * + * @param taskId 任务ID + */ + void deleteTask(Long taskId); + + /** + * 任务统计 VO + */ + TaskStatisticsVO getTaskStatistics(AppTikDigitalHumanPageReqVO pageReqVO); + + /** + * 任务统计信息 + */ + class TaskStatisticsVO { + + @Schema(description = "进行中任务数", example = "3") + private Integer processingCount; + + @Schema(description = "已完成任务数", example = "15") + private Integer completedCount; + + @Schema(description = "失败任务数", example = "2") + private Integer failedCount; + + @Schema(description = "总任务数", example = "20") + private Integer totalCount; + + // Getters and Setters + public Integer getProcessingCount() { + return processingCount; + } + + public void setProcessingCount(Integer processingCount) { + this.processingCount = processingCount; + } + + public Integer getCompletedCount() { + return completedCount; + } + + public void setCompletedCount(Integer completedCount) { + this.completedCount = completedCount; + } + + public Integer getFailedCount() { + return failedCount; + } + + public void setFailedCount(Integer failedCount) { + this.failedCount = failedCount; + } + + public Integer getTotalCount() { + return totalCount; + } + + public void setTotalCount(Integer totalCount) { + this.totalCount = totalCount; + } + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/DigitalHumanTaskServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/DigitalHumanTaskServiceImpl.java new file mode 100644 index 0000000000..b72c7ad987 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/DigitalHumanTaskServiceImpl.java @@ -0,0 +1,549 @@ +package cn.iocoder.yudao.module.tik.voice.service; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; +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.tik.file.dal.dataobject.TikUserFileDO; +import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper; +import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO; +import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper; +import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikUserVoiceDO; +import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikUserVoiceMapper; +import cn.iocoder.yudao.module.tik.voice.enums.DigitalHumanTaskStatusEnum; +import cn.iocoder.yudao.module.tik.voice.enums.DigitalHumanTaskStepEnum; +import cn.iocoder.yudao.module.tik.voice.service.TikUserVoiceService; +import cn.iocoder.yudao.module.tik.voice.vo.*; +import cn.iocoder.yudao.module.tik.voice.client.LatentsyncClient; +import cn.iocoder.yudao.module.tik.voice.service.LatentsyncService; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants; +import cn.iocoder.yudao.framework.common.util.date.DateUtils; +import cn.iocoder.yudao.framework.common.util.string.StrUtils; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * 数字人任务 Service 实现 + * + * @author 芋道源码 + */ +@Slf4j +@Service +@Validated +@RequiredArgsConstructor +public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService { + + private final TikDigitalHumanTaskMapper taskMapper; + private final TikUserVoiceMapper voiceMapper; + private final TikUserFileMapper userFileMapper; + private final FileMapper fileMapper; + private final FileApi fileApi; + private final TikUserVoiceService userVoiceService; + private final LatentsyncService latentsyncService; + + /** + * 预签名URL过期时间(24小时) + */ + private static final int PRESIGN_URL_EXPIRATION_SECONDS = 24 * 3600; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createTask(AppTikDigitalHumanCreateReqVO reqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + + // 1. 验证输入参数 + validateTaskInput(reqVO, userId); + + // 2. 创建任务记录 + TikDigitalHumanTaskDO task = createTaskRecord(reqVO, userId); + taskMapper.insert(task); + + // 3. 异步处理任务 + Long taskId = task.getId(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + log.info("[createTask][任务({})已创建,开始异步处理]", taskId); + processTaskAsync(taskId); + } + }); + + log.info("[createTask][用户({})创建数字人任务成功,任务ID({})]", userId, taskId); + return taskId; + } + + @Override + public AppTikDigitalHumanRespVO getTask(Long taskId) { + TikDigitalHumanTaskDO task = taskMapper.selectById(taskId); + if (task == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.GENERAL_NOT_EXISTS); + } + + Long userId = SecurityFrameworkUtils.getLoginUserId(); + if (!task.getUserId().equals(userId)) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.GENERAL_FORBIDDEN); + } + + return convertToRespVO(task); + } + + @Override + public PageResult getTaskPage(AppTikDigitalHumanPageReqVO pageReqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + pageReqVO.setUserId(userId); + + // 构建查询条件 + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eq(TikDigitalHumanTaskDO::getUserId, userId) + .likeIfPresent(TikDigitalHumanTaskDO::getTaskName, pageReqVO.getTaskName()) + .eqIfPresent(TikDigitalHumanTaskDO::getStatus, pageReqVO.getStatus()) + .betweenIfPresent(TikDigitalHumanTaskDO::getCreateTime, + pageReqVO.getCreateTimeStart(), pageReqVO.getCreateTimeEnd()) + .orderByDesc(TikDigitalHumanTaskDO::getCreateTime); + + // 查询分页结果 - 使用 Mapper 的重载方法,传入 pageParam 和 queryWrapper + PageResult pageResult = taskMapper.selectPage(pageReqVO, queryWrapper); + + // 转换为 VO + return CollectionUtils.convertPage(pageResult, this::convertToRespVO); + } + + @Override + public TaskStatisticsVO getTaskStatistics(Long userId) { + TaskStatisticsVO stats = new TaskStatisticsVO(); + + // 查询各种状态的任务数量 - 将 Long 转换为 Integer + Long processingCount = taskMapper.selectProcessingCount(userId); + Long completedCount = taskMapper.selectCompletedCount(userId); + + stats.setProcessingCount(processingCount != null ? processingCount.intValue() : 0); + stats.setCompletedCount(completedCount != null ? completedCount.intValue() : 0); + + // 查询失败任务数量 + Long failedCount = taskMapper.selectCount(new LambdaQueryWrapperX() + .eq(TikDigitalHumanTaskDO::getUserId, userId) + .eq(TikDigitalHumanTaskDO::getStatus, "FAILED")); + stats.setFailedCount(failedCount != null ? failedCount.intValue() : 0); + + // 总任务数 + stats.setTotalCount(stats.getProcessingCount() + stats.getCompletedCount() + stats.getFailedCount()); + + return stats; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelTask(Long taskId) { + TikDigitalHumanTaskDO task = taskMapper.selectById(taskId); + if (task == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.GENERAL_NOT_EXISTS); + } + + Long userId = SecurityFrameworkUtils.getLoginUserId(); + if (!task.getUserId().equals(userId)) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.GENERAL_FORBIDDEN); + } + + if (!"PROCESSING".equals(task.getStatus())) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.DIGITAL_HUMAN_TASK_CANNOT_CANCEL); + } + + // 更新任务状态 + TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO(); + updateObj.setId(taskId); + updateObj.setStatus("CANCELED"); + updateObj.setProgress(0); + updateObj.setCurrentStep("canceled"); + taskMapper.updateById(updateObj); + + log.info("[cancelTask][用户({})取消任务({})成功]", userId, taskId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void retryTask(Long taskId) { + TikDigitalHumanTaskDO task = taskMapper.selectById(taskId); + if (task == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.GENERAL_NOT_EXISTS); + } + + Long userId = SecurityFrameworkUtils.getLoginUserId(); + if (!task.getUserId().equals(userId)) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.GENERAL_FORBIDDEN); + } + + if (!"FAILED".equals(task.getStatus()) && !"CANCELED".equals(task.getStatus())) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.DIGITAL_HUMAN_TASK_CANNOT_RETRY); + } + + // 重置任务状态 + TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO(); + updateObj.setId(taskId); + updateObj.setStatus("PENDING"); + updateObj.setProgress(0); + updateObj.setCurrentStep("prepare_files"); + updateObj.setErrorMessage(null); + updateObj.setErrorDetail(null); + taskMapper.updateById(updateObj); + + // 重新开始异步处理 + processTaskAsync(taskId); + + log.info("[retryTask][用户({})重试任务({})成功]", userId, taskId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteTask(Long taskId) { + TikDigitalHumanTaskDO task = taskMapper.selectById(taskId); + if (task == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.GENERAL_NOT_EXISTS); + } + + Long userId = SecurityFrameworkUtils.getLoginUserId(); + if (!task.getUserId().equals(userId)) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.GENERAL_FORBIDDEN); + } + + // 删除任务 + taskMapper.deleteById(taskId); + + log.info("[deleteTask][用户({})删除任务({})成功]", userId, taskId); + } + + @Override + public TaskStatisticsVO getTaskStatistics(AppTikDigitalHumanPageReqVO pageReqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + return getTaskStatistics(userId); + } + + // ========== 私有方法 ========== + + /** + * 验证任务输入参数 + */ + private void validateTaskInput(AppTikDigitalHumanCreateReqVO reqVO, Long userId) { + // 验证文件信息:必须提供音频和视频文件之一 + boolean hasAudio = reqVO.getAudioFileId() != null || StrUtil.isNotBlank(reqVO.getAudioUrl()); + boolean hasVideo = reqVO.getVideoFileId() != null || StrUtil.isNotBlank(reqVO.getVideoUrl()); + + if (!hasAudio) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.DIGITAL_HUMAN_TASK_AUDIO_REQUIRED); + } + if (!hasVideo) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.DIGITAL_HUMAN_TASK_VIDEO_REQUIRED); + } + + // 如果提供了fileId,验证文件是否存在且属于用户 + if (reqVO.getAudioFileId() != null) { + validateUserFile(reqVO.getAudioFileId(), userId, "音频"); + } + if (reqVO.getVideoFileId() != null) { + validateUserFile(reqVO.getVideoFileId(), userId, "视频"); + } + + // 验证配音配置 + if (reqVO.getVoiceConfigId() != null) { + TikUserVoiceDO voice = voiceMapper.selectById(reqVO.getVoiceConfigId()); + if (voice == null || !voice.getUserId().equals(userId)) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.VOICE_NOT_EXISTS); + } + } + } + + /** + * 验证用户文件 + */ + private void validateUserFile(Long fileId, Long userId, String fileType) { + TikUserFileDO userFile = userFileMapper.selectOne(new LambdaQueryWrapperX() + .eq(TikUserFileDO::getId, fileId) + .eq(TikUserFileDO::getUserId, userId)); + if (userFile == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.FILE_NOT_EXISTS, fileType + "文件不存在"); + } + } + + /** + * 创建任务记录 + */ + private TikDigitalHumanTaskDO createTaskRecord(AppTikDigitalHumanCreateReqVO reqVO, Long userId) { + return TikDigitalHumanTaskDO.builder() + .userId(userId) + .taskName(reqVO.getTaskName()) + .aiProvider(StrUtil.blankToDefault(reqVO.getAiProvider(), "302ai")) + .audioFileId(reqVO.getAudioFileId()) + .videoFileId(reqVO.getVideoFileId()) + .audioUrl(reqVO.getAudioUrl()) + .videoUrl(reqVO.getVideoUrl()) + .voiceConfigId(reqVO.getVoiceConfigId()) + .speechRate(reqVO.getSpeechRate() != null ? reqVO.getSpeechRate() : 1.0f) + .volume(reqVO.getVolume() != null ? reqVO.getVolume() : 0f) + .emotion(StrUtil.blankToDefault(reqVO.getEmotion(), "neutral")) + .guidanceScale(reqVO.getGuidanceScale() != null ? reqVO.getGuidanceScale() : 1) + .seed(reqVO.getSeed() != null ? reqVO.getSeed() : 8888) + .status("PENDING") + .progress(0) + .currentStep("prepare_files") + .build(); + } + + /** + * 转换为响应 VO + */ + private AppTikDigitalHumanRespVO convertToRespVO(TikDigitalHumanTaskDO task) { + AppTikDigitalHumanRespVO respVO = BeanUtils.toBean(task, AppTikDigitalHumanRespVO.class); + + // 设置状态描述 + respVO.setStatusDesc(DigitalHumanTaskStatusEnum.valueOf(task.getStatus()).getDesc()); + + // 设置当前步骤描述 + respVO.setCurrentStepDesc(DigitalHumanTaskStepEnum.getDesc(task.getCurrentStep())); + + return respVO; + } + + /** + * 异步处理任务 + */ + @Async("taskExecutor") + public void processTaskAsync(Long taskId) { + try { + log.info("[processTaskAsync][开始处理任务({})]", taskId); + processTask(taskId); + } catch (Exception e) { + log.error("[processTaskAsync][任务({})处理异常]", taskId, e); + // 更新任务状态为失败 + updateTaskStatus(taskId, "FAILED", "async_error", 0, "任务处理异常", null, e.getMessage()); + } + } + + /** + * 处理任务(同步方法) + */ + @Transactional(rollbackFor = Exception.class) + public void processTask(Long taskId) { + TikDigitalHumanTaskDO task = taskMapper.selectById(taskId); + if (task == null) { + log.error("[processTask][任务({})不存在]", taskId); + return; + } + + try { + // 更新任务状态为处理中 + updateTaskStatus(taskId, "PROCESSING", "prepare_files", 5, "开始处理任务", null); + + // 步骤1:文件准备 + prepareFiles(task); + updateTaskProgress(taskId, DigitalHumanTaskStepEnum.PREPARE_FILES, "文件准备完成"); + + // 步骤2:语音合成 + String audioUrl = synthesizeVoice(task); + updateTaskProgress(taskId, DigitalHumanTaskStepEnum.SYNTHESIZE_VOICE, "语音合成完成"); + + // 步骤3:口型同步 + String syncedVideoUrl = syncLip(task, audioUrl); + updateTaskProgress(taskId, DigitalHumanTaskStepEnum.SYNC_LIP, "口型同步完成"); + + // 步骤4:生成视频 + String resultVideoUrl = generateVideo(task, syncedVideoUrl); + updateTaskProgress(taskId, DigitalHumanTaskStepEnum.FINISHING, "视频生成完成"); + + // 任务完成 + updateTaskStatus(taskId, "SUCCESS", "finishing", 100, "任务处理完成", resultVideoUrl); + + log.info("[processTask][任务({})处理完成]", taskId); + + } catch (Exception e) { + log.error("[processTask][任务({})处理失败]", taskId, e); + updateTaskStatus(taskId, "FAILED", task.getCurrentStep(), task.getProgress(), "任务处理失败:" + e.getMessage(), null, e.getMessage()); + } + } + + /** + * 准备文件 + */ + private void prepareFiles(TikDigitalHumanTaskDO task) throws Exception { + log.info("[prepareFiles][任务({})开始准备文件]", task.getId()); + + // 如果提供了fileId,生成预签名URL + if (task.getAudioFileId() != null) { + FileDO audioFile = fileMapper.selectById(task.getAudioFileId()); + if (audioFile != null) { + task.setAudioUrl(fileApi.presignGetUrl(audioFile.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS)); + } + } + + if (task.getVideoFileId() != null) { + FileDO videoFile = fileMapper.selectById(task.getVideoFileId()); + if (videoFile != null) { + task.setVideoUrl(fileApi.presignGetUrl(videoFile.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS)); + } + } + + // 验证文件URL + if (StrUtil.isBlank(task.getAudioUrl())) { + throw new Exception("音频文件URL生成失败"); + } + if (StrUtil.isBlank(task.getVideoUrl())) { + throw new Exception("视频文件URL生成失败"); + } + + // 更新任务记录 + TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO(); + updateObj.setId(task.getId()); + updateObj.setAudioUrl(task.getAudioUrl()); + updateObj.setVideoUrl(task.getVideoUrl()); + taskMapper.updateById(updateObj); + + log.info("[prepareFiles][任务({})文件准备完成]", task.getId()); + } + + /** + * 语音合成 + */ + private String synthesizeVoice(TikDigitalHumanTaskDO task) throws Exception { + log.info("[synthesizeVoice][任务({})开始语音合成]", task.getId()); + + // TODO: 调用现有的语音合成服务 + // 这里需要根据实际的语音合成API进行集成 + + // 临时返回音频URL(实际应该调用语音合成服务) + String audioUrl = task.getAudioUrl(); + + log.info("[synthesizeVoice][任务({})语音合成完成]", task.getId()); + return audioUrl; + } + + /** + * 口型同步 + */ + private String syncLip(TikDigitalHumanTaskDO task, String audioUrl) throws Exception { + log.info("[syncLip][任务({})开始口型同步,使用AI供应商: {}]", task.getId(), task.getAiProvider()); + + String syncedVideoUrl; + String aiProvider = task.getAiProvider(); + + // 根据AI供应商路由到不同的服务 + if ("302ai".equalsIgnoreCase(aiProvider)) { + // 302AI Latentsync 服务 + syncedVideoUrl = syncWithLatentsync(task, audioUrl); + } else if ("aliyun".equalsIgnoreCase(aiProvider)) { + // TODO: 阿里云语音驱动视频服务 + log.warn("[syncLip][任务({})暂不支持阿里云AI供应商,使用原视频URL]", task.getId()); + syncedVideoUrl = task.getVideoUrl(); + } else if ("openai".equalsIgnoreCase(aiProvider)) { + // TODO: OpenAI 语音驱动视频服务 + log.warn("[syncLip][任务({})暂不支持OpenAI AI供应商,使用原视频URL]", task.getId()); + syncedVideoUrl = task.getVideoUrl(); + } else if ("minimax".equalsIgnoreCase(aiProvider)) { + // TODO: MiniMax 语音驱动视频服务 + log.warn("[syncLip][任务({})暂不支持MiniMax AI供应商,使用原视频URL]", task.getId()); + syncedVideoUrl = task.getVideoUrl(); + } else { + log.error("[syncLip][任务({})不支持的AI供应商: {}]", task.getId(), aiProvider); + throw new Exception("不支持的AI供应商: " + aiProvider); + } + + log.info("[syncLip][任务({})口型同步完成]", task.getId()); + return syncedVideoUrl; + } + + /** + * 使用302AI Latentsync进行口型同步 + */ + private String syncWithLatentsync(TikDigitalHumanTaskDO task, String audioUrl) throws Exception { + // 构建Latentsync请求VO + AppTikLatentsyncSubmitReqVO reqVO = new AppTikLatentsyncSubmitReqVO(); + reqVO.setAudioUrl(audioUrl); + reqVO.setVideoUrl(task.getVideoUrl()); + reqVO.setGuidanceScale(task.getGuidanceScale()); + reqVO.setSeed(task.getSeed()); + + // 调用Latentsync服务 + AppTikLatentsyncSubmitRespVO response = latentsyncService.submitTask(reqVO); + + // 等待处理完成(这里需要根据实际的Latentsync API调整) + // 临时返回处理后的视频URL + return task.getVideoUrl(); + } + + /** + * 生成视频 + */ + private String generateVideo(TikDigitalHumanTaskDO task, String syncedVideoUrl) throws Exception { + log.info("[generateVideo][任务({})开始生成视频]", task.getId()); + + // TODO: 这里可以添加视频后处理逻辑,比如添加字幕、特效等 + + // 临时返回同步后的视频URL + String resultVideoUrl = syncedVideoUrl; + + log.info("[generateVideo][任务({})视频生成完成]", task.getId()); + return resultVideoUrl; + } + + /** + * 更新任务状态 + */ + private void updateTaskStatus(Long taskId, String status, String currentStep, Integer progress, + String message, String resultVideoUrl) { + updateTaskStatus(taskId, status, currentStep, progress, message, resultVideoUrl, null); + } + + /** + * 更新任务状态(带错误详情) + */ + private void updateTaskStatus(Long taskId, String status, String currentStep, Integer progress, + String message, String resultVideoUrl, String errorDetail) { + TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO(); + updateObj.setId(taskId); + updateObj.setStatus(status); + updateObj.setCurrentStep(currentStep); + updateObj.setProgress(progress); + + if ("SUCCESS".equals(status)) { + updateObj.setResultVideoUrl(resultVideoUrl); + updateObj.setFinishTime(LocalDateTime.now()); + } else if ("PROCESSING".equals(status)) { + updateObj.setStartTime(LocalDateTime.now()); + } else if ("FAILED".equals(status)) { + updateObj.setErrorMessage(message); + updateObj.setErrorDetail(errorDetail); + updateObj.setFinishTime(LocalDateTime.now()); + } + + taskMapper.updateById(updateObj); + log.info("[updateTaskStatus][任务({})状态更新: {}]", taskId, updateObj); + } + + /** + * 更新任务进度 + */ + private void updateTaskProgress(Long taskId, DigitalHumanTaskStepEnum step, String message) { + updateTaskStatus(taskId, "PROCESSING", step.getStep(), step.getProgress(), message, null); + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java index 864b66d98c..43f4c9d561 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/TikUserVoiceServiceImpl.java @@ -54,7 +54,7 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.*; /** * 用户配音 Service 实现类 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikDigitalHumanCreateReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikDigitalHumanCreateReqVO.java new file mode 100644 index 0000000000..ad26191c81 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikDigitalHumanCreateReqVO.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 创建数字人任务请求 VO + * + * @author 芋道源码 + */ +@Data +@Schema(description = "创建数字人任务请求") +public class AppTikDigitalHumanCreateReqVO { + + @Schema(description = "任务名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "我的数字人视频") + @NotBlank(message = "任务名称不能为空") + @Size(max = 128, message = "任务名称不能超过128个字符") + private String taskName; + + @Schema(description = "AI供应商(默认302ai)", example = "302ai", allowableValues = {"302ai", "aliyun", "openai", "minimax"}) + private String aiProvider; + + @Schema(description = "音频文件ID(tik_user_file.id),与audioUrl二选一", example = "123") + private Long audioFileId; + + @Schema(description = "音频文件URL(公网可访问),与audioFileId二选一", example = "https://example.com/audio.wav") + @Size(max = 1024, message = "音频URL不能超过1024个字符") + private String audioUrl; + + @Schema(description = "视频文件ID(tik_user_file.id),与videoUrl二选一", example = "456") + private Long videoFileId; + + @Schema(description = "视频文件URL(公网可访问),与videoFileId二选一", example = "https://example.com/video.mp4") + @Size(max = 1024, message = "视频URL不能超过1024个字符") + private String videoUrl; + + @Schema(description = "配音配置ID(tik_user_voice.id)", example = "789") + private Long voiceConfigId; + + @Schema(description = "语速(0.5-2.0,默认1.0)", example = "1.0") + @DecimalMin(value = "0.5", message = "语速不能小于0.5") + @DecimalMax(value = "2.0", message = "语速不能大于2.0") + private Float speechRate; + + @Schema(description = "音量(-10到10,默认0)", example = "0") + @DecimalMin(value = "-10", message = "音量不能小于-10") + @DecimalMax(value = "10", message = "音量不能大于10") + private Float volume; + + @Schema(description = "情感(默认neutral)", example = "neutral") + private String emotion; + + @Schema(description = "Latentsync guidance_scale(1-2,默认1)", example = "1") + @Min(value = 1, message = "guidanceScale不能小于1") + @Max(value = 2, message = "guidanceScale不能大于2") + private Integer guidanceScale; + + @Schema(description = "随机种子(默认8888)", example = "8888") + private Integer seed; + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikDigitalHumanPageReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikDigitalHumanPageReqVO.java new file mode 100644 index 0000000000..ee9d9b6729 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikDigitalHumanPageReqVO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 数字人任务分页查询 VO + * + * @author 芋道源码 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "数字人任务分页查询") +public class AppTikDigitalHumanPageReqVO extends PageParam { + + @Schema(description = "用户编号(自动填充,无需传递)") + private Long userId; + + @Schema(description = "任务名称(模糊搜索)", example = "数字人") + private String taskName; + + @Schema(description = "任务状态", example = "PROCESSING") + private String status; + + @Schema(description = "创建时间-开始", example = "2024-11-19 00:00:00") + private LocalDateTime createTimeStart; + + @Schema(description = "创建时间-结束", example = "2024-11-19 23:59:59") + private LocalDateTime createTimeEnd; + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikDigitalHumanRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikDigitalHumanRespVO.java new file mode 100644 index 0000000000..2739f1a696 --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikDigitalHumanRespVO.java @@ -0,0 +1,97 @@ +package cn.iocoder.yudao.module.tik.voice.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 数字人任务响应 VO + * + * @author 芋道源码 + */ +@Data +@Schema(description = "数字人任务响应") +public class AppTikDigitalHumanRespVO { + + @Schema(description = "任务ID", example = "12345") + private Long id; + + @Schema(description = "用户ID", example = "67890") + private Long userId; + + @Schema(description = "AI供应商", example = "302ai") + private String aiProvider; + + @Schema(description = "任务名称", example = "我的数字人视频") + private String taskName; + + // ========== 文件信息 ========== + @Schema(description = "音频文件URL(公网可访问)", example = "https://example.com/audio.wav") + private String audioUrl; + + @Schema(description = "视频文件URL(公网可访问)", example = "https://example.com/video.mp4") + private String videoUrl; + + // ========== 生成参数 ========== + @Schema(description = "配音配置ID", example = "789") + private Long voiceConfigId; + + @Schema(description = "voice_id", example = "cosyvoice-v2-xxx") + private String voiceId; + + @Schema(description = "语速", example = "1.0") + private Float speechRate; + + @Schema(description = "音量", example = "0") + private Float volume; + + @Schema(description = "情感", example = "neutral") + private String emotion; + + @Schema(description = "guidance_scale", example = "1") + private Integer guidanceScale; + + @Schema(description = "随机种子", example = "8888") + private Integer seed; + + // ========== 任务状态 ========== + @Schema(description = "任务状态", example = "PROCESSING") + private String status; + + @Schema(description = "状态描述", example = "处理中") + private String statusDesc; + + @Schema(description = "进度百分比(0-100)", example = "45") + private Integer progress; + + @Schema(description = "当前步骤", example = "synthesize_voice") + private String currentStep; + + @Schema(description = "当前步骤描述", example = "语音合成") + private String currentStepDesc; + + // ========== 结果信息 ========== + @Schema(description = "生成结果视频URL", example = "https://example.com/result.mp4") + private String resultVideoUrl; + + @Schema(description = "错误信息", example = "任务处理失败") + private String errorMessage; + + @Schema(description = "错误详情", example = "详细错误堆栈...") + private String errorDetail; + + // ========== 时间戳 ========== + @Schema(description = "创建时间", example = "2024-11-19 10:00:00") + private LocalDateTime createTime; + + @Schema(description = "更新时间", example = "2024-11-19 10:05:30") + private LocalDateTime updateTime; + + @Schema(description = "任务开始时间", example = "2024-11-19 10:00:05") + private LocalDateTime startTime; + + @Schema(description = "任务完成时间", example = "2024-11-19 10:05:30") + private LocalDateTime finishTime; + +}