From 72d8e5ae7466ac13a325f3d710b0b68b7471f644 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 20 Aug 2026 16:46:53 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20HEVC=20=E8=BD=AC=E7=A0=81=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E8=A7=A3=E8=80=A6=20+=20=E7=BC=A9=E7=95=A5=E5=9B=BE?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E8=A1=A5=E5=85=A8=20+=20extra=5Fmeta=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. ingest.py: HEVC 转码不再依赖 extract_success,两个功能解耦 2. thumbnail_generator.py: 补充 generate_and_upload_thumbnail 和 extract_and_upload_cover_frames 两个函数,修复 render_adapter.py ImportError 3. generation_task.py: 添加 extra_meta 字段,与 ORM 模型对齐 Co-Authored-By: xiaoxia --- .../video_processing/thumbnail_generator.py | 85 +++++++++++++++++++ apps/worker/worker_app/tasks/ingest.py | 2 +- packages/domain/generation_task.py | 6 ++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index 86f4a32ac..6ce0225ea 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -134,3 +134,88 @@ def _format_seek_time(seconds: float) -> str: m = int((seconds % 3600) // 60) s = seconds % 60 return f"{h:02d}:{m:02d}:{s:05.2f}" + + +def generate_and_upload_thumbnail( + video_path: str, + storage_key: str, + *, + seek_ratio: float = 0.15, +) -> str: + """从视频中提取一帧缩略图并上传到 OSS。 + + Args: + video_path: 视频文件路径 + storage_key: OSS 存储 key + seek_ratio: 抽帧位置比例(默认 0.15) + + Returns: + 上传后的 URL 字符串 + + Raises: + RuntimeError: 抽帧或上传失败 + """ + from video_processing.oss_helpers import upload_to_oss + + tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) + tmp.close() + try: + frame_path = extract_first_frame(video_path, output_path=tmp.name, seek_ratio=seek_ratio) + url = upload_to_oss(frame_path, storage_key) + if not url: + raise RuntimeError(f"上传缩略图到 OSS 失败: {storage_key}") + return url + finally: + Path(tmp.name).unlink(missing_ok=True) + + +def extract_and_upload_cover_frames( + video_path: str, + plan_id: str, + *, + num_frames: int = 3, + title_text: str = "", +) -> list[dict]: + """从视频中抽取多帧作为封面候选,上传到 OSS。 + + Args: + video_path: 视频文件路径 + plan_id: 编辑计划 ID(用于生成 storage key) + num_frames: 抽取帧数(默认 3) + title_text: 标题文字(当前版本未叠加,预留参数) + + Returns: + 封面候选列表,每项包含 {"url": str, "position": float} + """ + from video_processing.oss_helpers import upload_to_oss + from video_processing.ffmpeg_utils import probe_duration + + try: + duration = probe_duration(video_path) + except Exception: + duration = 0.0 + + candidates: list[dict] = [] + # 均匀分布抽帧点:从 10% 到 90% + for i in range(num_frames): + ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1) + tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) + tmp.close() + try: + frame_path = extract_first_frame( + video_path, + output_path=tmp.name, + seek_ratio=ratio, + min_seek_seconds=0.5, + ) + storage_key = f"covers/{plan_id}/frame_{i}.jpg" + url = upload_to_oss(frame_path, storage_key) + if url: + seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0 + candidates.append({"url": url, "position": round(seek_time, 2)}) + except Exception as e: + logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e) + finally: + Path(tmp.name).unlink(missing_ok=True) + + return candidates diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index 8740f5a89..8ccd9f745 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -238,7 +238,7 @@ def ingest_asset(job_id: str) -> dict: # ── HEVC 自动转码为 1080p H.264 ────────────────────────────── # 浏览器 WebCodecs 硬件解码 HEVC 输出黑帧,上传时自动转码 # 失败时降级使用原始文件,不阻塞上传流程 - if media_type == "video" and extract_success and local_file and local_file.exists(): + if media_type == "video" and local_file and local_file.exists(): codec = (metadata.get("codec") or "").lower() if codec in ("hevc", "h265", "hvh1"): logger.info( diff --git a/packages/domain/generation_task.py b/packages/domain/generation_task.py index 631f26bdb..b1f7d0eb0 100755 --- a/packages/domain/generation_task.py +++ b/packages/domain/generation_task.py @@ -121,6 +121,7 @@ class GenerationTask: output_height: int = 720 cover_url: str = "" custom_title: str = "" + extra_meta: dict = field(default_factory=dict) logs: str = "[]" created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @@ -152,6 +153,7 @@ class GenerationTask: output_height: int = 720, cover_url: str = "", custom_title: str = "", + extra_meta: dict | None = None, ) -> "GenerationTask": if not project_id.strip() and not template_id.strip(): raise ValueError("project_id 或 template_id 至少需要提供一个") @@ -182,6 +184,7 @@ class GenerationTask: output_height=output_height, cover_url=cover_url, custom_title=custom_title, + extra_meta=dict(extra_meta) if extra_meta else {}, ) # ── 状态查询 ──────────────────────────────────────────────────────────── @@ -301,6 +304,7 @@ class GenerationTask: *, cover_url: str = "", custom_title: str = "", + extra_meta: dict | None = None, output_width: int = 0, output_height: int = 0, ) -> None: @@ -318,6 +322,8 @@ class GenerationTask: self.output_width = output_width if output_height > 0: self.output_height = output_height + if extra_meta: + self.extra_meta.update(extra_meta) self.updated_at = datetime.now(timezone.utc) # ── 日志辅助 ──────────────────────────────────────────────────────────── -- 2.54.0 From 52e53d60e328bfaed81e1a0f3a1239b91b4e7b84 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 20 Aug 2026 08:52:25 +0000 Subject: [PATCH 2/6] style: auto-format with black + isort + prettier [skip ci-format-check] --- apps/worker/video_processing/thumbnail_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index 6ce0225ea..ad6e2fd6f 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -187,8 +187,8 @@ def extract_and_upload_cover_frames( Returns: 封面候选列表,每项包含 {"url": str, "position": float} """ - from video_processing.oss_helpers import upload_to_oss from video_processing.ffmpeg_utils import probe_duration + from video_processing.oss_helpers import upload_to_oss try: duration = probe_duration(video_path) -- 2.54.0 From 31203613c641fe143051419e0a512c2e66e41f11 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 20 Aug 2026 19:20:46 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20OSS=20=E4=B8=8A=E4=BC=A0/=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E8=B6=85=E6=97=B6=E4=BB=8E=20300s=20=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E5=88=B0=20900s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4K 视频文件大(200MB+),5 分钟超时不够用,改为 15 分钟。 Co-Authored-By: xiaoxia --- apps/worker/video_processing/oss_helpers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/worker/video_processing/oss_helpers.py b/apps/worker/video_processing/oss_helpers.py index 96bf9973f..05a58685a 100755 --- a/apps/worker/video_processing/oss_helpers.py +++ b/apps/worker/video_processing/oss_helpers.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) # OSS 上传配置 OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒),防止 TCP 握手挂死 -OSS_UPLOAD_TOTAL_TIMEOUT = 300 # 单文件上传总超时(秒),防止网络慢时无限卡住 +OSS_UPLOAD_TOTAL_TIMEOUT = 900 # 单文件上传总超时(秒),防止网络慢时无限卡住 OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB 以上走分片 OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数 @@ -127,10 +127,10 @@ def download_asset(asset_storage_key: str, local_path: Path) -> bool: def _download_via_http(url: str, local_path: Path) -> bool: """通过 HTTP 下载文件(支持预签名 URL)。 - 使用流式下载避免大文件内存溢出,超时 300s。 + 使用流式下载避免大文件内存溢出,超时 900s。 """ try: - resp = requests.get(url, stream=True, timeout=300) + resp = requests.get(url, stream=True, timeout=900) resp.raise_for_status() with open(local_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8 * 1024 * 1024): @@ -146,7 +146,7 @@ def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None: """上传文件到 OSS,返回公开 URL。 大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。 - 上传加总超时保护(默认 300s),防止网络异常时无限挂死。 + 上传加总超时保护(默认 900s),防止网络异常时无限挂死。 Args: local_path: 本地文件路径(Path 或 str 均可) -- 2.54.0 From 6a275324f53465685339cb7f2783cfb093d371da Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 20 Aug 2026 20:49:11 +0800 Subject: [PATCH 4/6] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=20create=5Fasset?= =?UTF-8?q?=20=E5=BA=9F=E5=BC=83=E5=90=8E=E7=9A=84=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_asset_dedup: 补充 storage.get_url mock - test_create_asset_optional_project_id: 删除(测试已废弃的端点) - test_assets_api: 集成测试改为直接插入 repository,绕过 410 端点 TestCreateAsset 改为验证 410 响应 全量测试:14095 passed,4 failed + 14 errors 均为 develop 预存问题 Co-Authored-By: xiaoxia --- tests/integration/test_assets_api.py | 241 +++++------------- tests/unit/test_asset_dedup.py | 1 + .../test_create_asset_optional_project_id.py | 190 -------------- 3 files changed, 71 insertions(+), 361 deletions(-) delete mode 100644 tests/unit/test_create_asset_optional_project_id.py diff --git a/tests/integration/test_assets_api.py b/tests/integration/test_assets_api.py index a5b2d399c..1d7d0b97f 100755 --- a/tests/integration/test_assets_api.py +++ b/tests/integration/test_assets_api.py @@ -260,6 +260,30 @@ def _make_user(**overrides) -> User: return User(**defaults) + +def _direct_insert_asset(client, name='test-video.mp4', storage_key=None, mime_type='video/mp4', status=None): + """Helper: insert asset directly into repo (bypass deprecated create_asset API).""" + import uuid as _uuid + app = client.app + asset_repo = app.dependency_overrides[get_asset_repository]() + kw = {} + if status is not None: + kw['status'] = status + else: + kw['status'] = AssetStatus.READY + asset = Asset( + id=_uuid.uuid4().hex, + project_id='proj-1', + library_id='lib-1', + name=name, + storage_key=storage_key or f'uploads/{name}', + mime_type=mime_type, + **kw, + ) + asset_repo.create(asset) + return asset.id + + def _make_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project: return Project(id=id, name="Test Project", owner_user_id=owner_user_id) @@ -344,8 +368,8 @@ def client(mock_storage): class TestCreateAsset: """创建素材端点测试。""" - def test_create_asset_success(self, client): - """正常创建素材成功。""" + def test_create_asset_returns_410_gone(self, client): + """create_asset 已废弃,返回 410 Gone 提示使用 ingest-jobs。""" resp = client.post( "/api/v1/assets", json={ @@ -354,59 +378,27 @@ class TestCreateAsset: "name": "new-video.mp4", "storage_key": "uploads/new-video.mp4", "mime_type": "video/mp4", - "file_size": 2048, - "duration": 15.0, }, ) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "new-video.mp4" - assert data["project_id"] == "proj-1" - assert data["library_id"] == "lib-1" - assert data["mime_type"] == "video/mp4" - assert "id" in data - assert data["status"] == "uploading" + assert resp.status_code == 410 - def test_create_asset_project_not_found(self, client): - """项目不存在返回 404。""" + def test_create_asset_any_type_returns_410(self, client): + """所有类型都返回 410 Gone(图片/音频也废弃)。""" resp = client.post( "/api/v1/assets", json={ - "project_id": "nonexistent", + "project_id": "proj-1", "library_id": "lib-1", - "name": "test.mp4", - "storage_key": "uploads/test.mp4", - "mime_type": "video/mp4", + "name": "photo.jpg", + "storage_key": "uploads/photo.jpg", + "mime_type": "image/jpeg", }, ) - assert resp.status_code == 404 - assert "Project" in resp.json()["detail"] + assert resp.status_code == 410 + + - def test_create_asset_library_not_found(self, client): - """素材库不存在返回 404。""" - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "nonexistent", - "name": "test.mp4", - "storage_key": "uploads/test.mp4", - "mime_type": "video/mp4", - }, - ) - assert resp.status_code == 404 - assert "AssetLibrary" in resp.json()["detail"] - def test_create_asset_missing_required_fields(self, client): - """缺少必填字段返回 422。""" - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "name": "test.mp4", - }, - ) - assert resp.status_code == 422 # --------------------------------------------------------------------------- @@ -418,20 +410,25 @@ class TestListAssets: """获取素材列表端点测试。""" def _create_test_assets(self, client, count: int = 3): - """辅助方法:创建测试素材(status=ready)。""" + """辅助方法:直接插入测试素材到 repository(绕过已废弃的 create_asset API)。""" + # 通过依赖覆盖获取 asset_repo + app = client.app + asset_repo = app.dependency_overrides.get(get_asset_repository, lambda: None)() + if asset_repo is None: + return for i in range(count): - client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": f"video-{i}.mp4", - "storage_key": f"uploads/video-{i}.mp4", - "mime_type": "video/mp4", - "file_size": 1024 * (i + 1), - "status": "ready", - }, + import uuid + asset = Asset( + id=uuid.uuid4().hex, + project_id="proj-1", + library_id="lib-1", + name=f"video-{i}.mp4", + storage_key=f"uploads/video-{i}.mp4", + mime_type="video/mp4", + file_size=1024 * (i + 1), + status=AssetStatus.READY, ) + asset_repo.create(asset) def test_empty_list(self, client): """无素材时返回空列表。""" @@ -517,17 +514,7 @@ class TestListAssets: def test_list_status_filter_uploading_visible(self, client): """uploading状态的素材默认能看到(上传后立即显示处理中)。""" - client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "uploading-test.mp4", - "storage_key": "uploads/uploading-test.mp4", - "mime_type": "video/mp4", - "status": "uploading", - }, - ) + _direct_insert_asset(client, name="uploading-test.mp4", status=AssetStatus.UPLOADING) resp = client.get("/api/v1/assets?library_id=lib-1") assert resp.status_code == 200 @@ -537,28 +524,8 @@ class TestListAssets: def test_list_with_keyword_filter(self, client): """按名称关键词过滤。""" - client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "hello-world.mp4", - "storage_key": "uploads/hello.mp4", - "mime_type": "video/mp4", - "status": "ready", - }, - ) - client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "goodbye.mp4", - "storage_key": "uploads/goodbye.mp4", - "mime_type": "video/mp4", - "status": "ready", - }, - ) + _direct_insert_asset(client, name="hello-world.mp4") + _direct_insert_asset(client, name="goodbye.mp4", mime_type="video/mp4", status=AssetStatus.READY) resp = client.get("/api/v1/assets?library_id=lib-1&keyword=hello") assert resp.status_code == 200 @@ -576,22 +543,8 @@ class TestGetAsset: """获取单个素材详情端点测试。""" def _create_asset(self, client) -> str: - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "detail-test.mp4", - "storage_key": "uploads/detail-test.mp4", - "mime_type": "video/mp4", - "file_size": 5000, - "duration": 25.0, - "width": 1280, - "height": 720, - "fps": 30.0, - }, - ) - return resp.json()["id"] + """Direct insert into repo (create_asset API is deprecated/410).""" + return _direct_insert_asset(client) def test_get_asset_success(self, client): """获取存在的素材详情成功。""" @@ -601,11 +554,7 @@ class TestGetAsset: assert resp.status_code == 200 data = resp.json() assert data["id"] == asset_id - assert data["name"] == "detail-test.mp4" - assert data["file_size"] == 5000 - assert data["duration"] == 25.0 - assert data["width"] == 1280 - assert data["height"] == 720 + assert data["name"] == "test-video.mp4" assert "file_url" in data assert "status" in data @@ -625,17 +574,8 @@ class TestUpdateAsset: """更新素材端点测试。""" def _create_asset(self, client) -> str: - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "old-name.mp4", - "storage_key": "uploads/old-name.mp4", - "mime_type": "video/mp4", - }, - ) - return resp.json()["id"] + """Direct insert into repo (create_asset API is deprecated/410).""" + return _direct_insert_asset(client) def test_update_asset_name(self, client): """更新素材名称成功。""" @@ -675,7 +615,7 @@ class TestUpdateAsset: resp = client.put(f"/api/v1/assets/{asset_id}", json={}) assert resp.status_code == 200 - assert resp.json()["name"] == "old-name.mp4" + assert resp.json()["name"] == "test-video.mp4" # --------------------------------------------------------------------------- @@ -687,17 +627,8 @@ class TestDeleteAsset: """删除素材端点测试。""" def _create_asset(self, client) -> str: - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "delete-test.mp4", - "storage_key": "uploads/delete-test.mp4", - "mime_type": "video/mp4", - }, - ) - return resp.json()["id"] + """Direct insert into repo (create_asset API is deprecated/410).""" + return _direct_insert_asset(client) def test_delete_asset_success(self, client): """删除存在的素材成功,返回 204。""" @@ -737,17 +668,8 @@ class TestBatchDeleteAssets: def _create_assets(self, client, count: int = 3) -> list[str]: ids = [] for i in range(count): - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": f"batch-{i}.mp4", - "storage_key": f"uploads/batch-{i}.mp4", - "mime_type": "video/mp4", - }, - ) - ids.append(resp.json()["id"]) + aid = _direct_insert_asset(client, name=f"batch-{i}.mp4") + ids.append(aid) return ids def test_batch_delete_success(self, client): @@ -802,17 +724,8 @@ class TestAssetTags: """素材标签相关端点测试。""" def _create_asset(self, client) -> str: - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "tag-test.mp4", - "storage_key": "uploads/tag-test.mp4", - "mime_type": "video/mp4", - }, - ) - return resp.json()["id"] + """Direct insert into repo (create_asset API is deprecated/410).""" + return _direct_insert_asset(client) def test_add_tags_to_asset(self, client): """给素材打标签。需要先在 tag_repo 中创建标签。""" @@ -847,22 +760,8 @@ class TestAssetsCRUDFlow: def test_full_crud_flow(self, client): """测试完整的创建 → 列表 → 详情 → 更新 → 删除流程。""" - # 1. 创建 - create_resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "crud-flow.mp4", - "storage_key": "uploads/crud-flow.mp4", - "mime_type": "video/mp4", - "file_size": 8192, - "metadata": {"source": "test"}, - "status": "ready", - }, - ) - assert create_resp.status_code == 200 - asset_id = create_resp.json()["id"] + # 1. 创建 (direct insert since create_asset is 410) + asset_id = _direct_insert_asset(client, name="crud-flow.mp4") # 2. 列表中应包含 list_resp = client.get("/api/v1/assets?library_id=lib-1") diff --git a/tests/unit/test_asset_dedup.py b/tests/unit/test_asset_dedup.py index a1cbc9b72..aa10d46c9 100644 --- a/tests/unit/test_asset_dedup.py +++ b/tests/unit/test_asset_dedup.py @@ -147,6 +147,7 @@ def _build_app( storage._normalize_storage_key = lambda key: key storage.file_exists = lambda key: True storage.upload_file = MagicMock(return_value="https://oss.example.com/file.mp4") + storage.get_url = MagicMock(return_value="https://oss.example.com/file.mp4") mock_user = MagicMock(spec=AuthenticatedUser) mock_user.id = "user-1" diff --git a/tests/unit/test_create_asset_optional_project_id.py b/tests/unit/test_create_asset_optional_project_id.py deleted file mode 100644 index 22ec7614b..000000000 --- a/tests/unit/test_create_asset_optional_project_id.py +++ /dev/null @@ -1,190 +0,0 @@ -"""测试 create_asset 端点:project_id 可选,从 library 自动推导。""" - -from unittest.mock import MagicMock, patch - -import pytest -from app.api.routes.assets import create_asset -from app.auth import AuthenticatedUser -from app.schemas.asset import CreateAssetRequest -from fastapi import HTTPException - -from packages.domain import AssetStatus, ClassificationStatus - - -@pytest.fixture -def mock_user(): - user = MagicMock(spec=AuthenticatedUser) - user.user.id = "user-123" - return user - - -@pytest.fixture -def mock_library(): - lib = MagicMock() - lib.id = "lib-abc" - lib.project_id = "proj-from-library" - return lib - - -@pytest.fixture -def mock_project(): - proj = MagicMock() - proj.id = "proj-from-library" - proj.can_access.return_value = True - return proj - - -def _make_request(**overrides): - defaults = dict( - library_id="lib-abc", - name="test-audio.mp3", - storage_key="uploads/test.mp3", - mime_type="audio/mpeg", - file_size=1024, - status="uploading", - ) - defaults.update(overrides) - return CreateAssetRequest(**defaults) - - -def test_project_id_derived_from_library_when_not_provided(mock_user, mock_library, mock_project): - """前端不传 project_id 时,从 library.project_id 自动推导。""" - request = _make_request() # project_id 默认 None - - asset_repo = MagicMock() - lib_repo = MagicMock() - lib_repo.get.return_value = mock_library - proj_repo = MagicMock() - proj_repo.find_by_id.return_value = mock_project - - expected_asset = MagicMock() - expected_asset.id = "asset-1" - expected_asset.project_id = "proj-from-library" - expected_asset.library_id = "lib-abc" - expected_asset.name = "test-audio.mp3" - expected_asset.storage_key = "" - expected_asset.mime_type = "audio/mpeg" - expected_asset.metadata = {} - expected_asset.file_size = 1024 - expected_asset.thumbnail_url = None - expected_asset.duration = None - expected_asset.width = None - expected_asset.height = None - expected_asset.fps = None - expected_asset.codec = None - expected_asset.status = AssetStatus.UPLOADING - expected_asset.classification_status = ClassificationStatus.PENDING - expected_asset.quality_score = None - expected_asset.created_at = None - expected_asset.uploaded_by_user_id = "user-123" - expected_asset.tag_ids = [] - with patch("app.api.routes.assets.CreateAssetUseCase") as mock_uc: - mock_uc.return_value.execute.return_value = expected_asset - result = create_asset( - request=request, - authenticated_user=mock_user, - asset_repository=asset_repo, - asset_library_repository=lib_repo, - project_repository=proj_repo, - ) - - # 验证 project_id 被正确推导 - proj_repo.find_by_id.assert_called_once_with("proj-from-library") - # 验证 use case 使用的是推导出的 project_id - cmd = mock_uc.return_value.execute.call_args[0][0] - assert cmd.project_id == "proj-from-library" - - -def test_explicit_project_id_used_when_provided(mock_user, mock_library, mock_project): - """前端显式传 project_id 时,优先使用请求值。""" - mock_project.id = "proj-explicit" - mock_project.can_access.return_value = True - mock_library.project_id = "proj-explicit" # 匹配 - - request = _make_request(project_id="proj-explicit") - - asset_repo = MagicMock() - lib_repo = MagicMock() - lib_repo.get.return_value = mock_library - proj_repo = MagicMock() - proj_repo.find_by_id.return_value = mock_project - - mock_asset = MagicMock() - mock_asset.id = "asset-1" - mock_asset.storage_key = "" - mock_asset.mime_type = "audio/mpeg" - mock_asset.project_id = "proj-explicit" - mock_asset.library_id = "lib-abc" - mock_asset.name = "test" - mock_asset.metadata = {} - mock_asset.file_size = 0 - mock_asset.thumbnail_url = None - mock_asset.duration = None - mock_asset.width = None - mock_asset.height = None - mock_asset.fps = None - mock_asset.codec = None - mock_asset.status = AssetStatus.UPLOADING - mock_asset.classification_status = ClassificationStatus.PENDING - mock_asset.quality_score = None - mock_asset.created_at = None - mock_asset.uploaded_by_user_id = "user-123" - mock_asset.tag_ids = [] - - with patch("app.api.routes.assets.CreateAssetUseCase") as mock_uc: - mock_uc.return_value.execute.return_value = mock_asset - create_asset( - request=request, - authenticated_user=mock_user, - asset_repository=asset_repo, - asset_library_repository=lib_repo, - project_repository=proj_repo, - ) - - proj_repo.find_by_id.assert_called_once_with("proj-explicit") - cmd = mock_uc.return_value.execute.call_args[0][0] - assert cmd.project_id == "proj-explicit" - - -def test_library_not_found_returns_404(mock_user): - """素材库不存在时返回 404。""" - request = _make_request() - - lib_repo = MagicMock() - lib_repo.get.return_value = None - proj_repo = MagicMock() - asset_repo = MagicMock() - - with pytest.raises(HTTPException) as exc_info: - create_asset( - request=request, - authenticated_user=mock_user, - asset_repository=asset_repo, - asset_library_repository=lib_repo, - project_repository=proj_repo, - ) - assert exc_info.value.status_code == 404 - - -def test_library_project_mismatch_returns_400(mock_user, mock_library, mock_project): - """当 library.project_id 与请求的 project_id 不一致时返回 400。""" - mock_library.project_id = "proj-A" - mock_project.id = "proj-B" - - request = _make_request(project_id="proj-B") - - lib_repo = MagicMock() - lib_repo.get.return_value = mock_library - proj_repo = MagicMock() - proj_repo.find_by_id.return_value = mock_project - asset_repo = MagicMock() - - with pytest.raises(HTTPException) as exc_info: - create_asset( - request=request, - authenticated_user=mock_user, - asset_repository=asset_repo, - asset_library_repository=lib_repo, - project_repository=proj_repo, - ) - assert exc_info.value.status_code == 400 -- 2.54.0 From 6c8827c5073eac44f87381c2937b6e227f664f76 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 20 Aug 2026 12:54:43 +0000 Subject: [PATCH 5/6] style: auto-format with black + isort + prettier [skip ci-format-check] --- tests/integration/test_assets_api.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/integration/test_assets_api.py b/tests/integration/test_assets_api.py index 1d7d0b97f..a68465541 100755 --- a/tests/integration/test_assets_api.py +++ b/tests/integration/test_assets_api.py @@ -260,23 +260,23 @@ def _make_user(**overrides) -> User: return User(**defaults) - -def _direct_insert_asset(client, name='test-video.mp4', storage_key=None, mime_type='video/mp4', status=None): +def _direct_insert_asset(client, name="test-video.mp4", storage_key=None, mime_type="video/mp4", status=None): """Helper: insert asset directly into repo (bypass deprecated create_asset API).""" import uuid as _uuid + app = client.app asset_repo = app.dependency_overrides[get_asset_repository]() kw = {} if status is not None: - kw['status'] = status + kw["status"] = status else: - kw['status'] = AssetStatus.READY + kw["status"] = AssetStatus.READY asset = Asset( id=_uuid.uuid4().hex, - project_id='proj-1', - library_id='lib-1', + project_id="proj-1", + library_id="lib-1", name=name, - storage_key=storage_key or f'uploads/{name}', + storage_key=storage_key or f"uploads/{name}", mime_type=mime_type, **kw, ) @@ -397,10 +397,6 @@ class TestCreateAsset: assert resp.status_code == 410 - - - - # --------------------------------------------------------------------------- # 4. GET /assets — 获取素材列表 # --------------------------------------------------------------------------- @@ -418,6 +414,7 @@ class TestListAssets: return for i in range(count): import uuid + asset = Asset( id=uuid.uuid4().hex, project_id="proj-1", -- 2.54.0 From 1744ad7c4529cee84ce911980d73177b62abbf82 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 20 Aug 2026 21:25:16 +0800 Subject: [PATCH 6/6] test: add extra_meta unit tests for diff coverage - test_create_with_extra_meta - test_create_without_extra_meta_defaults_empty - test_mark_confirmed_with_extra_meta - test_mark_confirmed_without_extra_meta_preserves_existing Covers generation_task.py lines 325-326 to meet 40% diff coverage gate. --- tests/unit/test_generation_task_domain.py | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/unit/test_generation_task_domain.py b/tests/unit/test_generation_task_domain.py index 6c2e86ac2..0eb2f764d 100755 --- a/tests/unit/test_generation_task_domain.py +++ b/tests/unit/test_generation_task_domain.py @@ -545,3 +545,44 @@ class TestGenerationTaskTimestamps: new_task.mark_pending_from_failed() assert new_task.started_at is None assert new_task.completed_at is None + + +class TestExtraMeta: + """extra_meta 字段测试""" + + def test_create_with_extra_meta(self): + """create() 传入 extra_meta 应正确存储""" + task = GenerationTask.create( + project_id="proj-1", + asset_library_id="lib-1", + extra_meta={"source": "preview", "resolution": "1080p"}, + ) + assert task.extra_meta == {"source": "preview", "resolution": "1080p"} + + def test_create_without_extra_meta_defaults_empty(self): + """create() 不传 extra_meta 应为空 dict""" + task = GenerationTask.create(project_id="proj-1", asset_library_id="lib-1") + assert task.extra_meta == {} + + def test_mark_confirmed_with_extra_meta(self): + """mark_confirmed() 传入 extra_meta 应合并到已有字段""" + task = GenerationTask.create( + project_id="proj-1", + asset_library_id="lib-1", + extra_meta={"source": "preview"}, + ) + task.mark_confirmed(extra_meta={"confirmed_by": "user", "resolution": "1080p"}) + assert task.extra_meta["source"] == "preview" + assert task.extra_meta["confirmed_by"] == "user" + assert task.extra_meta["resolution"] == "1080p" + + def test_mark_confirmed_without_extra_meta_preserves_existing(self): + """mark_confirmed() 不传 extra_meta 不应影响已有值""" + task = GenerationTask.create( + project_id="proj-1", + asset_library_id="lib-1", + extra_meta={"key": "value"}, + ) + task.mark_confirmed(cover_url="https://example.com/cover.jpg") + assert task.extra_meta == {"key": "value"} + assert task.cover_url == "https://example.com/cover.jpg" -- 2.54.0