Files
xiaoxia-saas/packages/domain/voice_clone_profile.py
T
灵应 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
feat(phase3): CosyVoice 配置 + VoiceCloneProfile/TTSJob 领域模型
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
2026-07-02 07:31:26 +08:00

262 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
}