@@ -37,12 +37,15 @@ OTHER_REMOTE_GROUP_MESSAGE = "该素材组属于其他火山账号,请新建
|
||||
|
||||
|
||||
def _serialize_asset(fa: FreeAsset) -> dict:
|
||||
# 火山仍在审核的素材不能把 TOS 原始链接回传给浏览器。否则前端虽已禁选,
|
||||
# 仍可能把未审核直链带入其它生成入口。审核通过后 poll 接口会再返回链接。
|
||||
available = fa.status == FreeAsset.Status.ACTIVE
|
||||
return {
|
||||
"id": str(fa.id),
|
||||
"name": fa.name,
|
||||
"url": fa.url,
|
||||
"url": fa.url if available else "",
|
||||
"type": fa.asset_type.lower(),
|
||||
"thumb_url": fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else ""),
|
||||
"thumb_url": (fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else "")) if available else "",
|
||||
"duration": fa.duration,
|
||||
"status": fa.status,
|
||||
"error_message": fa.error_message,
|
||||
@@ -167,7 +170,9 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only:
|
||||
if not group.thumbnail_url and fa.thumbnail_url:
|
||||
group.thumbnail_url = fa.thumbnail_url
|
||||
group.save(update_fields=["thumbnail_url", "updated_at"])
|
||||
return Response({"group": _serialize_group(group), "asset": _serialize_asset(fa)}, status=status.HTTP_201_CREATED)
|
||||
# 202 明确表示「已受理审核」而不是上传成功。只有轮询到 active 后,序列化结果
|
||||
# 才会含可用于生成的 URL。
|
||||
return Response({"group": _serialize_group(group), "asset": _serialize_asset(fa)}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
class FreeAssetGroupListView(APIView):
|
||||
@@ -263,7 +268,7 @@ class FreeAssetUploadView(APIView):
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
response = _upload_asset_to_group(request, team=team, group=group)
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
if response.status_code in (status.HTTP_201_CREATED, status.HTTP_202_ACCEPTED):
|
||||
response.data.pop("group", None)
|
||||
return response
|
||||
|
||||
|
||||
@@ -157,7 +157,10 @@ def reference_review_state(asset: Asset, *, require_registered: bool = False) ->
|
||||
火山素材 ID 并通过 asset:// 引用。普通直链会被生成接口按真人隐私素材直接拒绝。
|
||||
"""
|
||||
if not assets_client.is_enabled():
|
||||
# 审核整套机制没配置时不能拿它拦人,否则一关审核全平台引用都用不了
|
||||
# 平台自生成资产不依赖用户上传审核;用户上传则必须阻止,不能因为审核服务
|
||||
# 短暂不可用而把历史未审素材带进 Seedance。
|
||||
if asset.source == Asset.Source.UPLOAD:
|
||||
return "unavailable"
|
||||
return "allowed"
|
||||
if asset.review_status == "active" and (asset.review_remote_id or not require_registered):
|
||||
return "allowed"
|
||||
@@ -243,16 +246,22 @@ def ensure_review_drain_loop() -> None:
|
||||
def wait_upload_review(asset: Asset, *, timeout_s: float = 45.0, interval_s: float = 1.5) -> str:
|
||||
"""上传素材:立刻送审并等到终态。
|
||||
|
||||
返回 active / failed / processing(超时仍在审) / allowed(审核未启用)。
|
||||
审核未配置时不能拦上传,直接当 allowed。
|
||||
返回 active / failed / processing(超时仍在审) / unavailable(审核未启用或送审失败)。
|
||||
|
||||
用户上传的素材不能因为审核服务暂时不可用而被当作已通过。调用方应把
|
||||
unavailable 当作上传未完成处理,既不返回可引用链接,也不允许进入生成链路。
|
||||
"""
|
||||
if not assets_client.is_enabled():
|
||||
return "allowed"
|
||||
submit_asset_for_review(asset, force=True)
|
||||
return "unavailable"
|
||||
submitted = submit_asset_for_review(asset, force=True)
|
||||
asset.refresh_from_db()
|
||||
status = asset.review_status or ""
|
||||
if status in ("active", "failed"):
|
||||
return status
|
||||
# CreateAsset 失败时没有 remote id 可以轮询;此前会空等到超时后把上传误报为
|
||||
# 「审核中」。直接把真实的不可用状态交给上传接口,让用户可以重试。
|
||||
if not submitted and not asset.review_remote_id:
|
||||
return "unavailable"
|
||||
deadline = time.monotonic() + max(3.0, float(timeout_s))
|
||||
while time.monotonic() < deadline:
|
||||
status = poll_asset_review(asset) or ""
|
||||
|
||||
@@ -20,6 +20,74 @@ def _mk_team(username, team_name):
|
||||
return user, team
|
||||
|
||||
|
||||
class AssetUploadReviewGateTests(TestCase):
|
||||
"""通用本地上传必须审核通过,才暴露可引用的资产数据。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user, self.team = _mk_team("upload-review-user", "UploadReview")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.stored = SimpleNamespace(
|
||||
object_key="teams/upload-review/uploads/photo.png",
|
||||
bucket="test",
|
||||
content_type="image/png",
|
||||
size_bytes=12,
|
||||
)
|
||||
|
||||
def _post(self):
|
||||
return self.client.post(
|
||||
"/api/assets/upload/",
|
||||
{
|
||||
"file": SimpleUploadedFile("photo.png", b"fake-image", content_type="image/png"),
|
||||
"asset_type": Asset.Type.IMAGE,
|
||||
"category": Asset.Category.UPLOAD,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
def test_returns_asset_and_links_only_after_review_is_active(self):
|
||||
def approve(asset, **_kwargs):
|
||||
asset.review_status = "active"
|
||||
asset.review_remote_id = "remote-approved"
|
||||
asset.save(update_fields=["review_status", "review_remote_id"])
|
||||
return "active"
|
||||
|
||||
with patch("apps.assets.views.TosStorage") as tos, patch(
|
||||
"apps.assets.views.assets_client.is_enabled", return_value=True
|
||||
), patch("apps.assets.views.wait_upload_review", side_effect=approve) as wait:
|
||||
tos.return_value.upload_fileobj.return_value = self.stored
|
||||
response = self._post()
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.json()["review_status"], "active")
|
||||
self.assertIn("files", response.json())
|
||||
wait.assert_called_once()
|
||||
|
||||
def test_processing_review_does_not_return_asset_link(self):
|
||||
with patch("apps.assets.views.TosStorage") as tos, patch(
|
||||
"apps.assets.views.assets_client.is_enabled", return_value=True
|
||||
), patch("apps.assets.views.wait_upload_review", return_value="processing"):
|
||||
tos.return_value.upload_fileobj.return_value = self.stored
|
||||
response = self._post()
|
||||
|
||||
self.assertEqual(response.status_code, 408)
|
||||
self.assertEqual(response.json()["review_status"], "processing")
|
||||
self.assertNotIn("files", response.json())
|
||||
self.assertNotIn("url", response.json())
|
||||
|
||||
def test_unavailable_review_service_does_not_return_asset_link(self):
|
||||
with patch("apps.assets.views.TosStorage") as tos, patch(
|
||||
"apps.assets.views.assets_client.is_enabled", return_value=False
|
||||
), patch("apps.assets.views.wait_upload_review") as wait:
|
||||
tos.return_value.upload_fileobj.return_value = self.stored
|
||||
response = self._post()
|
||||
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.json()["review_status"], "unavailable")
|
||||
self.assertNotIn("files", response.json())
|
||||
wait.assert_not_called()
|
||||
|
||||
|
||||
class ModelLibraryApiTests(TestCase):
|
||||
"""模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。"""
|
||||
|
||||
@@ -421,7 +489,7 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
storage.public_url.return_value = "http://tos/a.png"
|
||||
|
||||
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image()}, format="multipart")
|
||||
self.assertEqual(res.status_code, 201)
|
||||
self.assertEqual(res.status_code, 202)
|
||||
group = FreeAssetGroup.objects.get(team=self.team)
|
||||
self.assertEqual(group.name, "默认素材")
|
||||
self.assertEqual(group.remote_group_id, "Group-1")
|
||||
@@ -429,6 +497,8 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
asset = group.assets.get()
|
||||
self.assertEqual(asset.asset_type, FreeAsset.Type.IMAGE)
|
||||
self.assertEqual(asset.status, FreeAsset.Status.PROCESSING)
|
||||
self.assertEqual(res.json()["asset"]["url"], "")
|
||||
self.assertEqual(res.json()["asset"]["thumb_url"], "")
|
||||
create_group.assert_called_once()
|
||||
create_asset.assert_called_once_with("Group-1", "http://tos/a.png", name="a.png", asset_type=FreeAsset.Type.IMAGE)
|
||||
self.assertEqual(res.json()["group"]["id"], str(group.id))
|
||||
@@ -445,8 +515,8 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
|
||||
first = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("a.png")}, format="multipart")
|
||||
second = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("b.png")}, format="multipart")
|
||||
self.assertEqual(first.status_code, 201)
|
||||
self.assertEqual(second.status_code, 201)
|
||||
self.assertEqual(first.status_code, 202)
|
||||
self.assertEqual(second.status_code, 202)
|
||||
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()
|
||||
@@ -475,7 +545,7 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
|
||||
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("new.png")}, format="multipart")
|
||||
|
||||
self.assertEqual(res.status_code, 201)
|
||||
self.assertEqual(res.status_code, 202)
|
||||
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")
|
||||
|
||||
@@ -19,7 +19,9 @@ from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
|
||||
from . import assets_client
|
||||
from .models import Asset, AssetFile, Model
|
||||
from .review import wait_upload_review
|
||||
from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
|
||||
from .storage import TosStorage
|
||||
|
||||
@@ -778,7 +780,6 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
class AssetUploadView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
serializer = AssetUploadSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@@ -793,22 +794,52 @@ class AssetUploadView(APIView):
|
||||
object_key=object_key,
|
||||
content_type=upload.content_type or "application/octet-stream",
|
||||
)
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
name=serializer.validated_data.get("name") or upload.name,
|
||||
asset_type=serializer.validated_data["asset_type"],
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=serializer.validated_data["category"],
|
||||
description=serializer.validated_data.get("description", ""),
|
||||
# 先持久化,审核服务才能从 TOS 拉取文件;网络审核绝不能放在数据库事务里,
|
||||
# 否则几十秒的轮询会长期锁住请求事务。
|
||||
with transaction.atomic():
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
name=serializer.validated_data.get("name") or upload.name,
|
||||
asset_type=serializer.validated_data["asset_type"],
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=serializer.validated_data["category"],
|
||||
description=serializer.validated_data.get("description", ""),
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=stored.object_key,
|
||||
bucket=stored.bucket,
|
||||
content_type=stored.content_type,
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
# 所有本地上传(图片/视频/音频)一律 force 送审。未通过、审核中或审核
|
||||
# 服务不可用时都不返回 AssetSerializer,避免提前泄露可供生成使用的链接。
|
||||
if not assets_client.is_enabled():
|
||||
return Response(
|
||||
{"detail": "素材审核服务暂不可用,请稍后重试", "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
review_status = wait_upload_review(asset)
|
||||
asset.refresh_from_db()
|
||||
if review_status == "active":
|
||||
return Response(AssetSerializer(asset).data, status=status.HTTP_201_CREATED)
|
||||
if review_status == "failed":
|
||||
detail = (asset.review_error or "素材未通过审核,请更换后重试").strip()
|
||||
return Response(
|
||||
{"detail": detail, "review_status": "failed", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if review_status == "processing":
|
||||
return Response(
|
||||
{"detail": "素材仍在审核中,请稍后重试", "review_status": "processing", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_408_REQUEST_TIMEOUT,
|
||||
)
|
||||
detail = (asset.review_error or "素材审核服务暂不可用,请稍后重试").strip()
|
||||
return Response(
|
||||
{"detail": detail, "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=stored.object_key,
|
||||
bucket=stored.bucket,
|
||||
content_type=stored.content_type,
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
return Response(AssetSerializer(asset).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
Reference in New Issue
Block a user