diff --git a/apps/api/app/api/routes/scripts_ai.py b/apps/api/app/api/routes/scripts_ai.py index 78a999942..0d3a2a549 100644 --- a/apps/api/app/api/routes/scripts_ai.py +++ b/apps/api/app/api/routes/scripts_ai.py @@ -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, diff --git a/tests/unit/test_extract_from_douyin_errors.py b/tests/unit/test_extract_from_douyin_errors.py new file mode 100644 index 000000000..affcfa6be --- /dev/null +++ b/tests/unit/test_extract_from_douyin_errors.py @@ -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 diff --git a/tests/unit/test_scripts_ai.py b/tests/unit/test_scripts_ai.py index 87b9f2417..86bb7f0d7 100644 --- a/tests/unit/test_scripts_ai.py +++ b/tests/unit/test_scripts_ai.py @@ -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,