video-shuoshan/backend/tests/test_1080p_billing.py
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

209 lines
8.9 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.

"""
1080P 分辨率支持的计费逻辑测试 — 严格对齐用户三原则:
1. 不兜底/静默降级
2. 钱的计算绝对准确(纯官方公式)
3. 不隐藏 bug非法组合 fail loud
运行方式:
cd backend && source venv/Scripts/activate && python -m pytest tests/test_1080p_billing.py -v
或 Django test runner
python manage.py test tests.test_1080p_billing
"""
import os
import sys
import django
# Django setup
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
django.setup()
import unittest
from utils.billing import (
RESOLUTION_MAP,
get_resolution,
estimate_tokens,
calculate_cost,
calculate_base_cost,
)
class TestResolutionMap(unittest.TestCase):
"""验证 RESOLUTION_MAP 的 18 个组合像素值与官方文档一致。"""
def test_1080p_pixels(self):
# 来自 docs/API文档/创建视频生成任务API.md Seedance 2.0 & 2.0 fast 列
self.assertEqual(get_resolution('16:9', '1080p'), (1920, 1080))
self.assertEqual(get_resolution('9:16', '1080p'), (1080, 1920))
self.assertEqual(get_resolution('4:3', '1080p'), (1664, 1248))
self.assertEqual(get_resolution('1:1', '1080p'), (1440, 1440))
self.assertEqual(get_resolution('3:4', '1080p'), (1248, 1664))
# 21:9 特别注意:是 2206×946不是 seedance 1.0 的 2176×928
self.assertEqual(get_resolution('21:9', '1080p'), (2206, 946))
def test_720p_pixels(self):
self.assertEqual(get_resolution('16:9', '720p'), (1280, 720))
self.assertEqual(get_resolution('21:9', '720p'), (1470, 630))
def test_480p_pixels(self):
self.assertEqual(get_resolution('16:9', '480p'), (864, 496))
self.assertEqual(get_resolution('21:9', '480p'), (992, 432))
def test_invalid_combo_raises(self):
"""原则 1非法组合必须 fail loud不静默降级。"""
with self.assertRaises(KeyError):
get_resolution('adaptive', '720p') # adaptive 不在 map
with self.assertRaises(KeyError):
get_resolution('16:9', '4K') # 不存在的 tier
with self.assertRaises(KeyError):
get_resolution('unknown', 'unknown')
def test_tier_required(self):
"""tier 参数必填,不允许默认 720p 静默降级。"""
with self.assertRaises(TypeError):
get_resolution('16:9') # type: ignore - 故意漏参数
class TestEstimateTokens(unittest.TestCase):
"""
严格对齐官方公式:`(输入视频时长+输出时长) ××× 帧率 / 1024`
预估端不做最低 token 修正(那是火山计费侧逻辑)。
"""
def test_formula_no_input_video(self):
# 720P 16:9 (1280×720), 5s 输出, 24fps
# 1280 × 720 × 24 × 5 / 1024 = 108000
self.assertEqual(estimate_tokens(1280, 720, 5), 108000)
def test_formula_with_input_video(self):
# 720P 16:9, 5s 输出 + 5s 输入 = 10s 总时长
# 1280 × 720 × 24 × 10 / 1024 = 216000
self.assertEqual(estimate_tokens(1280, 720, 5, input_video_duration=5), 216000)
def test_1080p_formula(self):
# 1080P 16:9 (1920×1080), 5s 输出, 无输入视频
# 1920 × 1080 × 24 × 5 / 1024 = 243000
self.assertEqual(estimate_tokens(1920, 1080, 5), 243000)
def test_1080p_with_input_video(self):
# 1080P 16:9, 5s 输出 + 2s 输入 = 7s
# 1920 × 1080 × 24 × 7 / 1024 = 340200
self.assertEqual(estimate_tokens(1920, 1080, 5, input_video_duration=2), 340200)
def test_no_silent_min_token_adjustment(self):
"""原则 2预估端严格按公式不做最低 token 修正。
火山文档说 1080p 5s 输入含视频最低 437400 tokens但那是火山计费侧的事
我们预估就老老实实按公式算 (5s+2s)×1920×1080×24/1024 = 340200不擅自拉高。
"""
# 1080p 5s 输出 + 2s 输入 = 7s 总时长
# 公式值 340200官方最低 437400
# 我们应该返回公式值,不主动调到最低值
result = estimate_tokens(1920, 1080, 5, input_video_duration=2)
self.assertEqual(result, 340200, "预估端不应主动修正到火山最低 token")
def test_float_input_duration(self):
"""输入视频时长可能是浮点数(前端 getMediaInfo 读取),要正确累加。"""
# 720P 16:9, 5s 输出 + 3.5s 输入 = 8.5s
# 1280 × 720 × 24 × 8.5 / 1024 = 183600
self.assertEqual(estimate_tokens(1280, 720, 5, input_video_duration=3.5), 183600)
class TestGetTokenPrice(unittest.TestCase):
"""验证单价选择逻辑 — 4 种模型×视频组合 + 1080p 独立单价 + Fast+1080P fail loud。"""
def setUp(self):
# Mock QuotaConfig — 用官方文档默认值
from types import SimpleNamespace
from decimal import Decimal
self.config = SimpleNamespace(
base_token_price=Decimal('46'),
base_token_price_video=Decimal('28'),
base_token_price_fast=Decimal('37'),
base_token_price_fast_video=Decimal('22'),
base_token_price_1080p=Decimal('51'),
base_token_price_1080p_video=Decimal('31'),
)
from apps.generation.views import _get_token_price
self._get_token_price = _get_token_price
def test_seedance_2_0_720p_no_video(self):
"""AirDrama 720P 不含视频 = 46 元/百万 tokens."""
price = self._get_token_price(self.config, 'seedance_2.0', False, '720p')
self.assertEqual(price, 46)
def test_seedance_2_0_720p_with_video(self):
"""AirDrama 720P 含视频 = 28."""
price = self._get_token_price(self.config, 'seedance_2.0', True, '720p')
self.assertEqual(price, 28)
def test_seedance_2_0_480p_same_as_720p(self):
"""480p 和 720p 共享同一单价(官方价格一致)。"""
price_480 = self._get_token_price(self.config, 'seedance_2.0', False, '480p')
price_720 = self._get_token_price(self.config, 'seedance_2.0', False, '720p')
self.assertEqual(price_480, price_720)
def test_seedance_2_0_1080p_no_video(self):
"""AirDrama 1080P 不含视频 = 51独立单价不是 720p 的 46."""
price = self._get_token_price(self.config, 'seedance_2.0', False, '1080p')
self.assertEqual(price, 51)
def test_seedance_2_0_1080p_with_video(self):
"""AirDrama 1080P 含视频 = 31独立单价不是 720p 的 28."""
price = self._get_token_price(self.config, 'seedance_2.0', True, '1080p')
self.assertEqual(price, 31)
def test_fast_720p_no_video(self):
"""Fast 720P 不含视频 = 37."""
price = self._get_token_price(self.config, 'seedance_2.0_fast', False, '720p')
self.assertEqual(price, 37)
def test_fast_720p_with_video(self):
"""Fast 720P 含视频 = 22."""
price = self._get_token_price(self.config, 'seedance_2.0_fast', True, '720p')
self.assertEqual(price, 22)
def test_fast_480p_uses_fast_price(self):
"""Fast 不分 480p/720p都用 fast 单价。"""
price_480 = self._get_token_price(self.config, 'seedance_2.0_fast', False, '480p')
price_720 = self._get_token_price(self.config, 'seedance_2.0_fast', False, '720p')
self.assertEqual(price_480, price_720)
self.assertEqual(price_480, 37)
def test_fast_1080p_raises_value_error(self):
"""原则 1 + 3Fast + 1080P 必须 fail loud不能静默按 720p 价(欺骗用户)。"""
with self.assertRaises(ValueError) as ctx:
self._get_token_price(self.config, 'seedance_2.0_fast', False, '1080p')
self.assertIn('1080p', str(ctx.exception).lower())
with self.assertRaises(ValueError):
self._get_token_price(self.config, 'seedance_2.0_fast', True, '1080p')
class TestCalculateCost(unittest.TestCase):
"""验证扣费金额计算 = tokens × 单价 × (1 + 加价%),精确到分."""
def test_720p_cost_matches_official_example(self):
"""官方示例720P 5s 16:9 = 4.97 元(无加价)."""
# 720p 5s 公式值 108000 tokens
tokens = estimate_tokens(1280, 720, 5)
# 46 元/百万 × 108000 / 1000000 = 4.968 ≈ 4.97
cost = calculate_cost(tokens, 46, 0)
self.assertEqual(str(cost), '4.97')
def test_1080p_no_video_cost(self):
"""1080P 5s 16:9 不含视频 = 1920×1080×24×5/1024 × 51 / 1000000 = 12.393 ≈ 12.39 元."""
tokens = estimate_tokens(1920, 1080, 5)
cost = calculate_cost(tokens, 51, 0)
self.assertEqual(str(cost), '12.39')
def test_markup_applied(self):
"""团队加价 20% 的情况。"""
tokens = estimate_tokens(1280, 720, 5) # 108000
cost = calculate_cost(tokens, 46, 20)
# 4.968 × 1.2 = 5.9616 → 5.96
self.assertEqual(str(cost), '5.96')
if __name__ == '__main__':
unittest.main(verbosity=2)