From 7e7b73a2e1af4fd4b510acf0a05feec2284452e5 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 25 Aug 2026 15:57:51 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20/tts/preview=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=85=8B=E9=9A=86=E9=9F=B3=E8=89=B2=20UUID=20=E4=B8=BA=20CosyV?= =?UTF-8?q?oice=20voice=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端传 VoiceCloneProfile UUID 作为 voice_id,但 CosyVoice 需要 profile.voice_id(实际音色标识)。当前代码直接传 UUID 导致 418 错误。 修复: - preview_tts 注入 VoiceCloneProfileRepository 依赖 - 调用前用 request.voice_id 查询 profile - 命中且 voice_id 非空 → 替换为实际音色 ID - 命中但 voice_id 为空(克隆未完成)→ 返回 400 - 未命中 → 预设音色,保持原逻辑 新增 3 个测试覆盖克隆音色解析、未完成克隆、预设音色直通。 --- apps/api/app/api/routes/tts.py | 16 +++- tests/unit/test_tts_preview.py | 160 +++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index eb74903ff..15dce80e1 100755 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -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: diff --git a/tests/unit/test_tts_preview.py b/tests/unit/test_tts_preview.py index 4320b4938..99b2a01d4 100644 --- a/tests/unit/test_tts_preview.py +++ b/tests/unit/test_tts_preview.py @@ -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, + ) -- 2.54.0 From 287a5dcd0f9b7ec871a1a6aa5849ca2f19f89245 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 25 Aug 2026 16:05:13 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=85=E5=85=8B=E9=9A=86?= =?UTF-8?q?=E9=9F=B3=E8=89=B2=E6=9D=83=E9=99=90=E6=A0=A1=E9=AA=8C=20+=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20user=5Fid=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI Code Review 指出越权风险:未校验 VoiceCloneProfile 归属。 新增 profile.user_id != authenticated_user.user.id → 403。 补充 12 个测试全部通过。 --- apps/api/app/api/routes/tts.py | 7 ++++++- tests/unit/test_tts_preview.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 15dce80e1..eb9581e07 100755 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -392,7 +392,12 @@ def preview_tts( actual_voice_id = request.voice_id profile = voice_clone_repo.get(request.voice_id) if profile is not None: - # 命中克隆音色 profile + # 命中克隆音色 profile — 校验归属权限 + if profile.user_id != authenticated_user.user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权访问该音色", + ) if not profile.voice_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/tests/unit/test_tts_preview.py b/tests/unit/test_tts_preview.py index 99b2a01d4..6ff37a799 100644 --- a/tests/unit/test_tts_preview.py +++ b/tests/unit/test_tts_preview.py @@ -309,6 +309,7 @@ class TestTTSPreviewEndpoint: # Mock voice clone profile with voice_id mock_profile = MagicMock() + mock_profile.user_id = "user-1" mock_profile.voice_id = "cosyvoice_actual_voice_123" mock_clone_repo = MagicMock() mock_clone_repo.get.return_value = mock_profile @@ -351,6 +352,7 @@ class TestTTSPreviewEndpoint: # Mock voice clone profile with empty voice_id (clone not finished) mock_profile = MagicMock() + mock_profile.user_id = "user-1" mock_profile.voice_id = "" mock_clone_repo = MagicMock() mock_clone_repo.get.return_value = mock_profile @@ -405,3 +407,35 @@ class TestTTSPreviewEndpoint: voice_id="longxiaoxia_v3", speed=1.0, ) + + def test_preview_clone_voice_wrong_user_returns_403(self): + """Accessing another user's clone profile returns 403.""" + 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 profile belonging to a different user + mock_profile = MagicMock() + mock_profile.user_id = "user-2" + mock_profile.voice_id = "cosyvoice_voice_xyz" + 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": "other-user-profile-uuid"}, + ) + assert resp.status_code == 403 + assert "无权访问该音色" in resp.json()["detail"] -- 2.54.0