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

与音色克隆接口保持一致:
- 路由层增加 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:
灵应
2026-07-05 17:05:17 +08:00
parent ff660b1438
commit 1f8bbe37dd
2 changed files with 278 additions and 2 deletions
+20 -2
View File
@@ -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,
+258
View File
@@ -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:
"""CosyVoiceAuthErrorAPI 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:
"""CosyVoiceErrorAPI 调用失败)→ 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