Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17c6b0e3bd | |||
| 78cab46578 | |||
| 9e87781a85 | |||
| 57545ab694 | |||
| 51694cbd0c | |||
| fca943428b | |||
| 3eb2fcf3ec | |||
| 50b413a6fa | |||
| 410487fef5 | |||
| fc99b5a080 |
+465
-467
File diff suppressed because it is too large
Load Diff
@@ -494,3 +494,5 @@
|
||||
- [Fixed] Bug 修复
|
||||
- [Security] 安全相关更新
|
||||
- [Performance] 性能优化
|
||||
---
|
||||
- 2026-09-16: fix extract-from-douyin 异常路径全部返回业务码(消除500) #1963
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -49,10 +49,10 @@ type AssetListResponse = {
|
||||
}
|
||||
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 360_000 })
|
||||
test.describe.configure({ timeout: 600_000 })
|
||||
|
||||
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(360_000)
|
||||
test.setTimeout(600_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
@@ -264,22 +264,22 @@ test.describe("Core generation flow", () => {
|
||||
// 注意:进度页底部按钮变为 disabled 的「⏳ 视频渲染中…」
|
||||
await expect(page.getByText("视频渲染中")).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// 等待渲染完成:进度卡变为「视频生成完成」(最长等待 3 分钟)
|
||||
await expect(page.getByText("视频生成完成")).toBeVisible({ timeout: 180_000 })
|
||||
// 等待渲染完成:单视频成片播放器渲染(带「⬇️ 下载」按钮),最长等待 3 分钟
|
||||
// 注意:message.success「视频生成完成」toast 3秒后自动消失,不能作为稳定断言点
|
||||
await expect(page.getByRole("button", { name: "⬇️ 下载" })).toBeVisible({ timeout: 420_000 })
|
||||
|
||||
// #1954 修复:生成完成后步骤4底部应显示「下一步:选择封面」按钮,点击进入步骤5封面页
|
||||
// #1954 修复:生成完成后步骤4底部应显示「下一步:选择封面」按钮
|
||||
// 等待底部主按钮从「⏳/确认生成」切换为「下一步:选择封面」
|
||||
const nextCoverBtn = page
|
||||
.locator(".xx-step-actions .xx-btn-primary")
|
||||
.filter({ hasText: "下一步:选择封面" })
|
||||
await expect(nextCoverBtn).toBeVisible({ timeout: 10_000 })
|
||||
.locator(".xx-step-actions > .xx-btn-primary")
|
||||
.filter({ hasText: "选择封面" })
|
||||
await expect(nextCoverBtn).toBeVisible({ timeout: 15_000 })
|
||||
await nextCoverBtn.click()
|
||||
|
||||
// 断言进入步骤5:步骤条应高亮「选择封面」,主内容出现「🖼️ 选择封面」标题
|
||||
await expect(page.getByRole("heading", { name: "🖼️ 选择封面" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
// 主按钮应消失(封面是最后一步),仅保留「← 上一步」
|
||||
await expect(page.locator(".xx-step-actions .xx-btn-primary")).toHaveCount(0)
|
||||
// 断言进入步骤5封面页:主内容出现「选择封面」标题
|
||||
await expect(page.getByText("🖼️ 选择封面")).toBeVisible({ timeout: 10_000 })
|
||||
// 底部操作栏主按钮应消失(封面是最后一步,只剩「← 上一步」)
|
||||
await expect(page.locator(".xx-step-actions > .xx-btn-primary")).toHaveCount(0)
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
// 创建失败时停留在标题页并展示错误提示
|
||||
|
||||
@@ -69,7 +69,11 @@ if [ -z "$IMAGE_TAG" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "ERROR: $ENV_FILE 不存在。CI 应先在 render_env 步骤渲染并上传此文件"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ .env file found: $ENV_FILE ($(wc -l < $ENV_FILE) lines)"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
|
||||
@@ -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