feat(tts): P0 音频转存 OSS — 合成完成后下载临时音频上传 OSS 存储永久 URL
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 48h27m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h27m21s

- TTSWorkflowService 新增 _transfer_audio_to_oss 方法
- start_synthesis 同步路径和 process_synthesis_result 均集成 OSS 转存
- OSS 转存失败时优雅降级,回退到 CosyVoice 临时 URL
- 支持 mp3/wav/pcm/opus 格式 content-type 映射
- 新增 9 个单元测试覆盖转存成功/失败/降级场景
- 向后兼容:storage_service 为可选参数,现有调用方无需修改

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
灵应
2026-07-07 12:27:06 +08:00
parent 8e2e1e1357
commit c47aed95fc
2 changed files with 360 additions and 5 deletions
+61 -5
View File
@@ -9,9 +9,12 @@
from __future__ import annotations
import io
import logging
from typing import Optional
import httpx
from packages.application.cosyvoice_service import (
CosyVoiceAuthError,
CosyVoiceError,
@@ -19,6 +22,7 @@ from packages.application.cosyvoice_service import (
)
from packages.domain.tts_job import TTSJob, TTSJobStatus
from packages.ports.tts_job_repository import TTSJobRepository
from packages.shared.storage import SharedStorageService, get_shared_storage_service
logger = logging.getLogger(__name__)
@@ -46,9 +50,55 @@ class TTSWorkflowService:
self,
repository: TTSJobRepository,
cosyvoice_service: CosyVoiceService,
storage_service: Optional[SharedStorageService] = None,
) -> None:
self.repository = repository
self.cosyvoice_service = cosyvoice_service
self._storage_service = storage_service
@property
def _storage(self) -> SharedStorageService:
if self._storage_service is None:
self._storage_service = get_shared_storage_service()
return self._storage_service
def _transfer_audio_to_oss(
self,
temp_url: str,
user_id: str,
job_id: str,
audio_format: str = "mp3",
) -> tuple[str, str]:
"""下载 CosyVoice 临时音频并转存到 OSS。
Returns:
(permanent_url, storage_key) 元组。
转存失败时回退到原始临时 URL,storage_key 为空字符串。
"""
storage_key = f"tts-outputs/{user_id}/{job_id}.{audio_format}"
content_type_map = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"pcm": "audio/pcm",
"opus": "audio/opus",
}
content_type = content_type_map.get(audio_format, "application/octet-stream")
try:
# 下载临时音频
resp = httpx.get(temp_url, timeout=60.0, follow_redirects=True)
resp.raise_for_status()
audio_data = resp.content
# 上传到 OSS
file_obj = io.BytesIO(audio_data)
permanent_url = self._storage.upload_file(file_obj, storage_key, content_type=content_type)
logger.info(f"音频转存 OSS 成功: job_id={job_id}, " f"storage_key={storage_key}, size={len(audio_data)}")
return permanent_url, storage_key
except Exception as e:
logger.warning(f"音频转存 OSS 失败,使用临时 URL: " f"job_id={job_id}, error={e}")
return temp_url, ""
def start_synthesis(
self,
@@ -93,17 +143,19 @@ class TTSWorkflowService:
job_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
job_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
# 如果 CosyVoice 同步返回了 audio_url直接标记完成
# 如果 CosyVoice 同步返回了 audio_url转存 OSS 后标记完成
audio_url = submit_result.get("audio_url", "")
if audio_url:
permanent_url, storage_key = self._transfer_audio_to_oss(audio_url, job.user_id, job.id, job.format)
job.mark_completed(
output_audio_url=audio_url,
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=submit_result.get("duration", 0.0),
file_size=submit_result.get("file_size", 0),
)
job.metadata = job_metadata
job = self.repository.update(job)
logger.info(f"TTS 合成同步完成: job_id={job.id}, audio_url={audio_url}")
logger.info(f"TTS 合成同步完成: job_id={job.id}, audio_url={permanent_url}")
return job
job.metadata = job_metadata
@@ -171,13 +223,17 @@ class TTSWorkflowService:
if job is None:
raise TTSJobNotFoundError(f"TTS job {job_id} not found")
# 转存音频到 OSS,获取永久 URL
permanent_url, storage_key = self._transfer_audio_to_oss(audio_url, job.user_id, job.id, job.format)
job.mark_completed(
output_audio_url=audio_url,
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=duration,
file_size=file_size,
)
job = self.repository.update(job)
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={audio_url}")
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={permanent_url}")
return job
def process_synthesis_failure(self, job_id: str, error_message: str) -> TTSJob:
+299
View File
@@ -0,0 +1,299 @@
"""TTS 音频转存 OSS 单元测试。
验证 TTSWorkflowService 在合成完成后将 CosyVoice 临时音频转存到 OSS
存储永久 URL 到 TTSJob.output_audio_urlOSS key 到 output_audio_key。
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from packages.application.cosyvoice_service import CosyVoiceService
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)
def _make_workflow(
cosyvoice_service: MagicMock | None = None,
repo: MagicMock | None = None,
storage: MagicMock | None = None,
) -> TTSWorkflowService:
if cosyvoice_service is None:
cosyvoice_service = MagicMock(spec=CosyVoiceService)
if repo is None:
repo = MagicMock()
repo.get.return_value = _make_job()
repo.update.side_effect = lambda j: j
if storage is None:
storage = MagicMock()
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
return TTSWorkflowService(
repository=repo,
cosyvoice_service=cosyvoice_service,
storage_service=storage,
)
class TestTransferAudioToOSS:
"""测试 _transfer_audio_to_oss 方法。"""
@patch("packages.application.tts_job.workflow.httpx")
def test_success_download_and_upload(self, mock_httpx: MagicMock) -> None:
"""成功下载音频并上传到 OSS,返回永久 URL 和 storage_key。"""
mock_resp = MagicMock()
mock_resp.content = b"fake audio data"
mock_resp.raise_for_status.return_value = None
mock_httpx.get.return_value = mock_resp
storage = MagicMock()
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/job_123.mp3"
workflow = _make_workflow(storage=storage)
url, key = workflow._transfer_audio_to_oss(
"https://cosyvoice-temp.com/audio.mp3",
"user_001",
"job_123",
"mp3",
)
assert url == "https://oss.example.com/tts-outputs/user_001/job_123.mp3"
assert key == "tts-outputs/user_001/job_123.mp3"
mock_httpx.get.assert_called_once_with(
"https://cosyvoice-temp.com/audio.mp3",
timeout=60.0,
follow_redirects=True,
)
storage.upload_file.assert_called_once()
call_args = storage.upload_file.call_args
assert call_args[0][1] == "tts-outputs/user_001/job_123.mp3"
assert call_args[1]["content_type"] == "audio/mpeg"
@patch("packages.application.tts_job.workflow.httpx")
def test_download_failure_fallback(self, mock_httpx: MagicMock) -> None:
"""下载失败时回退到原始临时 URL,storage_key 为空。"""
mock_httpx.get.side_effect = Exception("Network error")
workflow = _make_workflow()
url, key = workflow._transfer_audio_to_oss(
"https://cosyvoice-temp.com/audio.mp3",
"user_001",
"job_123",
)
assert url == "https://cosyvoice-temp.com/audio.mp3"
assert key == ""
@patch("packages.application.tts_job.workflow.httpx")
def test_upload_failure_fallback(self, mock_httpx: MagicMock) -> None:
"""上传 OSS 失败时回退到原始临时 URL。"""
mock_resp = MagicMock()
mock_resp.content = b"fake audio data"
mock_resp.raise_for_status.return_value = None
mock_httpx.get.return_value = mock_resp
storage = MagicMock()
storage.upload_file.side_effect = Exception("OSS bucket error")
workflow = _make_workflow(storage=storage)
url, key = workflow._transfer_audio_to_oss(
"https://cosyvoice-temp.com/audio.mp3",
"user_001",
"job_123",
)
assert url == "https://cosyvoice-temp.com/audio.mp3"
assert key == ""
@patch("packages.application.tts_job.workflow.httpx")
def test_wav_content_type(self, mock_httpx: MagicMock) -> None:
"""wav 格式使用正确的 content_type。"""
mock_resp = MagicMock()
mock_resp.content = b"fake wav data"
mock_resp.raise_for_status.return_value = None
mock_httpx.get.return_value = mock_resp
storage = MagicMock()
storage.upload_file.return_value = "https://oss.example.com/audio.wav"
workflow = _make_workflow(storage=storage)
workflow._transfer_audio_to_oss(
"https://cosyvoice-temp.com/audio.wav",
"user_001",
"job_456",
"wav",
)
call_args = storage.upload_file.call_args
assert call_args[1]["content_type"] == "audio/wav"
assert call_args[0][1] == "tts-outputs/user_001/job_456.wav"
class TestProcessSynthesisResultWithOSS:
"""测试 process_synthesis_result 集成 OSS 转存。"""
@patch("packages.application.tts_job.workflow.httpx")
def test_stores_permanent_url_and_key(self, mock_httpx: MagicMock) -> None:
"""合成结果存 OSS 永久 URL 和 storage_key。"""
mock_resp = MagicMock()
mock_resp.content = b"audio bytes"
mock_resp.raise_for_status.return_value = None
mock_httpx.get.return_value = mock_resp
storage = MagicMock()
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
repo = MagicMock()
job = _make_job(status=TTSJobStatus.PROCESSING)
repo.get.return_value = job
repo.update.side_effect = lambda j: j
workflow = _make_workflow(repo=repo, storage=storage)
result = workflow.process_synthesis_result(
"test_job_001",
audio_url="https://cosyvoice-temp.com/expiring.mp3",
duration=5.0,
file_size=50000,
)
assert result.status == TTSJobStatus.COMPLETED
assert result.output_audio_url == "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
assert result.output_audio_key == "tts-outputs/user_001/test_job_001.mp3"
assert result.duration == 5.0
assert result.file_size == 50000
@patch("packages.application.tts_job.workflow.httpx")
def test_fallback_to_temp_url_on_oss_failure(self, mock_httpx: MagicMock) -> None:
"""OSS 转存失败时,使用 CosyVoice 临时 URL(不阻塞合成流程)。"""
mock_httpx.get.side_effect = Exception("Download failed")
repo = MagicMock()
job = _make_job(status=TTSJobStatus.PROCESSING)
repo.get.return_value = job
repo.update.side_effect = lambda j: j
workflow = _make_workflow(repo=repo)
result = workflow.process_synthesis_result(
"test_job_001",
audio_url="https://cosyvoice-temp.com/expiring.mp3",
)
assert result.status == TTSJobStatus.COMPLETED
assert result.output_audio_url == "https://cosyvoice-temp.com/expiring.mp3"
assert result.output_audio_key == ""
class TestStartSynthesisSyncWithOSS:
"""测试 start_synthesis 同步路径的 OSS 转存。"""
@patch("packages.application.tts_job.workflow.httpx")
def test_sync_path_transfers_to_oss(self, mock_httpx: MagicMock) -> None:
"""CosyVoice 同步返回 audio_url 时,也走 OSS 转存。"""
mock_resp = MagicMock()
mock_resp.content = b"sync audio bytes"
mock_resp.raise_for_status.return_value = None
mock_httpx.get.return_value = mock_resp
storage = MagicMock()
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
service = MagicMock(spec=CosyVoiceService)
service.submit_synthesize_task.return_value = {
"task_id": "",
"audio_url": "https://cosyvoice-temp.com/sync.mp3",
"duration": 2.0,
"file_size": 20000,
"request_id": "req_sync",
}
repo = MagicMock()
repo.get.return_value = _make_job()
repo.update.side_effect = lambda j: j
workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)
job = workflow.start_synthesis("test_job_001")
assert job.status == TTSJobStatus.COMPLETED
assert job.output_audio_url == "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
assert job.output_audio_key == "tts-outputs/user_001/test_job_001.mp3"
assert job.duration == 2.0
@patch("packages.application.tts_job.workflow.httpx")
def test_sync_path_oss_failure_stores_temp_url(self, mock_httpx: MagicMock) -> None:
"""同步路径 OSS 失败时,降级存储临时 URL。"""
mock_httpx.get.side_effect = Exception("Network error")
service = MagicMock(spec=CosyVoiceService)
service.submit_synthesize_task.return_value = {
"task_id": "",
"audio_url": "https://cosyvoice-temp.com/sync.mp3",
"duration": 2.0,
"file_size": 20000,
"request_id": "req_sync",
}
repo = MagicMock()
repo.get.return_value = _make_job()
repo.update.side_effect = lambda j: j
workflow = _make_workflow(cosyvoice_service=service, repo=repo)
job = workflow.start_synthesis("test_job_001")
assert job.status == TTSJobStatus.COMPLETED
assert job.output_audio_url == "https://cosyvoice-temp.com/sync.mp3"
assert job.output_audio_key == ""
def test_async_path_no_oss_transfer(self) -> None:
"""异步路径(返回 task_id,无 audio_url)不触发 OSS 转存。"""
service = MagicMock(spec=CosyVoiceService)
service.submit_synthesize_task.return_value = {
"task_id": "cosy_task_async",
"audio_url": "",
"duration": 0.0,
"file_size": 0,
"request_id": "req_async",
}
repo = MagicMock()
repo.get.return_value = _make_job()
repo.update.side_effect = lambda j: j
storage = MagicMock()
workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)
job = workflow.start_synthesis("test_job_001")
assert job.status == TTSJobStatus.PROCESSING
# 异步路径不应调用 OSS 上传
storage.upload_file.assert_not_called()