diff --git a/frontend/app/web-gold/src/api/digitalHuman.js b/frontend/app/web-gold/src/api/digitalHuman.js index eacf4b0ee7..4c4222f35d 100644 --- a/frontend/app/web-gold/src/api/digitalHuman.js +++ b/frontend/app/web-gold/src/api/digitalHuman.js @@ -76,12 +76,3 @@ export function deleteTask(taskId) { }) } -/** - * 获取任务输出文件的签名URL - */ -export function getSignedUrls(taskId) { - return request({ - url: `/webApi/api/tik/digital-human/task/${taskId}/signed-url`, - method: 'get' - }) -} diff --git a/frontend/app/web-gold/src/components/VoiceSelector.vue b/frontend/app/web-gold/src/components/VoiceSelector.vue index bdd5bfa5b7..b286625487 100644 --- a/frontend/app/web-gold/src/components/VoiceSelector.vue +++ b/frontend/app/web-gold/src/components/VoiceSelector.vue @@ -264,12 +264,12 @@ const initPlayer = (url) => { player.on('canplay', () => { isPlayerInitializing.value = false - // 发送音频时长和 base64 数据给父组件 + // 发送音频时长和 URL 给父组件 const durationMs = Math.floor(player.audio.duration * 1000) if (durationMs > 0) { emit('audioGenerated', { durationMs, - audioBase64: currentAudioBase64.value + audioUrl: audioUrl.value // 使用 URL(性能优化) }) } }) diff --git a/frontend/app/web-gold/src/views/kling/IdentifyFace.vue b/frontend/app/web-gold/src/views/kling/IdentifyFace.vue index f4bce64326..656900da37 100644 --- a/frontend/app/web-gold/src/views/kling/IdentifyFace.vue +++ b/frontend/app/web-gold/src/views/kling/IdentifyFace.vue @@ -326,13 +326,13 @@ function clearVideo() { store.resetProcess() } -function handleAudioGenerated(data: { durationMs: number; audioBase64: string }) { +function handleAudioGenerated(data: { durationMs: number; audioUrl: string }) { if (store.timeline && data.durationMs > 0) { store.timeline.audioDurationMs = data.durationMs } - if (data.audioBase64) { + if (data.audioUrl) { store.audioData = { - audioBase64: data.audioBase64, + audioUrl: data.audioUrl, format: 'mp3', durationMs: data.durationMs } diff --git a/frontend/app/web-gold/src/views/kling/stores/useDigitalHumanStore.ts b/frontend/app/web-gold/src/views/kling/stores/useDigitalHumanStore.ts index 8f2529401c..a43db8a38b 100644 --- a/frontend/app/web-gold/src/views/kling/stores/useDigitalHumanStore.ts +++ b/frontend/app/web-gold/src/views/kling/stores/useDigitalHumanStore.ts @@ -21,7 +21,7 @@ import type { VoiceMeta, Video, PipelinePhase, VideoStep, AudioStep, CreateStep, /** 音频数据 */ interface AudioData { - audioBase64: string + audioUrl: string // 预签名 URL format: string durationMs: number } @@ -373,14 +373,20 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => { providerType: DEFAULT_VOICE_PROVIDER, } as any) - if (res.code !== 0 || !res.data?.audioBase64) { + if (res.code !== 0 || !res.data?.audioUrl) { throw new Error(res.msg || '音频生成失败') } + // 使用 URL(性能优化) + const audioUrl = res.data.audioUrl + if (!audioUrl) { + throw new Error('音频生成失败:未返回音频URL') + } - const durationMs = await parseAudioDuration(res.data.audioBase64) + // 获取音频时长 + const durationMs = await getAudioDurationFromUrl(audioUrl) audioData.value = { - audioBase64: res.data.audioBase64, + audioUrl: audioUrl, format: 'mp3', durationMs, } @@ -447,10 +453,8 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => { kling_face_end_time: identifyData.value.faceEndTime, ai_provider: 'kling', voiceConfigId: voiceId, - pre_generated_audio: { - audioBase64: audioData.value.audioBase64, - format: audioData.value.format, - }, + // 使用预生成的音频 URL(性能优化) + audio_url: audioData.value.audioUrl, sound_end_time: audioData.value.durationMs, }) @@ -571,32 +575,24 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => { // ==================== 工具方法 ==================== - /** 解析音频时长 */ - async function parseAudioDuration(base64Data: string): Promise { - const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data - const binaryString = window.atob(base64) - const bytes = new Uint8Array(binaryString.length) - for (let i = 0; i < bytes.length; i++) { - bytes[i] = binaryString.charCodeAt(i) - } - + /** 从 URL 获取音频时长 */ + async function getAudioDurationFromUrl(url: string): Promise { return new Promise((resolve, reject) => { - const blob = new Blob([bytes], { type: 'audio/mp3' }) const audio = new Audio() - const objectUrl = URL.createObjectURL(blob) let resolved = false let lastDuration = 0 const timeoutId = setTimeout(() => { if (!resolved) { cleanup() - reject(new Error('音频解析超时')) + reject(new Error('音频加载超时')) } }, 15000) const cleanup = () => { clearTimeout(timeoutId) - URL.revokeObjectURL(objectUrl) + audio.pause() + audio.src = '' } const tryResolve = (duration: number, source: string) => { @@ -621,11 +617,11 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => { resolve(Math.floor(lastDuration * 1000) - 200) } else { cleanup() - reject(new Error('音频解析失败')) + reject(new Error('音频加载失败')) } } - audio.src = objectUrl + audio.src = url audio.load() }) } diff --git a/frontend/app/web-gold/src/views/kling/types/identify-face.ts b/frontend/app/web-gold/src/views/kling/types/identify-face.ts index 05a25cd9de..7ab5031876 100644 --- a/frontend/app/web-gold/src/views/kling/types/identify-face.ts +++ b/frontend/app/web-gold/src/views/kling/types/identify-face.ts @@ -95,9 +95,9 @@ export interface AudioState { * 音频数据接口 */ export interface AudioData { - audioBase64: string - audioUrl?: string + audioUrl: string // 预签名 URL(性能优化) format?: string + durationMs?: number // 音频时长(毫秒) } /** @@ -253,9 +253,6 @@ export interface LipSyncTaskData { ai_provider: string voiceId?: string // 系统预置音色ID voiceConfigId?: string // 用户配音ID(tik_user_voice.id) - pre_generated_audio?: { - audioBase64: string - format: string - } + audio_url?: string // 预生成音频 URL(性能优化) sound_end_time?: number } diff --git a/frontend/app/web-gold/src/views/system/task-management/digital-human-task/index.vue b/frontend/app/web-gold/src/views/system/task-management/digital-human-task/index.vue index 886ca636be..d712657adc 100644 --- a/frontend/app/web-gold/src/views/system/task-management/digital-human-task/index.vue +++ b/frontend/app/web-gold/src/views/system/task-management/digital-human-task/index.vue @@ -132,13 +132,6 @@ - - - @@ -146,13 +139,12 @@ import { ref, onMounted } from 'vue' import { message, Modal } from 'ant-design-vue' import { SearchOutlined, DownloadOutlined, DeleteOutlined, PlayCircleOutlined } from '@ant-design/icons-vue' -import { getDigitalHumanTaskPage, cancelTask, deleteTask, getSignedUrls } from '@/api/digitalHuman' +import { getDigitalHumanTaskPage, cancelTask, deleteTask } from '@/api/digitalHuman' import { formatDate } from '@/utils/file' import { useTaskList } from '@/views/system/task-management/composables/useTaskList' import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations' import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling' import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue' -import VideoPreviewModal from '@/components/VideoPreviewModal.vue' // 进度状态映射 const PROGRESS_STATUS = { @@ -175,41 +167,16 @@ const rowSelection = { onChange: (keys) => { selectedRowKeys.value = keys } } -// 视频预览状态 -const previewVisible = ref(false) -const previewUrl = ref('') -const previewTitle = ref('') - // 状态判断 const isStatus = (status, target) => status === target || status === target.toUpperCase() -// 预览视频 -const handlePreview = async (record) => { - if (!record.id) { - message.warning('任务信息不完整') +// 预览视频(直接打开链接) +const handlePreview = (record) => { + if (!record.resultVideoUrl) { + message.warning('该任务暂无视频结果,请稍后再试') return } - - // 显示加载提示 - const hideLoading = message.loading('正在获取视频地址...', 0) - - try { - // 调用后端API获取带签名的视频URL - const res = await getSignedUrls(record.id) - hideLoading() - - if (res.code === 0 && res.data && res.data.length > 0) { - previewUrl.value = res.data[0] - previewTitle.value = record.taskName || '视频预览' - previewVisible.value = true - } else { - message.error(res.msg || '获取视频地址失败') - } - } catch (error) { - hideLoading() - console.error('获取视频播放URL失败:', error) - message.error('获取视频地址失败,请重试') - } + window.open(record.resultVideoUrl, '_blank') } // 下载视频 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 index 1283c985db..42a20f7f8c 100644 --- 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 @@ -113,11 +113,21 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService { task.setPendingRecordId(pendingRecordId); // 保存预扣记录ID taskMapper.insert(task); - // 4. ✅ 立即处理预生成音频(保存为临时文件,供后续步骤使用) + // 4. ✅ 立即处理预生成音频(优先使用 URL,降级使用 base64) Long taskId = task.getId(); - if (reqVO.getPreGeneratedAudio() != null && StrUtil.isNotBlank(reqVO.getPreGeneratedAudio().getAudioBase64())) { + + // 优先使用前端传递的 audioUrl(性能优化:避免 base64 编解码) + if (StrUtil.isNotBlank(reqVO.getAudioUrl())) { + TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO(); + updateObj.setId(taskId); + updateObj.setAudioUrl(reqVO.getAudioUrl()); + taskMapper.updateById(updateObj); + log.info("[createTask][任务({})使用前端传递的音频URL][audioUrl={}]", taskId, reqVO.getAudioUrl()); + } + // 降级:处理 preGeneratedAudio.base64(兼容旧版本) + else if (reqVO.getPreGeneratedAudio() != null && StrUtil.isNotBlank(reqVO.getPreGeneratedAudio().getAudioBase64())) { try { - log.info("[createTask][任务({})正在保存预生成音频...]", taskId); + log.info("[createTask][任务({})正在保存预生成音频(base64)...]", taskId); String audioUrl = saveTempAudioFile(reqVO.getPreGeneratedAudio().getAudioBase64(), reqVO.getPreGeneratedAudio().getFormat()); // 更新任务记录,保存音频URL diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java index d7a03133a0..0dfa61a720 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java @@ -234,10 +234,10 @@ public class LatentsyncPollingService { TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj)); - // 3. 确认预扣(任务成功,实际扣费) + // 3. 确认预扣(任务成功,实际扣费)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤 if (task != null && task.getPendingRecordId() != null) { try { - pointsService.confirmPendingDeduct(task.getPendingRecordId()); + TenantUtils.executeIgnore(() -> pointsService.confirmPendingDeduct(task.getPendingRecordId())); log.info("[completeTask][任务({})成功,确认扣费,预扣记录ID({})]", taskId, task.getPendingRecordId()); } catch (Exception e) { log.error("[completeTask][确认扣费失败,taskId={},recordId={}]", taskId, task.getPendingRecordId(), e); @@ -276,10 +276,10 @@ public class LatentsyncPollingService { TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj)); - // 3. 取消预扣(任务失败,不扣费) + // 3. 取消预扣(任务失败,不扣费)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤 if (task != null && task.getPendingRecordId() != null) { try { - pointsService.cancelPendingDeduct(task.getPendingRecordId()); + TenantUtils.executeIgnore(() -> pointsService.cancelPendingDeduct(task.getPendingRecordId())); log.info("[markTaskFailed][任务({})失败,取消预扣,预扣记录ID({})]", taskId, task.getPendingRecordId()); } catch (Exception e) { log.error("[markTaskFailed][取消预扣失败,taskId={},recordId={}]", taskId, task.getPendingRecordId(), e); @@ -308,10 +308,10 @@ public class LatentsyncPollingService { if ("SUCCESS".equals(status)) { updateObj.setFinishTime(LocalDateTime.now()); - // 确认预扣(任务成功) + // 确认预扣(任务成功)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤 if (task != null && task.getPendingRecordId() != null) { try { - pointsService.confirmPendingDeduct(task.getPendingRecordId()); + TenantUtils.executeIgnore(() -> pointsService.confirmPendingDeduct(task.getPendingRecordId())); log.info("[updateTaskStatus][任务({})成功,确认扣费]", taskId); } catch (Exception e) { log.error("[updateTaskStatus][确认扣费失败,taskId={}]", taskId, e); @@ -322,10 +322,10 @@ public class LatentsyncPollingService { } else if ("FAILED".equals(status)) { updateObj.setErrorMessage(errorMessage); updateObj.setFinishTime(LocalDateTime.now()); - // 取消预扣(任务失败) + // 取消预扣(任务失败)- 使用 TenantUtils.executeIgnore 避免 MyBatis Plus 租户过滤 if (task != null && task.getPendingRecordId() != null) { try { - pointsService.cancelPendingDeduct(task.getPendingRecordId()); + TenantUtils.executeIgnore(() -> pointsService.cancelPendingDeduct(task.getPendingRecordId())); log.info("[updateTaskStatus][任务({})失败,取消预扣]", taskId); } catch (Exception e) { log.error("[updateTaskStatus][取消预扣失败,taskId={}]", taskId, e); 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 15f125f1cd..d0d6e9f8ac 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 @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.tik.voice.service; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpUtil; @@ -445,21 +446,21 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { log.error("[synthesizeVoice][积分扣减失败: {}]", e.getMessage()); } - // 不暴露OSS链接,直接返回Base64编码的音频数据 - String audioBase64 = Base64.getEncoder().encodeToString(ttsResult.getAudio()); + // 保存到 OSS 临时目录,返回预签名 URL(性能优化:避免 base64 编码) + String audioUrl = saveToTempOss(ttsResult.getAudio(), format); log.info("[synthesizeVoice][合成成功,配音编号({}),voiceId({}),format({}),audioSize={}]", voiceConfigId, finalVoiceId, format, ttsResult.getAudio().length); AppTikVoiceTtsRespVO respVO = new AppTikVoiceTtsRespVO(); respVO.setFileId(null); // 不返回fileId(避免暴露) - respVO.setAudioBase64(audioBase64); // 返回Base64数据,前端可直接播放 + respVO.setAudioUrl(audioUrl); // 返回预签名 URL,前端可直接播放 respVO.setFormat(format); respVO.setSampleRate(ttsResult.getSampleRate()); respVO.setRequestId(""); // 不返回Request ID(避免暴露技术细节) respVO.setVoiceId(finalVoiceId); saveSynthCache(cacheKey, new SynthCacheEntry( - Base64.getEncoder().encodeToString(ttsResult.getAudio()), + audioUrl, // 缓存 URL 而不是 base64 format, ttsResult.getSampleRate(), ttsResult.getRequestId(), @@ -631,6 +632,24 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { } } + /** + * 保存音频到临时 OSS 目录(预签名 URL,1小时过期) + * + * @param audioBytes 音频字节数组 + * @param format 音频格式(如 mp3) + * @return 预签名 URL + */ + private String saveToTempOss(byte[] audioBytes, String format) { + String fileName = "temp/tts/" + IdUtil.fastSimpleUUID() + "." + format; + String mimeType = "audio/" + format; + + // 上传到 OSS,返回文件路径 + String filePath = fileApi.createFile(audioBytes, fileName, "temp/tts", mimeType); + + // 返回预签名 URL(1小时过期) + return fileApi.presignGetUrl(filePath, 3600); + } + private String buildCacheKey(String prefix, String voiceId, String fileUrl, @@ -714,13 +733,13 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { } private AppTikVoiceTtsRespVO buildSynthResponseFromCache(AppTikVoiceTtsReqVO reqVO, SynthCacheEntry cache) { - // 直接使用缓存的Base64数据,不上传OSS + // 使用缓存的 URL(性能优化:避免重复上传) String format = defaultFormat(cache.getFormat(), reqVO.getAudioFormat()); String voiceId = StrUtil.blankToDefault(reqVO.getVoiceId(), cache.getVoiceId()); AppTikVoiceTtsRespVO respVO = new AppTikVoiceTtsRespVO(); respVO.setFileId(null); // 不返回fileId(避免暴露) - respVO.setAudioBase64(cache.getAudioBase64()); // 返回Base64数据 + respVO.setAudioUrl(cache.getAudioUrl()); // 返回预签名 URL respVO.setFormat(format); respVO.setSampleRate(cache.getSampleRate()); respVO.setRequestId(""); // 不返回Request ID(避免暴露技术细节) @@ -753,7 +772,7 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService { @lombok.NoArgsConstructor @lombok.AllArgsConstructor private static class SynthCacheEntry { - private String audioBase64; + private String audioUrl; // 改为存储 URL private String format; private Integer sampleRate; private String requestId; 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 index bfef0446bf..5c8f12b7cc 100644 --- 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 @@ -95,10 +95,14 @@ public class AppTikDigitalHumanCreateReqVO { @JsonProperty("pre_generated_audio") private PreGeneratedAudioVO preGeneratedAudio; + @Schema(description = "预生成音频URL(与 preGeneratedAudio 二选一,优先使用)", example = "https://oss.example.com/temp/tts/xxx.mp3") + @JsonProperty("audio_url") + private String audioUrl; + @Data @Schema(description = "预生成音频信息") public static class PreGeneratedAudioVO { - @Schema(description = "音频Base64数据", example = "data:audio/mp3;base64,...") + @Schema(description = "音频Base64数据(降级方案)", example = "data:audio/mp3;base64,...") private String audioBase64; @Schema(description = "音频格式", example = "mp3") diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsRespVO.java index 1f94f34151..8ddc3f1210 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsRespVO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/vo/AppTikVoiceTtsRespVO.java @@ -10,11 +10,10 @@ public class AppTikVoiceTtsRespVO { @Schema(description = "用户文件编号", example = "1024") private Long fileId; - @Schema(description = "音频Base64数据(可直接播放,使用 data:audio/...;base64,... 格式)") + @Schema(description = "音频Base64数据(降级方案,优先使用 audioUrl)") private String audioBase64; - @Schema(description = "音频播放地址(预签名 URL,已废弃,不推荐使用)") - @Deprecated + @Schema(description = "音频播放地址(预签名 URL,1小时过期)") private String audioUrl; @Schema(description = "音频格式", example = "mp3")