fix: 统一封面管道增加cover_url多步fallback查找,修复500错误 #1373

Merged
auto-approve-bot merged 2 commits from fix/cover-500-cover-url-fallback into develop 2026-08-15 11:41:00 +08:00
2 changed files with 231 additions and 60 deletions
+78 -16
View File
@@ -209,39 +209,101 @@ def generate_cover(
) from e
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
if body.cover_type in ("ai_frame", "ai_regenerate"):
# 尝试从 GenerationTask 读取已持久化的封面 URL
cover_url_from_task = None
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
# 步骤 A:通过 generation_task_id 直接查找
generation_task_id = (plan.config or {}).get("generation_task_id", "")
if generation_task_id:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
task = gen_task_repo.get(generation_task_id)
if task and getattr(task, "cover_url", ""):
cover_data = {
"type": "ai_frame",
"image_url": task.cover_url,
"frame_time": 0.0,
"confidence": 0.95,
}
cover_url_from_task = task.cover_url
logger.info(
"[封面生成] 使用统一管道封面: plan_id=%s task_id=%s url=%s",
"[封面生成] 统一管道封面(步骤A-direct): plan_id=%s task_id=%s url=%s",
plan_id,
generation_task_id,
task.cover_url[:80],
cover_url_from_task[:80],
)
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
except Exception:
logger.warning(
"[封面生成] 读取 GenerationTask.cover_url 失败: plan_id=%s task_id=%s",
"[封面生成] 步骤A读取 cover_url 失败: plan_id=%s task_id=%s",
plan_id,
generation_task_id,
exc_info=True,
)
# 步骤 B:通过 source_edit_plan_id 查找关联预览任务的 cover_url
if not cover_url_from_task:
try:
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
for pt in preview_tasks:
if getattr(pt, "status", "") == "completed" and getattr(pt, "cover_url", ""):
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 统一管道封面(步骤B-source_plan): plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 C:通过 user+template 查找最近的已完成预览任务的 cover_url
if not cover_url_from_task:
try:
preview_tasks = gen_task_repo.list_latest_completed_preview(
user_id=str(current_user.user.id),
template_id=template_id,
)
for pt in preview_tasks:
if getattr(pt, "cover_url", ""):
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 统一管道封面(步骤C-user+template): plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤C查找 cover_url 失败: plan_id=%s template_id=%s",
plan_id,
template_id,
exc_info=True,
)
if cover_url_from_task:
cover_data = {
"type": "ai_frame",
"image_url": cover_url_from_task,
"frame_time": 0.0,
"confidence": 0.95,
}
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
logger.warning(
"[封面生成] 统一管道未找到 cover_url: plan_id=%s",
plan_id,
)
# ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务
raise HTTPException(
status_code=400,
detail="封面尚未生成,请先重新生成预览视频以触发封面自动提取",
)
from packages.shared.ai_service import run_generate_cover
try:
+153 -44
View File
@@ -154,11 +154,12 @@ class TestUnifiedCoverPipelineEndpoint:
assert result.cover["image_url"] == "https://oss.example.com/rendered/plan-1/cover.jpg"
assert result.cover["type"] == "ai_frame"
def test_cover_url_fallback_when_no_cover_url(self):
""" GenerationTask 没有 cover_url 时,跳过统一管道走 run_generate_cover"""
def test_cover_url_all_fallbacks_fail_returns_400(self):
"""所有步骤都找不到 cover_url 时,返回 400 而非 500"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
from fastapi import HTTPException
mock_plan = MagicMock()
mock_plan.config = {
@@ -168,7 +169,6 @@ class TestUnifiedCoverPipelineEndpoint:
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
# Task has no cover_url
@@ -181,42 +181,150 @@ class TestUnifiedCoverPipelineEndpoint:
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("packages.shared.ai_service.run_generate_cover") as mock_run,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
mock_repo.get.return_value = mock_task
# No tasks found by source_edit_plan_id or user+template
mock_repo.list_by_source_edit_plan.return_value = []
mock_repo.list_latest_completed_preview.return_value = []
mock_repo_cls.return_value = mock_repo
# Mock storage service
mock_storage_svc = MagicMock()
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/plan-2/video.mp4"
mock_storage_getter.return_value = mock_storage_svc
mock_run.return_value = {"type": "ai_frame", "image_url": "https://fallback.com/cover.jpg"}
from app.api.routes.generation_cover import generate_cover
with pytest.raises(HTTPException) as exc_info:
generate_cover(
body=body,
template_id="template-2",
plan_id="plan-2",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=MagicMock(),
)
assert exc_info.value.status_code == 400
assert "封面尚未生成" in exc_info.value.detail
def test_cover_url_found_via_source_edit_plan(self):
"""步骤B:通过 source_edit_plan_id 找到预览任务的 cover_url。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
mock_plan = MagicMock()
# No generation_task_id, so step A is skipped
mock_plan.config = {
"rendered_storage_key": "rendered/plan-x/video.mp4",
}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
# Preview task found by source_edit_plan_id with cover_url
mock_preview_task = MagicMock()
mock_preview_task.id = "preview-task-abc"
mock_preview_task.status = "completed"
mock_preview_task.cover_url = "https://oss.example.com/rendered/preview/cover.jpg"
mock_db = MagicMock()
body = GenerateCoverRequest(cover_type="ai_frame")
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_repo = MagicMock()
mock_repo.list_by_source_edit_plan.return_value = [mock_preview_task]
mock_repo_cls.return_value = mock_repo
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/preview/cover.jpg"}
}
from app.api.routes.generation_cover import generate_cover
result = generate_cover(
body=body,
template_id="template-2",
plan_id="plan-2",
template_id="template-x",
plan_id="plan-x",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=MagicMock(),
)
# 应该走 run_generate_cover 回退
mock_run.assert_called_once()
assert result.cover["image_url"] == "https://fallback.com/cover.jpg"
assert result.cover["image_url"] == "https://oss.example.com/rendered/preview/cover.jpg"
mock_repo.list_by_source_edit_plan.assert_called_once_with("plan-x")
def test_cover_url_found_via_user_template(self):
"""步骤C:通过 user+template 找到预览任务的 cover_url。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
mock_plan = MagicMock()
mock_plan.config = {
"rendered_storage_key": "rendered/plan-y/video.mp4",
}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
# Step B finds nothing, step C finds a task
mock_preview_task = MagicMock()
mock_preview_task.id = "preview-task-def"
mock_preview_task.status = "completed"
mock_preview_task.cover_url = "https://oss.example.com/rendered/user-template-cover.jpg"
mock_current_user = MagicMock()
mock_current_user.user.id = "user-123"
mock_db = MagicMock()
body = GenerateCoverRequest(cover_type="ai_frame")
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_repo = MagicMock()
mock_repo.list_by_source_edit_plan.return_value = []
mock_repo.list_latest_completed_preview.return_value = [mock_preview_task]
mock_repo_cls.return_value = mock_repo
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/user-template-cover.jpg"}
}
from app.api.routes.generation_cover import generate_cover
result = generate_cover(
body=body,
template_id="template-y",
plan_id="plan-y",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert result.cover["image_url"] == "https://oss.example.com/rendered/user-template-cover.jpg"
mock_repo.list_latest_completed_preview.assert_called_once_with(
user_id="user-123",
template_id="template-y",
)
class TestSourceEditPlanFallback:
"""测试步骤 2.5:通过 source_edit_plan_id 查找预览视频兜底逻辑。"""
def test_step25_finds_video_by_source_edit_plan_id(self):
"""当步骤1和步骤2都找不到时,步骤2.5通过source_edit_plan_id找到预览视频。"""
from unittest.mock import MagicMock, call, patch
"""当步骤1和步骤2都找不到时,步骤2.5通过source_edit_plan_id找到预览视频和封面"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
@@ -228,11 +336,12 @@ class TestSourceEditPlanFallback:
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
# Mock preview task found by source_edit_plan_id
# Mock preview task found by source_edit_plan_id — with cover_url
mock_preview_task = MagicMock()
mock_preview_task.id = "preview-task-789"
mock_preview_task.status = "completed"
mock_preview_task.is_preview = True
mock_preview_task.cover_url = "https://oss.example.com/rendered/cover.jpg"
# Mock generated video
mock_video = MagicMock()
@@ -247,13 +356,10 @@ class TestSourceEditPlanFallback:
patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
patch("packages.shared.ai_service.run_generate_cover") as mock_run,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
# Step 2: no generation_task_id, so get() is not called
mock_repo.get.return_value = None
# Step 2.5: found by source_edit_plan_id
# Both video lookup (step 2.5) and cover_url lookup (step B) use this
mock_repo.list_by_source_edit_plan.return_value = [mock_preview_task]
mock_repo_cls.return_value = mock_repo
@@ -261,14 +367,9 @@ class TestSourceEditPlanFallback:
mock_usecase.execute.return_value = [mock_video]
mock_usecase_cls.return_value = mock_usecase
mock_normalize.return_value = {"cover": {"type": "ai_frame", "image_url": ""}}
mock_storage_svc = MagicMock()
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/plan-x/video.mp4"
mock_storage_getter.return_value = mock_storage_svc
# cover_type=ai_frame but no cover_url on task -> fallback to run_generate_cover
mock_run.return_value = {"type": "ai_frame", "image_url": "https://cover.com/final.jpg"}
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/cover.jpg"}
}
from app.api.routes.generation_cover import generate_cover
@@ -281,10 +382,10 @@ class TestSourceEditPlanFallback:
current_user=MagicMock(),
)
# Verify step 2.5 was called
mock_repo.list_by_source_edit_plan.assert_called_once_with("plan-x")
# Step 3 should NOT be called since step 2.5 found the video
mock_repo.list_latest_completed_preview.assert_not_called()
# Verify step 2.5 was called for video
mock_repo.list_by_source_edit_plan.assert_called_with("plan-x")
# Cover was found via unified pipeline step B
assert result.cover["image_url"] == "https://oss.example.com/rendered/cover.jpg"
def test_step25_skips_non_completed_or_non_preview_tasks(self):
"""步骤2.5跳过非completed或非is_preview的任务,继续到步骤3。"""
@@ -304,18 +405,22 @@ class TestSourceEditPlanFallback:
mock_task_failed.id = "task-failed"
mock_task_failed.status = "failed"
mock_task_failed.is_preview = True
mock_task_failed.cover_url = ""
# Task that is not preview
mock_task_full = MagicMock()
mock_task_full.id = "task-full"
mock_task_full.status = "completed"
mock_task_full.is_preview = False
mock_task_full.cover_url = ""
mock_db = MagicMock()
# Step 3 fallback finds a valid preview task
# Step 3 fallback finds a valid preview task WITH cover_url
mock_step3_task = MagicMock()
mock_step3_task.id = "step3-task"
mock_step3_task.status = "completed"
mock_step3_task.cover_url = "https://oss.example.com/rendered/step3-cover.jpg"
mock_video = MagicMock()
mock_video.file_url = "rendered/step3/video.mp4"
@@ -328,13 +433,11 @@ class TestSourceEditPlanFallback:
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.ai_service.run_generate_cover") as mock_run,
):
mock_run.return_value = {"type": "ai_frame", "image_url": "https://cover.com/step3.jpg"}
mock_repo = MagicMock()
mock_repo.get.return_value = None
mock_repo.list_by_source_edit_plan.return_value = [mock_task_failed, mock_task_full]
# Step 3 returns a valid task
# Video step 3 and cover step C both use list_latest_completed_preview
mock_repo.list_latest_completed_preview.return_value = [mock_step3_task]
mock_repo_cls.return_value = mock_repo
@@ -342,7 +445,9 @@ class TestSourceEditPlanFallback:
mock_usecase.execute.return_value = [mock_video]
mock_usecase_cls.return_value = mock_usecase
mock_normalize.return_value = {"cover": {"type": "ai_frame", "image_url": ""}}
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/step3-cover.jpg"}
}
mock_storage_svc = MagicMock()
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/step3/video.mp4"
@@ -360,11 +465,12 @@ class TestSourceEditPlanFallback:
)
# Step 2.5 found tasks but none matched -> step 3 should be called
mock_repo.list_by_source_edit_plan.assert_called_once()
mock_repo.list_latest_completed_preview.assert_called_once()
mock_repo.list_by_source_edit_plan.assert_called()
mock_repo.list_latest_completed_preview.assert_called()
assert result.cover["image_url"] == "https://oss.example.com/rendered/step3-cover.jpg"
def test_step25_exception_does_not_block_step3(self):
"""步骤2.5异常时不影响步骤3兜底。"""
"""步骤2.5异常时不影响步骤3兜底(视频和封面都通过步骤3找到)"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
@@ -378,6 +484,8 @@ class TestSourceEditPlanFallback:
mock_step3_task = MagicMock()
mock_step3_task.id = "step3-task"
mock_step3_task.status = "completed"
mock_step3_task.cover_url = "https://oss.example.com/rendered/step3-cover.jpg"
mock_video = MagicMock()
mock_video.file_url = "rendered/step3/video.mp4"
@@ -392,14 +500,12 @@ class TestSourceEditPlanFallback:
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.ai_service.run_generate_cover") as mock_run,
):
mock_run.return_value = {"type": "ai_frame", "image_url": "https://cover.com/step3.jpg"}
mock_repo = MagicMock()
mock_repo.get.return_value = None
# Step 2.5 raises
# Both video step 2.5 and cover step B raise
mock_repo.list_by_source_edit_plan.side_effect = RuntimeError("db error")
# Step 3 succeeds
# Step 3 / step C succeeds
mock_repo.list_latest_completed_preview.return_value = [mock_step3_task]
mock_repo_cls.return_value = mock_repo
@@ -407,7 +513,9 @@ class TestSourceEditPlanFallback:
mock_usecase.execute.return_value = [mock_video]
mock_usecase_cls.return_value = mock_usecase
mock_normalize.return_value = {"cover": {"type": "ai_frame", "image_url": ""}}
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/step3-cover.jpg"}
}
mock_storage_svc = MagicMock()
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/step3/video.mp4"
@@ -425,7 +533,8 @@ class TestSourceEditPlanFallback:
)
# Step 3 was called after step 2.5 failed
mock_repo.list_latest_completed_preview.assert_called_once()
mock_repo.list_latest_completed_preview.assert_called()
assert result.cover["image_url"] == "https://oss.example.com/rendered/step3-cover.jpg"
class TestStrayLoggerRemoved: