fix: v0.18.0 商业级加固 — 并发安全、流式上传、错误反馈、类型修复
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m13s

- TOS 流式上传 upload_from_file_path(避免大文件 OOM)
- 视频生成完成后下载一次复用(TOS 上传 + 首帧提取)
- 并发安全:group thumbnail 用 select_for_update 原子更新
- 跨团队校验:_resolve_asset_group_all 加 group__team 过滤
- 异常信息脱敏:文件上传失败不再泄露内部异常
- SSRF 防护:download_to_temp 校验 URL scheme
- poll lock 终态释放:cache.delete 在 record.save 后调用
- duration=null 语义区分:ffprobe 失败存 None 非 0
- 前端 duration 未知 toast 警告:素材时长未确定时提示用户
- 搜索 API 失败 toast:素材搜索失败时反馈用户
- 视频保存降级标记:临时 URL 降级时设 error_message
- TypeScript 类型修复:AssetItem/AssetSearchResult.duration 改为 number|null
- rebuildMentionSpans 补完 assetId/assetType/assetName/duration 属性
- paste DOMPurify 白名单补完新 data attributes
- resolved_url NameError 修复:非素材库视频/音频引用用 url
- process_asset_media group 删除保护
- download_to_temp 改为 public API
- 清理前端死代码

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-04-04 18:49:08 +08:00
parent 61bcb9576f
commit 9a6d95a69d
9 changed files with 172 additions and 63 deletions

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-04-04 17:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0018_add_thumbnail_and_duration'),
]
operations = [
migrations.AlterField(
model_name='asset',
name='duration',
field=models.FloatField(default=None, null=True, verbose_name='时长(秒)'),
),
]

View File

@ -158,7 +158,7 @@ class Asset(models.Model):
url = models.CharField(max_length=1000, blank=True, default='', verbose_name='素材URL') url = models.CharField(max_length=1000, blank=True, default='', verbose_name='素材URL')
asset_type = models.CharField(max_length=10, choices=ASSET_TYPE_CHOICES, default='Image', verbose_name='素材类型') asset_type = models.CharField(max_length=10, choices=ASSET_TYPE_CHOICES, default='Image', verbose_name='素材类型')
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL') thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL')
duration = models.FloatField(default=0, verbose_name='时长(秒)') duration = models.FloatField(null=True, default=None, verbose_name='时长(秒)')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态')
error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息') error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')

View File

@ -80,6 +80,7 @@ def poll_video_task(self, record_id):
'seed', 'completed_at', 'seed', 'completed_at',
]) ])
cache.delete(lock_key)
logger.info( logger.info(
'poll_video_task: record=%s ark=%s final_status=%s', 'poll_video_task: record=%s ark=%s final_status=%s',
record_id, ark_task_id, new_status, record_id, ark_task_id, new_status,
@ -87,27 +88,35 @@ def poll_video_task(self, record_id):
def _handle_completed(record, ark_resp): def _handle_completed(record, ark_resp):
"""Process a completed task: persist video to TOS and settle payment.""" """Process a completed task: persist video to TOS, extract thumbnail, settle payment."""
import os
from utils.airdrama_client import extract_video_url from utils.airdrama_client import extract_video_url
video_url = extract_video_url(ark_resp) video_url = extract_video_url(ark_resp)
if video_url: if video_url:
# Download once to temp file, reuse for TOS upload + thumbnail extraction
tmp_path = None
try: try:
from utils.tos_client import upload_from_url from utils.media_utils import download_to_temp, extract_video_info_from_file
record.result_url = upload_from_url(video_url, folder='results') from utils.tos_client import upload_from_file_path, upload_file
except Exception:
logger.exception('poll_video_task: failed to persist video to TOS')
record.result_url = video_url
# Extract thumbnail from completed video tmp_path = download_to_temp(video_url, '.mp4')
try:
from utils.media_utils import extract_video_info # Upload video to TOS from file (streaming, no full memory load)
from utils.tos_client import upload_file record.result_url = upload_from_file_path(tmp_path, folder='results', content_type='video/mp4')
thumb_file, _ = extract_video_info(record.result_url)
# Extract thumbnail from the same local file (no second download)
thumb_file, _ = extract_video_info_from_file(tmp_path)
if thumb_file: if thumb_file:
record.thumbnail_url = upload_file(thumb_file, folder='thumbnails') record.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
except Exception: except Exception:
logger.exception('poll_video_task: failed to extract video thumbnail') logger.exception('poll_video_task: failed to persist video / extract thumbnail')
if not record.result_url:
record.result_url = video_url
record.error_message = '视频保存失败临时链接将在24小时后过期请联系管理员'
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
# 结算:按实际 tokens 扣费 # 结算:按实际 tokens 扣费
usage = ark_resp.get('usage', {}) usage = ark_resp.get('usage', {})
@ -187,14 +196,22 @@ def process_asset_media(asset_id):
asset.thumbnail_url = upload_file(thumb_file, folder='thumbnails') asset.thumbnail_url = upload_file(thumb_file, folder='thumbnails')
except Exception: except Exception:
logger.exception('process_asset_media: thumbnail upload failed for asset %s', asset_id) logger.exception('process_asset_media: thumbnail upload failed for asset %s', asset_id)
asset.duration = dur asset.duration = dur if dur > 0 else None # None = ffprobe failed, frontend skips duration check
asset.save(update_fields=['thumbnail_url', 'duration']) asset.save(update_fields=['thumbnail_url', 'duration'])
group = asset.group # Atomic update: only set group thumbnail if still empty (concurrent-safe)
from apps.generation.models import AssetGroup
from django.db import transaction
try:
with transaction.atomic():
group = AssetGroup.objects.select_for_update().get(pk=asset.group_id)
if not group.thumbnail_url and asset.thumbnail_url: if not group.thumbnail_url and asset.thumbnail_url:
group.thumbnail_url = asset.thumbnail_url group.thumbnail_url = asset.thumbnail_url
group.save(update_fields=['thumbnail_url']) group.save(update_fields=['thumbnail_url'])
except AssetGroup.DoesNotExist:
logger.warning('process_asset_media: group %s deleted, skipping thumbnail update', asset.group_id)
elif asset.asset_type == 'Audio': elif asset.asset_type == 'Audio':
asset.duration = get_audio_duration(asset.url) dur = get_audio_duration(asset.url)
asset.duration = dur if dur > 0 else None
asset.save(update_fields=['duration']) asset.save(update_fields=['duration'])
logger.info('process_asset_media: asset %s done (type=%s, dur=%.1f)', asset_id, asset.asset_type, asset.duration) logger.info('process_asset_media: asset %s done (type=%s, dur=%s)', asset_id, asset.asset_type, asset.duration)

View File

@ -425,12 +425,12 @@ def video_generate_view(request):
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
elif ref_type == 'video': elif ref_type == 'video':
item = {'type': 'video_url', 'video_url': {'url': resolved_url}} item = {'type': 'video_url', 'video_url': {'url': url}}
if role: if role:
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
elif ref_type == 'audio': elif ref_type == 'audio':
item = {'type': 'audio_url', 'audio_url': {'url': resolved_url}} item = {'type': 'audio_url', 'audio_url': {'url': url}}
if role: if role:
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
@ -3304,10 +3304,14 @@ def asset_group_add_asset_view(request, group_id):
error_message='', error_message='',
) )
# If first image asset or no thumbnail, set group thumbnail # Atomic: set group thumbnail only if still empty (concurrent-safe)
if not group.thumbnail_url and asset_type == 'Image': if asset_type == 'Image':
group.thumbnail_url = tos_url from django.db import transaction
group.save(update_fields=['thumbnail_url']) with transaction.atomic():
locked_group = AssetGroup.objects.select_for_update().get(pk=group.id)
if not locked_group.thumbnail_url:
locked_group.thumbnail_url = tos_url
locked_group.save(update_fields=['thumbnail_url'])
# Async: extract thumbnail + duration for video/audio # Async: extract thumbnail + duration for video/audio
if asset_type in ('Video', 'Audio'): if asset_type in ('Video', 'Audio'):

View File

@ -1,4 +1,9 @@
"""Media utilities: extract video thumbnails and durations using ffmpeg/ffprobe.""" """Media utilities: extract video thumbnails and durations using ffmpeg/ffprobe.
WARNING: These functions download files and run subprocess commands.
They MUST only be called from Celery tasks, NEVER from HTTP request handlers.
Calling from gunicorn (especially with gevent workers) will block the worker pool.
"""
import logging import logging
import subprocess import subprocess
@ -14,8 +19,12 @@ logger = logging.getLogger(__name__)
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100MB safety limit MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100MB safety limit
def _download_to_temp(url: str, suffix: str) -> str: def download_to_temp(url: str, suffix: str) -> str:
"""Download a URL to a temporary file. Returns the temp file path.""" """Download a URL to a temporary file. Returns the temp file path.
Only accepts http/https URLs to prevent SSRF.
"""
if not url.startswith(('http://', 'https://')):
raise ValueError(f'Invalid URL scheme: {url[:30]}')
resp = requests.get(url, timeout=30, stream=True) resp = requests.get(url, timeout=30, stream=True)
resp.raise_for_status() resp.raise_for_status()
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
@ -65,39 +74,49 @@ def _extract_first_frame(video_path: str, output_path: str) -> bool:
return False return False
def extract_video_info(video_url: str) -> tuple: def extract_video_info_from_file(video_path: str) -> tuple:
"""Extract first frame thumbnail + duration from a video URL. """Extract first frame thumbnail + duration from a local video file.
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float). Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
Does NOT delete the input file caller is responsible for cleanup.
""" """
tmp_video = None
tmp_thumb = None tmp_thumb = None
try: try:
# Determine suffix from URL duration = _get_duration_ffprobe(video_path)
suffix = '.mp4' tmp_thumb = video_path + '_thumb.jpg'
if '.mov' in video_url.lower(): if _extract_first_frame(video_path, tmp_thumb):
suffix = '.mov'
tmp_video = _download_to_temp(video_url, suffix)
# Get duration
duration = _get_duration_ffprobe(tmp_video)
# Extract first frame
tmp_thumb = tmp_video + '_thumb.jpg'
if _extract_first_frame(tmp_video, tmp_thumb):
with open(tmp_thumb, 'rb') as f: with open(tmp_thumb, 'rb') as f:
thumb_file = SimpleUploadedFile( thumb_file = SimpleUploadedFile(
'thumbnail.jpg', f.read(), content_type='image/jpeg' 'thumbnail.jpg', f.read(), content_type='image/jpeg'
) )
return thumb_file, duration return thumb_file, duration
return None, duration return None, duration
except Exception as e:
logger.warning('extract_video_info_from_file failed: %s', e)
return None, 0
finally:
if tmp_thumb and os.path.exists(tmp_thumb):
os.unlink(tmp_thumb)
def extract_video_info(video_url: str) -> tuple:
"""Extract first frame thumbnail + duration from a video URL.
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
NOTE: This function downloads the full video. For large files, call from
Celery tasks only never from HTTP request handlers.
"""
tmp_video = None
try:
suffix = '.mp4'
if '.mov' in video_url.lower():
suffix = '.mov'
tmp_video = download_to_temp(video_url, suffix)
return extract_video_info_from_file(tmp_video)
except Exception as e: except Exception as e:
logger.warning('extract_video_info failed for %s: %s', video_url, e) logger.warning('extract_video_info failed for %s: %s', video_url, e)
return None, 0 return None, 0
finally: finally:
if tmp_video and os.path.exists(tmp_video): if tmp_video and os.path.exists(tmp_video):
os.unlink(tmp_video) os.unlink(tmp_video)
if tmp_thumb and os.path.exists(tmp_thumb):
os.unlink(tmp_thumb)
def get_audio_duration(audio_url: str) -> float: def get_audio_duration(audio_url: str) -> float:
@ -105,7 +124,7 @@ def get_audio_duration(audio_url: str) -> float:
tmp_audio = None tmp_audio = None
try: try:
suffix = '.wav' if '.wav' in audio_url.lower() else '.mp3' suffix = '.wav' if '.wav' in audio_url.lower() else '.mp3'
tmp_audio = _download_to_temp(audio_url, suffix) tmp_audio = download_to_temp(audio_url, suffix)
return _get_duration_ffprobe(tmp_audio) return _get_duration_ffprobe(tmp_audio)
except Exception as e: except Exception as e:
logger.warning('get_audio_duration failed for %s: %s', audio_url, e) logger.warning('get_audio_duration failed for %s: %s', audio_url, e)

View File

@ -56,8 +56,10 @@ def upload_file(file_obj, folder='uploads'):
client.head_object(bucket=settings.TOS_BUCKET, key=key) client.head_object(bucket=settings.TOS_BUCKET, key=key)
logger.info('TOS dedup hit: %s', key) logger.info('TOS dedup hit: %s', key)
return url return url
except Exception: except Exception as e:
pass # Object doesn't exist, proceed with upload err_str = str(e).lower()
if '404' not in err_str and 'not found' not in err_str and 'nosuchkey' not in err_str:
logger.warning('TOS head_object unexpected error (proceeding with upload): %s', e)
client.put_object( client.put_object(
bucket=settings.TOS_BUCKET, bucket=settings.TOS_BUCKET,
@ -69,6 +71,44 @@ def upload_file(file_obj, folder='uploads'):
return url return url
def upload_from_file_path(file_path, folder='uploads', content_type=None):
"""Upload a local file to TOS by path (streaming, no full memory load).
Returns the permanent CDN URL.
"""
ext = file_path.rsplit('.', 1)[-1].lower() if '.' in file_path else 'bin'
if not content_type:
content_type = CONTENT_TYPE_MAP.get(ext, 'application/octet-stream')
# Use content hash for dedup
h = hashlib.sha256()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
h.update(chunk)
content_hash = h.hexdigest()
key = f'{folder}/{content_hash}.{ext}'
url = f'{settings.TOS_CDN_DOMAIN}/{key}'
client = get_tos_client()
try:
client.head_object(bucket=settings.TOS_BUCKET, key=key)
logger.info('TOS dedup hit: %s', key)
return url
except Exception as e:
# Only proceed if object not found (404). Re-raise on auth/config errors.
err_str = str(e).lower()
if '404' not in err_str and 'not found' not in err_str and 'nosuchkey' not in err_str:
logger.warning('TOS head_object unexpected error (proceeding with upload): %s', e)
with open(file_path, 'rb') as f:
client.put_object(
bucket=settings.TOS_BUCKET,
key=key,
content=f,
content_type=content_type,
)
return url
def upload_from_url(source_url, folder='results'): def upload_from_url(source_url, folder='results'):
"""Download a file from a URL and upload to TOS, return permanent CDN URL.""" """Download a file from a URL and upload to TOS, return permanent CDN URL."""
import requests as req import requests as req

View File

@ -188,6 +188,10 @@ export function PromptInput() {
thumbUrl: m.target.thumbUrl, thumbUrl: m.target.thumbUrl,
assetGroupId: m.target.assetGroupId, assetGroupId: m.target.assetGroupId,
groupName: m.target.groupName, groupName: m.target.groupName,
assetId: m.target.assetId,
assetType: m.target.assetType,
assetName: m.target.assetName,
duration: m.target.duration,
}); });
frag.appendChild(span); frag.appendChild(span);
lastIdx = m.end; lastIdx = m.end;
@ -313,7 +317,7 @@ export function PromptInput() {
} else { } else {
setShowMentionPopup(false); setShowMentionPopup(false);
} }
}).catch(() => {}); }).catch(() => { showToast('素材搜索失败,请重试'); });
}, 300); }, 300);
} else if (textAfterAt.includes(' ')) { } else if (textAfterAt.includes(' ')) {
// Space after @ text, close popup // Space after @ text, close popup
@ -389,7 +393,11 @@ export function PromptInput() {
return; return;
} }
// Instant check: duration limit (video/audio) // Instant check: duration limit (video/audio)
if (asset.duration > 0 && (asset.asset_type === 'Video' || asset.asset_type === 'Audio')) { if (asset.asset_type === 'Video' || asset.asset_type === 'Audio') {
if (!asset.duration) {
// Duration unknown (still processing or ffprobe failed) — warn but allow
showToast('该素材时长未确定,提交时将由服务端校验');
} else {
const existingDur = refs.filter((r) => r.type === typeKey && r.duration).reduce((s, r) => s + (r.duration || 0), 0); const existingDur = refs.filter((r) => r.type === typeKey && r.duration).reduce((s, r) => s + (r.duration || 0), 0);
const assetDur = typeKey === 'video' ? stats.durations.video : stats.durations.audio; const assetDur = typeKey === 'video' ? stats.durations.video : stats.durations.audio;
if (existingDur + assetDur + asset.duration > 15.4) { if (existingDur + assetDur + asset.duration > 15.4) {
@ -398,6 +406,7 @@ export function PromptInput() {
return; return;
} }
} }
}
setShowMentionPopup(false); setShowMentionPopup(false);
setMentionMode('references'); setMentionMode('references');
@ -438,7 +447,7 @@ export function PromptInput() {
assetId: String(asset.id), assetId: String(asset.id),
assetType: asset.asset_type, assetType: asset.asset_type,
assetName: asset.name, assetName: asset.name,
duration: String(asset.duration || 0), duration: asset.duration != null ? String(asset.duration) : '',
}); });
range.insertNode(mention); range.insertNode(mention);
@ -504,8 +513,9 @@ export function PromptInput() {
ALLOWED_TAGS: ['span', 'br', 'img'], ALLOWED_TAGS: ['span', 'br', 'img'],
ALLOWED_ATTR: [ ALLOWED_ATTR: [
'class', 'contenteditable', 'data-ref-id', 'data-ref-type', 'class', 'contenteditable', 'data-ref-id', 'data-ref-type',
'data-asset-group-id', 'data-group-name', 'data-thumb-url', 'data-asset-group-id', 'data-group-name',
'draggable', 'src', 'alt', 'width', 'height', 'style', 'data-asset-id', 'data-asset-type', 'data-asset-name', 'data-duration',
'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style',
], ],
}); });
document.execCommand('insertHTML', false, sanitized); document.execCommand('insertHTML', false, sanitized);

View File

@ -13,7 +13,8 @@ export function parseAssetMentions(html: string): {
const doc = parser.parseFromString(html, 'text/html'); const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('[data-ref-type="asset"]').forEach((el) => { doc.querySelectorAll('[data-ref-type="asset"]').forEach((el) => {
const t = (el as HTMLElement).dataset.assetType || 'Image'; const t = (el as HTMLElement).dataset.assetType || 'Image';
const dur = parseFloat((el as HTMLElement).dataset.duration || '0'); const rawDur = parseFloat((el as HTMLElement).dataset.duration || '0');
const dur = isNaN(rawDur) ? 0 : rawDur; // null/undefined → NaN → 0, ffprobe 失败不计入时长
if (t === 'Video') { counts.video++; durations.video += dur; } if (t === 'Video') { counts.video++; durations.video += dur; }
else if (t === 'Audio') { counts.audio++; durations.audio += dur; } else if (t === 'Audio') { counts.audio++; durations.audio += dur; }
else { counts.image++; } else { counts.image++; }

View File

@ -440,7 +440,7 @@ export interface AssetItem {
url: string; url: string;
asset_type: 'Image' | 'Video' | 'Audio'; asset_type: 'Image' | 'Video' | 'Audio';
thumbnail_url: string; thumbnail_url: string;
duration: number; duration: number | null;
status: 'processing' | 'active' | 'failed'; status: 'processing' | 'active' | 'failed';
remote_asset_id: string; remote_asset_id: string;
error_message: string; error_message: string;
@ -455,5 +455,5 @@ export interface AssetSearchResult {
group_name: string; group_name: string;
remote_asset_id: string; remote_asset_id: string;
thumbnail_url: string; thumbnail_url: string;
duration: number; duration: number | null;
} }