From 8b1be55f9d422a97eede57eb6b9238bde5ebbf8a Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 15 Aug 2026 11:30:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E5=B0=81=E9=9D=A2?= =?UTF-8?q?=E7=AE=A1=E9=81=93=E5=A2=9E=E5=8A=A0cover=5Furl=E5=A4=9A?= =?UTF-8?q?=E6=AD=A5fallback=E6=9F=A5=E6=89=BE=EF=BC=8C=E4=BF=AE=E5=A4=8D5?= =?UTF-8?q?00=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:统一封面管道只通过 generation_task_id 查找 cover_url, 但 generation_task_id 为空时(如 plan 创建时未关联), 直接 fall through 到 run_generate_cover → RuntimeError → 500。 修复: - 统一封面管道 section 增加与视频URL查找一致的 3 步 fallback: A: generation_task_id 直接查找 B: source_edit_plan_id 查找 C: user+template 兜底查找 - 所有步骤都找不到 cover_url 时返回 400 而非 500, 提示用户重新生成预览视频以触发封面自动提取 - 新增 3 个单元测试覆盖新 fallback 路径 --- apps/api/app/api/routes/generation_cover.py | 94 ++++++++-- tests/unit/test_generation_cover.py | 191 +++++++++++++++----- 2 files changed, 225 insertions(+), 60 deletions(-) diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index 90aa494e0..78b34b42f 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -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: diff --git a/tests/unit/test_generation_cover.py b/tests/unit/test_generation_cover.py index 86849758c..a38a31ff9 100644 --- a/tests/unit/test_generation_cover.py +++ b/tests/unit/test_generation_cover.py @@ -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,7 @@ 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 +380,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 +403,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 +431,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 +443,7 @@ 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 +461,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 +480,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 +496,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 +509,7 @@ 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 +527,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: -- 2.54.0 From e59cb168479a03aad972ca63e0b36713138e394f Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 15 Aug 2026 03:34:13 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + prettier [skip ci-format-check] --- tests/unit/test_generation_cover.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_generation_cover.py b/tests/unit/test_generation_cover.py index a38a31ff9..3f0e448a5 100644 --- a/tests/unit/test_generation_cover.py +++ b/tests/unit/test_generation_cover.py @@ -367,7 +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": "https://oss.example.com/rendered/cover.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 @@ -443,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": "https://oss.example.com/rendered/step3-cover.jpg"}} + 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" @@ -509,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": "https://oss.example.com/rendered/step3-cover.jpg"}} + 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" -- 2.54.0