feat: 功能优化

This commit is contained in:
2025-11-20 00:49:18 +08:00
parent cc5401e743
commit bd367c645b
23 changed files with 1631 additions and 110 deletions

View File

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

View File

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

View File

@@ -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 = ''
<!-- 生成按钮 -->
<div class="generate-section">
<a-button
type="primary"
size="large"
block
:disabled="!canGenerate"
:loading="isGenerating"
@click="generateVideo"
>
{{ isGenerating ? '生成中...' : '生成视频' }}
</a-button>
<div v-if="!currentTaskId || currentTaskStatus === 'SUCCESS' || currentTaskStatus === 'FAILED' || currentTaskStatus === 'CANCELED'" class="generate-actions">
<a-button
type="primary"
size="large"
block
:disabled="!canGenerate"
:loading="isGenerating"
@click="generateVideo"
>
{{ isGenerating ? '生成中...' : '生成视频' }}
</a-button>
</div>
<div v-else class="task-actions">
<div class="task-status">
<div class="status-row">
<span class="status-label">状态</span>
<span class="status-value">{{ getStatusText(currentTaskStatus) }}</span>
</div>
<div class="status-row">
<span class="status-label">步骤</span>
<span class="status-value">{{ getStepText(currentTaskStep) }}</span>
</div>
</div>
<div class="action-buttons">
<a-button
v-if="currentTaskStatus === 'PROCESSING'"
danger
block
@click="handleCancelTask"
>
取消任务
</a-button>
<a-button
v-if="currentTaskStatus === 'FAILED' || currentTaskStatus === 'CANCELED'"
type="primary"
block
@click="handleRetryTask"
>
重试任务
</a-button>
</div>
</div>
<a-progress
v-if="isGenerating"
:percent="generationProgress"
:show-info="true"
:status="currentTaskStatus === 'FAILED' ? 'exception' : 'active'"
/>
</div>
</section>
@@ -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;

View File

@@ -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, "文件分类无效");

View File

@@ -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 实现类

View File

@@ -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 实现类

View File

@@ -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 实现类

View File

@@ -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 实现类

View File

@@ -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 实现类

View File

@@ -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 实现类

View File

@@ -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 客户端

View File

@@ -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 客户端

View File

@@ -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<Long> createTask(@Valid @RequestBody AppTikDigitalHumanCreateReqVO reqVO) {
Long taskId = digitalHumanTaskService.createTask(reqVO);
return CommonResult.success(taskId);
}
@GetMapping("/task/get")
@Operation(summary = "查询任务详情")
public CommonResult<AppTikDigitalHumanRespVO> 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<PageResult<AppTikDigitalHumanRespVO>> getTaskPage(@ModelAttribute AppTikDigitalHumanPageReqVO pageReqVO) {
PageResult<AppTikDigitalHumanRespVO> result = digitalHumanTaskService.getTaskPage(pageReqVO);
return CommonResult.success(result);
}
@GetMapping("/task/statistics")
@Operation(summary = "查询任务统计")
public CommonResult<DigitalHumanTaskService.TaskStatisticsVO> getTaskStatistics() {
Long userId = SecurityFrameworkUtils.getLoginUserId();
DigitalHumanTaskService.TaskStatisticsVO statistics = digitalHumanTaskService.getTaskStatistics(userId);
return CommonResult.success(statistics);
}
@PostMapping("/task/{taskId}/cancel")
@Operation(summary = "取消任务")
public CommonResult<Boolean> 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<Boolean> 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<Boolean> deleteTask(
@Parameter(description = "任务ID", required = true, example = "12345")
@PathVariable Long taskId) {
digitalHumanTaskService.deleteTask(taskId);
return CommonResult.success(true);
}
}

View File

@@ -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;
// ========== 文件信息 ==========
/**
* 音频文件IDtik_user_file.id
*/
private Long audioFileId;
/**
* 视频文件IDtik_user_file.id
*/
private Long videoFileId;
/**
* 音频文件URL公网可访问用于Latentsync调用
*/
private String audioUrl;
/**
* 视频文件URL公网可访问用于Latentsync调用
*/
private String videoUrl;
// ========== 生成参数 ==========
/**
* 配音配置IDtik_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_scale1-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;
}

View File

@@ -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<TikDigitalHumanTaskDO> {
/**
* 分页查询用户的任务列表简单版本仅按userId过滤
*/
default PageResult<TikDigitalHumanTaskDO> selectPage(PageParam pageParam, Long userId) {
return selectPage(pageParam, new LambdaQueryWrapperX<TikDigitalHumanTaskDO>()
.eqIfPresent(TikDigitalHumanTaskDO::getUserId, userId)
.orderByDesc(TikDigitalHumanTaskDO::getCreateTime));
}
/**
* 分页查询用户的任务列表(完整版本,支持更多过滤条件)
*/
default PageResult<TikDigitalHumanTaskDO> selectPage(PageParam pageParam, LambdaQueryWrapperX<TikDigitalHumanTaskDO> queryWrapper) {
return BaseMapperX.super.selectPage(pageParam, queryWrapper);
}
/**
* 查询用户进行中的任务数量
*/
default Long selectProcessingCount(Long userId) {
return selectCount(new LambdaQueryWrapperX<TikDigitalHumanTaskDO>()
.eq(TikDigitalHumanTaskDO::getUserId, userId)
.eq(TikDigitalHumanTaskDO::getStatus, "PROCESSING"));
}
/**
* 查询用户已完成的任务数量
*/
default Long selectCompletedCount(Long userId) {
return selectCount(new LambdaQueryWrapperX<TikDigitalHumanTaskDO>()
.eq(TikDigitalHumanTaskDO::getUserId, userId)
.in(TikDigitalHumanTaskDO::getStatus, "SUCCESS", "FAILED", "CANCELED"));
}
/**
* 查询用户的最新任务列表
*/
default List<TikDigitalHumanTaskDO> selectRecentTasks(Long userId, Integer limit) {
return selectList(new LambdaQueryWrapperX<TikDigitalHumanTaskDO>()
.eq(TikDigitalHumanTaskDO::getUserId, userId)
.orderByDesc(TikDigitalHumanTaskDO::getCreateTime)
.last("LIMIT " + limit));
}
}

View File

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

View File

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

View File

@@ -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<AppTikDigitalHumanRespVO> 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;
}
}
}

View File

@@ -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<AppTikDigitalHumanRespVO> getTaskPage(AppTikDigitalHumanPageReqVO pageReqVO) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
pageReqVO.setUserId(userId);
// 构建查询条件
LambdaQueryWrapperX<TikDigitalHumanTaskDO> queryWrapper = new LambdaQueryWrapperX<TikDigitalHumanTaskDO>()
.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<TikDigitalHumanTaskDO> 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<TikDigitalHumanTaskDO>()
.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<TikUserFileDO>()
.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);
}
}

View File

@@ -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 实现类

View File

@@ -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 = "音频文件IDtik_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 = "视频文件IDtik_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 = "配音配置IDtik_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_scale1-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;
}

View File

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

View File

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