From b30eaed8382048b8f93a89237253f659fdef92ad Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Wed, 17 Jun 2026 10:49:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E7=81=AB=E5=B1=B1=E4=BA=BA?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E6=9D=90=E5=BA=93=E5=AE=A1=E6=A0=B8(?= =?UTF-8?q?=E7=BB=BF/=E7=BA=A2=E7=9B=BE)=E6=8E=A5=E5=85=A5=20+=20=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CLAUDE.md | 3 +- core/backend/airshelf/settings/base.py | 8 ++ core/backend/apps/ai/services.py | 5 + core/backend/apps/assets/assets_client.py | 119 ++++++++++++++++++ .../assets/migrations/0003_asset_review.py | 45 +++++++ core/backend/apps/assets/models.py | 15 +++ core/backend/apps/assets/review.py | 119 ++++++++++++++++++ core/backend/apps/assets/serializers.py | 4 +- core/backend/apps/projects/views.py | 8 ++ core/backend/requirements.txt | 1 + core/frontend/src/api.ts | 4 + core/frontend/src/routes/pipeline.tsx | 32 +++++ core/frontend/src/types.ts | 3 + 交接-AI生成Agent化-2026-06-17.md | 23 ++-- 14 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 core/backend/apps/assets/assets_client.py create mode 100644 core/backend/apps/assets/migrations/0003_asset_review.py create mode 100644 core/backend/apps/assets/review.py diff --git a/CLAUDE.md b/CLAUDE.md index 39669f8..250dff9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,8 @@ ### 测试 / 凭证 - 跑测试用 `DB_ENGINE=sqlite`(远程库无建库权限);测试 setUp 会停用 seed 的中转站文本模型保证命中可 mock 的 VolcanoArkProvider。 -- `.env` 已含 tokenssr/飞书/火山审核(借 AirDrama AK/SK,**待张业昌换**)/豆包TTS。🔴 火山人像素材库审核(绿/红盾)= 设计完成未接线,交接文档 §6 有照搬 AirDrama 步骤。 +- `.env` 已含 tokenssr/飞书/火山审核(借 AirDrama AK/SK,**待张业昌换**)/豆包TTS。 +- **火山人像素材库审核(绿/红盾)已接入并验证**:真人(person)基础资产生成后 `transaction.on_commit` 静默送审([assets/review.py](core/backend/apps/assets/review.py) + [assets_client.py](core/backend/apps/assets/assets_client.py),一团队一组 `AssetReviewGroup`);`Asset.review_status`(processing/active绿/failed红);端点 `poll-reviews` 前端基础资产趴每 8s 轮询刷新徽章。e2e 已拿到真绿盾。 ## 文件操作 diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 1fb6932..7e549e2 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -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") diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 7ee2354..22475db 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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 diff --git a/core/backend/apps/assets/assets_client.py b/core/backend/apps/assets/assets_client.py new file mode 100644 index 0000000..77528d9 --- /dev/null +++ b/core/backend/apps/assets/assets_client.py @@ -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) diff --git a/core/backend/apps/assets/migrations/0003_asset_review.py b/core/backend/apps/assets/migrations/0003_asset_review.py new file mode 100644 index 0000000..d38688b --- /dev/null +++ b/core/backend/apps/assets/migrations/0003_asset_review.py @@ -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, + }, + ), + ] diff --git a/core/backend/apps/assets/models.py b/core/backend/apps/assets/models.py index b758766..0a83294 100644 --- a/core/backend/apps/assets/models.py +++ b/core/backend/apps/assets/models.py @@ -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) diff --git a/core/backend/apps/assets/review.py b/core/backend/apps/assets/review.py new file mode 100644 index 0000000..74ca02a --- /dev/null +++ b/core/backend/apps/assets/review.py @@ -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 diff --git a/core/backend/apps/assets/serializers.py b/core/backend/apps/assets/serializers.py index 9f674c7..189fc0e 100644 --- a/core/backend/apps/assets/serializers.py +++ b/core/backend/apps/assets/serializers.py @@ -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): diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 79567d9..57167b9 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -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): diff --git a/core/backend/requirements.txt b/core/backend/requirements.txt index 9313837..28eafc6 100644 --- a/core/backend/requirements.txt +++ b/core/backend/requirements.txt @@ -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 diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 6e507ac..5172443 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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 }>(`/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) }); }, diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 3548466..75d7933 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -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>({}); + 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; + 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 ? 已采用 : 待采用} + {/* 火山真人审核绿/红盾(仅人物已采用资产) */} + {kind === "person" && group.adopted_asset ? (() => { + const rs = assetReview(group.adopted_asset); + if (rs === "active") return 审核✓; + if (rs === "failed") return 审核✗·重生; + if (rs === "processing") return 审核中; + return null; + })() : null} {/* 行38 · 可编辑提示词:改完点重跑/替换据此生成 */}