From f7a945d417f693c5daf06e86b0756ea21e55d1b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Tue, 7 Jul 2026 19:05:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E8=A7=86=E9=A2=91=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?MIME=E7=99=BD=E5=90=8D=E5=8D=95=E8=A1=A5=E5=85=A8=20+=20?= =?UTF-8?q?=E6=88=90=E7=89=87=E5=BA=93N+1=E6=9F=A5=E8=AF=A2=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chunked_upload.py ALLOWED_MIME_TYPES 从12种扩展到22种,与 upload.py 保持一致 新增: video/mpeg, video/x-matroska, video/3gpp, audio/flac, audio/aac, audio/x-m4a, audio/webm, image/bmp, image/tiff, image/svg+xml - generated_video_repository list_by_project/list_by_generation_task 改用 _to_domain() 替代 self.get(),消除 N+1 查询 - 新增 24 个单元测试覆盖以上修复及成片库 API 可用性确认 Closes #172 #173 Co-Authored-By: Claude Fable 5 --- apps/api/app/api/routes/chunked_upload.py | 15 +- .../generated_video_repository.py | 4 +- ...test_video_upload_fix_and_generated_api.py | 310 ++++++++++++++++++ 3 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_video_upload_fix_and_generated_api.py diff --git a/apps/api/app/api/routes/chunked_upload.py b/apps/api/app/api/routes/chunked_upload.py index cf8aaae4c..b9537c6e6 100644 --- a/apps/api/app/api/routes/chunked_upload.py +++ b/apps/api/app/api/routes/chunked_upload.py @@ -43,20 +43,33 @@ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB CHUNK_EXPIRY_HOURS = 24 -# Allowed file types (consistent with existing upload.py) +# Allowed file types — must stay in sync with upload.py ALLOWED_MIME_TYPES ALLOWED_MIME_TYPES = { + # Images "image/jpeg", "image/png", "image/gif", "image/webp", + "image/bmp", + "image/tiff", + "image/svg+xml", + # Video "video/mp4", "video/quicktime", + "video/mpeg", "video/x-msvideo", "video/webm", + "video/x-matroska", + "video/3gpp", + # Audio "audio/mpeg", "audio/wav", "audio/ogg", "audio/mp3", + "audio/flac", + "audio/aac", + "audio/x-m4a", + "audio/webm", } # Chunk storage root directory diff --git a/packages/adapters/sqlalchemy_impl/generated_video_repository.py b/packages/adapters/sqlalchemy_impl/generated_video_repository.py index 3730d04bf..31620e6f6 100644 --- a/packages/adapters/sqlalchemy_impl/generated_video_repository.py +++ b/packages/adapters/sqlalchemy_impl/generated_video_repository.py @@ -78,7 +78,7 @@ class SQLAlchemyGeneratedVideoRepository: def list_by_project(self, project_id: str) -> list[GeneratedVideo]: models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all() - return [self.get(model.id) for model in models if self.get(model.id) is not None] + return [self._to_domain(model) for model in models] def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]: models = ( @@ -86,7 +86,7 @@ class SQLAlchemyGeneratedVideoRepository: .filter(GeneratedVideoModel.generation_task_id == generation_task_id) .all() ) - return [self.get(model.id) for model in models if self.get(model.id) is not None] + return [self._to_domain(model) for model in models] def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]: """通过 batch_id 查找同批次生成的所有视频(跨 generation_task 关联查询)。""" diff --git a/tests/unit/test_video_upload_fix_and_generated_api.py b/tests/unit/test_video_upload_fix_and_generated_api.py new file mode 100644 index 000000000..972aef13c --- /dev/null +++ b/tests/unit/test_video_upload_fix_and_generated_api.py @@ -0,0 +1,310 @@ +""" +测试视频上传失败排查修复: +1. chunked_upload ALLOWED_MIME_TYPES 与 upload.py 保持一致 +2. generated_video_repository list 方法无 N+1 查询 +3. 成片库 API 端点可正常返回数据 +""" + +import json +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +# ─── 1. chunked_upload ALLOWED_MIME_TYPES 一致性 ─── + + +class TestChunkedUploadMIMEConsistency: + """chunked_upload.py 的 ALLOWED_MIME_TYPES 必须包含 upload.py 的所有类型。""" + + def _get_upload_mime_types(self) -> set: + from apps.api.app.api.routes.upload import ALLOWED_MIME_TYPES + + return set(ALLOWED_MIME_TYPES) + + def _get_chunked_mime_types(self) -> set: + from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES + + return set(ALLOWED_MIME_TYPES) + + def test_chunked_upload_has_all_upload_mime_types(self): + """chunked_upload 白名单必须覆盖 upload.py 的全部类型。""" + upload_types = self._get_upload_mime_types() + chunked_types = self._get_chunked_mime_types() + missing = upload_types - chunked_types + assert not missing, f"chunked_upload 缺少以下 MIME 类型: {missing}" + + def test_chunked_upload_supports_video_mpeg(self): + from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES + + assert "video/mpeg" in ALLOWED_MIME_TYPES + + def test_chunked_upload_supports_video_matroska(self): + from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES + + assert "video/x-matroska" in ALLOWED_MIME_TYPES + + def test_chunked_upload_supports_video_3gpp(self): + from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES + + assert "video/3gpp" in ALLOWED_MIME_TYPES + + def test_chunked_upload_supports_audio_flac(self): + from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES + + assert "audio/flac" in ALLOWED_MIME_TYPES + + def test_chunked_upload_supports_audio_aac(self): + from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES + + assert "audio/aac" in ALLOWED_MIME_TYPES + + def test_chunked_upload_supports_audio_m4a(self): + from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES + + assert "audio/x-m4a" in ALLOWED_MIME_TYPES + + def test_chunked_upload_supports_audio_webm(self): + from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES + + assert "audio/webm" in ALLOWED_MIME_TYPES + + def test_chunked_upload_supports_extra_image_types(self): + from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES + + assert "image/bmp" in ALLOWED_MIME_TYPES + assert "image/tiff" in ALLOWED_MIME_TYPES + assert "image/svg+xml" in ALLOWED_MIME_TYPES + + def test_both_have_same_core_video_types(self): + """两条路径的核心视频类型必须一致。""" + upload_types = self._get_upload_mime_types() + chunked_types = self._get_chunked_mime_types() + core_video = {"video/mp4", "video/quicktime", "video/webm"} + for vt in core_video: + assert vt in upload_types, f"upload.py 缺少 {vt}" + assert vt in chunked_types, f"chunked_upload.py 缺少 {vt}" + + +# ─── 2. GeneratedVideo Repository N+1 修复验证 ─── + + +class TestGeneratedVideoRepositoryNoNPlus1: + """list_by_project 和 list_by_generation_task 应使用 _to_domain 而非 self.get。""" + + def _make_model(self, video_id: str, project_id: str = "proj-1", task_id: str = "task-1"): + model = MagicMock() + model.id = video_id + model.project_id = project_id + model.generation_task_id = task_id + model.name = f"video-{video_id}.mp4" + model.file_url = f"https://oss.example.com/{video_id}.mp4" + model.file_size = 1024 + model.duration = 5.0 + model.thumbnail_url = None + model.width = 1280 + model.height = 720 + model.fps = 25.0 + model.status = "completed" + model.review_status = "pending_review" + model.generation_params = json.dumps({"mode": "one_take"}) + model.video_fingerprint = None + model.is_duplicate = False + model.duplicate_of = None + model.generated_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + model.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + return model + + def test_list_by_project_uses_to_domain(self): + """list_by_project 不应调用 self.get(N+1),应使用 _to_domain。""" + from packages.adapters.sqlalchemy_impl.generated_video_repository import ( + SQLAlchemyGeneratedVideoRepository, + ) + + session = MagicMock() + models = [self._make_model(f"v{i}") for i in range(5)] + + query_mock = MagicMock() + query_mock.filter.return_value.all.return_value = models + session.query.return_value = query_mock + + repo = SQLAlchemyGeneratedVideoRepository(session) + with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get: + result = repo.list_by_project("proj-1") + mock_get.assert_not_called() + + assert len(result) == 5 + assert all(v.id.startswith("v") for v in result) + + def test_list_by_generation_task_uses_to_domain(self): + """list_by_generation_task 不应调用 self.get(N+1),应使用 _to_domain。""" + from packages.adapters.sqlalchemy_impl.generated_video_repository import ( + SQLAlchemyGeneratedVideoRepository, + ) + + session = MagicMock() + models = [self._make_model(f"v{i}", task_id="task-42") for i in range(3)] + + query_mock = MagicMock() + query_mock.filter.return_value.all.return_value = models + session.query.return_value = query_mock + + repo = SQLAlchemyGeneratedVideoRepository(session) + with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get: + result = repo.list_by_generation_task("task-42") + mock_get.assert_not_called() + + assert len(result) == 3 + + def test_list_by_project_returns_empty_when_no_videos(self): + from packages.adapters.sqlalchemy_impl.generated_video_repository import ( + SQLAlchemyGeneratedVideoRepository, + ) + + session = MagicMock() + query_mock = MagicMock() + query_mock.filter.return_value.all.return_value = [] + session.query.return_value = query_mock + + repo = SQLAlchemyGeneratedVideoRepository(session) + result = repo.list_by_project("empty-project") + assert result == [] + + +# ─── 3. 成片库 API 端点可用性确认 ─── + + +class TestGeneratedVideosAPIAvailability: + """确认成片库 API 路由注册正确,端点可正常返回数据。""" + + def test_generated_videos_routes_registered(self): + """成片库路由已注册到 router。""" + from apps.api.app.api.router import api_router + + # 检查 router 包含 generated-videos 路径 + routes = [r for r in api_router.routes if hasattr(r, "path")] + gv_routes = [r for r in routes if "generated-videos" in r.path] + assert len(gv_routes) > 0, "generated-videos 路由未注册" + + def test_generated_videos_list_endpoint_exists(self): + """GET /generated-videos 端点存在。""" + from apps.api.app.api.routes.generated_videos import router + + paths = [r.path for r in router.routes if hasattr(r, "path")] + assert "" in paths, "GET /generated-videos 列表端点不存在" + + def test_generated_videos_detail_endpoint_exists(self): + """GET /generated-videos/{video_id} 端点存在。""" + from apps.api.app.api.routes.generated_videos import router + + paths = [r.path for r in router.routes if hasattr(r, "path")] + assert "/{video_id}" in paths, "GET /generated-videos/{{video_id}} 详情端点不存在" + + def test_generated_videos_review_endpoint_exists(self): + """PATCH /generated-videos/{video_id}/review 端点存在。""" + from apps.api.app.api.routes.generated_videos import router + + paths = [r.path for r in router.routes if hasattr(r, "path")] + assert "/{video_id}/review" in paths, "PATCH review 端点不存在" + + def test_generated_videos_download_url_endpoint_exists(self): + """GET /generated-videos/{video_id}/download-url 端点存在。""" + from apps.api.app.api.routes.generated_videos import router + + paths = [r.path for r in router.routes if hasattr(r, "path")] + assert "/{video_id}/download-url" in paths, "download-url 端点不存在" + + def test_generated_video_response_schema_complete(self): + """GeneratedVideoResponse 包含所有必要字段。""" + from apps.api.app.schemas.generated_video import GeneratedVideoResponse + + fields = GeneratedVideoResponse.model_fields + required_fields = [ + "id", + "project_id", + "name", + "file_url", + "status", + "review_status", + "download_url", + ] + for field in required_fields: + assert field in fields, f"GeneratedVideoResponse 缺少字段: {field}" + + def test_list_generated_videos_response_schema(self): + """ListGeneratedVideosResponse 包含 items 列表。""" + from apps.api.app.schemas.generated_video import ListGeneratedVideosResponse + + fields = ListGeneratedVideosResponse.model_fields + assert "items" in fields, "ListGeneratedVideosResponse 缺少 items 字段" + + def test_generation_task_results_endpoint_exists(self): + """GET /generation/tasks/{task_id}/results 端点存在。""" + from apps.api.app.api.routes.generation_tasks import router + + paths = [r.path for r in router.routes if hasattr(r, "path")] + assert "/tasks/{task_id}/results" in paths, "generation results 端点不存在" + + +# ─── 4. GeneratedVideo Use Cases 可用性 ─── + + +class TestGeneratedVideoUseCases: + """确认成片库 Use Case 层可正常工作。""" + + def _make_video(self, video_id: str, project_id: str = "proj-1"): + from packages.domain import GeneratedVideo + + return GeneratedVideo( + id=video_id, + project_id=project_id, + generation_task_id="task-1", + name=f"video-{video_id}.mp4", + file_url=f"https://oss.example.com/{video_id}.mp4", + file_size=1024, + duration=5.0, + width=1280, + height=720, + fps=25.0, + status="completed", + ) + + def test_list_generated_videos_use_case(self): + """ListGeneratedVideosUseCase 可正常列出视频。""" + from packages.application import ListGeneratedVideosUseCase + + repo = MagicMock() + videos = [self._make_video(f"v{i}") for i in range(3)] + repo.list_by_project.return_value = videos + + use_case = ListGeneratedVideosUseCase(repo) + result = use_case.execute("proj-1") + + repo.list_by_project.assert_called_once_with("proj-1") + assert len(result) == 3 + + def test_get_generated_video_use_case(self): + """GetGeneratedVideoUseCase 可正常获取单个视频。""" + from packages.application import GetGeneratedVideoUseCase + + repo = MagicMock() + video = self._make_video("v1") + repo.get.return_value = video + + use_case = GetGeneratedVideoUseCase(repo) + result = use_case.execute("v1") + + repo.get.assert_called_once_with("v1") + assert result.id == "v1" + + def test_list_by_task_use_case(self): + """ListGeneratedVideosByTaskUseCase 可按任务列出视频。""" + from packages.application import ListGeneratedVideosByTaskUseCase + + repo = MagicMock() + videos = [self._make_video(f"v{i}") for i in range(2)] + repo.list_by_generation_task.return_value = videos + + use_case = ListGeneratedVideosByTaskUseCase(repo) + result = use_case.execute("task-1") + + repo.list_by_generation_task.assert_called_once_with("task-1") + assert len(result) == 2 From 9e37c7b73d654e2e96e79db4ec080256b448f4f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Tue, 7 Jul 2026 19:44:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E9=85=8D=E9=9F=B3=E5=BA=93AI?= =?UTF-8?q?=E9=85=8D=E9=9F=B3=E6=8C=89=E9=92=AE=20+=20=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E4=BF=AE=E5=A4=8D=20+=20=E5=A4=9A=E9=A1=B9UI?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Task #197: 配音库新增AI配音按钮 + TTS合成面板 - Task #198: 上传音频按钮事件修复 - Task #199: 一键生成增加标题库选择 - Task #200: 模板库报错修复 - Task #201: 素材库视频上传失败修复 - Task #202: 素材库去掉配音类型 - Task #203: 模板卡片名称位置调整 - Task #204: 一键生成状态反馈完善 - Task #205: 成片库页面接入真实数据 Co-Authored-By: Claude Fable 5 --- apps/web/src/pages/assets/AssetLibrary.tsx | 25 +- apps/web/src/pages/generate/GeneratePage.tsx | 144 +++++++++-- .../web/src/pages/products/ProductLibrary.tsx | 28 ++- .../src/pages/templates/TemplateLibrary.tsx | 10 +- apps/web/src/pages/templates/templates.css | 18 ++ .../voice-materials/VoiceMaterialLibrary.tsx | 227 ++++++++++++++++++ 6 files changed, 401 insertions(+), 51 deletions(-) diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index 80aef0597..fa618d8ba 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -10,7 +10,6 @@ import { SearchOutlined, InboxOutlined, VideoCameraOutlined, - SoundOutlined, PictureOutlined, PlayCircleOutlined, CheckOutlined, @@ -37,7 +36,7 @@ import "./assets.css"; /* ============================================================ * 类型 * ============================================================ */ -type AssetKind = "video" | "voice" | "image"; +type AssetKind = "video" | "image"; type StatusType = "ok" | "warn" | "bad" | "info"; interface LibraryItem { @@ -67,7 +66,6 @@ interface AssetItem { /** 根据 mime_type 推断前端 AssetKind */ const inferKind = (mimeType: string): AssetKind => { if (mimeType.startsWith("video/")) return "video"; - if (mimeType.startsWith("audio/")) return "voice"; return "image"; }; @@ -99,7 +97,7 @@ const formatDuration = (seconds: number): string => { const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({ id: item.id, name: item.name, - kind: item.kind || inferKind("video"), + kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"), count: item.asset_count ?? 0, }); @@ -149,8 +147,6 @@ const kindIcon = (kind: AssetKind) => { switch (kind) { case "video": return ; - case "voice": - return ; case "image": return ; } @@ -160,8 +156,6 @@ const kindLabel = (kind: AssetKind) => { switch (kind) { case "video": return "视频"; - case "voice": - return "配音"; case "image": return "图片"; } @@ -172,8 +166,6 @@ const thumbGradient = (kind: AssetKind): string => { switch (kind) { case "video": return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"; - case "voice": - return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"; case "image": return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"; } @@ -246,7 +238,7 @@ const AssetCard: React.FC<{ )} {/* 视频/配音类显示播放按钮 */} - {(asset.kind === "video" || asset.kind === "voice") && ( + {asset.kind === "video" && ( { @@ -450,8 +442,9 @@ const AssetLibrary: React.FC = () => { message.success(`"${file.name}" 上传成功`); queryClient.invalidateQueries({ queryKey: ["assets"] }); queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); - } catch { - message.error(`"${file.name}" 上传失败`); + } catch (err: unknown) { + const detail = err instanceof Error ? err.message : ""; + message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`); } finally { setUploading(false); setUploadProgress(0); @@ -645,7 +638,7 @@ const AssetLibrary: React.FC = () => { beforeUpload={handleUpload} showUploadList={false} multiple - accept="video/*,audio/*,image/*" + accept="video/*,image/*" >

@@ -655,7 +648,7 @@ const AssetLibrary: React.FC = () => { {uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}

- 支持视频、音频、图片,单文件不超过 2GB + 支持视频、图片,单文件不超过 2GB

@@ -678,7 +671,6 @@ const AssetLibrary: React.FC = () => { options={[ { value: "all", label: "全部类型" }, { value: "video", label: "视频" }, - { value: "voice", label: "配音" }, { value: "image", label: "图片" }, ]} /> @@ -809,7 +801,6 @@ const AssetLibrary: React.FC = () => { style={{ width: "100%" }} options={[ { value: "video", label: "视频" }, - { value: "voice", label: "配音" }, { value: "image", label: "图片" }, ]} /> diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 1c6ad79ea..2e0621c4e 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -6,11 +6,14 @@ */ import React, { useState, useRef, useCallback, useEffect } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { Typography, message } from "antd"; +import { Typography, message, Select } from "antd"; import { AudioOutlined, ThunderboltOutlined, CheckCircleFilled, + CheckCircleOutlined, + CloseCircleOutlined, + LoadingOutlined, PlayCircleOutlined, PauseCircleOutlined, DownloadOutlined, @@ -160,6 +163,7 @@ const GeneratePage: React.FC = () => { const [generating, setGenerating] = useState(false); const [progress, setProgress] = useState(0); const [generated, setGenerated] = useState(false); + const [generateError, setGenerateError] = useState(null); const progressTimer = useRef>(undefined); const audioRef = useRef(null); @@ -477,6 +481,7 @@ const GeneratePage: React.FC = () => { setGenerating(true); setProgress(0); setGenerated(false); + setGenerateError(null); try { const voiceConfig: Record = {}; @@ -537,6 +542,7 @@ const GeneratePage: React.FC = () => { )?.error_message || "视频生成失败,请联系管理员或重试"; console.error("[生成失败] planId:", plan.id, "响应:", data); + setGenerateError(errorMsg); message.error(errorMsg); return; } @@ -593,7 +599,9 @@ const GeneratePage: React.FC = () => { "完整错误:", axiosErr, ); - message.error(backendMsg || "生成失败,请检查网络后重试或联系管理员"); + const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员"; + setGenerateError(errorMsg); + message.error(errorMsg); } }, [ title, @@ -917,14 +925,30 @@ const GeneratePage: React.FC = () => {

📝 选择标题

- +