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
300 lines
9.6 KiB
Python
300 lines
9.6 KiB
Python
"""TTSJob 领域模型 — Phase 3 CosyVoice 集成.
|
||
|
||
TTS 任务,用于管理文本转语音的合成请求。
|
||
|
||
状态机:
|
||
pending → processing → completed
|
||
↘ failed → pending (重试)
|
||
↘ cancelled
|
||
"""
|
||
|
||
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 TTSJobStatus(StrEnum):
|
||
"""TTS 任务状态枚举。"""
|
||
|
||
PENDING = "pending"
|
||
"""待处理(任务已创建,等待执行)"""
|
||
|
||
PROCESSING = "processing"
|
||
"""处理中(正在调用 CosyVoice API 合成)"""
|
||
|
||
COMPLETED = "completed"
|
||
"""已完成(音频合成成功)"""
|
||
|
||
FAILED = "failed"
|
||
"""失败(合成失败)"""
|
||
|
||
CANCELLED = "cancelled"
|
||
"""已取消(用户取消或系统取消)"""
|
||
|
||
|
||
# 终态集合
|
||
TERMINAL_STATUSES = frozenset({TTSJobStatus.COMPLETED, TTSJobStatus.FAILED, TTSJobStatus.CANCELLED})
|
||
|
||
# 合法状态转换
|
||
_VALID_TRANSITIONS: dict[TTSJobStatus, set[TTSJobStatus]] = {
|
||
TTSJobStatus.PENDING: {TTSJobStatus.PROCESSING, TTSJobStatus.FAILED, TTSJobStatus.CANCELLED},
|
||
TTSJobStatus.PROCESSING: {TTSJobStatus.COMPLETED, TTSJobStatus.FAILED, TTSJobStatus.CANCELLED},
|
||
TTSJobStatus.FAILED: {TTSJobStatus.PENDING}, # 重试回到 pending
|
||
}
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class TTSJob:
|
||
"""TTS 任务实体。
|
||
|
||
Attributes:
|
||
id: 任务唯一标识
|
||
user_id: 所属用户
|
||
project_id: 所属项目(可选)
|
||
voice_clone_profile_id: 关联的音色克隆档案 ID(可选,使用自定义音色时必填)
|
||
status: 当前状态
|
||
input_text: 输入文本
|
||
voice_id: 使用的音色 ID(CosyVoice 内置音色或克隆音色 ID)
|
||
voice_model: 使用的模型名称
|
||
output_audio_url: 输出音频文件 URL
|
||
output_audio_key: 输出音频 OSS key
|
||
duration: 音频时长(秒)
|
||
file_size: 文件大小(字节)
|
||
sample_rate: 采样率
|
||
format: 输出格式(mp3/wav/pcm)
|
||
error_message: 错误信息
|
||
retry_count: 已重试次数
|
||
max_retries: 最大重试次数
|
||
metadata: 扩展元数据(JSON)
|
||
started_at: 开始处理时间
|
||
completed_at: 完成时间
|
||
created_at: 创建时间
|
||
updated_at: 最后更新时间
|
||
"""
|
||
|
||
id: str
|
||
user_id: str
|
||
input_text: str
|
||
voice_id: str = ""
|
||
voice_model: str = ""
|
||
project_id: str = ""
|
||
voice_clone_profile_id: str = ""
|
||
status: TTSJobStatus = TTSJobStatus.PENDING
|
||
output_audio_url: str = ""
|
||
output_audio_key: str = ""
|
||
duration: float = 0.0
|
||
file_size: int = 0
|
||
sample_rate: int = 22050
|
||
format: str = "mp3"
|
||
error_message: str = ""
|
||
retry_count: int = 0
|
||
max_retries: int = 3
|
||
metadata: dict = field(default_factory=dict)
|
||
started_at: datetime | None = None
|
||
completed_at: datetime | None = None
|
||
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,
|
||
input_text: str,
|
||
*,
|
||
voice_id: str = "",
|
||
voice_model: str = "",
|
||
project_id: str = "",
|
||
voice_clone_profile_id: str = "",
|
||
sample_rate: int = 22050,
|
||
format: str = "mp3",
|
||
max_retries: int = 3,
|
||
metadata: dict | None = None,
|
||
) -> TTSJob:
|
||
"""创建 TTS 任务。
|
||
|
||
Args:
|
||
user_id: 用户 ID
|
||
input_text: 输入文本
|
||
voice_id: 使用的音色 ID
|
||
voice_model: 使用的模型名称
|
||
project_id: 项目 ID
|
||
voice_clone_profile_id: 关联的音色克隆档案 ID
|
||
sample_rate: 采样率
|
||
format: 输出格式
|
||
max_retries: 最大重试次数
|
||
metadata: 扩展元数据
|
||
|
||
Returns:
|
||
新建的 TTSJob 实例
|
||
|
||
Raises:
|
||
ValueError: 参数校验失败
|
||
"""
|
||
if not user_id.strip():
|
||
raise ValueError("user_id 不能为空")
|
||
if not input_text.strip():
|
||
raise ValueError("input_text 不能为空")
|
||
if len(input_text.strip()) > 10000:
|
||
raise ValueError("input_text 长度不能超过 10000 字符")
|
||
if format not in ("mp3", "wav", "pcm"):
|
||
raise ValueError(f"不支持的输出格式: {format},支持: mp3/wav/pcm")
|
||
|
||
return cls(
|
||
id=uuid4().hex,
|
||
user_id=user_id.strip(),
|
||
input_text=input_text.strip(),
|
||
voice_id=voice_id.strip(),
|
||
voice_model=voice_model.strip(),
|
||
project_id=project_id.strip(),
|
||
voice_clone_profile_id=voice_clone_profile_id.strip(),
|
||
sample_rate=sample_rate,
|
||
format=format.strip(),
|
||
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 == TTSJobStatus.FAILED and self.retry_count < self.max_retries
|
||
|
||
@property
|
||
def is_completed(self) -> bool:
|
||
"""是否已完成。"""
|
||
return self.status == TTSJobStatus.COMPLETED and bool(self.output_audio_url)
|
||
|
||
def transition_to(self, new_status: TTSJobStatus | str) -> None:
|
||
"""执行状态转换。
|
||
|
||
Args:
|
||
new_status: 目标状态
|
||
|
||
Raises:
|
||
ValueError: 非法状态转换
|
||
"""
|
||
if isinstance(new_status, str):
|
||
try:
|
||
new_status = TTSJobStatus(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(TTSJobStatus.PROCESSING)
|
||
self.started_at = datetime.now(timezone.utc)
|
||
self.error_message = ""
|
||
|
||
def mark_completed(
|
||
self,
|
||
output_audio_url: str,
|
||
*,
|
||
output_audio_key: str = "",
|
||
duration: float = 0.0,
|
||
file_size: int = 0,
|
||
) -> None:
|
||
"""标记为已完成。
|
||
|
||
Args:
|
||
output_audio_url: 输出音频 URL
|
||
output_audio_key: 输出音频 OSS key
|
||
duration: 音频时长
|
||
file_size: 文件大小
|
||
"""
|
||
if not output_audio_url.strip():
|
||
raise ValueError("output_audio_url 不能为空")
|
||
self.transition_to(TTSJobStatus.COMPLETED)
|
||
self.output_audio_url = output_audio_url.strip()
|
||
self.output_audio_key = output_audio_key.strip()
|
||
self.duration = duration
|
||
self.file_size = file_size
|
||
self.completed_at = datetime.now(timezone.utc)
|
||
self.error_message = ""
|
||
|
||
def mark_failed(self, error_message: str) -> None:
|
||
"""标记为失败。
|
||
|
||
Args:
|
||
error_message: 错误信息
|
||
"""
|
||
self.transition_to(TTSJobStatus.FAILED)
|
||
self.error_message = error_message
|
||
|
||
def mark_cancelled(self) -> None:
|
||
"""标记为已取消。"""
|
||
self.transition_to(TTSJobStatus.CANCELLED)
|
||
|
||
def prepare_retry(self) -> None:
|
||
"""准备重试:重置状态为 pending。
|
||
|
||
Raises:
|
||
ValueError: 不可重试
|
||
"""
|
||
if not self.is_retryable:
|
||
raise ValueError(
|
||
f"TTS 任务不可重试: status={self.status.value}, "
|
||
f"retry_count={self.retry_count}, max_retries={self.max_retries}"
|
||
)
|
||
self.retry_count += 1
|
||
self.transition_to(TTSJobStatus.PENDING)
|
||
self.error_message = ""
|
||
self.started_at = None
|
||
self.completed_at = None
|
||
|
||
def to_dict(self) -> dict:
|
||
"""序列化为字典。"""
|
||
return {
|
||
"id": self.id,
|
||
"user_id": self.user_id,
|
||
"project_id": self.project_id,
|
||
"voice_clone_profile_id": self.voice_clone_profile_id,
|
||
"status": self.status.value,
|
||
"input_text": self.input_text,
|
||
"voice_id": self.voice_id,
|
||
"voice_model": self.voice_model,
|
||
"output_audio_url": self.output_audio_url,
|
||
"output_audio_key": self.output_audio_key,
|
||
"duration": self.duration,
|
||
"file_size": self.file_size,
|
||
"sample_rate": self.sample_rate,
|
||
"format": self.format,
|
||
"error_message": self.error_message,
|
||
"retry_count": self.retry_count,
|
||
"max_retries": self.max_retries,
|
||
"is_retryable": self.is_retryable,
|
||
"is_completed": self.is_completed,
|
||
"metadata": self.metadata,
|
||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||
}
|