feat(phase3): CosyVoice 配置 + VoiceCloneProfile/TTSJob 领域模型
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
This commit is contained in:
灵应
2026-07-02 07:31:26 +08:00
parent cce5c252f2
commit f7c8b441d6
9 changed files with 1343 additions and 0 deletions
+8
View File
@@ -42,3 +42,11 @@ OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY_ID=your-access-key-id OSS_ACCESS_KEY_ID=your-access-key-id
OSS_ACCESS_KEY_SECRET=your-access-key-secret OSS_ACCESS_KEY_SECRET=your-access-key-secret
OSS_BUCKET_NAME=xiaoxia-autocut OSS_BUCKET_NAME=xiaoxia-autocut
# ==================== CosyVoice 语音合成配置 ====================
COSYVOICE_API_KEY=your-cosyvoice-api-key
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
COSYVOICE_MODEL=cosyvoice-v1
COSYVOICE_VOICE=longxiaochun
COSYVOICE_SAMPLE_RATE=22050
COSYVOICE_FORMAT=mp3
+8
View File
@@ -41,6 +41,14 @@ OSS_BUCKET_NAME=xiaoxia-autocut
OSS_DIRECT_UPLOAD_MAX_MB=2000 OSS_DIRECT_UPLOAD_MAX_MB=2000
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900 OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
# ==================== CosyVoice 语音合成(必须配置)====================
COSYVOICE_API_KEY=CHANGE_ME_COSYVOICE_API_KEY
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
COSYVOICE_MODEL=cosyvoice-v1
COSYVOICE_VOICE=longxiaochun
COSYVOICE_SAMPLE_RATE=22050
COSYVOICE_FORMAT=mp3
# ==================== 生成文件 ==================== # ==================== 生成文件 ====================
GENERATED_FILES_DIR=/app/generated GENERATED_FILES_DIR=/app/generated
GENERATED_FILES_URL_PREFIX=/generated-files GENERATED_FILES_URL_PREFIX=/generated-files
+299
View File
@@ -0,0 +1,299 @@
"""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,
}
+261
View File
@@ -0,0 +1,261 @@
"""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,
}
+58
View File
@@ -0,0 +1,58 @@
"""TTSJobRepository 端口接口 — Phase 3 CosyVoice 集成."""
from __future__ import annotations
from typing import Protocol
from packages.domain.tts_job import TTSJob, TTSJobStatus
class TTSJobRepository(Protocol):
"""TTS 任务仓储接口。"""
def create(self, job: TTSJob) -> TTSJob:
"""持久化一个新 TTS 任务。"""
...
def get(self, job_id: str) -> TTSJob | None:
"""根据 ID 获取任务。"""
...
def update(self, job: TTSJob) -> TTSJob:
"""更新任务状态。"""
...
def delete(self, job_id: str) -> bool:
"""删除任务。"""
...
def list_by_user(
self,
user_id: str,
*,
status: TTSJobStatus | str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[TTSJob]:
"""按用户列出任务,支持状态过滤。"""
...
def count_by_user(
self,
user_id: str,
*,
status: TTSJobStatus | str | None = None,
) -> int:
"""按用户统计任务数量。"""
...
def list_by_profile(
self,
voice_clone_profile_id: str,
*,
status: TTSJobStatus | str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[TTSJob]:
"""按音色克隆档案列出任务。"""
...
@@ -0,0 +1,51 @@
"""VoiceCloneProfileRepository 端口接口 — Phase 3 CosyVoice 集成."""
from __future__ import annotations
from typing import Protocol
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
class VoiceCloneProfileRepository(Protocol):
"""音色克隆档案仓储接口。"""
def create(self, profile: VoiceCloneProfile) -> VoiceCloneProfile:
"""持久化一个新音色克隆档案。"""
...
def get(self, profile_id: str) -> VoiceCloneProfile | None:
"""根据 ID 获取档案。"""
...
def update(self, profile: VoiceCloneProfile) -> VoiceCloneProfile:
"""更新档案状态。"""
...
def delete(self, profile_id: str) -> bool:
"""删除档案。"""
...
def list_by_user(
self,
user_id: str,
*,
status: VoiceCloneStatus | str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[VoiceCloneProfile]:
"""按用户列出档案,支持状态过滤。"""
...
def count_by_user(
self,
user_id: str,
*,
status: VoiceCloneStatus | str | None = None,
) -> int:
"""按用户统计档案数量。"""
...
def find_by_voice_id(self, voice_id: str) -> VoiceCloneProfile | None:
"""根据 CosyVoice 返回的音色 ID 查找档案。"""
...
+8
View File
@@ -29,6 +29,14 @@ class SharedSettings(BaseSettings):
oss_access_key_secret: str = "" oss_access_key_secret: str = ""
oss_bucket_name: str = "xiaoxia-autocut" oss_bucket_name: str = "xiaoxia-autocut"
# CosyVoice (阿里云语音合成)
cosyvoice_api_key: str = ""
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio"
cosyvoice_model: str = "cosyvoice-v1"
cosyvoice_voice: str = "longxiaochun" # 默认音色
cosyvoice_sample_rate: int = 22050
cosyvoice_format: str = "mp3" # 输出格式:mp3/wav/pcm
# Environment # Environment
environment: str = "development" environment: str = "development"
auto_create_schema: bool = False auto_create_schema: bool = False
+344
View File
@@ -0,0 +1,344 @@
"""TTSJob 领域模型单元测试 — Phase 3 CosyVoice 集成."""
from __future__ import annotations
import pytest
from packages.domain.tts_job import TTSJob, TTSJobStatus
class TestTTSJobCreate:
"""测试 TTSJob.create() 工厂方法。"""
def test_create_success(self) -> None:
"""正常创建 TTS 任务。"""
job = TTSJob.create(
user_id="user_001",
input_text="这是一段测试文本",
voice_id="longxiaochun",
voice_model="cosyvoice-v1",
project_id="project_001",
voice_clone_profile_id="profile_001",
sample_rate=22050,
format="mp3",
)
assert job.id
assert job.user_id == "user_001"
assert job.input_text == "这是一段测试文本"
assert job.voice_id == "longxiaochun"
assert job.voice_model == "cosyvoice-v1"
assert job.project_id == "project_001"
assert job.voice_clone_profile_id == "profile_001"
assert job.status == TTSJobStatus.PENDING
assert job.sample_rate == 22050
assert job.format == "mp3"
assert job.retry_count == 0
assert job.max_retries == 3
def test_create_minimal(self) -> None:
"""使用最小参数创建。"""
job = TTSJob.create(user_id="user_001", input_text="测试文本")
assert job.user_id == "user_001"
assert job.input_text == "测试文本"
assert job.status == TTSJobStatus.PENDING
assert job.voice_id == ""
assert job.project_id == ""
assert job.voice_clone_profile_id == ""
assert job.format == "mp3"
def test_create_empty_user_id_raises(self) -> None:
"""空 user_id 应抛出 ValueError。"""
with pytest.raises(ValueError, match="user_id 不能为空"):
TTSJob.create(user_id="", input_text="测试")
def test_create_empty_input_text_raises(self) -> None:
"""空 input_text 应抛出 ValueError。"""
with pytest.raises(ValueError, match="input_text 不能为空"):
TTSJob.create(user_id="user_001", input_text="")
def test_create_whitespace_input_text_raises(self) -> None:
"""空白 input_text 应抛出 ValueError。"""
with pytest.raises(ValueError, match="input_text 不能为空"):
TTSJob.create(user_id="user_001", input_text=" ")
def test_create_input_text_too_long_raises(self) -> None:
"""input_text 超过 10000 字符应抛出 ValueError。"""
with pytest.raises(ValueError, match="input_text 长度不能超过 10000 字符"):
TTSJob.create(user_id="user_001", input_text="a" * 10001)
def test_create_invalid_format_raises(self) -> None:
"""不支持的输出格式应抛出 ValueError。"""
with pytest.raises(ValueError, match="不支持的输出格式"):
TTSJob.create(user_id="user_001", input_text="测试", format="aac")
def test_create_valid_formats(self) -> None:
"""所有支持的格式都应正常创建。"""
for fmt in ("mp3", "wav", "pcm"):
job = TTSJob.create(user_id="user_001", input_text="测试", format=fmt)
assert job.format == fmt
def test_create_strips_whitespace(self) -> None:
"""应去除首尾空白。"""
job = TTSJob.create(
user_id=" user_001 ",
input_text=" 测试文本 ",
voice_id=" voice_001 ",
)
assert job.user_id == "user_001"
assert job.input_text == "测试文本"
assert job.voice_id == "voice_001"
class TestTTSJobStatus:
"""测试状态相关属性和方法。"""
def test_initial_status_is_pending(self) -> None:
"""初始状态应为 PENDING。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
assert job.status == TTSJobStatus.PENDING
def test_is_terminal_pending(self) -> None:
"""PENDING 不是终态。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
assert not job.is_terminal
def test_is_terminal_completed(self) -> None:
"""COMPLETED 是终态。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/audio.mp3")
assert job.is_terminal
def test_is_terminal_failed(self) -> None:
"""FAILED 是终态。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_failed("合成失败")
assert job.is_terminal
def test_is_terminal_cancelled(self) -> None:
"""CANCELLED 是终态。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_cancelled()
assert job.is_terminal
def test_is_retryable_not_failed(self) -> None:
"""非 FAILED 状态不可重试。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
assert not job.is_retryable
def test_is_retryable_failed_under_limit(self) -> None:
"""FAILED 且未超过重试上限时可重试。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_failed("合成失败")
assert job.is_retryable
def test_is_retryable_failed_over_limit(self) -> None:
"""超过重试上限时不可重试。"""
job = TTSJob.create(user_id="user_001", input_text="测试", max_retries=1)
job.mark_processing()
job.mark_failed("第一次失败")
job.prepare_retry()
job.mark_processing()
job.mark_failed("第二次失败")
assert not job.is_retryable
def test_is_completed_with_url(self) -> None:
"""COMPLETED 且有 output_audio_url 时应返回 True。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/audio.mp3")
assert job.is_completed
def test_is_completed_without_url(self) -> None:
"""COMPLETED 但无 output_audio_url 时应返回 False。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.status = TTSJobStatus.COMPLETED
job.output_audio_url = ""
assert not job.is_completed
class TestTTSJobTransitions:
"""测试状态转换。"""
def test_mark_processing(self) -> None:
"""PENDING → PROCESSING 转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
assert job.status == TTSJobStatus.PROCESSING
assert job.error_message == ""
assert job.started_at is not None
def test_mark_completed(self) -> None:
"""PROCESSING → COMPLETED 转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_completed(
output_audio_url="https://example.com/audio.mp3",
output_audio_key="tts/audio.mp3",
duration=5.5,
file_size=102400,
)
assert job.status == TTSJobStatus.COMPLETED
assert job.output_audio_url == "https://example.com/audio.mp3"
assert job.output_audio_key == "tts/audio.mp3"
assert job.duration == 5.5
assert job.file_size == 102400
assert job.completed_at is not None
assert job.error_message == ""
def test_mark_completed_empty_url_raises(self) -> None:
"""mark_completed 空 output_audio_url 应抛出 ValueError。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
with pytest.raises(ValueError, match="output_audio_url 不能为空"):
job.mark_completed(output_audio_url="")
def test_mark_completed_minimal(self) -> None:
"""mark_completed 最小参数。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/audio.mp3")
assert job.status == TTSJobStatus.COMPLETED
assert job.duration == 0.0
assert job.file_size == 0
def test_mark_failed(self) -> None:
"""PROCESSING → FAILED 转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_failed("API 调用失败")
assert job.status == TTSJobStatus.FAILED
assert job.error_message == "API 调用失败"
def test_mark_cancelled_from_pending(self) -> None:
"""PENDING → CANCELLED 转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_mark_cancelled_from_processing(self) -> None:
"""PROCESSING → CANCELLED 转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_invalid_transition_raises(self) -> None:
"""非法状态转换应抛出 ValueError。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
with pytest.raises(ValueError, match="非法状态转换"):
job.mark_completed(output_audio_url="https://example.com/audio.mp3")
def test_invalid_status_string_raises(self) -> None:
"""无效状态字符串应抛出 ValueError。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("invalid_status")
def test_transition_to_with_string(self) -> None:
"""支持字符串形式的状态转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.transition_to("processing")
assert job.status == TTSJobStatus.PROCESSING
class TestTTSJobRetry:
"""测试重试逻辑。"""
def test_prepare_retry_success(self) -> None:
"""成功重试。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_failed("失败")
job.prepare_retry()
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 1
assert job.error_message == ""
assert job.started_at is None
assert job.completed_at is None
def test_prepare_retry_not_failed_raises(self) -> None:
"""非 FAILED 状态重试应抛出 ValueError。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_prepare_retry_over_limit_raises(self) -> None:
"""超过重试上限重试应抛出 ValueError。"""
job = TTSJob.create(user_id="user_001", input_text="测试", max_retries=1)
job.mark_processing()
job.mark_failed("第一次失败")
job.prepare_retry()
job.mark_processing()
job.mark_failed("第二次失败")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
class TestTTSJobToDict:
"""测试序列化。"""
def test_to_dict_contains_all_fields(self) -> None:
"""to_dict 应包含所有字段。"""
job = TTSJob.create(
user_id="user_001",
input_text="测试文本",
voice_id="longxiaochun",
voice_model="cosyvoice-v1",
project_id="project_001",
voice_clone_profile_id="profile_001",
sample_rate=22050,
format="mp3",
max_retries=5,
metadata={"key": "value"},
)
result = job.to_dict()
assert result["id"] == job.id
assert result["user_id"] == "user_001"
assert result["input_text"] == "测试文本"
assert result["voice_id"] == "longxiaochun"
assert result["voice_model"] == "cosyvoice-v1"
assert result["project_id"] == "project_001"
assert result["voice_clone_profile_id"] == "profile_001"
assert result["status"] == "pending"
assert result["sample_rate"] == 22050
assert result["format"] == "mp3"
assert result["retry_count"] == 0
assert result["max_retries"] == 5
assert result["is_retryable"] is False
assert result["is_completed"] is False
assert result["metadata"] == {"key": "value"}
assert result["started_at"] is None
assert result["completed_at"] is None
assert result["created_at"] is not None
assert result["updated_at"] is not None
def test_to_dict_after_completion(self) -> None:
"""任务完成后 to_dict 应反映最新状态。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_completed(
output_audio_url="https://example.com/audio.mp3",
duration=10.5,
file_size=204800,
)
result = job.to_dict()
assert result["status"] == "completed"
assert result["output_audio_url"] == "https://example.com/audio.mp3"
assert result["duration"] == 10.5
assert result["file_size"] == 204800
assert result["is_completed"] is True
assert result["started_at"] is not None
assert result["completed_at"] is not None
+306
View File
@@ -0,0 +1,306 @@
"""VoiceCloneProfile 领域模型单元测试 — Phase 3 CosyVoice 集成."""
from __future__ import annotations
import pytest
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
class TestVoiceCloneProfileCreate:
"""测试 VoiceCloneProfile.create() 工厂方法。"""
def test_create_success(self) -> None:
"""正常创建音色克隆档案。"""
profile = VoiceCloneProfile.create(
user_id="user_001",
name="我的音色",
description="用于配音的自定义音色",
source_audio_url="https://example.com/audio.wav",
voice_model="cosyvoice-v1",
language="zh-CN",
gender="female",
)
assert profile.id
assert profile.user_id == "user_001"
assert profile.name == "我的音色"
assert profile.description == "用于配音的自定义音色"
assert profile.status == VoiceCloneStatus.PENDING
assert profile.source_audio_url == "https://example.com/audio.wav"
assert profile.voice_model == "cosyvoice-v1"
assert profile.language == "zh-CN"
assert profile.gender == "female"
assert profile.retry_count == 0
assert profile.max_retries == 3
assert profile.created_at
assert profile.updated_at
def test_create_minimal(self) -> None:
"""使用最小参数创建。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试音色")
assert profile.user_id == "user_001"
assert profile.name == "测试音色"
assert profile.status == VoiceCloneStatus.PENDING
assert profile.description == ""
assert profile.source_audio_url == ""
assert profile.language == "zh-CN"
assert profile.gender == "unknown"
def test_create_empty_user_id_raises(self) -> None:
"""空 user_id 应抛出 ValueError。"""
with pytest.raises(ValueError, match="user_id 不能为空"):
VoiceCloneProfile.create(user_id="", name="测试")
def test_create_whitespace_user_id_raises(self) -> None:
"""空白 user_id 应抛出 ValueError。"""
with pytest.raises(ValueError, match="user_id 不能为空"):
VoiceCloneProfile.create(user_id=" ", name="测试")
def test_create_empty_name_raises(self) -> None:
"""空 name 应抛出 ValueError。"""
with pytest.raises(ValueError, match="name 不能为空"):
VoiceCloneProfile.create(user_id="user_001", name="")
def test_create_name_too_long_raises(self) -> None:
"""name 超过 100 字符应抛出 ValueError。"""
with pytest.raises(ValueError, match="name 长度不能超过 100 字符"):
VoiceCloneProfile.create(user_id="user_001", name="a" * 101)
def test_create_strips_whitespace(self) -> None:
"""应去除首尾空白。"""
profile = VoiceCloneProfile.create(
user_id=" user_001 ",
name=" 测试音色 ",
description=" 描述 ",
)
assert profile.user_id == "user_001"
assert profile.name == "测试音色"
assert profile.description == "描述"
def test_create_gender_normalized(self) -> None:
"""gender 应转换为小写。"""
profile = VoiceCloneProfile.create(
user_id="user_001",
name="测试",
gender="FEMALE",
)
assert profile.gender == "female"
class TestVoiceCloneProfileStatus:
"""测试状态相关属性和方法。"""
def test_initial_status_is_pending(self) -> None:
"""初始状态应为 PENDING。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
assert profile.status == VoiceCloneStatus.PENDING
def test_is_terminal_pending(self) -> None:
"""PENDING 不是终态。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
assert not profile.is_terminal
def test_is_terminal_ready(self) -> None:
"""READY 是终态。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_ready(voice_id="voice_001")
assert profile.is_terminal
def test_is_terminal_failed(self) -> None:
"""FAILED 是终态。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_failed("克隆失败")
assert profile.is_terminal
def test_is_retryable_not_failed(self) -> None:
"""非 FAILED 状态不可重试。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
assert not profile.is_retryable
def test_is_retryable_failed_under_limit(self) -> None:
"""FAILED 且未超过重试上限时可重试。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_failed("克隆失败")
assert profile.is_retryable
def test_is_retryable_failed_over_limit(self) -> None:
"""超过重试上限时不可重试。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试", max_retries=1)
profile.mark_processing()
profile.mark_failed("第一次失败")
profile.prepare_retry()
profile.mark_processing()
profile.mark_failed("第二次失败")
assert not profile.is_retryable
def test_is_ready_with_voice_id(self) -> None:
"""READY 且有 voice_id 时应返回 True。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_ready(voice_id="voice_001")
assert profile.is_ready
def test_is_ready_without_voice_id(self) -> None:
"""READY 但无 voice_id 时应返回 False。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.status = VoiceCloneStatus.READY
profile.voice_id = ""
assert not profile.is_ready
class TestVoiceCloneProfileTransitions:
"""测试状态转换。"""
def test_mark_processing(self) -> None:
"""PENDING → PROCESSING 转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
assert profile.status == VoiceCloneStatus.PROCESSING
assert profile.error_message == ""
def test_mark_ready(self) -> None:
"""PROCESSING → READY 转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_ready(voice_id="voice_001")
assert profile.status == VoiceCloneStatus.READY
assert profile.voice_id == "voice_001"
assert profile.error_message == ""
def test_mark_ready_empty_voice_id_raises(self) -> None:
"""mark_ready 空 voice_id 应抛出 ValueError。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
with pytest.raises(ValueError, match="voice_id 不能为空"):
profile.mark_ready(voice_id="")
def test_mark_failed(self) -> None:
"""PROCESSING → FAILED 转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_failed("API 调用失败")
assert profile.status == VoiceCloneStatus.FAILED
assert profile.error_message == "API 调用失败"
def test_mark_disabled_from_pending(self) -> None:
"""PENDING → DISABLED 转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_disabled()
assert profile.status == VoiceCloneStatus.DISABLED
def test_mark_disabled_from_ready(self) -> None:
"""READY → DISABLED 转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_ready(voice_id="voice_001")
profile.mark_disabled()
assert profile.status == VoiceCloneStatus.DISABLED
def test_invalid_transition_raises(self) -> None:
"""非法状态转换应抛出 ValueError。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
with pytest.raises(ValueError, match="非法状态转换"):
profile.mark_ready(voice_id="voice_001") # PENDING → READY 非法
def test_invalid_status_string_raises(self) -> None:
"""无效状态字符串应抛出 ValueError。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
with pytest.raises(ValueError, match="无效状态"):
profile.transition_to("invalid_status")
def test_transition_to_with_string(self) -> None:
"""支持字符串形式的状态转换。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.transition_to("processing")
assert profile.status == VoiceCloneStatus.PROCESSING
class TestVoiceCloneProfileRetry:
"""测试重试逻辑。"""
def test_prepare_retry_success(self) -> None:
"""成功重试。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_failed("失败")
profile.prepare_retry()
assert profile.status == VoiceCloneStatus.PENDING
assert profile.retry_count == 1
assert profile.error_message == ""
assert profile.voice_id == ""
def test_prepare_retry_not_failed_raises(self) -> None:
"""非 FAILED 状态重试应抛出 ValueError。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
with pytest.raises(ValueError, match="不可重试"):
profile.prepare_retry()
def test_prepare_retry_over_limit_raises(self) -> None:
"""超过重试上限重试应抛出 ValueError。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试", max_retries=1)
profile.mark_processing()
profile.mark_failed("第一次失败")
profile.prepare_retry()
profile.mark_processing()
profile.mark_failed("第二次失败")
with pytest.raises(ValueError, match="不可重试"):
profile.prepare_retry()
class TestVoiceCloneProfileToDict:
"""测试序列化。"""
def test_to_dict_contains_all_fields(self) -> None:
"""to_dict 应包含所有字段。"""
profile = VoiceCloneProfile.create(
user_id="user_001",
name="测试音色",
description="描述",
source_audio_url="https://example.com/audio.wav",
voice_model="cosyvoice-v1",
language="zh-CN",
gender="female",
max_retries=5,
metadata={"key": "value"},
)
result = profile.to_dict()
assert result["id"] == profile.id
assert result["user_id"] == "user_001"
assert result["name"] == "测试音色"
assert result["description"] == "描述"
assert result["status"] == "pending"
assert result["source_audio_url"] == "https://example.com/audio.wav"
assert result["voice_model"] == "cosyvoice-v1"
assert result["language"] == "zh-CN"
assert result["gender"] == "female"
assert result["retry_count"] == 0
assert result["max_retries"] == 5
assert result["is_retryable"] is False
assert result["is_ready"] is False
assert result["metadata"] == {"key": "value"}
assert result["created_at"] is not None
assert result["updated_at"] is not None
def test_to_dict_after_state_change(self) -> None:
"""状态变更后 to_dict 应反映最新状态。"""
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
profile.mark_processing()
profile.mark_ready(voice_id="voice_001")
result = profile.to_dict()
assert result["status"] == "ready"
assert result["voice_id"] == "voice_001"
assert result["is_ready"] is True