diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index ec8902fe1..5fdac8e03 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -13,6 +13,7 @@ from app.api.routes.generated_videos import router as generated_videos_router from app.api.routes.generation_tasks import router as generation_tasks_router from app.api.routes.health import router as health_check_router from app.api.routes.ingest_jobs import router as ingest_jobs_router +from app.api.routes.internal_render import router as internal_render_router from app.api.routes.jobs import router as jobs_router from app.api.routes.projects import router as projects_router from app.api.routes.recipes import router as recipes_router @@ -156,3 +157,7 @@ api_router.include_router( feature_flags_router, tags=["Internal"], ) +api_router.include_router( + internal_render_router, + tags=["Internal"], +) diff --git a/apps/api/app/api/routes/internal_render.py b/apps/api/app/api/routes/internal_render.py new file mode 100755 index 000000000..9d1cf3da3 --- /dev/null +++ b/apps/api/app/api/routes/internal_render.py @@ -0,0 +1,120 @@ +"""渲染结果内部下载接口。 + +通过内部 API Key 鉴权,为灰度对比工具等内部系统提供渲染结果下载能力。 + +API: + GET /api/v1/internal/render/videos/{video_id}/download-url - 获取单个视频下载URL + GET /api/v1/internal/render/tasks/{task_id}/videos - 获取任务下所有视频及下载URL + +鉴权:X-API-Key header,走内部 API Key 验证 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.api.routes.auth import _verify_internal_api_key +from app.core.storage import OSSStorageService, get_storage_service +from app.dependencies import get_generated_video_repository +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/internal/render", tags=["Internal"]) + + +class InternalRenderVideoItem(BaseModel): + """内部渲染视频项。""" + + video_id: str + generation_task_id: str + project_id: str + name: str + file_url: str + file_size: int | None = None + duration: float | None = None + width: int | None = None + height: int | None = None + fps: float | None = None + status: str + download_url: str + + +class InternalRenderTaskVideosResponse(BaseModel): + """任务下所有渲染视频响应。""" + + task_id: str + count: int + videos: list[InternalRenderVideoItem] + + +class InternalRenderDownloadUrlResponse(BaseModel): + """单个视频下载URL响应。""" + + video_id: str + download_url: str + + +def _video_to_item(video: Any, download_url: str) -> InternalRenderVideoItem: + """将 GeneratedVideo 领域对象转为响应项。""" + return InternalRenderVideoItem( + video_id=video.id, + generation_task_id=video.generation_task_id, + project_id=video.project_id, + name=video.name, + file_url=video.file_url, + file_size=getattr(video, "file_size", None), + duration=getattr(video, "duration", None), + width=getattr(video, "width", None), + height=getattr(video, "height", None), + fps=getattr(video, "fps", None), + status=video.status, + download_url=download_url, + ) + + +@router.get("/videos/{video_id}/download-url", response_model=InternalRenderDownloadUrlResponse) +def get_render_video_download_url( + video_id: str, + _: bool = Depends(_verify_internal_api_key), + generated_video_repository: Any = Depends(get_generated_video_repository), + storage_service: OSSStorageService = Depends(get_storage_service), +) -> InternalRenderDownloadUrlResponse: + """获取单个渲染视频的下载URL(预签名)。""" + video = generated_video_repository.get(video_id) + if video is None: + raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found") + + download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400) + logger.info("内部渲染下载URL生成: video_id=%s", video_id) + return InternalRenderDownloadUrlResponse(video_id=video_id, download_url=download_url) + + +@router.get("/tasks/{task_id}/videos", response_model=InternalRenderTaskVideosResponse) +def get_render_task_videos( + task_id: str, + status: str | None = Query(None, description="按状态筛选,如 completed/failed"), + _: bool = Depends(_verify_internal_api_key), + generated_video_repository: Any = Depends(get_generated_video_repository), + storage_service: OSSStorageService = Depends(get_storage_service), +) -> InternalRenderTaskVideosResponse: + """获取生成任务下所有渲染视频及下载URL。""" + videos = generated_video_repository.list_by_generation_task(task_id) + + # 状态筛选 + if status: + videos = [v for v in videos if v.status == status] + + items = [] + for video in videos: + download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400) + items.append(_video_to_item(video, download_url)) + + logger.info("内部渲染任务视频查询: task_id=%s count=%d", task_id, len(items)) + return InternalRenderTaskVideosResponse( + task_id=task_id, + count=len(items), + videos=items, + ) diff --git a/tests/unit/test_internal_render_download.py b/tests/unit/test_internal_render_download.py new file mode 100644 index 000000000..c93b1082e --- /dev/null +++ b/tests/unit/test_internal_render_download.py @@ -0,0 +1,190 @@ +"""渲染结果内部下载接口单元测试。 + +测试 internal_render 路由的核心逻辑,mock 掉 repository 和 storage 依赖。 +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from app.api.routes.internal_render import ( + InternalRenderDownloadUrlResponse, + InternalRenderTaskVideosResponse, + _video_to_item, + get_render_task_videos, + get_render_video_download_url, +) + +# ── Helpers ──────────────────────────────────────────────────────────────── + + +class MockVideo: + """模拟 GeneratedVideo 领域对象。""" + + def __init__(self, **kwargs): + self.id = kwargs.get("id", "video-1") + self.generation_task_id = kwargs.get("generation_task_id", "task-1") + self.project_id = kwargs.get("project_id", "proj-1") + self.name = kwargs.get("name", "test_video.mp4") + self.file_url = kwargs.get("file_url", "videos/test/output.mp4") + self.file_size = kwargs.get("file_size", 1024000) + self.duration = kwargs.get("duration", 30.5) + self.width = kwargs.get("width", 1080) + self.height = kwargs.get("height", 1920) + self.fps = kwargs.get("fps", 30.0) + self.status = kwargs.get("status", "completed") + + +# ── _video_to_item 测试 ──────────────────────────────────────────────────── + + +class TestVideoToItem: + """测试视频对象转响应项。""" + + def test_basic_conversion(self): + video = MockVideo(id="v1", generation_task_id="t1", status="completed") + item = _video_to_item(video, "https://oss.example.com/download?v1") + assert item.video_id == "v1" + assert item.generation_task_id == "t1" + assert item.status == "completed" + assert item.download_url == "https://oss.example.com/download?v1" + + def test_missing_optional_fields(self): + """缺可选字段时返回 None。""" + video = MockVideo() + # 去掉可选字段 + del video.file_size + del video.duration + item = _video_to_item(video, "https://example.com/dl") + assert item.file_size is None + assert item.duration is None + assert item.width == 1080 # 还在 + + +# ── 路由函数测试 ──────────────────────────────────────────────────────────── + + +class TestGetRenderVideoDownloadUrl: + """测试单个视频下载URL接口。""" + + def test_video_exists(self): + video = MockVideo(id="v-abc", file_url="videos/abc/out.mp4") + mock_repo = MagicMock() + mock_repo.get.return_value = video + mock_storage = MagicMock() + mock_storage.get_download_url.return_value = "https://oss.test/signed?v=abc" + + result = get_render_video_download_url( + video_id="v-abc", + _=True, + generated_video_repository=mock_repo, + storage_service=mock_storage, + ) + + assert isinstance(result, InternalRenderDownloadUrlResponse) + assert result.video_id == "v-abc" + assert result.download_url == "https://oss.test/signed?v=abc" + mock_repo.get.assert_called_once_with("v-abc") + mock_storage.get_download_url.assert_called_once() + + def test_video_not_found_raises_404(self): + from fastapi import HTTPException + + mock_repo = MagicMock() + mock_repo.get.return_value = None + mock_storage = MagicMock() + + with pytest.raises(HTTPException) as exc_info: + get_render_video_download_url( + video_id="nonexistent", + _=True, + generated_video_repository=mock_repo, + storage_service=mock_storage, + ) + assert exc_info.value.status_code == 404 + + def test_download_url_long_expiry(self): + """过期时间应为 24 小时(86400s)。""" + video = MockVideo(id="v1") + mock_repo = MagicMock() + mock_repo.get.return_value = video + mock_storage = MagicMock() + mock_storage.get_download_url.return_value = "https://oss.test/signed" + + get_render_video_download_url( + video_id="v1", + _=True, + generated_video_repository=mock_repo, + storage_service=mock_storage, + ) + + # 验证 expires_seconds=86400 + call_kwargs = mock_storage.get_download_url.call_args + assert call_kwargs.kwargs.get("expires_seconds") == 86400 or call_kwargs[1].get("expires_seconds") == 86400 + + +class TestGetRenderTaskVideos: + """测试任务视频列表接口。""" + + def test_list_multiple_videos(self): + videos = [ + MockVideo(id="v1", status="completed"), + MockVideo(id="v2", status="completed"), + MockVideo(id="v3", status="failed"), + ] + mock_repo = MagicMock() + mock_repo.list_by_generation_task.return_value = videos + mock_storage = MagicMock() + mock_storage.get_download_url.return_value = "https://oss.test/signed" + + result = get_render_task_videos( + task_id="task-1", + status=None, + _=True, + generated_video_repository=mock_repo, + storage_service=mock_storage, + ) + + assert isinstance(result, InternalRenderTaskVideosResponse) + assert result.task_id == "task-1" + assert result.count == 3 + assert len(result.videos) == 3 + + def test_filter_by_status(self): + videos = [ + MockVideo(id="v1", status="completed"), + MockVideo(id="v2", status="completed"), + MockVideo(id="v3", status="failed"), + ] + mock_repo = MagicMock() + mock_repo.list_by_generation_task.return_value = videos + mock_storage = MagicMock() + mock_storage.get_download_url.return_value = "https://oss.test/signed" + + result = get_render_task_videos( + task_id="task-1", + status="completed", + _=True, + generated_video_repository=mock_repo, + storage_service=mock_storage, + ) + + assert result.count == 2 + assert all(v.status == "completed" for v in result.videos) + + def test_empty_task(self): + mock_repo = MagicMock() + mock_repo.list_by_generation_task.return_value = [] + mock_storage = MagicMock() + + result = get_render_task_videos( + task_id="empty-task", + status=None, + _=True, + generated_video_repository=mock_repo, + storage_service=mock_storage, + ) + + assert result.count == 0 + assert result.videos == []