feat: v0.9.7 登录风控第二期 — IP归属地解析 + 异常检测(R1-R5) + 飞书告警 + 自动封禁
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
- IP138 在线 API + ip2region 离线库双通道归属地解析,60 秒熔断降级 - 5 条异常检测规则:地区不对/不可能旅行/频繁登录/团队遍地开花/海外IP太杂 - 飞书 interactive 卡片告警(红色严重/橙色警告),含辅助指标 - R2 自动封禁用户、R4 自动封禁团队,封禁即踢下线 - 系统设置页全局配置 + 团队详情页独立阈值覆盖 - 安全日志页面 + 管理员修改密码入口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9809c31143
commit
be656900c0
41
CLAUDE.md
41
CLAUDE.md
@ -52,6 +52,8 @@ jimeng-clone/
|
|||||||
│ ├── apps/
|
│ ├── apps/
|
||||||
│ │ ├── accounts/ # User auth: models, views, serializers, urls
|
│ │ ├── accounts/ # User auth: models, views, serializers, urls
|
||||||
│ │ └── generation/ # Video generation: 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
|
│ ├── requirements.txt # Python dependencies
|
||||||
│ └── Dockerfile # Python 3.12 + gunicorn
|
│ └── Dockerfile # Python 3.12 + gunicorn
|
||||||
├── web/ # React 18 + Vite frontend
|
├── web/ # React 18 + Vite frontend
|
||||||
@ -152,6 +154,10 @@ jimeng-clone/
|
|||||||
| POST | `/api/v1/admin/teams/<id>/topup` | Add seconds to team pool |
|
| POST | `/api/v1/admin/teams/<id>/topup` | Add seconds to team pool |
|
||||||
| PUT | `/api/v1/admin/teams/<id>/set-pool` | Directly set team total seconds pool |
|
| PUT | `/api/v1/admin/teams/<id>/set-pool` | Directly set team total seconds pool |
|
||||||
| POST | `/api/v1/admin/teams/<id>/admin` | Create team admin user |
|
| POST | `/api/v1/admin/teams/<id>/admin` | Create team admin user |
|
||||||
|
| GET | `/api/v1/admin/anomalies` | Login anomaly records (filter by team/rule/level/date) |
|
||||||
|
| POST | `/api/v1/admin/test-feishu` | Send test Feishu alert message |
|
||||||
|
| POST | `/api/v1/admin/teams/<id>/auto-learn` | Auto-learn expected regions from login history |
|
||||||
|
| POST | `/api/v1/admin/teams/<id>/apply-learned-regions` | Apply auto-learned regions to team |
|
||||||
| GET | `/api/v1/admin/logs` | Audit logs (filter by action/operator/date) |
|
| GET | `/api/v1/admin/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/overview` | Content assets: global stats + per-team summary |
|
||||||
| GET | `/api/v1/admin/assets/team/<id>/members` | Content assets: team members with video stats |
|
| GET | `/api/v1/admin/assets/team/<id>/members` | Content assets: team members with video stats |
|
||||||
@ -174,9 +180,14 @@ jimeng-clone/
|
|||||||
### User (extends AbstractUser)
|
### User (extends AbstractUser)
|
||||||
- `email` (unique), `daily_seconds_limit` (default: 600), `monthly_seconds_limit` (default: 6000)
|
- `email` (unique), `daily_seconds_limit` (default: 600), `monthly_seconds_limit` (default: 6000)
|
||||||
- `must_change_password` (default: True) — forces password change on first login
|
- `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`
|
- `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
|
### GenerationRecord
|
||||||
- `user` (FK), `task_id` (UUID), `ark_task_id`, `prompt`, `mode` (universal|keyframe)
|
- `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`
|
- `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
|
- Used for concurrent session limiting via JWT session_id claim
|
||||||
|
|
||||||
### LoginRecord
|
### LoginRecord
|
||||||
- `user` (FK), `ip_address`, `user_agent`, `created_at` (indexed)
|
- `user` (FK), `team` (FK, redundant for efficient R4/R5 queries), `ip_address`, `user_agent`
|
||||||
- Records every login for future anomaly detection
|
- `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)
|
### QuotaConfig (Singleton, pk=1)
|
||||||
- `default_daily_seconds_limit`, `default_monthly_seconds_limit`
|
- `default_daily_seconds_limit`, `default_monthly_seconds_limit`
|
||||||
- `announcement`, `announcement_enabled`
|
- `announcement`, `announcement_enabled`
|
||||||
- `max_desktop_sessions` (default: 1), `max_mobile_sessions` (default: 0)
|
- `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`
|
- `updated_at`
|
||||||
|
|
||||||
## Frontend Routes
|
## Frontend Routes
|
||||||
@ -213,7 +238,8 @@ jimeng-clone/
|
|||||||
| `/admin/dashboard` | DashboardPage | Admin | Stats & charts |
|
| `/admin/dashboard` | DashboardPage | Admin | Stats & charts |
|
||||||
| `/admin/users` | UsersPage | Admin | User management |
|
| `/admin/users` | UsersPage | Admin | User management |
|
||||||
| `/admin/records` | RecordsPage | Admin | Generation records |
|
| `/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/logs` | AuditLogsPage | Admin | Admin operation audit logs |
|
||||||
| `/admin/assets` | AdminAssetsPage | Admin | Content assets (team→member→video hierarchy) |
|
| `/admin/assets` | AdminAssetsPage | Admin | Content assets (team→member→video hierarchy) |
|
||||||
| `/team/assets` | TeamAssetsPage | TeamAdmin | Team content assets (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) |
|
| `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_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 |
|
| `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
|
## 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: 登录记录 — LoginRecord 模型(IP + User-Agent)为异常检测打基础 | Backend |
|
||||||
| 2026-03-18 | v0.9.0: Token 生命周期缩短 — access 30min, refresh 1天 | 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.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)
|
### Phase 4 Details (2026-03-13)
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,26 @@ class SessionJWTAuthentication(JWTAuthentication):
|
|||||||
def get_user(self, validated_token):
|
def get_user(self, validated_token):
|
||||||
user = super().get_user(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')
|
session_id = validated_token.get('session_id')
|
||||||
if session_id is None:
|
if session_id is None:
|
||||||
# Legacy token without session_id — allow through
|
# Legacy token without session_id — allow through
|
||||||
|
|||||||
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -12,6 +12,8 @@ class Team(models.Model):
|
|||||||
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月消费上限(秒)')
|
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月消费上限(秒)')
|
||||||
daily_member_limit_default = models.IntegerField(default=600, verbose_name='新成员默认每日限额(秒)')
|
daily_member_limit_default = models.IntegerField(default=600, verbose_name='新成员默认每日限额(秒)')
|
||||||
is_active = models.BooleanField(default=True, 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='创建时间')
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||||
updated_at = models.DateTimeField(auto_now=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='每日秒数上限')
|
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
|
||||||
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
|
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
|
||||||
must_change_password = models.BooleanField(default=True, 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='创建时间')
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||||
|
|
||||||
@ -125,10 +128,15 @@ class ActiveSession(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class LoginRecord(models.Model):
|
class LoginRecord(models.Model):
|
||||||
"""登录记录 — 为团队级异常检测打基础。"""
|
"""登录记录 — 含 IP 归属地,供异常检测使用。"""
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='login_records', verbose_name='用户')
|
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地址')
|
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name='IP地址')
|
||||||
user_agent = models.TextField(blank=True, default='', verbose_name='User-Agent')
|
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='登录时间')
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='登录时间')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -137,7 +145,65 @@ class LoginRecord(models.Model):
|
|||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def __str__(self):
|
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):
|
def get_client_ip(request):
|
||||||
|
|||||||
@ -84,10 +84,59 @@ def login_view(request):
|
|||||||
status=status.HTTP_401_UNAUTHORIZED
|
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
|
# Record login IP and User-Agent
|
||||||
ip = get_client_ip(request)
|
ip = get_client_ip(request)
|
||||||
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
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
|
# Concurrent session management
|
||||||
device_type = parse_device_type(user_agent)
|
device_type = parse_device_type(user_agent)
|
||||||
|
|||||||
@ -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='短信告警手机号(预留)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -53,13 +53,30 @@ class GenerationRecord(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class QuotaConfig(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_daily_seconds_limit = models.IntegerField(default=600, verbose_name='默认每日秒数上限')
|
||||||
default_monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='默认每月秒数上限')
|
default_monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='默认每月秒数上限')
|
||||||
announcement = models.TextField(blank=True, default='', verbose_name='系统公告')
|
announcement = models.TextField(blank=True, default='', verbose_name='系统公告')
|
||||||
announcement_enabled = models.BooleanField(default=False, verbose_name='启用公告')
|
announcement_enabled = models.BooleanField(default=False, verbose_name='启用公告')
|
||||||
max_desktop_sessions = models.IntegerField(default=1, verbose_name='每用户最大桌面端会话数')
|
max_desktop_sessions = models.IntegerField(default=1, verbose_name='每用户最大桌面端会话数')
|
||||||
max_mobile_sessions = models.IntegerField(default=0, 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)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@ -35,6 +35,23 @@ class SystemSettingsSerializer(serializers.Serializer):
|
|||||||
announcement_enabled = serializers.BooleanField(required=False, default=False)
|
announcement_enabled = serializers.BooleanField(required=False, default=False)
|
||||||
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)
|
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)
|
||||||
max_mobile_sessions = serializers.IntegerField(min_value=0, required=False, default=0)
|
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 ──
|
# ── Team serializers ──
|
||||||
@ -43,6 +60,7 @@ class TeamCreateSerializer(serializers.Serializer):
|
|||||||
name = serializers.CharField(max_length=100)
|
name = serializers.CharField(max_length=100)
|
||||||
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False, default=6000)
|
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)
|
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):
|
class TeamUpdateSerializer(serializers.Serializer):
|
||||||
@ -50,6 +68,22 @@ class TeamUpdateSerializer(serializers.Serializer):
|
|||||||
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False)
|
monthly_seconds_limit = serializers.IntegerField(min_value=0, required=False)
|
||||||
daily_member_limit_default = serializers.IntegerField(min_value=0, required=False)
|
daily_member_limit_default = serializers.IntegerField(min_value=0, required=False)
|
||||||
is_active = serializers.BooleanField(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):
|
class TeamTopUpSerializer(serializers.Serializer):
|
||||||
|
|||||||
@ -35,6 +35,12 @@ urlpatterns = [
|
|||||||
path('admin/settings', views.admin_settings_view, name='admin_settings'),
|
path('admin/settings', views.admin_settings_view, name='admin_settings'),
|
||||||
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'),
|
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'),
|
||||||
|
|
||||||
|
# ── Super Admin: Anomaly Detection ──
|
||||||
|
path('admin/anomalies', views.admin_login_anomalies_view, name='admin_login_anomalies'),
|
||||||
|
path('admin/test-feishu', views.admin_test_feishu_view, name='admin_test_feishu'),
|
||||||
|
path('admin/teams/<int:team_id>/auto-learn', views.admin_team_auto_learn_view, name='admin_team_auto_learn'),
|
||||||
|
path('admin/teams/<int:team_id>/apply-learned-regions', views.admin_team_apply_learned_regions_view, name='admin_team_apply_learned_regions'),
|
||||||
|
|
||||||
# ── Super Admin: Content Assets ──
|
# ── Super Admin: Content Assets ──
|
||||||
path('admin/assets/overview', views.admin_assets_overview, name='admin_assets_overview'),
|
path('admin/assets/overview', views.admin_assets_overview, name='admin_assets_overview'),
|
||||||
path('admin/assets/team/<int:team_id>/members', views.admin_assets_team_members, name='admin_assets_team_members'),
|
path('admin/assets/team/<int:team_id>/members', views.admin_assets_team_members, name='admin_assets_team_members'),
|
||||||
|
|||||||
@ -20,8 +20,9 @@ from .serializers import (
|
|||||||
AdminCreateUserSerializer,
|
AdminCreateUserSerializer,
|
||||||
TeamCreateSerializer, TeamUpdateSerializer, TeamTopUpSerializer,
|
TeamCreateSerializer, TeamUpdateSerializer, TeamTopUpSerializer,
|
||||||
TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer,
|
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 apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
|
||||||
from utils.tos_client import upload_file as tos_upload
|
from utils.tos_client import upload_file as tos_upload
|
||||||
from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
|
from utils.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,
|
'daily_member_limit_default': t.daily_member_limit_default,
|
||||||
'member_count': t.members.count(),
|
'member_count': t.members.count(),
|
||||||
'is_active': t.is_active,
|
'is_active': t.is_active,
|
||||||
|
'expected_regions': t.expected_regions,
|
||||||
|
'disabled_by': t.disabled_by,
|
||||||
'created_at': t.created_at.isoformat(),
|
'created_at': t.created_at.isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -569,12 +572,14 @@ def admin_team_create_view(request):
|
|||||||
team = Team.objects.create(**serializer.validated_data)
|
team = Team.objects.create(**serializer.validated_data)
|
||||||
log_admin_action(request, 'team_create', 'team', target_id=team.id, target_name=team.name,
|
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,
|
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({
|
return Response({
|
||||||
'id': team.id,
|
'id': team.id,
|
||||||
'name': team.name,
|
'name': team.name,
|
||||||
'monthly_seconds_limit': team.monthly_seconds_limit,
|
'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,
|
||||||
'created_at': team.created_at.isoformat(),
|
'created_at': team.created_at.isoformat(),
|
||||||
}, status=status.HTTP_201_CREATED)
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
@ -591,11 +596,34 @@ def admin_team_detail_view(request, team_id):
|
|||||||
if request.method == 'PUT':
|
if request.method == 'PUT':
|
||||||
serializer = TeamUpdateSerializer(data=request.data)
|
serializer = TeamUpdateSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
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 = {f: getattr(team, f) for f in serializer.validated_data}
|
||||||
|
before['disabled_by'] = team.disabled_by
|
||||||
for field, value in serializer.validated_data.items():
|
for field, value in serializer.validated_data.items():
|
||||||
setattr(team, field, value)
|
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()
|
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 = {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,
|
log_admin_action(request, 'team_update', 'team', target_id=team.id, target_name=team.name,
|
||||||
before=before, after=after)
|
before=before, after=after)
|
||||||
return Response({
|
return Response({
|
||||||
@ -604,6 +632,8 @@ def admin_team_detail_view(request, team_id):
|
|||||||
'monthly_seconds_limit': team.monthly_seconds_limit,
|
'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,
|
||||||
'is_active': team.is_active,
|
'is_active': team.is_active,
|
||||||
|
'expected_regions': team.expected_regions,
|
||||||
|
'disabled_by': team.disabled_by,
|
||||||
'updated_at': team.updated_at.isoformat(),
|
'updated_at': team.updated_at.isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -627,6 +657,26 @@ def admin_team_detail_view(request, team_id):
|
|||||||
),
|
),
|
||||||
).order_by('-date_joined')
|
).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({
|
return Response({
|
||||||
'id': team.id,
|
'id': team.id,
|
||||||
'name': team.name,
|
'name': team.name,
|
||||||
@ -638,6 +688,9 @@ def admin_team_detail_view(request, team_id):
|
|||||||
'daily_member_limit_default': team.daily_member_limit_default,
|
'daily_member_limit_default': team.daily_member_limit_default,
|
||||||
'member_count': team.members.count(),
|
'member_count': team.members.count(),
|
||||||
'is_active': team.is_active,
|
'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(),
|
'created_at': team.created_at.isoformat(),
|
||||||
'members': [{
|
'members': [{
|
||||||
'id': m.id,
|
'id': m.id,
|
||||||
@ -645,6 +698,7 @@ def admin_team_detail_view(request, team_id):
|
|||||||
'email': m.email,
|
'email': m.email,
|
||||||
'is_team_admin': m.is_team_admin,
|
'is_team_admin': m.is_team_admin,
|
||||||
'is_active': m.is_active,
|
'is_active': m.is_active,
|
||||||
|
'disabled_by': m.disabled_by,
|
||||||
'daily_seconds_limit': m.daily_seconds_limit,
|
'daily_seconds_limit': m.daily_seconds_limit,
|
||||||
'monthly_seconds_limit': m.monthly_seconds_limit,
|
'monthly_seconds_limit': m.monthly_seconds_limit,
|
||||||
'seconds_today': m.seconds_today or 0,
|
'seconds_today': m.seconds_today or 0,
|
||||||
@ -819,6 +873,7 @@ def admin_users_list_view(request):
|
|||||||
'username': u.username,
|
'username': u.username,
|
||||||
'email': u.email,
|
'email': u.email,
|
||||||
'is_active': u.is_active,
|
'is_active': u.is_active,
|
||||||
|
'disabled_by': u.disabled_by,
|
||||||
'is_staff': u.is_staff,
|
'is_staff': u.is_staff,
|
||||||
'is_team_admin': u.is_team_admin,
|
'is_team_admin': u.is_team_admin,
|
||||||
'team_id': u.team_id,
|
'team_id': u.team_id,
|
||||||
@ -937,10 +992,16 @@ def admin_user_status_view(request, user_id):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
old_active = user.is_active
|
old_active = user.is_active
|
||||||
|
old_disabled_by = user.disabled_by
|
||||||
user.is_active = serializer.validated_data['is_active']
|
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,
|
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({
|
return Response({
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
@ -1068,6 +1129,34 @@ def admin_records_view(request):
|
|||||||
# Admin: System Settings
|
# 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'])
|
@api_view(['GET', 'PUT'])
|
||||||
@permission_classes([IsSuperAdmin])
|
@permission_classes([IsSuperAdmin])
|
||||||
def admin_settings_view(request):
|
def admin_settings_view(request):
|
||||||
@ -1075,52 +1164,166 @@ def admin_settings_view(request):
|
|||||||
config, _ = QuotaConfig.objects.get_or_create(pk=1)
|
config, _ = QuotaConfig.objects.get_or_create(pk=1)
|
||||||
|
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
return Response({
|
return Response(_settings_dict(config))
|
||||||
'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,
|
|
||||||
})
|
|
||||||
|
|
||||||
serializer = SystemSettingsSerializer(data=request.data)
|
serializer = SystemSettingsSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
before = {
|
before = _settings_dict(config)
|
||||||
'default_daily_seconds_limit': config.default_daily_seconds_limit,
|
for field in serializer.validated_data:
|
||||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
setattr(config, field, serializer.validated_data[field])
|
||||||
'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)
|
|
||||||
config.save()
|
config.save()
|
||||||
log_admin_action(request, 'settings_update', 'settings', target_name='系统设置',
|
log_admin_action(request, 'settings_update', 'settings', target_name='系统设置',
|
||||||
before=before,
|
before=before, after=_settings_dict(config))
|
||||||
after={
|
|
||||||
'default_daily_seconds_limit': config.default_daily_seconds_limit,
|
result = _settings_dict(config)
|
||||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
result['updated_at'] = config.updated_at.isoformat()
|
||||||
'announcement': config.announcement,
|
return Response(result)
|
||||||
'announcement_enabled': config.announcement_enabled,
|
|
||||||
'max_desktop_sessions': config.max_desktop_sessions,
|
|
||||||
'max_mobile_sessions': config.max_mobile_sessions,
|
# ──────────────────────────────────────────────
|
||||||
|
# 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({
|
return Response({
|
||||||
'default_daily_seconds_limit': config.default_daily_seconds_limit,
|
'total': total,
|
||||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
'page': page,
|
||||||
'announcement': config.announcement,
|
'page_size': page_size,
|
||||||
'announcement_enabled': config.announcement_enabled,
|
'total_pages': (total + page_size - 1) // page_size,
|
||||||
'max_desktop_sessions': config.max_desktop_sessions,
|
'results': results,
|
||||||
'max_mobile_sessions': config.max_mobile_sessions,
|
})
|
||||||
'updated_at': config.updated_at.isoformat(),
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsSuperAdmin])
|
||||||
|
def admin_test_feishu_view(request):
|
||||||
|
"""POST /api/v1/admin/test-feishu — Send a test Feishu alert."""
|
||||||
|
mobile = request.data.get('mobile', '').strip()
|
||||||
|
if not mobile:
|
||||||
|
return Response({'error': '请输入手机号'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
from utils.alert_service import send_feishu_test
|
||||||
|
success, message = send_feishu_test(mobile)
|
||||||
|
if success:
|
||||||
|
return Response({'message': message})
|
||||||
|
return Response({'error': message}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsSuperAdmin])
|
||||||
|
def admin_team_auto_learn_view(request, team_id):
|
||||||
|
"""POST /api/v1/admin/teams/<id>/auto-learn — Auto-learn expected regions from login history."""
|
||||||
|
try:
|
||||||
|
team = Team.objects.get(id=team_id)
|
||||||
|
except Team.DoesNotExist:
|
||||||
|
return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
from apps.accounts.models import LoginRecord
|
||||||
|
days = int(request.data.get('days', 30))
|
||||||
|
min_count = int(request.data.get('min_count', 3))
|
||||||
|
since = timezone.now() - timedelta(days=days)
|
||||||
|
|
||||||
|
# Aggregate domestic cities with at least min_count logins
|
||||||
|
from django.db.models import Count
|
||||||
|
city_stats = (
|
||||||
|
LoginRecord.objects.filter(
|
||||||
|
team=team,
|
||||||
|
created_at__gte=since,
|
||||||
|
geo_country='中国',
|
||||||
|
)
|
||||||
|
.exclude(geo_city='')
|
||||||
|
.values('geo_city')
|
||||||
|
.annotate(cnt=Count('id'))
|
||||||
|
.filter(cnt__gte=min_count)
|
||||||
|
.order_by('-cnt')
|
||||||
|
)
|
||||||
|
|
||||||
|
cities = [row['geo_city'] for row in city_stats]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'team_id': team.id,
|
||||||
|
'team_name': team.name,
|
||||||
|
'learned_cities': cities,
|
||||||
|
'days': days,
|
||||||
|
'min_count': min_count,
|
||||||
|
'current_expected_regions': team.expected_regions,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsSuperAdmin])
|
||||||
|
def admin_team_apply_learned_regions_view(request, team_id):
|
||||||
|
"""POST /api/v1/admin/teams/<id>/apply-learned-regions — Apply auto-learned regions."""
|
||||||
|
try:
|
||||||
|
team = Team.objects.get(id=team_id)
|
||||||
|
except Team.DoesNotExist:
|
||||||
|
return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
cities = request.data.get('cities', [])
|
||||||
|
if not isinstance(cities, list):
|
||||||
|
return Response({'error': 'cities 必须是数组'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
before = team.expected_regions
|
||||||
|
team.expected_regions = ','.join(cities)
|
||||||
|
team.save(update_fields=['expected_regions'])
|
||||||
|
log_admin_action(request, 'team_update', 'team', target_id=team.id, target_name=team.name,
|
||||||
|
before={'expected_regions': before},
|
||||||
|
after={'expected_regions': team.expected_regions})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'team_id': team.id,
|
||||||
|
'expected_regions': team.expected_regions,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -161,6 +161,12 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
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)
|
# Security headers (production)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
@ -6,3 +6,4 @@ mysqlclient>=2.2,<3.0
|
|||||||
gunicorn>=21.2,<23.0
|
gunicorn>=21.2,<23.0
|
||||||
tos>=2.7,<3.0
|
tos>=2.7,<3.0
|
||||||
requests>=2.31,<3.0
|
requests>=2.31,<3.0
|
||||||
|
ip-region>=1.0
|
||||||
|
|||||||
267
backend/utils/alert_service.py
Normal file
267
backend/utils/alert_service.py
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
"""告警服务 — 飞书 interactive 卡片私信 + 辅助指标。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 小毛球机器人
|
||||||
|
FEISHU_APP_ID = 'cli_a90478156bf85bd7'
|
||||||
|
FEISHU_APP_SECRET = '87N2nnx6Yv56TPjl2GraLdKOjFiGOSGp'
|
||||||
|
|
||||||
|
_RULE_NAMES = {
|
||||||
|
'region_mismatch': '登录地区不对 (R1)',
|
||||||
|
'impossible_travel': '不可能的旅行 (R2)',
|
||||||
|
'login_frequency': '登录太频繁 (R3)',
|
||||||
|
'multi_city': '团队遍地开花 (R4)',
|
||||||
|
'overseas_ip_diversity': '海外IP太杂 (R5)',
|
||||||
|
}
|
||||||
|
|
||||||
|
_LEVEL_COLORS = {
|
||||||
|
'warning': 'orange',
|
||||||
|
'critical': 'red',
|
||||||
|
}
|
||||||
|
|
||||||
|
_LEVEL_LABELS = {
|
||||||
|
'warning': '⚠️ 警告',
|
||||||
|
'critical': '🚨 严重',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tenant_access_token():
|
||||||
|
"""获取飞书 tenant_access_token。"""
|
||||||
|
import os
|
||||||
|
app_secret = os.environ.get('FEISHU_APP_SECRET', FEISHU_APP_SECRET)
|
||||||
|
if not app_secret:
|
||||||
|
raise RuntimeError('FEISHU_APP_SECRET not configured')
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
|
||||||
|
json={'app_id': FEISHU_APP_ID, 'app_secret': app_secret},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
if data.get('code') != 0:
|
||||||
|
raise RuntimeError(f'Feishu token error: {data}')
|
||||||
|
return data['tenant_access_token']
|
||||||
|
|
||||||
|
|
||||||
|
def _get_open_id_by_mobile(token, mobile):
|
||||||
|
"""通过手机号查询飞书 open_id。"""
|
||||||
|
resp = requests.post(
|
||||||
|
'https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id',
|
||||||
|
headers={'Authorization': f'Bearer {token}'},
|
||||||
|
json={'mobiles': [mobile]},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
if data.get('code') != 0:
|
||||||
|
raise RuntimeError(f'Feishu user lookup error: {data}')
|
||||||
|
user_list = data.get('data', {}).get('user_list', [])
|
||||||
|
if user_list and user_list[0].get('user_id'):
|
||||||
|
return user_list[0]['user_id']
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_auxiliary_metrics(team):
|
||||||
|
"""计算辅助指标:最近 7 天并发踢出次数 + 非工作时间登录占比。"""
|
||||||
|
from apps.accounts.models import LoginRecord
|
||||||
|
|
||||||
|
since = timezone.now() - timedelta(days=7)
|
||||||
|
|
||||||
|
# 并发踢出次数:ActiveSession 被删除的次数无法直接统计,
|
||||||
|
# 用 LoginAnomaly 中 rule=impossible_travel 的次数近似
|
||||||
|
from apps.accounts.models import LoginAnomaly
|
||||||
|
kick_count = LoginAnomaly.objects.filter(
|
||||||
|
team=team,
|
||||||
|
auto_disabled=True,
|
||||||
|
created_at__gte=since,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# 非工作时间登录占比 (22:00-08:00)
|
||||||
|
total_logins = LoginRecord.objects.filter(
|
||||||
|
team=team,
|
||||||
|
created_at__gte=since,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if total_logins > 0:
|
||||||
|
night_logins = 0
|
||||||
|
for record in LoginRecord.objects.filter(team=team, created_at__gte=since).only('created_at'):
|
||||||
|
hour = record.created_at.hour
|
||||||
|
if hour >= 22 or hour < 8:
|
||||||
|
night_logins += 1
|
||||||
|
night_ratio = round(night_logins / total_logins * 100, 1)
|
||||||
|
else:
|
||||||
|
night_ratio = 0
|
||||||
|
|
||||||
|
return kick_count, night_ratio
|
||||||
|
|
||||||
|
|
||||||
|
def _build_card(anomaly):
|
||||||
|
"""构建飞书 interactive 卡片。"""
|
||||||
|
team = anomaly.team
|
||||||
|
user = anomaly.user
|
||||||
|
record = anomaly.login_record
|
||||||
|
level = anomaly.level
|
||||||
|
rule = anomaly.rule
|
||||||
|
detail = anomaly.detail
|
||||||
|
|
||||||
|
color = _LEVEL_COLORS.get(level, 'blue')
|
||||||
|
level_label = _LEVEL_LABELS.get(level, level)
|
||||||
|
rule_name = _RULE_NAMES.get(rule, rule)
|
||||||
|
|
||||||
|
kick_count, night_ratio = _compute_auxiliary_metrics(team)
|
||||||
|
|
||||||
|
# 基本信息行
|
||||||
|
info_lines = [
|
||||||
|
f'**团队:** {team.name}',
|
||||||
|
f'**用户:** {user.username}',
|
||||||
|
f'**IP:** {record.ip_address}',
|
||||||
|
f'**归属地:** {record.geo_country} {record.geo_province} {record.geo_city}',
|
||||||
|
f'**规则:** {rule_name}',
|
||||||
|
]
|
||||||
|
|
||||||
|
# 根据规则添加详情
|
||||||
|
if rule == 'region_mismatch':
|
||||||
|
info_lines.append(f'**预期城市:** {", ".join(detail.get("expected", []))}')
|
||||||
|
info_lines.append(f'**实际城市:** {detail.get("city", "")}')
|
||||||
|
elif rule == 'impossible_travel':
|
||||||
|
info_lines.append(f'**当前城市:** {detail.get("current_city", "")}')
|
||||||
|
info_lines.append(f'**之前城市:** {detail.get("previous_city", "")}')
|
||||||
|
elif rule == 'login_frequency':
|
||||||
|
info_lines.append(f'**登录次数:** {detail.get("count", 0)} 次 / {detail.get("window_seconds", 0)}s')
|
||||||
|
elif rule == 'multi_city':
|
||||||
|
info_lines.append(f'**预期外城市:** {", ".join(detail.get("unexpected_cities", []))}')
|
||||||
|
elif rule == 'overseas_ip_diversity':
|
||||||
|
info_lines.append(f'**海外国家:** {", ".join(detail.get("countries", []))}')
|
||||||
|
|
||||||
|
# 自动封禁标注
|
||||||
|
if anomaly.auto_disabled:
|
||||||
|
target_label = '该用户' if anomaly.disabled_target == 'user' else '整个团队'
|
||||||
|
info_lines.append(f'\n🔒 **已自动封禁{target_label}**')
|
||||||
|
|
||||||
|
# 辅助指标
|
||||||
|
info_lines.append(f'\n---\n📊 **辅助指标(近7天):**')
|
||||||
|
info_lines.append(f'并发踢出次数:{kick_count}')
|
||||||
|
info_lines.append(f'非工作时间登录占比:{night_ratio}%')
|
||||||
|
|
||||||
|
card = {
|
||||||
|
'config': {'wide_screen_mode': True},
|
||||||
|
'header': {
|
||||||
|
'title': {'tag': 'plain_text', 'content': f'{level_label} {rule_name}'},
|
||||||
|
'template': color,
|
||||||
|
},
|
||||||
|
'elements': [
|
||||||
|
{
|
||||||
|
'tag': 'div',
|
||||||
|
'text': {
|
||||||
|
'tag': 'lark_md',
|
||||||
|
'content': '\n'.join(info_lines),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def send_feishu_alert(anomaly):
|
||||||
|
"""发送飞书告警卡片到配置的接收人。"""
|
||||||
|
from apps.generation.models import QuotaConfig
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = QuotaConfig.objects.get(pk=1)
|
||||||
|
except QuotaConfig.DoesNotExist:
|
||||||
|
logger.warning('QuotaConfig not found, skip alert')
|
||||||
|
return
|
||||||
|
|
||||||
|
mobiles_str = config.feishu_alert_mobiles
|
||||||
|
if not mobiles_str:
|
||||||
|
logger.info('No feishu alert mobiles configured, skip alert')
|
||||||
|
return
|
||||||
|
|
||||||
|
mobiles = [m.strip() for m in mobiles_str.split(',') if m.strip()]
|
||||||
|
if not mobiles:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = _get_tenant_access_token()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Failed to get feishu token: %s', e)
|
||||||
|
return
|
||||||
|
|
||||||
|
card = _build_card(anomaly)
|
||||||
|
|
||||||
|
for mobile in mobiles:
|
||||||
|
try:
|
||||||
|
open_id = _get_open_id_by_mobile(token, mobile)
|
||||||
|
if not open_id:
|
||||||
|
logger.warning('No feishu user found for mobile %s', mobile)
|
||||||
|
continue
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
'https://open.feishu.cn/open-apis/im/v1/messages',
|
||||||
|
headers={'Authorization': f'Bearer {token}'},
|
||||||
|
params={'receive_id_type': 'open_id'},
|
||||||
|
json={
|
||||||
|
'receive_id': open_id,
|
||||||
|
'msg_type': 'interactive',
|
||||||
|
'content': json.dumps(card, ensure_ascii=False),
|
||||||
|
},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
if data.get('code') != 0:
|
||||||
|
logger.error('Feishu send failed to %s: %s', mobile, data)
|
||||||
|
else:
|
||||||
|
logger.info('Feishu alert sent to %s for rule %s', mobile, anomaly.rule)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Feishu alert error for %s: %s', mobile, e)
|
||||||
|
|
||||||
|
|
||||||
|
def send_feishu_test(mobile):
|
||||||
|
"""发送测试消息到指定手机号。Returns (success, message)。"""
|
||||||
|
try:
|
||||||
|
token = _get_tenant_access_token()
|
||||||
|
open_id = _get_open_id_by_mobile(token, mobile)
|
||||||
|
if not open_id:
|
||||||
|
return False, f'未找到手机号 {mobile} 对应的飞书用户'
|
||||||
|
|
||||||
|
card = {
|
||||||
|
'config': {'wide_screen_mode': True},
|
||||||
|
'header': {
|
||||||
|
'title': {'tag': 'plain_text', 'content': '🔔 AirDrama 告警测试'},
|
||||||
|
'template': 'blue',
|
||||||
|
},
|
||||||
|
'elements': [
|
||||||
|
{
|
||||||
|
'tag': 'div',
|
||||||
|
'text': {
|
||||||
|
'tag': 'lark_md',
|
||||||
|
'content': '这是一条测试消息,说明飞书告警通道配置正常。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
import json
|
||||||
|
resp = requests.post(
|
||||||
|
'https://open.feishu.cn/open-apis/im/v1/messages',
|
||||||
|
headers={'Authorization': f'Bearer {token}'},
|
||||||
|
params={'receive_id_type': 'open_id'},
|
||||||
|
json={
|
||||||
|
'receive_id': open_id,
|
||||||
|
'msg_type': 'interactive',
|
||||||
|
'content': json.dumps(card, ensure_ascii=False),
|
||||||
|
},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
if data.get('code') != 0:
|
||||||
|
return False, f'发送失败: {data.get("msg", "")}'
|
||||||
|
return True, '测试消息已发送'
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
311
backend/utils/anomaly_detector.py
Normal file
311
backend/utils/anomaly_detector.py
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
"""登录异常检测引擎 — R1-R5 规则检测 + 封禁 + 告警冷却。"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_domestic(record) -> bool:
|
||||||
|
"""国内 IP:有国家且为中国。"""
|
||||||
|
return record.geo_country in ('中国', 'CN', 'China') and record.geo_city != ''
|
||||||
|
|
||||||
|
|
||||||
|
def _is_overseas(record) -> bool:
|
||||||
|
"""海外 IP:有国家且不是中国。"""
|
||||||
|
return record.geo_country != '' and record.geo_country not in ('中国', 'CN', 'China', '')
|
||||||
|
|
||||||
|
|
||||||
|
def _is_skip(record) -> bool:
|
||||||
|
"""内网 IP 或归属地解析失败。"""
|
||||||
|
return record.geo_source in ('skip', 'failed', '')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config(team, global_config):
|
||||||
|
"""获取团队级或全局默认的阈值配置。"""
|
||||||
|
from apps.accounts.models import TeamAnomalyConfig
|
||||||
|
|
||||||
|
team_cfg = None
|
||||||
|
try:
|
||||||
|
team_cfg = team.anomaly_config
|
||||||
|
except TeamAnomalyConfig.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _val(team_field, global_field):
|
||||||
|
if team_cfg:
|
||||||
|
v = getattr(team_cfg, team_field, None)
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
|
return getattr(global_config, global_field)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'r1_enabled': _val('r1_enabled', 'r1_enabled_default'),
|
||||||
|
'r2_enabled': _val('r2_enabled', 'r2_enabled_default'),
|
||||||
|
'r2_window': _val('r2_window_seconds', 'r2_window_seconds'),
|
||||||
|
'r3_enabled': _val('r3_enabled', 'r3_enabled_default'),
|
||||||
|
'r3_window': _val('r3_window_seconds', 'r3_window_seconds'),
|
||||||
|
'r3_max_count': _val('r3_max_count', 'r3_max_count'),
|
||||||
|
'r4_enabled': _val('r4_enabled', 'r4_enabled_default'),
|
||||||
|
'r4_window': _val('r4_window_seconds', 'r4_window_seconds'),
|
||||||
|
'r4_city_count': _val('r4_city_count', 'r4_city_count'),
|
||||||
|
'r5_enabled': _val('r5_enabled', 'r5_enabled_default'),
|
||||||
|
'r5_days': _val('r5_days', 'r5_days'),
|
||||||
|
'r5_country_count': _val('r5_country_count', 'r5_country_count'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_login_anomaly(login_record):
|
||||||
|
"""检测登录异常,返回 [(level, rule, detail), ...]。"""
|
||||||
|
from apps.accounts.models import LoginRecord
|
||||||
|
from apps.generation.models import QuotaConfig
|
||||||
|
|
||||||
|
user = login_record.user
|
||||||
|
team = login_record.team
|
||||||
|
|
||||||
|
if not team:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 用户或团队已被封禁 → 跳过检测
|
||||||
|
if not user.is_active or not team.is_active:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
global_config = QuotaConfig.objects.get(pk=1)
|
||||||
|
except QuotaConfig.DoesNotExist:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not global_config.anomaly_detection_enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cfg = _get_config(team, global_config)
|
||||||
|
anomalies = []
|
||||||
|
|
||||||
|
is_domestic = _is_domestic(login_record)
|
||||||
|
is_overseas = _is_overseas(login_record)
|
||||||
|
is_skip_ip = _is_skip(login_record)
|
||||||
|
|
||||||
|
if is_skip_ip:
|
||||||
|
# 内网 IP 跳过所有规则
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ── R1:登录地区不对 ──
|
||||||
|
if cfg['r1_enabled'] and is_domestic:
|
||||||
|
expected = team.expected_regions
|
||||||
|
if expected:
|
||||||
|
expected_cities = [c.strip() for c in expected.split(',') if c.strip()]
|
||||||
|
if expected_cities and login_record.geo_city not in expected_cities:
|
||||||
|
anomalies.append((
|
||||||
|
'warning', 'region_mismatch',
|
||||||
|
{
|
||||||
|
'ip': login_record.ip_address,
|
||||||
|
'city': login_record.geo_city,
|
||||||
|
'province': login_record.geo_province,
|
||||||
|
'expected': expected_cities,
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── R2:不可能的旅行 ──
|
||||||
|
if cfg['r2_enabled'] and is_domestic:
|
||||||
|
window = timezone.now() - timedelta(seconds=cfg['r2_window'])
|
||||||
|
recent = LoginRecord.objects.filter(
|
||||||
|
user=user,
|
||||||
|
created_at__gte=window,
|
||||||
|
geo_source__in=['online', 'offline'],
|
||||||
|
).exclude(pk=login_record.pk).exclude(
|
||||||
|
geo_city=''
|
||||||
|
).values_list('geo_city', flat=True).distinct()
|
||||||
|
|
||||||
|
for prev_city in recent:
|
||||||
|
if prev_city and login_record.geo_city and prev_city != login_record.geo_city:
|
||||||
|
# 只在双方都是国内 IP 时比较
|
||||||
|
anomalies.append((
|
||||||
|
'critical', 'impossible_travel',
|
||||||
|
{
|
||||||
|
'ip': login_record.ip_address,
|
||||||
|
'current_city': login_record.geo_city,
|
||||||
|
'previous_city': prev_city,
|
||||||
|
'window_seconds': cfg['r2_window'],
|
||||||
|
}
|
||||||
|
))
|
||||||
|
break # 只报一次
|
||||||
|
|
||||||
|
# ── R3:登录太频繁 ──
|
||||||
|
if cfg['r3_enabled']:
|
||||||
|
window = timezone.now() - timedelta(seconds=cfg['r3_window'])
|
||||||
|
count = LoginRecord.objects.filter(
|
||||||
|
user=user,
|
||||||
|
created_at__gte=window,
|
||||||
|
).count()
|
||||||
|
if count > cfg['r3_max_count']:
|
||||||
|
anomalies.append((
|
||||||
|
'warning', 'login_frequency',
|
||||||
|
{
|
||||||
|
'ip': login_record.ip_address,
|
||||||
|
'count': count,
|
||||||
|
'window_seconds': cfg['r3_window'],
|
||||||
|
'threshold': cfg['r3_max_count'],
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── R4:团队遍地开花 ──
|
||||||
|
if cfg['r4_enabled'] and is_domestic:
|
||||||
|
expected = team.expected_regions
|
||||||
|
expected_cities = [c.strip() for c in expected.split(',') if c.strip()] if expected else []
|
||||||
|
window = timezone.now() - timedelta(seconds=cfg['r4_window'])
|
||||||
|
|
||||||
|
team_cities = LoginRecord.objects.filter(
|
||||||
|
team=team,
|
||||||
|
created_at__gte=window,
|
||||||
|
geo_source__in=['online', 'offline'],
|
||||||
|
).exclude(
|
||||||
|
geo_city=''
|
||||||
|
).exclude(
|
||||||
|
geo_country__in=['', '0']
|
||||||
|
).filter(
|
||||||
|
geo_country__in=['中国', 'CN', 'China']
|
||||||
|
).values_list('geo_city', flat=True).distinct()
|
||||||
|
|
||||||
|
unexpected_cities = [c for c in team_cities if c not in expected_cities]
|
||||||
|
if len(unexpected_cities) >= cfg['r4_city_count']:
|
||||||
|
anomalies.append((
|
||||||
|
'critical', 'multi_city',
|
||||||
|
{
|
||||||
|
'unexpected_cities': unexpected_cities,
|
||||||
|
'expected_cities': expected_cities,
|
||||||
|
'count': len(unexpected_cities),
|
||||||
|
'threshold': cfg['r4_city_count'],
|
||||||
|
'window_seconds': cfg['r4_window'],
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── R5:海外IP太杂 ──
|
||||||
|
if cfg['r5_enabled'] and is_overseas:
|
||||||
|
since = timezone.now() - timedelta(days=cfg['r5_days'])
|
||||||
|
overseas_countries = LoginRecord.objects.filter(
|
||||||
|
team=team,
|
||||||
|
created_at__gte=since,
|
||||||
|
geo_source__in=['online', 'offline'],
|
||||||
|
).exclude(
|
||||||
|
geo_country__in=['中国', 'CN', 'China', '', '0']
|
||||||
|
).values_list('geo_country', flat=True).distinct()
|
||||||
|
|
||||||
|
country_list = list(overseas_countries)
|
||||||
|
if len(country_list) >= cfg['r5_country_count']:
|
||||||
|
anomalies.append((
|
||||||
|
'warning', 'overseas_ip_diversity',
|
||||||
|
{
|
||||||
|
'countries': country_list,
|
||||||
|
'count': len(country_list),
|
||||||
|
'threshold': cfg['r5_country_count'],
|
||||||
|
'days': cfg['r5_days'],
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
return anomalies
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_user(user):
|
||||||
|
"""封禁用户 — 设 is_active=False + 清除所有会话。"""
|
||||||
|
from apps.accounts.models import ActiveSession
|
||||||
|
|
||||||
|
user.is_active = False
|
||||||
|
user.disabled_by = 'system'
|
||||||
|
user.save(update_fields=['is_active', 'disabled_by'])
|
||||||
|
ActiveSession.objects.filter(user=user).delete()
|
||||||
|
logger.info('User %s disabled by anomaly detection', user.username)
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_team(team):
|
||||||
|
"""封禁团队 — 团队 is_active=False + 全员踢下线。"""
|
||||||
|
from apps.accounts.models import ActiveSession
|
||||||
|
|
||||||
|
team.is_active = False
|
||||||
|
team.disabled_by = 'system'
|
||||||
|
team.save(update_fields=['is_active', 'disabled_by'])
|
||||||
|
ActiveSession.objects.filter(user__team=team).delete()
|
||||||
|
logger.info('Team %s disabled by anomaly detection', team.name)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_in_cooldown(team, rule, cooldown_seconds):
|
||||||
|
"""检查告警冷却:同团队+同规则在冷却窗口内是否已告警。"""
|
||||||
|
from apps.accounts.models import LoginAnomaly
|
||||||
|
|
||||||
|
since = timezone.now() - timedelta(seconds=cooldown_seconds)
|
||||||
|
return LoginAnomaly.objects.filter(
|
||||||
|
team=team,
|
||||||
|
rule=rule,
|
||||||
|
alerted=True,
|
||||||
|
created_at__gte=since,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def process_anomalies(login_record, anomalies):
|
||||||
|
"""保存异常记录 + 发告警 + 封禁。"""
|
||||||
|
from apps.accounts.models import LoginAnomaly
|
||||||
|
from apps.generation.models import QuotaConfig
|
||||||
|
|
||||||
|
if not anomalies:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
global_config = QuotaConfig.objects.get(pk=1)
|
||||||
|
except QuotaConfig.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
cooldown = global_config.alert_cooldown_seconds
|
||||||
|
team = login_record.team
|
||||||
|
user = login_record.user
|
||||||
|
|
||||||
|
for level, rule, detail in anomalies:
|
||||||
|
# 确定是否需要封禁
|
||||||
|
auto_disabled = False
|
||||||
|
disabled_target = ''
|
||||||
|
|
||||||
|
if rule == 'impossible_travel':
|
||||||
|
_disable_user(user)
|
||||||
|
auto_disabled = True
|
||||||
|
disabled_target = 'user'
|
||||||
|
elif rule == 'multi_city':
|
||||||
|
_disable_team(team)
|
||||||
|
auto_disabled = True
|
||||||
|
disabled_target = 'team'
|
||||||
|
|
||||||
|
# 检查告警冷却
|
||||||
|
should_alert = not _is_in_cooldown(team, rule, cooldown)
|
||||||
|
|
||||||
|
# 保存异常记录
|
||||||
|
anomaly = LoginAnomaly.objects.create(
|
||||||
|
team=team,
|
||||||
|
user=user,
|
||||||
|
login_record=login_record,
|
||||||
|
level=level,
|
||||||
|
rule=rule,
|
||||||
|
detail=detail,
|
||||||
|
alerted=should_alert,
|
||||||
|
auto_disabled=auto_disabled,
|
||||||
|
disabled_target=disabled_target,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 异步发送告警(不阻塞登录)
|
||||||
|
if should_alert:
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_send_alert_safe,
|
||||||
|
args=(anomaly.pk,),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _send_alert_safe(anomaly_pk):
|
||||||
|
"""安全地发送告警,捕获所有异常。"""
|
||||||
|
try:
|
||||||
|
from apps.accounts.models import LoginAnomaly
|
||||||
|
anomaly = LoginAnomaly.objects.select_related('team', 'user', 'login_record').get(pk=anomaly_pk)
|
||||||
|
|
||||||
|
from utils.alert_service import send_feishu_alert
|
||||||
|
send_feishu_alert(anomaly)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Failed to send alert for anomaly %s: %s', anomaly_pk, e)
|
||||||
135
backend/utils/geo_client.py
Normal file
135
backend/utils/geo_client.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"""IP 归属地解析 — 阿里云市场在线 API + ip2region 离线库 + 熔断降级。"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── 熔断状态 ──
|
||||||
|
_circuit_open_until = 0 # 在线 API 失败后,此时间戳之前直接走离线
|
||||||
|
_CIRCUIT_COOLDOWN = 60 # 熔断冷却 60 秒
|
||||||
|
|
||||||
|
# ── ip2region 搜索器缓存 ──
|
||||||
|
_ip2region_searcher = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_private_ip(ip: str) -> bool:
|
||||||
|
"""判断是否为私有/本地 IP。"""
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(ip)
|
||||||
|
return addr.is_private or addr.is_loopback or addr.is_reserved
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_city(name: str) -> str:
|
||||||
|
"""标准化城市名:去掉「市」后缀。"""
|
||||||
|
if name and name.endswith('市'):
|
||||||
|
return name[:-1]
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_province(name: str) -> str:
|
||||||
|
"""标准化省份名:去掉「省」「自治区」「壮族自治区」等后缀。"""
|
||||||
|
if not name:
|
||||||
|
return name
|
||||||
|
for suffix in ['壮族自治区', '回族自治区', '维吾尔自治区', '自治区', '省']:
|
||||||
|
if name.endswith(suffix):
|
||||||
|
return name[:-len(suffix)]
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_online(ip: str) -> tuple:
|
||||||
|
"""阿里云市场 IP138 归属地 API(超时 2s)。
|
||||||
|
|
||||||
|
Returns: (country, province, city) or raises Exception.
|
||||||
|
"""
|
||||||
|
appcode = settings.ALIYUN_IP_GEO_APPCODE
|
||||||
|
if not appcode:
|
||||||
|
raise RuntimeError('ALIYUN_IP_GEO_APPCODE not configured')
|
||||||
|
|
||||||
|
url = f'https://ali.ip138.com/ip/?ip={ip}&datatype=json'
|
||||||
|
headers = {'Authorization': f'APPCODE {appcode}'}
|
||||||
|
resp = requests.get(url, headers=headers, timeout=2)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if data.get('ret') != 'ok':
|
||||||
|
raise RuntimeError(f'IP138 API error: {data.get("msg", data)}')
|
||||||
|
|
||||||
|
# data.data = ["国家", "省份", "城市", "运营商", "邮编", "区号"]
|
||||||
|
parts = data.get('data', [])
|
||||||
|
country = parts[0] if len(parts) > 0 else ''
|
||||||
|
province = _normalize_province(parts[1] if len(parts) > 1 else '')
|
||||||
|
city = _normalize_city(parts[2] if len(parts) > 2 else '')
|
||||||
|
|
||||||
|
return country, province, city
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_offline(ip: str) -> tuple:
|
||||||
|
"""ip2region 离线库解析。
|
||||||
|
|
||||||
|
Returns: (country, province, city) or ('', '', '').
|
||||||
|
"""
|
||||||
|
global _ip2region_searcher
|
||||||
|
|
||||||
|
if _ip2region_searcher is None:
|
||||||
|
try:
|
||||||
|
import ipregion, os
|
||||||
|
from ipregion import XdbSearcher
|
||||||
|
pkg_dir = os.path.dirname(ipregion.__file__)
|
||||||
|
db_path = os.path.join(pkg_dir, 'ip2region.xdb')
|
||||||
|
content = XdbSearcher.loadContentFromFile(dbfile=db_path)
|
||||||
|
_ip2region_searcher = XdbSearcher(contentBuff=content)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('ip2region init failed: %s', e)
|
||||||
|
return '', '', ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
region_str = _ip2region_searcher.searchByIPStr(ip)
|
||||||
|
# 格式: "中国|0|广东省|广州市|电信"
|
||||||
|
parts = region_str.split('|') if region_str else []
|
||||||
|
country = parts[0] if len(parts) > 0 and parts[0] != '0' else ''
|
||||||
|
province = _normalize_province(parts[2] if len(parts) > 2 and parts[2] != '0' else '')
|
||||||
|
city = _normalize_city(parts[3] if len(parts) > 3 and parts[3] != '0' else '')
|
||||||
|
return country, province, city
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('ip2region lookup failed for %s: %s', ip, e)
|
||||||
|
return '', '', ''
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ip_location(ip: str) -> tuple:
|
||||||
|
"""解析 IP 归属地。
|
||||||
|
|
||||||
|
Returns: (country, province, city, source)
|
||||||
|
source: 'online' / 'offline' / 'skip' / 'failed'
|
||||||
|
"""
|
||||||
|
if not ip or _is_private_ip(ip):
|
||||||
|
return '', '', '', 'skip'
|
||||||
|
|
||||||
|
global _circuit_open_until
|
||||||
|
|
||||||
|
# 尝试在线 API(熔断期间跳过)
|
||||||
|
now = time.time()
|
||||||
|
if now >= _circuit_open_until and settings.ALIYUN_IP_GEO_APPCODE:
|
||||||
|
try:
|
||||||
|
country, province, city = _resolve_online(ip)
|
||||||
|
return country, province, city, 'online'
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Online IP geo failed for %s: %s — circuit open for %ds', ip, e, _CIRCUIT_COOLDOWN)
|
||||||
|
_circuit_open_until = now + _CIRCUIT_COOLDOWN
|
||||||
|
|
||||||
|
# 降级到离线库
|
||||||
|
try:
|
||||||
|
country, province, city = _resolve_offline(ip)
|
||||||
|
if country or province or city:
|
||||||
|
return country, province, city, 'offline'
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Offline IP geo failed for %s: %s', ip, e)
|
||||||
|
|
||||||
|
return '', '', '', 'failed'
|
||||||
@ -4,6 +4,91 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-03-19 — v0.9.7: 登录风控第二期 — IP归属地解析 + 异常检测 + 飞书告警 + 自动封禁
|
||||||
|
|
||||||
|
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地验证,IP138 在线 API 需部署至阿里云后验证)
|
||||||
|
|
||||||
|
### 变更内容
|
||||||
|
|
||||||
|
#### 后端
|
||||||
|
1. **IP 归属地解析** — 新建 `geo_client.py`,主通道阿里云市场 IP138 API(精确到市),备通道 ip2region 离线库,60 秒熔断降级策略,私有 IP 自动跳过
|
||||||
|
2. **异常检测引擎** — 新建 `anomaly_detector.py`,5 条规则:
|
||||||
|
- R1 登录地区不对(警告)— 单账号从非预期城市登录
|
||||||
|
- R2 不可能的旅行(严重)— 单账号短时间内从两个不同城市登录,自动封禁该用户
|
||||||
|
- R3 登录太频繁(警告)— 单账号短时间内登录次数过多
|
||||||
|
- R4 团队遍地开花(严重)— 整个团队短时间内出现大量异地登录,自动封禁整个团队
|
||||||
|
- R5 海外IP太杂(警告)— 整个团队短期内出现大量不同国家的登录
|
||||||
|
3. **飞书告警服务** — 新建 `alert_service.py`,通过飞书 Open API 发送 interactive 卡片私信,红色头=严重/橙色头=警告,附带辅助指标(7天并发踢出次数、非工作时间登录占比)
|
||||||
|
4. **告警冷却** — 同团队+同规则 30 分钟内不重复告警(可配置)
|
||||||
|
5. **封禁机制** — R2 封用户 + R4 封团队,封禁即踢下线(清 ActiveSession),前端拦截 user_disabled/team_disabled 错误码弹窗提示
|
||||||
|
6. **团队级阈值配置** — TeamAnomalyConfig 模型(OneToOne → Team),未配置时取全局默认值
|
||||||
|
7. **自动学习预期地区** — 统计团队最近 30 天登录城市,频次 ≥ 3 的城市纳入预期列表
|
||||||
|
8. **LoginRecord 扩展** — 新增 team FK、geo_country/province/city/source 字段
|
||||||
|
9. **SessionJWT 双重检查** — 认证层同时检查 user.is_active 和 team.is_active
|
||||||
|
|
||||||
|
#### 前端
|
||||||
|
10. **系统设置页** — 异常检测总开关、R1-R5 默认阈值编辑(三段按钮组 默认|开|关)、飞书接收人手机号+测试按钮、短信(灰色 Coming soon)、告警冷却时间
|
||||||
|
11. **团队管理页** — 预期登录城市编辑+自动学习按钮、R1-R5 团队级阈值覆盖、disabled_by 来源标签(系统/管理员)
|
||||||
|
12. **安全日志页面** — `/admin/security` LoginAnomaly 记录列表,按团队/规则/级别/时间筛选
|
||||||
|
13. **用户管理页** — disabled_by 来源标签(系统自动禁用/管理员手动禁用)
|
||||||
|
14. **管理员修改密码** — AdminLayout 侧栏底部新增修改密码入口+弹窗
|
||||||
|
15. **前端拦截器** — user_disabled/team_disabled 错误码弹窗提示后跳登录页
|
||||||
|
|
||||||
|
### 新增/变更 API
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/admin/anomalies` | 登录异常记录(筛选:team/rule/level/date) |
|
||||||
|
| POST | `/api/v1/admin/test-feishu` | 发送飞书测试消息 |
|
||||||
|
| POST | `/api/v1/admin/teams/<id>/auto-learn` | 自动学习预期登录地区 |
|
||||||
|
| POST | `/api/v1/admin/teams/<id>/apply-learned-regions` | 应用学习到的预期地区 |
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/utils/geo_client.py` | IP 归属地解析(IP138 在线 + ip2region 离线) |
|
||||||
|
| `backend/utils/anomaly_detector.py` | 异常检测引擎(R1-R5 规则) |
|
||||||
|
| `backend/utils/alert_service.py` | 告警服务(飞书 interactive 卡片) |
|
||||||
|
| `backend/apps/accounts/migrations/0008_anomaly_detection_phase2.py` | 账号模型迁移 |
|
||||||
|
| `backend/apps/generation/migrations/0006_anomaly_detection_phase2.py` | 配额模型迁移 |
|
||||||
|
| `web/src/pages/AnomalyLogPage.tsx` | 安全日志页面 |
|
||||||
|
|
||||||
|
### 变更文件
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `CLAUDE.md` | 新增异常检测相关模型/API/路由/环境变量文档 |
|
||||||
|
| `backend/apps/accounts/authentication.py` | SessionJWT 认证增加 team.is_active 检查 |
|
||||||
|
| `backend/apps/accounts/models.py` | 新增 LoginAnomaly/TeamAnomalyConfig 模型,User/Team 新增 disabled_by |
|
||||||
|
| `backend/apps/accounts/views.py` | login_view 增加 geo 解析 + 异常检测调用 |
|
||||||
|
| `backend/apps/generation/models.py` | QuotaConfig 新增异常检测全局配置字段 |
|
||||||
|
| `backend/apps/generation/serializers.py` | 新增异常检测相关 Serializer |
|
||||||
|
| `backend/apps/generation/urls.py` | 新增 4 条路由 |
|
||||||
|
| `backend/apps/generation/views.py` | 新增 anomalies/test-feishu/auto-learn/apply-learned-regions 视图 |
|
||||||
|
| `backend/config/settings.py` | 新增 ALIYUN_IP_GEO_APPCODE / FEISHU_APP_SECRET 配置 |
|
||||||
|
| `backend/requirements.txt` | 新增 ip2region>=2.7.0 |
|
||||||
|
| `web/src/App.tsx` | 新增 /admin/security 路由 |
|
||||||
|
| `web/src/lib/api.ts` | 新增 getLoginAnomalies/testFeishu/teamAutoLearn/applyLearnedRegions |
|
||||||
|
| `web/src/pages/AdminLayout.tsx` | 侧栏新增"安全日志"导航 + 修改密码弹窗 |
|
||||||
|
| `web/src/pages/SettingsPage.tsx` | 新增异常检测配置卡片 |
|
||||||
|
| `web/src/pages/TeamsPage.tsx` | 预期地区编辑/自动学习 + 团队级阈值配置 |
|
||||||
|
| `web/src/pages/TeamsPage.module.css` | membersTitle 间距调整 |
|
||||||
|
| `web/src/pages/UsersPage.tsx` | disabled_by 来源标签 |
|
||||||
|
| `web/src/types/index.ts` | 新增 LoginAnomaly/TeamAnomalyConfig 接口 |
|
||||||
|
|
||||||
|
### 触发原因
|
||||||
|
- 火山引擎明确禁止 C 端使用 Seedance API,需要防止团队私自将账号开放给 C 端个人用户
|
||||||
|
- 第一期已完成并发会话限制 + Token 缩短 + 登录记录,第二期基于 IP 归属地做异常检测
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
- 异常检测不阻塞登录(try/except 包裹)
|
||||||
|
- 在线 API 熔断 60 秒自动降级离线库
|
||||||
|
- R2 城市名直接比较(不算距离),空城市跳过(防误封)
|
||||||
|
- R4 只统计预期城市列表之外的城市
|
||||||
|
- R5 统计国家数而非 IP 数(VPN 轮换 IP 但出口国家固定)
|
||||||
|
- 飞书告警异步发送(daemon thread),不拖慢登录
|
||||||
|
- TeamAnomalyConfig 独立模型,不污染 Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-03-16 — v0.9.1: 首页 + 播放器修复
|
## 2026-03-16 — v0.9.1: 首页 + 播放器修复
|
||||||
|
|
||||||
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地验证)
|
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地验证)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { UsersPage } from './pages/UsersPage';
|
|||||||
import { RecordsPage } from './pages/RecordsPage';
|
import { RecordsPage } from './pages/RecordsPage';
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
import { SettingsPage } from './pages/SettingsPage';
|
||||||
import { AuditLogsPage } from './pages/AuditLogsPage';
|
import { AuditLogsPage } from './pages/AuditLogsPage';
|
||||||
|
import { AnomalyLogPage } from './pages/AnomalyLogPage';
|
||||||
import { ProfilePage } from './pages/ProfilePage';
|
import { ProfilePage } from './pages/ProfilePage';
|
||||||
import { AssetsPage } from './pages/AssetsPage';
|
import { AssetsPage } from './pages/AssetsPage';
|
||||||
|
|
||||||
@ -77,6 +78,7 @@ export default function App() {
|
|||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="records" element={<RecordsPage />} />
|
<Route path="records" element={<RecordsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
<Route path="security" element={<AnomalyLogPage />} />
|
||||||
<Route path="logs" element={<AuditLogsPage />} />
|
<Route path="logs" element={<AuditLogsPage />} />
|
||||||
<Route path="assets" element={<AdminAssetsPage />} />
|
<Route path="assets" element={<AdminAssetsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type {
|
|||||||
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
|
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
|
||||||
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
|
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
|
||||||
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
|
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
|
||||||
|
LoginAnomaly, TeamAnomalyConfig,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { reportError } from './logCenter';
|
import { reportError } from './logCenter';
|
||||||
|
|
||||||
@ -30,9 +31,9 @@ api.interceptors.response.use(
|
|||||||
const authEndpoints = ['/auth/login', '/auth/register', '/auth/token/refresh'];
|
const authEndpoints = ['/auth/login', '/auth/register', '/auth/token/refresh'];
|
||||||
const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep));
|
const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep));
|
||||||
|
|
||||||
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
|
// Check special ban/kick codes on 401 or 403
|
||||||
// Check if session was kicked by another device login
|
|
||||||
const errorCode = error.response?.data?.code || error.response?.data?.detail?.code;
|
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') {
|
if (errorCode === 'session_expired_other_device') {
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem('refresh_token');
|
||||||
@ -40,7 +41,24 @@ api.interceptors.response.use(
|
|||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
return Promise.reject(error);
|
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;
|
originalRequest._retry = true;
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
@ -144,13 +162,13 @@ export const adminApi = {
|
|||||||
getTeams: () =>
|
getTeams: () =>
|
||||||
api.get<{ results: Team[] }>('/admin/teams'),
|
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),
|
api.post('/admin/teams/create', data),
|
||||||
|
|
||||||
getTeamDetail: (teamId: number) =>
|
getTeamDetail: (teamId: number) =>
|
||||||
api.get<TeamDetail>(`/admin/teams/${teamId}`),
|
api.get<TeamDetail>(`/admin/teams/${teamId}`),
|
||||||
|
|
||||||
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; is_active?: boolean }) =>
|
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
|
||||||
api.put(`/admin/teams/${teamId}`, data),
|
api.put(`/admin/teams/${teamId}`, data),
|
||||||
|
|
||||||
topUpTeam: (teamId: number, seconds: number) =>
|
topUpTeam: (teamId: number, seconds: number) =>
|
||||||
@ -243,6 +261,29 @@ export const adminApi = {
|
|||||||
results: AssetVideo[];
|
results: AssetVideo[];
|
||||||
}>(`/admin/assets/user/${userId}/videos`, { params: { page, page_size: pageSize } }),
|
}>(`/admin/assets/user/${userId}/videos`, { params: { page, page_size: pageSize } }),
|
||||||
|
|
||||||
|
// Anomaly detection
|
||||||
|
getLoginAnomalies: (params: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
team_id?: number;
|
||||||
|
rule?: string;
|
||||||
|
level?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
} = {}) =>
|
||||||
|
api.get<PaginatedResponse<LoginAnomaly> & { total_pages: number }>('/admin/anomalies', { params }),
|
||||||
|
|
||||||
|
testFeishu: (mobile: string) =>
|
||||||
|
api.post<{ message: string }>('/admin/test-feishu', { mobile }),
|
||||||
|
|
||||||
|
teamAutoLearn: (teamId: number, days: number = 30, minCount: number = 3) =>
|
||||||
|
api.post<{ team_id: number; team_name: string; learned_cities: string[]; days: number; min_count: number; current_expected_regions: string }>(
|
||||||
|
`/admin/teams/${teamId}/auto-learn`, { days, min_count: minCount }
|
||||||
|
),
|
||||||
|
|
||||||
|
teamApplyLearnedRegions: (teamId: number, cities: string[]) =>
|
||||||
|
api.post(`/admin/teams/${teamId}/apply-learned-regions`, { cities }),
|
||||||
|
|
||||||
getAuditLogs: (params: {
|
getAuditLogs: (params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../store/auth';
|
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 logoImg from '../assets/logo_32.png';
|
||||||
import styles from './AdminLayout.module.css';
|
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/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/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/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' },
|
{ 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 logout = useAuthStore((s) => s.logout);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
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 = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login', { replace: true });
|
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 (
|
return (
|
||||||
<div className={styles.layout}>
|
<div className={styles.layout}>
|
||||||
<aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}>
|
<aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}>
|
||||||
@ -74,8 +98,11 @@ export function AdminLayout() {
|
|||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className={styles.userMeta}>
|
<div className={styles.userMeta}>
|
||||||
<span className={styles.userName}>{user?.username}</span>
|
<span className={styles.userName}>{user?.username}</span>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button className={styles.logoutLink} onClick={() => setPwModalOpen(true)}>修改密码</button>
|
||||||
<button className={styles.logoutLink} onClick={handleLogout}>退出</button>
|
<button className={styles.logoutLink} onClick={handleLogout}>退出</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -84,6 +111,33 @@ export function AdminLayout() {
|
|||||||
<main className={`${styles.content} ${collapsed ? styles.contentExpanded : ''}`}>
|
<main className={`${styles.content} ${collapsed ? styles.contentExpanded : ''}`}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{pwModalOpen && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
|
||||||
|
onClick={() => setPwModalOpen(false)}>
|
||||||
|
<div style={{ background: 'var(--color-bg-card)', borderRadius: '12px', padding: '24px', width: '360px', border: '1px solid var(--color-border-card)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 style={{ margin: '0 0 16px', color: 'var(--color-text-primary)', fontSize: '16px' }}>修改密码</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<input type="password" placeholder="当前密码" value={oldPw} onChange={(e) => setOldPw(e.target.value)}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: '14px' }} />
|
||||||
|
<input type="password" placeholder="新密码(至少6位)" value={newPw} onChange={(e) => setNewPw(e.target.value)}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: '14px' }} />
|
||||||
|
<input type="password" placeholder="确认新密码" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleChangePassword()}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: '14px' }} />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
|
||||||
|
<button onClick={() => setPwModalOpen(false)}
|
||||||
|
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '13px' }}>取消</button>
|
||||||
|
<button onClick={handleChangePassword} disabled={pwSaving}
|
||||||
|
style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '13px', opacity: pwSaving ? 0.6 : 1 }}>
|
||||||
|
{pwSaving ? '修改中...' : '确认修改'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
224
web/src/pages/AnomalyLogPage.tsx
Normal file
224
web/src/pages/AnomalyLogPage.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { adminApi } from '../lib/api';
|
||||||
|
import type { LoginAnomaly, Team } from '../types';
|
||||||
|
import { showToast } from '../components/Toast';
|
||||||
|
import { DatePicker } from '../components/DatePicker';
|
||||||
|
import { Select } from '../components/Select';
|
||||||
|
import styles from './AuditLogsPage.module.css';
|
||||||
|
|
||||||
|
const RULE_OPTIONS = [
|
||||||
|
{ label: '全部规则', value: '' },
|
||||||
|
{ label: 'R1 登录地区不对', value: 'region_mismatch' },
|
||||||
|
{ label: 'R2 不可能的旅行', value: 'impossible_travel' },
|
||||||
|
{ label: 'R3 登录太频繁', value: 'login_frequency' },
|
||||||
|
{ label: 'R4 团队遍地开花', value: 'multi_city' },
|
||||||
|
{ label: 'R5 海外IP太杂', value: 'overseas_ip_diversity' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEVEL_OPTIONS = [
|
||||||
|
{ label: '全部级别', value: '' },
|
||||||
|
{ label: '警告', value: 'warning' },
|
||||||
|
{ label: '严重', value: 'critical' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RULE_LABELS: Record<string, string> = {
|
||||||
|
region_mismatch: 'R1 登录地区不对',
|
||||||
|
impossible_travel: 'R2 不可能的旅行',
|
||||||
|
login_frequency: 'R3 登录太频繁',
|
||||||
|
multi_city: 'R4 团队遍地开花',
|
||||||
|
overseas_ip_diversity: 'R5 海外IP太杂',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AnomalyLogPage() {
|
||||||
|
const [anomalies, setAnomalies] = useState<LoginAnomaly[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [ruleFilter, setRuleFilter] = useState('');
|
||||||
|
const [levelFilter, setLevelFilter] = useState('');
|
||||||
|
const [teamFilter, setTeamFilter] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAnomalies = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await adminApi.getLoginAnomalies({
|
||||||
|
page, page_size: pageSize,
|
||||||
|
rule: ruleFilter || undefined,
|
||||||
|
level: levelFilter || undefined,
|
||||||
|
team_id: teamFilter ? Number(teamFilter) : undefined,
|
||||||
|
start_date: startDate || undefined,
|
||||||
|
end_date: endDate || undefined,
|
||||||
|
});
|
||||||
|
setAnomalies(data.results);
|
||||||
|
setTotal(data.total);
|
||||||
|
} catch {
|
||||||
|
showToast('加载安全日志失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, ruleFilter, levelFilter, teamFilter, startDate, endDate]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchAnomalies(); }, [fetchAnomalies]);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPage(1);
|
||||||
|
fetchAnomalies();
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
const teamOptions = [
|
||||||
|
{ label: '全部团队', value: '' },
|
||||||
|
...teams.map((t) => ({ label: t.name, value: String(t.id) })),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<h1 className={styles.title}>安全日志</h1>
|
||||||
|
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<Select
|
||||||
|
value={ruleFilter}
|
||||||
|
onChange={(v) => { setRuleFilter(v); setPage(1); }}
|
||||||
|
placeholder="全部规则"
|
||||||
|
options={RULE_OPTIONS}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={levelFilter}
|
||||||
|
onChange={(v) => { setLevelFilter(v); setPage(1); }}
|
||||||
|
placeholder="全部级别"
|
||||||
|
options={LEVEL_OPTIONS}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={teamFilter}
|
||||||
|
onChange={(v) => { setTeamFilter(v); setPage(1); }}
|
||||||
|
placeholder="全部团队"
|
||||||
|
options={teamOptions}
|
||||||
|
/>
|
||||||
|
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
|
||||||
|
<span className={styles.dateSep}>~</span>
|
||||||
|
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
|
||||||
|
<button className={styles.searchBtn} onClick={handleSearch}>查询</button>
|
||||||
|
<button className={styles.refreshBtn} onClick={fetchAnomalies}>刷新</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>团队</th>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>级别</th>
|
||||||
|
<th>规则</th>
|
||||||
|
<th>IP / 归属地</th>
|
||||||
|
<th>详情</th>
|
||||||
|
<th>处理</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{Array.from({ length: 8 }).map((_, j) => (
|
||||||
|
<td key={j}><div className={styles.skeletonCell} /></td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : anomalies.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} className={styles.empty}>暂无异常记录</td></tr>
|
||||||
|
) : (
|
||||||
|
anomalies.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td className={styles.timeCell}>{new Date(a.created_at).toLocaleString('zh-CN')}</td>
|
||||||
|
<td>{a.team_name}</td>
|
||||||
|
<td>{a.username}</td>
|
||||||
|
<td>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 8px', borderRadius: 4, fontSize: 12, whiteSpace: 'nowrap',
|
||||||
|
background: a.level === 'critical' ? 'rgba(255, 77, 79, 0.15)' : 'rgba(250, 173, 20, 0.15)',
|
||||||
|
color: a.level === 'critical' ? '#ff4d4f' : '#faad14',
|
||||||
|
}}>
|
||||||
|
{a.level === 'critical' ? '严重' : '警告'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><span className={styles.actionBadge}>{RULE_LABELS[a.rule] || a.rule}</span></td>
|
||||||
|
<td className={styles.ipCell}>
|
||||||
|
{a.ip_address}
|
||||||
|
{(a.geo_country || a.geo_city) && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||||
|
{[a.geo_country, a.geo_province, a.geo_city].filter(Boolean).join(' ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 12, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{renderDetail(a)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{a.auto_disabled ? (
|
||||||
|
<span style={{ fontSize: 12, color: '#ff4d4f' }}>
|
||||||
|
已封禁{a.disabled_target === 'team' ? '团队' : '用户'}
|
||||||
|
</span>
|
||||||
|
) : a.alerted ? (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>已告警</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<span className={styles.pageInfo}>共 {total} 条</span>
|
||||||
|
<div className={styles.pageButtons}>
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage(page - 1)}><</button>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||||
|
let p: number;
|
||||||
|
if (totalPages <= 5) p = i + 1;
|
||||||
|
else if (page <= 3) p = i + 1;
|
||||||
|
else if (page >= totalPages - 2) p = totalPages - 4 + i;
|
||||||
|
else p = page - 2 + i;
|
||||||
|
return (
|
||||||
|
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(a: LoginAnomaly): string {
|
||||||
|
const d = a.detail;
|
||||||
|
switch (a.rule) {
|
||||||
|
case 'region_mismatch':
|
||||||
|
return `预期: ${(d.expected as string[] || []).join(',')} | 实际: ${d.city || ''}`;
|
||||||
|
case 'impossible_travel':
|
||||||
|
return `${d.previous_city || ''} → ${d.current_city || ''}`;
|
||||||
|
case 'login_frequency':
|
||||||
|
return `${d.count || 0} 次 / ${d.window_seconds || 0}s`;
|
||||||
|
case 'multi_city':
|
||||||
|
return `预期外城市: ${(d.unexpected_cities as string[] || []).join(',')}`;
|
||||||
|
case 'overseas_ip_diversity':
|
||||||
|
return `海外国家: ${(d.countries as string[] || []).join(',')}`;
|
||||||
|
default:
|
||||||
|
return JSON.stringify(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,24 @@ export function SettingsPage() {
|
|||||||
announcement_enabled: false,
|
announcement_enabled: false,
|
||||||
max_desktop_sessions: 1,
|
max_desktop_sessions: 1,
|
||||||
max_mobile_sessions: 0,
|
max_mobile_sessions: 0,
|
||||||
|
anomaly_detection_enabled: false,
|
||||||
|
r1_enabled_default: true,
|
||||||
|
r2_enabled_default: true,
|
||||||
|
r2_window_seconds: 3600,
|
||||||
|
r3_enabled_default: true,
|
||||||
|
r3_window_seconds: 3600,
|
||||||
|
r3_max_count: 10,
|
||||||
|
r4_enabled_default: true,
|
||||||
|
r4_window_seconds: 3600,
|
||||||
|
r4_city_count: 5,
|
||||||
|
r5_enabled_default: true,
|
||||||
|
r5_days: 7,
|
||||||
|
r5_country_count: 10,
|
||||||
|
feishu_alert_mobiles: '',
|
||||||
|
sms_alert_mobiles: '',
|
||||||
|
alert_cooldown_seconds: 1800,
|
||||||
});
|
});
|
||||||
|
const [testingFeishu, setTestingFeishu] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@ -53,6 +70,20 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTestFeishu = async () => {
|
||||||
|
const mobiles = settings.feishu_alert_mobiles.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (mobiles.length === 0) { showToast('请先填写飞书告警手机号'); return; }
|
||||||
|
setTestingFeishu(true);
|
||||||
|
try {
|
||||||
|
await adminApi.testFeishu(mobiles[0]);
|
||||||
|
showToast('测试消息已发送');
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast(err.response?.data?.error || '发送失败');
|
||||||
|
} finally {
|
||||||
|
setTestingFeishu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
@ -151,6 +182,167 @@ export function SettingsPage() {
|
|||||||
{saving ? '保存中...' : '保存公告'}
|
{saving ? '保存中...' : '保存公告'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<div>
|
||||||
|
<h2 className={styles.cardTitle}>异常检测与告警</h2>
|
||||||
|
<p className={styles.cardDesc}>基于登录 IP 归属地检测异常行为,发现异常时告警或自动封禁</p>
|
||||||
|
</div>
|
||||||
|
<label className={styles.switch}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.anomaly_detection_enabled}
|
||||||
|
onChange={(e) => setSettings({ ...settings, anomaly_detection_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className={styles.slider}></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.anomaly_detection_enabled && (
|
||||||
|
<>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 12, marginTop: 8 }}>规则默认阈值</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||||
|
<label className={styles.switch} style={{ marginBottom: 0 }}>
|
||||||
|
<input type="checkbox" checked={settings.r1_enabled_default} onChange={(e) => setSettings({ ...settings, r1_enabled_default: e.target.checked })} />
|
||||||
|
<span className={styles.slider}></span>
|
||||||
|
</label>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R1 — 登录地区不对(警告)</span>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>单账号从非预期城市登录时告警</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||||
|
<label className={styles.switch} style={{ marginBottom: 0 }}>
|
||||||
|
<input type="checkbox" checked={settings.r2_enabled_default} onChange={(e) => setSettings({ ...settings, r2_enabled_default: e.target.checked })} />
|
||||||
|
<span className={styles.slider}></span>
|
||||||
|
</label>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R2 — 不可能的旅行(严重,自动封禁用户)</span>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>单账号短时间内从两个不同城市登录,自动封禁该用户</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup} style={{ marginLeft: 56 }}>
|
||||||
|
<label>时间窗口 (秒)</label>
|
||||||
|
<input type="number" value={settings.r2_window_seconds} onChange={(e) => setSettings({ ...settings, r2_window_seconds: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||||
|
<label className={styles.switch} style={{ marginBottom: 0 }}>
|
||||||
|
<input type="checkbox" checked={settings.r3_enabled_default} onChange={(e) => setSettings({ ...settings, r3_enabled_default: e.target.checked })} />
|
||||||
|
<span className={styles.slider}></span>
|
||||||
|
</label>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R3 — 登录太频繁(警告)</span>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>单账号短时间内登录次数过多时告警</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formRow} style={{ marginLeft: 56 }}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>时间窗口 (秒)</label>
|
||||||
|
<input type="number" value={settings.r3_window_seconds} onChange={(e) => setSettings({ ...settings, r3_window_seconds: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>最大登录次数</label>
|
||||||
|
<input type="number" value={settings.r3_max_count} onChange={(e) => setSettings({ ...settings, r3_max_count: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||||
|
<label className={styles.switch} style={{ marginBottom: 0 }}>
|
||||||
|
<input type="checkbox" checked={settings.r4_enabled_default} onChange={(e) => setSettings({ ...settings, r4_enabled_default: e.target.checked })} />
|
||||||
|
<span className={styles.slider}></span>
|
||||||
|
</label>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R4 — 团队遍地开花(严重,自动封禁团队)</span>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>整个团队短时间内出现大量异地登录,自动封禁整个团队</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formRow} style={{ marginLeft: 56 }}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>时间窗口 (秒)</label>
|
||||||
|
<input type="number" value={settings.r4_window_seconds} onChange={(e) => setSettings({ ...settings, r4_window_seconds: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>预期外城市数阈值</label>
|
||||||
|
<input type="number" value={settings.r4_city_count} onChange={(e) => setSettings({ ...settings, r4_city_count: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||||
|
<label className={styles.switch} style={{ marginBottom: 0 }}>
|
||||||
|
<input type="checkbox" checked={settings.r5_enabled_default} onChange={(e) => setSettings({ ...settings, r5_enabled_default: e.target.checked })} />
|
||||||
|
<span className={styles.slider}></span>
|
||||||
|
</label>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R5 — 海外IP太杂(警告)</span>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>整个团队短期内出现大量不同国家的登录时告警</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formRow} style={{ marginLeft: 56 }}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>统计天数</label>
|
||||||
|
<input type="number" value={settings.r5_days} onChange={(e) => setSettings({ ...settings, r5_days: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>海外国家数阈值</label>
|
||||||
|
<input type="number" value={settings.r5_country_count} onChange={(e) => setSettings({ ...settings, r5_country_count: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 12 }}>告警配置</h3>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>飞书告警接收人手机号(多个用逗号分隔)</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.feishu_alert_mobiles}
|
||||||
|
onChange={(e) => setSettings({ ...settings, feishu_alert_mobiles: e.target.value })}
|
||||||
|
placeholder="13800138000,13900139000"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.saveBtn}
|
||||||
|
onClick={handleTestFeishu}
|
||||||
|
disabled={testingFeishu}
|
||||||
|
style={{ whiteSpace: 'nowrap', padding: '10px 16px' }}
|
||||||
|
>
|
||||||
|
{testingFeishu ? '发送中...' : '测试'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>短信告警手机号(Coming soon)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.sms_alert_mobiles}
|
||||||
|
onChange={(e) => setSettings({ ...settings, sms_alert_mobiles: e.target.value })}
|
||||||
|
placeholder="暂未开放"
|
||||||
|
disabled
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>告警冷却时间 (秒)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.alert_cooldown_seconds}
|
||||||
|
onChange={(e) => setSettings({ ...settings, alert_cooldown_seconds: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<p className={styles.cardHint}>同一团队 + 同一规则在此时间内不重复告警</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
|
||||||
|
{saving ? '保存中...' : '保存异常检测设置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -215,6 +215,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
margin-top: 20px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { adminApi } from '../lib/api';
|
|||||||
import type { Team, TeamDetail } from '../types';
|
import type { Team, TeamDetail } from '../types';
|
||||||
import { showToast } from '../components/Toast';
|
import { showToast } from '../components/Toast';
|
||||||
import { ConfirmModal } from '../components/ConfirmModal';
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
|
import { Select } from '../components/Select';
|
||||||
import styles from './TeamsPage.module.css';
|
import styles from './TeamsPage.module.css';
|
||||||
|
|
||||||
function fmtSec(s: number): string {
|
function fmtSec(s: number): string {
|
||||||
@ -18,6 +19,7 @@ export function TeamsPage() {
|
|||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [newMonthlyLimit, setNewMonthlyLimit] = useState('36000');
|
const [newMonthlyLimit, setNewMonthlyLimit] = useState('36000');
|
||||||
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('600');
|
const [newDailyMemberLimit, setNewDailyMemberLimit] = useState('600');
|
||||||
|
const [newExpectedRegions, setNewExpectedRegions] = useState('');
|
||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
|
|
||||||
// Top-up modal
|
// Top-up modal
|
||||||
@ -67,19 +69,30 @@ export function TeamsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-learn state
|
||||||
|
const [learnLoading, setLearnLoading] = useState(false);
|
||||||
|
const [learnedCities, setLearnedCities] = useState<string[]>([]);
|
||||||
|
const [learnOpen, setLearnOpen] = useState(false);
|
||||||
|
const [editingRegions, setEditingRegions] = useState(false);
|
||||||
|
const [editRegionsValue, setEditRegionsValue] = useState('');
|
||||||
|
const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false);
|
||||||
|
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
const resetCreateForm = () => {
|
const resetCreateForm = () => {
|
||||||
setNewName(''); setNewMonthlyLimit('36000'); setNewDailyMemberLimit('600');
|
setNewName(''); setNewMonthlyLimit('36000'); setNewDailyMemberLimit('600');
|
||||||
setCreateError('');
|
setNewExpectedRegions(''); setCreateError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateTeam = async () => {
|
const handleCreateTeam = async () => {
|
||||||
setCreateError('');
|
setCreateError('');
|
||||||
if (!newName.trim()) { setCreateError('请输入团队名称'); return; }
|
if (!newName.trim()) { setCreateError('请输入团队名称'); return; }
|
||||||
|
if (!newExpectedRegions.trim()) { setCreateError('请输入预期登录城市'); return; }
|
||||||
try {
|
try {
|
||||||
await adminApi.createTeam({
|
await adminApi.createTeam({
|
||||||
name: newName.trim(),
|
name: newName.trim(),
|
||||||
monthly_seconds_limit: Number(newMonthlyLimit),
|
monthly_seconds_limit: Number(newMonthlyLimit),
|
||||||
daily_member_limit_default: Number(newDailyMemberLimit),
|
daily_member_limit_default: Number(newDailyMemberLimit),
|
||||||
|
expected_regions: newExpectedRegions.trim(),
|
||||||
});
|
});
|
||||||
showToast('团队创建成功');
|
showToast('团队创建成功');
|
||||||
setCreateOpen(false);
|
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) => {
|
const openDrawer = async (teamId: number) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await adminApi.getTeamDetail(teamId);
|
const { data } = await adminApi.getTeamDetail(teamId);
|
||||||
@ -220,6 +261,11 @@ export function TeamsPage() {
|
|||||||
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
|
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
|
||||||
{t.is_active ? '启用' : '禁用'}
|
{t.is_active ? '启用' : '禁用'}
|
||||||
</span>
|
</span>
|
||||||
|
{!t.is_active && t.disabled_by && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginLeft: 4 }}>
|
||||||
|
({t.disabled_by === 'system' ? '系统' : '管理员'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
@ -259,6 +305,10 @@ export function TeamsPage() {
|
|||||||
<input type="number" value={newDailyMemberLimit} onChange={(e) => setNewDailyMemberLimit(e.target.value)} />
|
<input type="number" value={newDailyMemberLimit} onChange={(e) => setNewDailyMemberLimit(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>预期登录城市(必填,逗号分隔)</label>
|
||||||
|
<input type="text" value={newExpectedRegions} onChange={(e) => setNewExpectedRegions(e.target.value)} placeholder="广州市,深圳市,北京市" />
|
||||||
|
</div>
|
||||||
{createError && <div className={styles.formError}>{createError}</div>}
|
{createError && <div className={styles.formError}>{createError}</div>}
|
||||||
<div className={styles.modalActions}>
|
<div className={styles.modalActions}>
|
||||||
<button className={styles.cancelBtn} onClick={() => setCreateOpen(false)}>取消</button>
|
<button className={styles.cancelBtn} onClick={() => setCreateOpen(false)}>取消</button>
|
||||||
@ -336,6 +386,11 @@ export function TeamsPage() {
|
|||||||
<span className={`${styles.statusBadge} ${detailTeam.is_active ? styles.active : styles.disabled}`}>
|
<span className={`${styles.statusBadge} ${detailTeam.is_active ? styles.active : styles.disabled}`}>
|
||||||
{detailTeam.is_active ? '启用' : '禁用'}
|
{detailTeam.is_active ? '启用' : '禁用'}
|
||||||
</span>
|
</span>
|
||||||
|
{!detailTeam.is_active && detailTeam.disabled_by && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)', fontWeight: 400, marginLeft: 8 }}>
|
||||||
|
({detailTeam.disabled_by === 'system' ? '系统自动禁用' : '管理员手动禁用'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<button className={styles.detailClose} onClick={() => setDrawerOpen(false)}>
|
<button className={styles.detailClose} onClick={() => setDrawerOpen(false)}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@ -391,6 +446,179 @@ export function TeamsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16, marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||||
|
<span className={styles.detailLabel} style={{ marginBottom: 0 }}>预期登录城市</span>
|
||||||
|
{!editingRegions && (
|
||||||
|
<button
|
||||||
|
className={styles.topupBtn}
|
||||||
|
onClick={() => { setEditingRegions(true); setEditRegionsValue(detailTeam.expected_regions || ''); }}
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={styles.topupBtn}
|
||||||
|
onClick={handleAutoLearn}
|
||||||
|
disabled={learnLoading}
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||||
|
>
|
||||||
|
{learnLoading ? '学习中...' : '自动学习'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{editingRegions ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editRegionsValue}
|
||||||
|
onChange={(e) => setEditRegionsValue(e.target.value)}
|
||||||
|
placeholder="多个城市用逗号分隔,如:广州,深圳,北京"
|
||||||
|
style={{ flex: 1, padding: '6px 10px', borderRadius: 6, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.topupBtn}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await adminApi.updateTeam(detailTeam.id, { expected_regions: editRegionsValue.trim() });
|
||||||
|
setDetailTeam({ ...detailTeam, expected_regions: editRegionsValue.trim() });
|
||||||
|
setEditingRegions(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.response?.data?.error || '保存失败');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.topupBtn}
|
||||||
|
onClick={() => setEditingRegions(false)}
|
||||||
|
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>
|
||||||
|
{detailTeam.expected_regions || '(未设置)'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 团队级异常检测阈值 */}
|
||||||
|
<div style={{ marginTop: 16, padding: '12px 16px', background: 'var(--color-bg-page)', borderRadius: 8, border: '1px solid var(--color-border-card)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>异常检测阈值(留空 = 用全局默认)</span>
|
||||||
|
{!editingAnomalyConfig ? (
|
||||||
|
<button className={styles.topupBtn} onClick={() => { setEditingAnomalyConfig(true); setAnomalyConfigDraft(detailTeam.anomaly_config || {}); }} style={{ fontSize: 12, padding: '4px 10px' }}>编辑</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button className={styles.topupBtn} onClick={async () => {
|
||||||
|
try {
|
||||||
|
await adminApi.updateTeam(detailTeam.id, { anomaly_config: anomalyConfigDraft });
|
||||||
|
setDetailTeam({ ...detailTeam, anomaly_config: anomalyConfigDraft as any });
|
||||||
|
setEditingAnomalyConfig(false);
|
||||||
|
showToast('已保存');
|
||||||
|
} catch { showToast('保存失败'); }
|
||||||
|
}} style={{ fontSize: 12, padding: '4px 10px' }}>保存</button>
|
||||||
|
<button className={styles.topupBtn} onClick={() => setEditingAnomalyConfig(false)} style={{ fontSize: 12, padding: '4px 10px' }}>取消</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingAnomalyConfig ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, fontSize: 12 }}>
|
||||||
|
{[
|
||||||
|
{ title: 'R1 登录地区异常', desc: '单账号从非预期城市登录时告警', items: [
|
||||||
|
{ key: 'r1_enabled', label: '启用', type: 'bool' },
|
||||||
|
]},
|
||||||
|
{ title: 'R2 不可能的旅行', desc: '单账号短时间内从两个不同城市登录,自动封禁该用户', items: [
|
||||||
|
{ key: 'r2_enabled', label: '启用', type: 'bool' },
|
||||||
|
{ key: 'r2_window_seconds', label: '时间窗口(秒)', type: 'num' },
|
||||||
|
]},
|
||||||
|
{ title: 'R3 登录太频繁', desc: '单账号短时间内登录次数过多时告警', items: [
|
||||||
|
{ key: 'r3_enabled', label: '启用', type: 'bool' },
|
||||||
|
{ key: 'r3_window_seconds', label: '时间窗口(秒)', type: 'num' },
|
||||||
|
{ key: 'r3_max_count', label: '最大次数', type: 'num' },
|
||||||
|
]},
|
||||||
|
{ title: 'R4 团队遍地开花', desc: '整个团队短时间内出现大量异地登录,自动封禁整个团队', items: [
|
||||||
|
{ key: 'r4_enabled', label: '启用', type: 'bool' },
|
||||||
|
{ key: 'r4_window_seconds', label: '时间窗口(秒)', type: 'num' },
|
||||||
|
{ key: 'r4_city_count', label: '城市数阈值', type: 'num' },
|
||||||
|
]},
|
||||||
|
{ title: 'R5 海外IP太杂', desc: '整个团队短期内出现大量不同国家的登录时告警', items: [
|
||||||
|
{ key: 'r5_enabled', label: '启用', type: 'bool' },
|
||||||
|
{ key: 'r5_days', label: '统计天数', type: 'num' },
|
||||||
|
{ key: 'r5_country_count', label: '国家数阈值', type: 'num' },
|
||||||
|
]},
|
||||||
|
].map((group, gi) => (
|
||||||
|
<div key={group.title}>
|
||||||
|
{gi > 0 && <div style={{ height: 1, background: 'var(--color-border-card)', margin: '8px 0' }} />}
|
||||||
|
<div style={{ marginBottom: 6 }}>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)', fontWeight: 500, fontSize: 12 }}>{group.title}</span>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: 11, marginLeft: 8 }}>{group.desc}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 16px', paddingLeft: 8 }}>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<div key={item.key} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', minWidth: 80 }}>{item.label}</span>
|
||||||
|
<div style={{ width: 90 }}>
|
||||||
|
{item.type === 'bool' ? (
|
||||||
|
<div style={{ display: 'flex', borderRadius: 4, overflow: 'hidden', border: '1px solid var(--color-border-card)', width: '100%' }}>
|
||||||
|
{([['', '默认'], ['true', '开'], ['false', '关']] as const).map(([val, label]) => {
|
||||||
|
const cur = anomalyConfigDraft[item.key];
|
||||||
|
const selected = val === '' ? (cur === null || cur === undefined) : String(cur) === val;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => setAnomalyConfigDraft({ ...anomalyConfigDraft, [item.key]: val === '' ? null : val === 'true' })}
|
||||||
|
style={{ flex: 1, padding: '3px 0', fontSize: 11, border: 'none', cursor: 'pointer', background: selected ? 'var(--color-primary)' : 'var(--color-bg-card)', color: selected ? '#fff' : 'var(--color-text-secondary)', transition: 'all 0.15s' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={anomalyConfigDraft[item.key] ?? ''}
|
||||||
|
onChange={(e) => setAnomalyConfigDraft({ ...anomalyConfigDraft, [item.key]: e.target.value === '' ? null : Number(e.target.value) })}
|
||||||
|
placeholder="默认"
|
||||||
|
style={{ width: '100%', padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-card)', color: 'var(--color-text-primary)', fontSize: 12, boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||||
|
{(() => {
|
||||||
|
const c = detailTeam.anomaly_config;
|
||||||
|
if (!c) return '全部使用全局默认值';
|
||||||
|
const items: string[] = [];
|
||||||
|
if (c.r1_enabled !== null) items.push(`R1: ${c.r1_enabled ? '开' : '关'}`);
|
||||||
|
if (c.r2_enabled !== null) items.push(`R2: ${c.r2_enabled ? '开' : '关'}`);
|
||||||
|
if (c.r2_window_seconds !== null) items.push(`R2窗口: ${c.r2_window_seconds}秒`);
|
||||||
|
if (c.r3_enabled !== null) items.push(`R3: ${c.r3_enabled ? '开' : '关'}`);
|
||||||
|
if (c.r3_window_seconds !== null) items.push(`R3窗口: ${c.r3_window_seconds}秒`);
|
||||||
|
if (c.r3_max_count !== null) items.push(`R3次数: ${c.r3_max_count}`);
|
||||||
|
if (c.r4_enabled !== null) items.push(`R4: ${c.r4_enabled ? '开' : '关'}`);
|
||||||
|
if (c.r4_window_seconds !== null) items.push(`R4窗口: ${c.r4_window_seconds}秒`);
|
||||||
|
if (c.r4_city_count !== null) items.push(`R4城市: ${c.r4_city_count}`);
|
||||||
|
if (c.r5_enabled !== null) items.push(`R5: ${c.r5_enabled ? '开' : '关'}`);
|
||||||
|
if (c.r5_days !== null) items.push(`R5天数: ${c.r5_days}`);
|
||||||
|
if (c.r5_country_count !== null) items.push(`R5国家: ${c.r5_country_count}`);
|
||||||
|
return items.length ? items.join(' / ') : '全部使用全局默认值';
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 className={styles.membersTitle}>成员列表 ({detailTeam.members.length})</h4>
|
<h4 className={styles.membersTitle}>成员列表 ({detailTeam.members.length})</h4>
|
||||||
{detailTeam.members.length === 0 ? (
|
{detailTeam.members.length === 0 ? (
|
||||||
<div className={styles.empty}>暂无成员</div>
|
<div className={styles.empty}>暂无成员</div>
|
||||||
@ -422,6 +650,11 @@ export function TeamsPage() {
|
|||||||
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
|
<span className={`${styles.statusBadge} ${m.is_active ? styles.active : styles.disabled}`}>
|
||||||
{m.is_active ? '启用' : '禁用'}
|
{m.is_active ? '启用' : '禁用'}
|
||||||
</span>
|
</span>
|
||||||
|
{!m.is_active && m.disabled_by && (
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--color-text-secondary)', marginLeft: 2 }}>
|
||||||
|
({m.disabled_by === 'system' ? '系统' : '管理员'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{fmtSec(m.daily_seconds_limit)}</td>
|
<td>{fmtSec(m.daily_seconds_limit)}</td>
|
||||||
<td>{fmtSec(m.seconds_today)}</td>
|
<td>{fmtSec(m.seconds_today)}</td>
|
||||||
@ -437,6 +670,39 @@ export function TeamsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auto-Learn Result Modal */}
|
||||||
|
{learnOpen && detailTeam && (
|
||||||
|
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setLearnOpen(false); }}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<h3 className={styles.modalTitle}>自动学习结果 — {detailTeam.name}</h3>
|
||||||
|
{learnedCities.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>近 30 天无足够登录数据</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', fontSize: 13, marginBottom: 12 }}>
|
||||||
|
以下城市在近 30 天内登录 3 次以上,确认后将覆盖当前预期地区设置:
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 16 }}>
|
||||||
|
{learnedCities.map((city) => (
|
||||||
|
<span key={city} style={{
|
||||||
|
padding: '4px 10px', borderRadius: 12, fontSize: 12,
|
||||||
|
background: 'var(--color-bg-page)', border: '1px solid var(--color-border-card)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}}>{city}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button className={styles.cancelBtn} onClick={() => setLearnOpen(false)}>取消</button>
|
||||||
|
{learnedCities.length > 0 && (
|
||||||
|
<button className={styles.saveBtn} onClick={handleApplyLearnedRegions}>应用</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Edit Pool Modal */}
|
{/* Edit Pool Modal */}
|
||||||
{editPoolOpen && detailTeam && (
|
{editPoolOpen && detailTeam && (
|
||||||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditPoolOpen(false); }}>
|
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditPoolOpen(false); }}>
|
||||||
|
|||||||
@ -236,6 +236,11 @@ export function UsersPage() {
|
|||||||
<span className={`${styles.statusBadge} ${u.is_active ? styles.active : styles.disabled}`}>
|
<span className={`${styles.statusBadge} ${u.is_active ? styles.active : styles.disabled}`}>
|
||||||
{u.is_active ? '启用' : '禁用'}
|
{u.is_active ? '启用' : '禁用'}
|
||||||
</span>
|
</span>
|
||||||
|
{!u.is_active && u.disabled_by && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginLeft: 4 }}>
|
||||||
|
({u.disabled_by === 'system' ? '系统' : '管理员'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{u.daily_seconds_limit === -1 ? '不限' : u.daily_seconds_limit.toLocaleString() + 's'}</td>
|
<td>{u.daily_seconds_limit === -1 ? '不限' : u.daily_seconds_limit.toLocaleString() + 's'}</td>
|
||||||
<td>{u.monthly_seconds_limit === -1 ? '不限' : u.monthly_seconds_limit.toLocaleString() + 's'}</td>
|
<td>{u.monthly_seconds_limit === -1 ? '不限' : u.monthly_seconds_limit.toLocaleString() + 's'}</td>
|
||||||
@ -399,6 +404,11 @@ export function UsersPage() {
|
|||||||
<span className={`${styles.statusBadge} ${detailUser.is_active ? styles.active : styles.disabled}`}>
|
<span className={`${styles.statusBadge} ${detailUser.is_active ? styles.active : styles.disabled}`}>
|
||||||
{detailUser.is_active ? '启用' : '禁用'}
|
{detailUser.is_active ? '启用' : '禁用'}
|
||||||
</span>
|
</span>
|
||||||
|
{!detailUser.is_active && detailUser.disabled_by && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginLeft: 6 }}>
|
||||||
|
{detailUser.disabled_by === 'system' ? '系统自动禁用' : '管理员手动禁用'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailItem}>
|
<div className={styles.detailItem}>
|
||||||
<span className={styles.detailLabel}>注册时间</span>
|
<span className={styles.detailLabel}>注册时间</span>
|
||||||
|
|||||||
@ -120,6 +120,7 @@ export interface AdminUser {
|
|||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
disabled_by: string;
|
||||||
is_staff: boolean;
|
is_staff: boolean;
|
||||||
is_team_admin: boolean;
|
is_team_admin: boolean;
|
||||||
team_id: number | null;
|
team_id: number | null;
|
||||||
@ -158,6 +159,23 @@ export interface SystemSettings {
|
|||||||
announcement_enabled: boolean;
|
announcement_enabled: boolean;
|
||||||
max_desktop_sessions: number;
|
max_desktop_sessions: number;
|
||||||
max_mobile_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 {
|
export interface ProfileOverview {
|
||||||
@ -196,10 +214,28 @@ export interface Team {
|
|||||||
daily_member_limit_default: number;
|
daily_member_limit_default: number;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
expected_regions: string;
|
||||||
|
disabled_by: string;
|
||||||
created_at: 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 {
|
export interface TeamDetail extends Team {
|
||||||
|
anomaly_config: TeamAnomalyConfig | null;
|
||||||
members: TeamMember[];
|
members: TeamMember[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,6 +245,7 @@ export interface TeamMember {
|
|||||||
email: string;
|
email: string;
|
||||||
is_team_admin: boolean;
|
is_team_admin: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
disabled_by: string;
|
||||||
daily_seconds_limit: number;
|
daily_seconds_limit: number;
|
||||||
monthly_seconds_limit: number;
|
monthly_seconds_limit: number;
|
||||||
seconds_today: number;
|
seconds_today: number;
|
||||||
@ -216,6 +253,25 @@ export interface TeamMember {
|
|||||||
date_joined: string;
|
date_joined: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginAnomaly {
|
||||||
|
id: number;
|
||||||
|
team_id: number;
|
||||||
|
team_name: string;
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
level: 'warning' | 'critical';
|
||||||
|
rule: string;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
alerted: boolean;
|
||||||
|
auto_disabled: boolean;
|
||||||
|
disabled_target: string;
|
||||||
|
ip_address: string;
|
||||||
|
geo_country: string;
|
||||||
|
geo_province: string;
|
||||||
|
geo_city: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeamStats {
|
export interface TeamStats {
|
||||||
daily_trend: { date: string; seconds: number }[];
|
daily_trend: { date: string; seconds: number }[];
|
||||||
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
|
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user