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

1767 lines
67 KiB
Python

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,
)
from apps.accounts.models import Team, AdminAuditLog, log_admin_action
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/<task_id> — Poll Seedance + refund on failure.
DELETE /api/v1/video/tasks/<task_id> — 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,
'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})
return Response({
'id': team.id,
'name': team.name,
'monthly_seconds_limit': team.monthly_seconds_limit,
'daily_member_limit_default': team.daily_member_limit_default,
'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/<id> — 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)
before = {f: getattr(team, f) for f in serializer.validated_data}
for field, value in serializer.validated_data.items():
setattr(team, field, value)
team.save()
after = {f: getattr(team, f) for f in serializer.validated_data}
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,
'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')
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,
'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,
'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/<id>/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/<id>/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/<id>/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,
'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
user.is_active = serializer.validated_data['is_active']
user.save(update_fields=['is_active'])
log_admin_action(request, 'user_status_toggle', 'user', target_id=user.id, target_name=user.username,
before={'is_active': old_active}, after={'is_active': user.is_active})
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
# ──────────────────────────────────────────────
@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({
'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,
})
serializer = SystemSettingsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
before = {
'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,
}
config.default_daily_seconds_limit = serializer.validated_data['default_daily_seconds_limit']
config.default_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit']
config.announcement = serializer.validated_data.get('announcement', '')
config.announcement_enabled = serializer.validated_data.get('announcement_enabled', False)
config.max_desktop_sessions = serializer.validated_data.get('max_desktop_sessions', 1)
config.max_mobile_sessions = serializer.validated_data.get('max_mobile_sessions', 0)
config.save()
log_admin_action(request, 'settings_update', 'settings', target_name='系统设置',
before=before,
after={
'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,
})
return Response({
'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,
'updated_at': config.updated_at.isoformat(),
})
# ──────────────────────────────────────────────
# 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/<id> — 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/<id>/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/<id>/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/<id>/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/<id>/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/<id>/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,
})