seaislee1209 e2973284d0
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m20s
feat: 账号安全管控 + 内容资产页 + UI修缮 (v0.9.5 & v0.9.6)
v0.9.5 — 账号安全管控 + 内容资产页:
- 首次登录强制改密(must_change_password + ForceChangePasswordModal)
- 并发会话限制(ActiveSession + SessionJWT认证,可配置桌面/移动端会话数)
- Token生命周期缩短(access 30min, refresh 1天)
- 登录IP记录(LoginRecord模型,为异常检测打基础)
- 内容资产页(超管三级折叠/团队管两级折叠,按需懒加载)

v0.9.6 — UI修缮:
- 侧栏导航排序(内容资产移到用户管理下方)
- 视频网格高度调整(440px,3行+暗示可滚动)
- 秒数单位统一(不再换算为分钟/小时)
- 提示词标签溢出修复 + 弹窗方向自适应

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 12:02:54 +08:00

191 lines
6.6 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
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
)
# Record login IP and User-Agent
ip = get_client_ip(request)
user_agent = request.META.get('HTTP_USER_AGENT', '')
LoginRecord.objects.create(user=user, ip_address=ip, user_agent=user_agent)
# 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(['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
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,
}
# Team info
team = user.team
if team:
# Team monthly consumption
from apps.generation.models import GenerationRecord
team_monthly_used = GenerationRecord.objects.filter(
user__team=team,
created_at__date__gte=first_of_month,
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
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,
'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,
})