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:
co-authored by
Claude Opus 4.8
parent
521f5f4535
commit
b30eaed838
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -304,6 +304,10 @@ export const api = {
|
||||
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
|
||||
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
// 轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed),刷新基础资产趴徽章
|
||||
pollReviews(projectId: string) {
|
||||
return request<{ reviews: Record<string, string> }>(`/api/projects/${projectId}/poll-reviews/`, { method: "POST" });
|
||||
},
|
||||
generateStoryboard(projectId: string, payload: { prompt: string }) {
|
||||
return request(`/api/projects/${projectId}/generate-storyboard/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
|
||||
@@ -544,6 +544,30 @@ export function PipelinePage(props: {
|
||||
const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null;
|
||||
const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : 1);
|
||||
const [navigated, setNavigated] = useState(Boolean(initHash));
|
||||
// 火山人像审核状态(真人资产绿/红盾):本地覆盖 map(轮询刷新),回退 asset.review_status
|
||||
const [reviews, setReviews] = useState<Record<string, string>>({});
|
||||
const assetReview = (id?: string | null) => (id ? (reviews[id] || byId.get(id)?.review_status || "") : "");
|
||||
// 在基础资产趴(stage 2)定时轮询审核状态,刷新徽章(processing → active绿 / failed红)
|
||||
useEffect(() => {
|
||||
if (viewStage !== 2) return;
|
||||
let alive = true;
|
||||
let sawProcessing = false;
|
||||
let timer = 0;
|
||||
const tick = async () => {
|
||||
const r = await api.pollReviews(project.id).catch(() => null);
|
||||
if (!alive) return;
|
||||
const map = (r?.reviews || {}) as Record<string, string>;
|
||||
if (Object.keys(map).length) {
|
||||
sawProcessing = true;
|
||||
setReviews((m) => ({ ...m, ...map }));
|
||||
} else if (sawProcessing) {
|
||||
window.clearInterval(timer); // 审核都终态了,停轮询省空跑(后端无 processing 资产时返回 {})
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
timer = window.setInterval(tick, 8000);
|
||||
return () => { alive = false; window.clearInterval(timer); };
|
||||
}, [viewStage, project.id]);
|
||||
// 外部 hash 变化(浏览器前进/后退、地址栏改 #stage-N)也要切阶段——镜像有 hashchange 监听,这里补齐
|
||||
useEffect(() => {
|
||||
function onHashChange() {
|
||||
@@ -2184,6 +2208,14 @@ export function PipelinePage(props: {
|
||||
{group.adopted_asset
|
||||
? <span className="pill ok"><span className="dot"></span>已采用</span>
|
||||
: <span className="pill neutral"><span className="dot"></span>待采用</span>}
|
||||
{/* 火山真人审核绿/红盾(仅人物已采用资产) */}
|
||||
{kind === "person" && group.adopted_asset ? (() => {
|
||||
const rs = assetReview(group.adopted_asset);
|
||||
if (rs === "active") return <span className="pill ok" title="火山真人审核已通过" style={{ marginLeft: 4 }}><span className="dot"></span>审核✓</span>;
|
||||
if (rs === "failed") return <span className="pill err" title={`审核未过:${byId.get(group.adopted_asset)?.review_error || "建议改提示词重新生成"}`} style={{ marginLeft: 4 }}><span className="dot"></span>审核✗·重生</span>;
|
||||
if (rs === "processing") return <span className="pill neutral" style={{ marginLeft: 4 }}><span className="dot"></span>审核中</span>;
|
||||
return null;
|
||||
})() : null}
|
||||
</div>
|
||||
{/* 行38 · 可编辑提示词:改完点重跑/替换据此生成 */}
|
||||
<textarea
|
||||
|
||||
@@ -67,6 +67,9 @@ export type Asset = {
|
||||
preview_url: string;
|
||||
is_primary: boolean;
|
||||
}>;
|
||||
// 火山人像审核(仅真人 person 资产):"" 未送审 / processing 审核中 / active 绿盾 / failed 红标
|
||||
review_status?: string;
|
||||
review_error?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user