diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py old mode 100644 new mode 100755 index 7bb55d80b..93b27aa4d --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -7,6 +7,7 @@ from typing import Optional from app.auth import AuthenticatedUser, get_current_user from app.dependencies import ( + get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository, @@ -56,7 +57,10 @@ def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTS return SQLAlchemyTTSJobRepository(session) -def _to_response(job) -> TTSJobResponse: +def _to_response(job, sign_url=None) -> TTSJobResponse: + output_url = job.output_audio_url + if sign_url and output_url: + output_url = sign_url(output_url) return TTSJobResponse( id=job.id, user_id=job.user_id, @@ -66,7 +70,7 @@ def _to_response(job) -> TTSJobResponse: project_id=job.project_id, voice_clone_profile_id=job.voice_clone_profile_id, status=job.status, - output_audio_url=job.output_audio_url, + output_audio_url=output_url, output_audio_key=job.output_audio_key, duration=job.duration, file_size=job.file_size, @@ -176,6 +180,7 @@ def list_tts_jobs( status_filter: Optional[str] = Query(None, alias="status"), authenticated_user: AuthenticatedUser = Depends(get_current_user), repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), + sign_url=Depends(get_audio_url_signer), ) -> ListTTSJobResponse: """列出用户的 TTS 合成任务。""" user_id = authenticated_user.user.id @@ -183,7 +188,7 @@ def list_tts_jobs( skip = (page - 1) * page_size items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=page_size) return ListTTSJobResponse( - items=[_to_response(j) for j in items], + items=[_to_response(j, sign_url) for j in items], total=total, page=page, page_size=page_size, @@ -195,6 +200,7 @@ def get_tts_job( job_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), + sign_url=Depends(get_audio_url_signer), ) -> TTSJobResponse: """获取 TTS 任务详情。""" user_id = authenticated_user.user.id @@ -203,7 +209,7 @@ def get_tts_job( job = use_case.execute(job_id, user_id) except TTSJobNotFoundError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") - return _to_response(job) + return _to_response(job, sign_url) @router.get("/jobs/{job_id}/status", response_model=TTSStatusResponse) @@ -211,6 +217,7 @@ def get_tts_job_status( job_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), + sign_url=Depends(get_audio_url_signer), ) -> TTSStatusResponse: """查询 TTS 合成状态(用于前端轮询)。""" user_id = authenticated_user.user.id @@ -219,10 +226,13 @@ def get_tts_job_status( job = use_case.execute(job_id, user_id) except TTSJobNotFoundError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") + output_url = job.output_audio_url + if output_url: + output_url = sign_url(output_url) return TTSStatusResponse( id=job.id, status=job.status, - output_audio_url=job.output_audio_url, + output_audio_url=output_url, error_message=job.error_message, duration=job.duration, retry_count=job.retry_count, @@ -258,6 +268,7 @@ def save_tts_job_to_library( tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository), user_repository: UserRepository = Depends(get_user_repository), + sign_url=Depends(get_audio_url_signer), ) -> SaveToLibraryResponse: """将已完成的 TTS 合成结果保存到配音库。 @@ -328,7 +339,7 @@ def save_tts_job_to_library( return SaveToLibraryResponse( id=item.id, name=item.name, - audio_url=item.audio_url, + audio_url=sign_url(item.audio_url) if item.audio_url else "", duration=item.duration, voice_id=item.voice_id, voice_name=item.voice_name, diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py old mode 100644 new mode 100755 index 39bf2067d..740796283 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -6,7 +6,7 @@ import logging from typing import Optional from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository +from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_voice_clone_profile_repository from app.schemas.voice_clone import ( CreateVoiceCloneRequest, ListVoiceCloneResponse, @@ -37,13 +37,16 @@ logger = logging.getLogger(__name__) router = APIRouter() -def _to_response(profile) -> VoiceCloneProfileResponse: +def _to_response(profile, sign_url=None) -> VoiceCloneProfileResponse: + source_url = profile.source_audio_url + if sign_url and source_url: + source_url = sign_url(source_url) return VoiceCloneProfileResponse( id=profile.id, user_id=profile.user_id, name=profile.name, description=profile.description, - source_audio_url=profile.source_audio_url, + source_audio_url=source_url, voice_id=profile.voice_id, voice_model=profile.voice_model, language=profile.language, @@ -74,6 +77,7 @@ def create_voice_clone( request: CreateVoiceCloneRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service), + sign_url=Depends(get_audio_url_signer), ) -> VoiceCloneProfileResponse: """创建音色克隆任务。 @@ -109,7 +113,7 @@ def create_voice_clone( except Exception as inner_e: logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}") - return _to_response(profile) + return _to_response(profile, sign_url) @router.get("", response_model=ListVoiceCloneResponse) @@ -119,13 +123,14 @@ def list_voice_clones( limit: int = Query(50, ge=1, le=200), authenticated_user: AuthenticatedUser = Depends(get_current_user), repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), + sign_url=Depends(get_audio_url_signer), ) -> ListVoiceCloneResponse: """获取用户的音色克隆列表。""" user_id = authenticated_user.user.id use_case = ListVoiceClonesUseCase(repository) items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit) return ListVoiceCloneResponse( - items=[_to_response(p) for p in items], + items=[_to_response(p, sign_url) for p in items], total=total, ) @@ -135,6 +140,7 @@ def get_voice_clone( clone_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), + sign_url=Depends(get_audio_url_signer), ) -> VoiceCloneProfileResponse: """获取音色克隆详情。""" user_id = authenticated_user.user.id @@ -143,7 +149,7 @@ def get_voice_clone( profile = use_case.execute(clone_id, user_id) except VoiceCloneNotFoundError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") - return _to_response(profile) + return _to_response(profile, sign_url) @router.get("/{clone_id}/status", response_model=VoiceCloneStatusResponse) @@ -192,6 +198,7 @@ def retry_voice_clone( clone_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service), + sign_url=Depends(get_audio_url_signer), ) -> VoiceCloneProfileResponse: """重试失败的音色克隆。 @@ -224,4 +231,4 @@ def retry_voice_clone( except Exception as inner_e: logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}") - return _to_response(profile) + return _to_response(profile, sign_url) diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py old mode 100644 new mode 100755 index 6e90aeda2..73a6b486a --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import Literal, Optional from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_db_session, get_user_repository +from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository from app.schemas.voice import ( PresetVoiceItemResponse, PresetVoiceListResponse, @@ -50,7 +50,10 @@ def _get_clone_profile_repository(session: Session = Depends(get_db_session)) -> return SQLAlchemyVoiceCloneProfileRepository(session) -def _to_response(item) -> VoiceLibraryItemResponse: +def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse: + audio = item.audio_url + if sign_url and audio: + audio = sign_url(audio) return VoiceLibraryItemResponse( id=item.id, user_id=item.user_id, @@ -59,7 +62,7 @@ def _to_response(item) -> VoiceLibraryItemResponse: voice_provider=item.voice_provider, voice_id=item.voice_id, voice_name=item.voice_name, - audio_url=item.audio_url, + audio_url=audio, duration=item.duration, file_size=item.file_size, status=item.status, @@ -70,16 +73,20 @@ def _to_response(item) -> VoiceLibraryItemResponse: ) -def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoiceItemResponse: +def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None) -> UnifiedVoiceItemResponse: """将数据库音色转换为统一响应格式。 Args: item: VoiceLibraryItem profile_id_map: voice_id → profile_id 映射,用于填充 voice_clone_profile_id + sign_url: 音频URL预签名函数 """ profile_id = None if profile_id_map and item.voice_id: profile_id = profile_id_map.get(item.voice_id) + audio = item.audio_url + if sign_url and audio: + audio = sign_url(audio) return UnifiedVoiceItemResponse( id=item.id, type="clone", @@ -89,7 +96,7 @@ def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoi language="zh-CN", voice_id=item.voice_id, voice_provider=item.voice_provider or "cosyvoice", - audio_url=item.audio_url, + audio_url=audio, duration=item.duration, file_size=item.file_size, status=item.status, @@ -140,6 +147,7 @@ def list_voices_unified( authenticated_user: AuthenticatedUser = Depends(get_current_user), voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository), clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository), + sign_url=Depends(get_audio_url_signer), ) -> UnifiedVoiceListResponse: """获取配音列表(预置音色 + 用户克隆音色)。 @@ -167,7 +175,7 @@ def list_voices_unified( # 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id] profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {} - clone_items = [_to_unified_response(i, profile_id_map) for i in clone_items_raw] + clone_items = [_to_unified_response(i, profile_id_map, sign_url) for i in clone_items_raw] # 组装结果 if type == "preset": @@ -224,6 +232,7 @@ def list_voices_legacy( limit: int = Query(50, ge=1, le=200), authenticated_user: AuthenticatedUser = Depends(get_current_user), voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository), + sign_url=Depends(get_audio_url_signer), ) -> ListVoiceLibraryResponse: """原有配音列表接口(仅返回用户克隆音色)。 @@ -233,7 +242,7 @@ def list_voices_legacy( use_case = ListVoiceLibraryUseCase(voice_repository) items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit) return ListVoiceLibraryResponse( - items=[_to_response(i) for i in items], + items=[_to_response(i, sign_url) for i in items], total=total, ) @@ -243,13 +252,14 @@ def get_voice( voice_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository), + sign_url=Depends(get_audio_url_signer), ) -> VoiceLibraryItemResponse: user_id = authenticated_user.user.id use_case = GetVoiceLibraryUseCase(voice_repository) item = use_case.execute(voice_id, user_id) if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") - return _to_response(item) + return _to_response(item, sign_url) @router.post("", response_model=VoiceLibraryItemResponse, status_code=status.HTTP_201_CREATED) @@ -258,6 +268,7 @@ def create_voice( authenticated_user: AuthenticatedUser = Depends(get_current_user), voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository), user_repository: UserRepository = Depends(get_user_repository), + sign_url=Depends(get_audio_url_signer), ) -> VoiceLibraryItemResponse: user_id = authenticated_user.user.id plan_name = _get_user_plan(user_id, user_repository) @@ -283,7 +294,7 @@ def create_voice( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", ) - return _to_response(item) + return _to_response(item, sign_url) @router.put("/{voice_id}", response_model=VoiceLibraryItemResponse) @@ -292,6 +303,7 @@ def update_voice( request: UpdateVoiceLibraryRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository), + sign_url=Depends(get_audio_url_signer), ) -> VoiceLibraryItemResponse: user_id = authenticated_user.user.id command = UpdateVoiceLibraryCommand( @@ -313,7 +325,7 @@ def update_voice( item = use_case.execute(command) except NotFoundError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") - return _to_response(item) + return _to_response(item, sign_url) @router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py old mode 100755 new mode 100644 index afc9c40c4..0a0af5fd1 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -207,6 +207,7 @@ def get_cosyvoice_service(): 能被 CosyVoice 服务器下载。 """ from app.core.storage import get_storage_service + from packages.application.cosyvoice_service import CosyVoiceService storage = get_storage_service() @@ -216,3 +217,21 @@ def get_cosyvoice_service(): return storage.get_download_url(url, expires_seconds=86400) return CosyVoiceService(audio_url_signer=_sign_audio_url) + + +def get_audio_url_signer(): + """提供音频URL预签名函数(24小时有效期)。 + + 用于所有 API 返回给前端的音频 URL,确保私有 OSS bucket 下可正常访问。 + 空 URL、非 OSS URL 直接原样返回;签名失败时回退到原始 URL。 + """ + from app.core.storage import get_storage_service + + storage = get_storage_service() + + def sign_audio_url(url: str) -> str: + if not url: + return url + return storage.get_download_url(url, expires_seconds=86400) + + return sign_audio_url diff --git a/tests/unit/test_audio_url_presign.py b/tests/unit/test_audio_url_presign.py new file mode 100755 index 000000000..df5035876 --- /dev/null +++ b/tests/unit/test_audio_url_presign.py @@ -0,0 +1,93 @@ +"""测试音频URL预签名逻辑。 + +验证所有 API 返回的音频 URL 都会经过 OSS 预签名(24小时有效期), +确保私有 bucket 下的音频文件前端可正常访问。 +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +class TestAudioUrlSigner: + """测试音频URL签名函数的行为。""" + + def _make_signer(self, mock_storage): + """构造一个签名函数(模拟 get_audio_url_signer 的逻辑)。""" + + def sign_audio_url(url: str) -> str: + if not url: + return url + return mock_storage.get_download_url(url, expires_seconds=86400) + + return sign_audio_url + + def test_empty_url_returns_empty(self): + """空URL直接返回,不调用签名。""" + mock_storage = MagicMock() + signer = self._make_signer(mock_storage) + + result = signer("") + assert result == "" + mock_storage.get_download_url.assert_not_called() + + def test_none_url_returns_none(self): + """None URL直接返回(有些字段可能为None)。""" + mock_storage = MagicMock() + signer = self._make_signer(mock_storage) + + result = signer(None) # type: ignore + assert result is None + mock_storage.get_download_url.assert_not_called() + + def test_valid_url_gets_signed_24h(self): + """有效URL会调用 storage.get_download_url,有效期24小时(86400秒)。""" + mock_storage = MagicMock() + mock_storage.get_download_url.return_value = ( + "https://bucket.oss-cn-hangzhou.aliyuncs.com/audio/test.mp3?signature=xxx" + ) + signer = self._make_signer(mock_storage) + + result = signer("https://bucket.oss-cn-hangzhou.aliyuncs.com/audio/test.mp3") + + assert "signature=xxx" in result + mock_storage.get_download_url.assert_called_once_with( + "https://bucket.oss-cn-hangzhou.aliyuncs.com/audio/test.mp3", + expires_seconds=86400, + ) + + def test_storage_key_format_also_works(self): + """纯 storage key 格式也能正常签名(storage内部会处理)。""" + mock_storage = MagicMock() + mock_storage.get_download_url.return_value = "https://signed-url/audio.mp3?sig=xxx" + signer = self._make_signer(mock_storage) + + result = signer("audio/test.mp3") + + assert result == "https://signed-url/audio.mp3?sig=xxx" + mock_storage.get_download_url.assert_called_once_with( + "audio/test.mp3", + expires_seconds=86400, + ) + + def test_signer_via_dependencies_module(self): + """通过 dependencies 模块获取 signer,验证集成正确。""" + from app.core.storage import OSSStorageService + + mock_svc = MagicMock(spec=OSSStorageService) + mock_svc.get_download_url.return_value = "https://signed/a.mp3?sig=123" + + # 替换全局单例 + with patch("app.core.storage._storage_service", mock_svc): + from app.dependencies import get_audio_url_signer + + signer = get_audio_url_signer() + result = signer("test/audio.mp3") + + assert result == "https://signed/a.mp3?sig=123" + mock_svc.get_download_url.assert_called_once_with( + "test/audio.mp3", + expires_seconds=86400, + )