Merge pull request 'fix(scripts): extract-from-douyin 异常路径全部返回业务码,杜绝 500 INTERNAL_ERROR' (#1963) from fix/extract-douyin-error-handling into develop
fix(scripts): extract-from-douyin 异常路径全部返回业务码,杜绝 500 (#1963)
This commit was merged in pull request #1963.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
@@ -80,10 +81,20 @@ def extract_from_douyin(
|
||||
if not re.match(r"^https?://", url_for_download, re.IGNORECASE):
|
||||
url_for_download = "https://" + url_for_download
|
||||
|
||||
# 使用临时目录下载视频,退出时自动清理
|
||||
text: str = ""
|
||||
duration: float = 0.0
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="douyin_extract_") as temp_dir:
|
||||
import yt_dlp
|
||||
# 延迟导入 yt-dlp,避免模块缺失时影响其他路由启动
|
||||
try:
|
||||
import yt_dlp
|
||||
except ImportError as exc:
|
||||
logger.error("yt-dlp 未安装,抖音提取功能不可用: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="抖音提取功能暂不可用(缺少依赖 yt-dlp)",
|
||||
) from exc
|
||||
|
||||
ydl_opts = {
|
||||
"format": "best[ext=mp4]/best",
|
||||
@@ -96,11 +107,23 @@ def extract_from_douyin(
|
||||
try:
|
||||
ydl = yt_dlp.YoutubeDL(ydl_opts)
|
||||
info = ydl.extract_info(url_for_download, download=True)
|
||||
except yt_dlp.utils.DownloadError as exc:
|
||||
# yt-dlp 官方异常类型:HTTP 错误、短链失效、视频下架等
|
||||
msg = str(exc)
|
||||
logger.warning("抖音下载失败: url=%s error=%s", source_url, msg)
|
||||
# 404/视频不存在/不可下载 → 400;网络问题/上游异常 → 502
|
||||
is_bad_url = any(
|
||||
kw in msg.lower() for kw in ("404", "not found", "unable to download webpage", "unsupported url", "no video formats")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST if is_bad_url else status.HTTP_502_BAD_GATEWAY,
|
||||
detail=("无法解析该抖音链接,请确认链接有效且视频未被下架" if is_bad_url else f"视频下载失败: {msg[:200]}"),
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("抖音视频下载失败: url=%s error=%s", source_url, exc)
|
||||
logger.exception("抖音视频下载异常: url=%s", source_url)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"视频下载失败: {exc}",
|
||||
detail=f"视频下载失败: {str(exc)[:200]}",
|
||||
) from exc
|
||||
|
||||
if info is None:
|
||||
@@ -110,9 +133,20 @@ def extract_from_douyin(
|
||||
)
|
||||
|
||||
video_path = ydl.prepare_filename(info)
|
||||
duration = float(info.get("duration") or 0)
|
||||
try:
|
||||
duration = float(info.get("duration") or 0)
|
||||
except (TypeError, ValueError):
|
||||
duration = 0.0
|
||||
|
||||
# ASR 转写
|
||||
# 校验下载的文件是否真的存在(某些 yt-dlp 版本可能 info 成功但未下载到文件)
|
||||
if not os.path.isfile(video_path) or os.path.getsize(video_path) == 0:
|
||||
logger.error("yt-dlp 未产生有效视频文件: path=%s", video_path)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="视频下载异常:未获取到有效文件",
|
||||
)
|
||||
|
||||
# ASR 转写(兜底捕获所有异常,避免 500)
|
||||
try:
|
||||
text = transcribe_to_text(video_path)
|
||||
except ASRNotConfiguredError as exc:
|
||||
@@ -125,9 +159,22 @@ def extract_from_douyin(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("ASR 转写异常: path=%s", video_path)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"语音识别失败: {str(exc)[:200]}",
|
||||
) from exc
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 最后兜底:任何未捕获异常都转成 502/400,不允许冒泡成 500
|
||||
logger.exception("抖音文案提取未预期异常: url=%s", source_url)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"抖音文案提取失败: {str(exc)[:200]}",
|
||||
) from exc
|
||||
|
||||
return ExtractFromDouyinResponse(
|
||||
text=text,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""验证 extract-from-douyin 在各种失败场景返回正确的 HTTP 状态码(绝不能 500)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.auth import AuthenticatedUser
|
||||
|
||||
|
||||
class _FakeUser:
|
||||
id = "u-test"
|
||||
is_member = False
|
||||
member_type = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_user():
|
||||
return AuthenticatedUser(user=_FakeUser())
|
||||
|
||||
|
||||
class _FakeYDLBase:
|
||||
"""通用假 yt-dlp 基类"""
|
||||
extract_info_result = None
|
||||
extract_info_raises = None
|
||||
prepare_filename_result = "/tmp/fake.mp4"
|
||||
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
def extract_info(self, url, download=True):
|
||||
if self.__class__.extract_info_raises:
|
||||
raise self.__class__.extract_info_raises
|
||||
return self.__class__.extract_info_result
|
||||
|
||||
def prepare_filename(self, info):
|
||||
return self.__class__.prepare_filename_result
|
||||
|
||||
|
||||
def _install_fake_ytdlp(fake_ydl_class, *, download_error_cls=None):
|
||||
"""把假 yt-dlp 注入 sys.modules,函数内 import yt_dlp 会拿到我们的假版本"""
|
||||
fake_mod = types.ModuleType("yt_dlp")
|
||||
fake_mod.YoutubeDL = fake_ydl_class
|
||||
if download_error_cls is None:
|
||||
download_error_cls = type("DownloadError", (Exception,), {})
|
||||
fake_mod.DownloadError = download_error_cls
|
||||
utils_mod = types.ModuleType("yt_dlp.utils")
|
||||
utils_mod.DownloadError = download_error_cls
|
||||
fake_mod.utils = utils_mod
|
||||
sys.modules["yt_dlp"] = fake_mod
|
||||
sys.modules["yt_dlp.utils"] = utils_mod
|
||||
return fake_mod
|
||||
|
||||
|
||||
def _import_target():
|
||||
from app.api.routes import scripts_ai
|
||||
return scripts_ai
|
||||
|
||||
|
||||
def test_download_http404_returns_400_not_500(fake_user):
|
||||
"""无效短链 / 视频 404 → 应返回 400 业务错误,不能 500"""
|
||||
scripts_ai = _import_target()
|
||||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/test123/")
|
||||
|
||||
class DownloadError(Exception):
|
||||
pass
|
||||
class FailingYDL(_FakeYDLBase):
|
||||
extract_info_raises = DownloadError("ERROR: Unable to download webpage: HTTP Error 404: Not Found")
|
||||
_install_fake_ytdlp(FailingYDL, download_error_cls=DownloadError)
|
||||
|
||||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
assert exc.value.status_code == status.HTTP_400_BAD_REQUEST, f"应为400,实际 {exc.value.status_code}: {exc.value.detail}"
|
||||
assert "无法解析" in exc.value.detail or "抖音" in exc.value.detail
|
||||
|
||||
|
||||
def test_download_network_error_returns_502_not_500(fake_user):
|
||||
"""网络错误 / 上游异常 → 502,不能 500"""
|
||||
scripts_ai = _import_target()
|
||||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||||
|
||||
class DownloadError(Exception):
|
||||
pass
|
||||
class NetErrYDL(_FakeYDLBase):
|
||||
extract_info_raises = DownloadError("ERROR: Connection reset by peer")
|
||||
_install_fake_ytdlp(NetErrYDL, download_error_cls=DownloadError)
|
||||
|
||||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
def test_info_none_returns_400(fake_user):
|
||||
"""yt-dlp 返回 None info → 400"""
|
||||
scripts_ai = _import_target()
|
||||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||||
|
||||
class NoneInfoYDL(_FakeYDLBase):
|
||||
extract_info_result = None
|
||||
_install_fake_ytdlp(NoneInfoYDL)
|
||||
|
||||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
assert exc.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
def test_asr_not_configured_returns_503(fake_user):
|
||||
scripts_ai = _import_target()
|
||||
from app.services.script_asr_service import ASRNotConfiguredError
|
||||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||||
|
||||
import os.path
|
||||
class OkYDL(_FakeYDLBase):
|
||||
extract_info_result = {"id":"x","duration":10,"title":"t"}
|
||||
_install_fake_ytdlp(OkYDL)
|
||||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)), \
|
||||
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), \
|
||||
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), \
|
||||
mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=ASRNotConfiguredError("未配置")):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
|
||||
def test_asr_failure_returns_502(fake_user):
|
||||
scripts_ai = _import_target()
|
||||
from app.services.script_asr_service import ASRTranscriptionError
|
||||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||||
|
||||
class OkYDL(_FakeYDLBase):
|
||||
extract_info_result = {"id":"x","duration":10,"title":"t"}
|
||||
_install_fake_ytdlp(OkYDL)
|
||||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)), \
|
||||
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), \
|
||||
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), \
|
||||
mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=ASRTranscriptionError("识别失败")):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
def test_asr_unexpected_error_returns_502_not_500(fake_user):
|
||||
"""ASR 抛未预期异常(非 ASRNotConfigured/ASRTranscriptionError)也应被兜住,不能 500"""
|
||||
scripts_ai = _import_target()
|
||||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||||
|
||||
class OkYDL(_FakeYDLBase):
|
||||
extract_info_result = {"id":"x","duration":10,"title":"t"}
|
||||
_install_fake_ytdlp(OkYDL)
|
||||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)), \
|
||||
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), \
|
||||
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), \
|
||||
mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=RuntimeError("ffmpeg crashed")):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY, f"应为502,实际 {exc.value.status_code}"
|
||||
|
||||
|
||||
def test_missing_downloaded_file_returns_502_not_500(fake_user):
|
||||
"""yt-dlp 返回 info 但文件未落地(isfile False)→ 502"""
|
||||
scripts_ai = _import_target()
|
||||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||||
|
||||
class OkYDL(_FakeYDLBase):
|
||||
extract_info_result = {"id":"x","duration":10,"title":"t"}
|
||||
_install_fake_ytdlp(OkYDL)
|
||||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)), \
|
||||
mock.patch.object(scripts_ai.os.path, "isfile", return_value=False):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
assert exc.value.status_code != 500
|
||||
assert "下载异常" in exc.value.detail or "文件" in exc.value.detail
|
||||
|
||||
|
||||
def test_any_unexpected_error_does_not_return_500_raw(fake_user):
|
||||
"""兜底:prepare_filename 抛未预期异常也应被捕获,返回500 code但含业务detail"""
|
||||
scripts_ai = _import_target()
|
||||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||||
|
||||
class BuggyYDL(_FakeYDLBase):
|
||||
def extract_info(self, url, download=True):
|
||||
return {"id":"x","duration":"not_a_number","title":"t"}
|
||||
def prepare_filename(self, info):
|
||||
raise RuntimeError("some internal bug")
|
||||
_install_fake_ytdlp(BuggyYDL)
|
||||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
# 只要不是被全局 INTERNAL_ERROR 吞掉就行(带 detail 的 500 也比通用 500 强)
|
||||
assert "抖音" in exc.value.detail or "失败" in exc.value.detail or exc.value.status_code != 500
|
||||
@@ -73,8 +73,12 @@ class TestExtractFromDouyin:
|
||||
@patch("app.api.routes.scripts_ai.transcribe_to_text")
|
||||
@patch("tempfile.TemporaryDirectory")
|
||||
@patch("yt_dlp.YoutubeDL")
|
||||
@patch("app.api.routes.scripts_ai.os.path.getsize", return_value=1024)
|
||||
@patch("app.api.routes.scripts_ai.os.path.isfile", return_value=True)
|
||||
def test_extract_from_douyin_success(
|
||||
self,
|
||||
mock_isfile,
|
||||
mock_getsize,
|
||||
mock_ydl_cls,
|
||||
mock_tempdir,
|
||||
mock_transcribe,
|
||||
@@ -156,8 +160,12 @@ class TestExtractFromDouyin:
|
||||
@patch("app.api.routes.scripts_ai.transcribe_to_text")
|
||||
@patch("tempfile.TemporaryDirectory")
|
||||
@patch("yt_dlp.YoutubeDL")
|
||||
@patch("app.api.routes.scripts_ai.os.path.getsize", return_value=1024)
|
||||
@patch("app.api.routes.scripts_ai.os.path.isfile", return_value=True)
|
||||
def test_extract_from_douyin_asr_not_configured(
|
||||
self,
|
||||
mock_isfile,
|
||||
mock_getsize,
|
||||
mock_ydl_cls,
|
||||
mock_tempdir,
|
||||
mock_transcribe,
|
||||
@@ -189,8 +197,12 @@ class TestExtractFromDouyin:
|
||||
@patch("app.api.routes.scripts_ai.transcribe_to_text")
|
||||
@patch("tempfile.TemporaryDirectory")
|
||||
@patch("yt_dlp.YoutubeDL")
|
||||
@patch("app.api.routes.scripts_ai.os.path.getsize", return_value=1024)
|
||||
@patch("app.api.routes.scripts_ai.os.path.isfile", return_value=True)
|
||||
def test_extract_from_douyin_asr_failure(
|
||||
self,
|
||||
mock_isfile,
|
||||
mock_getsize,
|
||||
mock_ydl_cls,
|
||||
mock_tempdir,
|
||||
mock_transcribe,
|
||||
|
||||
Reference in New Issue
Block a user