import logging from rest_framework import status from rest_framework.decorators import api_view, permission_classes, parser_classes from rest_framework.parsers import MultiPartParser, JSONParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from django.contrib.auth import get_user_model from django.utils import timezone from django.db import transaction from django.db.models import Sum, Q, F from django.db.models.functions import TruncDate from django.db.utils import OperationalError as DbOperationalError from datetime import timedelta from .models import GenerationRecord, QuotaConfig from .serializers import ( VideoGenerateSerializer, QuotaUpdateSerializer, UserStatusSerializer, SystemSettingsSerializer, AdminCreateUserSerializer, TeamCreateSerializer, TeamUpdateSerializer, TeamTopUpSerializer, TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer, TeamAnomalyConfigSerializer, ) from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember from utils.tos_client import upload_file as tos_upload from utils.airdrama_client import create_task, query_task, extract_video_url, map_status User = get_user_model() logger = logging.getLogger(__name__) # File validation constants ALLOWED_IMAGE_EXTS = {'jpeg', 'jpg', 'png', 'webp', 'bmp', 'tiff', 'gif'} ALLOWED_VIDEO_EXTS = {'mp4', 'mov'} ALLOWED_AUDIO_EXTS = {'mp3', 'wav'} MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB MAX_AUDIO_SIZE = 15 * 1024 * 1024 # 15MB # Columns added in migration 0003; may not exist in production DB yet. _M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls') _m0003_ok = None # None = unknown, True = columns exist, False = missing def _eval_qs(qs, limit=None, get_kwargs=None): """Evaluate a GenerationRecord queryset, deferring migration-0003 columns if missing.""" global _m0003_ok def _run(q, defer): if defer: q = q.defer(*_M0003_COLS) if get_kwargs is not None: return q.get(**get_kwargs) if limit is not None: return list(q[:limit]) return list(q) if _m0003_ok is False: return _run(qs, defer=True) try: result = _run(qs, defer=False) _m0003_ok = True return result except DbOperationalError as e: if 'ark_task_id' in str(e): _m0003_ok = False return _run(qs, defer=True) raise # ────────────────────────────────────────────── # Media Upload # ────────────────────────────────────────────── @api_view(['POST']) @permission_classes([IsAuthenticated]) @parser_classes([MultiPartParser]) def upload_media_view(request): """POST /api/v1/media/upload — Upload file to TOS, return public URL.""" file = request.FILES.get('file') if not file: return Response({'error': '未上传文件'}, status=status.HTTP_400_BAD_REQUEST) ext = file.name.rsplit('.', 1)[-1].lower() if '.' in file.name else '' if ext in ALLOWED_IMAGE_EXTS: media_type = 'image' max_size = MAX_IMAGE_SIZE elif ext in ALLOWED_VIDEO_EXTS: media_type = 'video' max_size = MAX_VIDEO_SIZE elif ext in ALLOWED_AUDIO_EXTS: media_type = 'audio' max_size = MAX_AUDIO_SIZE else: return Response( {'error': f'不支持的文件格式: {ext}'}, status=status.HTTP_400_BAD_REQUEST, ) if file.size > max_size: limit_mb = max_size // (1024 * 1024) return Response( {'error': f'{media_type} 文件不能超过 {limit_mb}MB'}, status=status.HTTP_400_BAD_REQUEST, ) try: url = tos_upload(file, folder=media_type) except Exception as e: logger.exception('TOS upload failed') return Response( {'error': f'文件上传失败: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) return Response({ 'url': url, 'type': media_type, 'filename': file.name, 'size': file.size, }) # ────────────────────────────────────────────── # Video Generation (with 4-layer quota check) # ────────────────────────────────────────────── @api_view(['POST']) @permission_classes([IsTeamMember]) def video_generate_view(request): """POST /api/v1/video/generate — Four-layer quota check + AirDrama API.""" serializer = VideoGenerateSerializer(data=request.data) serializer.is_valid(raise_exception=True) user = request.user team = user.team # Pre-check: team disabled if not team.is_active: return Response( {'error': 'team_disabled', 'message': '您的团队已被停用,请联系管理员'}, status=status.HTTP_403_FORBIDDEN, ) today = timezone.now().date() first_of_month = today.replace(day=1) duration = serializer.validated_data['duration'] # ── Layer 1: User daily limit (skip if -1) ── if user.daily_seconds_limit != -1: daily_used = user.generation_records.filter( created_at__date=today ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 if daily_used + duration > user.daily_seconds_limit: return Response({ 'error': 'quota_exceeded', 'message': '您今日的生成额度已用完', 'daily_seconds_limit': user.daily_seconds_limit, 'daily_seconds_used': daily_used, 'reset_at': (timezone.now() + timedelta(days=1)).replace( hour=0, minute=0, second=0, microsecond=0 ).isoformat(), }, status=status.HTTP_429_TOO_MANY_REQUESTS) # ── Layer 2: User monthly limit (skip if -1) ── if user.monthly_seconds_limit != -1: monthly_used = user.generation_records.filter( created_at__date__gte=first_of_month ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 if monthly_used + duration > user.monthly_seconds_limit: return Response({ 'error': 'quota_exceeded', 'message': '您本月的生成额度已用完', 'monthly_seconds_limit': user.monthly_seconds_limit, 'monthly_seconds_used': monthly_used, }, status=status.HTTP_429_TOO_MANY_REQUESTS) # ── Layer 3 & 4: Team checks + pre-deduction (atomic with row lock) ── with transaction.atomic(): locked_team = Team.objects.select_for_update().get(pk=team.pk) # Layer 3: Team monthly limit team_monthly_used = GenerationRecord.objects.filter( user__team=locked_team, created_at__date__gte=first_of_month, ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 if team_monthly_used + duration > locked_team.monthly_seconds_limit: return Response({ 'error': 'quota_exceeded', 'message': '团队本月消费额度已用完', 'team_monthly_limit': locked_team.monthly_seconds_limit, 'team_monthly_used': team_monthly_used, }, status=status.HTTP_429_TOO_MANY_REQUESTS) # Layer 4: Team total pool if locked_team.total_seconds_used + duration > locked_team.total_seconds_pool: return Response({ 'error': 'quota_exceeded', 'message': '团队总额度已用完,请联系管理员充值', 'team_pool': locked_team.total_seconds_pool, 'team_used': locked_team.total_seconds_used, }, status=status.HTTP_429_TOO_MANY_REQUESTS) # Pre-deduction: create record + update team used references = request.data.get('references', []) reference_snapshots = [] content_items = [] for ref in references: url = ref.get('url', '') ref_type = ref.get('type', 'image') role = ref.get('role', '') label = ref.get('label', '') reference_snapshots.append({ 'url': url, 'type': ref_type, 'role': role, 'label': label, }) if ref_type == 'image': item = {'type': 'image_url', 'image_url': {'url': url}} if role: item['role'] = role content_items.append(item) elif ref_type == 'video': item = {'type': 'video_url', 'video_url': {'url': url}} if role: item['role'] = role content_items.append(item) elif ref_type == 'audio': item = {'type': 'audio_url', 'audio_url': {'url': url}} if role: item['role'] = role content_items.append(item) prompt = serializer.validated_data['prompt'] mode = serializer.validated_data['mode'] model = serializer.validated_data['model'] aspect_ratio = serializer.validated_data['aspect_ratio'] record = GenerationRecord.objects.create( user=user, prompt=prompt, mode=mode, model=model, aspect_ratio=aspect_ratio, duration=duration, seconds_consumed=duration, reference_urls=reference_snapshots, ) locked_team.total_seconds_used = F('total_seconds_used') + duration locked_team.save(update_fields=['total_seconds_used']) # ── Call AirDrama API (outside transaction to avoid holding lock) ── from django.conf import settings as django_settings if django_settings.SEEDANCE_ENABLED and django_settings.ARK_API_KEY: try: ark_response = create_task( prompt=prompt, model=model, content_items=content_items, aspect_ratio=aspect_ratio, duration=duration, ) ark_task_id = ark_response.get('id', '') record.ark_task_id = ark_task_id record.status = 'processing' record.save(update_fields=['ark_task_id', 'status']) except Exception as e: logger.exception('AirDrama API create task failed') record.status = 'failed' from utils.airdrama_client import AirDramaAPIError if isinstance(e, AirDramaAPIError): record.error_message = e.user_message else: record.error_message = str(e) record.save(update_fields=['status', 'error_message']) # Refund: API call failed, Seedance didn't charge _refund_quota(record, duration) else: record.status = 'completed' record.save(update_fields=['status']) return Response({ 'task_id': str(record.task_id), 'ark_task_id': getattr(record, 'ark_task_id', ''), 'status': record.status, 'estimated_time': 120, 'seconds_consumed': duration, 'error_message': getattr(record, 'error_message', '') or '', }, status=status.HTTP_202_ACCEPTED) def _refund_quota(record, seconds): """Refund pre-deducted seconds to team pool.""" if record.seconds_consumed == 0: return # already refunded team = record.user.team if not team: return with transaction.atomic(): locked_team = Team.objects.select_for_update().get(pk=team.pk) locked_team.total_seconds_used = F('total_seconds_used') - seconds locked_team.save(update_fields=['total_seconds_used']) record.seconds_consumed = 0 record.save(update_fields=['seconds_consumed']) # ────────────────────────────────────────────── # Video Tasks: List + Detail (with failure refund) # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsAuthenticated]) def video_tasks_list_view(request): """GET /api/v1/video/tasks — User's recent generation tasks (paginated). Query params: page_size: Number of tasks per page (default 20, max 100). offset: Number of tasks to skip (default 0). """ user = request.user page_size = min(int(request.query_params.get('page_size', 20)), 100) offset = max(int(request.query_params.get('offset', 0)), 0) qs = user.generation_records.order_by('-created_at') total = qs.count() records = _eval_qs(qs, limit=offset + page_size) # Apply offset after evaluation (defer compat) records = records[offset:] results = [_serialize_task(r) for r in records] return Response({ 'results': results, 'total': total, 'has_more': offset + page_size < total, }) @api_view(['GET', 'DELETE']) @permission_classes([IsAuthenticated]) def video_task_detail_view(request, task_id): """GET /api/v1/video/tasks/ — Poll Seedance + refund on failure. DELETE /api/v1/video/tasks/ — Delete task record.""" try: record = _eval_qs( GenerationRecord.objects.filter(user=request.user), get_kwargs={'task_id': task_id}, ) except GenerationRecord.DoesNotExist: return Response({'error': '任务不存在'}, status=status.HTTP_404_NOT_FOUND) if request.method == 'DELETE': record.delete() return Response(status=status.HTTP_204_NO_CONTENT) # If task is still active, poll AirDrama API for latest status ark_task_id = record.__dict__.get('ark_task_id', '') if record.status in ('queued', 'processing') and ark_task_id: try: ark_resp = query_task(ark_task_id) new_status = map_status(ark_resp.get('status', '')) record.status = new_status if new_status == 'completed': video_url = extract_video_url(ark_resp) if video_url: # Persist to TOS for permanent storage (Seedance URLs expire in 24h) try: from utils.tos_client import upload_from_url record.result_url = upload_from_url(video_url, folder='results') except Exception: logger.exception('Failed to persist video to TOS, using temporary URL') record.result_url = video_url elif new_status == 'failed': error = ark_resp.get('error', {}) code = error.get('code', '') if isinstance(error, dict) else '' raw_msg = error.get('message', '') if isinstance(error, dict) else str(error) from utils.airdrama_client import ERROR_MESSAGES record.error_message = ERROR_MESSAGES.get(code, raw_msg) # Phase 5: Refund if Seedance didn't charge usage = ark_resp.get('usage', {}) total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0 if total_tokens == 0 and record.seconds_consumed > 0: _refund_quota(record, record.seconds_consumed) record.save(update_fields=['status', 'result_url', 'error_message']) except Exception as e: logger.exception('AirDrama API query failed for %s', ark_task_id) return Response(_serialize_task(record)) def _serialize_task(record): """Serialize a GenerationRecord for the frontend.""" d = record.__dict__ return { 'id': record.id, 'task_id': str(record.task_id), 'ark_task_id': d.get('ark_task_id', ''), 'prompt': record.prompt, 'mode': record.mode, 'model': record.model, 'aspect_ratio': record.aspect_ratio, 'duration': record.duration, 'seconds_consumed': record.seconds_consumed, 'status': record.status, 'result_url': d.get('result_url', ''), 'error_message': d.get('error_message', ''), 'reference_urls': d.get('reference_urls') or [], 'created_at': record.created_at.isoformat(), } # ────────────────────────────────────────────── # Admin: Dashboard Stats # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsSuperAdmin]) def admin_stats_view(request): """GET /api/v1/admin/stats""" today = timezone.now().date() yesterday = today - timedelta(days=1) first_of_month = today.replace(day=1) thirty_days_ago = today - timedelta(days=29) total_users = User.objects.count() total_teams = Team.objects.count() new_users_today = User.objects.filter(date_joined__date=today).count() seconds_today = GenerationRecord.objects.filter( created_at__date=today ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 seconds_yesterday = GenerationRecord.objects.filter( created_at__date=yesterday ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 seconds_this_month = GenerationRecord.objects.filter( created_at__date__gte=first_of_month ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 # Last month same period for comparison if first_of_month.month == 1: last_month_start = first_of_month.replace(year=first_of_month.year - 1, month=12) else: last_month_start = first_of_month.replace(month=first_of_month.month - 1) days_into_month = (today - first_of_month).days + 1 last_month_same_day = last_month_start + timedelta(days=days_into_month - 1) seconds_last_month_period = GenerationRecord.objects.filter( created_at__date__gte=last_month_start, created_at__date__lte=last_month_same_day ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 today_change = round(((seconds_today - seconds_yesterday) / max(seconds_yesterday, 1)) * 100, 1) if seconds_yesterday else 0 month_change = round(((seconds_this_month - seconds_last_month_period) / max(seconds_last_month_period, 1)) * 100, 1) if seconds_last_month_period else 0 # Daily trend for past 30 days daily_trend_qs = ( GenerationRecord.objects .filter(created_at__date__gte=thirty_days_ago) .annotate(date=TruncDate('created_at')) .values('date') .annotate(seconds=Sum('seconds_consumed')) .order_by('date') ) trend_map = {str(item['date']): item['seconds'] or 0 for item in daily_trend_qs} daily_trend = [] for i in range(30): d = thirty_days_ago + timedelta(days=i) daily_trend.append({'date': str(d), 'seconds': trend_map.get(str(d), 0)}) # Top 10 users by seconds consumed this month top_users = ( User.objects.annotate( seconds_consumed=Sum( 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date__gte=first_of_month), ) ) .filter(seconds_consumed__gt=0) .order_by('-seconds_consumed')[:10] ) # Team consumption ranking this month top_teams = ( Team.objects.annotate( seconds_consumed=Sum( 'members__generation_records__seconds_consumed', filter=Q(members__generation_records__created_at__date__gte=first_of_month), ) ) .filter(seconds_consumed__gt=0) .order_by('-seconds_consumed') ) return Response({ 'total_users': total_users, 'total_teams': total_teams, 'new_users_today': new_users_today, 'seconds_consumed_today': seconds_today, 'seconds_consumed_this_month': seconds_this_month, 'today_change_percent': today_change, 'month_change_percent': month_change, 'daily_trend': daily_trend, 'top_users': [ {'user_id': u.id, 'username': u.username, 'seconds_consumed': u.seconds_consumed or 0} for u in top_users ], 'top_teams': [ {'team_id': t.id, 'name': t.name, 'seconds_consumed': t.seconds_consumed or 0} for t in top_teams ], }) # ────────────────────────────────────────────── # Admin: Team Management (Super Admin only) # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsSuperAdmin]) def admin_teams_list_view(request): """GET /api/v1/admin/teams — List all teams.""" today = timezone.now().date() first_of_month = today.replace(day=1) teams = Team.objects.all().order_by('-created_at') results = [] for t in teams: monthly_used = GenerationRecord.objects.filter( user__team=t, created_at__date__gte=first_of_month, ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 results.append({ 'id': t.id, 'name': t.name, 'total_seconds_pool': t.total_seconds_pool, 'total_seconds_used': t.total_seconds_used, 'remaining_seconds': t.remaining_seconds, 'monthly_seconds_limit': t.monthly_seconds_limit, 'monthly_seconds_used': monthly_used, 'daily_member_limit_default': t.daily_member_limit_default, 'member_count': t.members.count(), 'is_active': t.is_active, 'expected_regions': t.expected_regions, 'disabled_by': t.disabled_by, 'created_at': t.created_at.isoformat(), }) return Response({'results': results}) @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_team_create_view(request): """POST /api/v1/admin/teams/create — Create a new team.""" serializer = TeamCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) name = serializer.validated_data['name'] if Team.objects.filter(name=name).exists(): return Response({'error': '团队名称已存在'}, status=status.HTTP_400_BAD_REQUEST) team = Team.objects.create(**serializer.validated_data) log_admin_action(request, 'team_create', 'team', target_id=team.id, target_name=team.name, after={'name': team.name, 'monthly_seconds_limit': team.monthly_seconds_limit, 'daily_member_limit_default': team.daily_member_limit_default, 'expected_regions': team.expected_regions}) return Response({ 'id': team.id, 'name': team.name, 'monthly_seconds_limit': team.monthly_seconds_limit, 'daily_member_limit_default': team.daily_member_limit_default, 'expected_regions': team.expected_regions, 'created_at': team.created_at.isoformat(), }, status=status.HTTP_201_CREATED) @api_view(['GET', 'PUT']) @permission_classes([IsSuperAdmin]) def admin_team_detail_view(request, team_id): """GET/PUT /api/v1/admin/teams/ — Team detail + members / update team.""" try: team = Team.objects.get(id=team_id) except Team.DoesNotExist: return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) if request.method == 'PUT': serializer = TeamUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) # Handle disabled_by based on is_active change before = {f: getattr(team, f) for f in serializer.validated_data} before['disabled_by'] = team.disabled_by for field, value in serializer.validated_data.items(): setattr(team, field, value) # If admin manually toggles is_active, update disabled_by if 'is_active' in serializer.validated_data: if serializer.validated_data['is_active']: team.disabled_by = '' else: team.disabled_by = 'admin' team.save() # Update TeamAnomalyConfig if provided anomaly_config_data = request.data.get('anomaly_config') if anomaly_config_data and isinstance(anomaly_config_data, dict): ac_serializer = TeamAnomalyConfigSerializer(data=anomaly_config_data) ac_serializer.is_valid(raise_exception=True) ac, _ = TeamAnomalyConfig.objects.get_or_create(team=team) for field, value in ac_serializer.validated_data.items(): setattr(ac, field, value) ac.save() after = {f: getattr(team, f) for f in serializer.validated_data} after['disabled_by'] = team.disabled_by log_admin_action(request, 'team_update', 'team', target_id=team.id, target_name=team.name, before=before, after=after) return Response({ 'id': team.id, 'name': team.name, 'monthly_seconds_limit': team.monthly_seconds_limit, 'daily_member_limit_default': team.daily_member_limit_default, 'is_active': team.is_active, 'expected_regions': team.expected_regions, 'disabled_by': team.disabled_by, 'updated_at': team.updated_at.isoformat(), }) # GET: team detail + members today = timezone.now().date() first_of_month = today.replace(day=1) monthly_used = GenerationRecord.objects.filter( user__team=team, created_at__date__gte=first_of_month, ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 members = team.members.annotate( seconds_today=Sum( 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date=today), ), seconds_this_month=Sum( 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date__gte=first_of_month), ), ).order_by('-date_joined') # TeamAnomalyConfig try: ac = team.anomaly_config anomaly_config = { 'r1_enabled': ac.r1_enabled, 'r2_enabled': ac.r2_enabled, 'r2_window_seconds': ac.r2_window_seconds, 'r3_enabled': ac.r3_enabled, 'r3_window_seconds': ac.r3_window_seconds, 'r3_max_count': ac.r3_max_count, 'r4_enabled': ac.r4_enabled, 'r4_window_seconds': ac.r4_window_seconds, 'r4_city_count': ac.r4_city_count, 'r5_enabled': ac.r5_enabled, 'r5_days': ac.r5_days, 'r5_country_count': ac.r5_country_count, } except TeamAnomalyConfig.DoesNotExist: anomaly_config = None return Response({ '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': monthly_used, 'daily_member_limit_default': team.daily_member_limit_default, 'member_count': team.members.count(), 'is_active': team.is_active, 'expected_regions': team.expected_regions, 'disabled_by': team.disabled_by, 'anomaly_config': anomaly_config, 'created_at': team.created_at.isoformat(), 'members': [{ 'id': m.id, 'username': m.username, 'email': m.email, 'is_team_admin': m.is_team_admin, 'is_active': m.is_active, 'disabled_by': m.disabled_by, 'daily_seconds_limit': m.daily_seconds_limit, 'monthly_seconds_limit': m.monthly_seconds_limit, 'seconds_today': m.seconds_today or 0, 'seconds_this_month': m.seconds_this_month or 0, 'date_joined': m.date_joined.isoformat(), } for m in members], }) @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_team_topup_view(request, team_id): """POST /api/v1/admin/teams//topup — Add seconds to team pool.""" try: team = Team.objects.get(id=team_id) except Team.DoesNotExist: return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) serializer = TeamTopUpSerializer(data=request.data) serializer.is_valid(raise_exception=True) seconds = serializer.validated_data['seconds'] old_pool = team.total_seconds_pool with transaction.atomic(): locked = Team.objects.select_for_update().get(pk=team.pk) locked.total_seconds_pool = F('total_seconds_pool') + seconds locked.save(update_fields=['total_seconds_pool']) team.refresh_from_db() log_admin_action(request, 'team_topup', 'team', target_id=team.id, target_name=team.name, before={'total_seconds_pool': old_pool}, after={'total_seconds_pool': team.total_seconds_pool, 'topped_up': seconds}) return Response({ '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, 'topped_up': seconds, }) @api_view(['PUT']) @permission_classes([IsSuperAdmin]) def admin_team_set_pool_view(request, team_id): """PUT /api/v1/admin/teams//set-pool — Directly set total_seconds_pool.""" try: team = Team.objects.get(id=team_id) except Team.DoesNotExist: return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) new_pool = request.data.get('total_seconds_pool') if new_pool is None: return Response({'error': 'total_seconds_pool is required'}, status=status.HTTP_400_BAD_REQUEST) try: new_pool = int(new_pool) except (ValueError, TypeError): return Response({'error': '请输入有效的数字'}, status=status.HTTP_400_BAD_REQUEST) if new_pool < 0: return Response({'error': '总秒数池不能为负数'}, status=status.HTTP_400_BAD_REQUEST) if new_pool < team.total_seconds_used: return Response({'error': f'不能低于已消耗秒数 ({int(team.total_seconds_used)}s)'}, status=status.HTTP_400_BAD_REQUEST) old_pool = team.total_seconds_pool with transaction.atomic(): locked = Team.objects.select_for_update().get(pk=team.pk) locked.total_seconds_pool = new_pool locked.save(update_fields=['total_seconds_pool']) team.refresh_from_db() log_admin_action(request, 'team_set_pool', 'team', target_id=team.id, target_name=team.name, before={'total_seconds_pool': old_pool}, after={'total_seconds_pool': team.total_seconds_pool}) return Response({ '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, }) @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_team_create_admin_view(request, team_id): """POST /api/v1/admin/teams//admin — Create team admin account.""" try: team = Team.objects.get(id=team_id) except Team.DoesNotExist: return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) serializer = TeamAdminCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) username = serializer.validated_data['username'] email = serializer.validated_data['email'] if User.objects.filter(username=username).exists(): return Response({'error': '用户名已存在'}, status=status.HTTP_400_BAD_REQUEST) if User.objects.filter(email=email).exists(): return Response({'error': '邮箱已存在'}, status=status.HTTP_400_BAD_REQUEST) user = User.objects.create_user( username=username, email=email, password=serializer.validated_data['password'], team=team, is_team_admin=True, daily_seconds_limit=team.daily_member_limit_default, monthly_seconds_limit=-1, # Team admin unlimited by default ) log_admin_action(request, 'team_create_admin', 'user', target_id=user.id, target_name=user.username, after={'username': user.username, 'email': user.email, 'team': team.name}) return Response({ 'id': user.id, 'username': user.username, 'email': user.email, 'team': team.name, 'is_team_admin': True, }, status=status.HTTP_201_CREATED) # ────────────────────────────────────────────── # Admin: User Management # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsSuperAdmin]) def admin_users_list_view(request): """GET /api/v1/admin/users""" today = timezone.now().date() first_of_month = today.replace(day=1) page = int(request.query_params.get('page', 1)) page_size = min(int(request.query_params.get('page_size', 20)), 100) search = request.query_params.get('search', '').strip() status_filter = request.query_params.get('status', '').strip() team_id = request.query_params.get('team_id', '').strip() qs = User.objects.select_related('team').annotate( seconds_today=Sum( 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date=today), ), seconds_this_month=Sum( 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date__gte=first_of_month), ), ) if search: qs = qs.filter(Q(username__icontains=search) | Q(email__icontains=search)) if status_filter == 'active': qs = qs.filter(is_active=True) elif status_filter == 'disabled': qs = qs.filter(is_active=False) if team_id: qs = qs.filter(team_id=int(team_id)) total = qs.count() offset = (page - 1) * page_size users = qs.order_by('-date_joined')[offset:offset + page_size] results = [] for u in users: results.append({ 'id': u.id, 'username': u.username, 'email': u.email, 'is_active': u.is_active, 'disabled_by': u.disabled_by, 'is_staff': u.is_staff, 'is_team_admin': u.is_team_admin, 'team_id': u.team_id, 'team_name': u.team.name if u.team else None, 'date_joined': u.date_joined.isoformat(), 'daily_seconds_limit': u.daily_seconds_limit, 'monthly_seconds_limit': u.monthly_seconds_limit, 'seconds_today': u.seconds_today or 0, 'seconds_this_month': u.seconds_this_month or 0, }) return Response({ 'total': total, 'page': page, 'page_size': page_size, 'results': results, }) @api_view(['GET']) @permission_classes([IsSuperAdmin]) def admin_user_detail_view(request, user_id): """GET /api/v1/admin/users/:id""" try: user = User.objects.select_related('team').get(id=user_id) except User.DoesNotExist: return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND) today = timezone.now().date() first_of_month = today.replace(day=1) seconds_today = user.generation_records.filter( created_at__date=today ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 seconds_this_month = user.generation_records.filter( created_at__date__gte=first_of_month ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 seconds_total = user.generation_records.aggregate( total=Sum('seconds_consumed') )['total'] or 0 recent_records = _eval_qs(user.generation_records.order_by('-created_at'), limit=20) return Response({ 'id': user.id, 'username': user.username, 'email': user.email, 'is_active': user.is_active, 'is_staff': user.is_staff, 'is_team_admin': user.is_team_admin, 'team_id': user.team_id, 'team_name': user.team.name if user.team else None, 'date_joined': user.date_joined.isoformat(), 'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit, 'seconds_today': seconds_today, 'seconds_this_month': seconds_this_month, 'seconds_total': seconds_total, 'recent_records': [ { 'id': r.id, 'created_at': r.created_at.isoformat(), 'seconds_consumed': r.seconds_consumed, 'prompt': r.prompt, 'mode': r.mode, 'model': r.model, 'status': r.status, 'error_message': r.error_message or '', } for r in recent_records ], }) @api_view(['PUT']) @permission_classes([IsSuperAdmin]) def admin_user_quota_view(request, user_id): """PUT /api/v1/admin/users/:id/quota""" try: user = User.objects.get(id=user_id) except User.DoesNotExist: return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND) serializer = QuotaUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) before = {'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit} user.daily_seconds_limit = serializer.validated_data['daily_seconds_limit'] user.monthly_seconds_limit = serializer.validated_data['monthly_seconds_limit'] user.save(update_fields=['daily_seconds_limit', 'monthly_seconds_limit']) log_admin_action(request, 'user_quota_update', 'user', target_id=user.id, target_name=user.username, before=before, after={'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit}) return Response({ 'user_id': user.id, 'username': user.username, 'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit, 'updated_at': timezone.now().isoformat(), }) @api_view(['PATCH']) @permission_classes([IsSuperAdmin]) def admin_user_status_view(request, user_id): """PATCH /api/v1/admin/users/:id/status""" try: user = User.objects.get(id=user_id) except User.DoesNotExist: return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND) serializer = UserStatusSerializer(data=request.data) serializer.is_valid(raise_exception=True) old_active = user.is_active old_disabled_by = user.disabled_by user.is_active = serializer.validated_data['is_active'] if user.is_active: user.disabled_by = '' else: user.disabled_by = 'admin' user.save(update_fields=['is_active', 'disabled_by']) log_admin_action(request, 'user_status_toggle', 'user', target_id=user.id, target_name=user.username, before={'is_active': old_active, 'disabled_by': old_disabled_by}, after={'is_active': user.is_active, 'disabled_by': user.disabled_by}) return Response({ 'user_id': user.id, 'username': user.username, 'is_active': user.is_active, 'updated_at': timezone.now().isoformat(), }) @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_reset_password_view(request, user_id): """POST /api/v1/admin/users/:id/reset-password""" try: user = User.objects.get(id=user_id) except User.DoesNotExist: return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND) new_password = request.data.get('new_password', '') if len(new_password) < 8: return Response({'error': '密码至少8位'}, status=status.HTTP_400_BAD_REQUEST) user.set_password(new_password) user.must_change_password = True user.save(update_fields=['password', 'must_change_password']) log_admin_action(request, 'user_password_reset', 'user', target_id=user.id, target_name=user.username) return Response({'message': f'已重置 {user.username} 的密码'}) @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_create_user_view(request): """POST /api/v1/admin/users/create — Super admin creates a user.""" serializer = AdminCreateUserSerializer(data=request.data) serializer.is_valid(raise_exception=True) username = serializer.validated_data['username'] email = serializer.validated_data['email'] if User.objects.filter(username=username).exists(): return Response({'error': '用户名已存在'}, status=status.HTTP_400_BAD_REQUEST) if User.objects.filter(email=email).exists(): return Response({'error': '邮箱已存在'}, status=status.HTTP_400_BAD_REQUEST) user = User.objects.create_user( username=username, email=email, password=serializer.validated_data['password'], daily_seconds_limit=serializer.validated_data['daily_seconds_limit'], monthly_seconds_limit=serializer.validated_data['monthly_seconds_limit'], is_staff=serializer.validated_data['is_staff'], ) log_admin_action(request, 'user_create', 'user', target_id=user.id, target_name=user.username, after={'username': user.username, 'email': user.email, 'is_staff': user.is_staff}) return Response({ 'id': user.id, 'username': user.username, 'email': user.email, 'is_active': user.is_active, 'is_staff': user.is_staff, 'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit, 'created_at': timezone.now().isoformat(), }, status=status.HTTP_201_CREATED) # ────────────────────────────────────────────── # Admin: Consumption Records # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsSuperAdmin]) def admin_records_view(request): """GET /api/v1/admin/records""" page = int(request.query_params.get('page', 1)) page_size = min(int(request.query_params.get('page_size', 20)), 100) search = request.query_params.get('search', '').strip() start_date = request.query_params.get('start_date', '').strip() end_date = request.query_params.get('end_date', '').strip() team_id = request.query_params.get('team_id', '').strip() qs = GenerationRecord.objects.select_related('user', 'user__team').order_by('-created_at') if search: qs = qs.filter(user__username__icontains=search) if start_date: qs = qs.filter(created_at__date__gte=start_date) if end_date: qs = qs.filter(created_at__date__lte=end_date) if team_id: qs = qs.filter(user__team_id=int(team_id)) total = qs.count() offset = (page - 1) * page_size records = _eval_qs(qs[offset:offset + page_size]) results = [] for r in records: results.append({ 'id': r.id, 'created_at': r.created_at.isoformat(), 'user_id': r.user_id, 'username': r.user.username, 'team_name': r.user.team.name if r.user.team else None, 'seconds_consumed': r.seconds_consumed, 'prompt': r.prompt, 'mode': r.mode, 'model': r.model, 'aspect_ratio': r.aspect_ratio, 'status': r.status, 'error_message': r.error_message or '', }) return Response({ 'total': total, 'page': page, 'page_size': page_size, 'results': results, }) # ────────────────────────────────────────────── # Admin: System Settings # ────────────────────────────────────────────── def _settings_dict(config): """QuotaConfig → dict for API response.""" return { 'default_daily_seconds_limit': config.default_daily_seconds_limit, 'default_monthly_seconds_limit': config.default_monthly_seconds_limit, 'announcement': config.announcement, 'announcement_enabled': config.announcement_enabled, 'max_desktop_sessions': config.max_desktop_sessions, 'max_mobile_sessions': config.max_mobile_sessions, 'anomaly_detection_enabled': config.anomaly_detection_enabled, 'r1_enabled_default': config.r1_enabled_default, 'r2_enabled_default': config.r2_enabled_default, 'r2_window_seconds': config.r2_window_seconds, 'r3_enabled_default': config.r3_enabled_default, 'r3_window_seconds': config.r3_window_seconds, 'r3_max_count': config.r3_max_count, 'r4_enabled_default': config.r4_enabled_default, 'r4_window_seconds': config.r4_window_seconds, 'r4_city_count': config.r4_city_count, 'r5_enabled_default': config.r5_enabled_default, 'r5_days': config.r5_days, 'r5_country_count': config.r5_country_count, 'feishu_alert_mobiles': config.feishu_alert_mobiles, 'sms_alert_mobiles': config.sms_alert_mobiles, 'alert_cooldown_seconds': config.alert_cooldown_seconds, } @api_view(['GET', 'PUT']) @permission_classes([IsSuperAdmin]) def admin_settings_view(request): """GET/PUT /api/v1/admin/settings""" config, _ = QuotaConfig.objects.get_or_create(pk=1) if request.method == 'GET': return Response(_settings_dict(config)) serializer = SystemSettingsSerializer(data=request.data) serializer.is_valid(raise_exception=True) before = _settings_dict(config) for field in serializer.validated_data: setattr(config, field, serializer.validated_data[field]) config.save() log_admin_action(request, 'settings_update', 'settings', target_name='系统设置', before=before, after=_settings_dict(config)) result = _settings_dict(config) result['updated_at'] = config.updated_at.isoformat() return Response(result) # ────────────────────────────────────────────── # Admin: Anomaly Detection # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsSuperAdmin]) def admin_login_anomalies_view(request): """GET /api/v1/admin/anomalies — Login anomaly records list.""" page = int(request.query_params.get('page', 1)) page_size = min(int(request.query_params.get('page_size', 20)), 100) team_id = request.query_params.get('team_id', '').strip() rule = request.query_params.get('rule', '').strip() level = request.query_params.get('level', '').strip() start_date = request.query_params.get('start_date', '').strip() end_date = request.query_params.get('end_date', '').strip() qs = LoginAnomaly.objects.select_related('team', 'user', 'login_record').all() if team_id: qs = qs.filter(team_id=int(team_id)) if rule: qs = qs.filter(rule=rule) if level: qs = qs.filter(level=level) if start_date: qs = qs.filter(created_at__date__gte=start_date) if end_date: qs = qs.filter(created_at__date__lte=end_date) total = qs.count() offset = (page - 1) * page_size anomalies = list(qs[offset:offset + page_size]) results = [] for a in anomalies: record = a.login_record results.append({ 'id': a.id, 'team_id': a.team_id, 'team_name': a.team.name if a.team else '', 'user_id': a.user_id, 'username': a.user.username if a.user else '', 'level': a.level, 'rule': a.rule, 'detail': a.detail, 'alerted': a.alerted, 'auto_disabled': a.auto_disabled, 'disabled_target': a.disabled_target, 'ip_address': record.ip_address if record else '', 'geo_country': record.geo_country if record else '', 'geo_province': record.geo_province if record else '', 'geo_city': record.geo_city if record else '', 'created_at': a.created_at.isoformat(), }) return Response({ 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, 'results': results, }) @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_test_feishu_view(request): """POST /api/v1/admin/test-feishu — Send a test Feishu alert.""" mobile = request.data.get('mobile', '').strip() if not mobile: return Response({'error': '请输入手机号'}, status=status.HTTP_400_BAD_REQUEST) from utils.alert_service import send_feishu_test success, message = send_feishu_test(mobile) if success: return Response({'message': message}) return Response({'error': message}, status=status.HTTP_400_BAD_REQUEST) @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_team_auto_learn_view(request, team_id): """POST /api/v1/admin/teams//auto-learn — Auto-learn expected regions from login history.""" try: team = Team.objects.get(id=team_id) except Team.DoesNotExist: return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) from apps.accounts.models import LoginRecord days = int(request.data.get('days', 30)) min_count = int(request.data.get('min_count', 3)) since = timezone.now() - timedelta(days=days) # Aggregate domestic cities with at least min_count logins from django.db.models import Count city_stats = ( LoginRecord.objects.filter( team=team, created_at__gte=since, geo_country='中国', ) .exclude(geo_city='') .values('geo_city') .annotate(cnt=Count('id')) .filter(cnt__gte=min_count) .order_by('-cnt') ) cities = [row['geo_city'] for row in city_stats] return Response({ 'team_id': team.id, 'team_name': team.name, 'learned_cities': cities, 'days': days, 'min_count': min_count, 'current_expected_regions': team.expected_regions, }) @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_team_apply_learned_regions_view(request, team_id): """POST /api/v1/admin/teams//apply-learned-regions — Apply auto-learned regions.""" try: team = Team.objects.get(id=team_id) except Team.DoesNotExist: return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) cities = request.data.get('cities', []) if not isinstance(cities, list): return Response({'error': 'cities 必须是数组'}, status=status.HTTP_400_BAD_REQUEST) before = team.expected_regions team.expected_regions = ','.join(cities) team.save(update_fields=['expected_regions']) log_admin_action(request, 'team_update', 'team', target_id=team.id, target_name=team.name, before={'expected_regions': before}, after={'expected_regions': team.expected_regions}) return Response({ 'team_id': team.id, 'expected_regions': team.expected_regions, }) # ────────────────────────────────────────────── # Admin: Audit Logs # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsSuperAdmin]) def admin_audit_logs_view(request): """GET /api/v1/admin/logs — Query admin audit logs.""" page = int(request.query_params.get('page', 1)) page_size = min(int(request.query_params.get('page_size', 20)), 100) action = request.query_params.get('action', '').strip() operator = request.query_params.get('operator', '').strip() start_date = request.query_params.get('start_date', '').strip() end_date = request.query_params.get('end_date', '').strip() qs = AdminAuditLog.objects.select_related('operator').all() if action: qs = qs.filter(action=action) if operator: qs = qs.filter(operator_name__icontains=operator) if start_date: qs = qs.filter(created_at__date__gte=start_date) if end_date: qs = qs.filter(created_at__date__lte=end_date) total = qs.count() offset = (page - 1) * page_size logs = list(qs[offset:offset + page_size]) results = [] for log in logs: results.append({ 'id': log.id, 'operator_name': log.operator_name, 'action': log.action, 'action_display': log.get_action_display(), 'target_type': log.target_type, 'target_id': log.target_id, 'target_name': log.target_name, 'before': log.before, 'after': log.after, 'ip_address': log.ip_address, 'created_at': log.created_at.isoformat(), }) return Response({ 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, 'results': results, }) # ────────────────────────────────────────────── # Public: Announcement # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsAuthenticated]) def announcement_view(request): """GET /api/v1/announcement — return active announcement for logged-in users.""" config, _ = QuotaConfig.objects.get_or_create(pk=1) if config.announcement_enabled and config.announcement: return Response({'announcement': config.announcement, 'enabled': True}) return Response({'announcement': '', 'enabled': False}) # ────────────────────────────────────────────── # Team Admin: Team Management # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsTeamAdmin]) def team_info_view(request): """GET /api/v1/team/info — Team basic info for team admin.""" team = request.user.team today = timezone.now().date() first_of_month = today.replace(day=1) monthly_used = GenerationRecord.objects.filter( user__team=team, created_at__date__gte=first_of_month, ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 return Response({ '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': monthly_used, 'daily_member_limit_default': team.daily_member_limit_default, 'member_count': team.members.count(), 'is_active': team.is_active, }) @api_view(['GET']) @permission_classes([IsTeamAdmin]) def team_stats_view(request): """GET /api/v1/team/stats — Team consumption overview + member breakdown.""" team = request.user.team today = timezone.now().date() first_of_month = today.replace(day=1) thirty_days_ago = today - timedelta(days=29) # Daily trend daily_trend_qs = ( GenerationRecord.objects .filter(user__team=team, created_at__date__gte=thirty_days_ago) .annotate(date=TruncDate('created_at')) .values('date') .annotate(seconds=Sum('seconds_consumed')) .order_by('date') ) trend_map = {str(item['date']): item['seconds'] or 0 for item in daily_trend_qs} daily_trend = [] for i in range(30): d = thirty_days_ago + timedelta(days=i) daily_trend.append({'date': str(d), 'seconds': trend_map.get(str(d), 0)}) # Member consumption this month members = team.members.annotate( seconds_this_month=Sum( 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date__gte=first_of_month), ), ).filter(seconds_this_month__gt=0).order_by('-seconds_this_month') return Response({ 'daily_trend': daily_trend, 'member_consumption': [ {'user_id': m.id, 'username': m.username, 'seconds_consumed': m.seconds_this_month or 0} for m in members ], }) @api_view(['GET']) @permission_classes([IsTeamAdmin]) def team_members_list_view(request): """GET /api/v1/team/members — List team members.""" team = request.user.team today = timezone.now().date() first_of_month = today.replace(day=1) members = team.members.annotate( seconds_today=Sum( 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date=today), ), seconds_this_month=Sum( 'generation_records__seconds_consumed', filter=Q(generation_records__created_at__date__gte=first_of_month), ), ).order_by('-date_joined') return Response({ 'results': [{ 'id': m.id, 'username': m.username, 'email': m.email, 'is_team_admin': m.is_team_admin, 'is_active': m.is_active, 'daily_seconds_limit': m.daily_seconds_limit, 'monthly_seconds_limit': m.monthly_seconds_limit, 'seconds_today': m.seconds_today or 0, 'seconds_this_month': m.seconds_this_month or 0, 'date_joined': m.date_joined.isoformat(), } for m in members], }) @api_view(['POST']) @permission_classes([IsTeamAdmin]) def team_member_create_view(request): """POST /api/v1/team/members/create — Team admin creates a member.""" serializer = TeamMemberCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) team = request.user.team username = serializer.validated_data['username'] if User.objects.filter(username=username).exists(): return Response({'error': '用户名已存在'}, status=status.HTTP_400_BAD_REQUEST) daily = serializer.validated_data.get('daily_seconds_limit', team.daily_member_limit_default) monthly = serializer.validated_data.get('monthly_seconds_limit', -1) # Generate email from username (team members may not need real email) email = f'{username}@team.local' if User.objects.filter(email=email).exists(): email = f'{username}_{team.id}@team.local' user = User.objects.create_user( username=username, email=email, password=serializer.validated_data['password'], team=team, is_team_admin=False, # Cannot escalate privileges daily_seconds_limit=daily, monthly_seconds_limit=monthly, ) log_admin_action(request, 'member_create', 'user', target_id=user.id, target_name=user.username, after={'username': user.username, 'team': team.name}) return Response({ 'id': user.id, 'username': user.username, 'team': team.name, 'daily_seconds_limit': user.daily_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit, }, status=status.HTTP_201_CREATED) @api_view(['GET']) @permission_classes([IsTeamAdmin]) def team_member_detail_view(request, member_id): """GET /api/v1/team/members/ — Member detail + recent records.""" team = request.user.team try: member = team.members.get(id=member_id) except User.DoesNotExist: return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) today = timezone.now().date() first_of_month = today.replace(day=1) seconds_today = member.generation_records.filter( created_at__date=today ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 seconds_this_month = member.generation_records.filter( created_at__date__gte=first_of_month ).aggregate(total=Sum('seconds_consumed'))['total'] or 0 recent_records = _eval_qs(member.generation_records.order_by('-created_at'), limit=20) return Response({ 'id': member.id, 'username': member.username, 'is_active': member.is_active, 'is_team_admin': member.is_team_admin, 'daily_seconds_limit': member.daily_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit, 'seconds_today': seconds_today, 'seconds_this_month': seconds_this_month, 'recent_records': [ { 'id': r.id, 'created_at': r.created_at.isoformat(), 'seconds_consumed': r.seconds_consumed, 'prompt': r.prompt, 'mode': r.mode, 'model': r.model, 'status': r.status, 'error_message': r.error_message or '', } for r in recent_records ], }) @api_view(['PUT']) @permission_classes([IsTeamAdmin]) def team_member_quota_view(request, member_id): """PUT /api/v1/team/members//quota — Set member daily/monthly limit.""" team = request.user.team try: member = team.members.get(id=member_id) except User.DoesNotExist: return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) serializer = MemberQuotaSerializer(data=request.data) serializer.is_valid(raise_exception=True) before = {'daily_seconds_limit': member.daily_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit} member.daily_seconds_limit = serializer.validated_data['daily_seconds_limit'] member.monthly_seconds_limit = serializer.validated_data['monthly_seconds_limit'] member.save(update_fields=['daily_seconds_limit', 'monthly_seconds_limit']) log_admin_action(request, 'member_quota_update', 'user', target_id=member.id, target_name=member.username, before=before, after={'daily_seconds_limit': member.daily_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit}) return Response({ 'user_id': member.id, 'username': member.username, 'daily_seconds_limit': member.daily_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit, }) @api_view(['PATCH']) @permission_classes([IsTeamAdmin]) def team_member_status_view(request, member_id): """PATCH /api/v1/team/members//status — Enable/disable member.""" team = request.user.team try: member = team.members.get(id=member_id) except User.DoesNotExist: return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) # Cannot disable yourself or other team admins if member.id == request.user.id: return Response({'error': '不能停用自己的账号'}, status=status.HTTP_400_BAD_REQUEST) serializer = UserStatusSerializer(data=request.data) serializer.is_valid(raise_exception=True) old_active = member.is_active member.is_active = serializer.validated_data['is_active'] member.save(update_fields=['is_active']) log_admin_action(request, 'member_status_toggle', 'user', target_id=member.id, target_name=member.username, before={'is_active': old_active}, after={'is_active': member.is_active}) return Response({ 'user_id': member.id, 'username': member.username, 'is_active': member.is_active, }) # ────────────────────────────────────────────── # Profile: User's own consumption data # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsAuthenticated]) def profile_overview_view(request): """GET /api/v1/profile/overview""" user = request.user today = timezone.now().date() first_of_month = today.replace(day=1) period = request.query_params.get('period', '7d') days = 30 if period == '30d' else 7 start_date = today - timedelta(days=days - 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 total_seconds_used = user.generation_records.aggregate( total=Sum('seconds_consumed') )['total'] or 0 # Daily trend trend_qs = ( user.generation_records .filter(created_at__date__gte=start_date) .annotate(date=TruncDate('created_at')) .values('date') .annotate(seconds=Sum('seconds_consumed')) .order_by('date') ) trend_map = {str(item['date']): item['seconds'] or 0 for item in trend_qs} daily_trend = [] for i in range(days): d = start_date + timedelta(days=i) daily_trend.append({'date': str(d), 'seconds': trend_map.get(str(d), 0)}) data = { '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, 'total_seconds_used': total_seconds_used, 'daily_trend': daily_trend, } # Include team info team = user.team if team: 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'] = { '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, } return Response(data) @api_view(['GET']) @permission_classes([IsAuthenticated]) def profile_records_view(request): """GET /api/v1/profile/records""" user = request.user page = int(request.query_params.get('page', 1)) page_size = min(int(request.query_params.get('page_size', 20)), 100) qs = user.generation_records.order_by('-created_at') total = qs.count() offset = (page - 1) * page_size records = _eval_qs(qs[offset:offset + page_size]) results = [] for r in records: results.append({ 'id': r.id, 'created_at': r.created_at.isoformat(), 'seconds_consumed': r.seconds_consumed, 'prompt': r.prompt, 'mode': r.mode, 'model': r.model, 'aspect_ratio': r.aspect_ratio, 'status': r.status, 'error_message': r.error_message or '', }) return Response({ 'total': total, 'page': page, 'page_size': page_size, 'results': results, }) # ────────────────────────────────────────────── # Admin: Content Assets (hierarchical view) # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsSuperAdmin]) def admin_assets_overview(request): """GET /api/v1/admin/assets/overview — Global stats + per-team video/seconds summary.""" from apps.accounts.models import Team teams = Team.objects.all().order_by('name') team_data = [] total_videos = 0 total_seconds = 0 for team in teams: team_records = GenerationRecord.objects.filter( user__team=team, status='completed' ) video_count = team_records.count() seconds_consumed = team_records.aggregate(total=Sum('seconds_consumed'))['total'] or 0 total_videos += video_count total_seconds += seconds_consumed team_data.append({ 'id': team.id, 'name': team.name, 'video_count': video_count, 'seconds_consumed': seconds_consumed, 'member_count': team.members.count(), 'is_active': team.is_active, }) # Also count videos from users without a team no_team_records = GenerationRecord.objects.filter( user__team__isnull=True, status='completed' ) no_team_count = no_team_records.count() no_team_seconds = no_team_records.aggregate(total=Sum('seconds_consumed'))['total'] or 0 total_videos += no_team_count total_seconds += no_team_seconds return Response({ 'total_videos': total_videos, 'total_seconds': total_seconds, 'total_teams': teams.count(), 'teams': team_data, 'no_team': { 'video_count': no_team_count, 'seconds_consumed': no_team_seconds, }, }) @api_view(['GET']) @permission_classes([IsSuperAdmin]) def admin_assets_team_members(request, team_id): """GET /api/v1/admin/assets/team//members — Members of a team with video/seconds stats.""" from apps.accounts.models import Team try: team = Team.objects.get(id=team_id) except Team.DoesNotExist: return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND) members = team.members.all().order_by('username') member_data = [] total_videos = 0 total_seconds = 0 for member in members: records = member.generation_records.filter(status='completed') video_count = records.count() seconds_consumed = records.aggregate(total=Sum('seconds_consumed'))['total'] or 0 total_videos += video_count total_seconds += seconds_consumed member_data.append({ 'id': member.id, 'username': member.username, 'is_team_admin': member.is_team_admin, 'video_count': video_count, 'seconds_consumed': seconds_consumed, }) return Response({ 'team_id': team.id, 'team_name': team.name, 'total_videos': total_videos, 'total_seconds': total_seconds, 'member_count': len(member_data), 'members': member_data, }) @api_view(['GET']) @permission_classes([IsSuperAdmin]) def admin_assets_user_videos(request, user_id): """GET /api/v1/admin/assets/user//videos — Completed videos for a user (paginated).""" try: target_user = User.objects.get(id=user_id) except User.DoesNotExist: return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND) page = int(request.query_params.get('page', 1)) page_size = min(int(request.query_params.get('page_size', 30)), 100) qs = target_user.generation_records.filter(status='completed').order_by('-created_at') total = qs.count() offset = (page - 1) * page_size records = _eval_qs(qs[offset:offset + page_size]) results = [] for r in records: results.append({ 'id': r.id, 'task_id': str(r.task_id), 'prompt': r.prompt, 'result_url': r.result_url or '', 'duration': r.duration, 'seconds_consumed': r.seconds_consumed, 'aspect_ratio': r.aspect_ratio, 'created_at': r.created_at.isoformat(), }) return Response({ 'user_id': target_user.id, 'username': target_user.username, 'total': total, 'page': page, 'page_size': page_size, 'results': results, }) # ────────────────────────────────────────────── # Team Admin: Content Assets # ────────────────────────────────────────────── @api_view(['GET']) @permission_classes([IsTeamAdmin]) def team_assets_overview(request): """GET /api/v1/team/assets/overview — Team stats + per-member video/seconds summary.""" team = request.user.team members = team.members.all().order_by('username') member_data = [] total_videos = 0 total_seconds = 0 for member in members: records = member.generation_records.filter(status='completed') video_count = records.count() seconds_consumed = records.aggregate(total=Sum('seconds_consumed'))['total'] or 0 total_videos += video_count total_seconds += seconds_consumed member_data.append({ 'id': member.id, 'username': member.username, 'is_team_admin': member.is_team_admin, 'video_count': video_count, 'seconds_consumed': seconds_consumed, }) return Response({ 'team_id': team.id, 'team_name': team.name, 'total_videos': total_videos, 'total_seconds': total_seconds, 'member_count': len(member_data), 'members': member_data, }) @api_view(['GET']) @permission_classes([IsTeamAdmin]) def team_assets_member_videos(request, member_id): """GET /api/v1/team/assets/member//videos — Completed videos for a team member (paginated).""" team = request.user.team try: member = team.members.get(id=member_id) except User.DoesNotExist: return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) page = int(request.query_params.get('page', 1)) page_size = min(int(request.query_params.get('page_size', 30)), 100) qs = member.generation_records.filter(status='completed').order_by('-created_at') total = qs.count() offset = (page - 1) * page_size records = _eval_qs(qs[offset:offset + page_size]) results = [] for r in records: results.append({ 'id': r.id, 'task_id': str(r.task_id), 'prompt': r.prompt, 'result_url': r.result_url or '', 'duration': r.duration, 'seconds_consumed': r.seconds_consumed, 'aspect_ratio': r.aspect_ratio, 'created_at': r.created_at.isoformat(), }) return Response({ 'user_id': member.id, 'username': member.username, 'total': total, 'page': page, 'page_size': page_size, 'results': results, })