fix: /tts/preview 解析克隆音色 UUID 为 CosyVoice voice_id
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 46s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m48s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m49s
AI Code Review / AI Code Review (pull_request) Failing after 1m54s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m6s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m11s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m44s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m54s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 527h26m45s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 527h26m47s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 527h27m27s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 527h27m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 527h27m31s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 527h27m35s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 527h27m35s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 528h0m41s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 528h1m23s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 528h1m29s

前端传 VoiceCloneProfile UUID 作为 voice_id,但 CosyVoice 需要
profile.voice_id(实际音色标识)。当前代码直接传 UUID 导致 418 错误。

修复:
- preview_tts 注入 VoiceCloneProfileRepository 依赖
- 调用前用 request.voice_id 查询 profile
- 命中且 voice_id 非空 → 替换为实际音色 ID
- 命中但 voice_id 为空(克隆未完成)→ 返回 400
- 未命中 → 预设音色,保持原逻辑

新增 3 个测试覆盖克隆音色解析、未完成克隆、预设音色直通。
This commit is contained in:
CI Bot
2026-08-25 15:57:51 +08:00
parent a1ddcb6ff6
commit 7e7b73a2e1
2 changed files with 175 additions and 1 deletions
+15 -1
View File
@@ -381,15 +381,29 @@ def preview_tts(
request: TTSPreviewRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
) -> TTSPreviewResponse:
"""TTS 预览(试听)——同步合成,立即返回音频 URL。
用于前端预览配音效果,限制文本长度 200 字以内。
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
"""
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
actual_voice_id = request.voice_id
profile = voice_clone_repo.get(request.voice_id)
if profile is not None:
# 命中克隆音色 profile
if not profile.voice_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="音色克隆尚未完成,请稍后再试",
)
actual_voice_id = profile.voice_id
try:
result = cosyvoice_service.synthesize_speech(
text=request.text,
voice_id=request.voice_id,
voice_id=actual_voice_id,
speed=request.speed,
)
except CosyVoiceError as e:
+160
View File
@@ -81,6 +81,12 @@ class TestTTSPreviewEndpoint:
)
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
from app.dependencies import get_voice_clone_profile_repository
mock_clone_repo = MagicMock()
mock_clone_repo.get.return_value = None # no profile found = preset voice
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
client = self._make_client(app)
resp = client.post(
"/tts/preview",
@@ -120,6 +126,12 @@ class TestTTSPreviewEndpoint:
)
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
from app.dependencies import get_voice_clone_profile_repository
mock_clone_repo = MagicMock()
mock_clone_repo.get.return_value = None # no profile found = preset voice
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
client = self._make_client(app)
resp = client.post(
"/tts/preview",
@@ -158,6 +170,12 @@ class TestTTSPreviewEndpoint:
mock_service.synthesize_speech.side_effect = CosyVoiceError("API timeout")
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
from app.dependencies import get_voice_clone_profile_repository
mock_clone_repo = MagicMock()
mock_clone_repo.get.return_value = None # no profile found = preset voice
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
client = self._make_client(app)
resp = client.post(
"/tts/preview",
@@ -186,6 +204,12 @@ class TestTTSPreviewEndpoint:
mock_service.synthesize_speech.side_effect = ValueError("text 不能为空")
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
from app.dependencies import get_voice_clone_profile_repository
mock_clone_repo = MagicMock()
mock_clone_repo.get.return_value = None # no profile found = preset voice
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
client = self._make_client(app)
resp = client.post(
"/tts/preview",
@@ -213,6 +237,12 @@ class TestTTSPreviewEndpoint:
mock_service = MagicMock()
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
from app.dependencies import get_voice_clone_profile_repository
mock_clone_repo = MagicMock()
mock_clone_repo.get.return_value = None # no profile found = preset voice
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
client = self._make_client(app)
resp = client.post(
"/tts/preview",
@@ -239,9 +269,139 @@ class TestTTSPreviewEndpoint:
mock_service = MagicMock()
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
from app.dependencies import get_voice_clone_profile_repository
mock_clone_repo = MagicMock()
mock_clone_repo.get.return_value = None # no profile found = preset voice
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
client = self._make_client(app)
resp = client.post(
"/tts/preview",
json={"text": "hello", "voice_id": ""},
)
assert resp.status_code == 422
def test_preview_clone_voice_resolves_to_cosyvoice_id(self):
"""Clone voice UUID is resolved to CosyVoice voice_id."""
from dataclasses import dataclass
from fastapi import FastAPI
app = FastAPI()
from app.api.routes.tts import router
app.include_router(router, prefix="/tts")
from app.auth import get_current_user
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
fake_user = MagicMock()
fake_user.user.id = "user-1"
app.dependency_overrides[get_current_user] = lambda: fake_user
mock_service = MagicMock()
mock_service.synthesize_speech.return_value = FakeSynthesizeResult(
audio_url="https://x.com/cloned.mp3",
duration=1.8,
)
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
# Mock voice clone profile with voice_id
mock_profile = MagicMock()
mock_profile.voice_id = "cosyvoice_actual_voice_123"
mock_clone_repo = MagicMock()
mock_clone_repo.get.return_value = mock_profile
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
client = self._make_client(app)
# Frontend sends the profile UUID as voice_id
resp = client.post(
"/tts/preview",
json={"text": "克隆音色测试", "voice_id": "abc123-uuid-of-profile"},
)
assert resp.status_code == 200
data = resp.json()
assert data["audio_url"] == "https://x.com/cloned.mp3"
# Verify CosyVoice was called with the resolved voice_id, not the UUID
mock_service.synthesize_speech.assert_called_once_with(
text="克隆音色测试",
voice_id="cosyvoice_actual_voice_123",
speed=1.0,
)
# Verify repo was queried with the UUID
mock_clone_repo.get.assert_called_once_with("abc123-uuid-of-profile")
def test_preview_clone_voice_incomplete_returns_400(self):
"""Clone profile with empty voice_id returns 400."""
from fastapi import FastAPI
app = FastAPI()
from app.api.routes.tts import router
app.include_router(router, prefix="/tts")
from app.auth import get_current_user
from app.dependencies import get_voice_clone_profile_repository
fake_user = MagicMock()
fake_user.user.id = "user-1"
app.dependency_overrides[get_current_user] = lambda: fake_user
# Mock voice clone profile with empty voice_id (clone not finished)
mock_profile = MagicMock()
mock_profile.voice_id = ""
mock_clone_repo = MagicMock()
mock_clone_repo.get.return_value = mock_profile
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
client = self._make_client(app)
resp = client.post(
"/tts/preview",
json={"text": "测试未完成克隆", "voice_id": "abc123-uuid"},
)
assert resp.status_code == 400
assert "音色克隆尚未完成" in resp.json()["detail"]
def test_preview_preset_voice_passthrough(self):
"""Preset voice ID (not a profile UUID) passes through unchanged."""
from fastapi import FastAPI
app = FastAPI()
from app.api.routes.tts import router
app.include_router(router, prefix="/tts")
from app.auth import get_current_user
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
fake_user = MagicMock()
fake_user.user.id = "user-1"
app.dependency_overrides[get_current_user] = lambda: fake_user
mock_service = MagicMock()
mock_service.synthesize_speech.return_value = FakeSynthesizeResult(
audio_url="https://x.com/preset.mp3",
duration=2.0,
)
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
# Mock repo returns None (preset voice, not a clone profile)
mock_clone_repo = MagicMock()
mock_clone_repo.get.return_value = None
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
client = self._make_client(app)
resp = client.post(
"/tts/preview",
json={"text": "预设音色测试", "voice_id": "longxiaoxia_v3"},
)
assert resp.status_code == 200
# Verify CosyVoice was called with the original preset voice_id
mock_service.synthesize_speech.assert_called_once_with(
text="预设音色测试",
voice_id="longxiaoxia_v3",
speed=1.0,
)