fix: TTS 合成接口失败时返回 201 + failed 状态,不抛 500
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Staging E2E Tests (push) Failing after 91h45m45s
Deploy / Deploy Staging (push) Failing after 91h47m59s
CI/CD Pipeline / Frontend Lint (push) Failing after 91h48m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 91h48m24s
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Staging E2E Tests (push) Failing after 91h45m45s
Deploy / Deploy Staging (push) Failing after 91h47m59s
CI/CD Pipeline / Frontend Lint (push) Failing after 91h48m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 91h48m24s
与音色克隆接口保持一致: - 路由层增加 try/except 兜底,捕获 workflow 未处理的意外异常(DB错误等) - CosyVoice 失败时标记 job 为 failed,返回 201 + failed 状态任务记录 - Celery 调度失败时也标记 job 为 failed,不抛 500 - 新增 10 个单元测试覆盖各种失败场景 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -30,6 +31,8 @@ from packages.application.tts_job.use_cases import (
|
||||
)
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -74,6 +77,7 @@ def synthesize(
|
||||
"""发起 TTS 合成任务。
|
||||
|
||||
创建 TTS 任务 → 提交 CosyVoice 合成 → 触发 Celery 异步轮询。
|
||||
与音色克隆接口保持一致:CosyVoice 失败时不抛 500,而是返回 201 + failed 状态任务记录。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = CreateTTSJobUseCase(repository)
|
||||
@@ -91,7 +95,18 @@ def synthesize(
|
||||
repository=repository,
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
)
|
||||
job = workflow.start_synthesis(job.id)
|
||||
|
||||
try:
|
||||
job = workflow.start_synthesis(job.id)
|
||||
except Exception as e:
|
||||
# 兜底:workflow 内部已捕获 CosyVoiceError / ValueError,
|
||||
# 但 DB 异常、网络异常等意外错误可能逃逸。
|
||||
# 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。
|
||||
logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True)
|
||||
try:
|
||||
job = workflow.process_synthesis_failure(job.id, str(e))
|
||||
except Exception as inner_e:
|
||||
logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}")
|
||||
|
||||
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
|
||||
if job.status.value == "processing":
|
||||
@@ -103,7 +118,10 @@ def synthesize(
|
||||
process_tts_synthesis.delay(job.id)
|
||||
except Exception as e:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
|
||||
try:
|
||||
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
|
||||
|
||||
return TTSSynthesizeResponse(
|
||||
job_id=job.id,
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""TTS 合成失败场景单元测试。
|
||||
|
||||
验证 TTS 合成接口在 CosyVoice 失败时返回 201 + failed 状态(与音色克隆一致),
|
||||
而不是抛 500 错误。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.cosyvoice_service import (
|
||||
CosyVoiceAuthError,
|
||||
CosyVoiceError,
|
||||
CosyVoiceService,
|
||||
CosyVoiceTimeoutError,
|
||||
)
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
|
||||
|
||||
def _make_job(**kwargs) -> TTSJob:
|
||||
defaults = {
|
||||
"id": "test_job_001",
|
||||
"user_id": "user_001",
|
||||
"input_text": "测试文本",
|
||||
"voice_id": "voice_001",
|
||||
"voice_model": "",
|
||||
"project_id": "",
|
||||
"voice_clone_profile_id": "",
|
||||
"status": TTSJobStatus.PENDING,
|
||||
"output_audio_url": "",
|
||||
"output_audio_key": "",
|
||||
"duration": 0.0,
|
||||
"file_size": 0,
|
||||
"sample_rate": 22050,
|
||||
"format": "mp3",
|
||||
"error_message": "",
|
||||
"retry_count": 0,
|
||||
"max_retries": 3,
|
||||
"metadata": {},
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return TTSJob(**defaults)
|
||||
|
||||
|
||||
class TestTTSWorkflowFailureScenarios:
|
||||
"""测试 TTSWorkflowService 在各种失败场景下的行为。
|
||||
|
||||
核心要求:CosyVoice 失败时,job 应被标记为 failed,而不是抛异常。
|
||||
"""
|
||||
|
||||
def _make_workflow(self, cosyvoice_service: MagicMock, repo: MagicMock | None = None) -> TTSWorkflowService:
|
||||
if repo is None:
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = _make_job()
|
||||
repo.update.side_effect = lambda j: j
|
||||
return TTSWorkflowService(repository=repo, cosyvoice_service=cosyvoice_service)
|
||||
|
||||
def test_auth_error_marks_job_failed(self) -> None:
|
||||
"""CosyVoiceAuthError(API Key 未配置)→ job 标记为 failed。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.side_effect = CosyVoiceAuthError("CosyVoice API Key 未配置")
|
||||
|
||||
workflow = self._make_workflow(service)
|
||||
job = workflow.start_synthesis("test_job_001")
|
||||
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
assert "API Key 未配置" in job.error_message
|
||||
|
||||
def test_api_error_marks_job_failed(self) -> None:
|
||||
"""CosyVoiceError(API 调用失败)→ job 标记为 failed。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.side_effect = CosyVoiceError("CosyVoice API 服务端错误: HTTP 500")
|
||||
|
||||
workflow = self._make_workflow(service)
|
||||
job = workflow.start_synthesis("test_job_001")
|
||||
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
assert "服务端错误" in job.error_message
|
||||
|
||||
def test_timeout_error_marks_job_failed(self) -> None:
|
||||
"""CosyVoiceTimeoutError(超时)→ job 标记为 failed。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.side_effect = CosyVoiceTimeoutError("请求超时")
|
||||
|
||||
workflow = self._make_workflow(service)
|
||||
job = workflow.start_synthesis("test_job_001")
|
||||
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
assert "超时" in job.error_message
|
||||
|
||||
def test_value_error_marks_job_failed(self) -> None:
|
||||
"""ValueError(参数错误)→ job 标记为 failed。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.side_effect = ValueError("voice_id 不能为空")
|
||||
|
||||
workflow = self._make_workflow(service)
|
||||
job = workflow.start_synthesis("test_job_001")
|
||||
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
assert "voice_id" in job.error_message
|
||||
|
||||
def test_unexpected_exception_propagates(self) -> None:
|
||||
"""非预期异常(如 DB 错误)→ workflow 不捕获,由路由层兜底。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.side_effect = RuntimeError("unexpected DB error")
|
||||
|
||||
workflow = self._make_workflow(service)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unexpected DB error"):
|
||||
workflow.start_synthesis("test_job_001")
|
||||
|
||||
def test_sync_success_marks_job_completed(self) -> None:
|
||||
"""CosyVoice 同步返回 audio_url → job 标记为 completed。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.return_value = {
|
||||
"task_id": "",
|
||||
"audio_url": "https://example.com/audio.mp3",
|
||||
"duration": 3.5,
|
||||
"file_size": 12345,
|
||||
"request_id": "req_001",
|
||||
}
|
||||
|
||||
workflow = self._make_workflow(service)
|
||||
job = workflow.start_synthesis("test_job_001")
|
||||
|
||||
assert job.status == TTSJobStatus.COMPLETED
|
||||
assert job.output_audio_url == "https://example.com/audio.mp3"
|
||||
assert job.duration == 3.5
|
||||
|
||||
def test_async_success_marks_job_processing(self) -> None:
|
||||
"""CosyVoice 异步模式(返回 task_id)→ job 标记为 processing。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.return_value = {
|
||||
"task_id": "cosy_task_001",
|
||||
"audio_url": "",
|
||||
"duration": 0.0,
|
||||
"file_size": 0,
|
||||
"request_id": "req_002",
|
||||
}
|
||||
|
||||
workflow = self._make_workflow(service)
|
||||
job = workflow.start_synthesis("test_job_001")
|
||||
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
assert job.metadata.get("cosyvoice_task_id") == "cosy_task_001"
|
||||
|
||||
def test_process_synthesis_failure(self) -> None:
|
||||
"""process_synthesis_failure 将 job 标记为 failed。"""
|
||||
repo = MagicMock()
|
||||
job = _make_job(status=TTSJobStatus.PROCESSING)
|
||||
repo.get.return_value = job
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
workflow = TTSWorkflowService(repository=repo, cosyvoice_service=service)
|
||||
|
||||
result = workflow.process_synthesis_failure("test_job_001", "Celery 任务调度失败")
|
||||
|
||||
assert result.status == TTSJobStatus.FAILED
|
||||
assert "Celery 任务调度失败" in result.error_message
|
||||
|
||||
|
||||
class TestTTSRouteFailureScenarios:
|
||||
"""测试 TTS 路由层在各种失败场景下的行为。
|
||||
|
||||
核心要求:即使 workflow 抛出意外异常,路由也应返回 201 + failed,不抛 500。
|
||||
"""
|
||||
|
||||
def test_route_catches_unexpected_exception_from_workflow(self) -> None:
|
||||
"""路由层兜底:workflow 抛出非预期异常时,job 仍标记为 failed。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.side_effect = RuntimeError("unexpected")
|
||||
|
||||
repo = MagicMock()
|
||||
job = _make_job()
|
||||
repo.get.return_value = job
|
||||
repo.update.side_effect = lambda j: j
|
||||
repo.create.side_effect = lambda j: j
|
||||
|
||||
# 模拟路由逻辑
|
||||
from packages.application.tts_job.use_cases import CreateTTSJobUseCase
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
|
||||
use_case = CreateTTSJobUseCase(repo)
|
||||
created_job = use_case.execute(
|
||||
user_id="user_001",
|
||||
input_text="测试文本",
|
||||
voice_id="voice_001",
|
||||
)
|
||||
|
||||
workflow = TTSWorkflowService(repository=repo, cosyvoice_service=service)
|
||||
|
||||
# 模拟路由的 try/except 兜底逻辑
|
||||
try:
|
||||
result_job = workflow.start_synthesis(created_job.id)
|
||||
except Exception as e:
|
||||
result_job = workflow.process_synthesis_failure(created_job.id, str(e))
|
||||
|
||||
assert result_job.status == TTSJobStatus.FAILED
|
||||
assert "unexpected" in result_job.error_message
|
||||
|
||||
def test_route_handles_double_failure_gracefully(self) -> None:
|
||||
"""路由层兜底:即使 process_synthesis_failure 也失败,不崩溃。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.side_effect = RuntimeError("unexpected")
|
||||
|
||||
repo = MagicMock()
|
||||
job = _make_job()
|
||||
repo.get.return_value = job
|
||||
repo.update.side_effect = lambda j: j
|
||||
repo.create.side_effect = lambda j: j
|
||||
|
||||
from packages.application.tts_job.use_cases import CreateTTSJobUseCase
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
|
||||
use_case = CreateTTSJobUseCase(repo)
|
||||
created_job = use_case.execute(
|
||||
user_id="user_001",
|
||||
input_text="测试文本",
|
||||
voice_id="voice_001",
|
||||
)
|
||||
|
||||
# 让 process_synthesis_failure 也失败
|
||||
call_count = 0
|
||||
original_update = repo.update
|
||||
|
||||
def failing_update(j):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count > 2: # 第3次调用时失败(mark_failed 时的 update)
|
||||
raise RuntimeError("DB connection lost")
|
||||
return original_update(j)
|
||||
|
||||
repo.update.side_effect = failing_update
|
||||
|
||||
workflow = TTSWorkflowService(repository=repo, cosyvoice_service=service)
|
||||
|
||||
# 模拟路由的 try/except 兜底逻辑(包含内层 try/except)
|
||||
try:
|
||||
result_job = workflow.start_synthesis(created_job.id)
|
||||
except Exception as e:
|
||||
try:
|
||||
result_job = workflow.process_synthesis_failure(created_job.id, str(e))
|
||||
except Exception:
|
||||
# 路由层日志记录后,使用原始 job 对象
|
||||
result_job = created_job
|
||||
|
||||
# 不应崩溃,即使状态可能不是 failed
|
||||
assert result_job is not None
|
||||
Reference in New Issue
Block a user