From 09fcc39e4f6df2c8555a8c0dc4e95d8e910b3227 Mon Sep 17 00:00:00 2001 From: saas-backend Date: Thu, 10 Sep 2026 12:59:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(lipsync):=20=E7=A7=81=E6=9C=89=E6=A1=B6=20O?= =?UTF-8?q?SS=20=E5=AA=92=E4=BD=93=20URL=20=E6=8F=90=E4=BA=A4=20MediaKit?= =?UTF-8?q?=20=E5=89=8D=E7=BB=9F=E4=B8=80=E9=87=8D=E7=AD=BE=207=20?= =?UTF-8?q?=E5=A4=A9=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=AF=B9=E5=8F=A3=E5=9E=8B?= =?UTF-8?q?=20403=20(#1839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:bucket xiaoxia-autocut 为私有桶(匿名 GET 返回 403 AccessDenied)。 TTS 直生音频/输出成片经 storage.upload_file 返回不带签名的裸 public_url, MediaKit GPU worker(terminator→storageGW) 拉取时 403 Forbidden; 前端传入的 1h 短预签名 video_url 排队后也可能过期。 修复(仅改对口型相关): - 新增 LipsyncService._sign_media_url():自家 OSS 桶 host 的 URL(裸的/ 将过期的短预签名)统一重签为 7 天长有效期;外部临时 URL(CosyVoice/ MediaKit)原样透传;异常降级原 URL 不阻断。 - create_job 提交 MediaKit 前对 video_url/audio_url 统一重签。 - _persist_output_video 返回成片前同样重签,私有桶播放不再 403。 - 6 个新单测覆盖重签/透传/降级/端到端;lipsync 相关 46 测试全过。 --- apps/api/app/services/lipsync_service.py | 38 +++++++- tests/unit/test_lipsync_routes.py | 110 +++++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/apps/api/app/services/lipsync_service.py b/apps/api/app/services/lipsync_service.py index 36fa4b20c..8776ba6f5 100644 --- a/apps/api/app/services/lipsync_service.py +++ b/apps/api/app/services/lipsync_service.py @@ -15,6 +15,7 @@ import logging import uuid from datetime import datetime, timezone from typing import Optional +from urllib.parse import urlparse from app.services.mediakit_client import ( STATUS_COMPLETED, @@ -33,6 +34,10 @@ from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download logger = logging.getLogger(__name__) +# 传给 MediaKit GPU worker / 回给前端播放的 OSS 预签名有效期:7 天。 +# MediaKit 排队 + 拉取可能延迟,私有桶裸 URL 或 1 小时短预签名都会 403,故统一重签长有效期。 +MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600 + class LipsyncService: """对口型任务 Service.""" @@ -176,6 +181,12 @@ class LipsyncService: emotion=emotion, ) + # #1839: 私有桶 OSS 的裸 URL / 即将过期的短预签名会让 MediaKit GPU worker 拉取时 403, + # 提交前统一重签长有效期;外部临时 URL(CosyVoice/MediaKit)原样透传。 + video_url = self._sign_media_url(video_url) + if audio_url: + audio_url = self._sign_media_url(audio_url) + # 1. 创建数据库记录 job_id = str(uuid.uuid4()) job = LipsyncJobModel( @@ -316,11 +327,36 @@ class LipsyncService: storage_key = f"lipsync-outputs/{user_id}/{job_id}.mp4" permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4") logger.info("对口型输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key) - return permanent_url or temp_url + return self._sign_media_url(permanent_url) or temp_url except Exception as exc: logger.warning("对口型输出视频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc) return temp_url + def _sign_media_url(self, url: str) -> str: + """对自家 OSS 私有桶 URL 重签长有效期预签名,供 MediaKit 拉取 / 前端播放。 + + - 裸 public_url(upload_file 返回,不带签名)→ 私有桶匿名访问 403,重签。 + - 已带签名但即将过期的 URL(如前端 1h 预签名)→ 抽 storage_key 后重签。 + - 外部 URL(CosyVoice/MediaKit 临时链接,非本桶 host)→ 原样透传。 + - 任何异常都降级原样返回,不阻断主流程。 + """ + if not url: + return url + try: + storage = get_shared_storage_service() + public_base = getattr(storage, "public_url", "") + if not isinstance(public_base, str) or not public_base: + return url # 无法判定归属,保守透传 + own_host = urlparse(public_base).netloc.lower() + host = urlparse(url).netloc.lower() + if not own_host or host != own_host: + return url # 非自家 OSS(外部临时链接),不处理 + signed = storage.get_download_url(url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS) + return signed or url + except Exception as exc: # noqa: BLE001 - 签名失败不阻断,降级原 URL + logger.warning("对口型 URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc) + return url + # ── 取消任务 ────────────────────────────────────────────────────────── def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]: diff --git a/tests/unit/test_lipsync_routes.py b/tests/unit/test_lipsync_routes.py index ed24ecb11..2e606a98e 100644 --- a/tests/unit/test_lipsync_routes.py +++ b/tests/unit/test_lipsync_routes.py @@ -596,3 +596,113 @@ class TestErrorHandling: assert exc_info.value.code == "InvalidInput" mock_cosyvoice.submit_synthesize_task.assert_not_called() mock_mediakit.submit_lipsync.assert_not_called() + + +class TestSignMediaUrl403Fix: + """#1839 私有桶 OSS URL 重签:MediaKit GPU worker 拉取裸/过期 URL 会 403. + + - 自家 OSS 的裸 public_url / 已过期短预签名 → 重签 7 天长有效期 + - 外部临时 URL(CosyVoice/MediaKit)→ 原样透传 + - 签名异常 → 降级原 URL,不阻断 + """ + + OSS_PUBLIC_BASE = "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com" + + def _svc(self, mock_mediakit, mock_cosyvoice): + from app.services.lipsync_service import LipsyncService + + return LipsyncService( + MagicMock(), + client=mock_mediakit, + cosyvoice_service=mock_cosyvoice, + voice_clone_repo=MagicMock(), + ) + + def test_own_oss_unsigned_url_gets_resigned(self, mock_mediakit, mock_cosyvoice): + """裸 public_url(不带签名,私有桶匿名 403)必须被重签.""" + from app.services.lipsync_service import MEDIAKIT_URL_TTL_SECONDS + + svc = self._svc(mock_mediakit, mock_cosyvoice) + raw = f"{self.OSS_PUBLIC_BASE}/lipsync-tts/user-1/audio.mp3" + signed = raw + "?Expires=999&Signature=abc&OSSAccessKeyId=key" + + storage = MagicMock() + storage.public_url = self.OSS_PUBLIC_BASE + storage.get_download_url.return_value = signed + with patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage): + out = svc._sign_media_url(raw) + + assert out == signed + storage.get_download_url.assert_called_once_with(raw, expires_seconds=MEDIAKIT_URL_TTL_SECONDS) + assert MEDIAKIT_URL_TTL_SECONDS == 7 * 24 * 3600 + + def test_own_oss_expired_presign_gets_resigned(self, mock_mediakit, mock_cosyvoice): + """已带过期签名的旧预签名 URL 也要抽 key 后重签(不把旧 query 带进新签名).""" + svc = self._svc(mock_mediakit, mock_cosyvoice) + old = f"{self.OSS_PUBLIC_BASE}/avatar/video.mp4?Expires=111&Signature=old" + fresh = f"{self.OSS_PUBLIC_BASE}/avatar/video.mp4?Expires=999&Signature=fresh" + + storage = MagicMock() + storage.public_url = self.OSS_PUBLIC_BASE + storage.get_download_url.return_value = fresh + with patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage): + out = svc._sign_media_url(old) + + assert out == fresh + # 传给 get_download_url 的是原始完整 URL(内部抽 key),有效期 7 天 + called_url = storage.get_download_url.call_args.args[0] + assert called_url == old + + def test_external_url_passthrough(self, mock_mediakit, mock_cosyvoice): + """CosyVoice/MediaKit 外部临时链接不处理,原样透传.""" + svc = self._svc(mock_mediakit, mock_cosyvoice) + external = "https://cv-tts.cosyvoice.aliyuncs.com/output/x.mp3" + + storage = MagicMock() + storage.public_url = self.OSS_PUBLIC_BASE + with patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage): + out = svc._sign_media_url(external) + + assert out == external + storage.get_download_url.assert_not_called() + + def test_empty_url_returns_empty(self, mock_mediakit, mock_cosyvoice): + svc = self._svc(mock_mediakit, mock_cosyvoice) + assert svc._sign_media_url("") == "" + + def test_sign_error_falls_back_to_raw(self, mock_mediakit, mock_cosyvoice): + """签名抛异常时降级返回原 URL,不阻断对口型提交.""" + svc = self._svc(mock_mediakit, mock_cosyvoice) + raw = f"{self.OSS_PUBLIC_BASE}/lipsync-tts/u/a.mp3" + + storage = MagicMock() + storage.public_url = self.OSS_PUBLIC_BASE + storage.get_download_url.side_effect = RuntimeError("oss down") + with patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage): + out = svc._sign_media_url(raw) + + assert out == raw + + def test_create_job_resigns_oss_urls_before_submit(self, mock_mediakit, mock_cosyvoice): + """端到端:create_job 提交 MediaKit 前,自家 OSS 的 video_url 必须是重签后的长签名 URL.""" + svc = self._svc(mock_mediakit, mock_cosyvoice) + raw_video = f"{self.OSS_PUBLIC_BASE}/avatar/person.mp4" + signed_video = raw_video + "?Expires=999&Signature=fresh" + raw_audio = f"{self.OSS_PUBLIC_BASE}/direct/audio.mp3" + signed_audio = raw_audio + "?Expires=999&Signature=afresh" + + storage = MagicMock() + storage.public_url = self.OSS_PUBLIC_BASE + storage.get_download_url.side_effect = lambda u, expires_seconds=0: ( + signed_video if u == raw_video else signed_audio + ) + with patch("app.services.lipsync_service.get_shared_storage_service", return_value=storage): + svc.create_job( + user_id="user-1", + video_url=raw_video, + audio_url=raw_audio, + ) + + kw = mock_mediakit.submit_lipsync.call_args.kwargs + assert kw["video_url"] == signed_video + assert kw["audio_url"] == signed_audio -- 2.54.0