Files
xiaoxia-saas/packages/domain/tts_job.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

300 lines
9.6 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.
"""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: 使用的音色 IDCosyVoice 内置音色或克隆音色 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,
}