素材库(虚拟人像): - 后端:AssetGroup/Asset 模型 + 火山 Assets API 客户端 + 7 个 API 端点 - 前端:素材库管理弹窗(上传/浏览/追加/改名/状态轮询) - PromptInput:@ 搜索素材库 + mention 标签(缩略图+名字) - 提交生成时提取 asset:// 引用并去重 - 打开素材详情时自动检查云端状态,已删除的自动清理 - 后端 reference_snapshots 存储 thumb_url,刷新后标签缩略图和 hover 预览正常 生成页面 UI: - 提示词 hover 即梦风格:原位展开玻璃底覆盖视频,不弹浮层 - 标签(AirDrama/时长/比例)inline 排列,溢出时 canvas 截断 - 详细信息弹窗支持鼠标移上去不消失(延迟关闭),增加 token/费用信息 - 任务卡片/视频详情页提示词标签化(renderPromptWithMentions) - 视频详情页底部去掉重复按钮,信息栏 flex-wrap 自动换行 mention 标签: - 输入框内剪切/复制粘贴保留标签(handlePaste 检测 text/html) - 拖拽标签跟手(caretRangeFromPoint + drop 位置精确插入) - 拖拽时 hover 预览自动关闭,InputBar 蓝边仅外部文件拖入时触发 其他: - 联网搜索按钮(暂禁用,等火山确认 API) - card max-width 800→1024,参考图缩略图 48→56px 居中对齐 - 导航箭头禁用时不触发关闭(去掉 pointer-events:none) - API 错误信息附带原始报错便于排查 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2914 lines
112 KiB
Python
2914 lines
112 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, Count, Exists, OuterRef
|
||
from django.db.models.functions import TruncDate
|
||
from django.db.utils import OperationalError as DbOperationalError
|
||
from datetime import timedelta
|
||
|
||
from .models import GenerationRecord, QuotaConfig, AssetGroup, Asset
|
||
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, ActiveSession, LoginRecord
|
||
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']
|
||
search_mode = request.data.get('search_mode', 'off')
|
||
|
||
# ── 预估 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 = []
|
||
seen_urls = set() # 去重:同一个素材只引用一次
|
||
|
||
from .models import Asset as AssetModel
|
||
|
||
for ref in references:
|
||
url = ref.get('url', '')
|
||
original_url = url # 保留原始 URL 用于 reference_snapshots
|
||
ref_type = ref.get('type', 'image')
|
||
role = ref.get('role', '')
|
||
label = ref.get('label', '')
|
||
|
||
# 跳过重复 URL — 在所有操作之前判断
|
||
if original_url in seen_urls:
|
||
continue
|
||
seen_urls.add(original_url)
|
||
|
||
# 快照存原始 URL(前端重建 reEdit 需要 asset://group-{id} 格式)
|
||
snap = {'url': original_url, 'type': ref_type, 'role': role, 'label': label}
|
||
thumb_url = ref.get('thumb_url', '')
|
||
if thumb_url:
|
||
snap['thumb_url'] = thumb_url
|
||
reference_snapshots.append(snap)
|
||
|
||
# 转换 asset://group-{id} 为火山 Asset://Asset-xxx 格式(仅用于 content_items)
|
||
resolved_url = url
|
||
if url.startswith('asset://group-'):
|
||
try:
|
||
group_id = int(url.replace('asset://group-', ''))
|
||
first_asset = AssetModel.objects.filter(
|
||
group_id=group_id, status='active'
|
||
).first()
|
||
if first_asset and first_asset.remote_asset_id:
|
||
aid = first_asset.remote_asset_id
|
||
if aid.startswith('asset-'):
|
||
aid = 'Asset-' + aid[6:]
|
||
resolved_url = f'Asset://{aid}'
|
||
else:
|
||
logger.warning('No active asset found for group %s', group_id)
|
||
except (ValueError, Exception) as e:
|
||
logger.warning('Failed to resolve asset group URL %s: %s', url, e)
|
||
|
||
if ref_type == 'image':
|
||
item = {'type': 'image_url', 'image_url': {'url': resolved_url}}
|
||
if role:
|
||
item['role'] = role
|
||
content_items.append(item)
|
||
elif ref_type == 'video':
|
||
item = {'type': 'video_url', 'video_url': {'url': resolved_url}}
|
||
if role:
|
||
item['role'] = role
|
||
content_items.append(item)
|
||
elif ref_type == 'audio':
|
||
item = {'type': 'audio_url', 'audio_url': {'url': resolved_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,
|
||
search_mode=search_mode,
|
||
)
|
||
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),
|
||
),
|
||
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
|
||
).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),
|
||
'is_online': m.is_online,
|
||
'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),
|
||
),
|
||
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
|
||
)
|
||
|
||
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),
|
||
'is_online': u.is_online,
|
||
})
|
||
|
||
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),
|
||
),
|
||
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
|
||
).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),
|
||
'is_online': m.is_online,
|
||
'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,
|
||
'is_online': ActiveSession.objects.filter(user=member).exists(),
|
||
})
|
||
|
||
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,
|
||
'is_online': ActiveSession.objects.filter(user=member).exists(),
|
||
})
|
||
|
||
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,
|
||
})
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# Admin: Login Records
|
||
# ──────────────────────────────────────────────
|
||
|
||
@api_view(['GET'])
|
||
@permission_classes([IsSuperAdmin])
|
||
def admin_login_records_view(request):
|
||
"""GET /api/v1/admin/login-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()
|
||
team_id = request.query_params.get('team_id', '').strip()
|
||
start_date = request.query_params.get('start_date', '').strip()
|
||
end_date = request.query_params.get('end_date', '').strip()
|
||
city = request.query_params.get('city', '').strip()
|
||
|
||
qs = LoginRecord.objects.select_related('user', 'team').order_by('-created_at')
|
||
|
||
if search:
|
||
qs = qs.filter(user__username__icontains=search)
|
||
if team_id:
|
||
qs = qs.filter(team_id=int(team_id))
|
||
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 city:
|
||
qs = qs.filter(geo_city__icontains=city)
|
||
|
||
total = qs.count()
|
||
offset = (page - 1) * page_size
|
||
records = list(qs[offset:offset + page_size])
|
||
|
||
results = []
|
||
for r in records:
|
||
results.append({
|
||
'id': r.id,
|
||
'username': r.user.username,
|
||
'user_id': r.user_id,
|
||
'team_name': r.team.name if r.team else None,
|
||
'ip_address': r.ip_address or '',
|
||
'geo_country': r.geo_country,
|
||
'geo_province': r.geo_province,
|
||
'geo_city': r.geo_city,
|
||
'geo_source': r.geo_source,
|
||
'user_agent': r.user_agent,
|
||
'created_at': r.created_at.isoformat(),
|
||
})
|
||
|
||
return Response({
|
||
'total': total,
|
||
'page': page,
|
||
'page_size': page_size,
|
||
'results': results,
|
||
})
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# Virtual Avatar Asset Library
|
||
# ──────────────────────────────────────────────
|
||
|
||
def _assets_api_call(func, *args, **kwargs):
|
||
"""Safely call an assets_client function; returns (result, error_response).
|
||
|
||
If ASSETS_API_ENABLED is False the remote call is skipped and an empty
|
||
placeholder is returned so that local DB records are still created.
|
||
"""
|
||
from django.conf import settings as django_settings
|
||
if not django_settings.ASSETS_API_ENABLED:
|
||
return None, None
|
||
try:
|
||
from utils.assets_client import AssetsAPIError
|
||
result = func(*args, **kwargs)
|
||
return result, None
|
||
except AssetsAPIError as e:
|
||
logger.warning('Assets API error: %s', e)
|
||
return None, Response(
|
||
{'error': 'assets_api_error', 'message': str(e)},
|
||
status=status.HTTP_502_BAD_GATEWAY,
|
||
)
|
||
except Exception as e:
|
||
logger.exception('Assets API unexpected error')
|
||
return None, Response(
|
||
{'error': 'assets_api_error', 'message': f'素材 API 调用失败: {e}'},
|
||
status=status.HTTP_502_BAD_GATEWAY,
|
||
)
|
||
|
||
|
||
@api_view(['GET', 'POST'])
|
||
@permission_classes([IsTeamMember])
|
||
@parser_classes([MultiPartParser, JSONParser])
|
||
def asset_groups_view(request):
|
||
"""GET /api/v1/assets/groups — list groups for current team.
|
||
POST /api/v1/assets/groups — create a group with an initial image.
|
||
"""
|
||
team = request.user.team
|
||
|
||
if request.method == 'GET':
|
||
groups = (
|
||
AssetGroup.objects
|
||
.filter(team=team)
|
||
.annotate(asset_count=Count('assets'))
|
||
.order_by('-created_at')
|
||
)
|
||
results = []
|
||
for g in groups:
|
||
results.append({
|
||
'id': g.id,
|
||
'name': g.name,
|
||
'thumbnail_url': g.thumbnail_url,
|
||
'asset_count': g.asset_count,
|
||
'remote_group_id': g.remote_group_id,
|
||
'created_at': g.created_at.isoformat(),
|
||
})
|
||
return Response({'results': results})
|
||
|
||
# ── POST: create group + first asset ──
|
||
name = request.data.get('name', '').strip()
|
||
if not name:
|
||
return Response({'error': '请输入角色名称'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
file = request.FILES.get('file')
|
||
if not file:
|
||
return Response({'error': '请上传一张素材图片'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# Upload to TOS
|
||
try:
|
||
tos_url = tos_upload(file, folder='assets')
|
||
except Exception as e:
|
||
logger.exception('TOS upload failed for asset')
|
||
return Response(
|
||
{'error': f'文件上传失败: {e}'},
|
||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
)
|
||
|
||
# Create remote group
|
||
from utils import assets_client
|
||
remote_group_id = ''
|
||
result, err = _assets_api_call(assets_client.create_asset_group, name)
|
||
if err:
|
||
return err
|
||
if result is not None:
|
||
remote_group_id = result
|
||
|
||
# Create remote asset
|
||
remote_asset_id = ''
|
||
if remote_group_id:
|
||
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name)
|
||
if err:
|
||
return err
|
||
if result is not None:
|
||
remote_asset_id = result
|
||
|
||
# Local DB records
|
||
group = AssetGroup.objects.create(
|
||
team=team,
|
||
remote_group_id=remote_group_id,
|
||
name=name,
|
||
description='',
|
||
thumbnail_url=tos_url,
|
||
created_by=request.user,
|
||
)
|
||
Asset.objects.create(
|
||
group=group,
|
||
remote_asset_id=remote_asset_id,
|
||
name=name,
|
||
url=tos_url,
|
||
status='processing' if remote_asset_id else 'active',
|
||
error_message='',
|
||
)
|
||
|
||
return Response({
|
||
'id': group.id,
|
||
'name': group.name,
|
||
'thumbnail_url': group.thumbnail_url,
|
||
'remote_group_id': group.remote_group_id,
|
||
'asset_count': 1,
|
||
'created_at': group.created_at.isoformat(),
|
||
}, status=status.HTTP_201_CREATED)
|
||
|
||
|
||
@api_view(['GET', 'PUT'])
|
||
@permission_classes([IsTeamMember])
|
||
@parser_classes([JSONParser])
|
||
def asset_group_detail_view(request, group_id):
|
||
"""GET /api/v1/assets/groups/<id> — group info + assets.
|
||
PUT /api/v1/assets/groups/<id> — update name/description.
|
||
"""
|
||
team = request.user.team
|
||
try:
|
||
group = AssetGroup.objects.get(pk=group_id, team=team)
|
||
except AssetGroup.DoesNotExist:
|
||
return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||
|
||
if request.method == 'GET':
|
||
assets_qs = Asset.objects.filter(group=group).order_by('-created_at')
|
||
asset_list = []
|
||
for a in assets_qs:
|
||
asset_list.append({
|
||
'id': a.id,
|
||
'name': a.name,
|
||
'url': a.url,
|
||
'status': a.status,
|
||
'remote_asset_id': a.remote_asset_id,
|
||
'error_message': a.error_message,
|
||
'created_at': a.created_at.isoformat(),
|
||
})
|
||
return Response({
|
||
'id': group.id,
|
||
'name': group.name,
|
||
'description': group.description,
|
||
'thumbnail_url': group.thumbnail_url,
|
||
'remote_group_id': group.remote_group_id,
|
||
'created_at': group.created_at.isoformat(),
|
||
'assets': asset_list,
|
||
})
|
||
|
||
# ── PUT ──
|
||
new_name = request.data.get('name')
|
||
new_desc = request.data.get('description')
|
||
|
||
if new_name is None and new_desc is None:
|
||
return Response({'error': '请提供要更新的字段'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# Update remote
|
||
if group.remote_group_id:
|
||
from utils import assets_client
|
||
_, err = _assets_api_call(
|
||
assets_client.update_asset_group,
|
||
group.remote_group_id, name=new_name, description=new_desc,
|
||
)
|
||
if err:
|
||
return err
|
||
|
||
update_fields = []
|
||
if new_name is not None:
|
||
group.name = new_name
|
||
update_fields.append('name')
|
||
if new_desc is not None:
|
||
group.description = new_desc
|
||
update_fields.append('description')
|
||
group.save(update_fields=update_fields)
|
||
|
||
return Response({
|
||
'id': group.id,
|
||
'name': group.name,
|
||
'description': group.description,
|
||
'thumbnail_url': group.thumbnail_url,
|
||
'remote_group_id': group.remote_group_id,
|
||
})
|
||
|
||
|
||
@api_view(['POST'])
|
||
@permission_classes([IsTeamMember])
|
||
@parser_classes([MultiPartParser])
|
||
def asset_group_add_asset_view(request, group_id):
|
||
"""POST /api/v1/assets/groups/<id>/assets — add an image to a group."""
|
||
team = request.user.team
|
||
try:
|
||
group = AssetGroup.objects.get(pk=group_id, team=team)
|
||
except AssetGroup.DoesNotExist:
|
||
return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||
|
||
file = request.FILES.get('file')
|
||
if not file:
|
||
return Response({'error': '请上传文件'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
name = request.data.get('name', '').strip() or file.name
|
||
|
||
# Upload to TOS
|
||
try:
|
||
tos_url = tos_upload(file, folder='assets')
|
||
except Exception as e:
|
||
logger.exception('TOS upload failed for asset')
|
||
return Response(
|
||
{'error': f'文件上传失败: {e}'},
|
||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
)
|
||
|
||
# Create remote asset
|
||
from utils import assets_client
|
||
remote_asset_id = ''
|
||
if group.remote_group_id:
|
||
result, err = _assets_api_call(
|
||
assets_client.create_asset, group.remote_group_id, tos_url, name,
|
||
)
|
||
if err:
|
||
return err
|
||
if result is not None:
|
||
remote_asset_id = result
|
||
|
||
asset = Asset.objects.create(
|
||
group=group,
|
||
remote_asset_id=remote_asset_id,
|
||
name=name,
|
||
url=tos_url,
|
||
status='processing' if remote_asset_id else 'active',
|
||
error_message='',
|
||
)
|
||
|
||
# If first asset, set thumbnail
|
||
if not group.thumbnail_url:
|
||
group.thumbnail_url = tos_url
|
||
group.save(update_fields=['thumbnail_url'])
|
||
|
||
return Response({
|
||
'id': asset.id,
|
||
'name': asset.name,
|
||
'url': asset.url,
|
||
'status': asset.status,
|
||
'remote_asset_id': asset.remote_asset_id,
|
||
'created_at': asset.created_at.isoformat(),
|
||
}, status=status.HTTP_201_CREATED)
|
||
|
||
|
||
@api_view(['PUT'])
|
||
@permission_classes([IsTeamMember])
|
||
@parser_classes([JSONParser])
|
||
def asset_update_view(request, asset_id):
|
||
"""PUT /api/v1/assets/<id> — rename an asset."""
|
||
team = request.user.team
|
||
try:
|
||
asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team)
|
||
except Asset.DoesNotExist:
|
||
return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||
|
||
new_name = request.data.get('name')
|
||
if not new_name:
|
||
return Response({'error': '请提供素材名称'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
if asset.remote_asset_id:
|
||
from utils import assets_client
|
||
_, err = _assets_api_call(assets_client.update_asset, asset.remote_asset_id, name=new_name)
|
||
if err:
|
||
return err
|
||
|
||
asset.name = new_name
|
||
asset.save(update_fields=['name'])
|
||
|
||
return Response({
|
||
'id': asset.id,
|
||
'name': asset.name,
|
||
'url': asset.url,
|
||
'status': asset.status,
|
||
})
|
||
|
||
|
||
@api_view(['GET'])
|
||
@permission_classes([IsTeamMember])
|
||
def asset_search_view(request):
|
||
"""GET /api/v1/assets/search?q=... — fast search for @ popup."""
|
||
team = request.user.team
|
||
q = request.query_params.get('q', '').strip()
|
||
if not q:
|
||
return Response({'results': []})
|
||
|
||
groups = (
|
||
AssetGroup.objects
|
||
.filter(team=team, name__icontains=q)
|
||
.order_by('-created_at')[:20]
|
||
)
|
||
results = []
|
||
for g in groups:
|
||
results.append({
|
||
'id': g.id,
|
||
'name': g.name,
|
||
'thumbnail_url': g.thumbnail_url,
|
||
'remote_group_id': g.remote_group_id,
|
||
})
|
||
return Response({'results': results})
|
||
|
||
|
||
@api_view(['GET'])
|
||
@permission_classes([IsTeamMember])
|
||
def asset_poll_status_view(request, asset_id):
|
||
"""GET /api/v1/assets/<id>/status — poll remote processing status."""
|
||
team = request.user.team
|
||
try:
|
||
asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team)
|
||
except Asset.DoesNotExist:
|
||
return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||
|
||
if asset.remote_asset_id:
|
||
from utils import assets_client
|
||
from utils.assets_client import AssetsAPIError
|
||
try:
|
||
result = assets_client.get_asset(asset.remote_asset_id)
|
||
remote_status = result.get('Status', '')
|
||
if remote_status == 'Active':
|
||
asset.status = 'active'
|
||
asset.url = result.get('Url', asset.url)
|
||
elif remote_status == 'Failed':
|
||
asset.status = 'failed'
|
||
asset.error_message = result.get('ErrorMessage', '')
|
||
else:
|
||
asset.status = 'processing'
|
||
asset.save(update_fields=['status', 'url', 'error_message'])
|
||
except AssetsAPIError as e:
|
||
error_str = str(e)
|
||
# 火山返回素材不存在 → 删除本地记录
|
||
if 'not found' in error_str.lower() or 'NotFound' in e.code or 'NotExist' in e.code:
|
||
asset.delete()
|
||
return Response({'status': 'deleted', 'message': '素材在云端已被删除'})
|
||
return Response(
|
||
{'error': 'assets_api_error', 'message': error_str},
|
||
status=status.HTTP_502_BAD_GATEWAY,
|
||
)
|
||
except Exception as e:
|
||
error_str = str(e)
|
||
# 空响应 / JSON 解析失败 = 火山已删除该素材
|
||
if 'Expecting value' in error_str or 'JSONDecodeError' in type(e).__name__:
|
||
logger.info('Asset %s appears deleted on remote (empty response), removing local record', asset.remote_asset_id)
|
||
asset.delete()
|
||
return Response({'status': 'deleted', 'message': '素材在云端已被删除'})
|
||
logger.exception('Assets API unexpected error in poll')
|
||
return Response(
|
||
{'error': 'assets_api_error', 'message': f'素材 API 调用失败: {e}'},
|
||
status=status.HTTP_502_BAD_GATEWAY,
|
||
)
|
||
|
||
return Response({
|
||
'id': asset.id,
|
||
'name': asset.name,
|
||
'url': asset.url,
|
||
'status': asset.status,
|
||
'error_message': asset.error_message,
|
||
})
|