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
+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)