fix: 修复换火山账号导致素材库操作失败问题

This commit is contained in:
hh
2026-07-09 17:54:05 +08:00
parent 8c50096888
commit 3e52942547
5 changed files with 247 additions and 15 deletions
+18
View File
@@ -20,9 +20,15 @@ _ASSETS_ERROR_MESSAGES = {
"RequestError": "素材审核服务暂时不可用,请稍后重试", "RequestError": "素材审核服务暂时不可用,请稍后重试",
"InvalidParameter": "素材参数无效", "InvalidParameter": "素材参数无效",
"NotFound": "素材不存在或已被删除", "NotFound": "素材不存在或已被删除",
"NotFound.asset_id": "素材不存在或已被删除",
"NotFound.group_id": "素材组不存在或已被删除",
"Forbidden": "没有权限操作该素材(检查 AK/SK)", "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): class AssetsAPIError(Exception):
def __init__(self, code, message, status_code=400): def __init__(self, code, message, status_code=400):
@@ -33,6 +39,18 @@ class AssetsAPIError(Exception):
super().__init__(f"[{code}] {message}") 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: def is_enabled() -> bool:
cfg = getattr(settings, "ASSETS_API", {}) or {} cfg = getattr(settings, "ASSETS_API", {}) or {}
return bool(cfg.get("enabled") and cfg.get("access_key") and cfg.get("secret_key")) return bool(cfg.get("enabled") and cfg.get("access_key") and cfg.get("secret_key"))
+26 -10
View File
@@ -32,6 +32,8 @@ _VIDEO_MAX = 50 * 1024 * 1024
_AUDIO_MAX = 15 * 1024 * 1024 _AUDIO_MAX = 15 * 1024 * 1024
DEFAULT_GROUP_NAME = "默认素材" DEFAULT_GROUP_NAME = "默认素材"
DEFAULT_GROUP_MARKER = "__airshelf_free_asset_default__" DEFAULT_GROUP_MARKER = "__airshelf_free_asset_default__"
OTHER_REMOTE_ASSET_MESSAGE = "该素材属于其他火山账号,请重新上传后再编辑"
OTHER_REMOTE_GROUP_MESSAGE = "该素材组属于其他火山账号,请新建素材组后重新上传"
def _serialize_asset(fa: FreeAsset) -> dict: def _serialize_asset(fa: FreeAsset) -> dict:
@@ -63,10 +65,7 @@ def _assets_api_unavailable() -> Response:
return Response({"detail": "素材库服务未配置,请联系管理员"}, status=status.HTTP_503_SERVICE_UNAVAILABLE) return Response({"detail": "素材库服务未配置,请联系管理员"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
def _get_or_create_default_group(*, team, user) -> FreeAssetGroup: def _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
remote_group_id = assets_client.create_asset_group(DEFAULT_GROUP_NAME, DEFAULT_GROUP_MARKER) remote_group_id = assets_client.create_asset_group(DEFAULT_GROUP_NAME, DEFAULT_GROUP_MARKER)
return FreeAssetGroup.objects.create( return FreeAssetGroup.objects.create(
team=team, 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: def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only: bool = False) -> Response:
upload = request.FILES.get("file") upload = request.FILES.get("file")
if upload is None: if upload is None:
@@ -138,6 +150,8 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only:
try: try:
remote_asset_id = assets_client.create_asset(group.remote_group_id, url, name=name, asset_type=asset_type) remote_asset_id = assets_client.create_asset(group.remote_group_id, url, name=name, asset_type=asset_type)
except AssetsAPIError as exc: 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) return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
fa = FreeAsset.objects.create( fa = FreeAsset.objects.create(
@@ -215,8 +229,9 @@ class FreeAssetGroupDetailView(APIView):
try: try:
assets_client.update_asset_group(group.remote_group_id, name=group.name, description=group.description) assets_client.update_asset_group(group.remote_group_id, name=group.name, description=group.description)
except AssetsAPIError as exc: except AssetsAPIError as exc:
if exc.code != "NotFound": if assets_client.is_group_not_found_code(exc.code):
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) 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) group.save(update_fields=update_fields)
return Response({"group": _serialize_group(group)}) return Response({"group": _serialize_group(group)})
@@ -228,7 +243,7 @@ class FreeAssetGroupDetailView(APIView):
assets_client.delete_asset_group(group.remote_group_id) assets_client.delete_asset_group(group.remote_group_id)
except AssetsAPIError as exc: 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) return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
group.is_deleted = True group.is_deleted = True
group.save(update_fields=["is_deleted", "updated_at"]) group.save(update_fields=["is_deleted", "updated_at"])
@@ -286,8 +301,9 @@ class FreeAssetDetailView(APIView):
try: try:
assets_client.update_asset(fa.remote_asset_id, name=name) assets_client.update_asset(fa.remote_asset_id, name=name)
except AssetsAPIError as exc: except AssetsAPIError as exc:
if exc.code != "NotFound": if assets_client.is_asset_not_found_code(exc.code):
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) 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.name = name[:128]
fa.save(update_fields=["name", "updated_at"]) fa.save(update_fields=["name", "updated_at"])
return Response({"asset": _serialize_asset(fa)}) return Response({"asset": _serialize_asset(fa)})
@@ -299,7 +315,7 @@ class FreeAssetDetailView(APIView):
try: try:
assets_client.delete_asset(fa.remote_asset_id) assets_client.delete_asset(fa.remote_asset_id)
except AssetsAPIError as exc: 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) return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
fa.delete() fa.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
+199 -2
View File
@@ -3,11 +3,12 @@ from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile 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 rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User from apps.accounts.models import Team, TeamMember, User
from . import assets_client
from .models import Asset, FreeAsset, FreeAssetGroup, Model 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) 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): class FreeAssetQuickUploadTests(TestCase):
"""人物素材库快捷上传:自动默认组,但底层仍走 FreeAssetGroup / FreeAsset。""" """人物素材库快捷上传:自动默认组,但底层仍走 FreeAssetGroup / FreeAsset。"""
@@ -187,10 +214,11 @@ class FreeAssetQuickUploadTests(TestCase):
self.assertEqual(res.json()["group"]["id"], str(group.id)) 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.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_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.assets_client.create_asset", side_effect=["Asset-1", "Asset-2"])
@patch("apps.assets.free_library.TosStorage") @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 = storage_cls.return_value
storage.upload_fileobj.return_value = self.stored storage.upload_fileobj.return_value = self.stored
storage.public_url.return_value = "http://tos/a.png" 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(FreeAssetGroup.objects.filter(team=self.team).count(), 1)
self.assertEqual(FreeAsset.objects.filter(group__team=self.team).count(), 2) self.assertEqual(FreeAsset.objects.filter(group__team=self.team).count(), 2)
create_group.assert_called_once() create_group.assert_called_once()
get_group.assert_called_once_with("Group-1")
self.assertEqual(create_asset.call_count, 2) 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.is_enabled", return_value=True)
@patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-1") @patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-1")
@patch("apps.assets.free_library.TosStorage") @patch("apps.assets.free_library.TosStorage")
@@ -219,6 +279,143 @@ class FreeAssetQuickUploadTests(TestCase):
create_group.assert_called_once() 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): class VideoPacksTests(TestCase):
"""视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。""" """视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。"""
+3 -2
View File
@@ -431,11 +431,12 @@ export function ToastLike({ notice }: { notice: NonNullable<Notice> }) {
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}, []); }, []);
const Icon = notice.type === "error" ? AlertCircle : notice.type === "info" ? Info : Check; const Icon = notice.type === "error" ? AlertCircle : notice.type === "info" ? Info : Check;
return ( return createPortal(
<div className={`toast${show ? " show" : ""}`} role="status" aria-live="polite"> <div className={`toast${show ? " show" : ""}`} role="status" aria-live="polite">
<div className="ic-t"><Icon size={13} /></div> <div className="ic-t"><Icon size={13} /></div>
<div className="txt">{notice.text}<span className="mono">[ {notice.type.toUpperCase()} ]</span></div> <div className="txt">{notice.text}<span className="mono">[ {notice.type.toUpperCase()} ]</span></div>
</div> </div>,
document.body,
); );
} }
+1 -1
View File
@@ -1728,7 +1728,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
box-shadow: var(--shadow-floating); box-shadow: var(--shadow-floating);
transform: translateX(420px); transform: translateX(420px);
transition: transform var(--t-slow); transition: transform var(--t-slow);
z-index: 1000; z-index: 10000;
min-width: 260px; min-width: 260px;
} }
.toast.show { transform: translateX(0); } .toast.show { transform: translateX(0); }