fix(lipsync): 私有桶 OSS 媒体 URL 重签修复对口型 403 (#1839) #1837
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user