diff --git a/backend/apps/accounts/migrations/0015_add_username_update_audit_action.py b/backend/apps/accounts/migrations/0015_add_username_update_audit_action.py new file mode 100644 index 0000000..efabd1d --- /dev/null +++ b/backend/apps/accounts/migrations/0015_add_username_update_audit_action.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-05-18 15:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0014_set_existing_admins_as_owners'), + ] + + operations = [ + migrations.AlterField( + model_name='adminauditlog', + name='action', + field=models.CharField(choices=[('team_create', '创建团队'), ('team_update', '更新团队'), ('team_topup', '团队充值'), ('team_set_pool', '设置团队额度池'), ('team_create_admin', '创建团队管理员'), ('user_create', '创建用户'), ('user_quota_update', '更新用户额度'), ('user_status_toggle', '切换用户状态'), ('settings_update', '更新系统设置'), ('member_create', '创建团队成员'), ('member_quota_update', '更新成员额度'), ('member_status_toggle', '切换成员状态'), ('user_password_reset', '重置用户密码'), ('user_username_update', '修改用户名')], max_length=30, verbose_name='操作类型'), + ), + ] diff --git a/backend/apps/accounts/migrations/0016_user_is_observer.py b/backend/apps/accounts/migrations/0016_user_is_observer.py new file mode 100644 index 0000000..faf4627 --- /dev/null +++ b/backend/apps/accounts/migrations/0016_user_is_observer.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.29 on 2026-05-18 15:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0015_add_username_update_audit_action'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_observer', + field=models.BooleanField(default=False, verbose_name='观察者(仅对团管生效,可看全局资产)'), + ), + migrations.AlterField( + model_name='adminauditlog', + name='action', + field=models.CharField(choices=[('team_create', '创建团队'), ('team_update', '更新团队'), ('team_topup', '团队充值'), ('team_set_pool', '设置团队额度池'), ('team_create_admin', '创建团队管理员'), ('user_create', '创建用户'), ('user_quota_update', '更新用户额度'), ('user_status_toggle', '切换用户状态'), ('settings_update', '更新系统设置'), ('member_create', '创建团队成员'), ('member_quota_update', '更新成员额度'), ('member_status_toggle', '切换成员状态'), ('user_password_reset', '重置用户密码'), ('user_username_update', '修改用户名'), ('user_observer_toggle', '切换观察者标记')], max_length=30, verbose_name='操作类型'), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index ffedd24..aaa9134 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -52,6 +52,7 @@ class User(AbstractUser): ) is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员') is_team_owner = models.BooleanField(default=False, verbose_name='团队主管理员') + is_observer = models.BooleanField(default=False, verbose_name='观察者(仅对团管生效,可看全局资产)') daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限') monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限') # ── 次数限额(v0.10.0 新增) ── @@ -96,6 +97,8 @@ class AdminAuditLog(models.Model): ('member_quota_update', '更新成员额度'), ('member_status_toggle', '切换成员状态'), ('user_password_reset', '重置用户密码'), + ('user_username_update', '修改用户名'), + ('user_observer_toggle', '切换观察者标记'), ] operator = models.ForeignKey( diff --git a/backend/apps/accounts/permissions.py b/backend/apps/accounts/permissions.py index 7a8da9f..dd03bb1 100644 --- a/backend/apps/accounts/permissions.py +++ b/backend/apps/accounts/permissions.py @@ -43,3 +43,18 @@ class IsTeamMember(BasePermission): and request.user.is_authenticated and request.user.team is not None ) + + +class IsSuperAdminOrObserver(BasePermission): + """超级管理员,或被标记为观察者的团队管理员(可查看全局内容资产)。""" + def has_permission(self, request, view): + u = request.user + if not (u and u.is_authenticated): + return False + # 超管 + if u.is_staff and u.team is None: + return True + # 观察者团管 + if u.is_team_admin and u.team is not None and getattr(u, 'is_observer', False): + return True + return False diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 8331141..29d7d34 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -11,7 +11,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'is_team_owner', 'role', 'team_name', 'must_change_password') + fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'is_team_owner', 'is_observer', 'role', 'team_name', 'must_change_password') class RegisterSerializer(serializers.Serializer): diff --git a/backend/apps/generation/migrations/0021_add_api_prompt.py b/backend/apps/generation/migrations/0021_add_api_prompt.py new file mode 100644 index 0000000..1cd24d6 --- /dev/null +++ b/backend/apps/generation/migrations/0021_add_api_prompt.py @@ -0,0 +1,18 @@ +# v0.20.1 — 给 GenerationRecord 加 api_prompt 字段(实际发给火山的提示词,永久留痕) + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generation', '0020_quotaconfig_base_token_price_1080p_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='generationrecord', + name='api_prompt', + field=models.TextField(blank=True, default='', verbose_name='实际发给火山的提示词'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index f7f3ee0..4239f7d 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -34,6 +34,7 @@ class GenerationRecord(models.Model): task_id = models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='任务ID') ark_task_id = models.CharField(max_length=100, blank=True, default='', verbose_name='火山ARK任务ID') prompt = models.TextField(blank=True, verbose_name='提示词') + api_prompt = models.TextField(blank=True, default='', verbose_name='实际发给火山的提示词') mode = models.CharField(max_length=20, choices=MODE_CHOICES, verbose_name='创作模式') model = models.CharField(max_length=30, choices=MODEL_CHOICES, verbose_name='模型') aspect_ratio = models.CharField(max_length=10, verbose_name='宽高比') diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index 8110e1f..422743e 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -12,6 +12,8 @@ urlpatterns = [ # Public announcement path('announcement', views.announcement_view, name='announcement'), path('announcement/read', views.announcement_read_view, name='announcement_read'), + # Admin publish announcement (fan-out to all users) + path('admin/announcement/publish', views.admin_publish_announcement_view, name='admin_announcement_publish'), # ── Super Admin: Dashboard ── path('admin/stats', views.admin_stats_view, name='admin_stats'), @@ -32,6 +34,8 @@ urlpatterns = [ path('admin/users//quota', views.admin_user_quota_view, name='admin_user_quota'), path('admin/users//status', views.admin_user_status_view, name='admin_user_status'), path('admin/users//reset-password', views.admin_reset_password_view, name='admin_reset_password'), + path('admin/users//username', views.admin_user_username_update_view, name='admin_user_username_update'), + path('admin/users//observer', views.admin_user_observer_toggle_view, name='admin_user_observer_toggle'), # ── Super Admin: Records, Settings & Audit Logs ── path('admin/records', views.admin_records_view, name='admin_records'), @@ -62,6 +66,8 @@ urlpatterns = [ path('team/members//quota', views.team_member_quota_view, name='team_member_quota'), path('team/members//status', views.team_member_status_view, name='team_member_status'), path('team/members//role', views.team_member_role_view, name='team_member_role'), + path('team/members//reset-password', views.team_reset_member_password_view, name='team_reset_member_password'), + path('team/members//username', views.team_member_username_update_view, name='team_member_username_update'), # ── Team Admin: Consumption Records ── path('team/records', views.team_records_view, name='team_records'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 5b890f0..36ec146 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -23,7 +23,7 @@ from .serializers import ( TeamAnomalyConfigSerializer, ) from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession, LoginRecord -from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember +from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember, IsSuperAdminOrObserver 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 @@ -563,6 +563,9 @@ def video_generate_view(request): api_prompt = _format_prompt_for_ark(prompt, sorted_pairs) logger.info('[ark-prompt] original=%s | converted=%s | mapping=%s', prompt, api_prompt, label_to_placeholder) + # 即使 create_task 抛错也保留 api_prompt 方便事后查看实际传了什么 + record.api_prompt = api_prompt + record.save(update_fields=['api_prompt']) try: ark_response = create_task( prompt=api_prompt, @@ -1503,6 +1506,8 @@ def admin_users_list_view(request): 'disabled_by': u.disabled_by, 'is_staff': u.is_staff, 'is_team_admin': u.is_team_admin, + 'is_team_owner': u.is_team_owner, + 'is_observer': u.is_observer, 'team_id': u.team_id, 'team_name': u.team.name if u.team else None, 'date_joined': u.date_joined.isoformat(), @@ -1720,6 +1725,82 @@ def admin_reset_password_view(request, user_id): return Response({'message': f'已重置 {user.username} 的密码'}) +@api_view(['PATCH']) +@permission_classes([IsSuperAdmin]) +def admin_user_username_update_view(request, user_id): + """PATCH /api/v1/admin/users//username — 超管修改任意用户的用户名。""" + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND) + + # admin 账号保护:用户名不可修改(无论操作者是谁) + if user.username == 'admin': + return Response({'error': '不能修改超级管理员的用户名'}, status=status.HTTP_403_FORBIDDEN) + + new_username = (request.data.get('username') or '').strip() + # 长度按 UTF-8 字节计:ASCII 1 字节、中文 3 字节;3-20 字节 ≈ 3-20 个英文字符 或 ~1-6 个中文字符 + new_bytes = len(new_username.encode('utf-8')) + if not (3 <= new_bytes <= 20): + return Response({'error': '用户名长度需 3-20 个字符'}, status=status.HTTP_400_BAD_REQUEST) + if new_username == user.username: + return Response({'error': '新用户名与原用户名相同'}, status=status.HTTP_400_BAD_REQUEST) + if User.objects.filter(username=new_username).exclude(id=user.id).exists(): + return Response({'error': '该用户名已被占用'}, status=status.HTTP_400_BAD_REQUEST) + + from django.core.exceptions import ValidationError as DjangoValidationError + old_username = user.username + user.username = new_username + try: + user.full_clean(exclude=['password']) + except DjangoValidationError: + return Response({'error': '用户名包含非法字符'}, status=status.HTTP_400_BAD_REQUEST) + user.save(update_fields=['username']) + + log_admin_action( + request, 'user_username_update', 'user', + target_id=user.id, target_name=new_username, + before={'username': old_username}, + after={'username': new_username}, + ) + return Response({'user_id': user.id, 'username': user.username}) + + +@api_view(['PATCH']) +@permission_classes([IsSuperAdmin]) +def admin_user_observer_toggle_view(request, user_id): + """PATCH /api/v1/admin/users//observer — 仅超管,把团管标记为观察者(或取消)。""" + try: + target = User.objects.get(id=user_id) + except User.DoesNotExist: + return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 只允许给「团队管理员」打观察者标记;超管/普通成员一律拒 + if target.is_staff and target.team_id is None: + return Response({'error': '超级管理员无需设观察者'}, status=status.HTTP_400_BAD_REQUEST) + if not (target.is_team_admin and target.team_id is not None): + return Response({'error': '观察者标记只能给团队管理员'}, status=status.HTTP_400_BAD_REQUEST) + + is_observer = request.data.get('is_observer') + if is_observer is None: + return Response({'error': '请提供 is_observer 参数'}, status=status.HTTP_400_BAD_REQUEST) + new_val = bool(is_observer) + old_val = target.is_observer + if old_val == new_val: + return Response({'user_id': target.id, 'username': target.username, 'is_observer': new_val}) + + target.is_observer = new_val + target.save(update_fields=['is_observer']) + + log_admin_action( + request, 'user_observer_toggle', 'user', + target_id=target.id, target_name=target.username, + before={'is_observer': old_val}, + after={'is_observer': new_val}, + ) + return Response({'user_id': target.id, 'username': target.username, 'is_observer': new_val}) + + @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_create_user_view(request): @@ -1819,6 +1900,8 @@ def admin_records_view(request): 'seed': r.seed, 'ark_task_id': r.ark_task_id or '', 'result_url': r.result_url or '', + 'thumbnail_url': r.thumbnail_url or '', + 'api_prompt': r.api_prompt or '', }) return Response({ @@ -1884,6 +1967,8 @@ def team_records_view(request): 'seed': r.seed, 'ark_task_id': r.ark_task_id or '', 'result_url': r.result_url or '', + 'thumbnail_url': r.thumbnail_url or '', + 'api_prompt': r.api_prompt or '', }) return Response({ @@ -2181,28 +2266,81 @@ def admin_audit_logs_view(request): @api_view(['GET']) @permission_classes([IsAuthenticated]) def announcement_view(request): - """GET /api/v1/announcement — return active announcement + read status.""" - config, _ = QuotaConfig.objects.get_or_create(pk=1) - if config.announcement_enabled and config.announcement: - is_read = False - if request.user.is_authenticated and request.user.last_read_announcement: - is_read = request.user.last_read_announcement >= config.updated_at + """GET /api/v1/announcement — 返回当前用户最新未读公告(从 Notification 表)。 + + 兼容老前端的响应结构(announcement/enabled/is_read)。 + """ + from apps.notifications.models import Notification + latest = Notification.objects.filter( + recipient=request.user, type='announcement', is_read=False + ).order_by('-created_at').first() + if not latest: return Response({ - 'announcement': config.announcement, - 'enabled': True, - 'is_read': is_read, - 'updated_at': config.updated_at.isoformat(), + 'announcement': '', + 'enabled': False, + 'is_read': True, + 'notification_id': None, }) - return Response({'announcement': '', 'enabled': False, 'is_read': True}) + return Response({ + 'announcement': latest.content, + 'enabled': True, + 'is_read': False, + 'notification_id': latest.id, + 'updated_at': latest.created_at.isoformat(), + }) @api_view(['POST']) @permission_classes([IsAuthenticated]) def announcement_read_view(request): - """POST /api/v1/announcement/read — mark announcement as read.""" - request.user.last_read_announcement = timezone.now() - request.user.save(update_fields=['last_read_announcement']) - return Response({'ok': True}) + """POST /api/v1/announcement/read — 标记当前用户所有未读公告已读。""" + from apps.notifications.models import Notification + updated = Notification.objects.filter( + recipient=request.user, type='announcement', is_read=False + ).update(is_read=True) + return Response({'ok': True, 'updated': updated}) + + +@api_view(['POST']) +@permission_classes([IsSuperAdmin]) +def admin_publish_announcement_view(request): + """POST /api/v1/admin/announcement/publish — 超管点【发送公告】fan-out 给所有用户。 + + Body: { "content": "..." } + + 所有用户(含封禁,is_active=False 的用户解封后能看到累积的历史公告)。 + 用 bulk_create(batch_size=500) 防大团队 OOM。 + 同步把 content 写回 QuotaConfig.announcement 作为"当前最新公告"草稿存档, + 超管下次进设置页能看到上次发的内容(便于改动后再发)。 + """ + from apps.notifications.models import Notification + User = get_user_model() + + content = (request.data.get('content') or '').strip() + if not content: + return Response({'error': '公告内容不能为空'}, status=status.HTTP_400_BAD_REQUEST) + + # 存档到 QuotaConfig(作为编辑器数据源,不再控制 fan-out) + config, _ = QuotaConfig.objects.get_or_create(pk=1) + config.announcement = content + config.announcement_enabled = True # 字段保留兼容,但前端不再读取 + config.save(update_fields=['announcement', 'announcement_enabled']) + + # fan-out 给所有用户(含封禁) + user_ids = list(User.objects.all().values_list('id', flat=True)) + notifs = [Notification( + recipient_id=uid, + type='announcement', + title='系统公告', + content=content, + link_url='', + is_read=False, + ) for uid in user_ids] + Notification.objects.bulk_create(notifs, batch_size=500) + + log_admin_action(request, 'settings_update', 'system', target_id=0, target_name='announcement', + after={'recipients': len(notifs), 'content_preview': content[:80]}) + return Response({'sent_to': len(notifs), 'message': f'已发送给 {len(notifs)} 个用户'}) # ────────────────────────────────────────────── @@ -2620,6 +2758,129 @@ def team_member_role_view(request, member_id): }) +@api_view(['POST']) +@permission_classes([IsTeamAdmin]) +def team_reset_member_password_view(request, member_id): + """POST /api/v1/team/members//reset-password — 团管重置成员密码。 + + 权限矩阵(必须服务端硬校验,前端按钮只是 UX): + - 主管 (is_team_owner=True): 可改同团队的「副管 + 成员」,不可改其他主管 + - 副管 (is_team_admin=True && !is_team_owner): 只能改同团队的「成员」,不可改副管/主管 + + 随机生成 8 位密码 + must_change_password=True(成员下次登录强制改密)。 + """ + import secrets + import string + + team = request.user.team + if team is None: + return Response({'error': '当前用户没有团队'}, status=status.HTTP_400_BAD_REQUEST) + + try: + target = team.members.get(id=member_id) + except User.DoesNotExist: + return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) + + operator = request.user + + # 防御性校验:即使 team.members 已过滤,operator/target 跨团队中转改动也兜底 + if target.team_id != operator.team_id: + return Response({'error': '不在同一团队'}, status=status.HTTP_403_FORBIDDEN) + + # 自己不能重置自己(用修改密码功能) + if target.id == operator.id: + return Response({'error': '不能重置自己的密码,请用「修改密码」功能'}, status=status.HTTP_400_BAD_REQUEST) + + # 任何团管都不能改主管密码 — 主管密码必须超管重置(走 admin_reset_password_view) + if target.is_team_owner: + return Response({'error': '主管理员密码须由超级管理员重置'}, status=status.HTTP_403_FORBIDDEN) + + # 副管密码只有主管能重置,其他副管不行 + if target.is_team_admin and not operator.is_team_owner: + return Response({'error': '只有主管理员能重置副管理员密码'}, status=status.HTTP_403_FORBIDDEN) + + # 走到这里:operator 是主管或副管;target 要么是副管(operator 必是主管) 要么是普通成员 + alphabet = string.ascii_letters + string.digits + new_password = ''.join(secrets.choice(alphabet) for _ in range(8)) + + target.set_password(new_password) + target.must_change_password = True + target.save(update_fields=['password', 'must_change_password']) + + log_admin_action( + request, 'user_password_reset', 'user', + target_id=target.id, target_name=target.username, + after={'reset_by': 'team_admin', 'operator': operator.username}, + ) + + return Response({ + 'user_id': target.id, + 'username': target.username, + 'new_password': new_password, + 'message': f'已重置 {target.username} 的密码,下次登录需修改', + }) + + +@api_view(['PATCH']) +@permission_classes([IsTeamAdmin]) +def team_member_username_update_view(request, member_id): + """PATCH /api/v1/team/members//username — 团管修改本团队成员用户名。 + + 权限矩阵(同 team_reset_member_password_view): + - 主管 (is_team_owner=True): 可改同团队的副管 + 成员的用户名 + - 副管 (is_team_admin=True && !is_team_owner): 只能改同团队成员的用户名 + """ + team = request.user.team + if team is None: + return Response({'error': '当前用户没有团队'}, status=status.HTTP_400_BAD_REQUEST) + + try: + target = team.members.get(id=member_id) + except User.DoesNotExist: + return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) + + operator = request.user + + # 防御性兜底 + if target.team_id != operator.team_id: + return Response({'error': '不在同一团队'}, status=status.HTTP_403_FORBIDDEN) + if target.id == operator.id: + return Response({'error': '不能修改自己的用户名'}, status=status.HTTP_400_BAD_REQUEST) + if target.username == 'admin': + return Response({'error': '不能修改超级管理员的用户名'}, status=status.HTTP_403_FORBIDDEN) + if target.is_team_owner: + return Response({'error': '不能修改主管理员的用户名'}, status=status.HTTP_403_FORBIDDEN) + if target.is_team_admin and not operator.is_team_owner: + return Response({'error': '只有主管理员能修改副管理员的用户名'}, status=status.HTTP_403_FORBIDDEN) + + new_username = (request.data.get('username') or '').strip() + # 长度按 UTF-8 字节计:ASCII 1 字节、中文 3 字节;3-20 字节 ≈ 3-20 个英文字符 或 ~1-6 个中文字符 + new_bytes = len(new_username.encode('utf-8')) + if not (3 <= new_bytes <= 20): + return Response({'error': '用户名长度需 3-20 个字符'}, status=status.HTTP_400_BAD_REQUEST) + if new_username == target.username: + return Response({'error': '新用户名与原用户名相同'}, status=status.HTTP_400_BAD_REQUEST) + if User.objects.filter(username=new_username).exclude(id=target.id).exists(): + return Response({'error': '该用户名已被占用'}, status=status.HTTP_400_BAD_REQUEST) + + from django.core.exceptions import ValidationError as DjangoValidationError + old_username = target.username + target.username = new_username + try: + target.full_clean(exclude=['password']) + except DjangoValidationError: + return Response({'error': '用户名包含非法字符'}, status=status.HTTP_400_BAD_REQUEST) + target.save(update_fields=['username']) + + log_admin_action( + request, 'user_username_update', 'user', + target_id=target.id, target_name=new_username, + before={'username': old_username}, + after={'username': new_username}, + ) + return Response({'user_id': target.id, 'username': target.username}) + + # ────────────────────────────────────────────── # Profile: User's own consumption data # ────────────────────────────────────────────── @@ -2770,6 +3031,8 @@ def profile_records_view(request): 'resolution': r.resolution, 'status': r.status, 'error_message': r.error_message or '', + 'result_url': r.result_url or '', + 'thumbnail_url': r.thumbnail_url or '', }) return Response({ @@ -2785,7 +3048,7 @@ def profile_records_view(request): # ────────────────────────────────────────────── @api_view(['GET']) -@permission_classes([IsSuperAdmin]) +@permission_classes([IsSuperAdminOrObserver]) def admin_assets_overview(request): """GET /api/v1/admin/assets/overview — Global stats + per-team video/seconds summary.""" from apps.accounts.models import Team @@ -2834,7 +3097,7 @@ def admin_assets_overview(request): @api_view(['GET']) -@permission_classes([IsSuperAdmin]) +@permission_classes([IsSuperAdminOrObserver]) def admin_assets_team_members(request, team_id): """GET /api/v1/admin/assets/team//members — Members of a team with video/seconds stats.""" from apps.accounts.models import Team @@ -2874,7 +3137,7 @@ def admin_assets_team_members(request, team_id): @api_view(['GET']) -@permission_classes([IsSuperAdmin]) +@permission_classes([IsSuperAdminOrObserver]) def admin_assets_user_videos(request, user_id): """GET /api/v1/admin/assets/user//videos — Completed videos for a user (paginated).""" try: @@ -2897,6 +3160,7 @@ def admin_assets_user_videos(request, user_id): 'task_id': str(r.task_id), 'prompt': r.prompt, 'result_url': r.result_url or '', + 'thumbnail_url': r.thumbnail_url or '', 'duration': r.duration, 'seconds_consumed': r.seconds_consumed, 'aspect_ratio': r.aspect_ratio, @@ -2979,6 +3243,7 @@ def team_assets_member_videos(request, member_id): 'task_id': str(r.task_id), 'prompt': r.prompt, 'result_url': r.result_url or '', + 'thumbnail_url': r.thumbnail_url or '', 'duration': r.duration, 'seconds_consumed': r.seconds_consumed, 'aspect_ratio': r.aspect_ratio, diff --git a/backend/apps/notifications/__init__.py b/backend/apps/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/notifications/admin.py b/backend/apps/notifications/admin.py new file mode 100644 index 0000000..42de9a9 --- /dev/null +++ b/backend/apps/notifications/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from .models import Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ('recipient', 'type', 'title', 'is_read', 'created_at') + list_filter = ('type', 'is_read', 'created_at') + search_fields = ('recipient__username', 'title', 'content') + readonly_fields = ('created_at',) + date_hierarchy = 'created_at' diff --git a/backend/apps/notifications/apps.py b/backend/apps/notifications/apps.py new file mode 100644 index 0000000..c58ad51 --- /dev/null +++ b/backend/apps/notifications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.notifications' + verbose_name = '通知' diff --git a/backend/apps/notifications/migrations/0001_initial.py b/backend/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..4a479df --- /dev/null +++ b/backend/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.29 on 2026-05-12 18:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('anomaly_disabled_user', '账号因异常被自动封禁'), ('anomaly_disabled_team', '团队因异常被自动封禁'), ('quota_warning', '额度即将耗尽'), ('system', '系统通知')], default='system', max_length=30, verbose_name='类型')), + ('title', models.CharField(blank=True, default='', max_length=200, verbose_name='标题')), + ('content', models.TextField(blank=True, default='', verbose_name='内容')), + ('link_url', models.CharField(blank=True, default='', max_length=500, verbose_name='跳转链接')), + ('is_read', models.BooleanField(db_index=True, default=False, verbose_name='已读')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='接收人')), + ], + options={ + 'verbose_name': '站内通知', + 'verbose_name_plural': '站内通知', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['recipient', 'is_read', '-created_at'], name='notificatio_recipie_684eac_idx')], + }, + ), + ] diff --git a/backend/apps/notifications/migrations/__init__.py b/backend/apps/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/notifications/models.py b/backend/apps/notifications/models.py new file mode 100644 index 0000000..329d67e --- /dev/null +++ b/backend/apps/notifications/models.py @@ -0,0 +1,38 @@ +from django.db import models +from django.conf import settings + + +class Notification(models.Model): + """站内通知 — 异常封禁/额度告警/系统消息等。""" + + TYPE_CHOICES = [ + ('anomaly_disabled_user', '账号因异常被自动封禁'), + ('anomaly_disabled_team', '团队因异常被自动封禁'), + ('quota_warning', '额度即将耗尽'), + ('announcement', '系统公告'), + ('system', '系统通知'), + ] + + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='notifications', + verbose_name='接收人', + ) + type = models.CharField(max_length=30, choices=TYPE_CHOICES, default='system', verbose_name='类型') + title = models.CharField(max_length=200, blank=True, default='', verbose_name='标题') + content = models.TextField(blank=True, default='', verbose_name='内容') + link_url = models.CharField(max_length=500, blank=True, default='', verbose_name='跳转链接') + is_read = models.BooleanField(default=False, db_index=True, verbose_name='已读') + created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间') + + class Meta: + verbose_name = '站内通知' + verbose_name_plural = '站内通知' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['recipient', 'is_read', '-created_at']), + ] + + def __str__(self): + return f'{self.recipient.username} - {self.title}' diff --git a/backend/apps/notifications/serializers.py b/backend/apps/notifications/serializers.py new file mode 100644 index 0000000..14173a3 --- /dev/null +++ b/backend/apps/notifications/serializers.py @@ -0,0 +1,20 @@ +from rest_framework import serializers + +from .models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + """前端列表展示用 — 字段 contract 与 web 端 NotificationItem 一致。""" + + class Meta: + model = Notification + fields = ( + 'id', + 'type', + 'title', + 'content', + 'link_url', + 'is_read', + 'created_at', + ) + read_only_fields = fields diff --git a/backend/apps/notifications/urls.py b/backend/apps/notifications/urls.py new file mode 100644 index 0000000..196095b --- /dev/null +++ b/backend/apps/notifications/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + path('', views.notifications_list_view, name='notifications_list'), + path('unread-count', views.notifications_unread_count_view, name='notifications_unread_count'), + path('/read', views.notification_mark_read_view, name='notification_mark_read'), + path('read-all', views.notifications_mark_all_read_view, name='notifications_mark_all_read'), +] diff --git a/backend/apps/notifications/views.py b/backend/apps/notifications/views.py new file mode 100644 index 0000000..a04b122 --- /dev/null +++ b/backend/apps/notifications/views.py @@ -0,0 +1,114 @@ +import logging + +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .models import Notification +from .serializers import NotificationSerializer + +logger = logging.getLogger(__name__) + + +def _safe_int(value, default=0): + """安全转 int — 防止前端传非数字字符导致 500。""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def notifications_list_view(request): + """GET /api/v1/notifications/ + + Query params: + unread_only: 'true' / 'false' (default 'false') + page: 默认 1 + page_size: 默认 20, 上限 100 + + Response: + { + "total": int, # 当前过滤条件下总条数 + "unread_count": int, # 该用户全部未读数(不受 unread_only/分页影响) + "page": int, + "page_size": int, + "results": [...] + } + """ + user = request.user + + unread_only_raw = (request.query_params.get('unread_only') or 'false').strip().lower() + unread_only = unread_only_raw in ('true', '1', 'yes') + + page = max(_safe_int(request.query_params.get('page'), 1), 1) + page_size = _safe_int(request.query_params.get('page_size'), 20) + if page_size <= 0: + page_size = 20 + page_size = min(page_size, 100) + + base_qs = Notification.objects.filter(recipient=user) + qs = base_qs + if unread_only: + qs = qs.filter(is_read=False) + + total = qs.count() + # unread_count 必须基于该用户全部通知,不受 unread_only/分页影响 + unread_count = base_qs.filter(is_read=False).count() + + offset = (page - 1) * page_size + records = list(qs.order_by('-created_at')[offset:offset + page_size]) + results = NotificationSerializer(records, many=True).data + + return Response({ + 'total': total, + 'unread_count': unread_count, + 'page': page, + 'page_size': page_size, + 'results': results, + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def notifications_unread_count_view(request): + """GET /api/v1/notifications/unread-count + + 前端 60s 轮询,只拿数字不拉列表。 + """ + count = Notification.objects.filter(recipient=request.user, is_read=False).count() + return Response({'unread_count': count}) + + +@api_view(['PATCH']) +@permission_classes([IsAuthenticated]) +def notification_mark_read_view(request, notification_id): + """PATCH /api/v1/notifications//read + + 标记某条通知为已读。404 if not found 或不属于当前用户。 + """ + try: + notification = Notification.objects.get(pk=notification_id, recipient=request.user) + except Notification.DoesNotExist: + return Response({'error': '通知不存在'}, status=status.HTTP_404_NOT_FOUND) + + if not notification.is_read: + notification.is_read = True + notification.save(update_fields=['is_read']) + + return Response({'id': notification.id, 'is_read': True}) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def notifications_mark_all_read_view(request): + """POST /api/v1/notifications/read-all + + 一键已读。返回被标已读的条数。 + """ + updated = Notification.objects.filter( + recipient=request.user, is_read=False + ).update(is_read=True) + return Response({'updated': updated}) diff --git a/backend/config/settings.py b/backend/config/settings.py index 4f592b2..2186ca1 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -47,6 +47,7 @@ INSTALLED_APPS = [ # Local apps 'apps.accounts', 'apps.generation', + 'apps.notifications', ] MIDDLEWARE = [ diff --git a/backend/config/urls.py b/backend/config/urls.py index ecb4123..d333795 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path('healthz/', healthz), path('api/v1/auth/', include('apps.accounts.urls')), path('api/v1/', include('apps.generation.urls')), + path('api/v1/notifications/', include('apps.notifications.urls')), ] # Only expose Django admin in DEBUG mode diff --git a/backend/utils/anomaly_detector.py b/backend/utils/anomaly_detector.py index c9fa9e6..37adeca 100644 --- a/backend/utils/anomaly_detector.py +++ b/backend/utils/anomaly_detector.py @@ -229,6 +229,124 @@ def _disable_team(team): logger.info('Team %s disabled by anomaly detection', team.name) +# ───────────────────────────────────────────────────────────── +# 站内通知:异常封禁后,通知该团队的主管+副管(主管理员/管理员) +# ───────────────────────────────────────────────────────────── + +# 规则 label 中文映射 — 用于通知正文里展示触发的规则名,避免给非技术用户看到英文 rule key +_RULE_LABELS = { + 'region_mismatch': '地区不匹配', + 'impossible_travel': '不可能旅行', + 'login_frequency': '登录频次异常', + 'multi_city': '多城市登录', + 'overseas_ip_diversity': '海外IP多样性', +} + + +def _team_admin_recipients(team): + """返回团队的主管+副管(is_team_admin=True OR is_team_owner=True)。 + + team 为 None 时返回空 list (无人可通知)。 + """ + if team is None: + return [] + from django.db.models import Q + from django.contrib.auth import get_user_model + User = get_user_model() + return list( + User.objects.filter( + team=team, + ).filter( + Q(is_team_admin=True) | Q(is_team_owner=True) + ) + ) + + +def _notify_user_disabled(disabled_user, rule, created_at): + """用户被封禁 → 通知该团队的主管+副管。 + + 所有失败都吞掉(log warning),不能阻断封禁主流程。 + """ + try: + from apps.notifications.models import Notification + + team = disabled_user.team + if team is None: + # 无团队 → 无人需要通知 + return + + recipients = _team_admin_recipients(team) + if not recipients: + return + + rule_label = _RULE_LABELS.get(rule, rule) + # 时间格式化为本地可读 — settings USE_TZ=False,这里直接 strftime + time_str = created_at.strftime('%Y-%m-%d %H:%M') + + title = f'您团队成员 {disabled_user.username} 因登录异常被自动封禁' + content = ( + f'{disabled_user.username} ({disabled_user.email}) 在 {time_str} ' + f'触发{rule_label}规则,系统已自动封禁该账号。请前往安全日志查看详情。' + ) + link_url = '/admin/security' + + notifications = [ + Notification( + recipient=r, + type='anomaly_disabled_user', + title=title, + content=content, + link_url=link_url, + is_read=False, + ) + for r in recipients + ] + Notification.objects.bulk_create(notifications) + except Exception as e: + logger.warning('Failed to create user-disabled notifications: %s', e) + + +def _notify_team_disabled(team, rule, created_at): + """团队被封禁 → 通知该团队主管+副管。 + + 所有失败都吞掉(log warning),不能阻断封禁主流程。 + """ + try: + from apps.notifications.models import Notification + + if team is None: + return + + recipients = _team_admin_recipients(team) + if not recipients: + return + + rule_label = _RULE_LABELS.get(rule, rule) + time_str = created_at.strftime('%Y-%m-%d %H:%M') + + title = f'您所在团队 {team.name} 因登录异常被自动封禁' + content = ( + f'团队 {team.name} 在 {time_str} 触发{rule_label}规则,' + f'系统已自动封禁整个团队。请前往安全日志查看详情。' + ) + link_url = '/admin/security' + + notifications = [ + Notification( + recipient=r, + type='anomaly_disabled_team', + title=title, + content=content, + link_url=link_url, + is_read=False, + ) + for r in recipients + ] + Notification.objects.bulk_create(notifications) + except Exception as e: + logger.warning('Failed to create team-disabled notifications: %s', e) + + def _is_in_cooldown(team, rule, cooldown_seconds): """检查告警冷却:同团队+同规则在冷却窗口内是否已告警。""" from apps.accounts.models import LoginAnomaly @@ -266,10 +384,12 @@ def process_anomalies(login_record, anomalies): if rule == 'impossible_travel': _disable_user(user) + _notify_user_disabled(user, rule, login_record.created_at) auto_disabled = True disabled_target = 'user' elif rule == 'multi_city': _disable_team(team) + _notify_team_disabled(team, rule, login_record.created_at) auto_disabled = True disabled_target = 'team' diff --git a/docs/changelog.md b/docs/changelog.md index 9b9a160..6f4b6a7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,33 @@ --- +## 2026-05-12 — v0.20.1: 7 批次小修复 + 中等功能(主管bug/封面帧/api_prompt/站内通知/团管重置密码/reEdit prompt/Safari 自适应根因) + +**状态**: ✅ 本地完成 | **验收**: vitest 71/162 基线 0 回归 + 3 套 smoke (25+8+11) 全过 + 后端 curl 验证 4 通知 endpoint 全过 + 团管重置 6 项权限矩阵全过 + +### 变更内容 + +| # | 批次 | Commit | 关键改动 | +|---|------|--------|----------| +| A | 主管bug | `e86e3d4` | TeamsPage 主管 badge 加 onClick,后端早就支持 `is_team_admin=false` 同时清 `is_team_owner` | +| B | 封面帧前端 | `72f351d` | admin/team/profile 三个 records view 回传 `thumbnail_url`,三处 `