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>
173 lines
9.6 KiB
Python
173 lines
9.6 KiB
Python
import uuid
|
||
from django.db import models
|
||
from django.conf import settings
|
||
|
||
|
||
class GenerationRecord(models.Model):
|
||
"""Video generation call record."""
|
||
MODE_CHOICES = [
|
||
('universal', '全能参考'),
|
||
('keyframe', '首尾帧'),
|
||
]
|
||
MODEL_CHOICES = [
|
||
('seedance_2.0', 'AirDrama'),
|
||
('seedance_2.0_fast', 'AirDrama Fast'),
|
||
]
|
||
STATUS_CHOICES = [
|
||
('queued', '排队中'),
|
||
('processing', '生成中'),
|
||
('completed', '已完成'),
|
||
('failed', '失败'),
|
||
]
|
||
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name='generation_records',
|
||
verbose_name='用户',
|
||
)
|
||
task_id = models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='任务ID')
|
||
ark_task_id = models.CharField(max_length=100, blank=True, default='', verbose_name='火山ARK任务ID')
|
||
prompt = models.TextField(blank=True, verbose_name='提示词')
|
||
mode = models.CharField(max_length=20, choices=MODE_CHOICES, verbose_name='创作模式')
|
||
model = models.CharField(max_length=30, choices=MODEL_CHOICES, verbose_name='模型')
|
||
aspect_ratio = models.CharField(max_length=10, verbose_name='宽高比')
|
||
duration = models.IntegerField(verbose_name='视频时长(秒)')
|
||
seconds_consumed = models.FloatField(default=0, verbose_name='消费秒数')
|
||
# ── 金额计费字段(v0.10.0 新增) ──
|
||
tokens_consumed = models.IntegerField(default=0, verbose_name='消耗tokens')
|
||
cost_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='用户费用(元)')
|
||
base_cost_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='平台成本(元)')
|
||
frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)')
|
||
resolution = models.CharField(max_length=10, blank=True, default='', verbose_name='分辨率')
|
||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态')
|
||
result_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')
|
||
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
|
||
raw_error = models.TextField(blank=True, default='', verbose_name='原始错误信息')
|
||
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
|
||
is_favorited = models.BooleanField(default=False, verbose_name='已收藏')
|
||
is_deleted = models.BooleanField(default=False, verbose_name='用户已删除')
|
||
seed = models.BigIntegerField(default=-1, verbose_name='种子值')
|
||
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')
|
||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||
completed_at = models.DateTimeField(null=True, blank=True, verbose_name='完成时间')
|
||
|
||
class Meta:
|
||
verbose_name = '生成记录'
|
||
verbose_name_plural = '生成记录'
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['user', 'created_at']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f'{self.user.username} - {self.task_id}'
|
||
|
||
|
||
class QuotaConfig(models.Model):
|
||
"""Global quota configuration (singleton) — Phase 3: seconds + announcement + anomaly detection."""
|
||
default_daily_seconds_limit = models.IntegerField(default=600, verbose_name='默认每日秒数上限')
|
||
default_monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='默认每月秒数上限')
|
||
announcement = models.TextField(blank=True, default='', verbose_name='系统公告')
|
||
announcement_enabled = models.BooleanField(default=False, verbose_name='启用公告')
|
||
max_desktop_sessions = models.IntegerField(default=1, verbose_name='每用户最大桌面端会话数')
|
||
max_mobile_sessions = models.IntegerField(default=0, verbose_name='每用户最大移动端会话数')
|
||
# ── 异常检测全局默认配置 ──
|
||
anomaly_detection_enabled = models.BooleanField(default=False, verbose_name='异常检测总开关')
|
||
r1_enabled_default = models.BooleanField(default=True, verbose_name='R1 默认开关')
|
||
r2_enabled_default = models.BooleanField(default=True, verbose_name='R2 默认开关')
|
||
r2_window_seconds = models.IntegerField(default=3600, verbose_name='R2 默认时间窗口(秒)')
|
||
r3_enabled_default = models.BooleanField(default=True, verbose_name='R3 默认开关')
|
||
r3_window_seconds = models.IntegerField(default=3600, verbose_name='R3 默认时间窗口(秒)')
|
||
r3_max_count = models.IntegerField(default=10, verbose_name='R3 默认最大登录次数')
|
||
r4_enabled_default = models.BooleanField(default=True, verbose_name='R4 默认开关')
|
||
r4_window_seconds = models.IntegerField(default=3600, verbose_name='R4 默认时间窗口(秒)')
|
||
r4_city_count = models.IntegerField(default=5, verbose_name='R4 默认预期外城市数')
|
||
r5_enabled_default = models.BooleanField(default=True, verbose_name='R5 默认开关')
|
||
r5_days = models.IntegerField(default=7, verbose_name='R5 默认统计天数')
|
||
r5_country_count = models.IntegerField(default=10, verbose_name='R5 默认海外国家数')
|
||
feishu_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='飞书告警接收人手机号')
|
||
sms_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='短信告警手机号(预留)')
|
||
alert_cooldown_seconds = models.IntegerField(default=1800, verbose_name='告警冷却时间(秒)')
|
||
# ── 计费全局配置(v0.10.0 新增) ──
|
||
default_daily_generation_limit = models.IntegerField(default=50, verbose_name='默认每日生成次数')
|
||
default_monthly_generation_limit = models.IntegerField(default=1500, verbose_name='默认每月生成次数')
|
||
base_token_price = models.DecimalField(max_digits=10, decimal_places=2, default=46, verbose_name='基础token单价-不含视频(元/百万tokens)')
|
||
base_token_price_video = models.DecimalField(max_digits=10, decimal_places=2, default=28, verbose_name='基础token单价-含视频(元/百万tokens)')
|
||
base_token_price_fast = models.DecimalField(max_digits=10, decimal_places=2, default=37, verbose_name='Fast单价-不含视频(元/百万tokens)')
|
||
base_token_price_fast_video = models.DecimalField(max_digits=10, decimal_places=2, default=22, verbose_name='Fast单价-含视频(元/百万tokens)')
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = '系统配置'
|
||
verbose_name_plural = '系统配置'
|
||
|
||
def save(self, *args, **kwargs):
|
||
self.pk = 1
|
||
super().save(*args, **kwargs)
|
||
|
||
def __str__(self):
|
||
return f'全局配额: {self.default_daily_seconds_limit}s/日, {self.default_monthly_seconds_limit}s/月'
|
||
|
||
|
||
class AssetGroup(models.Model):
|
||
"""虚拟人像素材组 — 一个角色对应一个组。"""
|
||
team = models.ForeignKey(
|
||
'accounts.Team', on_delete=models.CASCADE,
|
||
related_name='asset_groups', verbose_name='所属团队',
|
||
)
|
||
remote_group_id = models.CharField(max_length=100, default='', verbose_name='火山Group ID')
|
||
name = models.CharField(max_length=100, default='', verbose_name='角色名')
|
||
description = models.CharField(max_length=300, blank=True, default='', verbose_name='描述')
|
||
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL')
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||
null=True, blank=True, related_name='created_asset_groups', verbose_name='创建人',
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||
|
||
class Meta:
|
||
verbose_name = '素材组'
|
||
verbose_name_plural = '素材组'
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
return f'{self.team.name} - {self.name}'
|
||
|
||
|
||
class Asset(models.Model):
|
||
"""虚拟人像素材 — 图片/视频/音频。"""
|
||
STATUS_CHOICES = [
|
||
('processing', '处理中'),
|
||
('active', '可用'),
|
||
('failed', '失败'),
|
||
]
|
||
ASSET_TYPE_CHOICES = [
|
||
('Image', '图像'),
|
||
('Video', '视频'),
|
||
('Audio', '音频'),
|
||
]
|
||
|
||
group = models.ForeignKey(
|
||
AssetGroup, on_delete=models.CASCADE,
|
||
related_name='assets', verbose_name='所属素材组',
|
||
)
|
||
remote_asset_id = models.CharField(max_length=100, default='', verbose_name='火山Asset ID')
|
||
name = models.CharField(max_length=100, default='', verbose_name='素材名称')
|
||
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='素材类型')
|
||
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL')
|
||
duration = models.FloatField(null=True, default=None, 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='错误信息')
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||
|
||
class Meta:
|
||
verbose_name = '素材'
|
||
verbose_name_plural = '素材'
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
return f'{self.group.name} - {self.name}'
|