feat: v0.9.7 登录风控第二期 — IP归属地解析 + 异常检测(R1-R5) + 飞书告警 + 自动封禁
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s

- IP138 在线 API + ip2region 离线库双通道归属地解析,60 秒熔断降级
- 5 条异常检测规则:地区不对/不可能旅行/频繁登录/团队遍地开花/海外IP太杂
- 飞书 interactive 卡片告警(红色严重/橙色警告),含辅助指标
- R2 自动封禁用户、R4 自动封禁团队,封禁即踢下线
- 系统设置页全局配置 + 团队详情页独立阈值覆盖
- 安全日志页面 + 管理员修改密码入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-19 00:02:56 +08:00
parent 9809c31143
commit be656900c0
25 changed files with 2329 additions and 58 deletions

View File

@ -52,6 +52,8 @@ jimeng-clone/
│ ├── apps/
│ │ ├── accounts/ # User auth: models, views, serializers, urls
│ │ └── generation/ # Video generation: models, views, serializers, urls
│ ├── utils/ # Shared utilities (geo_client, anomaly_detector, alert_service, tos_client)
│ ├── data/ # Offline data files (ip2region.xdb)
│ ├── requirements.txt # Python dependencies
│ └── Dockerfile # Python 3.12 + gunicorn
├── web/ # React 18 + Vite frontend
@ -152,6 +154,10 @@ jimeng-clone/
| POST | `/api/v1/admin/teams/<id>/topup` | Add seconds to team pool |
| PUT | `/api/v1/admin/teams/<id>/set-pool` | Directly set team total seconds pool |
| POST | `/api/v1/admin/teams/<id>/admin` | Create team admin user |
| GET | `/api/v1/admin/anomalies` | Login anomaly records (filter by team/rule/level/date) |
| POST | `/api/v1/admin/test-feishu` | Send test Feishu alert message |
| POST | `/api/v1/admin/teams/<id>/auto-learn` | Auto-learn expected regions from login history |
| POST | `/api/v1/admin/teams/<id>/apply-learned-regions` | Apply auto-learned regions to team |
| GET | `/api/v1/admin/logs` | Audit logs (filter by action/operator/date) |
| GET | `/api/v1/admin/assets/overview` | Content assets: global stats + per-team summary |
| GET | `/api/v1/admin/assets/team/<id>/members` | Content assets: team members with video stats |
@ -174,9 +180,14 @@ jimeng-clone/
### User (extends AbstractUser)
- `email` (unique), `daily_seconds_limit` (default: 600), `monthly_seconds_limit` (default: 6000)
- `must_change_password` (default: True) — forces password change on first login
- `team` (FK to Team), `is_team_admin`
- `team` (FK to Team), `is_team_admin`, `disabled_by` (''|'admin'|'system')
- `created_at`, `updated_at`
### Team
- `name`, `total_seconds_pool`, `total_seconds_used`, `monthly_seconds_limit`, `daily_member_limit_default`
- `expected_regions` (CharField 500, comma-separated cities for anomaly detection R1)
- `disabled_by` (''|'admin'|'system'), `is_active`
### GenerationRecord
- `user` (FK), `task_id` (UUID), `ark_task_id`, `prompt`, `mode` (universal|keyframe)
- `model` (seedance_2.0|seedance_2.0_fast), `aspect_ratio`, `duration`, `seconds_consumed`
@ -194,13 +205,27 @@ jimeng-clone/
- Used for concurrent session limiting via JWT session_id claim
### LoginRecord
- `user` (FK), `ip_address`, `user_agent`, `created_at` (indexed)
- Records every login for future anomaly detection
- `user` (FK), `team` (FK, redundant for efficient R4/R5 queries), `ip_address`, `user_agent`
- `geo_country`, `geo_province`, `geo_city`, `geo_source` ('online'|'offline'|'skip'|'failed')
- `created_at` (indexed)
### TeamAnomalyConfig (OneToOne → Team)
- Per-team anomaly detection thresholds (null = use global default)
- `r1_enabled`, `r2_enabled`/`r2_window_seconds`, `r3_enabled`/`r3_window_seconds`/`r3_max_count`
- `r4_enabled`/`r4_window_seconds`/`r4_city_count`, `r5_enabled`/`r5_days`/`r5_country_count`
### LoginAnomaly
- `team` (FK), `user` (FK), `login_record` (FK)
- `level` (warning|critical), `rule` (region_mismatch|impossible_travel|login_frequency|multi_city|overseas_ip_diversity)
- `detail` (JSON), `alerted`, `auto_disabled`, `disabled_target` (user|team|'')
- `created_at` (indexed)
### QuotaConfig (Singleton, pk=1)
- `default_daily_seconds_limit`, `default_monthly_seconds_limit`
- `announcement`, `announcement_enabled`
- `max_desktop_sessions` (default: 1), `max_mobile_sessions` (default: 0)
- Anomaly detection global defaults: `anomaly_detection_enabled`, R1-R5 enabled/thresholds
- `feishu_alert_mobiles`, `sms_alert_mobiles`, `alert_cooldown_seconds`
- `updated_at`
## Frontend Routes
@ -213,7 +238,8 @@ jimeng-clone/
| `/admin/dashboard` | DashboardPage | Admin | Stats & charts |
| `/admin/users` | UsersPage | Admin | User management |
| `/admin/records` | RecordsPage | Admin | Generation records |
| `/admin/settings` | SettingsPage | Admin | Global quota & announcement |
| `/admin/settings` | SettingsPage | Admin | Global quota, announcement & anomaly detection config |
| `/admin/security` | AnomalyLogPage | Admin | Login anomaly records (security log) |
| `/admin/logs` | AuditLogsPage | Admin | Admin operation audit logs |
| `/admin/assets` | AdminAssetsPage | Admin | Content assets (team→member→video hierarchy) |
| `/team/assets` | TeamAssetsPage | TeamAdmin | Team content assets (member→video hierarchy) |
@ -358,6 +384,8 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
| `TOS_CDN_DOMAIN` | TOS CDN domain for permanent URLs (default: `https://airdrama-media.tos-cn-beijing.volces.com`) | Yes (upload) |
| `ARK_API_KEY` | Volcano Engine ARK API key for Seedance | Yes (video gen) |
| `ARK_BASE_URL` | ARK API base URL (default: `https://ark.cn-beijing.volces.com/api/v3`) | No |
| `ALIYUN_IP_GEO_APPCODE` | Aliyun marketplace IP geolocation API AppCode | Yes (anomaly detection) |
| `FEISHU_APP_SECRET` | Feishu bot app secret for alert notifications | Yes (anomaly alerts) |
## Deployment
@ -411,6 +439,11 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
| 2026-03-18 | v0.9.0: 登录记录 — LoginRecord 模型IP + User-Agent为异常检测打基础 | Backend |
| 2026-03-18 | v0.9.0: Token 生命周期缩短 — access 30min, refresh 1天 | Backend |
| 2026-03-18 | v0.9.0: 内容资产页 — 超管/团队管三级折叠式资产浏览(团队→成员→视频) | Full stack |
| 2026-03-18 | v0.9.1: 登录风控第二期 — IP归属地解析 + 5条异常检测规则(R1-R5) + 飞书告警 + 自动封禁 | Full stack |
| 2026-03-18 | v0.9.1: 安全日志页面 — LoginAnomaly 记录列表,按团队/规则/级别/时间筛选 | Frontend |
| 2026-03-18 | v0.9.1: 系统设置页 — 异常检测总开关、R1-R5默认阈值、飞书接收人+测试、告警冷却 | Frontend |
| 2026-03-18 | v0.9.1: 团队管理 — 预期登录城市(必填) + 自动学习 + disabled_by 来源标签 | Full stack |
| 2026-03-18 | v0.9.1: 前端拦截器 — user_disabled/team_disabled 错误码处理,弹窗提示后跳登录 | Frontend |
### Phase 4 Details (2026-03-13)

View File

@ -15,6 +15,26 @@ class SessionJWTAuthentication(JWTAuthentication):
def get_user(self, validated_token):
user = super().get_user(validated_token)
# 检查用户是否被封禁
if not user.is_active:
raise InvalidToken({
'detail': '您的账号已被禁用,请联系团队管理员',
'code': 'user_disabled',
})
# 检查团队是否被封禁
if user.team_id:
try:
from .models import Team
team = Team.objects.get(pk=user.team_id)
if not team.is_active:
raise InvalidToken({
'detail': '您所在的团队已被禁用,请联系平台管理员',
'code': 'team_disabled',
})
except Team.DoesNotExist:
pass
session_id = validated_token.get('session_id')
if session_id is None:
# Legacy token without session_id — allow through

View File

@ -0,0 +1,99 @@
# Generated by Django 4.2.29 on 2026-03-18 12:11
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('accounts', '0007_set_existing_users_must_change_password_false'),
]
operations = [
migrations.AddField(
model_name='loginrecord',
name='geo_city',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='城市'),
),
migrations.AddField(
model_name='loginrecord',
name='geo_country',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='国家'),
),
migrations.AddField(
model_name='loginrecord',
name='geo_province',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='省份'),
),
migrations.AddField(
model_name='loginrecord',
name='geo_source',
field=models.CharField(blank=True, default='', max_length=10, verbose_name='归属地来源'),
),
migrations.AddField(
model_name='loginrecord',
name='team',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='login_records', to='accounts.team', verbose_name='所属团队'),
),
migrations.AddField(
model_name='team',
name='disabled_by',
field=models.CharField(blank=True, default='', max_length=10, verbose_name='禁用来源'),
),
migrations.AddField(
model_name='team',
name='expected_regions',
field=models.CharField(blank=True, default='', max_length=500, verbose_name='预期登录城市(逗号分隔)'),
),
migrations.AddField(
model_name='user',
name='disabled_by',
field=models.CharField(blank=True, default='', max_length=10, verbose_name='禁用来源'),
),
migrations.CreateModel(
name='TeamAnomalyConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('r1_enabled', models.BooleanField(blank=True, null=True, verbose_name='R1 开关')),
('r2_enabled', models.BooleanField(blank=True, null=True, verbose_name='R2 开关')),
('r2_window_seconds', models.IntegerField(blank=True, null=True, verbose_name='R2 时间窗口(秒)')),
('r3_enabled', models.BooleanField(blank=True, null=True, verbose_name='R3 开关')),
('r3_window_seconds', models.IntegerField(blank=True, null=True, verbose_name='R3 时间窗口(秒)')),
('r3_max_count', models.IntegerField(blank=True, null=True, verbose_name='R3 最大登录次数')),
('r4_enabled', models.BooleanField(blank=True, null=True, verbose_name='R4 开关')),
('r4_window_seconds', models.IntegerField(blank=True, null=True, verbose_name='R4 时间窗口(秒)')),
('r4_city_count', models.IntegerField(blank=True, null=True, verbose_name='R4 预期外城市数阈值')),
('r5_enabled', models.BooleanField(blank=True, null=True, verbose_name='R5 开关')),
('r5_days', models.IntegerField(blank=True, null=True, verbose_name='R5 统计天数')),
('r5_country_count', models.IntegerField(blank=True, null=True, verbose_name='R5 海外国家数阈值')),
('team', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='anomaly_config', to='accounts.team', verbose_name='团队')),
],
options={
'verbose_name': '团队异常检测配置',
'verbose_name_plural': '团队异常检测配置',
},
),
migrations.CreateModel(
name='LoginAnomaly',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('level', models.CharField(choices=[('warning', '警告'), ('critical', '严重')], max_length=10, verbose_name='严重程度')),
('rule', models.CharField(choices=[('region_mismatch', '登录地区不对'), ('impossible_travel', '不可能的旅行'), ('login_frequency', '登录太频繁'), ('multi_city', '团队遍地开花'), ('overseas_ip_diversity', '海外IP太杂')], max_length=30, 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(blank=True, default='', max_length=10, verbose_name='封禁对象')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')),
('login_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='anomalies', to='accounts.loginrecord', verbose_name='触发登录记录')),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_anomalies', to='accounts.team', verbose_name='团队')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_anomalies', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '登录异常',
'verbose_name_plural': '登录异常',
'ordering': ['-created_at'],
},
),
]

View File

@ -12,6 +12,8 @@ class Team(models.Model):
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='启用状态')
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='更新时间')
@ -40,6 +42,7 @@ class User(AbstractUser):
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
must_change_password = models.BooleanField(default=True, 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='更新时间')
@ -125,10 +128,15 @@ class ActiveSession(models.Model):
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:
@ -137,7 +145,65 @@ class LoginRecord(models.Model):
ordering = ['-created_at']
def __str__(self):
return f'{self.user.username} - {self.ip_address} - {self.created_at}'
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):

View File

@ -84,10 +84,59 @@ def login_view(request):
status=status.HTTP_401_UNAUTHORIZED
)
# Check if user or team is disabled
if not user.is_active:
code = 'user_disabled'
return Response(
{'code': code, 'message': '您的账号已被禁用,请联系团队管理员'},
status=status.HTTP_401_UNAUTHORIZED
)
if user.team and not user.team.is_active:
code = 'team_disabled'
return Response(
{'code': code, 'message': '您所在的团队已被禁用,请联系平台管理员'},
status=status.HTTP_403_FORBIDDEN
)
# Record login IP and User-Agent
ip = get_client_ip(request)
user_agent = request.META.get('HTTP_USER_AGENT', '')
LoginRecord.objects.create(user=user, ip_address=ip, user_agent=user_agent)
login_record = LoginRecord.objects.create(
user=user, team=user.team, ip_address=ip, user_agent=user_agent,
)
# IP 归属地解析 + 异常检测(不阻塞登录)
try:
from utils.geo_client import resolve_ip_location
country, province, city, source = resolve_ip_location(ip)
login_record.geo_country = country
login_record.geo_province = province
login_record.geo_city = city
login_record.geo_source = source
login_record.save(update_fields=['geo_country', 'geo_province', 'geo_city', 'geo_source'])
from utils.anomaly_detector import check_login_anomaly, process_anomalies
anomalies = check_login_anomaly(login_record)
if anomalies:
process_anomalies(login_record, anomalies)
# 封禁后重新检查anomaly_detector 可能刚封禁了用户/团队)
user.refresh_from_db()
if not user.is_active:
return Response(
{'code': 'user_disabled', 'message': '您的账号已被禁用,请联系团队管理员'},
status=status.HTTP_401_UNAUTHORIZED
)
if user.team:
user.team.refresh_from_db()
if not user.team.is_active:
return Response(
{'code': 'team_disabled', 'message': '您所在的团队已被禁用,请联系平台管理员'},
status=status.HTTP_403_FORBIDDEN
)
except Exception:
import logging
logging.getLogger(__name__).exception('Anomaly detection failed for login %s', login_record.pk)
# Concurrent session management
device_type = parse_device_type(user_agent)

View File

@ -0,0 +1,93 @@
# Generated by Django 4.2.29 on 2026-03-18 12:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0005_quotaconfig_max_desktop_sessions_and_more'),
]
operations = [
migrations.AddField(
model_name='quotaconfig',
name='alert_cooldown_seconds',
field=models.IntegerField(default=1800, verbose_name='告警冷却时间(秒)'),
),
migrations.AddField(
model_name='quotaconfig',
name='anomaly_detection_enabled',
field=models.BooleanField(default=False, verbose_name='异常检测总开关'),
),
migrations.AddField(
model_name='quotaconfig',
name='feishu_alert_mobiles',
field=models.CharField(blank=True, default='', max_length=500, verbose_name='飞书告警接收人手机号'),
),
migrations.AddField(
model_name='quotaconfig',
name='r1_enabled_default',
field=models.BooleanField(default=True, verbose_name='R1 默认开关'),
),
migrations.AddField(
model_name='quotaconfig',
name='r2_enabled_default',
field=models.BooleanField(default=True, verbose_name='R2 默认开关'),
),
migrations.AddField(
model_name='quotaconfig',
name='r2_window_seconds',
field=models.IntegerField(default=3600, verbose_name='R2 默认时间窗口(秒)'),
),
migrations.AddField(
model_name='quotaconfig',
name='r3_enabled_default',
field=models.BooleanField(default=True, verbose_name='R3 默认开关'),
),
migrations.AddField(
model_name='quotaconfig',
name='r3_max_count',
field=models.IntegerField(default=10, verbose_name='R3 默认最大登录次数'),
),
migrations.AddField(
model_name='quotaconfig',
name='r3_window_seconds',
field=models.IntegerField(default=3600, verbose_name='R3 默认时间窗口(秒)'),
),
migrations.AddField(
model_name='quotaconfig',
name='r4_city_count',
field=models.IntegerField(default=5, verbose_name='R4 默认预期外城市数'),
),
migrations.AddField(
model_name='quotaconfig',
name='r4_enabled_default',
field=models.BooleanField(default=True, verbose_name='R4 默认开关'),
),
migrations.AddField(
model_name='quotaconfig',
name='r4_window_seconds',
field=models.IntegerField(default=3600, verbose_name='R4 默认时间窗口(秒)'),
),
migrations.AddField(
model_name='quotaconfig',
name='r5_country_count',
field=models.IntegerField(default=10, verbose_name='R5 默认海外国家数'),
),
migrations.AddField(
model_name='quotaconfig',
name='r5_days',
field=models.IntegerField(default=7, verbose_name='R5 默认统计天数'),
),
migrations.AddField(
model_name='quotaconfig',
name='r5_enabled_default',
field=models.BooleanField(default=True, verbose_name='R5 默认开关'),
),
migrations.AddField(
model_name='quotaconfig',
name='sms_alert_mobiles',
field=models.CharField(blank=True, default='', max_length=500, verbose_name='短信告警手机号(预留)'),
),
]

View File

@ -53,13 +53,30 @@ class GenerationRecord(models.Model):
class QuotaConfig(models.Model):
"""Global quota configuration (singleton) — Phase 3: seconds + announcement."""
"""Global quota configuration (singleton) — Phase 3: seconds + announcement + anomaly detection."""
default_daily_seconds_limit = models.IntegerField(default=600, verbose_name='默认每日秒数上限')
default_monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='默认每月秒数上限')
announcement = models.TextField(blank=True, default='', verbose_name='系统公告')
announcement_enabled = models.BooleanField(default=False, verbose_name='启用公告')
max_desktop_sessions = models.IntegerField(default=1, verbose_name='每用户最大桌面端会话数')
max_mobile_sessions = models.IntegerField(default=0, verbose_name='每用户最大移动端会话数')
# ── 异常检测全局默认配置 ──
anomaly_detection_enabled = models.BooleanField(default=False, verbose_name='异常检测总开关')
r1_enabled_default = models.BooleanField(default=True, verbose_name='R1 默认开关')
r2_enabled_default = models.BooleanField(default=True, verbose_name='R2 默认开关')
r2_window_seconds = models.IntegerField(default=3600, verbose_name='R2 默认时间窗口(秒)')
r3_enabled_default = models.BooleanField(default=True, verbose_name='R3 默认开关')
r3_window_seconds = models.IntegerField(default=3600, verbose_name='R3 默认时间窗口(秒)')
r3_max_count = models.IntegerField(default=10, verbose_name='R3 默认最大登录次数')
r4_enabled_default = models.BooleanField(default=True, verbose_name='R4 默认开关')
r4_window_seconds = models.IntegerField(default=3600, verbose_name='R4 默认时间窗口(秒)')
r4_city_count = models.IntegerField(default=5, verbose_name='R4 默认预期外城市数')
r5_enabled_default = models.BooleanField(default=True, verbose_name='R5 默认开关')
r5_days = models.IntegerField(default=7, verbose_name='R5 默认统计天数')
r5_country_count = models.IntegerField(default=10, verbose_name='R5 默认海外国家数')
feishu_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='飞书告警接收人手机号')
sms_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='短信告警手机号(预留)')
alert_cooldown_seconds = models.IntegerField(default=1800, verbose_name='告警冷却时间(秒)')
updated_at = models.DateTimeField(auto_now=True)
class Meta:

View File

@ -35,6 +35,23 @@ class SystemSettingsSerializer(serializers.Serializer):
announcement_enabled = serializers.BooleanField(required=False, default=False)
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)
max_mobile_sessions = serializers.IntegerField(min_value=0, required=False, default=0)
# 异常检测配置
anomaly_detection_enabled = serializers.BooleanField(required=False, default=False)
r1_enabled_default = serializers.BooleanField(required=False, default=True)
r2_enabled_default = serializers.BooleanField(required=False, default=True)
r2_window_seconds = serializers.IntegerField(min_value=60, required=False, default=3600)
r3_enabled_default = serializers.BooleanField(required=False, default=True)
r3_window_seconds = serializers.IntegerField(min_value=60, required=False, default=3600)
r3_max_count = serializers.IntegerField(min_value=1, required=False, default=10)
r4_enabled_default = serializers.BooleanField(required=False, default=True)
r4_window_seconds = serializers.IntegerField(min_value=60, required=False, default=3600)
r4_city_count = serializers.IntegerField(min_value=1, required=False, default=5)
r5_enabled_default = serializers.BooleanField(required=False, default=True)
r5_days = serializers.IntegerField(min_value=1, required=False, default=7)
r5_country_count = serializers.IntegerField(min_value=1, required=False, default=10)
feishu_alert_mobiles = serializers.CharField(required=False, allow_blank=True, default='')
sms_alert_mobiles = serializers.CharField(required=False, allow_blank=True, default='')
alert_cooldown_seconds = serializers.IntegerField(min_value=0, required=False, default=1800)
# ── Team serializers ──
@ -43,6 +60,7 @@ class TeamCreateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100)
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False, default=6000)
daily_member_limit_default = serializers.IntegerField(min_value=0, required=False, default=600)
expected_regions = serializers.CharField(max_length=500, required=True)
class TeamUpdateSerializer(serializers.Serializer):
@ -50,6 +68,22 @@ class TeamUpdateSerializer(serializers.Serializer):
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False)
daily_member_limit_default = serializers.IntegerField(min_value=0, required=False)
is_active = serializers.BooleanField(required=False)
expected_regions = serializers.CharField(max_length=500, required=False, allow_blank=True)
class TeamAnomalyConfigSerializer(serializers.Serializer):
r1_enabled = serializers.BooleanField(required=False, allow_null=True, default=None)
r2_enabled = serializers.BooleanField(required=False, allow_null=True, default=None)
r2_window_seconds = serializers.IntegerField(min_value=60, required=False, allow_null=True, default=None)
r3_enabled = serializers.BooleanField(required=False, allow_null=True, default=None)
r3_window_seconds = serializers.IntegerField(min_value=60, required=False, allow_null=True, default=None)
r3_max_count = serializers.IntegerField(min_value=1, required=False, allow_null=True, default=None)
r4_enabled = serializers.BooleanField(required=False, allow_null=True, default=None)
r4_window_seconds = serializers.IntegerField(min_value=60, required=False, allow_null=True, default=None)
r4_city_count = serializers.IntegerField(min_value=1, required=False, allow_null=True, default=None)
r5_enabled = serializers.BooleanField(required=False, allow_null=True, default=None)
r5_days = serializers.IntegerField(min_value=1, required=False, allow_null=True, default=None)
r5_country_count = serializers.IntegerField(min_value=1, required=False, allow_null=True, default=None)
class TeamTopUpSerializer(serializers.Serializer):

View File

@ -35,6 +35,12 @@ urlpatterns = [
path('admin/settings', views.admin_settings_view, name='admin_settings'),
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'),
# ── Super Admin: Anomaly Detection ──
path('admin/anomalies', views.admin_login_anomalies_view, name='admin_login_anomalies'),
path('admin/test-feishu', views.admin_test_feishu_view, name='admin_test_feishu'),
path('admin/teams/<int:team_id>/auto-learn', views.admin_team_auto_learn_view, name='admin_team_auto_learn'),
path('admin/teams/<int:team_id>/apply-learned-regions', views.admin_team_apply_learned_regions_view, name='admin_team_apply_learned_regions'),
# ── Super Admin: Content Assets ──
path('admin/assets/overview', views.admin_assets_overview, name='admin_assets_overview'),
path('admin/assets/team/<int:team_id>/members', views.admin_assets_team_members, name='admin_assets_team_members'),

View File

@ -20,8 +20,9 @@ from .serializers import (
AdminCreateUserSerializer,
TeamCreateSerializer, TeamUpdateSerializer, TeamTopUpSerializer,
TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer,
TeamAnomalyConfigSerializer,
)
from apps.accounts.models import Team, AdminAuditLog, log_admin_action
from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
from utils.tos_client import upload_file as tos_upload
from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
@ -549,6 +550,8 @@ def admin_teams_list_view(request):
'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(),
})
@ -569,12 +572,14 @@ def admin_team_create_view(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})
'daily_member_limit_default': team.daily_member_limit_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,
'expected_regions': team.expected_regions,
'created_at': team.created_at.isoformat(),
}, status=status.HTTP_201_CREATED)
@ -591,11 +596,34 @@ def admin_team_detail_view(request, team_id):
if request.method == 'PUT':
serializer = TeamUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Handle disabled_by based on is_active change
before = {f: 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: 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({
@ -604,6 +632,8 @@ def admin_team_detail_view(request, team_id):
'monthly_seconds_limit': team.monthly_seconds_limit,
'daily_member_limit_default': team.daily_member_limit_default,
'is_active': team.is_active,
'expected_regions': team.expected_regions,
'disabled_by': team.disabled_by,
'updated_at': team.updated_at.isoformat(),
})
@ -627,6 +657,26 @@ def admin_team_detail_view(request, team_id):
),
).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,
@ -638,6 +688,9 @@ def admin_team_detail_view(request, team_id):
'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,
@ -645,6 +698,7 @@ def admin_team_detail_view(request, team_id):
'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,
'seconds_today': m.seconds_today or 0,
@ -819,6 +873,7 @@ def admin_users_list_view(request):
'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,
@ -937,10 +992,16 @@ def admin_user_status_view(request, user_id):
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']
user.save(update_fields=['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}, after={'is_active': user.is_active})
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,
@ -1068,6 +1129,34 @@ def admin_records_view(request):
# 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,
'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):
@ -1075,52 +1164,166 @@ def admin_settings_view(request):
config, _ = QuotaConfig.objects.get_or_create(pk=1)
if request.method == 'GET':
return Response({
'default_daily_seconds_limit': config.default_daily_seconds_limit,
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions,
'max_mobile_sessions': config.max_mobile_sessions,
})
return Response(_settings_dict(config))
serializer = SystemSettingsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
before = {
'default_daily_seconds_limit': config.default_daily_seconds_limit,
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions,
'max_mobile_sessions': config.max_mobile_sessions,
}
config.default_daily_seconds_limit = serializer.validated_data['default_daily_seconds_limit']
config.default_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit']
config.announcement = serializer.validated_data.get('announcement', '')
config.announcement_enabled = serializer.validated_data.get('announcement_enabled', False)
config.max_desktop_sessions = serializer.validated_data.get('max_desktop_sessions', 1)
config.max_mobile_sessions = serializer.validated_data.get('max_mobile_sessions', 0)
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={
'default_daily_seconds_limit': config.default_daily_seconds_limit,
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions,
'max_mobile_sessions': config.max_mobile_sessions,
})
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({
'default_daily_seconds_limit': config.default_daily_seconds_limit,
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions,
'max_mobile_sessions': config.max_mobile_sessions,
'updated_at': config.updated_at.isoformat(),
'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,
})

View File

@ -161,6 +161,12 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ──────────────────────────────────────────────
# IP Geolocation
# ──────────────────────────────────────────────
ALIYUN_IP_GEO_APPCODE = os.environ.get('ALIYUN_IP_GEO_APPCODE', '93a86e9dfc9e4c71bcd44baa4008e662')
IP2REGION_DB_PATH = BASE_DIR / 'data' / 'ip2region.xdb'
# ──────────────────────────────────────────────
# Security headers (production)
# ──────────────────────────────────────────────

View File

@ -6,3 +6,4 @@ mysqlclient>=2.2,<3.0
gunicorn>=21.2,<23.0
tos>=2.7,<3.0
requests>=2.31,<3.0
ip-region>=1.0

View File

@ -0,0 +1,267 @@
"""告警服务 — 飞书 interactive 卡片私信 + 辅助指标。"""
import json
import logging
from datetime import timedelta
import requests
from django.utils import timezone
logger = logging.getLogger(__name__)
# 小毛球机器人
FEISHU_APP_ID = 'cli_a90478156bf85bd7'
FEISHU_APP_SECRET = '87N2nnx6Yv56TPjl2GraLdKOjFiGOSGp'
_RULE_NAMES = {
'region_mismatch': '登录地区不对 (R1)',
'impossible_travel': '不可能的旅行 (R2)',
'login_frequency': '登录太频繁 (R3)',
'multi_city': '团队遍地开花 (R4)',
'overseas_ip_diversity': '海外IP太杂 (R5)',
}
_LEVEL_COLORS = {
'warning': 'orange',
'critical': 'red',
}
_LEVEL_LABELS = {
'warning': '⚠️ 警告',
'critical': '🚨 严重',
}
def _get_tenant_access_token():
"""获取飞书 tenant_access_token。"""
import os
app_secret = os.environ.get('FEISHU_APP_SECRET', FEISHU_APP_SECRET)
if not app_secret:
raise RuntimeError('FEISHU_APP_SECRET not configured')
resp = requests.post(
'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
json={'app_id': FEISHU_APP_ID, 'app_secret': app_secret},
timeout=5,
)
data = resp.json()
if data.get('code') != 0:
raise RuntimeError(f'Feishu token error: {data}')
return data['tenant_access_token']
def _get_open_id_by_mobile(token, mobile):
"""通过手机号查询飞书 open_id。"""
resp = requests.post(
'https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id',
headers={'Authorization': f'Bearer {token}'},
json={'mobiles': [mobile]},
timeout=5,
)
data = resp.json()
if data.get('code') != 0:
raise RuntimeError(f'Feishu user lookup error: {data}')
user_list = data.get('data', {}).get('user_list', [])
if user_list and user_list[0].get('user_id'):
return user_list[0]['user_id']
return None
def _compute_auxiliary_metrics(team):
"""计算辅助指标:最近 7 天并发踢出次数 + 非工作时间登录占比。"""
from apps.accounts.models import LoginRecord
since = timezone.now() - timedelta(days=7)
# 并发踢出次数ActiveSession 被删除的次数无法直接统计,
# 用 LoginAnomaly 中 rule=impossible_travel 的次数近似
from apps.accounts.models import LoginAnomaly
kick_count = LoginAnomaly.objects.filter(
team=team,
auto_disabled=True,
created_at__gte=since,
).count()
# 非工作时间登录占比 (22:00-08:00)
total_logins = LoginRecord.objects.filter(
team=team,
created_at__gte=since,
).count()
if total_logins > 0:
night_logins = 0
for record in LoginRecord.objects.filter(team=team, created_at__gte=since).only('created_at'):
hour = record.created_at.hour
if hour >= 22 or hour < 8:
night_logins += 1
night_ratio = round(night_logins / total_logins * 100, 1)
else:
night_ratio = 0
return kick_count, night_ratio
def _build_card(anomaly):
"""构建飞书 interactive 卡片。"""
team = anomaly.team
user = anomaly.user
record = anomaly.login_record
level = anomaly.level
rule = anomaly.rule
detail = anomaly.detail
color = _LEVEL_COLORS.get(level, 'blue')
level_label = _LEVEL_LABELS.get(level, level)
rule_name = _RULE_NAMES.get(rule, rule)
kick_count, night_ratio = _compute_auxiliary_metrics(team)
# 基本信息行
info_lines = [
f'**团队:** {team.name}',
f'**用户:** {user.username}',
f'**IP** {record.ip_address}',
f'**归属地:** {record.geo_country} {record.geo_province} {record.geo_city}',
f'**规则:** {rule_name}',
]
# 根据规则添加详情
if rule == 'region_mismatch':
info_lines.append(f'**预期城市:** {", ".join(detail.get("expected", []))}')
info_lines.append(f'**实际城市:** {detail.get("city", "")}')
elif rule == 'impossible_travel':
info_lines.append(f'**当前城市:** {detail.get("current_city", "")}')
info_lines.append(f'**之前城市:** {detail.get("previous_city", "")}')
elif rule == 'login_frequency':
info_lines.append(f'**登录次数:** {detail.get("count", 0)} 次 / {detail.get("window_seconds", 0)}s')
elif rule == 'multi_city':
info_lines.append(f'**预期外城市:** {", ".join(detail.get("unexpected_cities", []))}')
elif rule == 'overseas_ip_diversity':
info_lines.append(f'**海外国家:** {", ".join(detail.get("countries", []))}')
# 自动封禁标注
if anomaly.auto_disabled:
target_label = '该用户' if anomaly.disabled_target == 'user' else '整个团队'
info_lines.append(f'\n🔒 **已自动封禁{target_label}**')
# 辅助指标
info_lines.append(f'\n---\n📊 **辅助指标近7天**')
info_lines.append(f'并发踢出次数:{kick_count}')
info_lines.append(f'非工作时间登录占比:{night_ratio}%')
card = {
'config': {'wide_screen_mode': True},
'header': {
'title': {'tag': 'plain_text', 'content': f'{level_label} {rule_name}'},
'template': color,
},
'elements': [
{
'tag': 'div',
'text': {
'tag': 'lark_md',
'content': '\n'.join(info_lines),
},
},
],
}
return card
def send_feishu_alert(anomaly):
"""发送飞书告警卡片到配置的接收人。"""
from apps.generation.models import QuotaConfig
try:
config = QuotaConfig.objects.get(pk=1)
except QuotaConfig.DoesNotExist:
logger.warning('QuotaConfig not found, skip alert')
return
mobiles_str = config.feishu_alert_mobiles
if not mobiles_str:
logger.info('No feishu alert mobiles configured, skip alert')
return
mobiles = [m.strip() for m in mobiles_str.split(',') if m.strip()]
if not mobiles:
return
try:
token = _get_tenant_access_token()
except Exception as e:
logger.error('Failed to get feishu token: %s', e)
return
card = _build_card(anomaly)
for mobile in mobiles:
try:
open_id = _get_open_id_by_mobile(token, mobile)
if not open_id:
logger.warning('No feishu user found for mobile %s', mobile)
continue
resp = requests.post(
'https://open.feishu.cn/open-apis/im/v1/messages',
headers={'Authorization': f'Bearer {token}'},
params={'receive_id_type': 'open_id'},
json={
'receive_id': open_id,
'msg_type': 'interactive',
'content': json.dumps(card, ensure_ascii=False),
},
timeout=5,
)
data = resp.json()
if data.get('code') != 0:
logger.error('Feishu send failed to %s: %s', mobile, data)
else:
logger.info('Feishu alert sent to %s for rule %s', mobile, anomaly.rule)
except Exception as e:
logger.error('Feishu alert error for %s: %s', mobile, e)
def send_feishu_test(mobile):
"""发送测试消息到指定手机号。Returns (success, message)。"""
try:
token = _get_tenant_access_token()
open_id = _get_open_id_by_mobile(token, mobile)
if not open_id:
return False, f'未找到手机号 {mobile} 对应的飞书用户'
card = {
'config': {'wide_screen_mode': True},
'header': {
'title': {'tag': 'plain_text', 'content': '🔔 AirDrama 告警测试'},
'template': 'blue',
},
'elements': [
{
'tag': 'div',
'text': {
'tag': 'lark_md',
'content': '这是一条测试消息,说明飞书告警通道配置正常。',
},
},
],
}
import json
resp = requests.post(
'https://open.feishu.cn/open-apis/im/v1/messages',
headers={'Authorization': f'Bearer {token}'},
params={'receive_id_type': 'open_id'},
json={
'receive_id': open_id,
'msg_type': 'interactive',
'content': json.dumps(card, ensure_ascii=False),
},
timeout=5,
)
data = resp.json()
if data.get('code') != 0:
return False, f'发送失败: {data.get("msg", "")}'
return True, '测试消息已发送'
except Exception as e:
return False, str(e)

View File

@ -0,0 +1,311 @@
"""登录异常检测引擎 — R1-R5 规则检测 + 封禁 + 告警冷却。"""
import logging
import threading
from datetime import timedelta
from django.utils import timezone
logger = logging.getLogger(__name__)
def _is_domestic(record) -> bool:
"""国内 IP有国家且为中国。"""
return record.geo_country in ('中国', 'CN', 'China') and record.geo_city != ''
def _is_overseas(record) -> bool:
"""海外 IP有国家且不是中国。"""
return record.geo_country != '' and record.geo_country not in ('中国', 'CN', 'China', '')
def _is_skip(record) -> bool:
"""内网 IP 或归属地解析失败。"""
return record.geo_source in ('skip', 'failed', '')
def _get_config(team, global_config):
"""获取团队级或全局默认的阈值配置。"""
from apps.accounts.models import TeamAnomalyConfig
team_cfg = None
try:
team_cfg = team.anomaly_config
except TeamAnomalyConfig.DoesNotExist:
pass
def _val(team_field, global_field):
if team_cfg:
v = getattr(team_cfg, team_field, None)
if v is not None:
return v
return getattr(global_config, global_field)
return {
'r1_enabled': _val('r1_enabled', 'r1_enabled_default'),
'r2_enabled': _val('r2_enabled', 'r2_enabled_default'),
'r2_window': _val('r2_window_seconds', 'r2_window_seconds'),
'r3_enabled': _val('r3_enabled', 'r3_enabled_default'),
'r3_window': _val('r3_window_seconds', 'r3_window_seconds'),
'r3_max_count': _val('r3_max_count', 'r3_max_count'),
'r4_enabled': _val('r4_enabled', 'r4_enabled_default'),
'r4_window': _val('r4_window_seconds', 'r4_window_seconds'),
'r4_city_count': _val('r4_city_count', 'r4_city_count'),
'r5_enabled': _val('r5_enabled', 'r5_enabled_default'),
'r5_days': _val('r5_days', 'r5_days'),
'r5_country_count': _val('r5_country_count', 'r5_country_count'),
}
def check_login_anomaly(login_record):
"""检测登录异常,返回 [(level, rule, detail), ...]。"""
from apps.accounts.models import LoginRecord
from apps.generation.models import QuotaConfig
user = login_record.user
team = login_record.team
if not team:
return []
# 用户或团队已被封禁 → 跳过检测
if not user.is_active or not team.is_active:
return []
try:
global_config = QuotaConfig.objects.get(pk=1)
except QuotaConfig.DoesNotExist:
return []
if not global_config.anomaly_detection_enabled:
return []
cfg = _get_config(team, global_config)
anomalies = []
is_domestic = _is_domestic(login_record)
is_overseas = _is_overseas(login_record)
is_skip_ip = _is_skip(login_record)
if is_skip_ip:
# 内网 IP 跳过所有规则
return []
# ── R1登录地区不对 ──
if cfg['r1_enabled'] and is_domestic:
expected = team.expected_regions
if expected:
expected_cities = [c.strip() for c in expected.split(',') if c.strip()]
if expected_cities and login_record.geo_city not in expected_cities:
anomalies.append((
'warning', 'region_mismatch',
{
'ip': login_record.ip_address,
'city': login_record.geo_city,
'province': login_record.geo_province,
'expected': expected_cities,
}
))
# ── R2不可能的旅行 ──
if cfg['r2_enabled'] and is_domestic:
window = timezone.now() - timedelta(seconds=cfg['r2_window'])
recent = LoginRecord.objects.filter(
user=user,
created_at__gte=window,
geo_source__in=['online', 'offline'],
).exclude(pk=login_record.pk).exclude(
geo_city=''
).values_list('geo_city', flat=True).distinct()
for prev_city in recent:
if prev_city and login_record.geo_city and prev_city != login_record.geo_city:
# 只在双方都是国内 IP 时比较
anomalies.append((
'critical', 'impossible_travel',
{
'ip': login_record.ip_address,
'current_city': login_record.geo_city,
'previous_city': prev_city,
'window_seconds': cfg['r2_window'],
}
))
break # 只报一次
# ── R3登录太频繁 ──
if cfg['r3_enabled']:
window = timezone.now() - timedelta(seconds=cfg['r3_window'])
count = LoginRecord.objects.filter(
user=user,
created_at__gte=window,
).count()
if count > cfg['r3_max_count']:
anomalies.append((
'warning', 'login_frequency',
{
'ip': login_record.ip_address,
'count': count,
'window_seconds': cfg['r3_window'],
'threshold': cfg['r3_max_count'],
}
))
# ── R4团队遍地开花 ──
if cfg['r4_enabled'] and is_domestic:
expected = team.expected_regions
expected_cities = [c.strip() for c in expected.split(',') if c.strip()] if expected else []
window = timezone.now() - timedelta(seconds=cfg['r4_window'])
team_cities = LoginRecord.objects.filter(
team=team,
created_at__gte=window,
geo_source__in=['online', 'offline'],
).exclude(
geo_city=''
).exclude(
geo_country__in=['', '0']
).filter(
geo_country__in=['中国', 'CN', 'China']
).values_list('geo_city', flat=True).distinct()
unexpected_cities = [c for c in team_cities if c not in expected_cities]
if len(unexpected_cities) >= cfg['r4_city_count']:
anomalies.append((
'critical', 'multi_city',
{
'unexpected_cities': unexpected_cities,
'expected_cities': expected_cities,
'count': len(unexpected_cities),
'threshold': cfg['r4_city_count'],
'window_seconds': cfg['r4_window'],
}
))
# ── R5海外IP太杂 ──
if cfg['r5_enabled'] and is_overseas:
since = timezone.now() - timedelta(days=cfg['r5_days'])
overseas_countries = LoginRecord.objects.filter(
team=team,
created_at__gte=since,
geo_source__in=['online', 'offline'],
).exclude(
geo_country__in=['中国', 'CN', 'China', '', '0']
).values_list('geo_country', flat=True).distinct()
country_list = list(overseas_countries)
if len(country_list) >= cfg['r5_country_count']:
anomalies.append((
'warning', 'overseas_ip_diversity',
{
'countries': country_list,
'count': len(country_list),
'threshold': cfg['r5_country_count'],
'days': cfg['r5_days'],
}
))
return anomalies
def _disable_user(user):
"""封禁用户 — 设 is_active=False + 清除所有会话。"""
from apps.accounts.models import ActiveSession
user.is_active = False
user.disabled_by = 'system'
user.save(update_fields=['is_active', 'disabled_by'])
ActiveSession.objects.filter(user=user).delete()
logger.info('User %s disabled by anomaly detection', user.username)
def _disable_team(team):
"""封禁团队 — 团队 is_active=False + 全员踢下线。"""
from apps.accounts.models import ActiveSession
team.is_active = False
team.disabled_by = 'system'
team.save(update_fields=['is_active', 'disabled_by'])
ActiveSession.objects.filter(user__team=team).delete()
logger.info('Team %s disabled by anomaly detection', team.name)
def _is_in_cooldown(team, rule, cooldown_seconds):
"""检查告警冷却:同团队+同规则在冷却窗口内是否已告警。"""
from apps.accounts.models import LoginAnomaly
since = timezone.now() - timedelta(seconds=cooldown_seconds)
return LoginAnomaly.objects.filter(
team=team,
rule=rule,
alerted=True,
created_at__gte=since,
).exists()
def process_anomalies(login_record, anomalies):
"""保存异常记录 + 发告警 + 封禁。"""
from apps.accounts.models import LoginAnomaly
from apps.generation.models import QuotaConfig
if not anomalies:
return
try:
global_config = QuotaConfig.objects.get(pk=1)
except QuotaConfig.DoesNotExist:
return
cooldown = global_config.alert_cooldown_seconds
team = login_record.team
user = login_record.user
for level, rule, detail in anomalies:
# 确定是否需要封禁
auto_disabled = False
disabled_target = ''
if rule == 'impossible_travel':
_disable_user(user)
auto_disabled = True
disabled_target = 'user'
elif rule == 'multi_city':
_disable_team(team)
auto_disabled = True
disabled_target = 'team'
# 检查告警冷却
should_alert = not _is_in_cooldown(team, rule, cooldown)
# 保存异常记录
anomaly = LoginAnomaly.objects.create(
team=team,
user=user,
login_record=login_record,
level=level,
rule=rule,
detail=detail,
alerted=should_alert,
auto_disabled=auto_disabled,
disabled_target=disabled_target,
)
# 异步发送告警(不阻塞登录)
if should_alert:
thread = threading.Thread(
target=_send_alert_safe,
args=(anomaly.pk,),
daemon=True,
)
thread.start()
def _send_alert_safe(anomaly_pk):
"""安全地发送告警,捕获所有异常。"""
try:
from apps.accounts.models import LoginAnomaly
anomaly = LoginAnomaly.objects.select_related('team', 'user', 'login_record').get(pk=anomaly_pk)
from utils.alert_service import send_feishu_alert
send_feishu_alert(anomaly)
except Exception as e:
logger.error('Failed to send alert for anomaly %s: %s', anomaly_pk, e)

135
backend/utils/geo_client.py Normal file
View File

@ -0,0 +1,135 @@
"""IP 归属地解析 — 阿里云市场在线 API + ip2region 离线库 + 熔断降级。"""
import ipaddress
import logging
import re
import time
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
# ── 熔断状态 ──
_circuit_open_until = 0 # 在线 API 失败后,此时间戳之前直接走离线
_CIRCUIT_COOLDOWN = 60 # 熔断冷却 60 秒
# ── ip2region 搜索器缓存 ──
_ip2region_searcher = None
def _is_private_ip(ip: str) -> bool:
"""判断是否为私有/本地 IP。"""
try:
addr = ipaddress.ip_address(ip)
return addr.is_private or addr.is_loopback or addr.is_reserved
except (ValueError, TypeError):
return True
def _normalize_city(name: str) -> str:
"""标准化城市名:去掉「市」后缀。"""
if name and name.endswith(''):
return name[:-1]
return name
def _normalize_province(name: str) -> str:
"""标准化省份名:去掉「省」「自治区」「壮族自治区」等后缀。"""
if not name:
return name
for suffix in ['壮族自治区', '回族自治区', '维吾尔自治区', '自治区', '']:
if name.endswith(suffix):
return name[:-len(suffix)]
return name
def _resolve_online(ip: str) -> tuple:
"""阿里云市场 IP138 归属地 API超时 2s
Returns: (country, province, city) or raises Exception.
"""
appcode = settings.ALIYUN_IP_GEO_APPCODE
if not appcode:
raise RuntimeError('ALIYUN_IP_GEO_APPCODE not configured')
url = f'https://ali.ip138.com/ip/?ip={ip}&datatype=json'
headers = {'Authorization': f'APPCODE {appcode}'}
resp = requests.get(url, headers=headers, timeout=2)
resp.raise_for_status()
data = resp.json()
if data.get('ret') != 'ok':
raise RuntimeError(f'IP138 API error: {data.get("msg", data)}')
# data.data = ["国家", "省份", "城市", "运营商", "邮编", "区号"]
parts = data.get('data', [])
country = parts[0] if len(parts) > 0 else ''
province = _normalize_province(parts[1] if len(parts) > 1 else '')
city = _normalize_city(parts[2] if len(parts) > 2 else '')
return country, province, city
def _resolve_offline(ip: str) -> tuple:
"""ip2region 离线库解析。
Returns: (country, province, city) or ('', '', '').
"""
global _ip2region_searcher
if _ip2region_searcher is None:
try:
import ipregion, os
from ipregion import XdbSearcher
pkg_dir = os.path.dirname(ipregion.__file__)
db_path = os.path.join(pkg_dir, 'ip2region.xdb')
content = XdbSearcher.loadContentFromFile(dbfile=db_path)
_ip2region_searcher = XdbSearcher(contentBuff=content)
except Exception as e:
logger.warning('ip2region init failed: %s', e)
return '', '', ''
try:
region_str = _ip2region_searcher.searchByIPStr(ip)
# 格式: "中国|0|广东省|广州市|电信"
parts = region_str.split('|') if region_str else []
country = parts[0] if len(parts) > 0 and parts[0] != '0' else ''
province = _normalize_province(parts[2] if len(parts) > 2 and parts[2] != '0' else '')
city = _normalize_city(parts[3] if len(parts) > 3 and parts[3] != '0' else '')
return country, province, city
except Exception as e:
logger.warning('ip2region lookup failed for %s: %s', ip, e)
return '', '', ''
def resolve_ip_location(ip: str) -> tuple:
"""解析 IP 归属地。
Returns: (country, province, city, source)
source: 'online' / 'offline' / 'skip' / 'failed'
"""
if not ip or _is_private_ip(ip):
return '', '', '', 'skip'
global _circuit_open_until
# 尝试在线 API熔断期间跳过
now = time.time()
if now >= _circuit_open_until and settings.ALIYUN_IP_GEO_APPCODE:
try:
country, province, city = _resolve_online(ip)
return country, province, city, 'online'
except Exception as e:
logger.warning('Online IP geo failed for %s: %s — circuit open for %ds', ip, e, _CIRCUIT_COOLDOWN)
_circuit_open_until = now + _CIRCUIT_COOLDOWN
# 降级到离线库
try:
country, province, city = _resolve_offline(ip)
if country or province or city:
return country, province, city, 'offline'
except Exception as e:
logger.warning('Offline IP geo failed for %s: %s', ip, e)
return '', '', '', 'failed'

View File

@ -4,6 +4,91 @@
---
## 2026-03-19 — v0.9.7: 登录风控第二期 — IP归属地解析 + 异常检测 + 飞书告警 + 自动封禁
**状态**: ✅ 已完成 | **验收**: ✅ 通过本地验证IP138 在线 API 需部署至阿里云后验证)
### 变更内容
#### 后端
1. **IP 归属地解析** — 新建 `geo_client.py`,主通道阿里云市场 IP138 API精确到市备通道 ip2region 离线库60 秒熔断降级策略,私有 IP 自动跳过
2. **异常检测引擎** — 新建 `anomaly_detector.py`5 条规则:
- R1 登录地区不对(警告)— 单账号从非预期城市登录
- R2 不可能的旅行(严重)— 单账号短时间内从两个不同城市登录,自动封禁该用户
- R3 登录太频繁(警告)— 单账号短时间内登录次数过多
- R4 团队遍地开花(严重)— 整个团队短时间内出现大量异地登录,自动封禁整个团队
- R5 海外IP太杂警告— 整个团队短期内出现大量不同国家的登录
3. **飞书告警服务** — 新建 `alert_service.py`,通过飞书 Open API 发送 interactive 卡片私信,红色头=严重/橙色头=警告附带辅助指标7天并发踢出次数、非工作时间登录占比
4. **告警冷却** — 同团队+同规则 30 分钟内不重复告警(可配置)
5. **封禁机制** — R2 封用户 + R4 封团队,封禁即踢下线(清 ActiveSession前端拦截 user_disabled/team_disabled 错误码弹窗提示
6. **团队级阈值配置** — TeamAnomalyConfig 模型OneToOne → Team未配置时取全局默认值
7. **自动学习预期地区** — 统计团队最近 30 天登录城市,频次 ≥ 3 的城市纳入预期列表
8. **LoginRecord 扩展** — 新增 team FK、geo_country/province/city/source 字段
9. **SessionJWT 双重检查** — 认证层同时检查 user.is_active 和 team.is_active
#### 前端
10. **系统设置页** — 异常检测总开关、R1-R5 默认阈值编辑(三段按钮组 默认|开|关)、飞书接收人手机号+测试按钮、短信(灰色 Coming soon、告警冷却时间
11. **团队管理页** — 预期登录城市编辑+自动学习按钮、R1-R5 团队级阈值覆盖、disabled_by 来源标签(系统/管理员)
12. **安全日志页面**`/admin/security` LoginAnomaly 记录列表,按团队/规则/级别/时间筛选
13. **用户管理页** — disabled_by 来源标签(系统自动禁用/管理员手动禁用)
14. **管理员修改密码** — AdminLayout 侧栏底部新增修改密码入口+弹窗
15. **前端拦截器** — user_disabled/team_disabled 错误码弹窗提示后跳登录页
### 新增/变更 API
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/admin/anomalies` | 登录异常记录筛选team/rule/level/date |
| POST | `/api/v1/admin/test-feishu` | 发送飞书测试消息 |
| POST | `/api/v1/admin/teams/<id>/auto-learn` | 自动学习预期登录地区 |
| POST | `/api/v1/admin/teams/<id>/apply-learned-regions` | 应用学习到的预期地区 |
### 新增文件
| 文件 | 用途 |
|------|------|
| `backend/utils/geo_client.py` | IP 归属地解析IP138 在线 + ip2region 离线) |
| `backend/utils/anomaly_detector.py` | 异常检测引擎R1-R5 规则) |
| `backend/utils/alert_service.py` | 告警服务(飞书 interactive 卡片) |
| `backend/apps/accounts/migrations/0008_anomaly_detection_phase2.py` | 账号模型迁移 |
| `backend/apps/generation/migrations/0006_anomaly_detection_phase2.py` | 配额模型迁移 |
| `web/src/pages/AnomalyLogPage.tsx` | 安全日志页面 |
### 变更文件
| 文件 | 改动 |
|------|------|
| `CLAUDE.md` | 新增异常检测相关模型/API/路由/环境变量文档 |
| `backend/apps/accounts/authentication.py` | SessionJWT 认证增加 team.is_active 检查 |
| `backend/apps/accounts/models.py` | 新增 LoginAnomaly/TeamAnomalyConfig 模型User/Team 新增 disabled_by |
| `backend/apps/accounts/views.py` | login_view 增加 geo 解析 + 异常检测调用 |
| `backend/apps/generation/models.py` | QuotaConfig 新增异常检测全局配置字段 |
| `backend/apps/generation/serializers.py` | 新增异常检测相关 Serializer |
| `backend/apps/generation/urls.py` | 新增 4 条路由 |
| `backend/apps/generation/views.py` | 新增 anomalies/test-feishu/auto-learn/apply-learned-regions 视图 |
| `backend/config/settings.py` | 新增 ALIYUN_IP_GEO_APPCODE / FEISHU_APP_SECRET 配置 |
| `backend/requirements.txt` | 新增 ip2region>=2.7.0 |
| `web/src/App.tsx` | 新增 /admin/security 路由 |
| `web/src/lib/api.ts` | 新增 getLoginAnomalies/testFeishu/teamAutoLearn/applyLearnedRegions |
| `web/src/pages/AdminLayout.tsx` | 侧栏新增"安全日志"导航 + 修改密码弹窗 |
| `web/src/pages/SettingsPage.tsx` | 新增异常检测配置卡片 |
| `web/src/pages/TeamsPage.tsx` | 预期地区编辑/自动学习 + 团队级阈值配置 |
| `web/src/pages/TeamsPage.module.css` | membersTitle 间距调整 |
| `web/src/pages/UsersPage.tsx` | disabled_by 来源标签 |
| `web/src/types/index.ts` | 新增 LoginAnomaly/TeamAnomalyConfig 接口 |
### 触发原因
- 火山引擎明确禁止 C 端使用 Seedance API需要防止团队私自将账号开放给 C 端个人用户
- 第一期已完成并发会话限制 + Token 缩短 + 登录记录,第二期基于 IP 归属地做异常检测
### 关键设计决策
- 异常检测不阻塞登录try/except 包裹)
- 在线 API 熔断 60 秒自动降级离线库
- R2 城市名直接比较(不算距离),空城市跳过(防误封)
- R4 只统计预期城市列表之外的城市
- R5 统计国家数而非 IP 数VPN 轮换 IP 但出口国家固定)
- 飞书告警异步发送daemon thread不拖慢登录
- TeamAnomalyConfig 独立模型,不污染 Team
---
## 2026-03-16 — v0.9.1: 首页 + 播放器修复
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地验证)

View File

@ -13,6 +13,7 @@ import { UsersPage } from './pages/UsersPage';
import { RecordsPage } from './pages/RecordsPage';
import { SettingsPage } from './pages/SettingsPage';
import { AuditLogsPage } from './pages/AuditLogsPage';
import { AnomalyLogPage } from './pages/AnomalyLogPage';
import { ProfilePage } from './pages/ProfilePage';
import { AssetsPage } from './pages/AssetsPage';
@ -77,6 +78,7 @@ export default function App() {
<Route path="users" element={<UsersPage />} />
<Route path="records" element={<RecordsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="security" element={<AnomalyLogPage />} />
<Route path="logs" element={<AuditLogsPage />} />
<Route path="assets" element={<AdminAssetsPage />} />
</Route>

View File

@ -4,6 +4,7 @@ import type {
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
LoginAnomaly, TeamAnomalyConfig,
} from '../types';
import { reportError } from './logCenter';
@ -30,9 +31,9 @@ api.interceptors.response.use(
const authEndpoints = ['/auth/login', '/auth/register', '/auth/token/refresh'];
const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep));
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
// Check if session was kicked by another device login
const errorCode = error.response?.data?.code || error.response?.data?.detail?.code;
// Check special ban/kick codes on 401 or 403
const errorCode = error.response?.data?.code || error.response?.data?.detail?.code;
if ((error.response?.status === 401 || error.response?.status === 403) && !isAuthEndpoint) {
if (errorCode === 'session_expired_other_device') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
@ -40,7 +41,24 @@ api.interceptors.response.use(
window.location.href = '/login';
return Promise.reject(error);
}
if (errorCode === 'user_disabled') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
alert('您的账号已被禁用,请联系团队管理员');
window.location.href = '/login';
return Promise.reject(error);
}
if (errorCode === 'team_disabled') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
alert('您所在的团队已被禁用,请联系平台管理员');
window.location.href = '/login';
return Promise.reject(error);
}
}
// Auto-refresh on 401 (only for non-ban cases)
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
originalRequest._retry = true;
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
@ -144,13 +162,13 @@ export const adminApi = {
getTeams: () =>
api.get<{ results: Team[] }>('/admin/teams'),
createTeam: (data: { name: string; monthly_seconds_limit?: number; daily_member_limit_default?: number }) =>
createTeam: (data: { name: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; expected_regions: string }) =>
api.post('/admin/teams/create', data),
getTeamDetail: (teamId: number) =>
api.get<TeamDetail>(`/admin/teams/${teamId}`),
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; is_active?: boolean }) =>
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
api.put(`/admin/teams/${teamId}`, data),
topUpTeam: (teamId: number, seconds: number) =>
@ -243,6 +261,29 @@ export const adminApi = {
results: AssetVideo[];
}>(`/admin/assets/user/${userId}/videos`, { params: { page, page_size: pageSize } }),
// Anomaly detection
getLoginAnomalies: (params: {
page?: number;
page_size?: number;
team_id?: number;
rule?: string;
level?: string;
start_date?: string;
end_date?: string;
} = {}) =>
api.get<PaginatedResponse<LoginAnomaly> & { total_pages: number }>('/admin/anomalies', { params }),
testFeishu: (mobile: string) =>
api.post<{ message: string }>('/admin/test-feishu', { mobile }),
teamAutoLearn: (teamId: number, days: number = 30, minCount: number = 3) =>
api.post<{ team_id: number; team_name: string; learned_cities: string[]; days: number; min_count: number; current_expected_regions: string }>(
`/admin/teams/${teamId}/auto-learn`, { days, min_count: minCount }
),
teamApplyLearnedRegions: (teamId: number, cities: string[]) =>
api.post(`/admin/teams/${teamId}/apply-learned-regions`, { cities }),
getAuditLogs: (params: {
page?: number;
page_size?: number;

View File

@ -1,6 +1,7 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { authApi } from '../lib/api';
import logoImg from '../assets/logo_32.png';
import styles from './AdminLayout.module.css';
@ -11,6 +12,7 @@ const navItems = [
{ path: '/admin/assets', label: '内容资产', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z' },
{ path: '/admin/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z' },
{ path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },
{ path: '/admin/security', label: '安全日志', icon: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z' },
{ path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' },
];
@ -19,12 +21,34 @@ export function AdminLayout() {
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
const [pwModalOpen, setPwModalOpen] = useState(false);
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwSaving, setPwSaving] = useState(false);
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
const handleChangePassword = useCallback(async () => {
if (!oldPw || !newPw) return;
if (newPw.length < 6) { alert('新密码至少6位'); return; }
if (newPw !== confirmPw) { alert('两次输入的新密码不一致'); return; }
setPwSaving(true);
try {
await authApi.changePassword(oldPw, newPw);
alert('密码修改成功');
setPwModalOpen(false);
setOldPw(''); setNewPw(''); setConfirmPw('');
} catch (e: any) {
alert(e.response?.data?.error || e.response?.data?.detail || '修改失败');
} finally {
setPwSaving(false);
}
}, [oldPw, newPw, confirmPw]);
return (
<div className={styles.layout}>
<aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}>
@ -74,7 +98,10 @@ export function AdminLayout() {
{!collapsed && (
<div className={styles.userMeta}>
<span className={styles.userName}>{user?.username}</span>
<button className={styles.logoutLink} onClick={handleLogout}>退</button>
<div style={{ display: 'flex', gap: '8px' }}>
<button className={styles.logoutLink} onClick={() => setPwModalOpen(true)}></button>
<button className={styles.logoutLink} onClick={handleLogout}>退</button>
</div>
</div>
)}
</div>
@ -84,6 +111,33 @@ export function AdminLayout() {
<main className={`${styles.content} ${collapsed ? styles.contentExpanded : ''}`}>
<Outlet />
</main>
{pwModalOpen && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
onClick={() => setPwModalOpen(false)}>
<div style={{ background: 'var(--color-bg-card)', borderRadius: '12px', padding: '24px', width: '360px', border: '1px solid var(--color-border-card)' }}
onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 16px', color: 'var(--color-text-primary)', fontSize: '16px' }}></h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<input type="password" placeholder="当前密码" value={oldPw} onChange={(e) => setOldPw(e.target.value)}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: '14px' }} />
<input type="password" placeholder="新密码至少6位" value={newPw} onChange={(e) => setNewPw(e.target.value)}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: '14px' }} />
<input type="password" placeholder="确认新密码" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleChangePassword()}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: '14px' }} />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
<button onClick={() => setPwModalOpen(false)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '13px' }}></button>
<button onClick={handleChangePassword} disabled={pwSaving}
style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '13px', opacity: pwSaving ? 0.6 : 1 }}>
{pwSaving ? '修改中...' : '确认修改'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,224 @@
import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { LoginAnomaly, Team } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import { Select } from '../components/Select';
import styles from './AuditLogsPage.module.css';
const RULE_OPTIONS = [
{ label: '全部规则', value: '' },
{ label: 'R1 登录地区不对', value: 'region_mismatch' },
{ label: 'R2 不可能的旅行', value: 'impossible_travel' },
{ label: 'R3 登录太频繁', value: 'login_frequency' },
{ label: 'R4 团队遍地开花', value: 'multi_city' },
{ label: 'R5 海外IP太杂', value: 'overseas_ip_diversity' },
];
const LEVEL_OPTIONS = [
{ label: '全部级别', value: '' },
{ label: '警告', value: 'warning' },
{ label: '严重', value: 'critical' },
];
const RULE_LABELS: Record<string, string> = {
region_mismatch: 'R1 登录地区不对',
impossible_travel: 'R2 不可能的旅行',
login_frequency: 'R3 登录太频繁',
multi_city: 'R4 团队遍地开花',
overseas_ip_diversity: 'R5 海外IP太杂',
};
export function AnomalyLogPage() {
const [anomalies, setAnomalies] = useState<LoginAnomaly[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [ruleFilter, setRuleFilter] = useState('');
const [levelFilter, setLevelFilter] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [loading, setLoading] = useState(true);
const [teams, setTeams] = useState<Team[]>([]);
const pageSize = 20;
useEffect(() => {
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
}, []);
const fetchAnomalies = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getLoginAnomalies({
page, page_size: pageSize,
rule: ruleFilter || undefined,
level: levelFilter || undefined,
team_id: teamFilter ? Number(teamFilter) : undefined,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
setAnomalies(data.results);
setTotal(data.total);
} catch {
showToast('加载安全日志失败');
} finally {
setLoading(false);
}
}, [page, ruleFilter, levelFilter, teamFilter, startDate, endDate]);
useEffect(() => { fetchAnomalies(); }, [fetchAnomalies]);
const handleSearch = () => {
setPage(1);
fetchAnomalies();
};
const totalPages = Math.ceil(total / pageSize);
const teamOptions = [
{ label: '全部团队', value: '' },
...teams.map((t) => ({ label: t.name, value: String(t.id) })),
];
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<Select
value={ruleFilter}
onChange={(v) => { setRuleFilter(v); setPage(1); }}
placeholder="全部规则"
options={RULE_OPTIONS}
/>
<Select
value={levelFilter}
onChange={(v) => { setLevelFilter(v); setPage(1); }}
placeholder="全部级别"
options={LEVEL_OPTIONS}
/>
<Select
value={teamFilter}
onChange={(v) => { setTeamFilter(v); setPage(1); }}
placeholder="全部团队"
options={teamOptions}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
<button className={styles.refreshBtn} onClick={fetchAnomalies}></button>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>IP / </th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : anomalies.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
) : (
anomalies.map((a) => (
<tr key={a.id}>
<td className={styles.timeCell}>{new Date(a.created_at).toLocaleString('zh-CN')}</td>
<td>{a.team_name}</td>
<td>{a.username}</td>
<td>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 12, whiteSpace: 'nowrap',
background: a.level === 'critical' ? 'rgba(255, 77, 79, 0.15)' : 'rgba(250, 173, 20, 0.15)',
color: a.level === 'critical' ? '#ff4d4f' : '#faad14',
}}>
{a.level === 'critical' ? '严重' : '警告'}
</span>
</td>
<td><span className={styles.actionBadge}>{RULE_LABELS[a.rule] || a.rule}</span></td>
<td className={styles.ipCell}>
{a.ip_address}
{(a.geo_country || a.geo_city) && (
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
{[a.geo_country, a.geo_province, a.geo_city].filter(Boolean).join(' ')}
</div>
)}
</td>
<td style={{ fontSize: 12, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{renderDetail(a)}
</td>
<td>
{a.auto_disabled ? (
<span style={{ fontSize: 12, color: '#ff4d4f' }}>
{a.disabled_target === 'team' ? '团队' : '用户'}
</span>
) : a.alerted ? (
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}></span>
) : (
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>-</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<span className={styles.pageInfo}> {total} </span>
<div className={styles.pageButtons}>
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>&lt;</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let p: number;
if (totalPages <= 5) p = i + 1;
else if (page <= 3) p = i + 1;
else if (page >= totalPages - 2) p = totalPages - 4 + i;
else p = page - 2 + i;
return (
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
{p}
</button>
);
})}
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>&gt;</button>
</div>
</div>
)}
</div>
);
}
function renderDetail(a: LoginAnomaly): string {
const d = a.detail;
switch (a.rule) {
case 'region_mismatch':
return `预期: ${(d.expected as string[] || []).join(',')} | 实际: ${d.city || ''}`;
case 'impossible_travel':
return `${d.previous_city || ''}${d.current_city || ''}`;
case 'login_frequency':
return `${d.count || 0} 次 / ${d.window_seconds || 0}s`;
case 'multi_city':
return `预期外城市: ${(d.unexpected_cities as string[] || []).join(',')}`;
case 'overseas_ip_diversity':
return `海外国家: ${(d.countries as string[] || []).join(',')}`;
default:
return JSON.stringify(d);
}
}

View File

@ -12,7 +12,24 @@ export function SettingsPage() {
announcement_enabled: false,
max_desktop_sessions: 1,
max_mobile_sessions: 0,
anomaly_detection_enabled: false,
r1_enabled_default: true,
r2_enabled_default: true,
r2_window_seconds: 3600,
r3_enabled_default: true,
r3_window_seconds: 3600,
r3_max_count: 10,
r4_enabled_default: true,
r4_window_seconds: 3600,
r4_city_count: 5,
r5_enabled_default: true,
r5_days: 7,
r5_country_count: 10,
feishu_alert_mobiles: '',
sms_alert_mobiles: '',
alert_cooldown_seconds: 1800,
});
const [testingFeishu, setTestingFeishu] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -53,6 +70,20 @@ export function SettingsPage() {
}
};
const handleTestFeishu = async () => {
const mobiles = settings.feishu_alert_mobiles.split(',').map(s => s.trim()).filter(Boolean);
if (mobiles.length === 0) { showToast('请先填写飞书告警手机号'); return; }
setTestingFeishu(true);
try {
await adminApi.testFeishu(mobiles[0]);
showToast('测试消息已发送');
} catch (err: any) {
showToast(err.response?.data?.error || '发送失败');
} finally {
setTestingFeishu(false);
}
};
if (loading) {
return (
<div className={styles.page}>
@ -151,6 +182,167 @@ export function SettingsPage() {
{saving ? '保存中...' : '保存公告'}
</button>
</div>
<div className={styles.card}>
<div className={styles.cardHeader}>
<div>
<h2 className={styles.cardTitle}></h2>
<p className={styles.cardDesc}> IP </p>
</div>
<label className={styles.switch}>
<input
type="checkbox"
checked={settings.anomaly_detection_enabled}
onChange={(e) => setSettings({ ...settings, anomaly_detection_enabled: e.target.checked })}
/>
<span className={styles.slider}></span>
</label>
</div>
{settings.anomaly_detection_enabled && (
<>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 12, marginTop: 8 }}></h3>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<label className={styles.switch} style={{ marginBottom: 0 }}>
<input type="checkbox" checked={settings.r1_enabled_default} onChange={(e) => setSettings({ ...settings, r1_enabled_default: e.target.checked })} />
<span className={styles.slider}></span>
</label>
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R1 </span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
</div>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<label className={styles.switch} style={{ marginBottom: 0 }}>
<input type="checkbox" checked={settings.r2_enabled_default} onChange={(e) => setSettings({ ...settings, r2_enabled_default: e.target.checked })} />
<span className={styles.slider}></span>
</label>
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R2 </span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
</div>
<div className={styles.formGroup} style={{ marginLeft: 56 }}>
<label> ()</label>
<input type="number" value={settings.r2_window_seconds} onChange={(e) => setSettings({ ...settings, r2_window_seconds: Number(e.target.value) })} />
</div>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<label className={styles.switch} style={{ marginBottom: 0 }}>
<input type="checkbox" checked={settings.r3_enabled_default} onChange={(e) => setSettings({ ...settings, r3_enabled_default: e.target.checked })} />
<span className={styles.slider}></span>
</label>
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R3 </span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
</div>
<div className={styles.formRow} style={{ marginLeft: 56 }}>
<div className={styles.formGroup}>
<label> ()</label>
<input type="number" value={settings.r3_window_seconds} onChange={(e) => setSettings({ ...settings, r3_window_seconds: Number(e.target.value) })} />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={settings.r3_max_count} onChange={(e) => setSettings({ ...settings, r3_max_count: Number(e.target.value) })} />
</div>
</div>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<label className={styles.switch} style={{ marginBottom: 0 }}>
<input type="checkbox" checked={settings.r4_enabled_default} onChange={(e) => setSettings({ ...settings, r4_enabled_default: e.target.checked })} />
<span className={styles.slider}></span>
</label>
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R4 </span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
</div>
<div className={styles.formRow} style={{ marginLeft: 56 }}>
<div className={styles.formGroup}>
<label> ()</label>
<input type="number" value={settings.r4_window_seconds} onChange={(e) => setSettings({ ...settings, r4_window_seconds: Number(e.target.value) })} />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={settings.r4_city_count} onChange={(e) => setSettings({ ...settings, r4_city_count: Number(e.target.value) })} />
</div>
</div>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<label className={styles.switch} style={{ marginBottom: 0 }}>
<input type="checkbox" checked={settings.r5_enabled_default} onChange={(e) => setSettings({ ...settings, r5_enabled_default: e.target.checked })} />
<span className={styles.slider}></span>
</label>
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R5 IP太杂</span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
</div>
<div className={styles.formRow} style={{ marginLeft: 56 }}>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={settings.r5_days} onChange={(e) => setSettings({ ...settings, r5_days: Number(e.target.value) })} />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={settings.r5_country_count} onChange={(e) => setSettings({ ...settings, r5_country_count: Number(e.target.value) })} />
</div>
</div>
</div>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 12 }}></h3>
<div className={styles.formGroup}>
<label></label>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
value={settings.feishu_alert_mobiles}
onChange={(e) => setSettings({ ...settings, feishu_alert_mobiles: e.target.value })}
placeholder="13800138000,13900139000"
style={{ flex: 1 }}
/>
<button
className={styles.saveBtn}
onClick={handleTestFeishu}
disabled={testingFeishu}
style={{ whiteSpace: 'nowrap', padding: '10px 16px' }}
>
{testingFeishu ? '发送中...' : '测试'}
</button>
</div>
</div>
<div className={styles.formGroup}>
<label>Coming soon</label>
<input
type="text"
value={settings.sms_alert_mobiles}
onChange={(e) => setSettings({ ...settings, sms_alert_mobiles: e.target.value })}
placeholder="暂未开放"
disabled
style={{ opacity: 0.5 }}
/>
</div>
<div className={styles.formGroup}>
<label> ()</label>
<input
type="number"
value={settings.alert_cooldown_seconds}
onChange={(e) => setSettings({ ...settings, alert_cooldown_seconds: Number(e.target.value) })}
/>
<p className={styles.cardHint}> + </p>
</div>
</>
)}
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
{saving ? '保存中...' : '保存异常检测设置'}
</button>
</div>
</div>
);
}

View File

@ -215,6 +215,7 @@
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin-top: 20px;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);

View File

@ -3,6 +3,7 @@ import { adminApi } from '../lib/api';
import type { Team, TeamDetail } from '../types';
import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal';
import { Select } from '../components/Select';
import styles from './TeamsPage.module.css';
function fmtSec(s: number): string {
@ -18,6 +19,7 @@ export function TeamsPage() {
const [newName, setNewName] = useState('');
const [newMonthlyLimit, setNewMonthlyLimit] = useState('36000');
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('600');
const [newExpectedRegions, setNewExpectedRegions] = useState('');
const [createError, setCreateError] = useState('');
// Top-up modal
@ -67,19 +69,30 @@ export function TeamsPage() {
}
};
// Auto-learn state
const [learnLoading, setLearnLoading] = useState(false);
const [learnedCities, setLearnedCities] = useState<string[]>([]);
const [learnOpen, setLearnOpen] = useState(false);
const [editingRegions, setEditingRegions] = useState(false);
const [editRegionsValue, setEditRegionsValue] = useState('');
const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false);
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
const resetCreateForm = () => {
setNewName(''); setNewMonthlyLimit('36000'); setNewDailyMemberLimit('600');
setCreateError('');
setNewExpectedRegions(''); setCreateError('');
};
const handleCreateTeam = async () => {
setCreateError('');
if (!newName.trim()) { setCreateError('请输入团队名称'); return; }
if (!newExpectedRegions.trim()) { setCreateError('请输入预期登录城市'); return; }
try {
await adminApi.createTeam({
name: newName.trim(),
monthly_seconds_limit: Number(newMonthlyLimit),
daily_member_limit_default: Number(newDailyMemberLimit),
expected_regions: newExpectedRegions.trim(),
});
showToast('团队创建成功');
setCreateOpen(false);
@ -150,6 +163,34 @@ export function TeamsPage() {
}
};
const handleAutoLearn = async () => {
if (!detailTeam) return;
setLearnLoading(true);
try {
const { data } = await adminApi.teamAutoLearn(detailTeam.id);
setLearnedCities(data.learned_cities);
setLearnOpen(true);
} catch {
showToast('自动学习失败');
} finally {
setLearnLoading(false);
}
};
const handleApplyLearnedRegions = async () => {
if (!detailTeam) return;
try {
await adminApi.teamApplyLearnedRegions(detailTeam.id, learnedCities);
showToast('预期地区已更新');
setLearnOpen(false);
const { data } = await adminApi.getTeamDetail(detailTeam.id);
setDetailTeam(data);
fetchTeams();
} catch {
showToast('应用失败');
}
};
const openDrawer = async (teamId: number) => {
try {
const { data } = await adminApi.getTeamDetail(teamId);
@ -220,6 +261,11 @@ export function TeamsPage() {
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
{t.is_active ? '启用' : '禁用'}
</span>
{!t.is_active && t.disabled_by && (
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginLeft: 4 }}>
({t.disabled_by === 'system' ? '系统' : '管理员'})
</span>
)}
</td>
<td>
<div className={styles.actions}>
@ -259,6 +305,10 @@ export function TeamsPage() {
<input type="number" value={newDailyMemberLimit} onChange={(e) => setNewDailyMemberLimit(e.target.value)} />
</div>
</div>
<div className={styles.formGroup}>
<label></label>
<input type="text" value={newExpectedRegions} onChange={(e) => setNewExpectedRegions(e.target.value)} placeholder="广州市,深圳市,北京市" />
</div>
{createError && <div className={styles.formError}>{createError}</div>}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setCreateOpen(false)}></button>
@ -336,6 +386,11 @@ export function TeamsPage() {
<span className={`${styles.statusBadge} ${detailTeam.is_active ? styles.active : styles.disabled}`}>
{detailTeam.is_active ? '启用' : '禁用'}
</span>
{!detailTeam.is_active && detailTeam.disabled_by && (
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)', fontWeight: 400, marginLeft: 8 }}>
({detailTeam.disabled_by === 'system' ? '系统自动禁用' : '管理员手动禁用'})
</span>
)}
</h3>
<button className={styles.detailClose} onClick={() => setDrawerOpen(false)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@ -391,6 +446,179 @@ export function TeamsPage() {
</div>
</div>
<div style={{ marginTop: 16, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<span className={styles.detailLabel} style={{ marginBottom: 0 }}></span>
{!editingRegions && (
<button
className={styles.topupBtn}
onClick={() => { setEditingRegions(true); setEditRegionsValue(detailTeam.expected_regions || ''); }}
style={{ fontSize: 12, padding: '4px 10px' }}
>
</button>
)}
<button
className={styles.topupBtn}
onClick={handleAutoLearn}
disabled={learnLoading}
style={{ fontSize: 12, padding: '4px 10px' }}
>
{learnLoading ? '学习中...' : '自动学习'}
</button>
</div>
{editingRegions ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
value={editRegionsValue}
onChange={(e) => setEditRegionsValue(e.target.value)}
placeholder="多个城市用逗号分隔,如:广州,深圳,北京"
style={{ flex: 1, padding: '6px 10px', borderRadius: 6, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
/>
<button
className={styles.topupBtn}
onClick={async () => {
try {
await adminApi.updateTeam(detailTeam.id, { expected_regions: editRegionsValue.trim() });
setDetailTeam({ ...detailTeam, expected_regions: editRegionsValue.trim() });
setEditingRegions(false);
} catch (e: any) {
alert(e.response?.data?.error || '保存失败');
}
}}
style={{ fontSize: 12, padding: '4px 12px' }}
>
</button>
<button
className={styles.topupBtn}
onClick={() => setEditingRegions(false)}
style={{ fontSize: 12, padding: '4px 12px' }}
>
</button>
</div>
) : (
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>
{detailTeam.expected_regions || '(未设置)'}
</span>
)}
</div>
{/* 团队级异常检测阈值 */}
<div style={{ marginTop: 16, padding: '12px 16px', background: 'var(--color-bg-page)', borderRadius: 8, border: '1px solid var(--color-border-card)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}> = </span>
{!editingAnomalyConfig ? (
<button className={styles.topupBtn} onClick={() => { setEditingAnomalyConfig(true); setAnomalyConfigDraft(detailTeam.anomaly_config || {}); }} style={{ fontSize: 12, padding: '4px 10px' }}></button>
) : (
<div style={{ display: 'flex', gap: 6 }}>
<button className={styles.topupBtn} onClick={async () => {
try {
await adminApi.updateTeam(detailTeam.id, { anomaly_config: anomalyConfigDraft });
setDetailTeam({ ...detailTeam, anomaly_config: anomalyConfigDraft as any });
setEditingAnomalyConfig(false);
showToast('已保存');
} catch { showToast('保存失败'); }
}} style={{ fontSize: 12, padding: '4px 10px' }}></button>
<button className={styles.topupBtn} onClick={() => setEditingAnomalyConfig(false)} style={{ fontSize: 12, padding: '4px 10px' }}></button>
</div>
)}
</div>
{editingAnomalyConfig ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, fontSize: 12 }}>
{[
{ title: 'R1 登录地区异常', desc: '单账号从非预期城市登录时告警', items: [
{ key: 'r1_enabled', label: '启用', type: 'bool' },
]},
{ title: 'R2 不可能的旅行', desc: '单账号短时间内从两个不同城市登录,自动封禁该用户', items: [
{ key: 'r2_enabled', label: '启用', type: 'bool' },
{ key: 'r2_window_seconds', label: '时间窗口(秒)', type: 'num' },
]},
{ title: 'R3 登录太频繁', desc: '单账号短时间内登录次数过多时告警', items: [
{ key: 'r3_enabled', label: '启用', type: 'bool' },
{ key: 'r3_window_seconds', label: '时间窗口(秒)', type: 'num' },
{ key: 'r3_max_count', label: '最大次数', type: 'num' },
]},
{ title: 'R4 团队遍地开花', desc: '整个团队短时间内出现大量异地登录,自动封禁整个团队', items: [
{ key: 'r4_enabled', label: '启用', type: 'bool' },
{ key: 'r4_window_seconds', label: '时间窗口(秒)', type: 'num' },
{ key: 'r4_city_count', label: '城市数阈值', type: 'num' },
]},
{ title: 'R5 海外IP太杂', desc: '整个团队短期内出现大量不同国家的登录时告警', items: [
{ key: 'r5_enabled', label: '启用', type: 'bool' },
{ key: 'r5_days', label: '统计天数', type: 'num' },
{ key: 'r5_country_count', label: '国家数阈值', type: 'num' },
]},
].map((group, gi) => (
<div key={group.title}>
{gi > 0 && <div style={{ height: 1, background: 'var(--color-border-card)', margin: '8px 0' }} />}
<div style={{ marginBottom: 6 }}>
<span style={{ color: 'var(--color-text-primary)', fontWeight: 500, fontSize: 12 }}>{group.title}</span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 11, marginLeft: 8 }}>{group.desc}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 16px', paddingLeft: 8 }}>
{group.items.map((item) => (
<div key={item.key} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: 'var(--color-text-secondary)', minWidth: 80 }}>{item.label}</span>
<div style={{ width: 90 }}>
{item.type === 'bool' ? (
<div style={{ display: 'flex', borderRadius: 4, overflow: 'hidden', border: '1px solid var(--color-border-card)', width: '100%' }}>
{([['', '默认'], ['true', '开'], ['false', '关']] as const).map(([val, label]) => {
const cur = anomalyConfigDraft[item.key];
const selected = val === '' ? (cur === null || cur === undefined) : String(cur) === val;
return (
<button
key={val}
onClick={() => setAnomalyConfigDraft({ ...anomalyConfigDraft, [item.key]: val === '' ? null : val === 'true' })}
style={{ flex: 1, padding: '3px 0', fontSize: 11, border: 'none', cursor: 'pointer', background: selected ? 'var(--color-primary)' : 'var(--color-bg-card)', color: selected ? '#fff' : 'var(--color-text-secondary)', transition: 'all 0.15s' }}
>
{label}
</button>
);
})}
</div>
) : (
<input
type="number"
value={anomalyConfigDraft[item.key] ?? ''}
onChange={(e) => setAnomalyConfigDraft({ ...anomalyConfigDraft, [item.key]: e.target.value === '' ? null : Number(e.target.value) })}
placeholder="默认"
style={{ width: '100%', padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-card)', color: 'var(--color-text-primary)', fontSize: 12, boxSizing: 'border-box' }}
/>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{(() => {
const c = detailTeam.anomaly_config;
if (!c) return '全部使用全局默认值';
const items: string[] = [];
if (c.r1_enabled !== null) items.push(`R1: ${c.r1_enabled ? '开' : '关'}`);
if (c.r2_enabled !== null) items.push(`R2: ${c.r2_enabled ? '开' : '关'}`);
if (c.r2_window_seconds !== null) items.push(`R2窗口: ${c.r2_window_seconds}`);
if (c.r3_enabled !== null) items.push(`R3: ${c.r3_enabled ? '开' : '关'}`);
if (c.r3_window_seconds !== null) items.push(`R3窗口: ${c.r3_window_seconds}`);
if (c.r3_max_count !== null) items.push(`R3次数: ${c.r3_max_count}`);
if (c.r4_enabled !== null) items.push(`R4: ${c.r4_enabled ? '开' : '关'}`);
if (c.r4_window_seconds !== null) items.push(`R4窗口: ${c.r4_window_seconds}`);
if (c.r4_city_count !== null) items.push(`R4城市: ${c.r4_city_count}`);
if (c.r5_enabled !== null) items.push(`R5: ${c.r5_enabled ? '开' : '关'}`);
if (c.r5_days !== null) items.push(`R5天数: ${c.r5_days}`);
if (c.r5_country_count !== null) items.push(`R5国家: ${c.r5_country_count}`);
return items.length ? items.join(' / ') : '全部使用全局默认值';
})()}
</div>
)}
</div>
<h4 className={styles.membersTitle}> ({detailTeam.members.length})</h4>
{detailTeam.members.length === 0 ? (
<div className={styles.empty}></div>
@ -422,6 +650,11 @@ export function TeamsPage() {
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
{m.is_active ? '启用' : '禁用'}
</span>
{!m.is_active && m.disabled_by && (
<span style={{ fontSize: 10, color: 'var(--color-text-secondary)', marginLeft: 2 }}>
({m.disabled_by === 'system' ? '系统' : '管理员'})
</span>
)}
</td>
<td>{fmtSec(m.daily_seconds_limit)}</td>
<td>{fmtSec(m.seconds_today)}</td>
@ -437,6 +670,39 @@ export function TeamsPage() {
</div>
)}
{/* Auto-Learn Result Modal */}
{learnOpen && detailTeam && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setLearnOpen(false); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {detailTeam.name}</h3>
{learnedCities.length === 0 ? (
<p style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}> 30 </p>
) : (
<>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 13, marginBottom: 12 }}>
30 3
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 16 }}>
{learnedCities.map((city) => (
<span key={city} style={{
padding: '4px 10px', borderRadius: 12, fontSize: 12,
background: 'var(--color-bg-page)', border: '1px solid var(--color-border-card)',
color: 'var(--color-text-primary)',
}}>{city}</span>
))}
</div>
</>
)}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setLearnOpen(false)}></button>
{learnedCities.length > 0 && (
<button className={styles.saveBtn} onClick={handleApplyLearnedRegions}></button>
)}
</div>
</div>
</div>
)}
{/* Edit Pool Modal */}
{editPoolOpen && detailTeam && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditPoolOpen(false); }}>

View File

@ -236,6 +236,11 @@ export function UsersPage() {
<span className={`${styles.statusBadge} ${u.is_active ? styles.active : styles.disabled}`}>
{u.is_active ? '启用' : '禁用'}
</span>
{!u.is_active && u.disabled_by && (
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginLeft: 4 }}>
({u.disabled_by === 'system' ? '系统' : '管理员'})
</span>
)}
</td>
<td>{u.daily_seconds_limit === -1 ? '不限' : u.daily_seconds_limit.toLocaleString() + 's'}</td>
<td>{u.monthly_seconds_limit === -1 ? '不限' : u.monthly_seconds_limit.toLocaleString() + 's'}</td>
@ -399,6 +404,11 @@ export function UsersPage() {
<span className={`${styles.statusBadge} ${detailUser.is_active ? styles.active : styles.disabled}`}>
{detailUser.is_active ? '启用' : '禁用'}
</span>
{!detailUser.is_active && detailUser.disabled_by && (
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginLeft: 6 }}>
{detailUser.disabled_by === 'system' ? '系统自动禁用' : '管理员手动禁用'}
</span>
)}
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>

View File

@ -120,6 +120,7 @@ export interface AdminUser {
username: string;
email: string;
is_active: boolean;
disabled_by: string;
is_staff: boolean;
is_team_admin: boolean;
team_id: number | null;
@ -158,6 +159,23 @@ export interface SystemSettings {
announcement_enabled: boolean;
max_desktop_sessions: number;
max_mobile_sessions: number;
// Anomaly detection
anomaly_detection_enabled: boolean;
r1_enabled_default: boolean;
r2_enabled_default: boolean;
r2_window_seconds: number;
r3_enabled_default: boolean;
r3_window_seconds: number;
r3_max_count: number;
r4_enabled_default: boolean;
r4_window_seconds: number;
r4_city_count: number;
r5_enabled_default: boolean;
r5_days: number;
r5_country_count: number;
feishu_alert_mobiles: string;
sms_alert_mobiles: string;
alert_cooldown_seconds: number;
}
export interface ProfileOverview {
@ -196,10 +214,28 @@ export interface Team {
daily_member_limit_default: number;
member_count: number;
is_active: boolean;
expected_regions: string;
disabled_by: string;
created_at: string;
}
export interface TeamAnomalyConfig {
r1_enabled: boolean | null;
r2_enabled: boolean | null;
r2_window_seconds: number | null;
r3_enabled: boolean | null;
r3_window_seconds: number | null;
r3_max_count: number | null;
r4_enabled: boolean | null;
r4_window_seconds: number | null;
r4_city_count: number | null;
r5_enabled: boolean | null;
r5_days: number | null;
r5_country_count: number | null;
}
export interface TeamDetail extends Team {
anomaly_config: TeamAnomalyConfig | null;
members: TeamMember[];
}
@ -209,6 +245,7 @@ export interface TeamMember {
email: string;
is_team_admin: boolean;
is_active: boolean;
disabled_by: string;
daily_seconds_limit: number;
monthly_seconds_limit: number;
seconds_today: number;
@ -216,6 +253,25 @@ export interface TeamMember {
date_joined: string;
}
export interface LoginAnomaly {
id: number;
team_id: number;
team_name: string;
user_id: number;
username: string;
level: 'warning' | 'critical';
rule: string;
detail: Record<string, unknown>;
alerted: boolean;
auto_disabled: boolean;
disabled_target: string;
ip_address: string;
geo_country: string;
geo_province: string;
geo_city: string;
created_at: string;
}
export interface TeamStats {
daily_trend: { date: string; seconds: number }[];
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];