seaislee1209 699a390f45 fix: v0.10.2 — admin重新编辑隐藏/frozen防负数/进度条刷新保持/navigate修正
- admin资产页视频详情隐藏「重新编辑」按钮(hideReEdit prop)
- 团管重新编辑跳转修正:navigate('/') → navigate('/app')
- _release_freeze 防止 frozen_amount 变负数
- 生成进度条用 sessionStorage 持久化,刷新页面后从之前位置继续

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:24:14 +08:00

2445 lines
95 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, Count
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
from utils.billing import get_resolution, estimate_tokens, calculate_cost, calculate_base_cost
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']
prompt = serializer.validated_data['prompt']
mode = serializer.validated_data['mode']
model = serializer.validated_data['model']
aspect_ratio = serializer.validated_data['aspect_ratio']
# ── 预估 token 和费用 ──
config = QuotaConfig.objects.get_or_create(pk=1)[0]
w, h = get_resolution(aspect_ratio)
estimated_tokens = estimate_tokens(w, h, duration)
estimated_cost = calculate_cost(estimated_tokens, config.base_token_price, team.markup_percentage)
# ── Layer 1: 用户每日生成次数限额 (skip if -1) ──
if user.daily_generation_limit != -1:
daily_count = user.generation_records.filter(created_at__date=today).count()
if daily_count >= user.daily_generation_limit:
return Response({
'error': 'quota_exceeded',
'message': f'您今日的生成次数已达上限({user.daily_generation_limit}次)',
'daily_generation_limit': user.daily_generation_limit,
'daily_generation_used': daily_count,
'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: 用户每月生成次数限额 (skip if -1) ──
if user.monthly_generation_limit != -1:
monthly_count = user.generation_records.filter(
created_at__date__gte=first_of_month
).count()
if monthly_count >= user.monthly_generation_limit:
return Response({
'error': 'quota_exceeded',
'message': f'您本月的生成次数已达上限({user.monthly_generation_limit}次)',
'monthly_generation_limit': user.monthly_generation_limit,
'monthly_generation_used': monthly_count,
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# ── Layer 3 & 4: 团队余额检查 + 冻结 (atomic with row lock) ──
with transaction.atomic():
locked_team = Team.objects.select_for_update().get(pk=team.pk)
# Layer 3: 团队月消费限额
if locked_team.monthly_spending_limit != -1:
team_monthly_spent = GenerationRecord.objects.filter(
user__team=locked_team,
created_at__date__gte=first_of_month,
).aggregate(total=Sum('cost_amount'))['total'] or 0
if team_monthly_spent + estimated_cost > locked_team.monthly_spending_limit:
return Response({
'error': 'quota_exceeded',
'message': '团队本月消费额度已用完',
'team_monthly_limit': float(locked_team.monthly_spending_limit),
'team_monthly_spent': float(team_monthly_spent),
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# Layer 4: 团队可用余额
available = locked_team.balance - locked_team.frozen_amount
if estimated_cost > available:
return Response({
'error': 'quota_exceeded',
'message': '团队余额不足,请联系管理员充值',
'team_balance': float(locked_team.balance),
'team_frozen': float(locked_team.frozen_amount),
'team_available': float(available),
'estimated_cost': float(estimated_cost),
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# 构建参考素材
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)
# 冻结(不扣余额)
record = GenerationRecord.objects.create(
user=user,
prompt=prompt,
mode=mode,
model=model,
aspect_ratio=aspect_ratio,
duration=duration,
seconds_consumed=duration,
frozen_amount=estimated_cost,
resolution='720p',
tokens_consumed=0,
cost_amount=0,
base_cost_amount=0,
reference_urls=reference_snapshots,
)
locked_team.frozen_amount = F('frozen_amount') + estimated_cost
locked_team.total_seconds_used = F('total_seconds_used') + duration
locked_team.save(update_fields=['frozen_amount', 'total_seconds_used'])
# ── 调用 AirDrama API事务外避免持锁 ──
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'])
# API 调用失败,释放冻结
_release_freeze(record)
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,
'estimated_tokens': estimated_tokens,
'estimated_cost': float(estimated_cost),
'error_message': getattr(record, 'error_message', '') or '',
}, status=status.HTTP_202_ACCEPTED)
def _release_freeze(record):
"""释放冻结金额(不扣费)。"""
if record.frozen_amount == 0:
return # already released
team = record.user.team
if not team:
return
frozen = record.frozen_amount
with transaction.atomic():
locked_team = Team.objects.select_for_update().get(pk=team.pk)
# 防止 frozen_amount 变负
actual_release = min(frozen, locked_team.frozen_amount)
if actual_release > 0:
locked_team.frozen_amount = F('frozen_amount') - actual_release
locked_team.total_seconds_used = F('total_seconds_used') - record.seconds_consumed
locked_team.save(update_fields=['frozen_amount', 'total_seconds_used'])
record.frozen_amount = 0
record.seconds_consumed = 0
record.save(update_fields=['frozen_amount', 'seconds_consumed'])
def _settle_payment(record, total_tokens):
"""任务完成时结算:按实际 tokens 扣费并释放冻结。"""
team = record.user.team
if not team:
return
config = QuotaConfig.objects.get_or_create(pk=1)[0]
actual_cost = calculate_cost(total_tokens, config.base_token_price, team.markup_percentage)
base_cost = calculate_base_cost(total_tokens, config.base_token_price)
frozen = record.frozen_amount
with transaction.atomic():
locked_team = Team.objects.select_for_update().get(pk=team.pk)
locked_team.balance = F('balance') - actual_cost
locked_team.total_spent = F('total_spent') + actual_cost
locked_team.frozen_amount = F('frozen_amount') - frozen
locked_team.save(update_fields=['balance', 'total_spent', 'frozen_amount'])
record.tokens_consumed = total_tokens
record.cost_amount = actual_cost
record.base_cost_amount = base_cost
record.frozen_amount = 0
record.save(update_fields=['tokens_consumed', 'cost_amount', 'base_cost_amount', 'frozen_amount'])
# ──────────────────────────────────────────────
# 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
# 结算:按实际 tokens 扣费
usage = ark_resp.get('usage', {})
total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0
if total_tokens > 0:
_settle_payment(record, total_tokens)
else:
# API 没返回 tokens异常释放冻结不扣费
_release_freeze(record)
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)
# 失败时检查是否产生了 token 消耗
usage = ark_resp.get('usage', {})
total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0
if total_tokens > 0:
# Seedance 已计费,按实际扣费(允许透支)
_settle_payment(record, total_tokens)
else:
# Seedance 未计费,释放冻结
_release_freeze(record)
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,
'tokens_consumed': record.tokens_consumed,
'cost_amount': float(record.cost_amount),
'base_cost_amount': float(record.base_cost_amount),
'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
# Cost-based stats
cost_today = GenerationRecord.objects.filter(
created_at__date=today
).aggregate(total=Sum('cost_amount'))['total'] or 0
cost_yesterday = GenerationRecord.objects.filter(
created_at__date=yesterday
).aggregate(total=Sum('cost_amount'))['total'] or 0
cost_this_month = GenerationRecord.objects.filter(
created_at__date__gte=first_of_month
).aggregate(total=Sum('cost_amount'))['total'] or 0
base_cost_today = GenerationRecord.objects.filter(
created_at__date=today
).aggregate(total=Sum('base_cost_amount'))['total'] or 0
base_cost_this_month = GenerationRecord.objects.filter(
created_at__date__gte=first_of_month
).aggregate(total=Sum('base_cost_amount'))['total'] or 0
# Total revenue / cost / profit
total_revenue = GenerationRecord.objects.aggregate(total=Sum('cost_amount'))['total'] or 0
total_base_cost = GenerationRecord.objects.aggregate(total=Sum('base_cost_amount'))['total'] or 0
total_profit = total_revenue - total_base_cost
profit_margin = round(float(total_profit) / max(float(total_revenue), 0.01) * 100, 1) if total_revenue else 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
cost_last_month_period = GenerationRecord.objects.filter(
created_at__date__gte=last_month_start,
created_at__date__lte=last_month_same_day
).aggregate(total=Sum('cost_amount'))['total'] or 0
today_change = round(((float(cost_today) - float(cost_yesterday)) / max(float(cost_yesterday), 0.01)) * 100, 1) if cost_yesterday else 0
month_change = round(((float(cost_this_month) - float(cost_last_month_period)) / max(float(cost_last_month_period), 0.01)) * 100, 1) if cost_last_month_period else 0
# Daily trend for past 30 days (cost + base_cost)
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'),
cost=Sum('cost_amount'),
base_cost=Sum('base_cost_amount'),
)
.order_by('date')
)
trend_map = {str(item['date']): item for item in daily_trend_qs}
daily_trend = []
for i in range(30):
d = thirty_days_ago + timedelta(days=i)
item = trend_map.get(str(d), {})
daily_trend.append({
'date': str(d),
'seconds': item.get('seconds') or 0,
'cost': float(item.get('cost') or 0),
'base_cost': float(item.get('base_cost') or 0),
})
# Top 10 users by cost consumed this month
top_users = (
User.objects.annotate(
cost_consumed=Sum(
'generation_records__cost_amount',
filter=Q(generation_records__created_at__date__gte=first_of_month),
),
seconds_consumed=Sum(
'generation_records__seconds_consumed',
filter=Q(generation_records__created_at__date__gte=first_of_month),
),
)
.filter(Q(cost_consumed__gt=0) | Q(seconds_consumed__gt=0))
.order_by('-cost_consumed', '-seconds_consumed')[:10]
)
# Team consumption ranking this month
top_teams = (
Team.objects.annotate(
cost_consumed=Sum(
'members__generation_records__cost_amount',
filter=Q(members__generation_records__created_at__date__gte=first_of_month),
),
seconds_consumed=Sum(
'members__generation_records__seconds_consumed',
filter=Q(members__generation_records__created_at__date__gte=first_of_month),
),
)
.filter(Q(cost_consumed__gt=0) | Q(seconds_consumed__gt=0))
.order_by('-cost_consumed', '-seconds_consumed')
)
# Team profit ranking
team_profit_ranking = (
Team.objects.annotate(
team_revenue=Sum(
'members__generation_records__cost_amount',
filter=Q(members__generation_records__created_at__date__gte=first_of_month),
),
team_base_cost=Sum(
'members__generation_records__base_cost_amount',
filter=Q(members__generation_records__created_at__date__gte=first_of_month),
),
)
.filter(team_revenue__gt=0)
.order_by('-team_revenue')
)
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,
'cost_today': float(cost_today),
'cost_this_month': float(cost_this_month),
'base_cost_today': float(base_cost_today),
'base_cost_this_month': float(base_cost_this_month),
'total_revenue': float(total_revenue),
'total_base_cost': float(total_base_cost),
'total_profit': float(total_profit),
'profit_margin': profit_margin,
'today_change_percent': today_change,
'month_change_percent': month_change,
'daily_trend': daily_trend,
'top_users': [
{
'user_id': u.id, 'username': u.username,
'cost_consumed': float(u.cost_consumed or 0),
'seconds_consumed': u.seconds_consumed or 0,
}
for u in top_users
],
'top_teams': [
{
'team_id': t.id, 'name': t.name,
'cost_consumed': float(t.cost_consumed or 0),
'seconds_consumed': t.seconds_consumed or 0,
}
for t in top_teams
],
'team_profit_ranking': [
{
'team_id': t.id, 'name': t.name,
'revenue': float(t.team_revenue or 0),
'base_cost': float(t.team_base_cost or 0),
'profit': float((t.team_revenue or 0) - (t.team_base_cost or 0)),
}
for t in team_profit_ranking
],
})
# ──────────────────────────────────────────────
# 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
monthly_spent = GenerationRecord.objects.filter(
user__team=t,
created_at__date__gte=first_of_month,
).aggregate(total=Sum('cost_amount'))['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,
'balance': float(t.balance),
'total_spent': float(t.total_spent),
'available_balance': float(t.available_balance),
'monthly_spending_limit': float(t.monthly_spending_limit),
'monthly_spent': float(monthly_spent),
'frozen_amount': float(t.frozen_amount),
'markup_percentage': float(t.markup_percentage),
'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,
'markup_percentage': float(team.markup_percentage),
'monthly_spending_limit': float(team.monthly_spending_limit),
'daily_member_spending_default': float(team.daily_member_spending_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,
'markup_percentage': float(team.markup_percentage),
'monthly_spending_limit': float(team.monthly_spending_limit),
'daily_member_spending_default': float(team.daily_member_spending_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/<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)
# Handle disabled_by based on is_active change
def _json_safe(v):
from decimal import Decimal as D
return float(v) if isinstance(v, D) else v
before = {f: _json_safe(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: _json_safe(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,
'markup_percentage': float(team.markup_percentage),
'monthly_spending_limit': float(team.monthly_spending_limit),
'daily_member_spending_default': float(team.daily_member_spending_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
monthly_spent = GenerationRecord.objects.filter(
user__team=team,
created_at__date__gte=first_of_month,
).aggregate(total=Sum('cost_amount'))['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),
),
generations_today=Count(
'generation_records',
filter=Q(generation_records__created_at__date=today),
),
generations_this_month=Count(
'generation_records',
filter=Q(generation_records__created_at__date__gte=first_of_month),
),
spent_today=Sum(
'generation_records__cost_amount',
filter=Q(generation_records__created_at__date=today),
),
spent_this_month=Sum(
'generation_records__cost_amount',
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,
'balance': float(team.balance),
'total_spent': float(team.total_spent),
'available_balance': float(team.available_balance),
'monthly_spending_limit': float(team.monthly_spending_limit),
'monthly_spent': float(monthly_spent),
'frozen_amount': float(team.frozen_amount),
'markup_percentage': float(team.markup_percentage),
'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,
'daily_generation_limit': m.daily_generation_limit,
'monthly_generation_limit': m.monthly_generation_limit,
'seconds_today': m.seconds_today or 0,
'seconds_this_month': m.seconds_this_month or 0,
'generations_today': m.generations_today or 0,
'generations_this_month': m.generations_this_month or 0,
'spent_today': float(m.spent_today or 0),
'spent_this_month': float(m.spent_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 balance to team."""
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)
amount = serializer.validated_data['amount']
old_balance = float(team.balance)
with transaction.atomic():
locked = Team.objects.select_for_update().get(pk=team.pk)
locked.balance = F('balance') + amount
locked.save(update_fields=['balance'])
team.refresh_from_db()
log_admin_action(request, 'team_topup', 'team', target_id=team.id, target_name=team.name,
before={'balance': old_balance},
after={'balance': float(team.balance), 'topped_up': float(amount)})
return Response({
'id': team.id,
'name': team.name,
'balance': float(team.balance),
'total_spent': float(team.total_spent),
'available_balance': float(team.available_balance),
'frozen_amount': float(team.frozen_amount),
'topped_up': float(amount),
'total_seconds_pool': team.total_seconds_pool,
'total_seconds_used': team.total_seconds_used,
'remaining_seconds': team.remaining_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 team balance."""
try:
team = Team.objects.get(id=team_id)
except Team.DoesNotExist:
return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND)
# Accept both 'balance' (new) and 'total_seconds_pool' (backward compat)
new_balance = request.data.get('balance')
new_pool = request.data.get('total_seconds_pool')
if new_balance is not None:
from decimal import Decimal, InvalidOperation
try:
new_balance = Decimal(str(new_balance))
except (InvalidOperation, ValueError, TypeError):
return Response({'error': '请输入有效的金额'}, status=status.HTTP_400_BAD_REQUEST)
if new_balance < 0:
return Response({'error': '余额不能为负数'}, status=status.HTTP_400_BAD_REQUEST)
old_balance = float(team.balance)
with transaction.atomic():
locked = Team.objects.select_for_update().get(pk=team.pk)
locked.balance = new_balance
locked.save(update_fields=['balance'])
team.refresh_from_db()
log_admin_action(request, 'team_set_pool', 'team', target_id=team.id, target_name=team.name,
before={'balance': old_balance},
after={'balance': float(team.balance)})
return Response({
'id': team.id,
'name': team.name,
'balance': float(team.balance),
'total_spent': float(team.total_spent),
'available_balance': float(team.available_balance),
'frozen_amount': float(team.frozen_amount),
'total_seconds_pool': team.total_seconds_pool,
'total_seconds_used': team.total_seconds_used,
'remaining_seconds': team.remaining_seconds,
})
# Backward compat: total_seconds_pool
if new_pool is None:
return Response({'error': 'balance or 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,
'balance': float(team.balance),
'total_spent': float(team.total_spent),
'available_balance': float(team.available_balance),
'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)
config = QuotaConfig.objects.get_or_create(pk=1)[0]
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
daily_generation_limit=-1, # Team admin unlimited by default
monthly_generation_limit=-1,
)
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),
),
generations_today=Count(
'generation_records',
filter=Q(generation_records__created_at__date=today),
),
generations_this_month=Count(
'generation_records',
filter=Q(generation_records__created_at__date__gte=first_of_month),
),
spent_today=Sum(
'generation_records__cost_amount',
filter=Q(generation_records__created_at__date=today),
),
spent_this_month=Sum(
'generation_records__cost_amount',
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,
'daily_generation_limit': u.daily_generation_limit,
'monthly_generation_limit': u.monthly_generation_limit,
'seconds_today': u.seconds_today or 0,
'seconds_this_month': u.seconds_this_month or 0,
'generations_today': u.generations_today or 0,
'generations_this_month': u.generations_this_month or 0,
'spent_today': float(u.spent_today or 0),
'spent_this_month': float(u.spent_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
generations_today = user.generation_records.filter(
created_at__date=today
).count()
generations_this_month = user.generation_records.filter(
created_at__date__gte=first_of_month
).count()
spent_today = user.generation_records.filter(
created_at__date=today
).aggregate(total=Sum('cost_amount'))['total'] or 0
spent_this_month = user.generation_records.filter(
created_at__date__gte=first_of_month
).aggregate(total=Sum('cost_amount'))['total'] or 0
total_spent = user.generation_records.aggregate(
total=Sum('cost_amount')
)['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,
'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_limit,
'seconds_today': seconds_today,
'seconds_this_month': seconds_this_month,
'seconds_total': seconds_total,
'generations_today': generations_today,
'generations_this_month': generations_this_month,
'spent_today': float(spent_today),
'spent_this_month': float(spent_this_month),
'total_spent': float(total_spent),
'recent_records': [
{
'id': r.id,
'created_at': r.created_at.isoformat(),
'seconds_consumed': r.seconds_consumed,
'tokens_consumed': r.tokens_consumed,
'cost_amount': float(r.cost_amount),
'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_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_limit,
}
user.daily_generation_limit = serializer.validated_data['daily_generation_limit']
user.monthly_generation_limit = serializer.validated_data['monthly_generation_limit']
user.save(update_fields=['daily_generation_limit', 'monthly_generation_limit'])
log_admin_action(request, 'user_quota_update', 'user', target_id=user.id, target_name=user.username,
before=before,
after={
'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_limit,
})
return Response({
'user_id': user.id,
'username': user.username,
'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_limit,
'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'],
daily_generation_limit=serializer.validated_data['daily_generation_limit'],
monthly_generation_limit=serializer.validated_data['monthly_generation_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,
'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_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,
'tokens_consumed': r.tokens_consumed,
'cost_amount': float(r.cost_amount),
'base_cost_amount': float(r.base_cost_amount),
'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,
'default_daily_generation_limit': config.default_daily_generation_limit,
'default_monthly_generation_limit': config.default_monthly_generation_limit,
'base_token_price': float(config.base_token_price),
'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/<id>/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/<id>/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
monthly_spent = GenerationRecord.objects.filter(
user__team=team,
created_at__date__gte=first_of_month,
).aggregate(total=Sum('cost_amount'))['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,
'balance': float(team.balance),
'total_spent': float(team.total_spent),
'available_balance': float(team.available_balance),
'monthly_spending_limit': float(team.monthly_spending_limit),
'monthly_spent': float(monthly_spent),
'frozen_amount': float(team.frozen_amount),
'markup_percentage': float(team.markup_percentage),
'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 (cost + base_cost)
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'),
cost=Sum('cost_amount'),
base_cost=Sum('base_cost_amount'),
)
.order_by('date')
)
trend_map = {str(item['date']): item for item in daily_trend_qs}
daily_trend = []
for i in range(30):
d = thirty_days_ago + timedelta(days=i)
item = trend_map.get(str(d), {})
daily_trend.append({
'date': str(d),
'seconds': item.get('seconds') or 0,
'cost': float(item.get('cost') or 0),
'base_cost': float(item.get('base_cost') or 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),
),
cost_this_month=Sum(
'generation_records__cost_amount',
filter=Q(generation_records__created_at__date__gte=first_of_month),
),
generations_this_month=Count(
'generation_records',
filter=Q(generation_records__created_at__date__gte=first_of_month),
),
).filter(generations_this_month__gt=0).order_by('-cost_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,
'cost_consumed': float(m.cost_this_month or 0),
'generation_count': m.generations_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),
),
generations_today=Count(
'generation_records',
filter=Q(generation_records__created_at__date=today),
),
generations_this_month=Count(
'generation_records',
filter=Q(generation_records__created_at__date__gte=first_of_month),
),
spent_today=Sum(
'generation_records__cost_amount',
filter=Q(generation_records__created_at__date=today),
),
spent_this_month=Sum(
'generation_records__cost_amount',
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,
'daily_generation_limit': m.daily_generation_limit,
'monthly_generation_limit': m.monthly_generation_limit,
'seconds_today': m.seconds_today or 0,
'seconds_this_month': m.seconds_this_month or 0,
'generations_today': m.generations_today or 0,
'generations_this_month': m.generations_this_month or 0,
'spent_today': float(m.spent_today or 0),
'spent_this_month': float(m.spent_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)
# Generation count limits
config = QuotaConfig.objects.get_or_create(pk=1)[0]
daily_gen = serializer.validated_data.get('daily_generation_limit', config.default_daily_generation_limit)
monthly_gen = serializer.validated_data.get('monthly_generation_limit', config.default_monthly_generation_limit)
# 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,
daily_generation_limit=daily_gen,
monthly_generation_limit=monthly_gen,
)
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,
'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_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
generations_today = member.generation_records.filter(
created_at__date=today
).count()
generations_this_month = member.generation_records.filter(
created_at__date__gte=first_of_month
).count()
spent_today = member.generation_records.filter(
created_at__date=today
).aggregate(total=Sum('cost_amount'))['total'] or 0
spent_this_month = member.generation_records.filter(
created_at__date__gte=first_of_month
).aggregate(total=Sum('cost_amount'))['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,
'daily_generation_limit': member.daily_generation_limit,
'monthly_generation_limit': member.monthly_generation_limit,
'seconds_today': seconds_today,
'seconds_this_month': seconds_this_month,
'generations_today': generations_today,
'generations_this_month': generations_this_month,
'spent_today': float(spent_today),
'spent_this_month': float(spent_this_month),
'recent_records': [
{
'id': r.id,
'created_at': r.created_at.isoformat(),
'seconds_consumed': r.seconds_consumed,
'tokens_consumed': r.tokens_consumed,
'cost_amount': float(r.cost_amount),
'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_generation_limit': member.daily_generation_limit,
'monthly_generation_limit': member.monthly_generation_limit,
}
member.daily_generation_limit = serializer.validated_data['daily_generation_limit']
member.monthly_generation_limit = serializer.validated_data['monthly_generation_limit']
member.save(update_fields=['daily_generation_limit', 'monthly_generation_limit'])
log_admin_action(request, 'member_quota_update', 'user', target_id=member.id, target_name=member.username,
before=before,
after={
'daily_generation_limit': member.daily_generation_limit,
'monthly_generation_limit': member.monthly_generation_limit,
})
return Response({
'user_id': member.id,
'username': member.username,
'daily_generation_limit': member.daily_generation_limit,
'monthly_generation_limit': member.monthly_generation_limit,
'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
# Count-based usage
daily_generation_used = user.generation_records.filter(
created_at__date=today
).count()
monthly_generation_used = user.generation_records.filter(
created_at__date__gte=first_of_month
).count()
# Spending
daily_spent = user.generation_records.filter(
created_at__date=today
).aggregate(total=Sum('cost_amount'))['total'] or 0
monthly_spent = user.generation_records.filter(
created_at__date__gte=first_of_month
).aggregate(total=Sum('cost_amount'))['total'] or 0
total_spent = user.generation_records.aggregate(
total=Sum('cost_amount')
)['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'),
cost=Sum('cost_amount'),
count=Count('id'),
)
.order_by('date')
)
trend_map = {str(item['date']): item for item in trend_qs}
daily_trend = []
for i in range(days):
d = start_date + timedelta(days=i)
item = trend_map.get(str(d), {})
daily_trend.append({
'date': str(d),
'seconds': item.get('seconds') or 0,
'cost': float(item.get('cost') or 0),
'count': item.get('count') or 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_generation_limit': user.daily_generation_limit,
'daily_generation_used': daily_generation_used,
'monthly_generation_limit': user.monthly_generation_limit,
'monthly_generation_used': monthly_generation_used,
'daily_spent': float(daily_spent),
'monthly_spent': float(monthly_spent),
'total_spent': float(total_spent),
'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
team_monthly_spent = GenerationRecord.objects.filter(
user__team=team,
created_at__date__gte=first_of_month,
).aggregate(total=Sum('cost_amount'))['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,
'balance': float(team.balance),
'total_spent': float(team.total_spent),
'available_balance': float(team.available_balance),
'monthly_spending_limit': float(team.monthly_spending_limit),
'monthly_spent': float(team_monthly_spent),
'frozen_amount': float(team.frozen_amount),
}
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,
'tokens_consumed': r.tokens_consumed,
'cost_amount': float(r.cost_amount),
'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,
'reference_urls': r.reference_urls or [],
'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,
'reference_urls': r.reference_urls or [],
'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,
})