From be656900c06e3db4215730abb37a65e8ce1c73c5 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Thu, 19 Mar 2026 00:02:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.9.7=20=E7=99=BB=E5=BD=95=E9=A3=8E?= =?UTF-8?q?=E6=8E=A7=E7=AC=AC=E4=BA=8C=E6=9C=9F=20=E2=80=94=20IP=E5=BD=92?= =?UTF-8?q?=E5=B1=9E=E5=9C=B0=E8=A7=A3=E6=9E=90=20+=20=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E6=A3=80=E6=B5=8B(R1-R5)=20+=20=E9=A3=9E=E4=B9=A6=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=20+=20=E8=87=AA=E5=8A=A8=E5=B0=81=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IP138 在线 API + ip2region 离线库双通道归属地解析,60 秒熔断降级 - 5 条异常检测规则:地区不对/不可能旅行/频繁登录/团队遍地开花/海外IP太杂 - 飞书 interactive 卡片告警(红色严重/橙色警告),含辅助指标 - R2 自动封禁用户、R4 自动封禁团队,封禁即踢下线 - 系统设置页全局配置 + 团队详情页独立阈值覆盖 - 安全日志页面 + 管理员修改密码入口 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 41 ++- backend/apps/accounts/authentication.py | 20 ++ .../0008_anomaly_detection_phase2.py | 99 ++++++ backend/apps/accounts/models.py | 70 +++- backend/apps/accounts/views.py | 51 ++- .../0006_anomaly_detection_phase2.py | 93 ++++++ backend/apps/generation/models.py | 19 +- backend/apps/generation/serializers.py | 34 ++ backend/apps/generation/urls.py | 6 + backend/apps/generation/views.py | 287 +++++++++++++--- backend/config/settings.py | 6 + backend/requirements.txt | 1 + backend/utils/alert_service.py | 267 +++++++++++++++ backend/utils/anomaly_detector.py | 311 ++++++++++++++++++ backend/utils/geo_client.py | 135 ++++++++ docs/changelog.md | 85 +++++ web/src/App.tsx | 2 + web/src/lib/api.ts | 51 ++- web/src/pages/AdminLayout.tsx | 58 +++- web/src/pages/AnomalyLogPage.tsx | 224 +++++++++++++ web/src/pages/SettingsPage.tsx | 192 +++++++++++ web/src/pages/TeamsPage.module.css | 1 + web/src/pages/TeamsPage.tsx | 268 ++++++++++++++- web/src/pages/UsersPage.tsx | 10 + web/src/types/index.ts | 56 ++++ 25 files changed, 2329 insertions(+), 58 deletions(-) create mode 100644 backend/apps/accounts/migrations/0008_anomaly_detection_phase2.py create mode 100644 backend/apps/generation/migrations/0006_anomaly_detection_phase2.py create mode 100644 backend/utils/alert_service.py create mode 100644 backend/utils/anomaly_detector.py create mode 100644 backend/utils/geo_client.py create mode 100644 web/src/pages/AnomalyLogPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index ccc5566..8330835 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//topup` | Add seconds to team pool | | PUT | `/api/v1/admin/teams//set-pool` | Directly set team total seconds pool | | POST | `/api/v1/admin/teams//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//auto-learn` | Auto-learn expected regions from login history | +| POST | `/api/v1/admin/teams//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//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) diff --git a/backend/apps/accounts/authentication.py b/backend/apps/accounts/authentication.py index 68c83c3..8c0d99a 100644 --- a/backend/apps/accounts/authentication.py +++ b/backend/apps/accounts/authentication.py @@ -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 diff --git a/backend/apps/accounts/migrations/0008_anomaly_detection_phase2.py b/backend/apps/accounts/migrations/0008_anomaly_detection_phase2.py new file mode 100644 index 0000000..cbfeae1 --- /dev/null +++ b/backend/apps/accounts/migrations/0008_anomaly_detection_phase2.py @@ -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'], + }, + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index cf37366..dfc2259 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -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): diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index da8f795..a3c9b47 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -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) diff --git a/backend/apps/generation/migrations/0006_anomaly_detection_phase2.py b/backend/apps/generation/migrations/0006_anomaly_detection_phase2.py new file mode 100644 index 0000000..3ede64b --- /dev/null +++ b/backend/apps/generation/migrations/0006_anomaly_detection_phase2.py @@ -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='短信告警手机号(预留)'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index 1d68bac..40f21c8 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -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: diff --git a/backend/apps/generation/serializers.py b/backend/apps/generation/serializers.py index 181559c..b6cc30f 100644 --- a/backend/apps/generation/serializers.py +++ b/backend/apps/generation/serializers.py @@ -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): diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index bba864b..ff99666 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -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//auto-learn', views.admin_team_auto_learn_view, name='admin_team_auto_learn'), + path('admin/teams//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//members', views.admin_assets_team_members, name='admin_assets_team_members'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 5b9a85f..5c07c99 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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//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//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, }) diff --git a/backend/config/settings.py b/backend/config/settings.py index 1d1ce52..01cc993 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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) # ────────────────────────────────────────────── diff --git a/backend/requirements.txt b/backend/requirements.txt index 075f137..3580889 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/utils/alert_service.py b/backend/utils/alert_service.py new file mode 100644 index 0000000..27750cc --- /dev/null +++ b/backend/utils/alert_service.py @@ -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) diff --git a/backend/utils/anomaly_detector.py b/backend/utils/anomaly_detector.py new file mode 100644 index 0000000..25b01ae --- /dev/null +++ b/backend/utils/anomaly_detector.py @@ -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) diff --git a/backend/utils/geo_client.py b/backend/utils/geo_client.py new file mode 100644 index 0000000..aa0d7c3 --- /dev/null +++ b/backend/utils/geo_client.py @@ -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' diff --git a/docs/changelog.md b/docs/changelog.md index 5006a49..1d853ab 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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//auto-learn` | 自动学习预期登录地区 | +| POST | `/api/v1/admin/teams//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: 首页 + 播放器修复 **状态**: ✅ 已完成 | **验收**: ✅ 通过(本地验证) diff --git a/web/src/App.tsx b/web/src/App.tsx index b3d2977..0df3aee 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 694fe4e..201a816 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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(`/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 }) => 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 & { 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; diff --git a/web/src/pages/AdminLayout.tsx b/web/src/pages/AdminLayout.tsx index 525c3c0..8268eaa 100644 --- a/web/src/pages/AdminLayout.tsx +++ b/web/src/pages/AdminLayout.tsx @@ -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 (
@@ -84,6 +111,33 @@ export function AdminLayout() {
+ + {pwModalOpen && ( +
setPwModalOpen(false)}> +
e.stopPropagation()}> +

修改密码

+
+ 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' }} /> + 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' }} /> + 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' }} /> +
+ + +
+
+
+
+ )} ); } diff --git a/web/src/pages/AnomalyLogPage.tsx b/web/src/pages/AnomalyLogPage.tsx new file mode 100644 index 0000000..fedda02 --- /dev/null +++ b/web/src/pages/AnomalyLogPage.tsx @@ -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 = { + region_mismatch: 'R1 登录地区不对', + impossible_travel: 'R2 不可能的旅行', + login_frequency: 'R3 登录太频繁', + multi_city: 'R4 团队遍地开花', + overseas_ip_diversity: 'R5 海外IP太杂', +}; + +export function AnomalyLogPage() { + const [anomalies, setAnomalies] = useState([]); + 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([]); + 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 ( +
+

安全日志

+ +
+ { setLevelFilter(v); setPage(1); }} + placeholder="全部级别" + options={LEVEL_OPTIONS} + /> + setSettings({ ...settings, anomaly_detection_enabled: e.target.checked })} + /> + + +
+ + {settings.anomaly_detection_enabled && ( + <> +

规则默认阈值

+ +
+
+ + R1 — 登录地区不对(警告) + 单账号从非预期城市登录时告警 +
+
+ +
+
+ + R2 — 不可能的旅行(严重,自动封禁用户) + 单账号短时间内从两个不同城市登录,自动封禁该用户 +
+
+ + setSettings({ ...settings, r2_window_seconds: Number(e.target.value) })} /> +
+
+ +
+
+ + R3 — 登录太频繁(警告) + 单账号短时间内登录次数过多时告警 +
+
+
+ + setSettings({ ...settings, r3_window_seconds: Number(e.target.value) })} /> +
+
+ + setSettings({ ...settings, r3_max_count: Number(e.target.value) })} /> +
+
+
+ +
+
+ + R4 — 团队遍地开花(严重,自动封禁团队) + 整个团队短时间内出现大量异地登录,自动封禁整个团队 +
+
+
+ + setSettings({ ...settings, r4_window_seconds: Number(e.target.value) })} /> +
+
+ + setSettings({ ...settings, r4_city_count: Number(e.target.value) })} /> +
+
+
+ +
+
+ + R5 — 海外IP太杂(警告) + 整个团队短期内出现大量不同国家的登录时告警 +
+
+
+ + setSettings({ ...settings, r5_days: Number(e.target.value) })} /> +
+
+ + setSettings({ ...settings, r5_country_count: Number(e.target.value) })} /> +
+
+
+ +

告警配置

+ +
+ +
+ setSettings({ ...settings, feishu_alert_mobiles: e.target.value })} + placeholder="13800138000,13900139000" + style={{ flex: 1 }} + /> + +
+
+ +
+ + setSettings({ ...settings, sms_alert_mobiles: e.target.value })} + placeholder="暂未开放" + disabled + style={{ opacity: 0.5 }} + /> +
+ +
+ + setSettings({ ...settings, alert_cooldown_seconds: Number(e.target.value) })} + /> +

同一团队 + 同一规则在此时间内不重复告警

+
+ + )} + + +
); } diff --git a/web/src/pages/TeamsPage.module.css b/web/src/pages/TeamsPage.module.css index 5a8cb76..b080181 100644 --- a/web/src/pages/TeamsPage.module.css +++ b/web/src/pages/TeamsPage.module.css @@ -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); diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx index c30947f..49696fb 100644 --- a/web/src/pages/TeamsPage.tsx +++ b/web/src/pages/TeamsPage.tsx @@ -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([]); + const [learnOpen, setLearnOpen] = useState(false); + const [editingRegions, setEditingRegions] = useState(false); + const [editRegionsValue, setEditRegionsValue] = useState(''); + const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false); + const [anomalyConfigDraft, setAnomalyConfigDraft] = useState>({}); + 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() { {t.is_active ? '启用' : '禁用'} + {!t.is_active && t.disabled_by && ( + + ({t.disabled_by === 'system' ? '系统' : '管理员'}) + + )}
@@ -259,6 +305,10 @@ export function TeamsPage() { setNewDailyMemberLimit(e.target.value)} />
+
+ + setNewExpectedRegions(e.target.value)} placeholder="广州市,深圳市,北京市" /> +
{createError &&
{createError}
}
@@ -336,6 +386,11 @@ export function TeamsPage() { {detailTeam.is_active ? '启用' : '禁用'} + {!detailTeam.is_active && detailTeam.disabled_by && ( + + ({detailTeam.disabled_by === 'system' ? '系统自动禁用' : '管理员手动禁用'}) + + )}
+
+
+ 预期登录城市 + {!editingRegions && ( + + )} + +
+ {editingRegions ? ( +
+ 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 }} + /> + + +
+ ) : ( + + {detailTeam.expected_regions || '(未设置)'} + + )} +
+ + {/* 团队级异常检测阈值 */} +
+
+ 异常检测阈值(留空 = 用全局默认) + {!editingAnomalyConfig ? ( + + ) : ( +
+ + +
+ )} +
+ {editingAnomalyConfig ? ( +
+ {[ + { 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) => ( +
+ {gi > 0 &&
} +
+ {group.title} + {group.desc} +
+
+ {group.items.map((item) => ( +
+ {item.label} +
+ {item.type === 'bool' ? ( +
+ {([['', '默认'], ['true', '开'], ['false', '关']] as const).map(([val, label]) => { + const cur = anomalyConfigDraft[item.key]; + const selected = val === '' ? (cur === null || cur === undefined) : String(cur) === val; + return ( + + ); + })} +
+ ) : ( + 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' }} + /> + )} +
+
+ ))} +
+
+ ))} +
+ ) : ( +
+ {(() => { + 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(' / ') : '全部使用全局默认值'; + })()} +
+ )} +
+

成员列表 ({detailTeam.members.length})

{detailTeam.members.length === 0 ? (
暂无成员
@@ -422,6 +650,11 @@ export function TeamsPage() { {m.is_active ? '启用' : '禁用'} + {!m.is_active && m.disabled_by && ( + + ({m.disabled_by === 'system' ? '系统' : '管理员'}) + + )} {fmtSec(m.daily_seconds_limit)} {fmtSec(m.seconds_today)} @@ -437,6 +670,39 @@ export function TeamsPage() {
)} + {/* Auto-Learn Result Modal */} + {learnOpen && detailTeam && ( +
{ if (e.target === e.currentTarget) setLearnOpen(false); }}> +
+

自动学习结果 — {detailTeam.name}

+ {learnedCities.length === 0 ? ( +

近 30 天无足够登录数据

+ ) : ( + <> +

+ 以下城市在近 30 天内登录 3 次以上,确认后将覆盖当前预期地区设置: +

+
+ {learnedCities.map((city) => ( + {city} + ))} +
+ + )} +
+ + {learnedCities.length > 0 && ( + + )} +
+
+
+ )} + {/* Edit Pool Modal */} {editPoolOpen && detailTeam && (
{ if (e.target === e.currentTarget) setEditPoolOpen(false); }}> diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx index 7d47bb2..a1556f9 100644 --- a/web/src/pages/UsersPage.tsx +++ b/web/src/pages/UsersPage.tsx @@ -236,6 +236,11 @@ export function UsersPage() { {u.is_active ? '启用' : '禁用'} + {!u.is_active && u.disabled_by && ( + + ({u.disabled_by === 'system' ? '系统' : '管理员'}) + + )} {u.daily_seconds_limit === -1 ? '不限' : u.daily_seconds_limit.toLocaleString() + 's'} {u.monthly_seconds_limit === -1 ? '不限' : u.monthly_seconds_limit.toLocaleString() + 's'} @@ -399,6 +404,11 @@ export function UsersPage() { {detailUser.is_active ? '启用' : '禁用'} + {!detailUser.is_active && detailUser.disabled_by && ( + + {detailUser.disabled_by === 'system' ? '系统自动禁用' : '管理员手动禁用'} + + )}
注册时间 diff --git a/web/src/types/index.ts b/web/src/types/index.ts index db6e0d2..1e5c08f 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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; + 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 }[];