diff --git a/apps/api/app/api/routes/generated_videos.py b/apps/api/app/api/routes/generated_videos.py index b4391dbc9..5eebc24e2 100644 --- a/apps/api/app/api/routes/generated_videos.py +++ b/apps/api/app/api/routes/generated_videos.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException +from app.core.storage import MinIOService, get_minio_service from app.dependencies import get_generated_video_repository from app.schemas.generated_video import ( GeneratedVideoDownloadUrlResponse, @@ -73,9 +74,11 @@ def get_generated_video( def get_generated_video_download_url( video_id: str, generated_video_repository: SQLAlchemyGeneratedVideoRepository = Depends(get_generated_video_repository), + storage_service: MinIOService = Depends(get_minio_service), ) -> GeneratedVideoDownloadUrlResponse: use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository) - download_url = use_case.execute(video_id) - if download_url is None: + file_url = use_case.execute(video_id) + if file_url is None: raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found") + download_url = storage_service.get_download_url(file_url) return GeneratedVideoDownloadUrlResponse(video_id=video_id, download_url=download_url) diff --git a/apps/api/app/core/storage.py b/apps/api/app/core/storage.py index 771657088..b321575e3 100644 --- a/apps/api/app/core/storage.py +++ b/apps/api/app/core/storage.py @@ -1,5 +1,7 @@ """MinIO storage service for file uploads.""" +from datetime import timedelta from typing import BinaryIO +from urllib.parse import urlparse import os from minio import Minio @@ -67,6 +69,25 @@ class MinIOService: def get_url(self, storage_key: str) -> str: return f"{self.public_url}/{self.bucket_name}/{storage_key}" + def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str: + storage_key = self._normalize_storage_key(storage_key_or_url) + try: + return self.client.presigned_get_object( + self.bucket_name, + storage_key, + expires=timedelta(seconds=expires_seconds), + ) + except S3Error: + return self.get_url(storage_key) + + def _normalize_storage_key(self, storage_key_or_url: str) -> str: + prefix = f"/{self.bucket_name}/" + if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"): + parsed = urlparse(storage_key_or_url) + if prefix in parsed.path: + return parsed.path.split(prefix, 1)[1] + return storage_key_or_url.lstrip("/") + def delete_file(self, storage_key: str): try: self.client.remove_object(self.bucket_name, storage_key) diff --git a/docs/PHASE7-PROGRESS.md b/docs/PHASE7-PROGRESS.md index 5df38b7fa..9e03df8f2 100644 --- a/docs/PHASE7-PROGRESS.md +++ b/docs/PHASE7-PROGRESS.md @@ -2,7 +2,7 @@ **Phase**: Phase 7 - 核心视频剪辑业务 **状态**: 🔄 进行中 -**最后更新**: 2026-06-18 20:31 GMT+8 +**最后更新**: 2026-06-18 20:48 GMT+8 --- @@ -51,6 +51,7 @@ - [x] 生成任务创建后自动触发 worker - [x] 生成结果最小闭环测试已落地 - [x] 生成结果下载地址接口已落地 +- [x] 下载地址已升级为 MinIO 预签名优先策略 - [ ] 前端主链路联调完成 ### 4. 本轮已完成的具体验证 @@ -59,7 +60,7 @@ - [x] `tests/integration/test_upload_pipeline.py` 通过 - [x] `tests/integration/test_classification_pipeline.py` 通过 - [x] `tests/integration/test_projects.py` 通过 -- [x] `tests/integration/test_generation_pipeline.py` 通过(含生成结果最小闭环、下载地址查询) +- [x] `tests/integration/test_generation_pipeline.py` 通过(含生成结果最小闭环、下载地址查询、下载源 URL 稳定性) - [x] 素材与生成主线相关目录编译检查通过 --- @@ -99,6 +100,7 @@ - [x] GenerationTask / GeneratedVideo 主线骨架已补齐 - [x] API / Adapter / Worker 的生成结果流第一轮联调 - [x] GeneratedVideo 查询 / 下载地址第一轮打通 +- [x] 下载地址已接入 MinIO 预签名优先策略 - [ ] 测试补齐与回归验证 ### Step 3:保留 Agent 体系设计,等待 runtime 修复后再恢复实跑 @@ -109,7 +111,7 @@ ## 五、当前判断 -**当前 Phase 7 已完成素材前半主链打通,并把生成链推进到“最小可运行闭环 + 结果查询/下载接口可用”,整体仍保持在既定规则内推进。** +**当前 Phase 7 已完成素材前半主链打通,并把生成链推进到“最小可运行闭环 + 结果查询/下载接口可用(预签名优先)”,整体仍保持在既定规则内推进。** 当前执行策略是: - 暂停 Agent 实跑 @@ -127,7 +129,7 @@ - 答:Phase 7 - 核心视频剪辑业务 2. **当前 Phase 主要在做什么?** - - 答:已切入 Phase 7 第一批业务开发,当前已完成素材前半主链收口,并把生成链推进到最小可运行闭环与结果查询/下载可用,正在持续提交与 CI/CD 验证 + - 答:已切入 Phase 7 第一批业务开发,当前已完成素材前半主链收口,并把生成链推进到最小可运行闭环与结果查询/下载可用(预签名优先),正在持续提交与 CI/CD 验证 3. **当前最重要的阻塞点是什么?** - 答:OpenClaw 子 Agent runtime 暂不稳定,因此暂停 Agent 实跑;另外数据库字段命名仍有历史包袱,但已通过映射兼容,不阻断主线开发 @@ -154,4 +156,4 @@ --- -**状态结论**:Phase 7 未跑偏,已暂停 Agent 实跑并切回主会话直开;当前素材前半主链已打通,生成链已进入最小可运行闭环且结果查询/下载可用,现继续通过提交与 CI/CD 验证推进。 +**状态结论**:Phase 7 未跑偏,已暂停 Agent 实跑并切回主会话直开;当前素材前半主链已打通,生成链已进入最小可运行闭环且结果查询/下载可用(预签名优先),现继续通过提交与 CI/CD 验证推进。 diff --git a/packages/application/__init__.py b/packages/application/__init__.py index d92ae1b8c..8ed50eea9 100644 --- a/packages/application/__init__.py +++ b/packages/application/__init__.py @@ -3,7 +3,12 @@ from .asset_libraries import CreateAssetLibraryCommand, CreateAssetLibraryUseCase, ListAssetLibrariesUseCase from .assets import CreateAssetCommand, CreateAssetUseCase, ListAssetsUseCase from .classification_jobs import SubmitClassificationJobCommand, SubmitClassificationJobUseCase -from .generated_videos import GetGeneratedVideoUseCase, ListGeneratedVideosByTaskUseCase, ListGeneratedVideosUseCase +from .generated_videos import ( + GetGeneratedVideoDownloadUrlUseCase, + GetGeneratedVideoUseCase, + ListGeneratedVideosByTaskUseCase, + ListGeneratedVideosUseCase, +) from .generation_tasks import CreateGenerationTaskCommand, CreateGenerationTaskUseCase, GetGenerationTaskUseCase from .ingest_jobs import SubmitIngestJobCommand, SubmitIngestJobUseCase from .projects import CreateProjectCommand, CreateProjectUseCase, ListProjectsUseCase @@ -17,6 +22,7 @@ __all__ = [ "CreateGenerationTaskUseCase", "CreateProjectCommand", "CreateProjectUseCase", + "GetGeneratedVideoDownloadUrlUseCase", "GetGeneratedVideoUseCase", "GetGenerationTaskUseCase", "ListAssetLibrariesUseCase", diff --git a/tests/integration/test_generation_pipeline.py b/tests/integration/test_generation_pipeline.py index 91d9d2bf5..fddcc3a80 100644 --- a/tests/integration/test_generation_pipeline.py +++ b/tests/integration/test_generation_pipeline.py @@ -142,3 +142,23 @@ def test_get_generated_video_download_url(): use_case = GetGeneratedVideoDownloadUrlUseCase(video_repo) assert use_case.execute(video.id) == "https://example.invalid/generated/task-1.mp4" assert use_case.execute("missing") is None + + +def test_generated_video_download_source_url_is_stable(): + video_repo = DummyGeneratedVideoRepository() + video = GeneratedVideo.create( + workspace_id="ws-1", + project_id="proj-1", + generation_task_id="task-2", + name="task-2.mp4", + file_url="http://localhost:9000/xiaoxia-assets/generated/task-2.mp4", + file_size=1024, + duration=8.0, + width=1280, + height=720, + fps=25.0, + ) + video_repo.create(video) + + use_case = GetGeneratedVideoDownloadUrlUseCase(video_repo) + assert use_case.execute(video.id).endswith("/generated/task-2.mp4")