seaislee1209 39667ff19c feat: v0.19.0 1080P 分辨率支持 — 完整前后端 + 严格计费准确性 + 47 测试
火山 Seedance 2.0 于 2026-04-16 上线 1080P 支持。本次实现前端 UI、
后端校验/计费、数据库迁移,并严格遵守三原则:
1. 禁止兜底/静默降级 — Fast+1080P 组合在 UI/store/serializer/view/计价
   五层防御,任一层穿透都 fail loud,不悄悄按 720P 扣费
2. 钱的计算绝对准确 — 前端预估公式与后端 estimate_tokens 完全一致
   `(输入时长+输出时长) × 宽 × 高 × fps / 1024`;实际扣费按火山返回
   total_tokens × 官方单价;预估端不维护最低 token 修正表
3. 不隐藏 bug — 无 `or '720p'` / `|| '720p'` 兜底;类型严格;异常暴露

## 后端(7 处 + 1 次迁移)

- models.py: QuotaConfig 加 base_token_price_1080p(51)/base_token_price_1080p_video(31);
  GenerationRecord.resolution 加 RESOLUTION_CHOICES 约束 + default='720p'
- migrations/0020: 含 RunPython data migration 回填历史 resolution='' → '720p'
- utils/billing.py:
  * RESOLUTION_MAP 加 1080P 六种宽高比(21:9 是 2206×946,不是 seedance 1.0 值)
  * get_resolution 去掉 tier 默认值,非法组合 raise KeyError 不静默降级
  * estimate_tokens 纯官方公式,加 input_video_duration 参数(公式完整)
- utils/airdrama_client.py: create_task 加 resolution 必填参数(无默认值)
- apps/generation/serializers.py:
  * VideoGenerateSerializer 加 resolution ChoiceField
  * aspect_ratio 改 ChoiceField 显式拒绝 adaptive
  * SystemSettingsSerializer 加 2 个 1080P 单价
- apps/generation/views.py:
  * _get_token_price 加 resolution 必填参数,Fast+1080P raise ValueError
  * _sum_video_duration 累加视频参考时长
  * video_generate_view 读 resolution、400 拒绝 Fast+1080P 组合、
    传给 get_resolution/estimate_tokens/_get_token_price/create_task/
    GenerationRecord.resolution(移除 L450 硬编码 '720p')
  * _settle_payment 按 record.resolution 取单价(1080P 结算按 1080P 价)
  * _serialize_task + 5 处手工序列化加 resolution 字段(无 `or '720p'`)
- apps/accounts/views.py: team 接口返回 token_price_1080p/_video

## 前端(10 处)

- types/index.ts: Resolution 类型;GenerationTask/BackendTask/Team/
  QuotaConfig/AssetVideo 加字段(全部必填,无 optional)
- store/inputBar.ts: resolution state;setModel/setResolution 双向拦截
  Fast+1080P 组合,toast 提示引导,不静默降级
- store/generation.ts: addTask/backendToFrontend/reEdit/regenerate 全链路
  携带 resolution;mapErrorMessage 改 '今日生成次数或团队余额不足'
- components/Toolbar.tsx:
  * 加分辨率选择器 Dropdown(位置:比例和时长之间)
  * modelItems/resolutionItems 双向 disabled(Fast 下 1080P 灰 / 1080P 下 Fast 灰)
  * estimatedTokens 对齐后端公式(含输入视频时长 + assetMentions 视频时长)
  * estimatedCost 按 resolution 选单价(Fast→fast_*、1080p→1080p_*、其他→基础)
  * tooltip 明示"实际费用以火山 API 返回的 token 数为准"
- components/Dropdown.tsx: 加 disabled 属性支持
- components/VideoDetailModal.tsx: 重新编辑恢复 resolution
- components/GenerationCard.tsx: 动态显示 task.resolution.toUpperCase()
- pages/SettingsPage.tsx: 加 2 个 1080P 单价输入框(独立分组)
- pages/AdminAssetsPage.tsx / TeamAssetsPage.tsx: 去 || '720p' 兜底
- lib/api.ts: videoApi.generate 参数 resolution 必填

## 测试(47 个用例)

### 后端(28 个)
- tests/test_1080p_billing.py(23): RESOLUTION_MAP 像素、estimate_tokens
  公式(含/不含输入视频、不做最低 token 修正)、_get_token_price 六种
  组合、Fast+1080P 抛异常、calculate_cost 对齐官方示例 4.97 / 12.39 元
- tests/test_1080p_api.py(5): video_generate_view 拒绝 Fast+1080P (400)
  + 拒绝 adaptive + 拒绝非法 resolution + 默认值兼容 + 合法组合通过

### 前端(19 个)
- test/unit/resolution1080p.test.ts(14): store 状态、双向拦截
  (1080P 下切 Fast 被阻止 model 不变、反向同样)、官方像素契约测试、
  价格示例对齐(720P 4.97 / 1080P 12.39)
- test/e2e/resolution-1080p.spec.ts(5): 真实浏览器验证默认 720P、
  Dropdown 双向置灰、tooltip 明示以火山为准

## 与官方文档对齐

- 参数:resolution (480p/720p/1080p 小写)、ratio、duration、generate_audio
- 像素:来自 docs/API文档/创建视频生成任务API.md Seedance 2.0 & 2.0 fast 列
- 单价:来自 docs/API文档/seedance模型价格.md (46/28/51/31/37/22)
- Fast 不支持 1080P:来自 docs/API文档/Seedance 2.0 1080P.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:06:45 +08:00

180 lines
10 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.

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', '失败'),
]
RESOLUTION_CHOICES = [
('480p', '480P'),
('720p', '720P'),
('1080p', '1080P'),
]
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, choices=RESOLUTION_CHOICES, default='720p', 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)')
base_token_price_1080p = models.DecimalField(max_digits=10, decimal_places=2, default=51, verbose_name='1080P单价-不含视频(元/百万tokens)')
base_token_price_1080p_video = models.DecimalField(max_digits=10, decimal_places=2, default=31, verbose_name='1080P单价-含视频(元/百万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}'