diff --git a/core/backend/apps/assets/assets_client.py b/core/backend/apps/assets/assets_client.py index d9d445c..38356e4 100644 --- a/core/backend/apps/assets/assets_client.py +++ b/core/backend/apps/assets/assets_client.py @@ -20,9 +20,15 @@ _ASSETS_ERROR_MESSAGES = { "RequestError": "素材审核服务暂时不可用,请稍后重试", "InvalidParameter": "素材参数无效", "NotFound": "素材不存在或已被删除", + "NotFound.asset_id": "素材不存在或已被删除", + "NotFound.group_id": "素材组不存在或已被删除", "Forbidden": "没有权限操作该素材(检查 AK/SK)", } +_NOT_FOUND_CODES = frozenset({"NotFound", "NotFound.asset_id", "NotFound.group_id"}) +_ASSET_NOT_FOUND_CODES = frozenset({"NotFound", "NotFound.asset_id"}) +_GROUP_NOT_FOUND_CODES = frozenset({"NotFound", "NotFound.group_id"}) + class AssetsAPIError(Exception): def __init__(self, code, message, status_code=400): @@ -33,6 +39,18 @@ class AssetsAPIError(Exception): super().__init__(f"[{code}] {message}") +def is_not_found_code(code: str | None) -> bool: + return code in _NOT_FOUND_CODES + + +def is_asset_not_found_code(code: str | None) -> bool: + return code in _ASSET_NOT_FOUND_CODES + + +def is_group_not_found_code(code: str | None) -> bool: + return code in _GROUP_NOT_FOUND_CODES + + def is_enabled() -> bool: cfg = getattr(settings, "ASSETS_API", {}) or {} return bool(cfg.get("enabled") and cfg.get("access_key") and cfg.get("secret_key")) diff --git a/core/backend/apps/assets/free_library.py b/core/backend/apps/assets/free_library.py index 3668d49..9fc2a0d 100644 --- a/core/backend/apps/assets/free_library.py +++ b/core/backend/apps/assets/free_library.py @@ -32,6 +32,8 @@ _VIDEO_MAX = 50 * 1024 * 1024 _AUDIO_MAX = 15 * 1024 * 1024 DEFAULT_GROUP_NAME = "默认素材" DEFAULT_GROUP_MARKER = "__airshelf_free_asset_default__" +OTHER_REMOTE_ASSET_MESSAGE = "该素材属于其他火山账号,请重新上传后再编辑" +OTHER_REMOTE_GROUP_MESSAGE = "该素材组属于其他火山账号,请新建素材组后重新上传" def _serialize_asset(fa: FreeAsset) -> dict: @@ -63,10 +65,7 @@ def _assets_api_unavailable() -> Response: return Response({"detail": "素材库服务未配置,请联系管理员"}, status=status.HTTP_503_SERVICE_UNAVAILABLE) -def _get_or_create_default_group(*, team, user) -> FreeAssetGroup: - group = FreeAssetGroup.objects.filter(team=team, is_deleted=False, description=DEFAULT_GROUP_MARKER).first() - if group is not None: - return group +def _create_default_group(*, team, user) -> FreeAssetGroup: remote_group_id = assets_client.create_asset_group(DEFAULT_GROUP_NAME, DEFAULT_GROUP_MARKER) return FreeAssetGroup.objects.create( team=team, @@ -77,6 +76,19 @@ def _get_or_create_default_group(*, team, user) -> FreeAssetGroup: ) +def _get_or_create_default_group(*, team, user) -> FreeAssetGroup: + group = FreeAssetGroup.objects.filter(team=team, is_deleted=False, description=DEFAULT_GROUP_MARKER).first() + if group is not None: + try: + assets_client.get_asset_group(group.remote_group_id) + except AssetsAPIError as exc: + if not assets_client.is_group_not_found_code(exc.code): + raise + return _create_default_group(team=team, user=user) + return group + return _create_default_group(team=team, user=user) + + def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only: bool = False) -> Response: upload = request.FILES.get("file") if upload is None: @@ -138,6 +150,8 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only: try: remote_asset_id = assets_client.create_asset(group.remote_group_id, url, name=name, asset_type=asset_type) except AssetsAPIError as exc: + if assets_client.is_group_not_found_code(exc.code): + return Response({"detail": OTHER_REMOTE_GROUP_MESSAGE}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) fa = FreeAsset.objects.create( @@ -215,8 +229,9 @@ class FreeAssetGroupDetailView(APIView): try: assets_client.update_asset_group(group.remote_group_id, name=group.name, description=group.description) except AssetsAPIError as exc: - if exc.code != "NotFound": - return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) + if assets_client.is_group_not_found_code(exc.code): + return Response({"detail": OTHER_REMOTE_GROUP_MESSAGE}, status=status.HTTP_400_BAD_REQUEST) + return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) group.save(update_fields=update_fields) return Response({"group": _serialize_group(group)}) @@ -228,7 +243,7 @@ class FreeAssetGroupDetailView(APIView): assets_client.delete_asset_group(group.remote_group_id) except AssetsAPIError as exc: # 远程已不存在 → 幂等继续清本地;其它错误如实反馈 - if exc.code != "NotFound": + if not assets_client.is_group_not_found_code(exc.code): return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) group.is_deleted = True group.save(update_fields=["is_deleted", "updated_at"]) @@ -286,8 +301,9 @@ class FreeAssetDetailView(APIView): try: assets_client.update_asset(fa.remote_asset_id, name=name) except AssetsAPIError as exc: - if exc.code != "NotFound": - return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) + if assets_client.is_asset_not_found_code(exc.code): + return Response({"detail": OTHER_REMOTE_ASSET_MESSAGE}, status=status.HTTP_400_BAD_REQUEST) + return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) fa.name = name[:128] fa.save(update_fields=["name", "updated_at"]) return Response({"asset": _serialize_asset(fa)}) @@ -299,7 +315,7 @@ class FreeAssetDetailView(APIView): try: assets_client.delete_asset(fa.remote_asset_id) except AssetsAPIError as exc: - if exc.code != "NotFound": + if not assets_client.is_asset_not_found_code(exc.code): return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) fa.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index 7e4c60b..05719bd 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -3,11 +3,12 @@ from types import SimpleNamespace from unittest.mock import patch from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import TestCase +from django.test import SimpleTestCase, TestCase from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User +from . import assets_client from .models import Asset, FreeAsset, FreeAssetGroup, Model @@ -152,6 +153,32 @@ class AssetTrashApiTests(TestCase): self.assertEqual(self.client.post(f"/api/assets/{self.live.id}/restore/").status_code, 404) +class AssetsClientErrorCodeTests(SimpleTestCase): + def test_not_found_helpers_cover_volcano_variants(self): + self.assertTrue(assets_client.is_not_found_code("NotFound")) + self.assertTrue(assets_client.is_not_found_code("NotFound.asset_id")) + self.assertTrue(assets_client.is_not_found_code("NotFound.group_id")) + self.assertFalse(assets_client.is_not_found_code("Forbidden")) + self.assertFalse(assets_client.is_not_found_code(None)) + + self.assertTrue(assets_client.is_asset_not_found_code("NotFound")) + self.assertTrue(assets_client.is_asset_not_found_code("NotFound.asset_id")) + self.assertFalse(assets_client.is_asset_not_found_code("NotFound.group_id")) + + self.assertTrue(assets_client.is_group_not_found_code("NotFound")) + self.assertTrue(assets_client.is_group_not_found_code("NotFound.group_id")) + self.assertFalse(assets_client.is_group_not_found_code("NotFound.asset_id")) + + def test_not_found_variant_messages_are_not_generic(self): + asset_error = assets_client.AssetsAPIError("NotFound.asset_id", "remote asset missing") + group_error = assets_client.AssetsAPIError("NotFound.group_id", "remote group missing") + unknown_error = assets_client.AssetsAPIError("Unknown", "unknown") + + self.assertNotEqual(asset_error.user_message, unknown_error.user_message) + self.assertNotEqual(group_error.user_message, unknown_error.user_message) + self.assertNotEqual(asset_error.user_message, group_error.user_message) + + class FreeAssetQuickUploadTests(TestCase): """人物素材库快捷上传:自动默认组,但底层仍走 FreeAssetGroup / FreeAsset。""" @@ -187,10 +214,11 @@ class FreeAssetQuickUploadTests(TestCase): self.assertEqual(res.json()["group"]["id"], str(group.id)) @patch("apps.assets.free_library.assets_client.is_enabled", return_value=True) + @patch("apps.assets.free_library.assets_client.get_asset_group", return_value={"Id": "Group-1"}) @patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-1") @patch("apps.assets.free_library.assets_client.create_asset", side_effect=["Asset-1", "Asset-2"]) @patch("apps.assets.free_library.TosStorage") - def test_quick_upload_reuses_default_group(self, storage_cls, create_asset, create_group, _enabled): + def test_quick_upload_reuses_default_group(self, storage_cls, create_asset, create_group, get_group, _enabled): storage = storage_cls.return_value storage.upload_fileobj.return_value = self.stored storage.public_url.return_value = "http://tos/a.png" @@ -202,8 +230,40 @@ class FreeAssetQuickUploadTests(TestCase): self.assertEqual(FreeAssetGroup.objects.filter(team=self.team).count(), 1) self.assertEqual(FreeAsset.objects.filter(group__team=self.team).count(), 2) create_group.assert_called_once() + get_group.assert_called_once_with("Group-1") self.assertEqual(create_asset.call_count, 2) + @patch("apps.assets.free_library.assets_client.is_enabled", return_value=True) + @patch( + "apps.assets.free_library.assets_client.get_asset_group", + side_effect=assets_client.AssetsAPIError("NotFound.group_id", "old group not visible"), + ) + @patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-2") + @patch("apps.assets.free_library.assets_client.create_asset", return_value="Asset-2") + @patch("apps.assets.free_library.TosStorage") + def test_quick_upload_replaces_old_default_group(self, storage_cls, create_asset, create_group, get_group, _enabled): + old_group = FreeAssetGroup.objects.create( + team=self.team, + created_by=self.user, + name="默认素材", + description="__airshelf_free_asset_default__", + remote_group_id="Old-Group", + ) + storage = storage_cls.return_value + storage.upload_fileobj.return_value = self.stored + storage.public_url.return_value = "http://tos/a.png" + + res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("new.png")}, format="multipart") + + self.assertEqual(res.status_code, 201) + self.assertFalse(FreeAsset.objects.filter(group=old_group).exists()) + new_group = FreeAssetGroup.objects.exclude(id=old_group.id).get(team=self.team) + self.assertEqual(new_group.remote_group_id, "Group-2") + self.assertEqual(new_group.assets.count(), 1) + get_group.assert_called_once_with("Old-Group") + create_group.assert_called_once() + create_asset.assert_called_once_with("Group-2", "http://tos/a.png", name="new.png", asset_type=FreeAsset.Type.IMAGE) + @patch("apps.assets.free_library.assets_client.is_enabled", return_value=True) @patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-1") @patch("apps.assets.free_library.TosStorage") @@ -219,6 +279,143 @@ class FreeAssetQuickUploadTests(TestCase): create_group.assert_called_once() +class FreeAssetGroupUploadTests(TestCase): + def setUp(self): + self.user, self.team = _mk_team("fgu", "TeamFGU") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.group = FreeAssetGroup.objects.create( + team=self.team, + created_by=self.user, + name="旧素材组", + description="", + remote_group_id="Old-Group", + ) + self.stored = SimpleNamespace(object_key="teams/x/free-create/library/a.png", bucket="b", content_type="image/png", size_bytes=10) + + def _image(self, name="a.png"): + return SimpleUploadedFile(name, b"img", content_type="image/png") + + @patch("apps.assets.free_library.assets_client.is_enabled", return_value=True) + @patch( + "apps.assets.free_library.assets_client.create_asset", + side_effect=assets_client.AssetsAPIError("NotFound.group_id", "old group not visible"), + ) + @patch("apps.assets.free_library.TosStorage") + def test_upload_to_old_remote_group_returns_clear_message(self, storage_cls, create_asset, _enabled): + storage = storage_cls.return_value + storage.upload_fileobj.return_value = self.stored + storage.public_url.return_value = "http://tos/a.png" + + res = self.client.post(f"/api/assets/free-groups/{self.group.id}/assets/", {"file": self._image()}, format="multipart") + + self.assertEqual(res.status_code, 400) + self.assertEqual(res.json()["detail"], "该素材组属于其他火山账号,请新建素材组后重新上传") + self.assertFalse(FreeAsset.objects.filter(group=self.group).exists()) + create_asset.assert_called_once_with("Old-Group", "http://tos/a.png", name="a.png", asset_type=FreeAsset.Type.IMAGE) + + +class FreeAssetDeleteCompatibilityTests(TestCase): + def setUp(self): + self.user, self.team = _mk_team("fdel", "TeamFDEL") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.group = FreeAssetGroup.objects.create( + team=self.team, + created_by=self.user, + name="旧素材组", + description="", + remote_group_id="Old-Group", + ) + self.asset = FreeAsset.objects.create( + group=self.group, + remote_asset_id="Old-Asset", + name="旧素材.png", + url="http://tos/a.png", + asset_type=FreeAsset.Type.IMAGE, + thumbnail_url="http://tos/a.png", + status=FreeAsset.Status.ACTIVE, + ) + + @patch( + "apps.assets.free_library.assets_client.delete_asset", + side_effect=assets_client.AssetsAPIError("NotFound.asset_id", "old asset not visible"), + ) + def test_delete_old_remote_asset_clears_local_asset(self, delete_asset): + res = self.client.delete(f"/api/assets/free-assets/{self.asset.id}/") + + self.assertEqual(res.status_code, 204) + self.assertFalse(FreeAsset.objects.filter(id=self.asset.id).exists()) + delete_asset.assert_called_once_with("Old-Asset") + + @patch( + "apps.assets.free_library.assets_client.delete_asset_group", + side_effect=assets_client.AssetsAPIError("NotFound.group_id", "old group not visible"), + ) + def test_delete_old_remote_group_marks_local_group_deleted(self, delete_group): + res = self.client.delete(f"/api/assets/free-groups/{self.group.id}/") + + self.assertEqual(res.status_code, 204) + self.group.refresh_from_db() + self.assertTrue(self.group.is_deleted) + self.assertTrue(FreeAsset.objects.filter(id=self.asset.id).exists()) + delete_group.assert_called_once_with("Old-Group") + + +class FreeAssetRenameCompatibilityTests(TestCase): + def setUp(self): + self.user, self.team = _mk_team("fren", "TeamFREN") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.group = FreeAssetGroup.objects.create( + team=self.team, + created_by=self.user, + name="旧素材组", + description="old description", + remote_group_id="Old-Group", + ) + self.asset = FreeAsset.objects.create( + group=self.group, + remote_asset_id="Old-Asset", + name="旧素材.png", + url="http://tos/a.png", + asset_type=FreeAsset.Type.IMAGE, + thumbnail_url="http://tos/a.png", + status=FreeAsset.Status.ACTIVE, + ) + + @patch( + "apps.assets.free_library.assets_client.update_asset", + side_effect=assets_client.AssetsAPIError("NotFound.asset_id", "old asset not visible"), + ) + def test_rename_old_remote_asset_returns_clear_message_without_saving(self, update_asset): + res = self.client.patch(f"/api/assets/free-assets/{self.asset.id}/", {"name": "新名字.png"}, format="json") + + self.assertEqual(res.status_code, 400) + self.assertEqual(res.json()["detail"], "该素材属于其他火山账号,请重新上传后再编辑") + self.asset.refresh_from_db() + self.assertEqual(self.asset.name, "旧素材.png") + update_asset.assert_called_once_with("Old-Asset", name="新名字.png") + + @patch( + "apps.assets.free_library.assets_client.update_asset_group", + side_effect=assets_client.AssetsAPIError("NotFound.group_id", "old group not visible"), + ) + def test_rename_old_remote_group_returns_clear_message_without_saving(self, update_group): + res = self.client.patch( + f"/api/assets/free-groups/{self.group.id}/", + {"name": "新素材组", "description": "new description"}, + format="json", + ) + + self.assertEqual(res.status_code, 400) + self.assertEqual(res.json()["detail"], "该素材组属于其他火山账号,请新建素材组后重新上传") + self.group.refresh_from_db() + self.assertEqual(self.group.name, "旧素材组") + self.assertEqual(self.group.description, "old description") + update_group.assert_called_once_with("Old-Group", name="新素材组", description="new description") + + class VideoPacksTests(TestCase): """视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。""" diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 1b87a1c..17f8fc7 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -431,11 +431,12 @@ export function ToastLike({ notice }: { notice: NonNullable }) { return () => cancelAnimationFrame(raf); }, []); const Icon = notice.type === "error" ? AlertCircle : notice.type === "info" ? Info : Check; - return ( + return createPortal(
{notice.text}[ {notice.type.toUpperCase()} ]
-
+ , + document.body, ); } diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index f8b6a89..edc57cc 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -1728,7 +1728,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); } box-shadow: var(--shadow-floating); transform: translateX(420px); transition: transform var(--t-slow); - z-index: 1000; + z-index: 10000; min-width: 260px; } .toast.show { transform: translateX(0); }