seaislee1209 ae0e2d4365
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m56s
fix: 素材库引用缩略图烂图 + 火山跨项目素材同步脚本
- MentionTag/createMentionSpan/VideoDetailModal: img onError fallback,缩略图加载失败显示占位图标
- buildReferenceSnapshots: 素材库引用用 thumb_url 做 previewUrl,不再过滤
- isAssetRef 标记防止视频缩略图被 <video> 标签渲染、重新编辑时防重复
- sync_volcano_assets management command: 从火山 default 项目同步素材到本地 DB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:31:25 +08:00

151 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
从火山 default 项目同步素材组+素材到本地数据库。
用法:
python manage.py sync_volcano_assets --team-id 1 [--dry-run]
python manage.py sync_volcano_assets --team-id 1 --group-ids group-xxx,group-yyy
"""
import logging
from django.core.management.base import BaseCommand
from apps.accounts.models import Team
from apps.generation.models import AssetGroup, Asset
from utils.assets_client import _do_request
logger = logging.getLogger(__name__)
# 火山素材所在的项目名
SOURCE_PROJECT = 'default'
def fetch_groups_from_volcano(group_ids=None, page_size=50):
"""从火山 default 项目拉取资源组列表。"""
body = {
'Filter': {'GroupType': 'AIGC'},
'PageNumber': 1,
'PageSize': page_size,
'ProjectName': SOURCE_PROJECT,
}
if group_ids:
body['Filter']['GroupIds'] = group_ids
result = _do_request('ListAssetGroups', body)
return result.get('Items', [])
def fetch_assets_from_volcano(group_id):
"""从火山 default 项目拉取某组下的所有素材。"""
body = {
'Filter': {'GroupType': 'AIGC', 'GroupIds': [group_id]},
'PageNumber': 1,
'PageSize': 100,
'ProjectName': SOURCE_PROJECT,
}
result = _do_request('ListAssets', body)
return result.get('Items', [])
class Command(BaseCommand):
help = '从火山 default 项目同步素材组和素材到本地数据库'
def add_arguments(self, parser):
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--team-id', type=int, help='绑定到的团队 ID')
group.add_argument('--team-name', type=str, help='绑定到的团队名称')
parser.add_argument('--group-ids', type=str, default='', help='指定要同步的组 ID逗号分隔留空同步全部')
parser.add_argument('--dry-run', action='store_true', help='只打印不写入数据库')
def handle(self, *args, **options):
dry_run = options['dry_run']
group_ids_str = options['group_ids'].strip()
if options['team_id']:
try:
team = Team.objects.get(pk=options['team_id'])
except Team.DoesNotExist:
self.stderr.write(f'团队 ID {options["team_id"]} 不存在')
return
else:
try:
team = Team.objects.get(name=options['team_name'])
except Team.DoesNotExist:
self.stderr.write(f'团队「{options["team_name"]}」不存在')
return
self.stdout.write(f'目标团队: {team.name} (ID={team_id})')
if dry_run:
self.stdout.write('** DRY RUN — 不会写入数据库 **')
# 拉取火山资源组
group_ids = [g.strip() for g in group_ids_str.split(',') if g.strip()] if group_ids_str else None
volcano_groups = fetch_groups_from_volcano(group_ids=group_ids)
self.stdout.write(f'从火山 {SOURCE_PROJECT} 项目拉取到 {len(volcano_groups)} 个资源组')
created_groups = 0
created_assets = 0
skipped_groups = 0
skipped_assets = 0
for vg in volcano_groups:
remote_gid = vg.get('Id', '')
group_name = vg.get('Name', '') or remote_gid
# 检查本地是否已存在
existing = AssetGroup.objects.filter(remote_group_id=remote_gid, team=team).first()
if existing:
self.stdout.write(f' [跳过] 组 {group_name} ({remote_gid}) — 本地已存在 (pk={existing.pk})')
skipped_groups += 1
local_group = existing
else:
self.stdout.write(f' [新增] 组 {group_name} ({remote_gid})')
if not dry_run:
local_group = AssetGroup.objects.create(
team=team,
remote_group_id=remote_gid,
name=group_name,
description=vg.get('Description', ''),
)
else:
local_group = None
created_groups += 1
# 拉取该组下的素材
volcano_assets = fetch_assets_from_volcano(remote_gid)
self.stdout.write(f'{len(volcano_assets)} 个素材')
for va in volcano_assets:
remote_aid = va.get('Id', '')
asset_name = va.get('Name', '') or remote_aid
asset_type = va.get('AssetType', 'Image')
status_map = {'Active': 'active', 'Processing': 'processing', 'Failed': 'failed'}
local_status = status_map.get(va.get('Status', ''), 'processing')
preview_url = va.get('PreviewUrl', '') or ''
# 检查本地是否已存在
if local_group and Asset.objects.filter(remote_asset_id=remote_aid, group=local_group).exists():
self.stdout.write(f' [跳过] 素材 {asset_name} ({remote_aid})')
skipped_assets += 1
continue
self.stdout.write(f' [新增] 素材 {asset_name} | {asset_type} | {local_status} | {remote_aid}')
if not dry_run and local_group:
Asset.objects.create(
group=local_group,
remote_asset_id=remote_aid,
name=asset_name,
url=preview_url,
asset_type=asset_type,
status=local_status,
)
created_assets += 1
# 更新组缩略图(取第一张图片素材的 URL
if not dry_run:
for ag in AssetGroup.objects.filter(team=team, thumbnail_url=''):
first_img = ag.assets.filter(asset_type='Image', status='active').first()
if first_img and first_img.url:
ag.thumbnail_url = first_img.url
ag.save(update_fields=['thumbnail_url'])
self.stdout.write(f' [更新缩略图] {ag.name}')
self.stdout.write('')
self.stdout.write(f'完成! 新增 {created_groups} 组 + {created_assets} 素材,跳过 {skipped_groups} 组 + {skipped_assets} 素材')