@@ -193,15 +193,20 @@ def _guard_asset_reference(asset: Asset, label: str, ref_type: str = "") -> None
|
|||||||
if state == "allowed":
|
if state == "allowed":
|
||||||
return
|
return
|
||||||
name = label or asset.name or "未命名"
|
name = label or asset.name or "未命名"
|
||||||
|
if state == "unavailable":
|
||||||
|
raise ValueError(f"素材「{name}」审核服务暂不可用,请稍后再试")
|
||||||
if state == "failed":
|
if state == "failed":
|
||||||
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考")
|
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考")
|
||||||
# unsubmitted / 仍 processing:补送并短等,通过则放行
|
# unsubmitted / 仍 processing:补送并短等,通过则放行
|
||||||
waited = wait_upload_review(asset, timeout_s=20.0)
|
waited = wait_upload_review(asset, timeout_s=20.0)
|
||||||
if waited in ("active", "allowed"):
|
if waited == "active":
|
||||||
return
|
return
|
||||||
if waited == "failed":
|
if waited == "failed":
|
||||||
err = (asset.review_error or "").strip()
|
err = (asset.review_error or "").strip()
|
||||||
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考" + (f"({err})" if err else ""))
|
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考" + (f"({err})" if err else ""))
|
||||||
|
if waited == "unavailable":
|
||||||
|
err = (asset.review_error or "").strip()
|
||||||
|
raise ValueError(f"素材「{name}」审核服务暂不可用,请稍后再试" + (f"({err})" if err else ""))
|
||||||
raise ValueError(f"素材「{name}」还在审核中,请稍后再试")
|
raise ValueError(f"素材「{name}」还在审核中,请稍后再试")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def _asset(team, *, source, review_status="", review_remote_id="", category=Asse
|
|||||||
|
|
||||||
|
|
||||||
class ReferenceReviewStateTests(TestCase):
|
class ReferenceReviewStateTests(TestCase):
|
||||||
"""审核判定四态。审核服务开着时才生效,关着一律放行(不能拿没配置的机制拦人)。"""
|
"""审核判定四态。平台生成素材可免责,用户上传绝不能因审核停用而放行。"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="refowner", password="p")
|
self.user = User.objects.create_user(username="refowner", password="p")
|
||||||
@@ -83,9 +83,9 @@ class ReferenceReviewStateTests(TestCase):
|
|||||||
asset.save(update_fields=["in_library"])
|
asset.save(update_fields=["in_library"])
|
||||||
self.assertEqual(reference_review_state(asset), "unsubmitted")
|
self.assertEqual(reference_review_state(asset), "unsubmitted")
|
||||||
|
|
||||||
def test_review_disabled_lets_everything_through(self):
|
def test_review_disabled_still_blocks_uploaded_asset(self):
|
||||||
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
||||||
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "allowed")
|
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "unavailable")
|
||||||
|
|
||||||
|
|
||||||
class VideoSeedRangeTests(TestCase):
|
class VideoSeedRangeTests(TestCase):
|
||||||
|
|||||||
@@ -1715,8 +1715,14 @@ class FreeVideoUploadView(APIView):
|
|||||||
thumb_url = storage.public_url(object_key=poster_key)
|
thumb_url = storage.public_url(object_key=poster_key)
|
||||||
|
|
||||||
# 上传即送审:通过后再入库并返回链接,避免出片时才把「已提交审核」塞进对话。
|
# 上传即送审:通过后再入库并返回链接,避免出片时才把「已提交审核」塞进对话。
|
||||||
|
from apps.assets import assets_client
|
||||||
from apps.assets.review import wait_upload_review
|
from apps.assets.review import wait_upload_review
|
||||||
|
|
||||||
|
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)
|
review_status = wait_upload_review(asset)
|
||||||
asset.refresh_from_db()
|
asset.refresh_from_db()
|
||||||
if review_status == "failed":
|
if review_status == "failed":
|
||||||
@@ -1727,7 +1733,13 @@ class FreeVideoUploadView(APIView):
|
|||||||
{"detail": "素材还在审核中,请稍后再试", "review_status": "processing", "asset_id": str(asset.id)},
|
{"detail": "素材还在审核中,请稍后再试", "review_status": "processing", "asset_id": str(asset.id)},
|
||||||
status=status.HTTP_408_REQUEST_TIMEOUT,
|
status=status.HTTP_408_REQUEST_TIMEOUT,
|
||||||
)
|
)
|
||||||
# active / allowed:进库,可供引用
|
if review_status != "active":
|
||||||
|
detail = (asset.review_error or "素材审核服务暂不可用,请稍后重试").strip()
|
||||||
|
return Response(
|
||||||
|
{"detail": detail, "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
# active:进库,可供引用
|
||||||
if not asset.in_library:
|
if not asset.in_library:
|
||||||
asset.in_library = True
|
asset.in_library = True
|
||||||
asset.save(update_fields=["in_library", "updated_at"])
|
asset.save(update_fields=["in_library", "updated_at"])
|
||||||
@@ -1742,7 +1754,7 @@ class FreeVideoUploadView(APIView):
|
|||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
"thumb_url": thumb_url or (url if kind == "image" else ""),
|
"thumb_url": thumb_url or (url if kind == "image" else ""),
|
||||||
"review_status": "active" if review_status in ("active", "allowed") else review_status,
|
"review_status": "active",
|
||||||
"in_library": True,
|
"in_library": True,
|
||||||
},
|
},
|
||||||
status=status.HTTP_201_CREATED,
|
status=status.HTTP_201_CREATED,
|
||||||
|
|||||||
@@ -37,12 +37,15 @@ OTHER_REMOTE_GROUP_MESSAGE = "该素材组属于其他火山账号,请新建
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_asset(fa: FreeAsset) -> dict:
|
def _serialize_asset(fa: FreeAsset) -> dict:
|
||||||
|
# 火山仍在审核的素材不能把 TOS 原始链接回传给浏览器。否则前端虽已禁选,
|
||||||
|
# 仍可能把未审核直链带入其它生成入口。审核通过后 poll 接口会再返回链接。
|
||||||
|
available = fa.status == FreeAsset.Status.ACTIVE
|
||||||
return {
|
return {
|
||||||
"id": str(fa.id),
|
"id": str(fa.id),
|
||||||
"name": fa.name,
|
"name": fa.name,
|
||||||
"url": fa.url,
|
"url": fa.url if available else "",
|
||||||
"type": fa.asset_type.lower(),
|
"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,
|
"duration": fa.duration,
|
||||||
"status": fa.status,
|
"status": fa.status,
|
||||||
"error_message": fa.error_message,
|
"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:
|
if not group.thumbnail_url and fa.thumbnail_url:
|
||||||
group.thumbnail_url = fa.thumbnail_url
|
group.thumbnail_url = fa.thumbnail_url
|
||||||
group.save(update_fields=["thumbnail_url", "updated_at"])
|
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):
|
class FreeAssetGroupListView(APIView):
|
||||||
@@ -263,7 +268,7 @@ class FreeAssetUploadView(APIView):
|
|||||||
if group is None:
|
if group is None:
|
||||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
response = _upload_asset_to_group(request, team=team, group=group)
|
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)
|
response.data.pop("group", None)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,10 @@ def reference_review_state(asset: Asset, *, require_registered: bool = False) ->
|
|||||||
火山素材 ID 并通过 asset:// 引用。普通直链会被生成接口按真人隐私素材直接拒绝。
|
火山素材 ID 并通过 asset:// 引用。普通直链会被生成接口按真人隐私素材直接拒绝。
|
||||||
"""
|
"""
|
||||||
if not assets_client.is_enabled():
|
if not assets_client.is_enabled():
|
||||||
# 审核整套机制没配置时不能拿它拦人,否则一关审核全平台引用都用不了
|
# 平台自生成资产不依赖用户上传审核;用户上传则必须阻止,不能因为审核服务
|
||||||
|
# 短暂不可用而把历史未审素材带进 Seedance。
|
||||||
|
if asset.source == Asset.Source.UPLOAD:
|
||||||
|
return "unavailable"
|
||||||
return "allowed"
|
return "allowed"
|
||||||
if asset.review_status == "active" and (asset.review_remote_id or not require_registered):
|
if asset.review_status == "active" and (asset.review_remote_id or not require_registered):
|
||||||
return "allowed"
|
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:
|
def wait_upload_review(asset: Asset, *, timeout_s: float = 45.0, interval_s: float = 1.5) -> str:
|
||||||
"""上传素材:立刻送审并等到终态。
|
"""上传素材:立刻送审并等到终态。
|
||||||
|
|
||||||
返回 active / failed / processing(超时仍在审) / allowed(审核未启用)。
|
返回 active / failed / processing(超时仍在审) / unavailable(审核未启用或送审失败)。
|
||||||
审核未配置时不能拦上传,直接当 allowed。
|
|
||||||
|
用户上传的素材不能因为审核服务暂时不可用而被当作已通过。调用方应把
|
||||||
|
unavailable 当作上传未完成处理,既不返回可引用链接,也不允许进入生成链路。
|
||||||
"""
|
"""
|
||||||
if not assets_client.is_enabled():
|
if not assets_client.is_enabled():
|
||||||
return "allowed"
|
return "unavailable"
|
||||||
submit_asset_for_review(asset, force=True)
|
submitted = submit_asset_for_review(asset, force=True)
|
||||||
asset.refresh_from_db()
|
asset.refresh_from_db()
|
||||||
status = asset.review_status or ""
|
status = asset.review_status or ""
|
||||||
if status in ("active", "failed"):
|
if status in ("active", "failed"):
|
||||||
return status
|
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))
|
deadline = time.monotonic() + max(3.0, float(timeout_s))
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
status = poll_asset_review(asset) or ""
|
status = poll_asset_review(asset) or ""
|
||||||
|
|||||||
@@ -20,6 +20,74 @@ def _mk_team(username, team_name):
|
|||||||
return user, team
|
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):
|
class ModelLibraryApiTests(TestCase):
|
||||||
"""模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。"""
|
"""模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。"""
|
||||||
|
|
||||||
@@ -421,7 +489,7 @@ class FreeAssetQuickUploadTests(TestCase):
|
|||||||
storage.public_url.return_value = "http://tos/a.png"
|
storage.public_url.return_value = "http://tos/a.png"
|
||||||
|
|
||||||
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image()}, format="multipart")
|
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)
|
group = FreeAssetGroup.objects.get(team=self.team)
|
||||||
self.assertEqual(group.name, "默认素材")
|
self.assertEqual(group.name, "默认素材")
|
||||||
self.assertEqual(group.remote_group_id, "Group-1")
|
self.assertEqual(group.remote_group_id, "Group-1")
|
||||||
@@ -429,6 +497,8 @@ class FreeAssetQuickUploadTests(TestCase):
|
|||||||
asset = group.assets.get()
|
asset = group.assets.get()
|
||||||
self.assertEqual(asset.asset_type, FreeAsset.Type.IMAGE)
|
self.assertEqual(asset.asset_type, FreeAsset.Type.IMAGE)
|
||||||
self.assertEqual(asset.status, FreeAsset.Status.PROCESSING)
|
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_group.assert_called_once()
|
||||||
create_asset.assert_called_once_with("Group-1", "http://tos/a.png", name="a.png", asset_type=FreeAsset.Type.IMAGE)
|
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))
|
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")
|
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")
|
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(first.status_code, 202)
|
||||||
self.assertEqual(second.status_code, 201)
|
self.assertEqual(second.status_code, 202)
|
||||||
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()
|
||||||
@@ -475,7 +545,7 @@ class FreeAssetQuickUploadTests(TestCase):
|
|||||||
|
|
||||||
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("new.png")}, format="multipart")
|
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())
|
self.assertFalse(FreeAsset.objects.filter(group=old_group).exists())
|
||||||
new_group = FreeAssetGroup.objects.exclude(id=old_group.id).get(team=self.team)
|
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.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 apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||||
|
|
||||||
|
from . import assets_client
|
||||||
from .models import Asset, AssetFile, Model
|
from .models import Asset, AssetFile, Model
|
||||||
|
from .review import wait_upload_review
|
||||||
from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
|
from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
|
||||||
from .storage import TosStorage
|
from .storage import TosStorage
|
||||||
|
|
||||||
@@ -778,7 +780,6 @@ class ModelLibraryViewSet(ModelViewSet):
|
|||||||
class AssetUploadView(APIView):
|
class AssetUploadView(APIView):
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = AssetUploadSerializer(data=request.data)
|
serializer = AssetUploadSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
@@ -793,6 +794,9 @@ class AssetUploadView(APIView):
|
|||||||
object_key=object_key,
|
object_key=object_key,
|
||||||
content_type=upload.content_type or "application/octet-stream",
|
content_type=upload.content_type or "application/octet-stream",
|
||||||
)
|
)
|
||||||
|
# 先持久化,审核服务才能从 TOS 拉取文件;网络审核绝不能放在数据库事务里,
|
||||||
|
# 否则几十秒的轮询会长期锁住请求事务。
|
||||||
|
with transaction.atomic():
|
||||||
asset = Asset.objects.create(
|
asset = Asset.objects.create(
|
||||||
id=asset_id,
|
id=asset_id,
|
||||||
team=team,
|
team=team,
|
||||||
@@ -811,4 +815,31 @@ class AssetUploadView(APIView):
|
|||||||
size_bytes=stored.size_bytes,
|
size_bytes=stored.size_bytes,
|
||||||
is_primary=True,
|
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)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
// processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。
|
// processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Check, ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
|
import { Check, CheckCircle2, ChevronLeft, CircleX, Clock3, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
|
||||||
import { api } from "../../api";
|
import { api } from "../../api";
|
||||||
import { useFileDrop } from "../use-file-drop";
|
import { useFileDrop } from "../use-file-drop";
|
||||||
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
|
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
|
||||||
import { useBodyScrollLock } from "../overlays";
|
import { useBodyScrollLock } from "../overlays";
|
||||||
|
|
||||||
const STATUS_PILL: Record<FreeAssetItem["status"], { cls: string; label: string }> = {
|
const STATUS_ICON: Record<FreeAssetItem["status"], { cls: string; label: string; Icon: typeof CheckCircle2 }> = {
|
||||||
processing: { cls: "pill-info", label: "审核中" },
|
processing: { cls: "is-processing", label: "审核中,暂不可引用", Icon: Clock3 },
|
||||||
active: { cls: "pill-ok", label: "可用" },
|
active: { cls: "is-active", label: "审核通过,可被引用", Icon: CheckCircle2 },
|
||||||
failed: { cls: "pill-err", label: "未通过" }
|
failed: { cls: "is-failed", label: "审核未通过,无法引用", Icon: CircleX }
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||||
@@ -122,7 +122,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
const data = await api.uploadFreeAsset(activeGroup.id, form);
|
const data = await api.uploadFreeAsset(activeGroup.id, form);
|
||||||
setAssets((prev) => [data.asset, ...prev]);
|
setAssets((prev) => [data.asset, ...prev]);
|
||||||
notify("success", "素材已上传,审核中");
|
notify("info", "素材已提交审核,通过后即可引用");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify("error", error instanceof Error ? error.message : "上传失败");
|
notify("error", error instanceof Error ? error.message : "上传失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -149,7 +149,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
const data = await api.quickUploadFreeAsset(form);
|
const data = await api.quickUploadFreeAsset(form);
|
||||||
setActiveGroup(data.group);
|
setActiveGroup(data.group);
|
||||||
setAssets([data.asset]);
|
setAssets([data.asset]);
|
||||||
notify("success", "图片已上传到默认素材组,审核中");
|
notify("info", "图片已提交审核,通过后即可引用");
|
||||||
void loadGroupDetail(data.group);
|
void loadGroupDetail(data.group);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify("error", error instanceof Error ? error.message : "上传失败");
|
notify("error", error instanceof Error ? error.message : "上传失败");
|
||||||
@@ -443,7 +443,8 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
) : (
|
) : (
|
||||||
<div className="fc-lib-assets">
|
<div className="fc-lib-assets">
|
||||||
{assets.map((item) => {
|
{assets.map((item) => {
|
||||||
const pill = STATUS_PILL[item.status];
|
const statusIcon = STATUS_ICON[item.status];
|
||||||
|
const StatusIcon = statusIcon.Icon;
|
||||||
const isEditing = editingAssetId === item.id;
|
const isEditing = editingAssetId === item.id;
|
||||||
const isDeleting = confirmDeleteAssetId === item.id;
|
const isDeleting = confirmDeleteAssetId === item.id;
|
||||||
return (
|
return (
|
||||||
@@ -458,6 +459,9 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
<div className="fc-lib-thumb">
|
<div className="fc-lib-thumb">
|
||||||
{item.thumb_url ? <img src={item.thumb_url} alt={item.name} /> : <span className="mono">{item.type === "audio" ? "♪" : item.type.toUpperCase()}</span>}
|
{item.thumb_url ? <img src={item.thumb_url} alt={item.name} /> : <span className="mono">{item.type === "audio" ? "♪" : item.type.toUpperCase()}</span>}
|
||||||
{item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
|
{item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
|
||||||
|
<span className={`fc-lib-status-icon ${statusIcon.cls}`} title={statusIcon.label} aria-label={statusIcon.label}>
|
||||||
|
<StatusIcon size={14} strokeWidth={1.8} />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="fc-lib-rename-row" onClick={(event) => event.stopPropagation()}>
|
<div className="fc-lib-rename-row" onClick={(event) => event.stopPropagation()}>
|
||||||
@@ -489,7 +493,6 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
) : (
|
) : (
|
||||||
<div className="fc-lib-name" title={item.name}>{item.name}</div>
|
<div className="fc-lib-name" title={item.name}>{item.name}</div>
|
||||||
)}
|
)}
|
||||||
<span className={`pill pill-l3 fc-lib-status ${pill.cls}`}><span className="dot" />{pill.label}</span>
|
|
||||||
{item.status === "failed" && item.error_message && <div className="fc-lib-err mono" title={item.error_message}>{item.error_message}</div>}
|
{item.status === "failed" && item.error_message && <div className="fc-lib-err mono" title={item.error_message}>{item.error_message}</div>}
|
||||||
<div className="fc-lib-ops">
|
<div className="fc-lib-ops">
|
||||||
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameAsset(item); }}><Pencil size={12} /></button>
|
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameAsset(item); }}><Pencil size={12} /></button>
|
||||||
|
|||||||
@@ -189,6 +189,140 @@ html[data-theme="dark"] .settings-page {
|
|||||||
/* --st-black 已改成浅字色,不能再当选中底用 */
|
/* --st-black 已改成浅字色,不能再当选中底用 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 平台后台:后台壳层保留了一组独立浅色变量,必须从根节点统一接入深色 token。 */
|
||||||
|
html[data-theme="dark"] .admin-app {
|
||||||
|
--st-muted: var(--black-alpha-64);
|
||||||
|
--st-text: var(--accent-black);
|
||||||
|
--st-line: var(--border-faint);
|
||||||
|
--st-wash: var(--background-lighter);
|
||||||
|
--st-hover: var(--black-alpha-4);
|
||||||
|
--st-shadow: none;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-app .topbar {
|
||||||
|
border-color: var(--border-faint);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-rail-mark,
|
||||||
|
html[data-theme="dark"] .admin-group,
|
||||||
|
html[data-theme="dark"] .admin-crumb,
|
||||||
|
html[data-theme="dark"] .admin-crumb button {
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-link {
|
||||||
|
color: var(--black-alpha-64);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-link:hover {
|
||||||
|
color: var(--accent-black);
|
||||||
|
background: var(--black-alpha-4);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-link.active {
|
||||||
|
color: var(--heat);
|
||||||
|
background: var(--heat-12);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-foot {
|
||||||
|
border-color: var(--border-faint);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-user {
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-user .av {
|
||||||
|
color: var(--accent-white);
|
||||||
|
background: var(--heat);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-user-meta .nm {
|
||||||
|
color: var(--accent-black);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-user-meta .rl,
|
||||||
|
html[data-theme="dark"] .admin-logout {
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-logout:hover {
|
||||||
|
color: var(--accent-black);
|
||||||
|
background: var(--black-alpha-4);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-crumb button:hover,
|
||||||
|
html[data-theme="dark"] .admin-crumb span {
|
||||||
|
color: var(--accent-black);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-mode {
|
||||||
|
color: var(--heat);
|
||||||
|
background: var(--heat-12);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-welcome p,
|
||||||
|
html[data-theme="dark"] .admin-group-label span,
|
||||||
|
html[data-theme="dark"] .admin-page .page-head .sub,
|
||||||
|
html[data-theme="dark"] .admin-page .page-head p,
|
||||||
|
html[data-theme="dark"] .admin-page .page-head .sub .mono {
|
||||||
|
color: var(--black-alpha-64);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-entry {
|
||||||
|
border-color: var(--border-faint);
|
||||||
|
color: var(--accent-black);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-entry:hover {
|
||||||
|
border-color: var(--black-alpha-24);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-entry svg:last-child {
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-entry-copy small {
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .tabs-sub {
|
||||||
|
border-color: var(--border-faint);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .tab-sub.active {
|
||||||
|
color: var(--accent-white);
|
||||||
|
background: var(--heat);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-page table.t.admin-table,
|
||||||
|
html[data-theme="dark"] .admin-attempt-list,
|
||||||
|
html[data-theme="dark"] .gov-card,
|
||||||
|
html[data-theme="dark"] .admin-page .card-hard {
|
||||||
|
border-color: var(--border-faint);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-page table.t.admin-table thead th,
|
||||||
|
html[data-theme="dark"] .admin-json,
|
||||||
|
html[data-theme="dark"] .pt-textarea,
|
||||||
|
html[data-theme="dark"] .admin-page .empty-state {
|
||||||
|
border-color: var(--border-faint);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .gov-card.warn {
|
||||||
|
border-color: var(--heat-20);
|
||||||
|
background: var(--heat-12);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .pt-vars code,
|
||||||
|
html[data-theme="dark"] .qw-chip {
|
||||||
|
border-color: var(--heat-20);
|
||||||
|
color: var(--heat);
|
||||||
|
background: var(--heat-12);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-drawer {
|
||||||
|
--st-muted: var(--black-alpha-64);
|
||||||
|
--st-text: var(--accent-black);
|
||||||
|
--st-line: var(--border-faint);
|
||||||
|
--st-wash: var(--background-lighter);
|
||||||
|
border-color: var(--border-faint);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .admin-bulk-bar {
|
||||||
|
border-color: var(--border-faint);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--shadow-floating);
|
||||||
|
}
|
||||||
|
|
||||||
/* 设置侧栏:选中项白底是 --st-black 被深色复用成字色导致 */
|
/* 设置侧栏:选中项白底是 --st-black 被深色复用成字色导致 */
|
||||||
html[data-theme="dark"] .settings-page .st-tab {
|
html[data-theme="dark"] .settings-page .st-tab {
|
||||||
color: rgba(232, 234, 237, 0.72);
|
color: rgba(232, 234, 237, 0.72);
|
||||||
|
|||||||
@@ -936,6 +936,23 @@
|
|||||||
.fc-lib-rename-btn:disabled { opacity: .45; cursor: not-allowed; }
|
.fc-lib-rename-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
.fc-lib-count { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
|
.fc-lib-count { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
|
||||||
.fc-lib-err { font-size: 10.5px; color: var(--accent-crimson); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.fc-lib-err { font-size: 10.5px; color: var(--accent-crimson); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.fc-lib-status-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
}
|
||||||
|
.fc-lib-status-icon.is-active { background: var(--forest-bg); color: var(--accent-forest); border-color: var(--forest-bd); }
|
||||||
|
.fc-lib-status-icon.is-processing { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); }
|
||||||
|
.fc-lib-status-icon.is-failed { background: var(--crimson-bg); color: var(--accent-crimson); border-color: var(--crimson-bd); }
|
||||||
.fc-lib-status {
|
.fc-lib-status {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -966,7 +983,7 @@
|
|||||||
height: 7px;
|
height: 7px;
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
}
|
}
|
||||||
.fc-lib-ops { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; }
|
.fc-lib-ops { position: absolute; top: 14px; left: 14px; display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; }
|
||||||
.fc-lib-group:hover .fc-lib-ops, .fc-lib-asset:hover .fc-lib-ops { opacity: 1; }
|
.fc-lib-group:hover .fc-lib-ops, .fc-lib-asset:hover .fc-lib-ops { opacity: 1; }
|
||||||
.fc-lib-ops button {
|
.fc-lib-ops button {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
|
|||||||
Reference in New Issue
Block a user