All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m20s
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>
191 lines
6.6 KiB
Python
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,
|
|
})
|