seaislee1209 cec1e5d770 feat(observer): 团管观察者标记 — 可看全局内容资产(不见 ¥)
后端:
- User.is_observer BooleanField (0016 migration, default=False)
- AdminAuditLog 加 user_observer_toggle 操作类型
- UserSerializer fields 含 is_observer (/auth/me 透出)
- IsSuperAdminOrObserver permission 类:超管 + (is_team_admin && is_observer)
- 3 个 assets endpoint (overview/team_members/user_videos) 权限从 IsSuperAdmin 改为 IsSuperAdminOrObserver
- admin_user_observer_toggle_view (PATCH /admin/users/<id>/observer):
  仅超管,只允许打在团管上,拒超管自己 + 拒成员
- admin_users_list_view 返回 is_team_owner/is_observer 字段(前端 row-level 判断用)

前端:
- User/AdminUser/TeamMember type 加 is_observer
- adminApi.toggleUserObserver
- ProtectedRoute 新 requireAdminOrObserver prop + requireAdmin 智能 fallback(团管被拒回 /team/dashboard)
- App.tsx /admin 父路由 requireAdminOrObserver,子路由除 assets 外仍 requireAdmin (race 防御)
- RoleAwareAdminIndexRedirect:观察者团管入 /admin 跳 /admin/assets,超管跳 /admin/dashboard
- AdminLayout sidebar 角色过滤:观察者只见「内容资产」+ 「返回首页」改「返回团队管理」+ logo「观察者」字样
- TeamAdminLayout 观察者团管加「全局资产」入口跳 /admin/assets
- AdminAssetsPage 4 处 ¥ 条件渲染 (hideMoney = role !== 'super_admin')
- UsersPage 行加「设为观察者/取消观察者」按钮(仅 is_team_admin && team_id) + 观察者 badge
- toast 提示「需该用户重新登录后生效」(JWT 不缓存 is_observer claim)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:58:18 +08:00

260 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
class Team(models.Model):
"""团队模型 — 额度管理的核心单位。"""
name = models.CharField(max_length=100, unique=True, verbose_name='团队名称')
total_seconds_pool = models.BigIntegerField(default=0, verbose_name='总额度池(秒)')
total_seconds_used = models.FloatField(default=0, verbose_name='已消耗总秒数')
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月消费上限(秒)')
daily_member_limit_default = models.IntegerField(default=600, verbose_name='新成员默认每日限额(秒)')
# ── 金额计费字段v0.10.0 新增) ──
balance = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='团队余额(元)')
total_spent = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='已消费总额(元)')
monthly_spending_limit = models.DecimalField(max_digits=12, decimal_places=2, default=-1, verbose_name='每月消费上限(元)')
daily_member_spending_default = models.DecimalField(max_digits=12, decimal_places=2, default=50, verbose_name='新成员默认每日消费限额(元)')
frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)')
markup_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0, verbose_name='加价百分比')
max_concurrent_tasks = models.IntegerField(default=5, verbose_name='最大并发任务数')
is_active = models.BooleanField(default=True, verbose_name='启用状态')
expected_regions = models.CharField(max_length=500, blank=True, default='', verbose_name='预期登录城市(逗号分隔)')
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
verbose_name = '团队'
verbose_name_plural = '团队'
def __str__(self):
return self.name
@property
def remaining_seconds(self):
return self.total_seconds_pool - self.total_seconds_used
@property
def available_balance(self):
return self.balance - self.frozen_amount
class User(AbstractUser):
"""Extended user model — Phase 5: team-based quota."""
email = models.EmailField(unique=True, verbose_name='邮箱')
team = models.ForeignKey(
Team, on_delete=models.SET_NULL,
null=True, blank=True,
related_name='members',
verbose_name='所属团队',
)
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 新增) ──
daily_generation_limit = models.IntegerField(default=50, verbose_name='每日生成次数上限')
monthly_generation_limit = models.IntegerField(default=1500, verbose_name='每月生成次数上限')
spending_limit = models.DecimalField(max_digits=12, decimal_places=2, default=-1, verbose_name='用户总消费额度(元)')
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
last_read_announcement = models.DateTimeField(null=True, blank=True, verbose_name='最后阅读公告时间')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
verbose_name = '用户'
verbose_name_plural = '用户'
def __str__(self):
return self.username
@property
def role(self):
if self.is_staff and self.team is None:
return 'super_admin'
if self.is_team_admin and self.team is not None:
return 'team_admin'
return 'member'
class AdminAuditLog(models.Model):
"""管理员操作审计日志"""
ACTION_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', '切换观察者标记'),
]
operator = models.ForeignKey(
User, on_delete=models.SET_NULL,
null=True, related_name='audit_logs',
verbose_name='操作人',
)
operator_name = models.CharField(max_length=150, verbose_name='操作人用户名')
action = models.CharField(max_length=30, choices=ACTION_CHOICES, verbose_name='操作类型')
target_type = models.CharField(max_length=20, verbose_name='目标类型')
target_id = models.IntegerField(null=True, blank=True, verbose_name='目标ID')
target_name = models.CharField(max_length=200, blank=True, default='', verbose_name='目标名称')
before = models.JSONField(null=True, blank=True, verbose_name='变更前')
after = models.JSONField(null=True, blank=True, verbose_name='变更后')
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name='IP地址')
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='操作时间')
class Meta:
verbose_name = '审计日志'
verbose_name_plural = '审计日志'
ordering = ['-created_at']
def __str__(self):
return f'{self.operator_name} - {self.get_action_display()} - {self.target_name}'
class ActiveSession(models.Model):
"""活跃会话 — 用于并发登录设备限制。"""
DEVICE_TYPE_CHOICES = [
('desktop', '桌面端'),
('mobile', '移动端'),
('unknown', '未知'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='active_sessions', verbose_name='用户')
session_id = models.UUIDField(default=uuid.uuid4, unique=True, db_index=True, verbose_name='会话ID')
device_type = models.CharField(max_length=10, choices=DEVICE_TYPE_CHOICES, default='unknown', verbose_name='设备类型')
user_agent = models.TextField(blank=True, default='', verbose_name='User-Agent')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
class Meta:
verbose_name = '活跃会话'
verbose_name_plural = '活跃会话'
ordering = ['created_at']
def __str__(self):
return f'{self.user.username} - {self.device_type} - {self.session_id}'
class LoginRecord(models.Model):
"""登录记录 — 含 IP 归属地,供异常检测使用。"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='login_records', verbose_name='用户')
team = models.ForeignKey(Team, on_delete=models.SET_NULL, null=True, blank=True, related_name='login_records', verbose_name='所属团队')
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name='IP地址')
user_agent = models.TextField(blank=True, default='', verbose_name='User-Agent')
geo_country = models.CharField(max_length=50, blank=True, default='', verbose_name='国家')
geo_province = models.CharField(max_length=50, blank=True, default='', verbose_name='省份')
geo_city = models.CharField(max_length=50, blank=True, default='', verbose_name='城市')
geo_source = models.CharField(max_length=10, blank=True, default='', 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']
def __str__(self):
return f'{self.user.username} - {self.ip_address} - {self.geo_city} - {self.created_at}'
class TeamAnomalyConfig(models.Model):
"""团队级异常检测阈值配置 — 未设置的字段使用全局默认值。"""
team = models.OneToOneField(Team, on_delete=models.CASCADE, related_name='anomaly_config', verbose_name='团队')
r1_enabled = models.BooleanField(null=True, blank=True, verbose_name='R1 开关')
r2_enabled = models.BooleanField(null=True, blank=True, verbose_name='R2 开关')
r2_window_seconds = models.IntegerField(null=True, blank=True, verbose_name='R2 时间窗口(秒)')
r3_enabled = models.BooleanField(null=True, blank=True, verbose_name='R3 开关')
r3_window_seconds = models.IntegerField(null=True, blank=True, verbose_name='R3 时间窗口(秒)')
r3_max_count = models.IntegerField(null=True, blank=True, verbose_name='R3 最大登录次数')
r4_enabled = models.BooleanField(null=True, blank=True, verbose_name='R4 开关')
r4_window_seconds = models.IntegerField(null=True, blank=True, verbose_name='R4 时间窗口(秒)')
r4_city_count = models.IntegerField(null=True, blank=True, verbose_name='R4 预期外城市数阈值')
r5_enabled = models.BooleanField(null=True, blank=True, verbose_name='R5 开关')
r5_days = models.IntegerField(null=True, blank=True, verbose_name='R5 统计天数')
r5_country_count = models.IntegerField(null=True, blank=True, verbose_name='R5 海外国家数阈值')
class Meta:
verbose_name = '团队异常检测配置'
verbose_name_plural = '团队异常检测配置'
def __str__(self):
return f'{self.team.name} 异常检测配置'
class LoginAnomaly(models.Model):
"""登录异常记录。"""
LEVEL_CHOICES = [
('warning', '警告'),
('critical', '严重'),
]
RULE_CHOICES = [
('region_mismatch', '登录地区不对'),
('impossible_travel', '不可能的旅行'),
('login_frequency', '登录太频繁'),
('multi_city', '团队遍地开花'),
('overseas_ip_diversity', '海外IP太杂'),
]
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='login_anomalies', verbose_name='团队')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='login_anomalies', verbose_name='用户')
login_record = models.ForeignKey(LoginRecord, on_delete=models.CASCADE, related_name='anomalies', verbose_name='触发登录记录')
level = models.CharField(max_length=10, choices=LEVEL_CHOICES, verbose_name='严重程度')
rule = models.CharField(max_length=30, choices=RULE_CHOICES, verbose_name='触发规则')
detail = models.JSONField(default=dict, verbose_name='详情')
alerted = models.BooleanField(default=False, verbose_name='已发告警')
auto_disabled = models.BooleanField(default=False, verbose_name='已自动封禁')
disabled_target = models.CharField(max_length=10, blank=True, default='', 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']
def __str__(self):
return f'{self.team.name} - {self.get_rule_display()} - {self.get_level_display()}'
def get_client_ip(request):
"""从请求中提取客户端 IP 地址。"""
return request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR')
def parse_device_type(user_agent):
"""根据 User-Agent 判断设备类型。"""
ua_lower = (user_agent or '').lower()
mobile_keywords = ['iphone', 'ipad', 'android', 'mobile', 'ipod', 'windows phone']
if any(kw in ua_lower for kw in mobile_keywords):
return 'mobile'
if ua_lower:
return 'desktop'
return 'unknown'
def log_admin_action(request, action, target_type, target_id=None, target_name='', before=None, after=None):
"""记录管理员操作日志"""
ip = get_client_ip(request)
AdminAuditLog.objects.create(
operator=request.user,
operator_name=request.user.username,
action=action,
target_type=target_type,
target_id=target_id,
target_name=target_name,
before=before,
after=after,
ip_address=ip,
)