From 8fa287036d794112dcbf58b49018e33dd02f9cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=A8=E6=88=B7CI=20Test?= Date: Fri, 10 Jul 2026 11:25:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20generation=5Ftasks=20results=20=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E8=BF=94=E5=9B=9E=20OSS=20=E9=A2=84=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=20URL=EF=BC=8824h=20=E6=9C=89=E6=95=88=E6=9C=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-2 修复:list_generation_results 端点原先直接返回原始 file_url, OSS bucket 为 private 导致前端访问视频返回 403。 改动: - 注入 storage_service 依赖(复用已有 OSSStorageService.get_download_url) - 对每个视频的 file_url 生成预签名 URL,有效期 86400 秒(24h) - _to_generated_video_response 支持 download_url 参数 - 与 generated_videos.py 中的模式保持一致 新增测试: - test_results_endpoint_generates_presigned_urls:验证预签名 URL 生成 - test_results_endpoint_handles_empty_videos:验证空结果正常返回 Co-Authored-By: Claude Fable 5 --- apps/api/app/api/routes/generation_tasks.py | 11 +- tests/unit/test_generation_presigned_url.py | 179 ++++++++++++++++++++ 2 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_generation_presigned_url.py diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index b4ece7f3e..4f95856a4 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -4,6 +4,7 @@ from typing import Any from app.auth import AuthenticatedUser, get_current_user from app.core.celery_app import celery_app +from app.core.storage import OSSStorageService, get_storage_service from app.dependencies import ( get_asset_library_repository, get_asset_repository, @@ -63,7 +64,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse: ) -def _to_generated_video_response(item) -> GeneratedVideoResponse: +def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse: return GeneratedVideoResponse( id=item.id, project_id=item.project_id, @@ -76,6 +77,7 @@ def _to_generated_video_response(item) -> GeneratedVideoResponse: width=item.width, height=item.height, fps=item.fps, + download_url=download_url, ) @@ -265,6 +267,7 @@ def list_generation_results( generation_task_repository: Any = Depends(get_generation_task_repository), generated_video_repository: Any = Depends(get_generated_video_repository), project_repository: Any = Depends(get_project_repository), + storage_service: OSSStorageService = Depends(get_storage_service), ) -> ListGeneratedVideosResponse: task = generation_task_repository.get(task_id) if task is None: @@ -273,7 +276,11 @@ def list_generation_results( _check_project_access(task.project_id, authenticated_user.user.id, project_repository) use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository) items = use_case.execute(task_id) - return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items]) + responses = [] + for item in items: + download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400) + responses.append(_to_generated_video_response(item, download_url=download_url)) + return ListGeneratedVideosResponse(items=responses) @router.post("/tasks/{task_id}/retry", response_model=GenerationTaskResponse) diff --git a/tests/unit/test_generation_presigned_url.py b/tests/unit/test_generation_presigned_url.py new file mode 100644 index 000000000..12caf3ce7 --- /dev/null +++ b/tests/unit/test_generation_presigned_url.py @@ -0,0 +1,179 @@ +""" +P0-2 修复测试:generation_tasks results 端点返回 OSS 预签名 URL + +验证 list_generation_results 端点: +- 对每个生成视频的 file_url 调用 storage_service.get_download_url() +- 返回的 download_url 是预签名临时 URL(24h 有效期) +- 与 generated_videos.py 中的模式一致 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from app.api.routes.generation_tasks import router +from app.auth import AuthenticatedUser, get_current_user +from app.core.storage import OSSStorageService, get_storage_service +from app.dependencies import ( + get_generated_video_repository, + get_generation_task_repository, + get_project_repository, +) +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from packages.domain import ( + GeneratedVideo, + GenerationTask, + GenerationTaskStatus, + Project, + User, +) + +# --------------------------------------------------------------------------- +# Stub repositories +# --------------------------------------------------------------------------- + + +class StubProjectRepository: + def __init__(self, projects=None): + self._projects = projects or {} + + def find_by_id(self, project_id): + return self._projects.get(project_id) + + +class StubGenerationTaskRepository: + def __init__(self, tasks=None): + self._tasks = tasks or {} + + def get(self, task_id): + return self._tasks.get(task_id) + + +class StubGeneratedVideoRepository: + def __init__(self, videos=None): + self._videos = videos or {} + + def list_by_generation_task(self, task_id): + return [v for v in self._videos.values() if v.generation_task_id == task_id] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_app(task, videos, storage_mock): + app = FastAPI() + app.include_router(router, prefix="/api/v1/generation") + + user = User(id="user-1", email="test@test.com", display_name="Test", username="testuser") + auth = AuthenticatedUser(user=user, token_type="access") + + project = Project(id="project-1", name="Test", description="", owner_user_id="user-1") + + app.dependency_overrides[get_current_user] = lambda: auth + app.dependency_overrides[get_project_repository] = lambda: StubProjectRepository({"project-1": project}) + app.dependency_overrides[get_generation_task_repository] = lambda: StubGenerationTaskRepository({"task-1": task}) + app.dependency_overrides[get_generated_video_repository] = lambda: StubGeneratedVideoRepository(videos) + app.dependency_overrides[get_storage_service] = lambda: storage_mock + + return app + + +def test_results_endpoint_generates_presigned_urls(): + """验证 list_generation_results 为每个视频生成预签名 download_url""" + task = GenerationTask( + id="task-1", + project_id="project-1", + asset_library_id="lib-1", + strategy_id="s1", + status=GenerationTaskStatus.COMPLETED, + progress=100, + result_count=2, + created_by_user_id="user-1", + ) + + videos = { + "v1": GeneratedVideo.create( + project_id="project-1", + generation_task_id="task-1", + name="video1.mp4", + file_url="https://bucket.oss-cn-hangzhou.aliyuncs.com/generated/v1.mp4", + file_size=1024, + duration=5.0, + width=1280, + height=720, + fps=25.0, + ), + "v2": GeneratedVideo.create( + project_id="project-1", + generation_task_id="task-1", + name="video2.mp4", + file_url="https://bucket.oss-cn-hangzhou.aliyuncs.com/generated/v2.mp4", + file_size=2048, + duration=10.0, + width=1920, + height=1080, + fps=30.0, + ), + } + + storage_mock = MagicMock(spec=OSSStorageService) + storage_mock.get_download_url.side_effect = ( + lambda url, expires_seconds=3600: f"{url}?signature=presigned&expires={expires_seconds}" + ) + + app = _make_app(task, videos, storage_mock) + client = TestClient(app) + + response = client.get("/api/v1/generation/tasks/task-1/results") + assert response.status_code == 200 + + data = response.json() + assert len(data["items"]) == 2 + + # Verify presigned URLs were generated with 24h expiry + assert storage_mock.get_download_url.call_count == 2 + for call in storage_mock.get_download_url.call_args_list: + assert call.kwargs["expires_seconds"] == 86400 + + # Verify download_url is present in response + for item in data["items"]: + assert item["download_url"] is not None + assert "signature=presigned" in item["download_url"] + assert "expires=86400" in item["download_url"] + + # Verify file_url is still the original (raw) URL + assert data["items"][0]["file_url"] == videos["v1"].file_url + + +def test_results_endpoint_handles_empty_videos(): + """验证无视频时正常返回空列表""" + task = GenerationTask( + id="task-1", + project_id="project-1", + asset_library_id="lib-1", + strategy_id="s1", + status=GenerationTaskStatus.RUNNING, + progress=50, + created_by_user_id="user-1", + ) + + storage_mock = MagicMock(spec=OSSStorageService) + app = _make_app(task, {}, storage_mock) + client = TestClient(app) + + response = client.get("/api/v1/generation/tasks/task-1/results") + assert response.status_code == 200 + assert response.json()["items"] == [] + storage_mock.get_download_url.assert_not_called() -- 2.54.0