feat(core): 火山人像素材库审核(绿/红盾)接入 + 端到端验证

- assets_client.py:照搬 AirDrama,volcengine SDK + AK/SK 签名(凭证走 settings.ASSETS_API)。
- review.py:真人资产送审/轮询编排。一团队一组(AssetReviewGroup,OneToOne),全 best-effort 不破坏主流程。
- 模型:AssetReviewGroup + Asset.review_status/review_remote_id/review_error(迁移 assets/0003)。
- 集成:真人基础资产生成后 transaction.on_commit 静默送审(services.generate_base_asset kind==person)。
- 端点 poll-reviews + 序列化暴露 review_status;前端基础资产趴每8s轮询、人物卡渲染 审核✓(绿)/审核✗·重生(红)/审核中,审核终态后停轮询。
- e2e 已验:送审→建组→processing→active(真拿到火山绿盾)。

对抗式交叉验证(2审查员,0 critical)修复:
- get_or_create_team_group 并发竞态:DB 唯一约束去重 + select_for_update 串行化远程建组,杜绝重复建组/丢送审。
- create_asset 返回空 Id 不再标 processing(否则卡死黄);processing 超 15 分钟兜底判 failed,未知 Status 记日志,防永久 processing + 无限轮询。

凭证暂借 AirDrama,.env 改 ASSETS_API_* 两行即可换;ASSETS_API_ENABLED=false 可一键关停。回归 18/18 过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-17 10:51:23 +08:00
co-authored by Claude Opus 4.8
parent 521f5f4535
commit b30eaed838
14 changed files with 375 additions and 14 deletions
+8
View File
@@ -205,4 +205,12 @@ PROVIDER_KEYS = {
"tokenssr": env("TOKENSSR_API_KEY", ""),
}
# 火山引擎人像素材库审核(真人资产绿/红盾)· AK/SK 暂借 AirDrama 已邀测账号,张业昌待换成 AirShelf 自有
ASSETS_API = {
"access_key": env("ASSETS_API_ACCESS_KEY", ""),
"secret_key": env("ASSETS_API_SECRET_KEY", ""),
"enabled": str(env("ASSETS_API_ENABLED", "false")).lower() == "true",
"project_name": env("ASSETS_API_PROJECT_NAME", "int_dev_Airlabs"),
}
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000")
+5
View File
@@ -610,6 +610,11 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
group.candidate_assets.add(asset)
group.adopted_asset = asset
group.save(update_fields=["adopted_asset", "updated_at"])
# 真人资产:事务提交后静默送火山审核(best-effort,网络调用放 on_commit 避免占着事务)
if kind == BaseAssetGroup.Kind.PERSON:
from apps.assets.review import submit_asset_for_review
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
return group
except Exception as exc:
task.status = AITask.Status.FAILED
+119
View File
@@ -0,0 +1,119 @@
"""火山引擎人像素材库 Assets API 客户端(AK/SK 签名)。
照搬自 AirDrama,改用 settings.ASSETS_API 凭证(暂借 AirDrama 已邀测账号,张业昌待换)。
同步调用,出错抛 AssetsAPIError。真人(person)基础资产生成后静默上传审核 → 轮询 active(绿)/failed(红)。
"""
import json
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
SERVICE = "ark"
REGION = "cn-beijing"
API_VERSION = "2024-01-01"
HOST = "open.volcengineapi.com"
_ASSETS_ERROR_MESSAGES = {
"ConfigError": "素材审核服务未配置(ASSETS_API_* 凭证)",
"RequestError": "素材审核服务暂时不可用,请稍后重试",
"InvalidParameter": "素材参数无效",
"NotFound": "素材不存在或已被删除",
"Forbidden": "没有权限操作该素材(检查 AK/SK)",
}
class AssetsAPIError(Exception):
def __init__(self, code, message, status_code=400):
self.code = code
self.api_message = message
self.status_code = status_code
self.user_message = _ASSETS_ERROR_MESSAGES.get(code) or "素材审核操作失败,请稍后重试"
super().__init__(f"[{code}] {message}")
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"))
def _project() -> str:
return (getattr(settings, "ASSETS_API", {}) or {}).get("project_name") or "int_dev_Airlabs"
def _get_service():
"""构建带 AK/SK 的 volcengine Service(延迟 import SDK,未装/未配不影响其它功能)。"""
cfg = getattr(settings, "ASSETS_API", {}) or {}
ak, sk = cfg.get("access_key"), cfg.get("secret_key")
if not ak or not sk:
raise AssetsAPIError("ConfigError", "ASSETS_API access_key / secret_key not configured")
from volcengine.ApiInfo import ApiInfo
from volcengine.base.Service import Service
from volcengine.Credentials import Credentials
from volcengine.ServiceInfo import ServiceInfo
service_info = ServiceInfo(
HOST,
{"Accept": "application/json", "Content-Type": "application/json"},
Credentials(ak, sk, SERVICE, REGION),
10, 30,
)
actions = ["CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset"]
api_info = {a: ApiInfo("POST", "/", {"Action": a, "Version": API_VERSION}, {}, {}) for a in actions}
return Service(service_info, api_info)
def _do_request(action: str, body_dict: dict) -> dict:
service = _get_service()
body = json.dumps(body_dict, ensure_ascii=False)
try:
resp = service.json(action, {}, body)
except Exception as e: # noqa: BLE001 — SDK 非 200 抛 Exception(resp.text.encode())
raw = e.args[0] if e.args else ""
error_str = raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
logger.warning("Assets API %s error: %s", action, error_str[:300])
try:
err = json.loads(error_str).get("ResponseMetadata", {}).get("Error", {})
if err:
raise AssetsAPIError(err.get("Code", "Unknown"), err.get("Message", error_str))
except json.JSONDecodeError:
pass
raise AssetsAPIError("RequestError", error_str or "Empty response")
data = json.loads(resp) if isinstance(resp, str) else resp
err = data.get("ResponseMetadata", {}).get("Error", {})
if err:
raise AssetsAPIError(err.get("Code", "Unknown"), err.get("Message", str(data)))
return data.get("Result", {})
def create_asset_group(name: str, description: str = "", group_type: str = "AIGC") -> str:
"""建素材组,返回远程 group id。"""
return _do_request(
"CreateAssetGroup",
{"Name": name, "Description": description, "GroupType": group_type, "ProjectName": _project()},
).get("Id", "")
def create_asset(group_id: str, image_url: str, name: str = "", asset_type: str = "Image") -> str:
"""往组里传一张素材(URL),返回远程 asset id;审核异步,稍后 get_asset 查状态。"""
return _do_request(
"CreateAsset",
{"GroupId": group_id, "URL": image_url, "Name": name, "AssetType": asset_type, "ProjectName": _project()},
).get("Id", "")
def get_asset(asset_id: str) -> dict:
"""查单个素材详情(含审核 Status:Active/Failed/Processing、Url、ErrorMessage)。"""
return _do_request("GetAsset", {"Id": asset_id, "ProjectName": _project()})
def list_asset_groups(page: int = 1, page_size: int = 20, name: str | None = None) -> tuple:
filter_dict = {"GroupType": "AIGC"}
if name:
filter_dict["Name"] = name
result = _do_request(
"ListAssetGroups",
{"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()},
)
return result.get("Items", []), result.get("TotalCount", 0)
@@ -0,0 +1,45 @@
# Generated by Django 5.1.15 on 2026-06-17 02:28
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_loginsession_userpreference'),
('assets', '0002_initial'),
]
operations = [
migrations.AddField(
model_name='asset',
name='review_error',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='asset',
name='review_remote_id',
field=models.CharField(blank=True, max_length=128),
),
migrations.AddField(
model_name='asset',
name='review_status',
field=models.CharField(blank=True, max_length=16),
),
migrations.CreateModel(
name='AssetReviewGroup',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('remote_group_id', models.CharField(max_length=128)),
('name', models.CharField(blank=True, max_length=128)),
('team', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review_group', to='accounts.team')),
],
options={
'abstract': False,
},
),
]
+15
View File
@@ -40,11 +40,26 @@ class Asset(TeamOwnedModel):
)
metadata = models.JSONField(default=dict, blank=True)
is_deleted = models.BooleanField(default=False)
# 火山人像素材库审核(仅真人 person 资产):"" = 未送审 / processing 审核中 / active 绿盾通过 / failed 红标(需改提示词重生)
review_status = models.CharField(max_length=16, blank=True)
review_remote_id = models.CharField(max_length=128, blank=True)
review_error = models.TextField(blank=True)
def __str__(self) -> str:
return self.name
class AssetReviewGroup(TimeStampedModel):
"""一团队一火山人像素材组(真人资产审核统一上传到这里,单组可放 500 万)。"""
team = models.OneToOneField("accounts.Team", on_delete=models.CASCADE, related_name="review_group")
remote_group_id = models.CharField(max_length=128)
name = models.CharField(max_length=128, blank=True)
def __str__(self) -> str:
return f"{self.team_id}:{self.remote_group_id}"
class AssetFile(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="files")
object_key = models.CharField(max_length=512)
+119
View File
@@ -0,0 +1,119 @@
"""火山人像素材库审核编排:真人资产静默送审 + 轮询绿/红状态。
策略(用户定):一团队一素材组,后台静默上传,前端只显示绿盾(active)/红标(failed,提示改提示词重生)。
全部 best-effort:审核未启用/出错都不影响主流程(生图/采用照常)。
"""
import logging
from datetime import timedelta
from django.db import IntegrityError, transaction
from django.utils import timezone
from apps.assets import assets_client
from apps.assets.models import Asset, AssetReviewGroup
from apps.assets.storage import TosStorage
logger = logging.getLogger(__name__)
# processing 超过此时长仍无终态 → 判超时失败,停止再查(防永久 processing + 无限轮询)
_PROCESSING_TIMEOUT = timedelta(minutes=15)
# 火山 Status → 本地 review_status
_STATUS_MAP = {"Active": "active", "Failed": "failed", "Processing": "processing", "Pending": "processing"}
def _asset_url(asset: Asset) -> str:
"""资产主图可公开访问 URL(火山要从 URL 抓图;TOS 签名 URL 即可,会立即抓取)。"""
f = asset.files.filter(is_primary=True).first() or asset.files.first()
if f is None:
return ""
if f.preview_url:
return f.preview_url
try:
return TosStorage().presigned_get_url(object_key=f.object_key)
except Exception: # noqa: BLE001
return ""
def get_or_create_team_group(team) -> AssetReviewGroup:
"""取/建团队的火山素材组(一团队一组)。并发安全:
① DB 行靠 OneToOne 唯一约束去重(只有一个赢,输的 catch 后复用);
② 远程建组用 select_for_update 串行化,只有第一个把 remote_group_id 从空写非空的那次才真建组,
避免并发双建远程组(孤儿)+ 丢送审。"""
grp = AssetReviewGroup.objects.filter(team=team).first()
if grp and grp.remote_group_id:
return grp
name = f"airshelf-team-{team.id}"
if grp is None:
try:
grp = AssetReviewGroup.objects.create(team=team, name=name, remote_group_id="")
except IntegrityError:
grp = AssetReviewGroup.objects.get(team=team)
if not grp.remote_group_id:
with transaction.atomic():
locked = AssetReviewGroup.objects.select_for_update().get(pk=grp.pk)
if not locked.remote_group_id:
locked.remote_group_id = assets_client.create_asset_group(name=name, description="AirShelf 真人素材审核")
locked.save(update_fields=["remote_group_id"])
grp = locked
return grp
def submit_asset_for_review(asset: Asset) -> None:
"""真人资产静默送审:建组(若无)→ 传素材 → 标 processing。出错只记日志,不抛。"""
if not assets_client.is_enabled() or asset.category != Asset.Category.PERSON:
return
url = _asset_url(asset)
if not url:
return
try:
grp = get_or_create_team_group(asset.team)
remote_id = assets_client.create_asset(group_id=grp.remote_group_id, image_url=url, name=(asset.name or "person")[:64])
if not remote_id:
# 火山没回 Id:不要标 processing(否则 remote_id 为空、poll 永远早退、卡死黄),留空可重试
logger.warning("create_asset 返回空 id,asset %s 暂不送审(可重试)", asset.id)
return
asset.review_remote_id = remote_id
asset.review_status = "processing"
asset.review_error = ""
asset.save(update_fields=["review_remote_id", "review_status", "review_error", "updated_at"])
except Exception as exc: # noqa: BLE001
logger.warning("submit_asset_for_review failed for asset %s: %s", asset.id, exc)
def poll_asset_review(asset: Asset) -> str:
"""查单个真人资产审核状态并更新 review_status。返回最新状态。
只在状态变化时落库(保留 updated_at 作为「进入 processing 的时刻」);processing 超时兜底为 failed。"""
if not assets_client.is_enabled() or not asset.review_remote_id:
return asset.review_status
# 超时兜底:processing 太久(火山卡住 / remote_id 失效每次抛错)→ 判失败,退出永久轮询
if asset.review_status == "processing" and asset.updated_at and (timezone.now() - asset.updated_at) > _PROCESSING_TIMEOUT:
asset.review_status = "failed"
asset.review_error = "审核超时,请重新生成"
asset.save(update_fields=["review_status", "review_error", "updated_at"])
return "failed"
try:
data = assets_client.get_asset(asset.review_remote_id)
raw = data.get("Status")
status = _STATUS_MAP.get(raw)
if status is None:
logger.warning("未知审核 Status %r(asset %s),暂按 processing 处理", raw, asset.id)
status = "processing"
if status != asset.review_status: # 只在变化时写,避免每次 poll 刷新 updated_at 让超时永不触发
asset.review_status = status
asset.review_error = (data.get("ErrorMessage") or "") if status == "failed" else ""
asset.save(update_fields=["review_status", "review_error", "updated_at"])
except Exception as exc: # noqa: BLE001
logger.warning("poll_asset_review failed for asset %s: %s", asset.id, exc)
return asset.review_status
def poll_team_reviews(team) -> dict:
"""轮询该团队所有「审核中」真人资产,更新状态。返回 {asset_id: status}。"""
out: dict[str, str] = {}
pending = Asset.objects.filter(
team=team, category=Asset.Category.PERSON, review_status="processing", is_deleted=False
)
for asset in pending:
out[str(asset.id)] = poll_asset_review(asset)
return out
+3 -1
View File
@@ -72,10 +72,12 @@ class AssetSerializer(serializers.ModelSerializer):
"is_deleted",
"origin_task",
"files",
"review_status",
"review_error",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
read_only_fields = ["id", "review_status", "review_error", "created_at", "updated_at"]
class AssetUploadSerializer(serializers.Serializer):
+8
View File
@@ -234,6 +234,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
promote_base_asset_stage_if_ready(project)
return Response(BaseAssetGroupSerializer(group).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="poll-reviews")
def poll_reviews(self, request, pk=None):
"""轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed)。前端基础资产趴定时调,刷新徽章。"""
project = self.get_object()
from apps.assets.review import poll_team_reviews
return Response({"reviews": poll_team_reviews(project.team)})
@action(detail=True, methods=["post"], url_path="adopt-base-asset")
@transaction.atomic
def adopt_base_asset(self, request, pk=None):
+1
View File
@@ -7,6 +7,7 @@ PyMySQL>=1.1,<2.0
python-dotenv>=1.0,<2.0
boto3>=1.34,<2.0
requests>=2.31,<3.0
volcengine>=1.0.100 # 火山引擎 SDK:人像素材库审核(AK/SK 签名)
gunicorn>=21.2,<23.0
whitenoise>=6.6,<7.0