f7c8b441d6
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 173h2m54s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 173h2m59s
Deploy / Deploy Staging (push) Failing after 173h22m24s
CI/CD Pipeline / Frontend Lint (push) Failing after 173h22m54s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 173h23m2s
Task 3.01: CosyVoice 配置 - 在 SharedSettings 中添加 CosyVoice 配置字段 - 更新 .env.example 和 .env.production.example Task 3.02: VoiceCloneProfile 领域模型 - 创建 VoiceCloneProfile 实体(音色克隆档案) - 状态机: pending → processing → ready/failed → disabled - 支持重试机制(retry_count/max_retries) - 创建 VoiceCloneProfileRepository 端口接口 Task 3.03: TTSJob 领域模型 - 创建 TTSJob 实体(TTS 任务) - 关联 VoiceCloneProfile(voice_clone_profile_id) - 状态机: pending → processing → completed/failed → cancelled - 支持重试机制 - 创建 TTSJobRepository 端口接口 单元测试: - VoiceCloneProfile: 31 个测试用例 - TTSJob: 34 个测试用例 - 全部 65 个测试通过 遵循六边形架构: Domain → Port → Adapter
262 lines
8.1 KiB
Python
262 lines
8.1 KiB
Python
"""VoiceCloneProfile 领域模型 — Phase 3 CosyVoice 集成.
|
||
|
||
音色克隆档案,用于管理用户的自定义音色。
|
||
|
||
状态机:
|
||
pending → processing → ready
|
||
↘ failed → pending (重试)
|
||
↘ disabled
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
|
||
if sys.version_info >= (3, 11):
|
||
from enum import StrEnum
|
||
else:
|
||
from enum import Enum
|
||
|
||
class StrEnum(str, Enum):
|
||
pass
|
||
|
||
|
||
from uuid import uuid4
|
||
|
||
|
||
class VoiceCloneStatus(StrEnum):
|
||
"""音色克隆状态枚举。"""
|
||
|
||
PENDING = "pending"
|
||
"""待处理(音频已上传,等待克隆)"""
|
||
|
||
PROCESSING = "processing"
|
||
"""处理中(正在调用 CosyVoice API 克隆)"""
|
||
|
||
READY = "ready"
|
||
"""就绪(克隆完成,可用于 TTS)"""
|
||
|
||
FAILED = "failed"
|
||
"""失败(克隆失败)"""
|
||
|
||
DISABLED = "disabled"
|
||
"""已禁用(用户手动禁用或违规)"""
|
||
|
||
|
||
# 终态集合
|
||
TERMINAL_STATUSES = frozenset({VoiceCloneStatus.READY, VoiceCloneStatus.FAILED, VoiceCloneStatus.DISABLED})
|
||
|
||
# 合法状态转换
|
||
_VALID_TRANSITIONS: dict[VoiceCloneStatus, set[VoiceCloneStatus]] = {
|
||
VoiceCloneStatus.PENDING: {VoiceCloneStatus.PROCESSING, VoiceCloneStatus.FAILED, VoiceCloneStatus.DISABLED},
|
||
VoiceCloneStatus.PROCESSING: {VoiceCloneStatus.READY, VoiceCloneStatus.FAILED, VoiceCloneStatus.DISABLED},
|
||
VoiceCloneStatus.FAILED: {VoiceCloneStatus.PENDING}, # 重试回到 pending
|
||
VoiceCloneStatus.READY: {VoiceCloneStatus.DISABLED}, # 可以禁用已就绪的音色
|
||
}
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class VoiceCloneProfile:
|
||
"""音色克隆档案实体。
|
||
|
||
Attributes:
|
||
id: 档案唯一标识
|
||
user_id: 所属用户
|
||
name: 音色名称(用户可见)
|
||
description: 音色描述
|
||
status: 当前状态
|
||
source_audio_url: 源音频文件 URL(用户上传的用于克隆的音频)
|
||
voice_id: CosyVoice 返回的音色 ID(克隆成功后填充)
|
||
voice_model: 使用的模型名称
|
||
language: 语言代码(如 zh-CN, en-US)
|
||
gender: 性别(male/female/unknown)
|
||
error_message: 错误信息
|
||
retry_count: 已重试次数
|
||
max_retries: 最大重试次数
|
||
metadata: 扩展元数据(JSON)
|
||
created_at: 创建时间
|
||
updated_at: 最后更新时间
|
||
"""
|
||
|
||
id: str
|
||
user_id: str
|
||
name: str
|
||
description: str = ""
|
||
status: VoiceCloneStatus = VoiceCloneStatus.PENDING
|
||
source_audio_url: str = ""
|
||
voice_id: str = ""
|
||
voice_model: str = ""
|
||
language: str = "zh-CN"
|
||
gender: str = "unknown"
|
||
error_message: str = ""
|
||
retry_count: int = 0
|
||
max_retries: int = 3
|
||
metadata: dict = field(default_factory=dict)
|
||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||
|
||
@classmethod
|
||
def create(
|
||
cls,
|
||
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: dict | None = None,
|
||
) -> VoiceCloneProfile:
|
||
"""创建音色克隆档案。
|
||
|
||
Args:
|
||
user_id: 用户 ID
|
||
name: 音色名称
|
||
description: 音色描述
|
||
source_audio_url: 源音频文件 URL
|
||
voice_model: 使用的模型名称
|
||
language: 语言代码
|
||
gender: 性别
|
||
max_retries: 最大重试次数
|
||
metadata: 扩展元数据
|
||
|
||
Returns:
|
||
新建的 VoiceCloneProfile 实例
|
||
|
||
Raises:
|
||
ValueError: 参数校验失败
|
||
"""
|
||
if not user_id.strip():
|
||
raise ValueError("user_id 不能为空")
|
||
if not name.strip():
|
||
raise ValueError("name 不能为空")
|
||
if len(name.strip()) > 100:
|
||
raise ValueError("name 长度不能超过 100 字符")
|
||
|
||
return cls(
|
||
id=uuid4().hex,
|
||
user_id=user_id.strip(),
|
||
name=name.strip(),
|
||
description=description.strip(),
|
||
source_audio_url=source_audio_url.strip(),
|
||
voice_model=voice_model.strip(),
|
||
language=language.strip(),
|
||
gender=gender.strip().lower(),
|
||
max_retries=max_retries,
|
||
metadata=metadata or {},
|
||
)
|
||
|
||
@property
|
||
def is_terminal(self) -> bool:
|
||
"""是否处于终态。"""
|
||
return self.status in TERMINAL_STATUSES
|
||
|
||
@property
|
||
def is_retryable(self) -> bool:
|
||
"""是否可重试(失败且未超过重试上限)。"""
|
||
return self.status == VoiceCloneStatus.FAILED and self.retry_count < self.max_retries
|
||
|
||
@property
|
||
def is_ready(self) -> bool:
|
||
"""是否就绪(可用于 TTS)。"""
|
||
return self.status == VoiceCloneStatus.READY and bool(self.voice_id)
|
||
|
||
def transition_to(self, new_status: VoiceCloneStatus | str) -> None:
|
||
"""执行状态转换。
|
||
|
||
Args:
|
||
new_status: 目标状态
|
||
|
||
Raises:
|
||
ValueError: 非法状态转换
|
||
"""
|
||
if isinstance(new_status, str):
|
||
try:
|
||
new_status = VoiceCloneStatus(new_status)
|
||
except ValueError:
|
||
raise ValueError(f"无效状态: {new_status}")
|
||
|
||
allowed = _VALID_TRANSITIONS.get(self.status, set())
|
||
if new_status not in allowed:
|
||
raise ValueError(
|
||
f"非法状态转换: {self.status.value} → {new_status.value},"
|
||
f"允许: {{{', '.join(s.value for s in allowed)}}}"
|
||
)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
self.status = new_status
|
||
self.updated_at = now
|
||
|
||
def mark_processing(self) -> None:
|
||
"""标记为处理中。"""
|
||
self.transition_to(VoiceCloneStatus.PROCESSING)
|
||
self.error_message = ""
|
||
|
||
def mark_ready(self, voice_id: str) -> None:
|
||
"""标记为就绪。
|
||
|
||
Args:
|
||
voice_id: CosyVoice 返回的音色 ID
|
||
"""
|
||
if not voice_id.strip():
|
||
raise ValueError("voice_id 不能为空")
|
||
self.transition_to(VoiceCloneStatus.READY)
|
||
self.voice_id = voice_id.strip()
|
||
self.error_message = ""
|
||
|
||
def mark_failed(self, error_message: str) -> None:
|
||
"""标记为失败。
|
||
|
||
Args:
|
||
error_message: 错误信息
|
||
"""
|
||
self.transition_to(VoiceCloneStatus.FAILED)
|
||
self.error_message = error_message
|
||
|
||
def mark_disabled(self) -> None:
|
||
"""标记为禁用。"""
|
||
self.transition_to(VoiceCloneStatus.DISABLED)
|
||
|
||
def prepare_retry(self) -> None:
|
||
"""准备重试:重置状态为 pending。
|
||
|
||
Raises:
|
||
ValueError: 不可重试
|
||
"""
|
||
if not self.is_retryable:
|
||
raise ValueError(
|
||
f"音色克隆不可重试: status={self.status.value}, "
|
||
f"retry_count={self.retry_count}, max_retries={self.max_retries}"
|
||
)
|
||
self.retry_count += 1
|
||
self.transition_to(VoiceCloneStatus.PENDING)
|
||
self.error_message = ""
|
||
self.voice_id = ""
|
||
|
||
def to_dict(self) -> dict:
|
||
"""序列化为字典。"""
|
||
return {
|
||
"id": self.id,
|
||
"user_id": self.user_id,
|
||
"name": self.name,
|
||
"description": self.description,
|
||
"status": self.status.value,
|
||
"source_audio_url": self.source_audio_url,
|
||
"voice_id": self.voice_id,
|
||
"voice_model": self.voice_model,
|
||
"language": self.language,
|
||
"gender": self.gender,
|
||
"error_message": self.error_message,
|
||
"retry_count": self.retry_count,
|
||
"max_retries": self.max_retries,
|
||
"is_retryable": self.is_retryable,
|
||
"is_ready": self.is_ready,
|
||
"metadata": self.metadata,
|
||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||
}
|