video-shuoshan/backend/utils/tos_client.py
seaislee1209 9a6d95a69d
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m13s
fix: v0.18.0 商业级加固 — 并发安全、流式上传、错误反馈、类型修复
- 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>
2026-04-04 18:49:08 +08:00

133 lines
4.1 KiB
Python

"""Volcano Engine TOS file upload utility using official TOS SDK."""
import hashlib
import uuid
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
CONTENT_TYPE_MAP = {
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
'webp': 'image/webp', 'gif': 'image/gif', 'bmp': 'image/bmp',
'tiff': 'image/tiff',
'mp4': 'video/mp4', 'mov': 'video/quicktime',
'mp3': 'audio/mpeg', 'wav': 'audio/wav',
}
_client = None
def get_tos_client():
import tos
global _client
if _client is None:
endpoint = settings.TOS_ENDPOINT.replace('https://', '').replace('http://', '')
_client = tos.TosClientV2(
ak=settings.TOS_ACCESS_KEY,
sk=settings.TOS_SECRET_KEY,
endpoint=endpoint,
region=settings.TOS_REGION,
)
return _client
def upload_file(file_obj, folder='uploads'):
"""Upload a file to TOS bucket with content-hash dedup, return its public URL.
Uses MD5 hash of file content as the object key. If the same file
has already been uploaded, the existing URL is returned without
re-uploading, saving storage and bandwidth.
"""
ext = file_obj.name.rsplit('.', 1)[-1].lower()
content_type = CONTENT_TYPE_MAP.get(ext, 'application/octet-stream')
client = get_tos_client()
content = file_obj.read()
# Use content hash as key for dedup
content_hash = hashlib.sha256(content).hexdigest()
key = f'{folder}/{content_hash}.{ext}'
url = f'{settings.TOS_CDN_DOMAIN}/{key}'
# Check if object already exists — skip upload if so
try:
client.head_object(bucket=settings.TOS_BUCKET, key=key)
logger.info('TOS dedup hit: %s', key)
return url
except Exception as e:
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(
bucket=settings.TOS_BUCKET,
key=key,
content=content,
content_type=content_type,
)
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'):
"""Download a file from a URL and upload to TOS, return permanent CDN URL."""
import requests as req
resp = req.get(source_url, timeout=120, stream=True)
resp.raise_for_status()
content = resp.content
content_type = resp.headers.get('Content-Type', 'video/mp4')
ext = 'mp4' # Seedance always returns mp4
key = f'{folder}/{uuid.uuid4().hex}.{ext}'
client = get_tos_client()
client.put_object(
bucket=settings.TOS_BUCKET,
key=key,
content=content,
content_type=content_type,
)
return f'{settings.TOS_CDN_DOMAIN}/{key}'