火山 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>
288 lines
11 KiB
Python
288 lines
11 KiB
Python
from rest_framework import status
|
||
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from django.contrib.auth import authenticate, get_user_model
|
||
from django.utils import timezone
|
||
from django.db.models import Sum, Count
|
||
|
||
from .serializers import UserSerializer
|
||
from .models import ActiveSession, LoginRecord, get_client_ip, parse_device_type
|
||
from .tokens import SessionRefreshToken
|
||
from django.contrib.auth.hashers import check_password
|
||
|
||
User = get_user_model()
|
||
|
||
|
||
class LoginRateThrottle(ScopedRateThrottle):
|
||
scope = 'login'
|
||
|
||
|
||
@api_view(['POST'])
|
||
@permission_classes([AllowAny])
|
||
def register_view(request):
|
||
"""POST /api/v1/auth/register — disabled, all accounts created by admins."""
|
||
return Response(
|
||
{'error': 'registration_disabled', 'message': '公开注册已关闭,请联系管理员'},
|
||
status=status.HTTP_403_FORBIDDEN,
|
||
)
|
||
|
||
|
||
def _enforce_session_limit(user, device_type):
|
||
"""Enforce concurrent session limits: remove oldest sessions if over limit."""
|
||
from apps.generation.models import QuotaConfig
|
||
config = QuotaConfig.objects.filter(pk=1).first()
|
||
if device_type == 'desktop':
|
||
max_sessions = config.max_desktop_sessions if config else 1
|
||
elif device_type == 'mobile':
|
||
max_sessions = config.max_mobile_sessions if config else 0
|
||
else:
|
||
max_sessions = 1
|
||
|
||
if max_sessions <= 0:
|
||
# 0 means no sessions allowed for this device type — but still allow login
|
||
# (treat as unlimited for unknown device types)
|
||
if device_type == 'unknown':
|
||
return
|
||
# For mobile with limit 0, still allow (no mobile enforcement yet)
|
||
return
|
||
|
||
existing = ActiveSession.objects.filter(
|
||
user=user, device_type=device_type
|
||
).order_by('created_at')
|
||
|
||
# If at or over limit, delete oldest sessions to make room for the new one
|
||
over_count = existing.count() - max_sessions + 1
|
||
if over_count > 0:
|
||
ids_to_remove = list(existing.values_list('id', flat=True)[:over_count])
|
||
ActiveSession.objects.filter(id__in=ids_to_remove).delete()
|
||
|
||
|
||
@api_view(['POST'])
|
||
@permission_classes([AllowAny])
|
||
@throttle_classes([LoginRateThrottle])
|
||
def login_view(request):
|
||
"""POST /api/v1/auth/login"""
|
||
|
||
username = request.data.get('username', '').strip()
|
||
password = request.data.get('password', '')
|
||
|
||
# Try authenticate with username first, then email
|
||
user = authenticate(username=username, password=password)
|
||
if user is None:
|
||
# Try email login
|
||
try:
|
||
user_by_email = User.objects.get(email=username)
|
||
user = authenticate(username=user_by_email.username, password=password)
|
||
except User.DoesNotExist:
|
||
pass
|
||
|
||
if user is None:
|
||
return Response(
|
||
{'error': 'invalid_credentials', 'message': '用户名或密码错误'},
|
||
status=status.HTTP_401_UNAUTHORIZED
|
||
)
|
||
|
||
# Check if user or team is disabled
|
||
if not user.is_active:
|
||
code = 'user_disabled'
|
||
return Response(
|
||
{'code': code, 'message': '您的账号已被禁用,请联系团队管理员'},
|
||
status=status.HTTP_401_UNAUTHORIZED
|
||
)
|
||
if user.team and not user.team.is_active:
|
||
code = 'team_disabled'
|
||
return Response(
|
||
{'code': code, 'message': '您所在的团队已被禁用,请联系平台管理员'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Record login IP and User-Agent
|
||
ip = get_client_ip(request)
|
||
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||
login_record = LoginRecord.objects.create(
|
||
user=user, team=user.team, ip_address=ip, user_agent=user_agent,
|
||
geo_country='', geo_province='', geo_city='', geo_source='',
|
||
)
|
||
|
||
# IP 归属地解析 + 异常检测(不阻塞登录)
|
||
try:
|
||
from utils.geo_client import resolve_ip_location
|
||
country, province, city, source = resolve_ip_location(ip)
|
||
login_record.geo_country = country
|
||
login_record.geo_province = province
|
||
login_record.geo_city = city
|
||
login_record.geo_source = source
|
||
login_record.save(update_fields=['geo_country', 'geo_province', 'geo_city', 'geo_source'])
|
||
|
||
from utils.anomaly_detector import check_login_anomaly, process_anomalies
|
||
anomalies = check_login_anomaly(login_record)
|
||
if anomalies:
|
||
process_anomalies(login_record, anomalies)
|
||
|
||
# 封禁后重新检查(anomaly_detector 可能刚封禁了用户/团队)
|
||
user.refresh_from_db()
|
||
if not user.is_active:
|
||
return Response(
|
||
{'code': 'user_disabled', 'message': '您的账号已被禁用,请联系团队管理员'},
|
||
status=status.HTTP_401_UNAUTHORIZED
|
||
)
|
||
if user.team:
|
||
user.team.refresh_from_db()
|
||
if not user.team.is_active:
|
||
return Response(
|
||
{'code': 'team_disabled', 'message': '您所在的团队已被禁用,请联系平台管理员'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
except Exception:
|
||
import logging
|
||
logging.getLogger(__name__).exception('Anomaly detection failed for login %s', login_record.pk)
|
||
|
||
# Concurrent session management
|
||
device_type = parse_device_type(user_agent)
|
||
_enforce_session_limit(user, device_type)
|
||
session = ActiveSession.objects.create(user=user, device_type=device_type, user_agent=user_agent)
|
||
|
||
refresh = SessionRefreshToken.for_user_session(user, session.session_id)
|
||
return Response({
|
||
'user': UserSerializer(user).data,
|
||
'tokens': {
|
||
'access': str(refresh.access_token),
|
||
'refresh': str(refresh),
|
||
}
|
||
})
|
||
|
||
|
||
@api_view(['POST'])
|
||
@permission_classes([IsAuthenticated])
|
||
def logout_view(request):
|
||
"""POST /api/v1/auth/logout — 清除当前会话,标记用户离线。"""
|
||
session_id = getattr(request, 'session_id', None)
|
||
if session_id:
|
||
ActiveSession.objects.filter(user=request.user, session_id=session_id).delete()
|
||
else:
|
||
# fallback: 清除该用户所有会话
|
||
ActiveSession.objects.filter(user=request.user).delete()
|
||
return Response({'detail': 'ok'})
|
||
|
||
|
||
@api_view(['GET'])
|
||
@permission_classes([IsAuthenticated])
|
||
def me_view(request):
|
||
"""GET /api/v1/auth/me — returns role, team info, and quota."""
|
||
user = request.user
|
||
today = timezone.now().date()
|
||
first_of_month = today.replace(day=1)
|
||
|
||
daily_seconds_used = user.generation_records.filter(
|
||
created_at__date=today
|
||
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||
|
||
monthly_seconds_used = user.generation_records.filter(
|
||
created_at__date__gte=first_of_month
|
||
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||
|
||
# Count-based usage
|
||
daily_generation_used = user.generation_records.filter(
|
||
created_at__date=today
|
||
).count()
|
||
|
||
monthly_generation_used = user.generation_records.filter(
|
||
created_at__date__gte=first_of_month
|
||
).count()
|
||
|
||
data = UserSerializer(user).data
|
||
data['quota'] = {
|
||
'daily_seconds_limit': user.daily_seconds_limit,
|
||
'daily_seconds_used': daily_seconds_used,
|
||
'monthly_seconds_limit': user.monthly_seconds_limit,
|
||
'monthly_seconds_used': monthly_seconds_used,
|
||
'daily_generation_limit': user.daily_generation_limit,
|
||
'daily_generation_used': daily_generation_used,
|
||
'monthly_generation_limit': user.monthly_generation_limit,
|
||
'monthly_generation_used': monthly_generation_used,
|
||
}
|
||
|
||
# Team info
|
||
team = user.team
|
||
if team:
|
||
# Team monthly consumption
|
||
from apps.generation.models import GenerationRecord, QuotaConfig
|
||
team_monthly_used = GenerationRecord.objects.filter(
|
||
user__team=team,
|
||
created_at__date__gte=first_of_month,
|
||
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||
|
||
team_monthly_spent = GenerationRecord.objects.filter(
|
||
user__team=team,
|
||
created_at__date__gte=first_of_month,
|
||
).aggregate(total=Sum('cost_amount'))['total'] or 0
|
||
|
||
config = QuotaConfig.objects.get_or_create(pk=1)[0]
|
||
markup_mult = 1 + float(team.markup_percentage) / 100
|
||
token_price = float(config.base_token_price) * markup_mult
|
||
|
||
data['team'] = {
|
||
'id': team.id,
|
||
'name': team.name,
|
||
'total_seconds_pool': team.total_seconds_pool,
|
||
'total_seconds_used': team.total_seconds_used,
|
||
'remaining_seconds': team.remaining_seconds,
|
||
'monthly_seconds_limit': team.monthly_seconds_limit,
|
||
'monthly_seconds_used': team_monthly_used,
|
||
'balance': float(team.balance),
|
||
'total_spent': float(team.total_spent),
|
||
'available_balance': float(team.available_balance),
|
||
'monthly_spending_limit': float(team.monthly_spending_limit),
|
||
'monthly_spent': float(team_monthly_spent),
|
||
'frozen_amount': float(team.frozen_amount),
|
||
'token_price': token_price,
|
||
'token_price_video': float(config.base_token_price_video) * markup_mult,
|
||
'token_price_fast': float(config.base_token_price_fast) * markup_mult,
|
||
'token_price_fast_video': float(config.base_token_price_fast_video) * markup_mult,
|
||
'token_price_1080p': float(config.base_token_price_1080p) * markup_mult,
|
||
'token_price_1080p_video': float(config.base_token_price_1080p_video) * markup_mult,
|
||
'is_active': team.is_active,
|
||
}
|
||
data['team_disabled'] = not team.is_active
|
||
else:
|
||
data['team'] = None
|
||
data['team_disabled'] = False
|
||
|
||
return Response(data)
|
||
|
||
|
||
@api_view(['POST'])
|
||
@permission_classes([IsAuthenticated])
|
||
def change_password_view(request):
|
||
"""POST /api/v1/auth/change-password — user changes own password."""
|
||
old_password = request.data.get('old_password', '')
|
||
new_password = request.data.get('new_password', '')
|
||
|
||
if not old_password or not new_password:
|
||
return Response(
|
||
{'error': 'missing_fields', 'message': '请填写旧密码和新密码'},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
|
||
if len(new_password) < 8:
|
||
return Response(
|
||
{'error': 'password_too_short', 'message': '新密码至少8位'},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
|
||
if not check_password(old_password, request.user.password):
|
||
return Response(
|
||
{'error': 'wrong_password', 'message': '旧密码错误'},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
|
||
request.user.set_password(new_password)
|
||
request.user.must_change_password = False
|
||
request.user.save(update_fields=['password', 'must_change_password'])
|
||
return Response({
|
||
'message': '密码修改成功',
|
||
'user': UserSerializer(request.user).data,
|
||
})
|