seaislee1209 85f76d8543 feat: v0.8.2~v0.8.4 — 管理后台 UI 修复 + 团队详情重构 + 审计日志系统
v0.8.2: DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip
v0.8.3: 团队详情抽屉→弹窗重构 + 修改秒数池功能 + member_count 修复
v0.8.4: AdminAuditLog 模型 + 12 处管理操作埋点 + 日志查询页面(/admin/logs)

审计日志覆盖所有管理员 mutation 操作(充值、修改额度、创建/禁用用户等),
记录操作人、变更前后值、IP 地址,支持按操作类型/操作人/日期筛选。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:18:44 +08:00

114 lines
4.7 KiB
Python

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='新成员默认每日限额(秒)')
is_active = models.BooleanField(default=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.name
@property
def remaining_seconds(self):
return self.total_seconds_pool - self.total_seconds_used
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='团队管理员')
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
monthly_seconds_limit = models.IntegerField(default=6000, 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', '切换成员状态'),
]
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}'
def log_admin_action(request, action, target_type, target_id=None, target_name='', before=None, after=None):
"""记录管理员操作日志"""
ip = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR')
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,
)