061b9cfcd6
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 6m58s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 2m31s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m24s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
293 lines
11 KiB
Python
Executable File
293 lines
11 KiB
Python
Executable File
"""Voice clone workflow orchestration — Phase 3.
|
||
|
||
编排音色克隆的完整流程:
|
||
1. 创建 VoiceCloneProfile
|
||
2. 提交 CosyVoice 克隆任务
|
||
3. 处理克隆结果(成功/失败)
|
||
4. 重试失败的克隆
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Optional
|
||
|
||
from packages.application.cosyvoice_service import (
|
||
CosyVoiceAuthError,
|
||
CosyVoiceError,
|
||
CosyVoiceService,
|
||
)
|
||
from packages.application.voice_clone.use_cases import (
|
||
CreateVoiceCloneUseCase,
|
||
RetryVoiceCloneUseCase,
|
||
VoiceCloneNotFoundError,
|
||
)
|
||
from packages.domain.voice_clone_profile import VoiceCloneProfile
|
||
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
|
||
from packages.shared.url_security import UrlSecurityError, validate_url_safety
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class VoiceCloneWorkflowError(Exception):
|
||
"""音色克隆工作流异常。"""
|
||
|
||
pass
|
||
|
||
|
||
class VoiceCloneWorkflowService:
|
||
"""音色克隆工作流编排服务。
|
||
|
||
协调 CreateVoiceCloneUseCase + CosyVoiceService,
|
||
实现完整的克隆生命周期管理。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
repository: VoiceCloneProfileRepository,
|
||
cosyvoice_service: CosyVoiceService,
|
||
) -> None:
|
||
self.repository = repository
|
||
self.cosyvoice_service = cosyvoice_service
|
||
|
||
def start_clone(
|
||
self,
|
||
user_id: str,
|
||
name: str,
|
||
*,
|
||
description: str = "",
|
||
source_audio_url: str = "",
|
||
voice_model: str = "",
|
||
language: str = "zh-CN",
|
||
gender: str = "unknown",
|
||
max_retries: int = 3,
|
||
metadata: Optional[dict] = None,
|
||
) -> VoiceCloneProfile:
|
||
"""启动音色克隆流程。
|
||
|
||
1. 创建 VoiceCloneProfile (pending)
|
||
2. 若有音频 URL:标记 processing → 提交 CosyVoice 克隆任务
|
||
若无音频 URL:保持 pending,等待用户上传
|
||
3. 保存 task_id 到 metadata
|
||
4. 返回 profile(Celery task 由调用方触发)
|
||
|
||
Args:
|
||
user_id: 用户 ID
|
||
name: 音色名称
|
||
description: 描述
|
||
source_audio_url: 参考音频 URL
|
||
voice_model: 模型名称
|
||
language: 语言
|
||
gender: 性别
|
||
max_retries: 最大重试次数
|
||
metadata: 扩展元数据
|
||
|
||
Returns:
|
||
VoiceCloneProfile: 已创建的 profile(有 URL 时为 processing,无 URL 时为 pending)
|
||
|
||
Raises:
|
||
VoiceCloneWorkflowError: CosyVoice 提交失败
|
||
"""
|
||
# 1. 创建 profile
|
||
create_use_case = CreateVoiceCloneUseCase(self.repository)
|
||
profile = create_use_case.execute(
|
||
user_id=user_id,
|
||
name=name,
|
||
description=description,
|
||
source_audio_url=source_audio_url,
|
||
voice_model=voice_model,
|
||
language=language,
|
||
gender=gender,
|
||
max_retries=max_retries,
|
||
metadata=metadata,
|
||
)
|
||
|
||
# 2. 提交 CosyVoice 克隆任务(仅有音频 URL 时才标记 processing)
|
||
if source_audio_url:
|
||
# SSRF 防护:校验音频 URL 安全性
|
||
try:
|
||
source_audio_url = validate_url_safety(source_audio_url, purpose="download")
|
||
except UrlSecurityError as e:
|
||
profile.mark_failed(f"音频URL安全校验失败: {e}")
|
||
profile = self.repository.update(profile)
|
||
logger.warning(f"音色克隆音频URL安全校验失败: profile_id={profile.id}, error={e}")
|
||
return profile
|
||
|
||
# 标记为 processing
|
||
profile.mark_processing()
|
||
profile = self.repository.update(profile)
|
||
|
||
try:
|
||
submit_result = self.cosyvoice_service.submit_clone_task(
|
||
audio_url=source_audio_url,
|
||
voice_name=name,
|
||
language=language,
|
||
)
|
||
|
||
# 4. 保存 voice_id / request_id 到 metadata
|
||
# 注意:key 保留 cosyvoice_task_id 以兼容旧数据,实际存的是 voice_id
|
||
task_metadata = dict(profile.metadata)
|
||
task_metadata["cosyvoice_task_id"] = submit_result.get("voice_id", "")
|
||
task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
|
||
|
||
# 如果 CosyVoice 直接返回了 OK 状态,直接标记 ready
|
||
voice_id = submit_result.get("voice_id", "")
|
||
status = submit_result.get("status", "").upper()
|
||
if voice_id and status == "OK":
|
||
profile.mark_ready(voice_id)
|
||
profile.metadata = task_metadata
|
||
profile = self.repository.update(profile)
|
||
logger.info(f"音色克隆同步完成: profile_id={profile.id}, voice_id={voice_id}")
|
||
return profile
|
||
|
||
profile.metadata = task_metadata
|
||
profile = self.repository.update(profile)
|
||
logger.info(
|
||
f"音色克隆任务已提交: profile_id={profile.id}, " f"voice_id={submit_result.get('voice_id')}"
|
||
)
|
||
|
||
except (CosyVoiceError, CosyVoiceAuthError) as e:
|
||
# CosyVoice 提交失败,标记为 failed
|
||
profile.mark_failed(str(e))
|
||
profile = self.repository.update(profile)
|
||
logger.error(f"音色克隆提交失败: profile_id={profile.id}, error={e}")
|
||
return profile
|
||
except ValueError as e:
|
||
profile.mark_failed(str(e))
|
||
profile = self.repository.update(profile)
|
||
logger.error(f"音色克隆参数错误: profile_id={profile.id}, error={e}")
|
||
return profile
|
||
else:
|
||
# 没有音频 URL,保持 pending 状态等待用户上传
|
||
logger.info(f"音色克隆已创建但无音频URL,保持pending: profile_id={profile.id}")
|
||
|
||
return profile
|
||
|
||
def poll_and_process_clone(self, profile_id: str, timeout: float = 300.0) -> VoiceCloneProfile:
|
||
"""轮询 CosyVoice 克隆任务并处理结果。
|
||
|
||
从 profile.metadata 获取 task_id,调用 CosyVoiceService.poll_clone_task()
|
||
轮询状态,然后通过 process_clone_result / process_clone_failure 更新 profile。
|
||
|
||
供 Celery 后台任务调用,避免直接访问私有方法。
|
||
"""
|
||
profile = self.repository.get(profile_id)
|
||
if profile is None:
|
||
raise VoiceCloneNotFoundError(f"Voice clone {profile_id} not found")
|
||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||
if not task_id:
|
||
raise ValueError(f"VoiceCloneProfile {profile_id} has no cosyvoice_task_id in metadata")
|
||
result = self.cosyvoice_service.poll_clone_task(task_id, timeout=timeout)
|
||
return self.process_clone_result(profile_id, result["voice_id"])
|
||
|
||
def process_clone_result(self, profile_id: str, voice_id: str) -> VoiceCloneProfile:
|
||
"""处理克隆成功结果。
|
||
|
||
Args:
|
||
profile_id: Profile ID
|
||
voice_id: CosyVoice 返回的音色 ID
|
||
|
||
Returns:
|
||
VoiceCloneProfile: 更新后的 profile
|
||
|
||
Raises:
|
||
VoiceCloneNotFoundError: profile 不存在
|
||
"""
|
||
profile = self.repository.get(profile_id)
|
||
if profile is None:
|
||
raise VoiceCloneNotFoundError(f"Voice clone {profile_id} not found")
|
||
|
||
profile.mark_ready(voice_id)
|
||
profile = self.repository.update(profile)
|
||
logger.info(f"音色克隆成功: profile_id={profile_id}, voice_id={voice_id}")
|
||
return profile
|
||
|
||
def process_clone_failure(self, profile_id: str, error_message: str) -> VoiceCloneProfile:
|
||
"""处理克隆失败结果。
|
||
|
||
Args:
|
||
profile_id: Profile ID
|
||
error_message: 错误信息
|
||
|
||
Returns:
|
||
VoiceCloneProfile: 更新后的 profile
|
||
|
||
Raises:
|
||
VoiceCloneNotFoundError: profile 不存在
|
||
"""
|
||
profile = self.repository.get(profile_id)
|
||
if profile is None:
|
||
raise VoiceCloneNotFoundError(f"Voice clone {profile_id} not found")
|
||
|
||
profile.mark_failed(error_message)
|
||
profile = self.repository.update(profile)
|
||
logger.error(f"音色克隆失败: profile_id={profile_id}, error={error_message}")
|
||
return profile
|
||
|
||
def retry_clone(self, clone_id: str, user_id: str) -> VoiceCloneProfile:
|
||
"""重试失败的音色克隆。
|
||
|
||
1. 调用 RetryVoiceCloneUseCase 重置状态为 pending
|
||
2. 标记为 processing
|
||
3. 重新提交 CosyVoice 克隆任务
|
||
|
||
Args:
|
||
clone_id: Profile ID
|
||
user_id: 用户 ID
|
||
|
||
Returns:
|
||
VoiceCloneProfile: 更新后的 profile
|
||
|
||
Raises:
|
||
VoiceCloneNotFoundError: profile 不存在
|
||
VoiceCloneNotRetryableError: 不可重试
|
||
VoiceCloneWorkflowError: CosyVoice 提交失败
|
||
"""
|
||
# 1. 重置状态
|
||
retry_use_case = RetryVoiceCloneUseCase(self.repository)
|
||
profile = retry_use_case.execute(clone_id, user_id)
|
||
|
||
# 2. 标记为 processing
|
||
profile.mark_processing()
|
||
profile = self.repository.update(profile)
|
||
|
||
# 3. 重新提交 CosyVoice
|
||
if profile.source_audio_url:
|
||
# SSRF 防护:重新校验音频 URL 安全性
|
||
try:
|
||
validate_url_safety(profile.source_audio_url, purpose="download")
|
||
except UrlSecurityError as e:
|
||
profile.mark_failed(f"音频URL安全校验失败: {e}")
|
||
profile = self.repository.update(profile)
|
||
logger.warning(f"音色克隆重试音频URL安全校验失败: profile_id={clone_id}, error={e}")
|
||
return profile
|
||
|
||
try:
|
||
submit_result = self.cosyvoice_service.submit_clone_task(
|
||
audio_url=profile.source_audio_url,
|
||
voice_name=profile.name,
|
||
language=profile.language,
|
||
)
|
||
|
||
task_metadata = dict(profile.metadata)
|
||
task_metadata["cosyvoice_task_id"] = submit_result.get("voice_id", "")
|
||
task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
|
||
|
||
voice_id = submit_result.get("voice_id", "")
|
||
status = submit_result.get("status", "").upper()
|
||
if voice_id and status == "OK":
|
||
profile.mark_ready(voice_id)
|
||
profile.metadata = task_metadata
|
||
profile = self.repository.update(profile)
|
||
return profile
|
||
|
||
profile.metadata = task_metadata
|
||
profile = self.repository.update(profile)
|
||
|
||
except (CosyVoiceError, CosyVoiceAuthError, ValueError) as e:
|
||
profile.mark_failed(str(e))
|
||
profile = self.repository.update(profile)
|
||
logger.error(f"音色克隆重试提交失败: profile_id={clone_id}, error={e}")
|
||
|
||
return profile
|